From 185d68a9cfa56c344cbe73b232c726947a1a441c Mon Sep 17 00:00:00 2001 From: O6lvl4 Date: Mon, 28 Sep 2026 08:59:20 +0900 Subject: [PATCH] Extend C-095 to the RFC 8259 control-character escape rule and pin it with a json_stringify_control_chars fixture Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/contracts/README.md | 2 +- docs/contracts/conformance.md | 4 +-- docs/contracts/contracts.toml | 3 +- ref/src/value.rs | 15 ++++++-- .../json_stringify_control_chars.almd | 36 +++++++++++++++++++ 5 files changed, 54 insertions(+), 6 deletions(-) create mode 100644 spec/wasm_cross/json_stringify_control_chars.almd diff --git a/docs/contracts/README.md b/docs/contracts/README.md index 33ca3b00..1532d209 100644 --- a/docs/contracts/README.md +++ b/docs/contracts/README.md @@ -129,7 +129,7 @@ contemporaneous 156, **retroactive 132** (shrink-only ceiling 132), unmeasured 1 | C-092 | A generic record field is sized by its instantiated type at construction | 0.27.6 | active | fixture | 3 | | C-093 | Mutually-recursive variant types compile on both targets | 0.27.6 | active | fixture | 1 | | C-094 | A protocol-method UFCS call on an inferred lambda param resolves the element type | 0.27.6 | active | fixture | 1 | -| C-095 | json.stringify_pretty is byte-identical indented output across targets | 0.27.6 | active | fixture | 1 | +| C-095 | json.stringify_pretty is byte-identical indented output across targets | 0.27.6 | active | fixture | 2 | | C-096 | process.args works on WASM and matches native | 0.27.6 | active | fixture | 1 | | C-097 | generic + on a type parameter concatenates strings/lists identically across targets | 0.27.6 | active | fixture | 1 | | C-098 | cross-module derived Codec methods dispatch on WASM and match native | 0.27.6 | active | fixture | 0 | diff --git a/docs/contracts/conformance.md b/docs/contracts/conformance.md index 3a7c271e..97ce1639 100644 --- a/docs/contracts/conformance.md +++ b/docs/contracts/conformance.md @@ -11,7 +11,7 @@ > (spec-coverage + evidence-class >= fixture for every active contract), so this > page cannot legitimately contain an empty Fixtures cell. -132 normative sections; 808 distinct executable fixtures. +132 normative sections; 809 distinct executable fixtures. | Section | Contracts | Fixtures (how CI runs each) | |---------|-----------|------------------------------| @@ -41,7 +41,7 @@ | ALS-D3 | C-063 | `spec/wasm_cross/json_gltf_walk.almd` (byte-compare) | | ALS-D4 | C-032, C-160, C-285, C-347 | `spec/wasm_cross/regex_engine.almd` (byte-compare)
`spec/wasm_cross/regex_fuzz_batch.almd` (byte-compare)
`spec/wasm_cross/regex_repetition_depth.almd` (byte-compare)
`spec/wasm_cross/bundled_pure_modules.almd` (byte-compare)
`spec/wasm_cross/composition_fuzz_batch.almd` (byte-compare) | | ALS-D5 | C-037 | `spec/wasm_cross/bytes_f16.almd` (byte-compare) | -| ALS-D6 | C-084, C-085, C-095, C-098, C-103, C-209, C-211, C-216, C-217, C-357 | `spec/wasm_cross/codec_decode_errors.almd` (byte-compare)
`spec/wasm_cross/codec_float_int.almd` (byte-compare)
`spec/wasm_cross/json_stringify_pretty.almd` (byte-compare)
`tests/crossmod_matrix_test.rs` (cargo gate)
`spec/wasm_cross/value_merge.almd` (byte-compare)
`spec/wasm_cross/value_array_leak_loop.almd` (byte-compare)
`spec/wasm_cross/value_as_array_leak_loop.almd` (byte-compare)
`spec/wasm_cross/value_as_array_roundtrip.almd` (byte-compare)
`spec/wasm_cross/value_array_tuple_tco.almd` (byte-compare)
`spec/wasm_cross/codec_none_omission.almd` (byte-compare)
`spec/stdlib/codec_field_matrix_test.almd` (both-target test)
`spec/wasm_cross/pure_bang_propagation.almd` (byte-compare)
`spec/lang/pure_result_bang_test.almd` (both-target test)
`spec/wasm_cross/effect_option_explicit_bang.almd` (byte-compare)
`spec/wasm_cross/let_wildcard_discard.almd` (byte-compare)
`spec/wasm_cross/json_get_int_float.almd` (byte-compare) | +| ALS-D6 | C-084, C-085, C-095, C-098, C-103, C-209, C-211, C-216, C-217, C-357 | `spec/wasm_cross/codec_decode_errors.almd` (byte-compare)
`spec/wasm_cross/codec_float_int.almd` (byte-compare)
`spec/wasm_cross/json_stringify_pretty.almd` (byte-compare)
`spec/wasm_cross/json_stringify_control_chars.almd` (byte-compare)
`tests/crossmod_matrix_test.rs` (cargo gate)
`spec/wasm_cross/value_merge.almd` (byte-compare)
`spec/wasm_cross/value_array_leak_loop.almd` (byte-compare)
`spec/wasm_cross/value_as_array_leak_loop.almd` (byte-compare)
`spec/wasm_cross/value_as_array_roundtrip.almd` (byte-compare)
`spec/wasm_cross/value_array_tuple_tco.almd` (byte-compare)
`spec/wasm_cross/codec_none_omission.almd` (byte-compare)
`spec/stdlib/codec_field_matrix_test.almd` (both-target test)
`spec/wasm_cross/pure_bang_propagation.almd` (byte-compare)
`spec/lang/pure_result_bang_test.almd` (both-target test)
`spec/wasm_cross/effect_option_explicit_bang.almd` (byte-compare)
`spec/wasm_cross/let_wildcard_discard.almd` (byte-compare)
`spec/wasm_cross/json_get_int_float.almd` (byte-compare) | | ALS-D7 | C-062, C-090 | `spec/wasm_cross/bytes_rawptr.almd` (byte-compare)
`spec/wasm_cross/bytes_from_list_param.almd` (byte-compare) | | ALS-DL1 | C-260 | `spec/wasm_cross/declaration_forms.almd` (byte-compare) | | ALS-DL2 | C-263 | `spec/wasm_cross/declaration_forms.almd` (byte-compare) | diff --git a/docs/contracts/contracts.toml b/docs/contracts/contracts.toml index 5cda9665..e7831d04 100644 --- a/docs/contracts/contracts.toml +++ b/docs/contracts/contracts.toml @@ -1229,11 +1229,12 @@ evidence = [ id = "C-095" spec = "ALS-D6" title = "json.stringify_pretty is byte-identical indented output across targets" -statement = "json.stringify_pretty produces the same indented JSON on both backends. The WASM arm was a stub (compact output plus a stray newline); it now has a real recursive pretty-printer (__json_stringify_pretty) mirroring the native oracle (json.rs stringify_value): two-space indent per depth, one array element / object member per line, comma-then-newline separators, and inline [] / {} for empty containers. Scalars match the compact form. String quoting is the canonical 5-escape rule (backslash, quote, \\n, \\r, \\t; everything else raw UTF-8) — the SAME rule the compact stringify and the wasm __json_quote use. Previously the native pretty printer used Rust's {:?} (escape_debug), which escaped combining marks as `\\u{301}` — not valid JSON escaping and a byte divergence against the wasm leg (differential-fuzz: a `cafe` + U+0301 key). Verified byte-identical native == wasm over a nested object/array document, a top-level array, a top-level scalar, and combining-mark strings/keys." +statement = "json.stringify_pretty produces the same indented JSON on both backends. The WASM arm was a stub (compact output plus a stray newline); it now has a real recursive pretty-printer (__json_stringify_pretty) mirroring the native oracle (json.rs stringify_value): two-space indent per depth, one array element / object member per line, comma-then-newline separators, and inline [] / {} for empty containers. Scalars match the compact form. String quoting is ONE rule, the SAME in the compact stringify (values and keys), the pretty form and `${v}` of a Value, on every leg (native runtime, structural wasm, self-hosted wasm __json_quote, interp): RFC 8259 §7 — backslash and quote as `\\\\` and `\\\"`, U+000A / U+000D / U+0009 as `\\n` / `\\r` / `\\t`, every OTHER U+0000..U+001F as `\\u00xx` (lowercase hex; no `\\b` / `\\f` short forms), and everything else raw UTF-8, U+007F included. Previously the native pretty printer used Rust's {:?} (escape_debug), which escaped combining marks as `\\u{301}` — not valid JSON escaping and a byte divergence against the wasm leg (differential-fuzz: a `cafe` + U+0301 key). Verified byte-identical native == wasm over a nested object/array document, a top-level array, a top-level scalar, and combining-mark strings/keys. EXTENDED (#2802): until then the rule was a 5-escape set (backslash, quote, \\n, \\r, \\t), so the rest of the control range came out RAW — invalid JSON that a strict parser refuses (Python `json.loads`: `Invalid control character`); it surfaced as an OpenAI-compatible server answering 400 to a request body that carried an ANSI escape from a command log. Pinned by spec/wasm_cross/json_stringify_control_chars.almd — each of the 32 control characters, `\"`, `\\` and U+007F, as a value and as a key, through json.stringify, json.stringify_pretty and value.stringify, each text parsed back by json.parse to the same Value." since = "0.27.6" status = "active" evidence = [ { path = "spec/wasm_cross/json_stringify_pretty.almd", class = "fixture" }, + { path = "spec/wasm_cross/json_stringify_control_chars.almd", class = "fixture" }, ] [[contract]] id = "C-096" diff --git a/ref/src/value.rs b/ref/src/value.rs index 5580fb9c..15a7ba07 100644 --- a/ref/src/value.rs +++ b/ref/src/value.rs @@ -195,9 +195,14 @@ pub fn dyn_text(d: &Dyn) -> String { } } -/// the canonical 5-escape rule (stdlib value_core __json_quote): backslash, -/// quote, \n, \r, \t — every other byte, control chars included, is RAW +/// JSON string quoting, RFC 8259 §7 (C-095, #2802): backslash and quote as +/// `\\` and `\"`, U+000A / U+000D / U+0009 as `\n` / `\r` / `\t`, every OTHER +/// U+0000..U+001F as `\u00xx` (lowercase hex, no `\b` / `\f`), everything +/// else — U+007F included — raw pub fn json_quote(s: &str) -> String { + const HEX: [char; 16] = [ + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f', + ]; let mut out = String::from("\""); for c in s.chars() { match c { @@ -206,6 +211,12 @@ pub fn json_quote(s: &str) -> String { '\n' => out.push_str("\\n"), '\r' => out.push_str("\\r"), '\t' => out.push_str("\\t"), + c if (c as u32) < 0x20 => { + let b = c as usize; + out.push_str("\\u00"); + out.push(HEX[b >> 4]); + out.push(HEX[b & 15]); + } c => out.push(c), } } diff --git a/spec/wasm_cross/json_stringify_control_chars.almd b/spec/wasm_cross/json_stringify_control_chars.almd new file mode 100644 index 00000000..42a54bd5 --- /dev/null +++ b/spec/wasm_cross/json_stringify_control_chars.almd @@ -0,0 +1,36 @@ +// @contract: C-095 +// JSON string quoting follows RFC 8259 §7 on every serializer and every leg +// (#2802): `\` and `"` as `\\` and `\"`, U+000A / U+000D / U+0009 as `\n` / +// `\r` / `\t`, every OTHER U+0000..U+001F as `\u00xx` (lowercase hex, no +// `\b` / `\f` short forms), and everything else raw UTF-8 — U+007F included. +// Before, only the five short escapes existed and the rest of the control +// range came out raw, which a strict parser (Python `json.loads`) refuses. +// Each control character is stringified as a value AND as a key, through the +// compact and the pretty form, and parsed back with `json.parse`. +import json + +fn probe(c: Int) -> String = { + let s = "a" + string.from_codepoint(c) + "b" + let doc = value.object([(s, value.str(s))]) + let text = json.stringify(doc) + let pretty = json.stringify_pretty(doc) + let back = json.parse(text) ?? value.null() + let back_pretty = json.parse(pretty) ?? value.null() + "${c} ${text} ${value.stringify(value.str(s))} ${back == doc} ${back_pretty == doc}" +} + +fn main() -> Unit = { + for c in 0..<32 { + println(probe(c)) + } + println(probe(34)) + println(probe(92)) + println(probe(127)) + // the whole range in one string, compact and pretty + let all = list.range(0, 32) |> list.map((c) => string.from_codepoint(c)) |> list.join("") + let doc = value.array([value.str(all)]) + println(json.stringify(doc)) + println(json.stringify_pretty(doc)) + let round = (json.parse(json.stringify(doc)) ?? value.null()) == doc + println("round ${round}") +}