EscapedSurrogateInFieldName1683Test.java

package tools.jackson.core.unittest.read;

import org.junit.jupiter.api.Test;

import tools.jackson.core.JsonParser;
import tools.jackson.core.JsonToken;
import tools.jackson.core.exc.StreamReadException;
import tools.jackson.core.json.JsonFactory;
import tools.jackson.core.json.JsonReadFeature;
import tools.jackson.core.unittest.JacksonCoreTestBase;

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

/**
 * Tests for [jackson-core#1683]: JSON-escaped lone surrogates in field names
 * must be rejected by the Reader-based parser, matching the fix applied for
 * the UTF-8 stream parser in [jackson-core#1541]. String values are
 * (as with UTF-8 parser) not validated, and must pass through as-is.
 *
 * <p>Only exercises {@link tools.jackson.core.json.ReaderBasedJsonParser}
 * paths. Every test runs over {@link #MODES}: {@code String} input (whole
 * content in one buffer), plain {@code Reader} and 1-char-at-a-time throttled
 * {@code Reader}; the latter forces every escape to span input-buffer boundaries.
 */
class EscapedSurrogateInFieldName1683Test extends JacksonCoreTestBase
{
    // Local pseudo-mode for `JsonFactory.createParser(String)`, in addition
    // to Reader-backed ALL_TEXT_MODES
    private final static int MODE_STRING = -1;

    private final static int[] MODES = new int[] {
        MODE_STRING, MODE_READER, MODE_READER_THROTTLED
    };

    // Size of char input buffer `ReaderBasedJsonParser` reads into
    // (BufferRecycler.CHAR_TOKEN_BUFFER)
    private final static int INPUT_BUFFER_LEN = 4000;

    // 6-char escape for high surrogate of U+1F600
    private final static String HI_ESCAPE = "\\uD83D";
    private final static String LO_ESCAPE = "\\uDE00";

    private final JsonFactory FACTORY = new JsonFactory();

    private final JsonFactory APOS_FACTORY = JsonFactory.builder()
            .enable(JsonReadFeature.ALLOW_SINGLE_QUOTES)
            .build();

    // ---- field-name coverage ----------------------------------------------

    // Each backslash-u escape is written using an explicit '\\' + 'u' + hex
    // prefix so the Java pre-lexer doesn't fold it into a single UTF-16 code
    // unit before it reaches the parser.

    @Test
    void loneLeadingSurrogateInFieldName() throws Exception {
        assertRejects(FACTORY, "{\"\\uD800\":1}");
    }

    @Test
    void loneTrailingSurrogateInFieldName() throws Exception {
        assertRejects(FACTORY, "{\"\\uDC00\":1}");
    }

    @Test
    void reversedSurrogatePairInFieldName() throws Exception {
        assertRejects(FACTORY, "{\"\\uDC00\\uD800\":1}");
    }

    @Test
    void highSurrogateFollowedByNonEscapeInFieldName() throws Exception {
        assertRejects(FACTORY, "{\"\\uD800x\":1}");
    }

