LargeNameReadTest.java

package tools.jackson.core.unittest.constraints;

import java.io.IOException;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

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.exc.StreamConstraintsException;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.core.sym.PropertyNameMatcher;
import tools.jackson.core.unittest.*;
import tools.jackson.core.util.JsonRecyclerPools;
import tools.jackson.core.util.Named;

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

// [core#1047]: Add max-name-length constraints
class LargeNameReadTest extends JacksonCoreTestBase
{
    private final static int UTF8_INITIAL_QUAD_BUFFER_QUADS = 16;

    private final JsonFactory JSON_F_DEFAULT = newStreamFactory();

    private final JsonFactory JSON_F_NAME_100 = JsonFactory.builder()
            .streamReadConstraints(StreamReadConstraints.builder().maxNameLength(100).build())
            .build();

    // Factory that also allows non-standard name flavors ("apostrophe" and unquoted)
    private final JsonFactory JSON_F_NAME_100_ODD = JsonFactory.builder()
            .streamReadConstraints(StreamReadConstraints.builder().maxNameLength(100).build())
            .configure(JsonReadFeature.ALLOW_SINGLE_QUOTES, true)
            .configure(JsonReadFeature.ALLOW_UNQUOTED_PROPERTY_NAMES, true)
            .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 {
        _testLargeNameWithSmallLimitBytes(JSON_F_NAME_100);
    }

