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
14 changes: 3 additions & 11 deletions crates/sc-compose/src/commands/compose_output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
23 changes: 23 additions & 0 deletions crates/sc-compose/tests/cli/fuzz_regressions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"
);
}
59 changes: 45 additions & 14 deletions crates/sc-composer/src/render_check.rs
Original file line number Diff line number Diff line change
Expand Up @@ -311,20 +311,20 @@ pub fn check_rendered_output_with_meta(
});
}

let Err(error) = serde_json::from_str::<serde_json::Value>(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(
Expand All @@ -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,
Expand Down Expand Up @@ -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\":", "}"), ("[", "]")] {
Expand Down
Loading