Skip to content
Merged
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
2 changes: 1 addition & 1 deletion docs/contracts/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
4 changes: 2 additions & 2 deletions docs/contracts/conformance.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
|---------|-----------|------------------------------|
Expand Down Expand Up @@ -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)<br>`spec/wasm_cross/regex_fuzz_batch.almd` (byte-compare)<br>`spec/wasm_cross/regex_repetition_depth.almd` (byte-compare)<br>`spec/wasm_cross/bundled_pure_modules.almd` (byte-compare)<br>`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)<br>`spec/wasm_cross/codec_float_int.almd` (byte-compare)<br>`spec/wasm_cross/json_stringify_pretty.almd` (byte-compare)<br>`tests/crossmod_matrix_test.rs` (cargo gate)<br>`spec/wasm_cross/value_merge.almd` (byte-compare)<br>`spec/wasm_cross/value_array_leak_loop.almd` (byte-compare)<br>`spec/wasm_cross/value_as_array_leak_loop.almd` (byte-compare)<br>`spec/wasm_cross/value_as_array_roundtrip.almd` (byte-compare)<br>`spec/wasm_cross/value_array_tuple_tco.almd` (byte-compare)<br>`spec/wasm_cross/codec_none_omission.almd` (byte-compare)<br>`spec/stdlib/codec_field_matrix_test.almd` (both-target test)<br>`spec/wasm_cross/pure_bang_propagation.almd` (byte-compare)<br>`spec/lang/pure_result_bang_test.almd` (both-target test)<br>`spec/wasm_cross/effect_option_explicit_bang.almd` (byte-compare)<br>`spec/wasm_cross/let_wildcard_discard.almd` (byte-compare)<br>`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)<br>`spec/wasm_cross/codec_float_int.almd` (byte-compare)<br>`spec/wasm_cross/json_stringify_pretty.almd` (byte-compare)<br>`spec/wasm_cross/json_stringify_control_chars.almd` (byte-compare)<br>`tests/crossmod_matrix_test.rs` (cargo gate)<br>`spec/wasm_cross/value_merge.almd` (byte-compare)<br>`spec/wasm_cross/value_array_leak_loop.almd` (byte-compare)<br>`spec/wasm_cross/value_as_array_leak_loop.almd` (byte-compare)<br>`spec/wasm_cross/value_as_array_roundtrip.almd` (byte-compare)<br>`spec/wasm_cross/value_array_tuple_tco.almd` (byte-compare)<br>`spec/wasm_cross/codec_none_omission.almd` (byte-compare)<br>`spec/stdlib/codec_field_matrix_test.almd` (both-target test)<br>`spec/wasm_cross/pure_bang_propagation.almd` (byte-compare)<br>`spec/lang/pure_result_bang_test.almd` (both-target test)<br>`spec/wasm_cross/effect_option_explicit_bang.almd` (byte-compare)<br>`spec/wasm_cross/let_wildcard_discard.almd` (byte-compare)<br>`spec/wasm_cross/json_get_int_float.almd` (byte-compare) |
| ALS-D7 | C-062, C-090 | `spec/wasm_cross/bytes_rawptr.almd` (byte-compare)<br>`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) |
Expand Down
3 changes: 2 additions & 1 deletion docs/contracts/contracts.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
15 changes: 13 additions & 2 deletions ref/src/value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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),
}
}
Expand Down
36 changes: 36 additions & 0 deletions spec/wasm_cross/json_stringify_control_chars.almd
Original file line number Diff line number Diff line change
@@ -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}")
}
Loading