From f48d6481f1690cf2f117212f3380d16d687c18b5 Mon Sep 17 00:00:00 2001 From: Otar Chekurishvili Date: Mon, 17 Aug 2026 19:12:38 +0400 Subject: [PATCH 01/12] Add opt-in JSON comments and trailing comma parsing --- ext/json/json.c | 7 +- ext/json/json.stub.php | 11 +++ ext/json/json_arginfo.h | 4 +- ext/json/json_parser.y | 22 +++++ ext/json/json_scanner.re | 84 ++++++++++++++++ ext/json/php_json.h | 4 + .../tests/json_decode_allow_comments.phpt | 70 ++++++++++++++ .../json_decode_allow_comments_errors.phpt | 67 +++++++++++++ .../json_decode_allow_comments_utf8.phpt | 57 +++++++++++ .../json_decode_allow_trailing_commas.phpt | 96 +++++++++++++++++++ ...on_decode_relaxed_syntax_interactions.phpt | 78 +++++++++++++++ ...ast_error_msg_error_location_comments.phpt | 40 ++++++++ ext/json/tests/json_validate_002.phpt | 4 +- sapi/fuzzer/fuzzer-json.c | 16 +++- 14 files changed, 553 insertions(+), 7 deletions(-) create mode 100644 ext/json/tests/json_decode_allow_comments.phpt create mode 100644 ext/json/tests/json_decode_allow_comments_errors.phpt create mode 100644 ext/json/tests/json_decode_allow_comments_utf8.phpt create mode 100644 ext/json/tests/json_decode_allow_trailing_commas.phpt create mode 100644 ext/json/tests/json_decode_relaxed_syntax_interactions.phpt create mode 100644 ext/json/tests/json_last_error_msg_error_location_comments.phpt diff --git a/ext/json/json.c b/ext/json/json.c index 04a62f52152f..0ebf5998a5a8 100644 --- a/ext/json/json.c +++ b/ext/json/json.c @@ -351,8 +351,11 @@ PHP_FUNCTION(json_validate) ZEND_PARSE_PARAMETERS_END(); - if ((options != 0) && (options != PHP_JSON_INVALID_UTF8_IGNORE)) { - zend_argument_value_error(3, "must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE)"); + if ((options & ~(PHP_JSON_INVALID_UTF8_IGNORE + | PHP_JSON_ALLOW_COMMENTS + | PHP_JSON_ALLOW_TRAILING_COMMAS)) != 0) { + zend_argument_value_error(3, "must be a valid flag (allowed flags: " + "JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS)"); RETURN_THROWS(); } diff --git a/ext/json/json.stub.php b/ext/json/json.stub.php index a805c3893dd1..737d341e9ca2 100644 --- a/ext/json/json.stub.php +++ b/ext/json/json.stub.php @@ -90,6 +90,17 @@ */ const JSON_THROW_ON_ERROR = UNKNOWN; +/** + * @var int + * @cvalue PHP_JSON_ALLOW_COMMENTS + */ +const JSON_ALLOW_COMMENTS = UNKNOWN; +/** + * @var int + * @cvalue PHP_JSON_ALLOW_TRAILING_COMMAS + */ +const JSON_ALLOW_TRAILING_COMMAS = UNKNOWN; + /** * @var int * @cvalue PHP_JSON_ERROR_NONE diff --git a/ext/json/json_arginfo.h b/ext/json/json_arginfo.h index 87ba9cce3afd..906491686cbd 100644 --- a/ext/json/json_arginfo.h +++ b/ext/json/json_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit json.stub.php instead. - * Stub hash: 0ceb50047401c4b9e878c09cc518eacc274f7fff */ + * Stub hash: 405dc5ea03cb0ed560c3717cad38203b2f55d870 */ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_MASK_EX(arginfo_json_encode, 0, 1, MAY_BE_STRING|MAY_BE_FALSE) ZEND_ARG_TYPE_INFO(0, value, IS_MIXED, 0) @@ -68,6 +68,8 @@ static void register_json_symbols(int module_number) REGISTER_LONG_CONSTANT("JSON_INVALID_UTF8_IGNORE", PHP_JSON_INVALID_UTF8_IGNORE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_INVALID_UTF8_SUBSTITUTE", PHP_JSON_INVALID_UTF8_SUBSTITUTE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_THROW_ON_ERROR", PHP_JSON_THROW_ON_ERROR, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("JSON_ALLOW_COMMENTS", PHP_JSON_ALLOW_COMMENTS, CONST_PERSISTENT); + REGISTER_LONG_CONSTANT("JSON_ALLOW_TRAILING_COMMAS", PHP_JSON_ALLOW_TRAILING_COMMAS, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_NONE", PHP_JSON_ERROR_NONE, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_DEPTH", PHP_JSON_ERROR_DEPTH, CONST_PERSISTENT); REGISTER_LONG_CONSTANT("JSON_ERROR_STATE_MISMATCH", PHP_JSON_ERROR_STATE_MISMATCH, CONST_PERSISTENT); diff --git a/ext/json/json_parser.y b/ext/json/json_parser.y index 0fd3e2c4e364..742f7141042f 100644 --- a/ext/json/json_parser.y +++ b/ext/json/json_parser.y @@ -119,6 +119,17 @@ members: } } | member + | member ',' + { + if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { + if (!parser->scanner.errcode) { + parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; + } + zval_ptr_dtor_nogc(&$1); + YYERROR; + } + ZVAL_COPY_VALUE(&$$, &$1); + } ; member: @@ -175,6 +186,17 @@ elements: } } | element + | element ',' + { + if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { + if (!parser->scanner.errcode) { + parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; + } + zval_ptr_dtor_nogc(&$1); + YYERROR; + } + ZVAL_COPY_VALUE(&$$, &$1); + } ; element: diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index be62875a00e0..6d91975fd5b7 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -102,6 +102,11 @@ void php_json_scanner_init(php_json_scanner *s, const char *str, size_t str_len, int php_json_scan(php_json_scanner *s) { + /* Comments are fully consumed before returning, so their opener location can remain local. */ + php_json_ctype *comment_token = s->cursor; + php_json_ctype *comment_line_start = s->line_start; + uint64_t comment_line = s->line; + ZVAL_NULL(&s->value); std: @@ -203,6 +208,23 @@ std: goto std; } WS { goto std; } + "//" { + if (!(s->options & PHP_JSON_ALLOW_COMMENTS)) { + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + PHP_JSON_CONDITION_SET_AND_GOTO(COMMENT_LINE); + } + "/*" { + if (!(s->options & PHP_JSON_ALLOW_COMMENTS)) { + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + comment_token = s->token; + comment_line_start = s->line_start; + comment_line = s->line; + PHP_JSON_CONDITION_SET_AND_GOTO(COMMENT_BLOCK); + } EOI { if (s->limit < s->cursor) { return PHP_JSON_T_EOI; @@ -230,6 +252,68 @@ std: s->errcode = PHP_JSON_ERROR_UTF8; return PHP_JSON_T_ERROR; } + "\r\n" { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_SET(JS); + goto std; + } + [\r\n] { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_SET(JS); + goto std; + } + EOI { + if (s->limit < s->cursor) { + s->token = s->limit; + PHP_JSON_CONDITION_SET(JS); + return PHP_JSON_T_EOI; + } + PHP_JSON_CONDITION_GOTO(COMMENT_LINE); + } + UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_LINE); } + ANY { + if (s->options & (PHP_JSON_INVALID_UTF8_IGNORE | PHP_JSON_INVALID_UTF8_SUBSTITUTE)) { + PHP_JSON_CONDITION_GOTO(COMMENT_LINE); + } + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_UTF8; + return PHP_JSON_T_ERROR; + } + "*/" { + PHP_JSON_CONDITION_SET(JS); + goto std; + } + "\r\n" { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + [\r\n] { + s->line++; + s->line_start = s->cursor; + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + EOI { + if (s->limit < s->cursor) { + s->token = comment_token; + s->line_start = comment_line_start; + s->line = comment_line; + s->errcode = PHP_JSON_ERROR_SYNTAX; + return PHP_JSON_T_ERROR; + } + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); } + ANY { + if (s->options & (PHP_JSON_INVALID_UTF8_IGNORE | PHP_JSON_INVALID_UTF8_SUBSTITUTE)) { + PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + } + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_UTF8; + return PHP_JSON_T_ERROR; + } EOI { if (s->limit < s->cursor) { s->errcode = PHP_JSON_ERROR_SYNTAX; diff --git a/ext/json/php_json.h b/ext/json/php_json.h index f20b20964a71..0145a0dfaa6c 100644 --- a/ext/json/php_json.h +++ b/ext/json/php_json.h @@ -89,6 +89,10 @@ static inline void php_json_error_details_clear(php_json_error_details *out) { #define PHP_JSON_INVALID_UTF8_SUBSTITUTE (1<<21) #define PHP_JSON_THROW_ON_ERROR (1<<22) +/* json_validate() and json_decode() common options */ +#define PHP_JSON_ALLOW_COMMENTS (1<<23) +#define PHP_JSON_ALLOW_TRAILING_COMMAS (1<<24) + /* default depth */ #define PHP_JSON_PARSER_DEFAULT_DEPTH 512 diff --git a/ext/json/tests/json_decode_allow_comments.phpt b/ext/json/tests/json_decode_allow_comments.phpt new file mode 100644 index 000000000000..a613ac2888cc --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments.phpt @@ -0,0 +1,70 @@ +--TEST-- +json_decode() and json_validate() with JSON_ALLOW_COMMENTS +--FILE-- + +--EXPECT-- +{"a":[1,2],"b":3} +bool(true) +[1,2] +bool(true) +[] +bool(true) +[] +bool(true) +1 +bool(true) +2 +bool(true) +3 +bool(true) +4 +bool(true) +6 +bool(true) +7 +bool(true) +["//","/* not a comment */","https://php.net/a/*b*/"] +bool(true) +5 +bool(true) +8 +bool(true) diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt new file mode 100644 index 000000000000..c5f50ec6c815 --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -0,0 +1,67 @@ +--TEST-- +JSON_ALLOW_COMMENTS rejects malformed or misplaced comments +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:16 +NULL +4: Syntax error near location 1:19 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:2 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 1:26 +NULL +4: Syntax error near location 1:22 +NULL +4: Syntax error near location 1:1 +NULL +4: Syntax error near location 3:3 +JsonException: 4: Syntax error near location 2:2 diff --git a/ext/json/tests/json_decode_allow_comments_utf8.phpt b/ext/json/tests/json_decode_allow_comments_utf8.phpt new file mode 100644 index 000000000000..c7d78ff56196 --- /dev/null +++ b/ext/json/tests/json_decode_allow_comments_utf8.phpt @@ -0,0 +1,57 @@ +--TEST-- +JSON_ALLOW_COMMENTS interactions with malformed UTF-8 flags +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +NULL +5 +array(1) { + [0]=> + string(2) "ab" +} +0 +array(1) { + [0]=> + string(5) "a�b" +} +0 +NULL +5 +int(1) +0 +bool(false) +5 +bool(true) +0 +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 diff --git a/ext/json/tests/json_decode_allow_trailing_commas.phpt b/ext/json/tests/json_decode_allow_trailing_commas.phpt new file mode 100644 index 000000000000..ce111945791d --- /dev/null +++ b/ext/json/tests/json_decode_allow_trailing_commas.phpt @@ -0,0 +1,96 @@ +--TEST-- +json_decode() and json_validate() with JSON_ALLOW_TRAILING_COMMAS +--FILE-- +getCode(), ': ', $e->getMessage(), "\n"; +} + +?> +--EXPECT-- +[1] +[1] +[1] +{"a":1} +[[1],{"a":2}] +error 4: Syntax error near location 1:2 +error 4: Syntax error near location 1:2 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:8 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +error 4: Syntax error near location 1:4 +[1] +{"a":1} +error 2: State mismatch (invalid or malformed JSON) near location 1:4 +error 2: State mismatch (invalid or malformed JSON) near location 1:8 +bool(true) +bool(true) +bool(false) +bool(true) +bool(false) +bool(false) +4: Syntax error near location 1:4 diff --git a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt new file mode 100644 index 000000000000..068cffd20b34 --- /dev/null +++ b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt @@ -0,0 +1,78 @@ +--TEST-- +JSON comment and trailing-comma flags interact correctly with existing behavior +--FILE-- + 1], RELAXED)); + +/* Association, bigint conversion, and duplicate-key behavior are unchanged. */ +var_dump(json_decode( + '/* before */{"n":9223372036854775808,"same":1,"same":2,}', + null, + 512, + RELAXED | JSON_OBJECT_AS_ARRAY | JSON_BIGINT_AS_STRING +)); +var_dump(json_decode('{"a":1,}', false, 512, JSON_ALLOW_TRAILING_COMMAS)); + +/* Comments and trailing commas do not consume nesting depth. */ +$nested = '/* before */[[0,],]'; +var_dump(json_decode($nested, true, 2, RELAXED)); +echo json_last_error(), ': ', json_last_error_msg(), "\n"; +var_dump(json_validate($nested, 3, RELAXED)); + +/* A trailing-comma reduction must not replace a more specific scanner error. */ +foreach ([ + "[1,\x01]", + "[1,\x80]", + '[1,"\\ud834"]', +] as $json) { + var_dump(json_decode($json, true, 512, RELAXED)); + echo json_last_error(), ': ', json_last_error_msg(), "\n"; +} + +/* json_validate() accepts only its documented combinations. */ +var_dump(json_validate('/* comment */[1,]', 512, RELAXED)); +var_dump(json_validate( + "/* \x80 */[1,]", + 512, + RELAXED | JSON_INVALID_UTF8_IGNORE +)); +foreach ([JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_SUBSTITUTE] as $invalidFlag) { + try { + json_validate('{}', flags: RELAXED | $invalidFlag); + } catch (ValueError $e) { + echo get_class($e), "\n"; + } +} + +?> +--EXPECTF-- +int(8388608) +int(16777216) +string(7) "{"x":1}" +array(2) { + ["n"]=> + string(19) "9223372036854775808" + ["same"]=> + int(2) +} +object(stdClass)#%d (1) { + ["a"]=> + int(1) +} +NULL +1: Maximum stack depth exceeded near location 1:14 +bool(true) +NULL +3: Control character error, possibly incorrectly encoded near location 1:4 +NULL +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 +NULL +10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +bool(true) +bool(true) +ValueError +ValueError diff --git a/ext/json/tests/json_last_error_msg_error_location_comments.phpt b/ext/json/tests/json_last_error_msg_error_location_comments.phpt new file mode 100644 index 000000000000..9d451d0be896 --- /dev/null +++ b/ext/json/tests/json_last_error_msg_error_location_comments.phpt @@ -0,0 +1,40 @@ +--TEST-- +JSON comment and trailing-comma errors preserve original source locations +--FILE-- + +--EXPECT-- +bool(false) +4: Syntax error near location 3:17 +bool(false) +4: Syntax error near location 4:4 +bool(false) +4: Syntax error near location 1:22 +bool(false) +4: Syntax error near location 3:3 +bool(false) +4: Syntax error near location 1:19 diff --git a/ext/json/tests/json_validate_002.phpt b/ext/json/tests/json_validate_002.phpt index 423564c4ad7c..c3b003ea57b3 100644 --- a/ext/json/tests/json_validate_002.phpt +++ b/ext/json/tests/json_validate_002.phpt @@ -36,10 +36,10 @@ string(8) "No error" Error: 0 json_validate(): Argument #2 ($depth) must be greater than 0 int(0) string(8) "No error" -Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE) +Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) int(0) string(8) "No error" -Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE) +Error: 0 json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) int(0) string(8) "No error" bool(false) diff --git a/sapi/fuzzer/fuzzer-json.c b/sapi/fuzzer/fuzzer-json.c index 5029cb9a585d..68f08f0b26aa 100644 --- a/sapi/fuzzer/fuzzer-json.c +++ b/sapi/fuzzer/fuzzer-json.c @@ -36,10 +36,22 @@ int LLVMFuzzerTestOneInput(const uint8_t *Data, size_t Size) { memcpy(data, Data, Size); data[Size] = '\0'; - for (int option = 0; option <=1; ++option) { + static const int options[] = { + 0, + PHP_JSON_OBJECT_AS_ARRAY, + PHP_JSON_ALLOW_COMMENTS, + PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_OBJECT_AS_ARRAY | PHP_JSON_BIGINT_AS_STRING + | PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS | PHP_JSON_INVALID_UTF8_IGNORE, + PHP_JSON_ALLOW_COMMENTS | PHP_JSON_ALLOW_TRAILING_COMMAS | PHP_JSON_INVALID_UTF8_SUBSTITUTE, + }; + + for (size_t i = 0; i < sizeof(options) / sizeof(options[0]); ++i) { zval result; php_json_parser parser; - php_json_parser_init(&parser, &result, data, Size, option, 10); + php_json_parser_init(&parser, &result, data, Size, options[i], 10); if (php_json_yyparse(&parser) == SUCCESS) { zval_ptr_dtor(&result); } From ca0857f0732f3530f64c5603c14a909334908195 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 13:19:15 +0000 Subject: [PATCH 02/12] Print the exception class and message in JSON relaxed-syntax tests Catch Throwable instead of a specific exception class, so that an unexpected exception type shows in the test output instead of escaping the try block. Print $e::class, the code and the message, and also assert the ValueError message for the invalid json_validate() flags. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XaWra1qpfXbgU9JZ4LozYB --- ext/json/tests/json_decode_allow_comments_errors.phpt | 4 ++-- ext/json/tests/json_decode_allow_comments_utf8.phpt | 6 +++--- ext/json/tests/json_decode_allow_trailing_commas.phpt | 6 +++--- .../tests/json_decode_relaxed_syntax_interactions.phpt | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt index c5f50ec6c815..a885ab342f4e 100644 --- a/ext/json/tests/json_decode_allow_comments_errors.phpt +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -32,8 +32,8 @@ decode_error("{\n \"a\": 1,\n /* unterminated\n still open"); try { json_decode("[1,\n /* unterminated", true, 512, JSON_ALLOW_COMMENTS | JSON_THROW_ON_ERROR); -} catch (JsonException $e) { - echo get_class($e), ': ', $e->getCode(), ': ', $e->getMessage(), "\n"; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getCode(), ': ', $e->getMessage(), "\n"; } ?> diff --git a/ext/json/tests/json_decode_allow_comments_utf8.phpt b/ext/json/tests/json_decode_allow_comments_utf8.phpt index c7d78ff56196..73a2762eb88f 100644 --- a/ext/json/tests/json_decode_allow_comments_utf8.phpt +++ b/ext/json/tests/json_decode_allow_comments_utf8.phpt @@ -28,8 +28,8 @@ echo json_last_error(), "\n"; try { json_decode($badLineComment, flags: JSON_ALLOW_COMMENTS | JSON_THROW_ON_ERROR); -} catch (JsonException $e) { - echo $e->getCode(), ': ', $e->getMessage(), "\n"; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getCode(), ': ', $e->getMessage(), "\n"; } ?> @@ -54,4 +54,4 @@ bool(false) 5 bool(true) 0 -5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 +JsonException: 5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 diff --git a/ext/json/tests/json_decode_allow_trailing_commas.phpt b/ext/json/tests/json_decode_allow_trailing_commas.phpt index ce111945791d..83a5550381bc 100644 --- a/ext/json/tests/json_decode_allow_trailing_commas.phpt +++ b/ext/json/tests/json_decode_allow_trailing_commas.phpt @@ -64,8 +64,8 @@ var_dump(json_validate( try { json_decode('[1,,]', true, 512, JSON_ALLOW_TRAILING_COMMAS | JSON_THROW_ON_ERROR); -} catch (JsonException $e) { - echo $e->getCode(), ': ', $e->getMessage(), "\n"; +} catch (Throwable $e) { + echo $e::class, ': ', $e->getCode(), ': ', $e->getMessage(), "\n"; } ?> @@ -93,4 +93,4 @@ bool(false) bool(true) bool(false) bool(false) -4: Syntax error near location 1:4 +JsonException: 4: Syntax error near location 1:4 diff --git a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt index 068cffd20b34..0b7dd196a4af 100644 --- a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt +++ b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt @@ -43,8 +43,8 @@ var_dump(json_validate( foreach ([JSON_BIGINT_AS_STRING, JSON_INVALID_UTF8_SUBSTITUTE] as $invalidFlag) { try { json_validate('{}', flags: RELAXED | $invalidFlag); - } catch (ValueError $e) { - echo get_class($e), "\n"; + } catch (Throwable $e) { + echo $e::class, ': ', $e->getCode(), ': ', $e->getMessage(), "\n"; } } @@ -74,5 +74,5 @@ NULL 10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 bool(true) bool(true) -ValueError -ValueError +ValueError: 0: json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) +ValueError: 0: json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) From d1f8484e220bd516a2456de72b7f175577478034 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:41:11 +0000 Subject: [PATCH 03/12] Do not count a bare CR in JSON comments as a new line Outside comments, the JSON scanner treats a bare CR as whitespace and counts only LF and CRLF as line endings. The comment rules also counted a bare CR as a line ending. As a result, an error location after a bare CR in a comment did not agree with the same input without the comment. A bare CR still ends a line comment, but it no longer starts a new line. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ext/json/json_scanner.re | 14 +++------ ...ast_error_msg_error_location_comments.phpt | 30 +++++++++++++++++-- 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index 6d91975fd5b7..ae9248e4dd5a 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -252,15 +252,14 @@ std: s->errcode = PHP_JSON_ERROR_UTF8; return PHP_JSON_T_ERROR; } - "\r\n" { + NL { s->line++; s->line_start = s->cursor; PHP_JSON_CONDITION_SET(JS); goto std; } - [\r\n] { - s->line++; - s->line_start = s->cursor; + "\r" { + /* A bare CR ends the comment but is whitespace, as in the JS condition. */ PHP_JSON_CONDITION_SET(JS); goto std; } @@ -285,12 +284,7 @@ std: PHP_JSON_CONDITION_SET(JS); goto std; } - "\r\n" { - s->line++; - s->line_start = s->cursor; - PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); - } - [\r\n] { + NL { s->line++; s->line_start = s->cursor; PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); diff --git a/ext/json/tests/json_last_error_msg_error_location_comments.phpt b/ext/json/tests/json_last_error_msg_error_location_comments.phpt index 9d451d0be896..c74642f07a82 100644 --- a/ext/json/tests/json_last_error_msg_error_location_comments.phpt +++ b/ext/json/tests/json_last_error_msg_error_location_comments.phpt @@ -11,8 +11,20 @@ function validate_error(string $json, int $flags): void { /* Newlines within skipped block comments still advance source locations. */ validate_error("{\n/* line 2\nline 3 */ \"key\" 1\n}", JSON_ALLOW_COMMENTS); -/* CR, LF, and CRLF each count as one line ending. */ -validate_error("// CR\r// LF\n// CRLF\r\n[1 2]", JSON_ALLOW_COMMENTS); +/* A bare CR ends a line comment, but only LF and CRLF start a new line. */ +validate_error("// CR\r[1 // LF\n// CRLF\r\n 2]", JSON_ALLOW_COMMENTS); + +/* Line endings inside comments count the same as outside comments. */ +foreach ([ + "[1, \r 2 3]", + "[1,// \r 2 3]", + "[1,/*\r*/ 2 3]", + "[1, \r\n 2 3]", + "[1,//\r\n 2 3]", + "[1,/*\r\n*/ 2 3]", +] as $json) { + validate_error($json, JSON_ALLOW_COMMENTS); +} /* Columns count Unicode characters, not UTF-8 bytes. */ validate_error('[1 /* héllo wörld */ 2]', JSON_ALLOW_COMMENTS); @@ -31,7 +43,19 @@ validate_error( bool(false) 4: Syntax error near location 3:17 bool(false) -4: Syntax error near location 4:4 +4: Syntax error near location 3:2 +bool(false) +4: Syntax error near location 1:12 +bool(false) +4: Syntax error near location 1:12 +bool(false) +4: Syntax error near location 1:12 +bool(false) +4: Syntax error near location 2:6 +bool(false) +4: Syntax error near location 2:6 +bool(false) +4: Syntax error near location 2:6 bool(false) 4: Syntax error near location 1:22 bool(false) From 0e2d7ed2a76085604bbe393ee299e541ad92acd7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:42:06 +0000 Subject: [PATCH 04/12] Reject NUL bytes in JSON comments Outside comments and inside strings, an embedded NUL byte is a control character error. The comment rules skipped it as comment text. Some other parsers end a comment at a NUL byte, so the same input decoded to different values in PHP and in those parsers. An embedded NUL in a line or block comment now gives JSON_ERROR_CTRL_CHAR at the NUL byte. Other control characters stay valid comment text. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ext/json/json_scanner.re | 8 +++- .../tests/json_decode_allow_comments.phpt | 7 ++-- .../json_decode_allow_comments_errors.phpt | 39 +++++++++++++++++++ 3 files changed, 49 insertions(+), 5 deletions(-) diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index ae9248e4dd5a..07054f09a5a4 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -269,7 +269,9 @@ std: PHP_JSON_CONDITION_SET(JS); return PHP_JSON_T_EOI; } - PHP_JSON_CONDITION_GOTO(COMMENT_LINE); + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_CTRL_CHAR; + return PHP_JSON_T_ERROR; } UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_LINE); } ANY { @@ -297,7 +299,9 @@ std: s->errcode = PHP_JSON_ERROR_SYNTAX; return PHP_JSON_T_ERROR; } - PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); + s->token = s->cursor - 1; + s->errcode = PHP_JSON_ERROR_CTRL_CHAR; + return PHP_JSON_T_ERROR; } UTF8 { PHP_JSON_CONDITION_GOTO(COMMENT_BLOCK); } ANY { diff --git a/ext/json/tests/json_decode_allow_comments.phpt b/ext/json/tests/json_decode_allow_comments.phpt index a613ac2888cc..c8af38e03c5b 100644 --- a/ext/json/tests/json_decode_allow_comments.phpt +++ b/ext/json/tests/json_decode_allow_comments.phpt @@ -31,13 +31,14 @@ decode_and_validate("// CR\r2"); decode_and_validate("// CRLF\r\n3"); decode_and_validate("4// EOF"); decode_and_validate("// U+2028 \u{2028} and U+2029 \u{2029}\n6"); -decode_and_validate("// embedded NUL: a\x00b\n7"); +decode_and_validate("// controls: a\x01\x0cb\n7"); /* Comment delimiters inside strings are ordinary string content. */ decode_and_validate('["//", "/* not a comment */", "https://php.net/a/*b*/"]'); -/* Valid Unicode, Unicode line separators, controls, and NUL are comment text. */ -decode_and_validate("/* héllo \u{2028} world \u{2029} \x01\x0c\x00 */ 5"); +/* Valid Unicode, Unicode line separators, and controls other than NUL are + * comment text. */ +decode_and_validate("/* héllo \u{2028} world \u{2029} \x01\x0c */ 5"); decode_and_validate('/* ' . str_repeat('x', 65536) . ' */ 8'); ?> diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt index a885ab342f4e..687bff2ab231 100644 --- a/ext/json/tests/json_decode_allow_comments_errors.phpt +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -29,6 +29,21 @@ decode_error('/* outer /* inner */ */ 1'); decode_error('/* unterminated'); decode_error("{\n \"a\": 1,\n /* unterminated\n still open"); +/* An embedded NUL is a control character error, also inside comments. */ +foreach ([ + "[1, // a\x00b\n 2]", + "[1, /* a\x00b */ 2]", + "[1, /* x\n a\x00b */ 2]", + "[1, /* a\x00b", + "[1, // a\x00", +] as $json) { + decode_error($json); + var_dump(json_validate($json, 512, JSON_ALLOW_COMMENTS)); + echo json_last_error(), ": ", json_last_error_msg(), "\n"; +} +decode_error("[1, // a\x00b\n 2]", JSON_ALLOW_COMMENTS | JSON_INVALID_UTF8_IGNORE); +decode_error("[1, /* a\x00b */ 2]", JSON_ALLOW_COMMENTS | JSON_INVALID_UTF8_SUBSTITUTE); + try { json_decode("[1,\n /* unterminated", true, 512, JSON_ALLOW_COMMENTS | JSON_THROW_ON_ERROR); @@ -64,4 +79,28 @@ NULL 4: Syntax error near location 1:1 NULL 4: Syntax error near location 3:3 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:9 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:9 +NULL +3: Control character error, possibly incorrectly encoded near location 2:3 +bool(false) +3: Control character error, possibly incorrectly encoded near location 2:3 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:9 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:9 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 +NULL +3: Control character error, possibly incorrectly encoded near location 1:9 JsonException: 4: Syntax error near location 2:2 From 386f236593eeccead4fdc62ec7ee0caf3fdd4a94 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:54:32 +0000 Subject: [PATCH 05/12] Test that strict JSON parsing keeps scanner error codes after a comma The trailing-comma rules add a default reduction after a comma. Without JSON_ALLOW_TRAILING_COMMAS, this reduction also runs when the scanner returns an error, and only the errcode check in its action keeps the scanner's error code. The test ran only with the relaxed flags, so it did not run this check. Run the cases also without flags, add an object case, and check json_validate() too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ...on_decode_relaxed_syntax_interactions.phpt | 48 +++++++++++++++---- 1 file changed, 40 insertions(+), 8 deletions(-) diff --git a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt index 0b7dd196a4af..89129c8e6a9f 100644 --- a/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt +++ b/ext/json/tests/json_decode_relaxed_syntax_interactions.phpt @@ -23,14 +23,20 @@ var_dump(json_decode($nested, true, 2, RELAXED)); echo json_last_error(), ': ', json_last_error_msg(), "\n"; var_dump(json_validate($nested, 3, RELAXED)); -/* A trailing-comma reduction must not replace a more specific scanner error. */ -foreach ([ - "[1,\x01]", - "[1,\x80]", - '[1,"\\ud834"]', -] as $json) { - var_dump(json_decode($json, true, 512, RELAXED)); - echo json_last_error(), ': ', json_last_error_msg(), "\n"; +/* A trailing-comma reduction must not replace a more specific scanner error. + * Without the flag, the reduction also runs when the scanner reports an error. */ +foreach ([0, RELAXED] as $flags) { + foreach ([ + "[1,\x01]", + "[1,\x80]", + '[1,"\\ud834"]', + "{\"a\":1,\x01}", + ] as $json) { + var_dump(json_decode($json, true, 512, $flags)); + echo json_last_error(), ': ', json_last_error_msg(), "\n"; + var_dump(json_validate($json, 512, $flags)); + echo json_last_error(), ': ', json_last_error_msg(), "\n"; + } } /* json_validate() accepts only its documented combinations. */ @@ -68,10 +74,36 @@ NULL bool(true) NULL 3: Control character error, possibly incorrectly encoded near location 1:4 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:4 NULL 5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 +bool(false) +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 NULL 10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +bool(false) +10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +NULL +3: Control character error, possibly incorrectly encoded near location 1:8 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:8 +NULL +3: Control character error, possibly incorrectly encoded near location 1:4 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:4 +NULL +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 +bool(false) +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:4 +NULL +10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +bool(false) +10: Single unpaired UTF-16 surrogate in unicode escape near location 1:4 +NULL +3: Control character error, possibly incorrectly encoded near location 1:8 +bool(false) +3: Control character error, possibly incorrectly encoded near location 1:8 bool(true) bool(true) ValueError: 0: json_validate(): Argument #3 ($flags) must be a valid flag (allowed flags: JSON_INVALID_UTF8_IGNORE, JSON_ALLOW_COMMENTS, JSON_ALLOW_TRAILING_COMMAS) From c87797f04563e117d71673593afe0ca04ffd5e61 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:55:45 +0000 Subject: [PATCH 06/12] Test invalid UTF-8 only inside JSON comments The block comment case had the invalid byte in the comment and also in the string. Without flags, the string also gave error 5, and the test did not print the location. Thus the test did not show that the comment itself reports the error. A block comment rule that always skipped invalid bytes also passed the test. The line comment case did not test JSON_INVALID_UTF8_SUBSTITUTE or json_validate(). Put the invalid byte only in the comment, run the same checks for both comment types, and print the error message so that the location is also checked. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- .../json_decode_allow_comments_utf8.phpt | 61 +++++++++++-------- 1 file changed, 36 insertions(+), 25 deletions(-) diff --git a/ext/json/tests/json_decode_allow_comments_utf8.phpt b/ext/json/tests/json_decode_allow_comments_utf8.phpt index 73a2762eb88f..8bcd13157df8 100644 --- a/ext/json/tests/json_decode_allow_comments_utf8.phpt +++ b/ext/json/tests/json_decode_allow_comments_utf8.phpt @@ -5,26 +5,25 @@ JSON_ALLOW_COMMENTS interactions with malformed UTF-8 flags function show_decode(string $json, int $flags): void { var_dump(json_decode($json, true, 512, JSON_ALLOW_COMMENTS | $flags)); - echo json_last_error(), "\n"; + echo json_last_error(), ': ', json_last_error_msg(), "\n"; } -$badBlockComment = "/* bad: \x80 */ [\"a\x80b\"]"; -$badLineComment = "// bad: \x80\n1"; +function show_validate(string $json, int $flags): void { + var_dump(json_validate($json, 512, JSON_ALLOW_COMMENTS | $flags)); + echo json_last_error(), ': ', json_last_error_msg(), "\n"; +} -show_decode($badBlockComment, 0); -show_decode($badBlockComment, JSON_INVALID_UTF8_IGNORE); -show_decode($badBlockComment, JSON_INVALID_UTF8_SUBSTITUTE); -show_decode($badLineComment, 0); -show_decode($badLineComment, JSON_INVALID_UTF8_IGNORE); +/* The invalid byte is only in the comment, so each result comes from the comment. */ +$badBlockComment = "/* bad: \x80 */ [\"ab\"]"; +$badLineComment = "// bad: \x80\n[\"ab\"]"; -var_dump(json_validate($badBlockComment, 512, JSON_ALLOW_COMMENTS)); -echo json_last_error(), "\n"; -var_dump(json_validate( - $badBlockComment, - 512, - JSON_ALLOW_COMMENTS | JSON_INVALID_UTF8_IGNORE -)); -echo json_last_error(), "\n"; +foreach ([$badBlockComment, $badLineComment] as $json) { + show_decode($json, 0); + show_decode($json, JSON_INVALID_UTF8_IGNORE); + show_decode($json, JSON_INVALID_UTF8_SUBSTITUTE); + show_validate($json, 0); + show_validate($json, JSON_INVALID_UTF8_IGNORE); +} try { json_decode($badLineComment, flags: JSON_ALLOW_COMMENTS | JSON_THROW_ON_ERROR); @@ -35,23 +34,35 @@ try { ?> --EXPECT-- NULL -5 +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 array(1) { [0]=> string(2) "ab" } -0 +0: No error array(1) { [0]=> - string(5) "a�b" + string(2) "ab" } -0 +0: No error +bool(false) +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 +bool(true) +0: No error NULL -5 -int(1) -0 +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 +array(1) { + [0]=> + string(2) "ab" +} +0: No error +array(1) { + [0]=> + string(2) "ab" +} +0: No error bool(false) -5 +5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 bool(true) -0 +0: No error JsonException: 5: Malformed UTF-8 characters, possibly incorrectly encoded near location 1:9 From a4f9abd242ae1f61d160e8918ed1e18cb7c0cf85 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:55:57 +0000 Subject: [PATCH 07/12] Use real newlines in JSON comment error tests Two cases used '\n' in single quotes, so the input contained a backslash and an "n" instead of a newline. Use double quotes so that each case tests the input that its comment describes. The expected output does not change. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ext/json/tests/json_decode_allow_comments_errors.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt index 687bff2ab231..3360a8dfc068 100644 --- a/ext/json/tests/json_decode_allow_comments_errors.phpt +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -9,13 +9,13 @@ function decode_error(string $json, int $flags = JSON_ALLOW_COMMENTS): void { } /* Strict parsing is unchanged. */ -decode_error('// comment\n1', 0); +decode_error("// comment\n1", 0); decode_error('/* comment */ 1', 0); /* Comments are trivia, not values or token fragments. */ decode_error('// comment only'); decode_error('/* comment only */'); -decode_error('# comment\n1'); +decode_error("# comment\n1"); decode_error('/'); decode_error('tr/* comment */ue'); decode_error('1e/* comment */2'); From 3c00e266a7580f3fded5b5af1cd1083cf852b9a8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 17:56:47 +0000 Subject: [PATCH 08/12] Add edge-case tests for JSON comment delimiters Add regression tests for comment delimiters that touch: an empty block comment, an extra '*' before the closer, and a '/' right after the opener. Also test a comment directly after a float and an exponent, and a comment inside a number. All cases already behave correctly. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- .../tests/json_decode_allow_comments.phpt | 19 +++++++++++++++++++ .../json_decode_allow_comments_errors.phpt | 6 ++++++ 2 files changed, 25 insertions(+) diff --git a/ext/json/tests/json_decode_allow_comments.phpt b/ext/json/tests/json_decode_allow_comments.phpt index c8af38e03c5b..065b057f9138 100644 --- a/ext/json/tests/json_decode_allow_comments.phpt +++ b/ext/json/tests/json_decode_allow_comments.phpt @@ -33,6 +33,15 @@ decode_and_validate("4// EOF"); decode_and_validate("// U+2028 \u{2028} and U+2029 \u{2029}\n6"); decode_and_validate("// controls: a\x01\x0cb\n7"); +/* Delimiters can touch. The '*' of an opener does not also close the comment. */ +decode_and_validate('/**/1'); +decode_and_validate('/***/1'); +decode_and_validate('/*/ */1'); + +/* A comment can directly follow a float or an exponent. */ +decode_and_validate('1.5/*x*/'); +decode_and_validate('1e5//x'); + /* Comment delimiters inside strings are ordinary string content. */ decode_and_validate('["//", "/* not a comment */", "https://php.net/a/*b*/"]'); @@ -63,6 +72,16 @@ bool(true) bool(true) 7 bool(true) +1 +bool(true) +1 +bool(true) +1 +bool(true) +1.5 +bool(true) +100000 +bool(true) ["//","/* not a comment */","https://php.net/a/*b*/"] bool(true) 5 diff --git a/ext/json/tests/json_decode_allow_comments_errors.phpt b/ext/json/tests/json_decode_allow_comments_errors.phpt index 3360a8dfc068..557f94cb1889 100644 --- a/ext/json/tests/json_decode_allow_comments_errors.phpt +++ b/ext/json/tests/json_decode_allow_comments_errors.phpt @@ -19,6 +19,7 @@ decode_error("# comment\n1"); decode_error('/'); decode_error('tr/* comment */ue'); decode_error('1e/* comment */2'); +decode_error('1./**/5'); decode_error('-/* comment */1'); decode_error('{"a" /* missing colon */ 1}'); @@ -27,6 +28,7 @@ decode_error('/* outer /* inner */ */ 1'); /* Unterminated block comments report their opening delimiter. */ decode_error('/* unterminated'); +decode_error('/*/1'); decode_error("{\n \"a\": 1,\n /* unterminated\n still open"); /* An embedded NUL is a control character error, also inside comments. */ @@ -70,6 +72,8 @@ NULL NULL 4: Syntax error near location 1:2 NULL +4: Syntax error near location 1:2 +NULL 4: Syntax error near location 1:1 NULL 4: Syntax error near location 1:26 @@ -78,6 +82,8 @@ NULL NULL 4: Syntax error near location 1:1 NULL +4: Syntax error near location 1:1 +NULL 4: Syntax error near location 3:3 NULL 3: Control character error, possibly incorrectly encoded near location 1:9 From ca4bd6d2361bed36711313fadff30c7f9bea7187 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:18:54 +0000 Subject: [PATCH 09/12] Use one grammar rule for JSON trailing commas The "member ','" and "element ','" rules had the same action. When trailing commas were not allowed, each action freed the value by hand before YYERROR, because bison does not run %destructor for the symbols of the rule whose action calls YYERROR. Move the comma into its own trailing_comma rule, as object_end and array_end already do. YYERROR then removes only the comma, and the %destructor frees the member or element value. php_json_yyerror() keeps an error code that the scanner already set. There is no change in behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ext/json/json_parser.y | 36 ++++++++++++++---------------------- 1 file changed, 14 insertions(+), 22 deletions(-) diff --git a/ext/json/json_parser.y b/ext/json/json_parser.y index 742f7141042f..50b5c71b4c72 100644 --- a/ext/json/json_parser.y +++ b/ext/json/json_parser.y @@ -119,17 +119,7 @@ members: } } | member - | member ',' - { - if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { - if (!parser->scanner.errcode) { - parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; - } - zval_ptr_dtor_nogc(&$1); - YYERROR; - } - ZVAL_COPY_VALUE(&$$, &$1); - } + | member trailing_comma ; member: @@ -186,17 +176,7 @@ elements: } } | element - | element ',' - { - if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { - if (!parser->scanner.errcode) { - parser->scanner.errcode = PHP_JSON_ERROR_SYNTAX; - } - zval_ptr_dtor_nogc(&$1); - YYERROR; - } - ZVAL_COPY_VALUE(&$$, &$1); - } + | element trailing_comma ; element: @@ -212,6 +192,18 @@ element: } ; +trailing_comma: + ',' + { + if (!(parser->scanner.options & PHP_JSON_ALLOW_TRAILING_COMMAS)) { + /* This rule also reduces on an error token from the scanner, + * so keep the scanner's error code. */ + php_json_yyerror(parser, NULL); + YYERROR; + } + } +; + key: PHP_JSON_T_STRING | PHP_JSON_T_ESTRING From 9198a0c2ef2e002f3e071c5972fac0ae0b7185bf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:20:04 +0000 Subject: [PATCH 10/12] Remove an unused copy of the block comment opener in the JSON scanner While the scanner is in COMMENT_BLOCK, the rules jump to the condition label and do not pass the std label, so s->token keeps the position of the "/*" opener. The comment_token copy and its restore had no effect. Keep the line and line start copies, because the line ending rules change them. Correct the code comment, which said that comments are always fully consumed before the function returns. There is no change in behavior. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- ext/json/json_scanner.re | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/ext/json/json_scanner.re b/ext/json/json_scanner.re index 07054f09a5a4..7681fb9ed771 100644 --- a/ext/json/json_scanner.re +++ b/ext/json/json_scanner.re @@ -102,8 +102,8 @@ void php_json_scanner_init(php_json_scanner *s, const char *str, size_t str_len, int php_json_scan(php_json_scanner *s) { - /* Comments are fully consumed before returning, so their opener location can remain local. */ - php_json_ctype *comment_token = s->cursor; + /* Line of the last block comment opener, for an unterminated comment. + * s->token keeps the opener position while in COMMENT_BLOCK. */ php_json_ctype *comment_line_start = s->line_start; uint64_t comment_line = s->line; @@ -220,7 +220,6 @@ std: s->errcode = PHP_JSON_ERROR_SYNTAX; return PHP_JSON_T_ERROR; } - comment_token = s->token; comment_line_start = s->line_start; comment_line = s->line; PHP_JSON_CONDITION_SET_AND_GOTO(COMMENT_BLOCK); @@ -293,7 +292,6 @@ std: } EOI { if (s->limit < s->cursor) { - s->token = comment_token; s->line_start = comment_line_start; s->line = comment_line; s->errcode = PHP_JSON_ERROR_SYNTAX; From 184b2d5fd491255e9f188cf86610b8c62087c88e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:20:44 +0000 Subject: [PATCH 11/12] Add JSON comment and trailing comma tokens to the fuzzer dictionary The dictionary had "//" and "/**/", but not the block comment delimiters alone, a trailing comma before a closer, or a bare CR, which ends a line comment. Add "/*", "*/", ",]", ",}" and "\x0d". libFuzzer only accepts \\, \" and \xNN escapes in a dictionary, so the CR is written as "\x0d". Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- sapi/fuzzer/json.dict | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/sapi/fuzzer/json.dict b/sapi/fuzzer/json.dict index e08245a2194f..adfd4dd4407b 100644 --- a/sapi/fuzzer/json.dict +++ b/sapi/fuzzer/json.dict @@ -50,3 +50,8 @@ "\"\":0" "//" "/**/" +"/*" +"*/" +",]" +",}" +"\x0d" From a4cd163ec4b67e13a0f7e283bc9abda988a138c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:21:13 +0000 Subject: [PATCH 12/12] Add UPGRADING notes for JSON comments and trailing commas Describe the JSON_ALLOW_COMMENTS and JSON_ALLOW_TRAILING_COMMAS flags under New Features, the new json_decode() and json_validate() flags under Changed Functions, and the two constants under New Global Constants. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PCee9RRsbzYcC4ovMxhU6H --- UPGRADING | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/UPGRADING b/UPGRADING index 46cf4cafab12..2071afdd4278 100644 --- a/UPGRADING +++ b/UPGRADING @@ -44,6 +44,16 @@ PHP 8.7 UPGRADE NOTES A ValueError is thrown if the array is empty or contains an invalid locale, and a TypeError if it contains a non-string value. +- JSON: + . json_decode() and json_validate() can now accept comments and trailing + commas. JSON_ALLOW_COMMENTS accepts "//" line comments and "/* */" + block comments wherever whitespace is allowed. Block comments do not + nest. A line comment ends at LF, CR, CRLF, or the end of the input. + A NUL byte in a comment is a control character error. + JSON_ALLOW_TRAILING_COMMAS accepts one comma after the last element of + a non-empty array or the last member of a non-empty object. Without + these flags, parsing does not change. + - Streams: . The so_linger, so_rcvbuf and so_sndbuf stream socket context options are now also applied to unix and unix datagram sockets. @@ -60,6 +70,13 @@ PHP 8.7 UPGRADE NOTES 5. Changed Functions ======================================== +- JSON: + . json_decode() now accepts the JSON_ALLOW_COMMENTS and + JSON_ALLOW_TRAILING_COMMAS flags. + . json_validate() now accepts the JSON_ALLOW_COMMENTS and + JSON_ALLOW_TRAILING_COMMAS flags, in addition to + JSON_INVALID_UTF8_IGNORE. + ======================================== 6. New Functions ======================================== @@ -80,6 +97,10 @@ PHP 8.7 UPGRADE NOTES 10. New Global Constants ======================================== +- JSON: + . JSON_ALLOW_COMMENTS. + . JSON_ALLOW_TRAILING_COMMAS. + ======================================== 11. Changes to INI File Handling ========================================