AsyncLargeNumberReadTest.java

package tools.jackson.core.unittest.constraints;

import org.junit.jupiter.api.Test;

import tools.jackson.core.*;
import tools.jackson.core.async.ByteArrayFeeder;
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 static org.junit.jupiter.api.Assertions.*;

/**
 * Number Length Constraint Bypass in Non-Blocking (Async) JSON Parsers.
 *<p>
 * Covers two scenarios: a complete oversized number fed in one shot (with
 * {@code endOfInput()}), and digit-only input streamed across many chunks with no
 * terminator and no {@code endOfInput()} ��� verifying {@code maxNumberLength} is enforced
 * promptly while streaming, not only at value completion.
 */
class AsyncLargeNumberReadTest
    extends AsyncTestBase
{
    private static final int TEST_NUMBER_LENGTH = StreamReadConstraints.DEFAULT_MAX_NUM_LEN * 2;

    private final JsonFactory JSON_F = newStreamFactory();

    // // // Streaming-chunk constraints (lower limit so tests stay fast)

    private static final int MAX_NUM_LEN = 1000;
    // Chunk size kept modest so the test runs quickly under CI but still exceeds
    // maxNumberLength after the very first chunk.
    private static final int CHUNK_SIZE = 4 * 1024;
    // Hard cap on chunks fed: well past maxNumberLength but bounded so a regressed
    // build cannot OOM the CI machine.
    private static final int MAX_CHUNKS = 32;

    private final JsonFactory STRICT_F = JsonFactory.builder()
            .streamReadConstraints(StreamReadConstraints.builder()
                    .maxNumberLength(MAX_NUM_LEN)
                    .build())
            .build();

    // // // Complete-payload tests: oversized number fed in one shot with endOfInput()

    @Test
    void asyncParserFailsTooLongInt() throws Exception {
        byte[] payload = buildPayloadWithLongInteger(TEST_NUMBER_LENGTH);

        try (JsonParser p = JSON_F.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            ByteArrayFeeder byteArrayFeeder = (ByteArrayFeeder) p;
            byteArrayFeeder.feedInput(payload, 0, payload.length);
            byteArrayFeeder.endOfInput();

            _asyncParserFailsTooLongNumber(p, JsonToken.VALUE_NUMBER_INT);
        }
    }

    @Test
    void asyncParserFailsTooLongDecimal() throws Exception {
        byte[] payload = buildPayloadWithLongDecimal(TEST_NUMBER_LENGTH);

        try (JsonParser p = JSON_F.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            ByteArrayFeeder byteArrayFeeder = (ByteArrayFeeder) p;
            byteArrayFeeder.feedInput(payload, 0, payload.length);
            byteArrayFeeder.endOfInput();

            _asyncParserFailsTooLongNumber(p, JsonToken.VALUE_NUMBER_FLOAT);
        }
    }

    @Test
    void asyncParserFailsTooLongDecimalWithExponent() throws Exception {
        byte[] payload = buildPayloadWithLongExponent(TEST_NUMBER_LENGTH);

        try (JsonParser p = JSON_F.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            ByteArrayFeeder byteArrayFeeder = (ByteArrayFeeder) p;
            byteArrayFeeder.feedInput(payload, 0, payload.length);
            byteArrayFeeder.endOfInput();

            _asyncParserFailsTooLongNumber(p, JsonToken.VALUE_NUMBER_FLOAT);
        }
    }

    // // // Streaming-chunk tests: enforcement must fire mid-stream, before completion

    /**
     * Streams the integer portion of a number across many chunks. Asserts that
     * {@link StreamConstraintsException} is raised promptly once the accumulated
     * digit length exceeds {@code maxNumberLength}, rather than only at value
     * completion.
     */
    @Test
    void integerPath_streamingChunks_rejectsBeyondMaxNumberLength() throws Exception {
        // CHUNK_SIZE > MAX_NUM_LEN, so failure must occur on the first chunk past
        // the limit; chunksFed == 1 in practice.
        int chunksFed = streamDigitsUntilRejected("{\"v\":", CHUNK_SIZE);
        assertTrue(chunksFed <= 2,
                "StreamConstraintsException raised too late: after " + chunksFed
                        + " chunks of " + CHUNK_SIZE
                        + " digits (maxNumberLength=" + MAX_NUM_LEN + ")");
    }

    /**
     * Companion to the integer-path test: pins the fraction-path streaming behavior
     * so a future refactor cannot regress it.
     */
    @Test
    void fractionPath_streamingChunks_rejectsBeyondMaxNumberLength() throws Exception {
        int chunksFed = streamDigitsUntilRejected("{\"v\":0.", CHUNK_SIZE);
        assertTrue(chunksFed <= 2,
                "StreamConstraintsException raised too late: after " + chunksFed
                        + " chunks of " + CHUNK_SIZE
                        + " digits (maxNumberLength=" + MAX_NUM_LEN + ")");
    }

    /**
     * Pins the headline cross-chunk scenario directly: with a chunk size well below
     * {@code maxNumberLength}, the limit is only crossed after several streaming
     * suspensions, so this exercises accumulation inside {@code _finishNumberIntegralPart}
     * (not just a single oversized first chunk). Asserts the exception fires at the
     * expected boundary ��� after more than one chunk, but as soon as the limit is passed.
     */
    @Test
    void integerPath_smallChunksAccumulate_rejectAtBoundary() throws Exception {
        final int smallChunk = 100; // < MAX_NUM_LEN, so several chunks are needed
        // Limit is crossed once accumulated digits exceed MAX_NUM_LEN; with 100-digit
        // chunks that is the 11th chunk (1000 ok at chunk 10, 1100 > 1000 at chunk 11).
        final int expectedFailChunk = (MAX_NUM_LEN / smallChunk) + 1;

        int chunksFed = streamDigitsUntilRejected("{\"v\":", smallChunk);
        // Must accumulate across several chunks before firing...
        assertTrue(chunksFed > 1,
                "Exception fired on a single chunk; cross-chunk accumulation not exercised");
        // ...and fire as soon as the limit is passed, not arbitrarily later.
        assertTrue(chunksFed <= expectedFailChunk + 1,
                "StreamConstraintsException raised too late: after " + chunksFed
                        + " chunks of " + smallChunk + " (expected ~" + expectedFailChunk
                        + ", maxNumberLength=" + MAX_NUM_LEN + ")");
    }

    /**
     * Guards against the validator becoming over-eager: an integer whose length is
     * just below {@code maxNumberLength}, fed across many small chunks (each smaller
     * than the limit, so accumulation crosses several streaming-suspension points),
     * must still parse cleanly to the expected value.
     */
    @Test
    void integerPath_justUnderMaxNumberLength_parsesCleanly() throws Exception {
        // One short of the limit, so the validator must NOT fire.
        final int digitCount = MAX_NUM_LEN - 1;
        final String number = digitString(digitCount);

        // Feed the whole document in small chunks (well under maxNumberLength) so the
        // integer is accumulated across multiple _finishNumberIntegralPart resumes.
        try (AsyncReaderWrapper r = asyncForBytes(STRICT_F, 100,
                utf8Bytes("{\"v\":" + number + "}"), 0)) {
            assertToken(JsonToken.START_OBJECT, r.nextToken());
            assertToken(JsonToken.PROPERTY_NAME, r.nextToken());
            assertToken(JsonToken.VALUE_NUMBER_INT, r.nextToken());
            assertEquals(number, r.currentText(),
                    "Sub-limit integer should parse without StreamConstraintsException");
            assertToken(JsonToken.END_OBJECT, r.nextToken());
        }
    }

    /**
     * Negative-number variant of the cross-chunk rejection test: a leading {@code '-'}
     * routes through {@code _startNegativeNumber()} and the {@code negMod == -1} branch
     * of {@code _finishNumberIntegralPart}, so the sign must be excluded from the
     * validated digit length. With chunk size below {@code maxNumberLength}, the limit
     * is only crossed after accumulating across several streaming suspensions.
     */
    @Test
    void negativeIntegerPath_smallChunksAccumulate_rejectAtBoundary() throws Exception {
        final int smallChunk = 100; // < MAX_NUM_LEN, so several chunks are needed
        // Sign is excluded from the validated length, so the limit is crossed once the
        // accumulated *digit* count exceeds MAX_NUM_LEN: 1000 ok at chunk 10, 1100 > 1000
        // at chunk 11 (same boundary as the positive case).
        final int expectedFailChunk = (MAX_NUM_LEN / smallChunk) + 1;

        // Note trailing '-': routes the value through the negative-number path.
        int chunksFed = streamDigitsUntilRejected("{\"v\":-", smallChunk);
        // Must accumulate across several chunks before firing...
        assertTrue(chunksFed > 1,
                "Exception fired on a single chunk; cross-chunk accumulation not exercised");
        // ...and fire at the same boundary as the positive case (sign excluded from the
        // validated length), not one chunk earlier.
        assertTrue(chunksFed >= expectedFailChunk,
                "StreamConstraintsException raised too early (sign likely counted): after "
                        + chunksFed + " chunks of " + smallChunk
                        + " (expected ~" + expectedFailChunk + ", maxNumberLength=" + MAX_NUM_LEN + ")");
        assertTrue(chunksFed <= expectedFailChunk + 1,
                "StreamConstraintsException raised too late: after " + chunksFed
                        + " chunks of " + smallChunk + " (expected ~" + expectedFailChunk
                        + ", maxNumberLength=" + MAX_NUM_LEN + ")");
    }

    // // // Helper methods

    private void _asyncParserFailsTooLongNumber(JsonParser p, JsonToken tokenMatch) throws Exception {
        boolean foundNumber = false;
        try {
            while (p.nextToken() != null) {
                if (p.currentToken() == tokenMatch) {
                    foundNumber = true;
                    String numberText = p.getString();
                    assertEquals(TEST_NUMBER_LENGTH, numberText.length(),
                            "Async parser silently accepted all " + TEST_NUMBER_LENGTH + " digits");
                }
            }
            fail("Async parser must reject a " + TEST_NUMBER_LENGTH + "-digit number (number found? "+foundNumber+")");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Number value length (");
            verifyException(e, " exceeds the maximum allowed");
        }
    }

    /**
     * Feeds {@code preamble} (drained to {@code NOT_AVAILABLE}), then streams fixed-size
     * digit-only chunks with no terminator and no {@code endOfInput()}, returning the
     * number of chunks fed before {@link StreamConstraintsException} is raised. Fails the
     * test if any token other than {@code NOT_AVAILABLE} appears while streaming, or if
     * {@link #MAX_CHUNKS} chunks are accepted without the limit being enforced.
     */
    private int streamDigitsUntilRejected(String preamble, int chunkSize) throws Exception {
        try (JsonParser ap = STRICT_F.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            ByteArrayFeeder feeder = (ByteArrayFeeder) ap;

            // Open object and field name (and number prefix); no terminator for the value.
            byte[] pre = utf8Bytes(preamble);
            feeder.feedInput(pre, 0, pre.length);
            JsonToken t;
            while ((t = ap.nextToken()) != JsonToken.NOT_AVAILABLE) {
                if (t == null) {
                    fail("Parser ended unexpectedly while draining preamble");
                }
            }

            byte[] digits = digitBytes(chunkSize);
            int chunksFed = 0;
            try {
                for (int c = 0; c < MAX_CHUNKS; c++) {
                    feeder.feedInput(digits, 0, digits.length);
                    chunksFed++;
                    assertToken(JsonToken.NOT_AVAILABLE, ap.nextToken());
                }
            } catch (StreamConstraintsException e) {
                // Expected: validator must fire on a NOT_AVAILABLE exit once the
                // accumulated digit length exceeds maxNumberLength.
                verifyException(e, "Number value length (");
                verifyException(e, " exceeds the maximum allowed");
                return chunksFed;
            }
            // Reaching here means the parser accepted chunkSize * MAX_CHUNKS digits
            // without raising ��� i.e. maxNumberLength was not enforced while streaming.
            fail("Async parser accepted " + (chunkSize * MAX_CHUNKS)
                    + " digits with maxNumberLength=" + MAX_NUM_LEN
                    + "; expected StreamConstraintsException");
            return -1; // unreachable
        }
    }

    private byte[] buildPayloadWithLongInteger(int numDigits) {
        return utf8Bytes("{\"v\":" + digitString(numDigits) + "}");
    }

    private byte[] buildPayloadWithLongDecimal(int numDigits) {
        return utf8Bytes("{\"v\":0." + digitString(numDigits) + "}");
    }

    private byte[] buildPayloadWithLongExponent(int numDigits) {
        return utf8Bytes("{\"v\":1.1E" + digitString(numDigits));
    }

    private static byte[] digitBytes(int count) {
        byte[] digits = new byte[count];
        for (int i = 0; i < count; i++) {
            digits[i] = (byte) ('1' + (i % 9));
        }
        return digits;
    }

    private static String digitString(int count) {
        StringBuilder sb = new StringBuilder(count);
        for (int i = 0; i < count; i++) {
            sb.append((char) ('1' + (i % 9)));
        }
        return sb.toString();
    }
}