    @Test
    void validSurrogatePairInFieldName() throws Exception {
        for (int mode : MODES) {
            try (JsonParser p = open(FACTORY, mode, "{\"\\uD800\\uDC00\":1}")) {
                assertToken(JsonToken.START_OBJECT, p.nextToken());
                assertToken(JsonToken.PROPERTY_NAME, p.nextToken());
                assertEquals("\uD800\uDC00", p.currentName(), "mode=" + mode);
                assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken());
                assertToken(JsonToken.END_OBJECT, p.nextToken());
            }
        }
    }

    @Test
    void loneSurrogateInSingleQuotedFieldName() throws Exception {
        assertRejects(APOS_FACTORY, "{'\\uDC00':1}");
    }

    // ---- string values: not validated -------------------------------------

    @Test
    void escapedSurrogatesInStringValuePassThrough() throws Exception {
        _testStringValuePassThrough(FACTORY, "[\"\\uD800\",\"\\uDC00\",\"\\uDC00\\uD800\",\"\\uD800\\uDC00\"]");
    }

    @Test
    void escapedSurrogatesInSingleQuotedStringValuePassThrough() throws Exception {
        _testStringValuePassThrough(APOS_FACTORY, "['\\uD800','\\uDC00','\\uDC00\\uD800','\\uD800\\uDC00']");
    }

    @Test
    void escapedSurrogatesInSkippedStringValue() throws Exception {
        for (int mode : MODES) {
            try (JsonParser p = open(FACTORY, mode, "{\"outer\":{\"k\":\"\\uDC00\\uD800\"}}")) {
                assertToken(JsonToken.START_OBJECT, p.nextToken());
                assertToken(JsonToken.PROPERTY_NAME, p.nextToken());
                assertToken(JsonToken.START_OBJECT, p.nextToken());
                p.skipChildren();
                assertToken(JsonToken.END_OBJECT, p.currentToken());
                assertToken(JsonToken.END_OBJECT, p.nextToken());
            }
        }
    }

    private void _testStringValuePassThrough(JsonFactory f, String doc) throws Exception {
        for (int mode : MODES) {
            try (JsonParser p = open(f, mode, doc)) {
                assertToken(JsonToken.START_ARRAY, p.nextToken());
                assertToken(JsonToken.VALUE_STRING, p.nextToken());
                assertEquals("\uD800", p.getText(), "mode=" + mode);
                assertToken(JsonToken.VALUE_STRING, p.nextToken());
                assertEquals("\uDC00", p.getText(), "mode=" + mode);
                assertToken(JsonToken.VALUE_STRING, p.nextToken());
                assertEquals("\uDC00\uD800", p.getText(), "mode=" + mode);
                assertToken(JsonToken.VALUE_STRING, p.nextToken());
                assertEquals("\uD800\uDC00", p.getText(), "mode=" + mode);
                assertToken(JsonToken.END_ARRAY, p.nextToken());
            }
        }
    }

    // ---- text-buffer segment-boundary coverage ----------------------------

    /**
     * Regression test for the buffer overflow that would occur if the high
     * surrogate write in {@code _parseName2} landed on the last slot of the
     * current text-buffer segment. Without the bounds check between the hi
     * and lo writes, the fall-through path would then write the low
     * surrogate at index {@code outBuf.length}, throwing
     * {@link ArrayIndexOutOfBoundsException} instead of returning a valid
     * astral field name.
     *
     * <p>The default first-segment size in {@code TextBuffer} is 200 UTF-16
     * code units; we sweep several padding sizes so at least one lands the
     * high-surrogate write on {@code outBuf.length - 1}.
     */
    @Test
    void longFieldNameWithSurrogatePairAtSegmentBoundary() throws Exception {
        int[] padSizes = { 199, 200, 201, 399, 400, 401, 4000 };
        for (int mode : MODES) {
            for (int pad : padSizes) {
                String doc = "{\"" + pad(pad) + HI_ESCAPE + LO_ESCAPE + "\":1}";
                try (JsonParser p = open(FACTORY, mode, doc)) {
                    assertToken(JsonToken.START_OBJECT, p.nextToken());
                    assertToken(JsonToken.PROPERTY_NAME, p.nextToken());
                    String name = p.currentName();
                    assertEquals(pad + 1, name.codePointCount(0, name.length()),
                            "codepoint count for pad=" + pad + ", mode=" + mode);
                    assertEquals(0x1F600, name.codePointAt(pad),
                            "astral cp at index " + pad + " for pad=" + pad + ", mode=" + mode);
                    assertToken(JsonToken.VALUE_NUMBER_INT, p.nextToken());
                    assertToken(JsonToken.END_OBJECT, p.nextToken());
                }
            }
        }
    }

    @Test
    void longFieldNameWithLoneTrailingSurrogateAtSegmentBoundary() throws Exception {
        // Same padding sweep, but lone-trailing-surrogate escape ��� must still
        // reject cleanly at the boundary rather than overflowing.
        int[] padSizes = { 199, 200, 201, 399, 400, 401, 4000 };
        for (int pad : padSizes) {
            assertRejects(FACTORY, "{\"" + pad(pad) + "\\uDC00\":1}");
        }
    }

    // ---- input-buffer boundary coverage -----------------------------------

    /**
     * High-surrogate escape ending exactly at the end of the first input
     * buffer, so the following low-surrogate escape (or other char, or EOF)
     * is only seen after {@code _loadMore()}. Applies to Reader-backed input
     * only: String input is never refilled.
     */
    @Test
    void highSurrogateAtInputBufferEndInFieldName() throws Exception {
        final String lead = "{\"" + pad(INPUT_BUFFER_LEN - 2 - HI_ESCAPE.length()) + HI_ESCAPE;
        assertEquals(INPUT_BUFFER_LEN, lead.length());

        for (int mode : ALL_TEXT_MODES) {
            // Valid pair split across buffers: accepted
            try (JsonParser p = open(FACTORY, mode, lead + LO_ESCAPE + "\":1}")) {
                assertToken(JsonToken.START_OBJECT, p.nextToken());
                assertToken(JsonToken.PROPERTY_NAME, p.nextToken());
                String name = p.currentName();
                assertEquals(0x1F600, name.codePointAt(name.length() - 2), "mode=" + mode);
            }
            // High surrogate followed by non-escape char in next buffer
            try (JsonParser p = open(FACTORY, mode, lead + "x\":1}")) {
                p.nextToken();
                p.nextToken();
                fail("Expected StreamReadException (mode=" + mode + ")");
            } catch (StreamReadException e) {
                verifyException(e, "surrogate");
            }
            // High surrogate as the very last content
            try (JsonParser p = open(FACTORY, mode, lead)) {
                p.nextToken();
                p.nextToken();
                fail("Expected StreamReadException (mode=" + mode + ")");
            } catch (StreamReadException e) {
                verifyException(e, "end-of-input");
            }
        }
    }

    // ---- helpers ----------------------------------------------------------

    private JsonParser open(JsonFactory f, int mode, String doc) throws Exception {
        if (mode == MODE_STRING) {
            return f.createParser(doc);
        }
        return createParser(f, mode, doc);
    }

    private static String pad(int len) {
        StringBuilder sb = new StringBuilder(len);
        for (int i = 0; i < len; i++) {
            sb.append('a');
        }
        return sb.toString();
    }

    private void assertRejects(JsonFactory f, String doc) throws Exception {
        for (int mode : MODES) {
            try (JsonParser p = open(f, mode, doc)) {
                while (p.nextToken() != null) { }
                fail("Expected StreamReadException for malformed surrogate escape (mode="
                        + mode + ") in: " + doc);
            } catch (StreamReadException e) {
                verifyException(e, "surrogate");
            }
        }
    }
}