Skip to content

Commit 90cbf90

Browse files
authored
[java] Enforce commas between JSON elements; accept trailing commas (#17738)
`hasNext()` previously consumed a leading comma if present, but never required one. That accepted spec-invalid inputs like '[1 2 3]' (missing separator) and '[,1]' (leading comma). It also rejected trailing commas like '[1,2,3,]' because it consumed the comma and then tried to read another element. Track whether the current container has seen an element (parallel deque kept in sync with the container stack, marked in expect()) and use it in hasNext() to require a comma between elements while allowing a single trailing comma before the container's closer.
1 parent 740b692 commit 90cbf90

2 files changed

Lines changed: 166 additions & 8 deletions

File tree

java/src/org/openqa/selenium/json/JsonInput.java

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ public class JsonInput implements Closeable {
4949
// Used when reading maps and collections so that we handle de-nesting and
5050
// figuring out whether we're expecting a NAME properly.
5151
private final Deque<Container> stack = new ArrayDeque<>();
52+
// Parallel stack tracking whether the current container has seen at least
53+
// one element. Used by hasNext() to enforce comma separators between
54+
// elements while remaining lenient about a single trailing comma.
55+
private final Deque<Boolean> containerHasElement = new ArrayDeque<>();
5256

5357
JsonInput(Reader source, JsonTypeCoercer coercer, PropertySetting setter) {
5458

@@ -353,13 +357,30 @@ public boolean hasNext() {
353357
}
354358

355359
skipWhitespace(input);
360+
boolean seenElement = Boolean.TRUE.equals(containerHasElement.peekFirst());
361+
356362
if (input.peek() == ',') {
363+
if (!seenElement) {
364+
throw new JsonException("Unexpected ',' before first element of container. " + input);
365+
}
357366
input.read();
358-
return true;
367+
// We've moved past the separator, so we're once again expecting an element rather than
368+
// another comma. Clear the flag so a repeat hasNext() before reading is a no-op.
369+
clearSeenElement();
370+
skipWhitespace(input);
371+
JsonType afterComma = peek();
372+
// Trailing comma leniency: '[1,]' and '{"a":1,}' are accepted.
373+
return afterComma != JsonType.END_COLLECTION && afterComma != JsonType.END_MAP;
359374
}
360375

361376
JsonType type = peek();
362-
return type != JsonType.END_COLLECTION && type != JsonType.END_MAP;
377+
if (type == JsonType.END_COLLECTION || type == JsonType.END_MAP) {
378+
return false;
379+
}
380+
if (seenElement) {
381+
throw new JsonException("Expected ',' or end of container but saw " + type + ". " + input);
382+
}
383+
return true;
363384
}
364385

365386
/**
@@ -370,6 +391,7 @@ public boolean hasNext() {
370391
public void beginArray() {
371392
expect(JsonType.START_COLLECTION);
372393
stack.addFirst(Container.COLLECTION);
394+
containerHasElement.addFirst(false);
373395
input.read();
374396
}
375397

@@ -380,12 +402,13 @@ public void beginArray() {
380402
*/
381403
public void endArray() {
382404
expect(JsonType.END_COLLECTION);
383-
Container expectation = stack.removeFirst();
384-
if (expectation != Container.COLLECTION) {
405+
if (stack.peekFirst() != Container.COLLECTION) {
385406
// The only other thing we could be closing is a map
386407
throw new JsonException(
387408
"Attempt to close a JSON List, but a JSON Object was expected. " + input);
388409
}
410+
stack.removeFirst();
411+
containerHasElement.removeFirst();
389412
input.read();
390413
}
391414

@@ -397,6 +420,7 @@ public void endArray() {
397420
public void beginObject() {
398421
expect(JsonType.START_MAP);
399422
stack.addFirst(Container.MAP_NAME);
423+
containerHasElement.addFirst(false);
400424
input.read();
401425
}
402426

@@ -407,11 +431,11 @@ public void beginObject() {
407431
*/
408432
public void endObject() {
409433
expect(JsonType.END_MAP);
410-
Container expectation = stack.removeFirst();
411-
if (expectation != Container.MAP_NAME) {
412-
// The only other thing we could be closing is a map
434+
if (stack.peekFirst() != Container.MAP_NAME) {
413435
throw new JsonException("Attempt to close a JSON Map, but not ready to. " + input);
414436
}
437+
stack.removeFirst();
438+
containerHasElement.removeFirst();
415439
input.read();
416440
}
417441

@@ -565,10 +589,31 @@ private void expect(JsonType type) {
565589
return; // End of Name handling
566590
}
567591

568-
// Handle the case where we're reading a value
592+
// Handle the case where we're reading a value.
593+
if (type == JsonType.END_COLLECTION || type == JsonType.END_MAP) {
594+
// Closing the container - don't treat as a new element in it.
595+
return;
596+
}
569597
if (top == Container.MAP_VALUE) {
570598
stack.removeFirst();
571599
stack.addFirst(Container.MAP_NAME);
600+
markElementRead();
601+
} else if (top == Container.COLLECTION) {
602+
markElementRead();
603+
}
604+
}
605+
606+
private void markElementRead() {
607+
if (!containerHasElement.isEmpty()) {
608+
containerHasElement.removeFirst();
609+
containerHasElement.addFirst(true);
610+
}
611+
}
612+
613+
private void clearSeenElement() {
614+
if (!containerHasElement.isEmpty()) {
615+
containerHasElement.removeFirst();
616+
containerHasElement.addFirst(false);
572617
}
573618
}
574619

java/test/org/openqa/selenium/json/JsonInputTest.java

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -384,6 +384,119 @@ void shouldReportEndOfInputAsEofNotAsAUnicodeReplacement() {
384384
}
385385
}
386386

387+
@Test
388+
void shouldAcceptTrailingCommaInArray() {
389+
try (JsonInput input = newInput("[1,2,3,]")) {
390+
input.beginArray();
391+
assertThat(input.hasNext()).isTrue();
392+
assertThat(input.nextNumber()).isEqualTo(1L);
393+
assertThat(input.hasNext()).isTrue();
394+
assertThat(input.nextNumber()).isEqualTo(2L);
395+
assertThat(input.hasNext()).isTrue();
396+
assertThat(input.nextNumber()).isEqualTo(3L);
397+
assertThat(input.hasNext()).isFalse();
398+
input.endArray();
399+
}
400+
}
401+
402+
@Test
403+
void shouldAcceptTrailingCommaInObject() {
404+
try (JsonInput input = newInput("{\"a\":1,}")) {
405+
input.beginObject();
406+
assertThat(input.hasNext()).isTrue();
407+
assertThat(input.nextName()).isEqualTo("a");
408+
assertThat(input.nextNumber()).isEqualTo(1L);
409+
assertThat(input.hasNext()).isFalse();
410+
input.endObject();
411+
}
412+
}
413+
414+
@Test
415+
void shouldRejectMissingCommaBetweenArrayElements() {
416+
try (JsonInput input = newInput("[1 2]")) {
417+
input.beginArray();
418+
assertThat(input.hasNext()).isTrue();
419+
assertThat(input.nextNumber()).isEqualTo(1L);
420+
assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
421+
}
422+
}
423+
424+
@Test
425+
void shouldRejectLeadingCommaInArray() {
426+
try (JsonInput input = newInput("[,1]")) {
427+
input.beginArray();
428+
assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
429+
}
430+
}
431+
432+
@Test
433+
void shouldRejectMissingCommaBetweenObjectEntries() {
434+
try (JsonInput input = newInput("{\"a\":1 \"b\":2}")) {
435+
input.beginObject();
436+
assertThat(input.hasNext()).isTrue();
437+
assertThat(input.nextName()).isEqualTo("a");
438+
assertThat(input.nextNumber()).isEqualTo(1L);
439+
assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
440+
}
441+
}
442+
443+
@Test
444+
void shouldRejectLeadingCommaInObject() {
445+
try (JsonInput input = newInput("{,\"a\":1}")) {
446+
input.beginObject();
447+
assertThatExceptionOfType(JsonException.class).isThrownBy(input::hasNext);
448+
}
449+
}
450+
451+
@Test
452+
void mismatchedCloseLeavesParserStateIntact() {
453+
// endObject() while inside an array must fail without popping the container stack,
454+
// so the parser still knows it is inside the array afterwards.
455+
try (JsonInput input = newInput("[1}")) {
456+
input.beginArray();
457+
assertThat(input.nextNumber()).isEqualTo(1L);
458+
assertThatExceptionOfType(JsonException.class)
459+
.isThrownBy(input::endObject)
460+
.withMessageStartingWith("Attempt to close a JSON Map");
461+
// Before the fix this threw "not in a container type" because the array's
462+
// stack entry had already been popped.
463+
assertThat(input.hasNext()).isFalse();
464+
}
465+
}
466+
467+
@Test
468+
void hasNextIsIdempotentBetweenElementsInArray() {
469+
// Iterator-style probing (peek/hasNext repeatedly before reading) must not falsely fail
470+
// once hasNext() has already consumed the comma before the next element.
471+
try (JsonInput input = newInput("[1,2]")) {
472+
input.beginArray();
473+
assertThat(input.hasNext()).isTrue();
474+
assertThat(input.nextNumber()).isEqualTo(1L);
475+
assertThat(input.hasNext()).isTrue();
476+
assertThat(input.hasNext()).isTrue();
477+
assertThat(input.nextNumber()).isEqualTo(2L);
478+
assertThat(input.hasNext()).isFalse();
479+
assertThat(input.hasNext()).isFalse();
480+
input.endArray();
481+
}
482+
}
483+
484+
@Test
485+
void hasNextIsIdempotentBetweenEntriesInObject() {
486+
try (JsonInput input = newInput("{\"a\":1,\"b\":2}")) {
487+
input.beginObject();
488+
assertThat(input.hasNext()).isTrue();
489+
assertThat(input.nextName()).isEqualTo("a");
490+
assertThat(input.nextNumber()).isEqualTo(1L);
491+
assertThat(input.hasNext()).isTrue();
492+
assertThat(input.hasNext()).isTrue();
493+
assertThat(input.nextName()).isEqualTo("b");
494+
assertThat(input.nextNumber()).isEqualTo(2L);
495+
assertThat(input.hasNext()).isFalse();
496+
input.endObject();
497+
}
498+
}
499+
387500
@Test
388501
void nullInputsShouldCoerceAsNullValues() throws IOException {
389502
try (InputStream is = new ByteArrayInputStream(new byte[0]);

0 commit comments

Comments
 (0)