From 18fe1fec455c288b96c00dc9c64ff0f9395363c3 Mon Sep 17 00:00:00 2001 From: Alexander Danilov Date: Sun, 13 Sep 2026 16:36:24 +0300 Subject: [PATCH 1/3] json: replace packed charmap bitmap with a byte-indexed lookup table --- ext/json/json.c | 2 + ext/json/json_encoder.c | 18 +++-- ext/json/php_json_encoder.h | 2 + .../json_encode_byte_classification.phpt | 68 +++++++++++++++++++ 4 files changed, 85 insertions(+), 5 deletions(-) create mode 100644 ext/json/tests/json_encode_byte_classification.phpt diff --git a/ext/json/json.c b/ext/json/json.c index 04a62f52152f..2fe2b054a130 100644 --- a/ext/json/json.c +++ b/ext/json/json.c @@ -49,6 +49,8 @@ static PHP_MINIT_FUNCTION(json) register_json_symbols(module_number); + php_json_escape_dirty_table_init(); + return SUCCESS; } /* }}} */ diff --git a/ext/json/json_encoder.c b/ext/json/json_encoder.c index b8ae31040c8b..70d176fd6339 100644 --- a/ext/json/json_encoder.c +++ b/ext/json/json_encoder.c @@ -30,6 +30,18 @@ static const char digits[] = "0123456789abcdef"; +static uint8_t php_json_escape_dirty_table[256]; + +void php_json_escape_dirty_table_init(void) +{ + for (int b = 0; b < 256; b++) { + php_json_escape_dirty_table[b] = + b < 0x20 || b >= 0x80 + || b == '"' || b == '\\' || b == '/' + || b == '<' || b == '>' || b == '&' || b == '\''; + } +} + static zend_always_inline bool php_json_check_stack_limit(void) { #ifdef ZEND_CHECK_STACK_LIMIT @@ -384,12 +396,8 @@ zend_result php_json_escape_string( pos = 0; do { - static const uint32_t charmap[8] = { - 0xffffffff, 0x500080c4, 0x10000000, 0x00000000, - 0xffffffff, 0xffffffff, 0xffffffff, 0xffffffff}; - unsigned int us = (unsigned char)s[pos]; - if (EXPECTED(!ZEND_BIT_TEST(charmap, us))) { + if (EXPECTED(!php_json_escape_dirty_table[us])) { pos++; len--; if (len == 0) { diff --git a/ext/json/php_json_encoder.h b/ext/json/php_json_encoder.h index 30cc1d564fa5..18a582283040 100644 --- a/ext/json/php_json_encoder.h +++ b/ext/json/php_json_encoder.h @@ -35,4 +35,6 @@ zend_result php_json_encode_zval(smart_str *buf, zval *val, int options, php_jso zend_result php_json_escape_string(smart_str *buf, const char *s, size_t len, int options, php_json_encoder *encoder); +void php_json_escape_dirty_table_init(void); + #endif /* PHP_JSON_ENCODER_H */ diff --git a/ext/json/tests/json_encode_byte_classification.phpt b/ext/json/tests/json_encode_byte_classification.phpt new file mode 100644 index 000000000000..52930c7e4ac7 --- /dev/null +++ b/ext/json/tests/json_encode_byte_classification.phpt @@ -0,0 +1,68 @@ +--TEST-- +json_encode() escapes every byte value 0x00-0xFF correctly +--FILE-- +'; + case 0x26: return ($options & JSON_HEX_AMP) ? "\\u0026" : '&'; + case 0x27: return ($options & JSON_HEX_APOS) ? "\\u0027" : "'"; + } + if ($b < 0x20) { + return sprintf('\u%04x', $b); + } + return chr($b); +} + +function check_ascii_range(int $options): void { + for ($b = 0x00; $b < 0x80; $b++) { + $expected = '"' . expected_escape($b, $options) . '"'; + $actual = json_encode(chr($b), $options); + if ($actual !== $expected) { + printf("MISMATCH (options=%d) at byte 0x%02x: expected %s got %s\n", + $options, $b, var_export($expected, true), var_export($actual, true)); + } + } +} + +function check_lone_high_bytes(): void { + /* A single byte >= 0x80 is never valid UTF-8 on its own -- every one + * of these must be rejected as invalid UTF-8, not silently passed + * through unescaped. */ + for ($b = 0x80; $b <= 0xff; $b++) { + $actual = json_encode(chr($b)); + if ($actual !== false) { + printf("MISMATCH at byte 0x%02x: expected false (invalid UTF-8) got %s\n", + $b, var_export($actual, true)); + } + if (json_last_error() !== JSON_ERROR_UTF8) { + printf("MISMATCH at byte 0x%02x: expected JSON_ERROR_UTF8, got error code %d\n", + $b, json_last_error()); + } + } +} + +check_ascii_range(0); +check_ascii_range(JSON_UNESCAPED_SLASHES); +check_ascii_range(JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS); +check_ascii_range(JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_UNESCAPED_SLASHES); +check_lone_high_bytes(); + +echo "Done\n"; +?> +--EXPECT-- +Done From 71446a2b82d3ee7bd7de7ebd236ddb3175832728 Mon Sep 17 00:00:00 2001 From: Alexander Danilov Date: Mon, 14 Sep 2026 20:21:13 +0300 Subject: [PATCH 2/3] static table + php script for generation --- ext/json/gen_json_escape_table.php | 50 ++++++++++++++++++++++++++++++ ext/json/json.c | 2 -- ext/json/json_encoder.c | 13 +------- ext/json/php_json_encoder.h | 2 -- ext/json/php_json_escape_table.h | 27 ++++++++++++++++ 5 files changed, 78 insertions(+), 16 deletions(-) create mode 100644 ext/json/gen_json_escape_table.php create mode 100644 ext/json/php_json_escape_table.h diff --git a/ext/json/gen_json_escape_table.php b/ext/json/gen_json_escape_table.php new file mode 100644 index 000000000000..55de918b2fd7 --- /dev/null +++ b/ext/json/gen_json_escape_table.php @@ -0,0 +1,50 @@ +#!/usr/bin/env php += 0x80, which need + // UTF-8 decoding), and the ASCII specials " \ / < > & '. + return $b < 0x20 || $b >= 0x80 + || $b === 0x22 // " + || $b === 0x5c // backslash + || $b === 0x2f // / + || $b === 0x3c // < + || $b === 0x3e // > + || $b === 0x26 // & + || $b === 0x27; // ' +} + +$result = <<<'HEADER' +/* This file was generated by ext/json/gen_json_escape_table.php. + * + * DO NOT EDIT THIS FILE! + * + * Classifies which bytes php_json_escape_string() must escape: control + * characters (< 0x20), non-ASCII bytes (>= 0x80, which need UTF-8 + * decoding), and the ASCII specials " \ / < > & '. + */ + +static const bool php_json_escape_dirty_table[256] = { + +HEADER; + +for ($row = 0; $row < 16; $row++) { + $values = []; + for ($col = 0; $col < 16; $col++) { + $b = $row * 16 + $col; + $values[] = is_dirty($b) ? '1' : '0'; + } + $result .= "\t" . implode(', ', $values) . ",\n"; +} + +$result .= "};\n"; + +file_put_contents(__DIR__ . '/php_json_escape_table.h', $result); +echo "Generated php_json_escape_table.h\n"; diff --git a/ext/json/json.c b/ext/json/json.c index 2fe2b054a130..04a62f52152f 100644 --- a/ext/json/json.c +++ b/ext/json/json.c @@ -49,8 +49,6 @@ static PHP_MINIT_FUNCTION(json) register_json_symbols(module_number); - php_json_escape_dirty_table_init(); - return SUCCESS; } /* }}} */ diff --git a/ext/json/json_encoder.c b/ext/json/json_encoder.c index 70d176fd6339..f53a21e92597 100644 --- a/ext/json/json_encoder.c +++ b/ext/json/json_encoder.c @@ -22,6 +22,7 @@ #include "zend_smart_str.h" #include "php_json.h" #include "php_json_encoder.h" +#include "php_json_escape_table.h" #include "zend_portability.h" #include #include "zend_enum.h" @@ -30,18 +31,6 @@ static const char digits[] = "0123456789abcdef"; -static uint8_t php_json_escape_dirty_table[256]; - -void php_json_escape_dirty_table_init(void) -{ - for (int b = 0; b < 256; b++) { - php_json_escape_dirty_table[b] = - b < 0x20 || b >= 0x80 - || b == '"' || b == '\\' || b == '/' - || b == '<' || b == '>' || b == '&' || b == '\''; - } -} - static zend_always_inline bool php_json_check_stack_limit(void) { #ifdef ZEND_CHECK_STACK_LIMIT diff --git a/ext/json/php_json_encoder.h b/ext/json/php_json_encoder.h index 18a582283040..30cc1d564fa5 100644 --- a/ext/json/php_json_encoder.h +++ b/ext/json/php_json_encoder.h @@ -35,6 +35,4 @@ zend_result php_json_encode_zval(smart_str *buf, zval *val, int options, php_jso zend_result php_json_escape_string(smart_str *buf, const char *s, size_t len, int options, php_json_encoder *encoder); -void php_json_escape_dirty_table_init(void); - #endif /* PHP_JSON_ENCODER_H */ diff --git a/ext/json/php_json_escape_table.h b/ext/json/php_json_escape_table.h new file mode 100644 index 000000000000..cdb83727f295 --- /dev/null +++ b/ext/json/php_json_escape_table.h @@ -0,0 +1,27 @@ +/* This file was generated by ext/json/gen_json_escape_table.php. + * + * DO NOT EDIT THIS FILE! + * + * Classifies which bytes php_json_escape_string() must escape: control + * characters (< 0x20), non-ASCII bytes (>= 0x80, which need UTF-8 + * decoding), and the ASCII specials " \ / < > & '. + */ + +static const bool php_json_escape_dirty_table[256] = { + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 0, 0, 1, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 1, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, + 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, +}; From 5114f86722151defa4a4733d543786f864119d66 Mon Sep 17 00:00:00 2001 From: Alexander Danilov Date: Mon, 14 Sep 2026 21:53:09 +0300 Subject: [PATCH 3/3] script generation check - CI and makefile added --- .github/actions/verify-generated-files/action.yml | 1 + ext/json/Makefile.frag | 5 +++++ ext/json/gen_json_escape_table.php | 0 3 files changed, 6 insertions(+) mode change 100644 => 100755 ext/json/gen_json_escape_table.php diff --git a/.github/actions/verify-generated-files/action.yml b/.github/actions/verify-generated-files/action.yml index 79c49dbfcfff..44a315a5ee15 100644 --- a/.github/actions/verify-generated-files/action.yml +++ b/.github/actions/verify-generated-files/action.yml @@ -11,6 +11,7 @@ runs: scripts/gdb/debug_gdb_scripts_gen.php Zend/zend_vm_gen.php ext/tokenizer/tokenizer_data_gen.php + ext/json/gen_json_escape_table.php build/gen_stub.php -f --generate-optimizer-info --verify ext/phar/makestub.php .github/scripts/test-directory-unchanged.sh . diff --git a/ext/json/Makefile.frag b/ext/json/Makefile.frag index 683709aa3ef3..4f4ac9d4b71b 100644 --- a/ext/json/Makefile.frag +++ b/ext/json/Makefile.frag @@ -3,3 +3,8 @@ $(srcdir)/json_scanner.c $(srcdir)/php_json_scanner_defs.h: $(srcdir)/json_scann $(srcdir)/json_parser.tab.c $(srcdir)/json_parser.tab.h: $(srcdir)/json_parser.y @$(YACC) $(YFLAGS) --defines -l $(srcdir)/json_parser.y -o $(srcdir)/json_parser.tab.c + +$(srcdir)/php_json_escape_table.h: $(srcdir)/gen_json_escape_table.php + @if test ! -z "$(PHP)"; then \ + $(PHP) $(srcdir)/gen_json_escape_table.php; \ + fi; diff --git a/ext/json/gen_json_escape_table.php b/ext/json/gen_json_escape_table.php old mode 100644 new mode 100755