Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 41 additions & 5 deletions subprojects/groovy-json/src/main/java/groovy/json/JsonLexer.java
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,10 @@ public class JsonLexer implements Iterator<JsonToken> {
private static final char UPPER_E = 'E';
private static final char ZERO = '0';
private static final char NINE = '9';
/** The characters which may follow a backslash in a JSON string, other than {@code u}. */
private static final String SIMPLE_ESCAPES = "\"\\/bfnrt";
/** The digits a {@code \\u} escape may carry; deliberately ASCII only. */
private static final String HEX_DIGITS = "0123456789abcdefABCDEF";

private final LineColumnReader reader;

Expand Down Expand Up @@ -107,18 +111,19 @@ public JsonToken nextToken() {
StringBuilder currentContent = new StringBuilder("\"");
// consume the first double quote starting the string
reader.read();
boolean isEscaped = false;
for (;;) {
int read = reader.read();
if (read == -1) return null;

isEscaped = (!isEscaped && currentContent.charAt(currentContent.length() - 1) == '\\');

char charRead = (char) read;
currentContent.append(charRead);

if (charRead == '"' && !isEscaped &&
possibleTokenType.matching(currentContent.toString())) {
if (charRead == '\\') {
// Consume and check the escape sequence where it is read. Validating
// here keeps the scan linear in the length of the token, and reports
// the position of the offending escape rather than of the token end.
readEscapeSequence(currentContent, possibleTokenType);
} else if (charRead == '"') {
token.setEndLine(reader.getLine());
token.setEndColumn(reader.getColumn());
token.setText(unescape(currentContent.toString()));
Expand Down Expand Up @@ -163,6 +168,37 @@ public JsonToken nextToken() {
}
}

/**
* Reads the remainder of an escape sequence whose backslash has already been consumed,
* appending it to the token content and rejecting anything RFC 8259 does not allow to
* follow a backslash.
*
* @param currentContent the token content read so far, ending in the backslash
* @param type the token type being matched, for the error message
* @throws IOException if reading fails
*/
private void readEscapeSequence(StringBuilder currentContent, JsonTokenType type) throws IOException {
int escapeRead = reader.read();
if (escapeRead == -1) return; // unterminated; the caller stops at end of input
char escapeChar = (char) escapeRead;
currentContent.append(escapeChar);

if (escapeChar == 'u') {
for (int i = 0; i < 4; i += 1) {
int hexRead = reader.read();
if (hexRead == -1) return;
char hexChar = (char) hexRead;
currentContent.append(hexChar);
// Character.digit would accept non-ASCII digits, which JSON does not.
if (HEX_DIGITS.indexOf(hexChar) == -1) {
throwJsonException(currentContent.toString(), type);
}
}
} else if (SIMPLE_ESCAPES.indexOf(escapeChar) == -1) {
throwJsonException(currentContent.toString(), type);
}
}

private void throwJsonException(String content, JsonTokenType type) {
throw new JsonException(
"Lexing failed on line: " +
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,50 @@ class JsonSlurperMalformedStringTest {
assertValidUnicodeEscapes('JsonSlurperClassic') { String doc -> slurper.parseText(doc).k }
}

/**
* GROOVY-12272: the lexer used to re-validate the whole accumulated token at every unescaped
* quote, so a string holding an invalid escape followed by many quotes cost O(n^2). The
* escape is now checked where it is read, which both bounds the work and reports the
* position of the offending escape rather than of the end of the document.
*/
@Test
void testInvalidEscapeFollowedByManyQuotesStaysLinear() {
def document = { int quotes -> '{"k":"\\q' + ('"' * quotes) + '"}' }

// Warm up so the measurement is not dominated by class loading and JIT.
3.times { parseIgnoringFailure(document(2000)) }

long small = timeParse(document(4000))
long large = timeParse(document(16000))

// Four times the input. Quadratic cost would be about sixteen times the work; allow a
// wide margin so the test is about the growth curve, not the absolute speed.
assert large < Math.max(small, 5) * 8,
"parse time grew from ${small}ms to ${large}ms for a 4x larger document"
}

@Test
void testInvalidEscapeIsReportedWhereItOccurs() {
def e = shouldFail(JsonException) {
new JsonSlurperClassic().parseText('{"k":"a\\qb"}')
}
// The report names the escape, not the whole remaining document.
assert e.message.contains('\\q')
}

private static void parseIgnoringFailure(String text) {
try {
new JsonSlurperClassic().parseText(text)
} catch (JsonException ignored) {
}
}

private static long timeParse(String text) {
long start = System.nanoTime()
parseIgnoringFailure(text)
(System.nanoTime() - start) / 1_000_000L
}

private static void assertRejected(String context, String description, Closure parse) {
def thrown = null
try {
Expand Down
Loading