From 90109f671c1c158cc5ecc2c5867e7fa57f95218c Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 4 Sep 2026 18:01:40 +0000 Subject: [PATCH 1/4] SESSION-EMPTY-OBJECT: refuse {} in the JSON preflight so a malformed document cannot abort the host json-syntax 0.12.5 (and jstrict 0.14.0 before it) never calls `end_fragment` for an empty JSON object, so the reserved `CodeMap` entry keeps `volume = 0` and `IterMapped::next` reads every later member one slot off. `session::parse::Parser::keys` hits `Option::unwrap()` on the resulting `None` (`json-syntax-0.12.5/src/object/mod.rs:795:67`), reachable from untrusted input through the C API (`miso_engine_v1_compile_session`) and the wasm boot path (`miso_engine_web_v1_boot`). The workspace's release profile is `panic = "abort"`, so this aborts the host process / traps the AudioWorklet rather than returning a diagnostic. No V1 schema position ever admits `{}` (every object rejects unknown keys and requires every field explicit; empty arrays are legal, empty objects never are). This implements option 1 from #387: a preflight refusal in `crates/session/src/json_preflight.rs` that rejects an empty object anywhere in the document, before the typed walk and before the dependency's `Value` tree is built, with a proper source span and `DiagnosticCode::JsonSyntax` -- the same code the existing duplicate-key and depth-129 preflight refusals already use, since a `json.syntax` diagnostic is documented (see `tools/session-validator/src/lib.rs`) to be produced only by the grammar stage and returned alone. No new diagnostic code was needed. Factored the three refusal sites' path materialization into one `Scanner::current_path` helper. Options 2 (trusting `volume == 0` in the typed walk) and 3 (patching json-syntax upstream) are not done here, per the issue's ordering; upstream still has the bug. Tests: - `crates/session/tests/json_grammar.rs`: every `{}` placement in `canonical.json` (`render_profile`, `output_profile`, a track's `builtins`) refuses with an exact span and path; `canonical-minimal.json` with an empty `render_profile` reports `json.syntax` at `$.render_profile` instead of the previously bogus `numeric.out_of_schema_range` at `$.output_profile.channels` with degenerate `151..151` spans; a document content-equivalent to the fuzz crash the #385 verifier found (`crash-52d9c906ce5ad7f1d1e67dad91b13ec69e2caab5`, not present on disk in this worktree) no longer panics; the minimal `{"a":{},"b":1}` CodeMap illustration from the issue. - `crates/capi/src/ffi.rs` (`ffi::tests::empty_object_document_is_diagnosed_not_aborted`): `miso_engine_v1_compile_session` on the nine-track fixture with an empty `render_profile` returns `RESULT_COMPILE_REJECTED` with `json.syntax\t$.render_profile\n`, not an abort. - `hosts/host-web/src/tests.rs` (`tests::empty_object_document_boots_to_a_diagnostic_not_a_trap`): `AudioWorkletEngineHost::boot` and the raw `miso_engine_web_v1_boot` C ABI entry both return `RESULT_REFUSED_DOCUMENT` with the same diagnostic instead of trapping. Re-pinned the AudioWorklet artifact (`crates/session` ships in the wasm build): `22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6` -> `439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958` (`MISO_ENGINE_WEB_AUDIOWORKLET_REPIN=1 bash scripts/build-web-audioworklet.sh `). Commands run: - cargo test --locked -p session - cargo test --locked -p capi - cargo test --locked -p host-web - bash scripts/check-session-policy.sh - bash scripts/check-realtime-policy.sh - bash scripts/check-workspace-policy.sh - bash scripts/build-web-audioworklet.sh - bash scripts/check-web-audioworklet.sh - python3 scripts/check-browser-expected-resources.py - cargo clippy --locked -p session -p capi -p host-web --all-targets -- -D warnings - cargo fmt --all -- --check - git diff --check Refs #387 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EwL1uTcxsmopHtamG6bMko --- crates/capi/src/ffi.rs | 75 ++++++++++ crates/session/src/json_preflight.rs | 33 ++--- crates/session/tests/json_grammar.rs | 128 ++++++++++++++++++ hosts/host-web/src/tests.rs | 56 ++++++++ ...so-engine-v1-audio-worklet-artifact.sha256 | 2 +- 5 files changed, 277 insertions(+), 17 deletions(-) diff --git a/crates/capi/src/ffi.rs b/crates/capi/src/ffi.rs index 379bc0923..7ee848e6f 100644 --- a/crates/capi/src/ffi.rs +++ b/crates/capi/src/ffi.rs @@ -1878,6 +1878,81 @@ mod tests { destroy(engine); } + /// Issue #387: json-syntax 0.12.5 never calls `end_fragment` for an empty JSON object, so the + /// reserved `CodeMap` entry keeps `volume = 0` and every later member is read one slot off -- + /// `session::parse::Parser::keys` used to hit `Option::unwrap()` on the resulting `None`, + /// which aborted the process under this workspace's `panic = "abort"` release profile + /// (`catch_result`'s `catch_unwind` cannot contain an abort). The session preflight now + /// refuses `{}` anywhere in the document before the typed walk, so this reaches + /// `RESULT_COMPILE_REJECTED` with a documented diagnostic instead, exactly like any other + /// malformed document. + #[test] + fn empty_object_document_is_diagnosed_not_aborted() { + const JSON: &str = + include_str!("../../../fixtures/session/v1/parametric-eq-nine-track.json"); + let needle = + "\"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n }"; + assert!(JSON.contains(needle), "fixture shape drifted"); + let document = JSON.replacen(needle, "\"render_profile\": {}", 1); + + let mut engine = ptr::null_mut(); + assert_eq!(create(&config(), &mut engine), RESULT_OK); + let mut diagnostics = BytesOut { + struct_size: BYTES_OUT_SIZE, + reserved0: 0, + data: ptr::null_mut(), + capacity_bytes: 0, + required_bytes: 0, + }; + let mut session = ptr::dangling_mut::(); + let mut plan = ptr::dangling_mut::(); + let result = + // SAFETY: Every pointer names a complete local ABI value or the mutated document bytes. + unsafe { + miso_engine_v1_compile_session( + engine, + document.as_ptr(), + document.len() as u64, + &limits(), + &mut diagnostics, + &mut session, + &mut plan, + ) + }; + assert_eq!(result, RESULT_BUFFER_TOO_SMALL); + assert!(session.is_null()); + assert!(plan.is_null()); + assert!(diagnostics.required_bytes > 0); + + let mut storage = vec![0_u8; diagnostics.required_bytes as usize]; + diagnostics.data = storage.as_mut_ptr(); + diagnostics.capacity_bytes = storage.len() as u64; + session = ptr::dangling_mut(); + plan = ptr::dangling_mut(); + let result = + // SAFETY: The retry output can hold the complete diagnostic and outputs are writable. + unsafe { + miso_engine_v1_compile_session( + engine, + document.as_ptr(), + document.len() as u64, + &limits(), + &mut diagnostics, + &mut session, + &mut plan, + ) + }; + assert_eq!(result, RESULT_COMPILE_REJECTED); + assert!(session.is_null()); + assert!(plan.is_null()); + let rendered = core::str::from_utf8(&storage).expect("diagnostics are UTF-8"); + assert!( + rendered.contains("json.syntax\t$.render_profile\n"), + "unexpected diagnostic bytes: {rendered:?}" + ); + destroy(engine); + } + /// F6, end to end: a rejected source submission or seek reaches a C host as the diagnostic for /// the rule it broke, through the real entry point and the real `last_error` path. Every one of /// these used to be `RESULT_INVALID_ARGUMENT` with `source.submit.rejected` or diff --git a/crates/session/src/json_preflight.rs b/crates/session/src/json_preflight.rs index 17fd5e1b3..3669a3e9c 100644 --- a/crates/session/src/json_preflight.rs +++ b/crates/session/src/json_preflight.rs @@ -60,12 +60,17 @@ impl Scanner<'_> { fn object(&mut self, depth: usize) -> Result<(), SyntaxRefusal> { self.open(depth)?; + let open_at = self.cursor; self.cursor += 1; self.whitespace(); let mut keys = BTreeSet::new(); if self.peek() == Some(b'}') { self.cursor += 1; - return Ok(()); + return Err(SyntaxRefusal { + path: self.current_path(), + span: open_at..self.cursor, + message: "empty JSON object {} is never a valid V1 schema value", + }); } loop { self.whitespace(); @@ -83,17 +88,8 @@ impl Scanner<'_> { }; let key = key.to_string(); if !keys.insert(key.clone()) { - let mut path = DiagnosticPath::root(); - for segment in &self.path { - path = match segment { - PathSegment::Field(value) => path.key(value), - PathSegment::Index(value) => path.index(*value), - PathSegment::Id(_) => unreachable!(), - }; - } - path = path.key(&key); return Err(SyntaxRefusal { - path, + path: self.current_path().key(&key), span: key_start..key_end, message: "duplicate object member", }); @@ -148,6 +144,15 @@ impl Scanner<'_> { if depth <= MAXIMUM_JSON_DEPTH { return Ok(()); } + Err(SyntaxRefusal { + path: self.current_path(), + span: self.cursor..self.cursor + 1, + message: "JSON nesting exceeds the maximum depth of 128", + }) + } + + /// Materialize the structured path to the value currently being scanned. + fn current_path(&self) -> DiagnosticPath { let mut path = DiagnosticPath::root(); for segment in &self.path { path = match segment { @@ -156,11 +161,7 @@ impl Scanner<'_> { PathSegment::Id(_) => unreachable!(), }; } - Err(SyntaxRefusal { - path, - span: self.cursor..self.cursor + 1, - message: "JSON nesting exceeds the maximum depth of 128", - }) + path } fn string(&mut self) -> Result<&str, ()> { diff --git a/crates/session/tests/json_grammar.rs b/crates/session/tests/json_grammar.rs index b91a32880..d6a17e39d 100644 --- a/crates/session/tests/json_grammar.rs +++ b/crates/session/tests/json_grammar.rs @@ -3,6 +3,7 @@ use session::{DiagnosticCode, canonical_session_json, parse_session_json}; const CANONICAL: &str = include_str!("../../../fixtures/session/v1/canonical.json"); +const CANONICAL_MINIMAL: &str = include_str!("../../../fixtures/session/v1/canonical-minimal.json"); fn only<'a>( error: &'a session::DiagnosticSet, @@ -246,6 +247,133 @@ fn numeric_lexemes_reach_typed_rules_without_f64_preprocessing() { } } +// Issue #387: json-syntax 0.12.5 (and jstrict 0.14.0 before it, on `main`) never calls +// `end_fragment` for an empty JSON object, so the reserved `CodeMap` entry keeps `volume = 0` +// and every later member is read one slot off. `session::parse::Parser::keys` hits +// `Option::unwrap()` on the resulting `None` at `json-syntax-0.12.5/src/object/mod.rs:795:67`. +// No V1 position ever admits `{}` (docs/SESSION_SCHEMA_V1.md: every object rejects unknown keys +// and requires every field explicit), so the preflight in `json_preflight.rs` refuses an empty +// object anywhere in the document, before the typed walk -- and before the dependency's `Value` +// tree is even built -- with `DiagnosticCode::JsonSyntax`, mirroring the existing duplicate-key +// and depth-129 preflight refusals. +#[test] +fn empty_object_refuses_at_every_placement_instead_of_corrupting_the_code_map() { + let cases = [ + ( + "render_profile", + r#""id": "native", + "mode": "single_thread""#, + ), + ( + "output_profile", + r#""id": "main", + "channels": 2, + "sample_format": "f32_planar""#, + ), + ]; + for (key, inner) in cases { + let needle = format!("\"{key}\": {{\n {inner}\n }}"); + assert!( + CANONICAL.contains(&needle), + "fixture shape drifted for {key}" + ); + let source = CANONICAL.replacen(&needle, &format!("\"{key}\": {{}}"), 1); + let error = parse_session_json(&source).expect_err("empty object refuses"); + let diagnostic = only(&error, DiagnosticCode::JsonSyntax, &format!("$.{key}")); + let start = source.find(&format!("\"{key}\": {{}}")).unwrap() + key.len() + 4; + let span = diagnostic.span.expect("empty-object span"); + assert_eq!((span.byte_start, span.byte_end), (start, start + 2)); + assert_eq!( + diagnostic.message, + "empty JSON object {} is never a valid V1 schema value" + ); + } + + // A track's `builtins` table: the same refusal fires for a nested object below the root. + let builtins_start = CANONICAL.find("\"builtins\": {").expect("builtins key"); + let mut depth = 0i32; + let mut cursor = CANONICAL[builtins_start..].find('{').unwrap() + builtins_start; + let builtins_end = loop { + match CANONICAL.as_bytes()[cursor] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + break cursor + 1; + } + } + _ => {} + } + cursor += 1; + }; + let source = format!( + "{}\"builtins\": {{}}{}", + &CANONICAL[..builtins_start], + &CANONICAL[builtins_end..] + ); + let error = parse_session_json(&source).expect_err("empty builtins table refuses"); + let diagnostic = only(&error, DiagnosticCode::JsonSyntax, "$.tracks[0].builtins"); + let start = source.find("\"builtins\": {}").unwrap() + "\"builtins\": ".len(); + let span = diagnostic.span.expect("empty-object span"); + assert_eq!((span.byte_start, span.byte_end), (start, start + 2)); +} + +#[test] +fn canonical_minimal_empty_render_profile_reports_syntax_not_a_bogus_numeric_range() { + // Before the fix this fixture reported a spurious `numeric.out_of_schema_range` at + // `$.output_profile.channels` (the value is `2`, well inside range) plus degenerate + // `151..151` spans for the real missing-field errors, because the corrupted `CodeMap` + // misattributed every entry after the empty `render_profile` object. + let source = CANONICAL_MINIMAL.replacen( + "\"render_profile\": {\n \"id\": \"single\",\n \"mode\": \"single_thread\"\n }", + "\"render_profile\": {}", + 1, + ); + assert!(source != CANONICAL_MINIMAL, "fixture shape drifted"); + let error = parse_session_json(&source).expect_err("empty object refuses"); + let diagnostic = only(&error, DiagnosticCode::JsonSyntax, "$.render_profile"); + let span = diagnostic.span.expect("empty-object span"); + assert_ne!( + (span.byte_start, span.byte_end), + (151, 151), + "span must not degenerate" + ); + assert!( + error + .diagnostics() + .iter() + .all(|d| d.code != DiagnosticCode::NumericOutOfSchemaRange), + "must not report the bogus numeric.out_of_schema_range: {error}" + ); +} + +#[test] +fn fuzz_crash_52d9c906_empty_render_profile_diagnoses_instead_of_aborting() { + // Content-equivalent to fuzz artifact `crash-52d9c906ce5ad7f1d1e67dad91b13ec69e2caab5` + // (`session_parse`, found by the #385 verifier): `canonical.json` with `render_profile`'s + // body reduced to whitespace only, which is still an empty object under the JSON grammar. + // The literal fuzz artifact file was not present on disk in this worktree; this input + // reproduces the same `Option::unwrap()` panic pre-fix (verified on unmodified `main`/#385). + let source = CANONICAL.replacen( + "\"render_profile\": {\n \"id\": \"native\",\n \"mode\": \"single_thread\"\n }", + "\"render_profile\": {\n \n }", + 1, + ); + let error = parse_session_json(&source).expect_err("empty object refuses"); + only(&error, DiagnosticCode::JsonSyntax, "$.render_profile"); +} + +#[test] +fn minimal_two_member_object_with_an_empty_first_value_diagnoses_instead_of_aborting() { + // The raw `CodeMap` illustration from issue #387: `{"a":{},"b":1}` is the smallest input + // that demonstrates the off-by-one -- an empty object followed by a sibling member whose + // `CodeMap` slot the corrupted `IterMapped` would misread. + let error = parse_session_json(r#"{"a":{},"b":1}"#).expect_err("empty object refuses"); + let diagnostic = only(&error, DiagnosticCode::JsonSyntax, "$.a"); + let span = diagnostic.span.expect("empty-object span"); + assert_eq!((span.byte_start, span.byte_end), (5, 7)); +} + #[test] fn syntax_and_typed_spans_are_exact_after_multibyte_text_and_newlines() { let source = "{\n \"🙂\": 0,\n \"schema_version\": false\n}"; diff --git a/hosts/host-web/src/tests.rs b/hosts/host-web/src/tests.rs index 3896cc0e5..a8d1814dc 100644 --- a/hosts/host-web/src/tests.rs +++ b/hosts/host-web/src/tests.rs @@ -482,6 +482,62 @@ fn malformed_config_and_atomic_compile_failure_are_sticky() { assert!(!failure.diagnostic().is_empty()); } +/// Issue #387: json-syntax 0.12.5 never calls `end_fragment` for an empty JSON object, so the +/// reserved `CodeMap` entry keeps `volume = 0` and every later member is read one slot off -- +/// `session::parse::Parser::keys` used to hit `Option::unwrap()` on the resulting `None`, which +/// traps the AudioWorklet module under this workspace's `panic = "abort"` release profile +/// (`scripts/build-web-audioworklet.sh` builds `--release`). The session preflight now refuses +/// `{}` anywhere in the document before the typed walk, so `AudioWorkletEngineHost::boot` -- +/// the same call `miso_engine_web_v1_boot` (`hosts/host-web/src/ffi.rs`) makes -- returns a +/// documented `RESULT_REFUSED_DOCUMENT` diagnostic instead of unwinding into the abort. +#[test] +fn empty_object_document_boots_to_a_diagnostic_not_a_trap() { + let document = one_track_session(128); + let start = document + .find("\"render_profile\": {") + .expect("render_profile key"); + let open = start + "\"render_profile\": ".len(); + let mut depth = 0i32; + let mut cursor = open; + let close = loop { + match document.as_bytes()[cursor] { + b'{' => depth += 1, + b'}' => { + depth -= 1; + if depth == 0 { + break cursor + 1; + } + } + _ => {} + } + cursor += 1; + }; + let mutated = format!("{}{{}}{}", &document[..open], &document[close..]); + assert_ne!(mutated, document, "render_profile was not actually emptied"); + + let failure = AudioWorkletEngineHost::boot(mutated.as_bytes(), boot_options(128)) + .err() + .expect("empty object must refuse, not abort"); + assert_eq!(failure.result(), RESULT_REFUSED_DOCUMENT); + assert_eq!(failure.diagnostic(), b"json.syntax\t$.render_profile\n"); + + // The same document through the raw C ABI boot entry the wasm artifact exports: staged, + // booted, and refused without ever reaching a live handle. + assert_eq!(miso_engine_web_v1_dispose(0), RESULT_OK); + crate::ffi::test_stage_document(mutated.as_bytes()); + assert_eq!( + miso_engine_web_v1_boot(mutated.len() as u32), + 0, + "a refused boot never returns a live handle" + ); + assert_eq!(miso_engine_web_v1_boot_result(), RESULT_REFUSED_DOCUMENT); + assert!( + miso_engine_web_v1_boot_diagnostic_bytes() > 0, + "the refusal leaves a nonempty diagnostic prefix for the host to read" + ); + assert_eq!(miso_engine_web_v1_status_ptr(0), 0); +} + #[test] fn compile_resource_caps_are_inclusive_and_one_below_rejects() { let mut document = one_track_session(128); diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 index 0f4874c55..e0a142733 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 @@ -1 +1 @@ -22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6 \ No newline at end of file +439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958 \ No newline at end of file From 816957afcf0793883e1cfdb2c2aa9f566653a073 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 4 Sep 2026 18:04:30 +0000 Subject: [PATCH 2/4] Re-pin the AudioWorklet qualification lineage for #387 - npm run qualify -- --artifacts --browser all --record-matrix --candidate-commit 90109f671c1c158cc5ecc2c5867e7fa57f95218c --self-test-mutations: chromium 151.0.7922.34, firefox 153.0, webkit 26.5 all passed. - npm run qualify -- --artifacts --browser all --check-matrix --self-test-mutations: PASS against the recorded results.json. candidateCommit fc01c534... -> 90109f67... (this branch's #387 fix commit); wasmSha256 22e4c25c... -> 439ba6b5... (matches the re-pinned artifact digest recorded in the parent commit). Refs #387 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EwL1uTcxsmopHtamG6bMko --- hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md | 2 +- hosts/host-web/qualification/results.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md index 9bd12d1c4..bb08eaca6 100644 --- a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md +++ b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md @@ -1,7 +1,7 @@ # Browser deployment matrix -This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `fc01c534cce3d8c1464e489955bdab8bb45d9fe1` and the single shipped simd128 AudioWorklet artifact `22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. +This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `90109f671c1c158cc5ecc2c5867e7fa57f95218c` and the single shipped simd128 AudioWorklet artifact `439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. | Browser engine | Qualified version floor | Attestation outcome | SIMD gate | AudioWorklet boot | Native corpus digest | Live console (#137) | Observation (#143) | 100 ms main-thread stall | | --- | --- | --- | --- | --- | --- | --- | --- | --- | diff --git a/hosts/host-web/qualification/results.json b/hosts/host-web/qualification/results.json index d83e77303..add637101 100644 --- a/hosts/host-web/qualification/results.json +++ b/hosts/host-web/qualification/results.json @@ -1,7 +1,7 @@ { "schema": "miso.web.qualification.matrix.v1", - "candidateCommit": "fc01c534cce3d8c1464e489955bdab8bb45d9fe1", - "wasmSha256": "22e4c25cba7f97b66db720ad8ac8cf653de0afcabe84101693f4fa166b90d4e6", + "candidateCommit": "90109f671c1c158cc5ecc2c5867e7fa57f95218c", + "wasmSha256": "439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958", "playwrightVersion": "1.62.1", "platform": "linux-headless", "artifact": "single shipped simd128 AudioWorklet artifact", From 12973933896823e30f5ad2c447d3f52455916b79 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 4 Sep 2026 19:01:33 +0000 Subject: [PATCH 3/4] SESSION-EMPTY-OBJECT: document the {} refusal, correct the preflight comment, and seed the CI fuzz corpus Verifier feedback on PR #390 (REQUEST CHANGES, documentation and PR-truthfulness only; the fix itself was verified correct: 186 {} placements, 48 scanner attacks, fuzz clean, all CI job commands green locally): 1. docs/SESSION_SCHEMA_V1.md: added the empty-object refusal to the preflight paragraph that already documents the duplicate-key and depth-129 refusals -- code, path/span shape, and the json-syntax 0.12.5 CodeMap defect it works around (issue #387). Empty arrays are called out as unaffected. 2. crates/session/src/json_preflight.rs:31-32: the comment claimed this pass "only returns the two refusals for which the value parser does not expose the required contract information." That was already inaccurate for the empty-object refusal added in the parent commit: the value parser normally *would* diagnose an empty object correctly (missing schema fields etc.); this preflight refuses it first specifically to keep the corrupted json-syntax CodeMap state (no `end_fragment` call on the empty-object branch) from ever being reached. Rewritten to name all three refusals and state that reason accurately. 3. .github/workflows/fuzz.yml:117 / fixtures/session/v1/fuzz-seeds/empty-object.json: the issue asked to add `{}` to the session_parse fuzz corpus seeds. Added a small committed fixture (canonical.json with render_profile set to {}) and appended it to the session_parse job's `-seed_inputs=` list (libFuzzer accepts a comma-separated list). Verified directly: running the compiled session_parse fuzz binary once against this exact seed on this branch executes cleanly (no crash); the identical byte content was already proven to panic session::parse::Parser::keys on the unmodified pre-#387 tree (session-level repro captured before implementing the fix, and independently by the #385 verifier's own fuzzer, which is how #387 was found in the first place) -- so this seed would crash main's session_parse target on its first execution. Re-pin note (not requested, but required): editing json_preflight.rs's comment shifts every subsequent line number in that file, including its one slicing panic site (`self.source[start..self.cursor]` in `Scanner::string`). Rust embeds a `core::panic::Location` (file/line/column) constant at that call site regardless of the release profile's `-C strip=debuginfo` (that strip level removes DWARF sections, not these compiled-in panic-location strings), so a comment-only edit to a shipped source file still moves the wasm artifact's bytes. Confirmed by rebuilding: digest changed 439ba6b5... -> f1f27d8d... at the identical byte count (2,635,719), i.e. exactly a debug-metadata-only delta, not a behavior change. Re-pinned the artifact and regenerated the qualification lineage against this commit's own hash, same as the prior two commits did. Per the coordinator's explicit instruction, crates/capi/tests/resource_lifecycle.rs is untouched; its pre-existing, already-diagnosed-separately failure on main since #385 is out of scope here. Commands run: - cargo test --locked -p session - bash scripts/check-session-policy.sh - RUSTDOCFLAGS="-D warnings" cargo doc --locked -p session -p capi -p host-web --no-deps - cargo fmt --all -- --check - git diff --check - cargo clippy --locked -p session -p capi -p host-web --all-targets -- -D warnings - bash scripts/build-web-audioworklet.sh (re-pin, then normal build against the new pin) - bash scripts/check-web-audioworklet.sh - python3 scripts/check-browser-expected-resources.py - ./target/x86_64-unknown-linux-gnu/release/session_parse fixtures/session/v1/fuzz-seeds/empty-object.json (single-input run, no crash) Refs #387 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EwL1uTcxsmopHtamG6bMko --- .github/workflows/fuzz.yml | 2 +- crates/session/src/json_preflight.rs | 11 +- docs/SESSION_SCHEMA_V1.md | 19 ++- .../session/v1/fuzz-seeds/empty-object.json | 137 ++++++++++++++++++ ...so-engine-v1-audio-worklet-artifact.sha256 | 2 +- 5 files changed, 162 insertions(+), 9 deletions(-) create mode 100644 fixtures/session/v1/fuzz-seeds/empty-object.json diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 0a895db93..8ad837fc7 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -114,7 +114,7 @@ jobs: run: | mkdir -p target/ci/session-fuzz/parse export RUSTFLAGS='-C target-feature=+avx2,+fma' # lane D4 guard; cargo-fuzz prepends this to its sanitizer flags - cargo +nightly-2026-08-20 fuzz run session_parse target/ci/session-fuzz/parse -- -runs=10000 -seed=557074001 -seed_inputs=fixtures/session/v1/canonical.json + cargo +nightly-2026-08-20 fuzz run session_parse target/ci/session-fuzz/parse -- -runs=10000 -seed=557074001 -seed_inputs=fixtures/session/v1/canonical.json,fixtures/session/v1/fuzz-seeds/empty-object.json - name: Run bounded compiler fuzz target run: | mkdir -p target/ci/session-fuzz/compile diff --git a/crates/session/src/json_preflight.rs b/crates/session/src/json_preflight.rs index 3669a3e9c..352ec1975 100644 --- a/crates/session/src/json_preflight.rs +++ b/crates/session/src/json_preflight.rs @@ -28,8 +28,15 @@ pub(crate) fn preflight(source: &str) -> Result<(), SyntaxRefusal> { path: Vec::new(), }; scanner.whitespace(); - // Malformed JSON is diagnosed by the authoritative dependency. This pass only returns the - // two refusals for which the value parser does not expose the required contract information. + // Malformed JSON is diagnosed by the authoritative dependency. This pass returns exactly + // three refusals: duplicate object member and nesting depth 129, for which the value parser + // does not expose the required contract information, plus empty object `{}` (issue #387), + // which the value parser *would* diagnose correctly on its own except that json-syntax 0.12.5 + // never finishes the reserved `CodeMap` entry for an empty object (no `end_fragment` call on + // that branch, unlike the empty-array branch), so every later sibling member is misread and + // the dependency can panic instead of returning a diagnostic. This preflight refuses `{}` + // itself, before the dependency ever builds a `Value` tree, so that corrupted `CodeMap` state + // is never reached. scanner.value(1)?; Ok(()) } diff --git a/docs/SESSION_SCHEMA_V1.md b/docs/SESSION_SCHEMA_V1.md index 4e2e5533a..b4db2ffd0 100644 --- a/docs/SESSION_SCHEMA_V1.md +++ b/docs/SESSION_SCHEMA_V1.md @@ -1,11 +1,20 @@ # Session schema V1 `session` accepts strict RFC 8259 JSON through exact-pinned `json-syntax 0.12.5`, after a -contract-owned duplicate-key and nesting-depth preflight. Comments, trailing commas, multiple -top-level values, BOMs, invalid escapes, unpaired surrogates and non-JSON numeric tokens refuse. -A duplicate member refuses before its value is parsed or retained, at the decoded member path, -with a byte span over the second key. The root object is depth one; opening any object or array at -depth 129 refuses before that subtree is built. +contract-owned duplicate-key, nesting-depth, and empty-object preflight. Comments, trailing +commas, multiple top-level values, BOMs, invalid escapes, unpaired surrogates and non-JSON numeric +tokens refuse. A duplicate member refuses before its value is parsed or retained, at the decoded +member path, with a byte span over the second key. The root object is depth one; opening any +object or array at depth 129 refuses before that subtree is built. + +An empty JSON object `{}` anywhere in the document refuses with `json.syntax` at that value's +path, with a byte span over its `{...}` bytes, before the typed walk runs (issue #387). This is a +workaround for a `json-syntax 0.12.5` defect, not a schema rule stated for its own sake: the +dependency never finishes the reserved `CodeMap` entry for an empty object (it never calls +`end_fragment` for that branch, unlike the empty-array branch), which otherwise misreads every +sibling member declared after it and can panic. No V1 schema position accepts `{}` regardless -- +every object rejects unknown keys and requires every field explicit -- so the refusal costs no +legal document; empty arrays remain legal and are unaffected. Canonical output is defined by the schema walk, not generic map order, RFC 8785/JCS, or a serde serializer. It is UTF-8 without BOM, uses LF and two-space indentation, has no tabs or trailing diff --git a/fixtures/session/v1/fuzz-seeds/empty-object.json b/fixtures/session/v1/fuzz-seeds/empty-object.json new file mode 100644 index 000000000..bb942cc4c --- /dev/null +++ b/fixtures/session/v1/fuzz-seeds/empty-object.json @@ -0,0 +1,137 @@ +{ + "schema_version": 1, + "session_id": "demo.session", + "revision": "7", + "sample_rate_hz": 48000, + "quantum_frames": 128, + "render_profile": {}, + "output_profile": { + "id": "main", + "channels": 2, + "sample_format": "f32_planar" + }, + "sources": [ + { + "id": "voice", + "content": "sha256:2a97516c354b68848cdbd8f54a226a0a55b21ed138e207ad6c5cbb9c00aa5aea", + "channels": 2, + "bit_depth": "32f", + "frames": "48000" + } + ], + "tracks": [ + { + "id": "vocal", + "source_id": "voice", + "left_source_channel": 0, + "right_source_channel": 1, + "builtins": { + "left": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 20.0, + "lpf_hz": 20000.0, + "delay_samples": 0 + }, + "right": { + "polarity_invert": false, + "trim_db": 0.0, + "hpf_hz": 20.0, + "lpf_hz": 20000.0, + "delay_samples": 0 + } + }, + "simd1": { + "effects": [] + }, + "dynamic": { + "effects": [ + { + "id": "eq", + "identity": { + "kind": "native", + "effect_id": "parametric-eq" + }, + "quality": "normal", + "bypass": false, + "link_mode": "dual_mono", + "params": [ + { + "parameter_id": 1, + "channel": "both", + "unit": "db", + "value": 0.0 + } + ], + "sidechain": { + "kind": "none" + } + } + ] + }, + "simd2": { + "effects": [] + }, + "fader": { + "left_db": 0.0, + "right_db": 0.0, + "left_mute": false, + "right_mute": false + }, + "pan": { + "left": 1.0, + "right": 1.0, + "smoothing_samples": 16 + } + } + ], + "submixes": [], + "outputs": [ + { + "id": "main-out" + } + ], + "routes": [ + { + "id": "to-main", + "source": { + "kind": "track", + "track_id": "vocal", + "tap": "post_matrix" + }, + "destination": { + "kind": "output_input", + "output_id": "main-out" + }, + "channel_matrix": { + "ll": 1.0, + "lr": 0.0, + "rl": 0.0, + "rr": 1.0 + }, + "gain_db": 0.0 + } + ], + "automation": [ + { + "id": "eq-gain", + "target": { + "entity_id": "vocal", + "rack": "dynamic", + "effect_id": "eq", + "parameter_id": 1, + "channel": "both" + }, + "segments": [ + { + "shape": "linear", + "start_sample": "0", + "end_sample": "480", + "start_value": 0.0, + "end_value": -3.0, + "unit": "db" + } + ] + } + ] +} diff --git a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 index e0a142733..a021c6bfd 100644 --- a/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 +++ b/hosts/host-web/web/miso-engine-v1-audio-worklet-artifact.sha256 @@ -1 +1 @@ -439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958 \ No newline at end of file +f1f27d8daa4c9f0828c9a3b2ca939bfdb507d041db6d7bc2a63ec6c826c1f74d \ No newline at end of file From a52bdae080468943c4604b44f173095d80612767 Mon Sep 17 00:00:00 2001 From: BL Date: Fri, 4 Sep 2026 19:02:16 +0000 Subject: [PATCH 4/4] Re-pin the AudioWorklet qualification lineage after the documentation commit The prior commit only edits comments/docs/a fuzz fixture/a workflow file, but one of those edits (crates/session/src/json_preflight.rs's comment) is inside a real shipped source file. Shifting its line numbers moved the compiled `core::panic::Location` constant for `Scanner::string`'s one slicing panic site, which changed the wasm artifact's bytes even though no behavior changed (2,635,719 bytes before and after; only the digest moved). See that commit's message for the full explanation. - MISO_ENGINE_WEB_AUDIOWORKLET_REPIN=1 bash scripts/build-web-audioworklet.sh : produced f1f27d8daa4c9f0828c9a3b2ca939bfdb507d041db6d7bc2a63ec6c826c1f74d (was 439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958). - bash scripts/build-web-audioworklet.sh (normal build against the new pin): PASS, same 2,635,719 bytes. - bash scripts/check-web-audioworklet.sh : PASS. - python3 scripts/check-browser-expected-resources.py: PASS. - npm run qualify -- --artifacts --browser all --record-matrix --candidate-commit 12973933896823e30f5ad2c447d3f52455916b79 --self-test-mutations: chromium 151.0.7922.34, firefox 153.0, webkit 26.5 all passed. - npm run qualify -- --artifacts --browser all --check-matrix --self-test-mutations: PASS against the recorded results.json. Refs #387 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01EwL1uTcxsmopHtamG6bMko --- hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md | 2 +- hosts/host-web/qualification/results.json | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md index bb08eaca6..002ae6a81 100644 --- a/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md +++ b/hosts/host-web/BROWSER_DEPLOYMENT_MATRIX.md @@ -1,7 +1,7 @@ # Browser deployment matrix -This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `90109f671c1c158cc5ecc2c5867e7fa57f95218c` and the single shipped simd128 AudioWorklet artifact `439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. +This matrix is generated from the pinned Playwright 1.62.1 headless Linux qualification run over candidate `12973933896823e30f5ad2c447d3f52455916b79` and the single shipped simd128 AudioWorklet artifact `f1f27d8daa4c9f0828c9a3b2ca939bfdb507d041db6d7bc2a63ec6c826c1f74d`. The version shown is the lowest version qualified by this run; older versions are unqualified, not implicitly supported. | Browser engine | Qualified version floor | Attestation outcome | SIMD gate | AudioWorklet boot | Native corpus digest | Live console (#137) | Observation (#143) | 100 ms main-thread stall | | --- | --- | --- | --- | --- | --- | --- | --- | --- | diff --git a/hosts/host-web/qualification/results.json b/hosts/host-web/qualification/results.json index add637101..bb869fc44 100644 --- a/hosts/host-web/qualification/results.json +++ b/hosts/host-web/qualification/results.json @@ -1,7 +1,7 @@ { "schema": "miso.web.qualification.matrix.v1", - "candidateCommit": "90109f671c1c158cc5ecc2c5867e7fa57f95218c", - "wasmSha256": "439ba6b513c5b1c9c2d1ff462d5cdd43136b0d70df540d4a21c238886d165958", + "candidateCommit": "12973933896823e30f5ad2c447d3f52455916b79", + "wasmSha256": "f1f27d8daa4c9f0828c9a3b2ca939bfdb507d041db6d7bc2a63ec6c826c1f74d", "playwrightVersion": "1.62.1", "platform": "linux-headless", "artifact": "single shipped simd128 AudioWorklet artifact",