LargeDocReadTest.java

package tools.jackson.core.unittest.constraints;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.util.Arrays;

import org.junit.jupiter.api.Test;

import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.ObjectReadContext;
import tools.jackson.core.StreamReadConstraints;
import tools.jackson.core.async.ByteArrayFeeder;
import tools.jackson.core.async.ByteBufferFeeder;
import tools.jackson.core.exc.StreamConstraintsException;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.unittest.async.AsyncTestBase;
import tools.jackson.core.unittest.testutil.AsyncReaderWrapper;
import tools.jackson.core.unittest.testutil.MockDataInput;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.fail;

// [core#1047]: Add max-name-length constraints
class LargeDocReadTest extends AsyncTestBase
{
    private final JsonFactory JSON_F_DEFAULT = newStreamFactory();

    private final JsonFactory JSON_F_DOC_10K = JsonFactory.builder()
            .streamReadConstraints(StreamReadConstraints.builder().maxDocumentLength(10_000L).build())
            .build();

    private final JsonFactory JSON_F_MAX_TOKENS_1K = JsonFactory.builder()
        .streamReadConstraints(StreamReadConstraints.builder().maxTokenCount(1_000L).build())
        .build();

    // Test name that is below default max name
    @Test
    void largeNameBytes() throws Exception {
        final String doc = generateJSON(StreamReadConstraints.defaults().getMaxNameLength() - 100);
        try (JsonParser p = createParserUsingStream(JSON_F_DEFAULT, doc, "UTF-8")) {
            consumeTokens(p);
        }
    }

    @Test
    void largeNameChars() throws Exception {
        final String doc = generateJSON(StreamReadConstraints.defaults().getMaxNameLength() - 100);
        try (JsonParser p = createParserUsingReader(JSON_F_DEFAULT, doc)) {
            consumeTokens(p);
        }
    }

    @Test
    void largeNameWithSmallLimitBytes() throws Exception
    {
        final String doc = generateJSON(12_000);
        try (JsonParser p = createParserUsingStream(JSON_F_DOC_10K, doc, "UTF-8")) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
        // [core#1548] validate for fixed buffer too
        try (JsonParser p = JSON_F_DOC_10K.createParser(ObjectReadContext.empty(),
                utf8Bytes(doc))) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
    }

    @Test
    void largeNameWithSmallLimitChars() throws Exception
    {
        final String doc = generateJSON(12_000);
        try (JsonParser p = createParserUsingReader(JSON_F_DOC_10K, doc)) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
        // [core#1548] validate for fixed buffer too
        try (JsonParser p = JSON_F_DOC_10K.createParser(ObjectReadContext.empty(),
                doc.toCharArray())) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
    }

