Skip to content
Closed
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 .github/workflows/fuzz.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 75 additions & 0 deletions crates/capi/src/ffi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Session>();
let mut plan = ptr::dangling_mut::<Plan>();
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
Expand Down
44 changes: 26 additions & 18 deletions crates/session/src/json_preflight.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down Expand Up @@ -60,12 +67,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();
Expand All @@ -83,17 +95,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",
});
Expand Down Expand Up @@ -148,6 +151,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 {
Expand All @@ -156,11 +168,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, ()> {
Expand Down
128 changes: 128 additions & 0 deletions crates/session/tests/json_grammar.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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}";
Expand Down
19 changes: 14 additions & 5 deletions docs/SESSION_SCHEMA_V1.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading
Loading