diff --git a/crates/sc-compose/src/commands/compose_output.rs b/crates/sc-compose/src/commands/compose_output.rs index 6651e8cf..624e2c4d 100644 --- a/crates/sc-compose/src/commands/compose_output.rs +++ b/crates/sc-compose/src/commands/compose_output.rs @@ -126,17 +126,9 @@ fn append_json_record( rendered, ) .map_err(CommandError::render_check)?; - let value: serde_json::Value = serde_json::from_str(checked.body()).map_err(|error| { - CommandError::render_append( - anyhow!(error).context(format!( - "failed to parse checked JSON from template {}", - template_path.display() - )), - DiagnosticCode::ErrRenderJsonMalformed, - Vec::new(), - ) - })?; - if !value.is_object() { + // The body already passed JSON syntax validation. Inspect only its root + // token, avoiding a numeric conversion that would reject valid exponents. + if !checked.body().trim_start().starts_with('{') { return Err(CommandError::render_append( anyhow!("--append requires the rendered output to be a JSON object"), DiagnosticCode::ErrRenderAppendNotObject, diff --git a/crates/sc-compose/tests/cli/fuzz_regressions.rs b/crates/sc-compose/tests/cli/fuzz_regressions.rs index 3fef7365..2706999a 100644 --- a/crates/sc-compose/tests/cli/fuzz_regressions.rs +++ b/crates/sc-compose/tests/cli/fuzz_regressions.rs @@ -426,3 +426,26 @@ fn fuzz_017_nested_raw_values_append_as_one_line_without_changing_lexemes() { assert_eq!(envelope["payload"]["bytes_written"], appended.len()); assert_eq!(envelope["payload"]["appended"], true); } + +// FUZZ-017 round 3: JSON number grammar is not limited by floating-point range. +#[test] +fn fuzz_017_append_preserves_large_exponent_lexemes() { + let root = temp_root("fuzz-017-large-exponent"); + write_file( + &root.join("rec.json.j2"), + r#"{"n":1e400,"tiny":-1.2300e-4000}"#, + ); + let destination = root.join("log.jsonl"); + let output = sc_compose() + .args(["render", "--file", "rec.json.j2", "--root"]) + .arg(&root) + .arg("--append") + .arg(&destination) + .output() + .unwrap(); + assert!(output.status.success(), "{output:?}"); + assert_eq!( + std::fs::read_to_string(destination).unwrap(), + "{\"n\":1e400,\"tiny\":-1.2300e-4000}\n" + ); +} diff --git a/crates/sc-composer/src/render_check.rs b/crates/sc-composer/src/render_check.rs index 1dc99c9b..425835bd 100644 --- a/crates/sc-composer/src/render_check.rs +++ b/crates/sc-composer/src/render_check.rs @@ -311,20 +311,20 @@ pub fn check_rendered_output_with_meta( }); } - let Err(error) = serde_json::from_str::(rendered) else { - return Ok(CheckedOutput { - body: rendered.to_owned(), - meta, - }); - }; - - let line = error.line(); - let column = error.column(); - let byte_offset = byte_offset_at(rendered, line, column, error.classify()); - // Keep the parser's first syntax failure authoritative. Its recursion - // guard reports the opening container at the unsupported depth; only - // matching structural evidence changes that failure's classification. - if excessive_json_depth(rendered) == Some(byte_offset) { + // RawValue validates JSON grammar without converting number lexemes to f64. + // Its iterative parser is paired with our explicit nesting bound below. + let parse_error = serde_json::from_str::<&serde_json::value::RawValue>(rendered).err(); + let syntax_offset = parse_error + .as_ref() + .map(|error| byte_offset_at(rendered, error.line(), error.column(), error.classify())); + // Preserve the first failure: an earlier syntax error remains malformed, + // while exceeding the supported depth is reported before later failures. + if let Some(byte_offset) = excessive_json_depth(rendered) + && syntax_offset.is_none_or(|offset| byte_offset <= offset) + { + let prefix = &rendered[..byte_offset]; + let line = prefix.bytes().filter(|byte| *byte == b'\n').count() + 1; + let column = prefix.rsplit('\n').next().map_or(0, str::len) + 1; return Err(OutputCheckError { reason: OutputCheckReason::JsonDepthLimit { limit: MAX_JSON_NESTING_DEPTH }, diagnostics: vec![Diagnostic::new( @@ -334,6 +334,15 @@ pub fn check_rendered_output_with_meta( ).with_path(&meta.template).with_location(line, column)], }); } + let Some(error) = parse_error else { + return Ok(CheckedOutput { + body: rendered.to_owned(), + meta, + }); + }; + let line = error.line(); + let column = error.column(); + let byte_offset = byte_offset_at(rendered, line, column, error.classify()); let diagnostic = Diagnostic::new( DiagnosticSeverity::Error, @@ -435,6 +444,28 @@ mod tests { } } + #[test] + fn json_number_validation_preserves_grammar_beyond_f64_range() { + let body = r#"{"n":1e400,"tiny":-1.2300e-4000}"#; + assert_eq!( + check_rendered_output(OutputFormat::Json, Path::new("numbers.json.j2"), body) + .expect("valid number lexemes") + .body(), + body + ); + let deep = format!("{}1e400{}", "[".repeat(128), "]".repeat(128)); + let error = check_rendered_output(OutputFormat::Json, Path::new("deep.json.j2"), &deep) + .expect_err("depth remains bounded"); + assert_eq!( + error.reason, + super::OutputCheckReason::JsonDepthLimit { limit: 127 } + ); + assert_eq!( + error.diagnostics[0].code, + crate::DiagnosticCode::ErrRenderJsonDepthLimit + ); + } + #[test] fn json_depth_boundary_has_a_distinct_typed_failure() { for (open, close) in [("{\"a\":", "}"), ("[", "]")] {