    @Test
    void largeNameWithSmallLimitAsync() throws Exception
    {
        final byte[] doc = utf8Bytes(generateJSON(12_000));

        // first with byte[] backend
        try (AsyncReaderWrapper p = asyncForBytes(JSON_F_DOC_10K, 1000, doc, 1)) {
            consumeAsync(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }

        // then with byte buffer
        try (AsyncReaderWrapper p = asyncForByteBuffer(JSON_F_DOC_10K, 1000, doc, 1)) {
            consumeAsync(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
    }

    // [core#1642] maxDocumentLength must also be enforced when the caller feeds
    // the whole document via a single feedInput() call (e.g. pre-buffered input),
    // not just when input arrives split across multiple feedInput() calls.
    @Test
    void largeNameWithSmallLimitAsyncSingleFeed() throws Exception
    {
        final byte[] doc = utf8Bytes(generateJSON(12_000));

        // first with byte[] backend: bytesPerRead >= doc.length so the whole
        // document goes through in exactly one feedInput() call
        try (AsyncReaderWrapper p = asyncForBytes(JSON_F_DOC_10K, doc.length, doc, 1)) {
            consumeAsync(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }

        // then with byte buffer backend, same single-call condition
        try (AsyncReaderWrapper p = asyncForByteBuffer(JSON_F_DOC_10K, doc.length, doc, 1)) {
            consumeAsync(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
    }

    // [core#1642] Boundary check: a single feedInput() call carrying EXACTLY
    // maxDocumentLength bytes must still parse successfully -- validateDocumentLength()
    // rejects only len > maxDocumentLength, so the limit itself is inclusive.
    // This pins down "bytes fed, not consumed" semantics and guards against a
    // future off-by-one in the single-feed fix.
    @Test
    void largeNameWithSmallLimitAsyncSingleFeedAtBoundary() throws Exception
    {
        final long limit = JSON_F_DOC_10K.streamReadConstraints().getMaxDocumentLength();
        final byte[] doc = utf8Bytes(generateExactLengthJSON((int) limit));
        assertEquals(limit, doc.length);

        // first with byte[] backend: bytesPerRead >= doc.length so the whole
        // document goes through in exactly one feedInput() call
        try (AsyncReaderWrapper p = asyncForBytes(JSON_F_DOC_10K, doc.length, doc, 1)) {
            consumeAsync(p);
        }

        // then with byte buffer backend, same single-call condition
        try (AsyncReaderWrapper p = asyncForByteBuffer(JSON_F_DOC_10K, doc.length, doc, 1)) {
            consumeAsync(p);
        }
    }

    // [core#1642] Same boundary, but reached across MANY feedInput() calls: bytes
    // fed must accumulate to exactly maxDocumentLength and still parse, verifying
    // the single-feed fix did not start double-counting incrementally fed buffers.
    @Test
    void largeNameWithSmallLimitAsyncMultiFeedAtBoundary() throws Exception
    {
        final long limit = JSON_F_DOC_10K.streamReadConstraints().getMaxDocumentLength();
        final byte[] doc = utf8Bytes(generateExactLengthJSON((int) limit));
        assertEquals(limit, doc.length);

        // 1000 bytes per call, so exactly 10 feedInput() calls totalling the limit
        try (AsyncReaderWrapper p = asyncForBytes(JSON_F_DOC_10K, 1000, doc, 1)) {
            consumeAsync(p);
        }
        try (AsyncReaderWrapper p = asyncForByteBuffer(JSON_F_DOC_10K, 1000, doc, 1)) {
            consumeAsync(p);
        }
    }

    // [core#1642] A rejected feedInput() must not corrupt the running byte count:
    // validation happens BEFORE any state is updated, so a caller that catches the
    // StreamConstraintsException and keeps feeding still gets an accurate total
    // (the rejected call's predecessor must not be counted twice).
    @Test
    void docLengthCountIntactAfterRejectedFeedBytes() throws Exception
    {
        try (JsonParser p = JSON_F_DOC_10K.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            final ByteArrayFeeder feeder = (ByteArrayFeeder) p.nonBlockingInputFeeder();

            // 5000 fed, well under the 10000 limit
            feeder.feedInput(whitespace(5000), 0, 5000);
            assertToken(JsonToken.NOT_AVAILABLE, p.nextToken());

            // would reach 11000: rejected, and must leave the count at 5000
            try {
                feeder.feedInput(whitespace(6000), 0, 6000);
                fail("expected StreamConstraintsException");
            } catch (StreamConstraintsException e) {
                verifyMaxDocLen(JSON_F_DOC_10K, e);
            }

            // 5000 more == 10000 total: at the limit, so must still be accepted
            feeder.feedInput(whitespace(5000), 0, 5000);
            assertToken(JsonToken.NOT_AVAILABLE, p.nextToken());

            // and one byte past it must report the true total, not an inflated one
            try {
                feeder.feedInput(whitespace(1), 0, 1);
                fail("expected StreamConstraintsException");
            } catch (StreamConstraintsException e) {
                verifyException(e, "Document length (10001)");
            }
        }
    }

    // [core#1642] as above, for the ByteBuffer-backed parser
    @Test
    void docLengthCountIntactAfterRejectedFeedByteBuffer() throws Exception
    {
        try (JsonParser p = JSON_F_DOC_10K.createNonBlockingByteBufferParser(ObjectReadContext.empty())) {
            final ByteBufferFeeder feeder = (ByteBufferFeeder) p.nonBlockingInputFeeder();

            feeder.feedInput(ByteBuffer.wrap(whitespace(5000)));
            assertToken(JsonToken.NOT_AVAILABLE, p.nextToken());

            try {
                feeder.feedInput(ByteBuffer.wrap(whitespace(6000)));
                fail("expected StreamConstraintsException");
            } catch (StreamConstraintsException e) {
                verifyMaxDocLen(JSON_F_DOC_10K, e);
            }

            feeder.feedInput(ByteBuffer.wrap(whitespace(5000)));
            assertToken(JsonToken.NOT_AVAILABLE, p.nextToken());

            try {
                feeder.feedInput(ByteBuffer.wrap(whitespace(1)));
                fail("expected StreamConstraintsException");
            } catch (StreamConstraintsException e) {
                verifyException(e, "Document length (10001)");
            }
        }
    }

    // [core#1575] DataInput with maxDocumentLength should enforce the limit
    @Test
    void dataInputWithDocLengthLimitEnforced() throws Exception
    {
        final String doc = generateJSON(12_000);
        try (JsonParser p = JSON_F_DOC_10K.createParser(ObjectReadContext.empty(),
                new MockDataInput(doc))) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyMaxDocLen(JSON_F_DOC_10K, e);
        }
    }

    // [core#1575] DataInput without maxDocumentLength should still work
    @Test
    void dataInputWithoutDocLengthLimitWorks() throws Exception
    {
        final String doc = generateJSON(100);
        try (JsonParser p = JSON_F_DEFAULT.createParser(ObjectReadContext.empty(),
                new MockDataInput(doc))) {
            consumeTokens(p);
        }
    }

    @Test
    void tokenLimitBytes() throws Exception {
        final String doc = generateJSON(StreamReadConstraints.defaults().getMaxNameLength() - 100);
        try (JsonParser p = createParserUsingStream(JSON_F_MAX_TOKENS_1K, doc, "UTF-8")) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            assertEquals("Token count (1001) exceeds the maximum allowed (1000, from `StreamReadConstraints.getMaxTokenCount()`)",
                    e.getMessage());
        }
    }

    private void consumeTokens(JsonParser p) throws IOException {
        while (p.nextToken() != null) {
            ;
        }
    }

    private void consumeAsync(AsyncReaderWrapper w) throws IOException {
        while (w.nextToken() != null) {
            ;
        }
    }

    // Builds a valid JSON array whose UTF-8 byte length is exactly {@code exactLen},
    // using trailing whitespace padding before the closing bracket (all-ASCII content,
    // so char length == byte length).
    private String generateExactLengthJSON(final int exactLen) {
        final StringBuilder sb = new StringBuilder();
        sb.append('[');
        while (sb.length() < exactLen - 10) {
            sb.append("1,");
        }
        sb.append('1');
        while (sb.length() < exactLen - 1) {
            sb.append(' ');
        }
        sb.append(']');
        return sb.toString();
    }

    // Content that is valid-but-tokenless, so buffers can be fed and fully consumed
    // without producing tokens: lets tests exercise feedInput() accounting directly.
    private byte[] whitespace(final int len) {
        final byte[] b = new byte[len];
        Arrays.fill(b, (byte) ' ');
        return b;
    }

    private String generateJSON(final int docLen) {
        final StringBuilder sb = new StringBuilder();
        sb.append("[");

        int i = 0;
        while (docLen > sb.length()) {
            sb.append(++i).append(",\n");
        }
        sb.append("true ] ");
        return sb.toString();
    }

    private void verifyMaxDocLen(JsonFactory f, StreamConstraintsException e) {
        verifyException(e, "Document length");
        verifyException(e, "exceeds the maximum allowed ("
                +f.streamReadConstraints().getMaxDocumentLength()
                );
    }
}