    private void _testLargeNameWithSmallLimitBytes(JsonFactory jf) throws Exception
    {
        final String doc = generateJSON(1000);
        try (JsonParser p = createParserUsingStream(jf, doc, "UTF-8")) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
        }
    }

    @Test
    void largeNameWithSmallLimitChars() throws Exception {
        _testLargeNameWithSmallLimitChars(JSON_F_NAME_100);
    }

    private void _testLargeNameWithSmallLimitChars(JsonFactory jf) throws Exception
    {
        final String doc = generateJSON(1000);
        try (JsonParser p = createParserUsingReader(jf, doc)) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
        }
    }

    // [core#1643]: Reader-backed parser must reject an over-limit name promptly, the
    // same way byte-based input already does -- not only once the entire (possibly
    // huge) name has already been buffered.
    // (note: `String` / `char[]` input is not affected the same way, since the whole
    // document is already in memory and gets scanned in-place, without buffering)
    @Test
    void largeNameWithSmallLimitCharsFailsFast() throws Exception {
        // Name much larger than the configured limit: without incremental checking
        // the whole name gets buffered (bounded only by much bigger `maxStringLength`)
        // before failing, whereas the fix must reject within a segment fill or two.
        _testLargeNameFailsFast(JSON_F_NAME_100, "\"");
    }

    // [core#1643]: ... and same goes for the non-standard name flavors, which are
    // decoded by different code paths ("apostrophe" and unquoted names)
    @Test
    void largeOddNameWithSmallLimitCharsFailsFast() throws Exception {
        _testLargeNameFailsFast(JSON_F_NAME_100_ODD, "'");
        _testLargeNameFailsFast(JSON_F_NAME_100_ODD, "");
    }

    private void _testLargeNameFailsFast(JsonFactory jf, String nameQuote) throws Exception
    {
        // 09-Sep-2026, tatu: [core#1643] Must NOT use recycled buffers here: check is
        //   only made when `TextBuffer` segment gets full, and a buffer left behind by
        //   an earlier test in same thread may be up to 64kB (`BufferRecycler` retains
        //   the biggest one released, see [core#1186]) -- which would make the first
        //   check occur much later than with a fresh (small) buffer.
        jf = jf.rebuild()
                .recyclerPool(JsonRecyclerPools.nonRecyclingPool())
                .build();
        final int nameLen = 1_000_000;
        final String doc = generateJSON(nameLen, nameQuote);
        try (JsonParser p = createParserUsingReader(jf, doc)) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
            // Length the exception reports tells us how much had been accumulated
            // before the check fired: needs to be small fraction of the whole name
            final int reportedLen = _reportedNameLength(e);
            final int maxExpected = nameLen >> 4;
            if (reportedLen > maxExpected) {
                fail("Should have failed before buffering "+maxExpected
                        +" chars (limit is 100), but reported length was: "+reportedLen);
            }
        }
    }

    private int _reportedNameLength(StreamConstraintsException e) {
        Matcher m = Pattern.compile("Name length \\((\\d+)\\)").matcher(e.getMessage());
        if (!m.find()) {
            fail("Could not find reported name length from message: "+e.getMessage());
        }
        return Integer.parseInt(m.group(1));
    }

    @Test
    void largeNameWithSmallLimitAsync() throws Exception
    {
        final byte[] doc = utf8Bytes(generateJSON(1000));
        try (JsonParser p = JSON_F_NAME_100.createNonBlockingByteArrayParser(ObjectReadContext.empty())) {
            ((ByteArrayFeeder) p).feedInput(doc, 0, doc.length);
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
        }
    }

    // [core#1602]: DataInput-backed parser must also enforce maxNameLength
    @Test
    void largeNameWithSmallLimitDataInput() throws Exception {
        _testLargeNameWithSmallLimitDataInput(JSON_F_NAME_100);
    }

    private void _testLargeNameWithSmallLimitDataInput(JsonFactory jf) throws Exception
    {
        final String doc = generateJSON(1000);
        try (JsonParser p = createParser(jf, MODE_DATA_INPUT, doc)) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
        }
    }

    @Test
    void largeEscapedSupplementaryNameWithSmallLimitDataInputFailsDuringQuadBufferGrowth() throws Exception {
        _testLargeEscapedSupplementaryNameWithSmallLimitDataInput(JSON_F_NAME_100, "\"");
        _testLargeEscapedSupplementaryNameWithSmallLimitDataInput(JSON_F_NAME_100_ODD, "'");
    }

    private void _testLargeEscapedSupplementaryNameWithSmallLimitDataInput(JsonFactory jf,
            String nameQuote) throws Exception
    {
        final int pairCount = 1_000;
        // Decoded supplementary code point is stored as 4 UTF-8 bytes in the quad buffer.
        final int fullNameLen = 1 + (pairCount << 2);
        _testEscapedSupplementaryNameAtDataInputQuadGrowBoundary(jf,
                generateEscapedSupplementaryNameJSON(pairCount, nameQuote), fullNameLen);

        // These prefixes force the two flush points inside the supplementary
        // character branch to grow the quad buffer.
        _testEscapedSupplementaryNameAtDataInputQuadGrowBoundary(jf,
                generateEscapedSupplementaryNameJSON(pairCount, nameQuote, 2),
                2 + (pairCount << 2));
        _testEscapedSupplementaryNameAtDataInputQuadGrowBoundary(jf,
                generateEscapedSupplementaryNameJSON(pairCount, nameQuote, 3),
                3 + (pairCount << 2));
    }

    @Test
    void largeNameWithSmallLimitAndMatcherFailsDuringQuadBufferGrowth() throws Exception {
        final int nameLen = 1_000;
        final String name = generateName(nameLen);
        final String doc = "{\"" + name + "\":\"value\"}";
        final PropertyNameMatcher matcher = JSON_F_NAME_100.constructNameMatcher(
                List.of(Named.fromString(name)), false);

        try (JsonParser p = createParser(JSON_F_NAME_100, MODE_INPUT_STREAM, doc)) {
            assertToken(JsonToken.START_OBJECT, p.nextToken());
            p.nextNameMatch(matcher);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
            verifyNameLengthFailedAtUtf8QuadGrowBoundary(e,
                    JSON_F_NAME_100.streamReadConstraints().getMaxNameLength(), nameLen);
        }
    }

    private void _testEscapedSupplementaryNameAtDataInputQuadGrowBoundary(JsonFactory jf,
            String doc, int fullNameLen) throws Exception
    {
        try (JsonParser p = createParser(jf, MODE_DATA_INPUT, doc)) {
            consumeTokens(p);
            fail("expected StreamConstraintsException");
        } catch (StreamConstraintsException e) {
            verifyException(e, "Name length");
            verifyNameLengthFailedAtUtf8QuadGrowBoundary(e,
                    jf.streamReadConstraints().getMaxNameLength(), fullNameLen);
        }
    }

    private void verifyNameLengthFailedAtUtf8QuadGrowBoundary(StreamConstraintsException e,
            int maxNameLength, int fullNameLen) {
        final int reportedLen = _reportedNameLength(e);
        final int expectedFailingGrowBoundary = _utf8QuadGrowBoundaryAbove(maxNameLength);

        if (reportedLen <= maxNameLength) {
            fail("Expected reported name length to exceed maxNameLength "+maxNameLength
                    +", but reported length was: "+reportedLen);
        }
        if (reportedLen >= fullNameLen) {
            fail("Should have failed while growing the quad buffer, before buffering the full "
                    +"decoded UTF-8 name length "+fullNameLen
                    +", but reported length was: "+reportedLen);
        }
        if (reportedLen != expectedFailingGrowBoundary) {
            fail("Expected failure at UTF-8 quad-buffer grow boundary "
                    +expectedFailingGrowBoundary+", but reported length was: "+reportedLen);
        }
    }

    private int _utf8QuadGrowBoundaryAbove(int maxNameLength) {
        int quadBufferLen = UTF8_INITIAL_QUAD_BUFFER_QUADS;
        while ((quadBufferLen << 2) <= maxNameLength) {
            quadBufferLen += quadBufferLen;
        }
        return quadBufferLen << 2;
    }

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

    private String generateJSON(final int nameLen) {
        return generateJSON(nameLen, "\"");
    }

    // @param nameQuote Quote character to use around name; empty String for unquoted name
    private String generateJSON(final int nameLen, final String nameQuote) {
        final StringBuilder sb = new StringBuilder();
        sb.append("{").append(nameQuote);
        for (int i = 0; i < nameLen; i++) {
            sb.append("a");
        }
        sb.append(nameQuote).append(":\"value\"}");
        return sb.toString();
    }

    private String generateName(final int nameLen) {
        final StringBuilder sb = new StringBuilder(nameLen);
        for (int i = 0; i < nameLen; i++) {
            sb.append("a");
        }
        return sb.toString();
    }

    private String generateEscapedSupplementaryNameJSON(final int pairCount, String nameQuote) {
        return generateEscapedSupplementaryNameJSON(pairCount, nameQuote, 1);
    }

    private String generateEscapedSupplementaryNameJSON(final int pairCount, String nameQuote,
            int asciiPrefixLen) {
        final StringBuilder sb = new StringBuilder();
        sb.append("{").append(nameQuote);
        for (int i = 0; i < asciiPrefixLen; i++) {
            sb.append("a");
        }
        for (int i = 0; i < pairCount; i++) {
            sb.append("\\ud83d\\udc4d");
        }
        sb.append(nameQuote).append(":\"value\"}");
        return sb.toString();
    }
}