From 8603d8d8c952c43bd83ac4d55a28aabd023de65d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 20 Oct 2026 14:14:41 +0000 Subject: [PATCH] feat(json): write JSON report artefacts as I-JSON/JCS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The estate JSON policy (standards 3-practice/JSON-POLICY.adoc, rule J-1) makes authored JSON I-JSON (RFC 7493) serialised as JCS (RFC 8785) with one final line feed. panic-attack wrote every report with to_string_pretty, so none of it was canonical. Add crate::json::to_canonical_string, which serialises through serde_json::Value and canonicalises with ijson-jcs (the tool behind the estate gate, pinned to a full commit SHA because it is not on crates.io), so output passes `ijson-jcs check` by construction. Route the report artefacts through it: - ReportOutputFormat::Json (--output-format json, save_report, storage) - assail --output and its --quiet/--headless stdout form - the amuck, abduct, axial, adjudicate, assay, aggregate and assemblyline reports, the assemblyline storage copy and ReportFormatter::save - the patch-bridge triage report and mitigation registry The assail --attest seal already hashes report_json.as_bytes(), so it now hashes canonical bytes. Stdout prints use print!, because the canonical string already ends in its one LF. Left alone, with separate consumers: VeriSimDB hexads, SARIF, PanLL exports, the attestation envelope (next PR) and ad-hoc CLI JSON. Checks: build on stable and on 1.89.0 --locked, clippy -D warnings --all-features, 662 tests pass. New tests cover the RFC 8785 rules (sorted keys, 1e+21, -0, \u0001, one LF), the I-JSON ±(2^53-1) bound, and idempotence on a real report. `assail --output` and stdout outputs both pass `ijson-jcs check`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PFaqv6iBXrt3W5Akd1Fq5k --- CHANGELOG.adoc | 14 ++++++++ Cargo.lock | 11 ++++++ Cargo.toml | 3 ++ src/abduct/mod.rs | 2 +- src/adjudicate/mod.rs | 2 +- src/aggregate/mod.rs | 2 +- src/amuck/mod.rs | 2 +- src/assay/mod.rs | 2 +- src/assemblyline.rs | 2 +- src/axial/mod.rs | 2 +- src/bridge/registry.rs | 2 +- src/json.rs | 77 +++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + src/main.rs | 10 +++--- src/report/formatter.rs | 2 +- src/report/output.rs | 3 +- src/storage/mod.rs | 2 +- 17 files changed, 123 insertions(+), 16 deletions(-) create mode 100644 src/json.rs diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 71bb1ee..d72bf10 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,20 @@ === [Unreleased] +==== Changed — JSON report artefacts are I-JSON/JCS (JSON-POLICY rule J-1) + +* Every JSON report panic-attack writes is now canonical: I-JSON (RFC 7493) +serialised as JCS (RFC 8785) with one final line feed, through +`+crate::json::to_canonical_string+` and the estate tool `+ijson-jcs+`. This +covers `+--output-format json+`, `+assail --output+` and its stdout form, the +assault, amuck, abduct, axial, adjudicate, assay, aggregate and assemblyline +reports, and the patch-bridge triage report and registry. Output is compact: +pipe it through `+jq .+` to read it. +* The `+assail --attest+` seal hashes these canonical bytes, so the report hash +is reproducible by any JCS implementation. +* A report that is not I-JSON (for example an integer beyond ±(2^53−1)) now +fails to serialise instead of being written. + ==== Fixed — YAML parser replaced with safe Rust (RUSTSEC-2024-0449) * *`+serde_yaml+` → `+serde-saphyr+` 2.0.* `+serde_yaml+` 0.9 is diff --git a/Cargo.lock b/Cargo.lock index ac99989..782ee83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2128,6 +2128,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ijson-jcs" +version = "0.1.0" +source = "git+https://github.com/hyperpolymath/ijson-jcs?rev=3a9a4ee326a968a32b2f152b4faec243a2d00765#3a9a4ee326a968a32b2f152b4faec243a2d00765" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "image" version = "0.25.10" @@ -2935,6 +2945,7 @@ dependencies = [ "filetime", "getrandom 0.4.3", "hex", + "ijson-jcs", "log", "proptest", "rayon", diff --git a/Cargo.toml b/Cargo.toml index bc94348..c2aea07 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,9 @@ anyhow = "1.0" log = "0.4" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +# JCS (RFC 8785) for JSON artefacts: the estate tool behind JSON-POLICY rule +# J-1. Not on crates.io, so pinned to a full commit SHA. +ijson-jcs = { git = "https://github.com/hyperpolymath/ijson-jcs", rev = "3a9a4ee326a968a32b2f152b4faec243a2d00765" } # Pure safe-Rust YAML (replaces serde_yaml + unsafe-libyaml, RUSTSEC-2024-0449). serde-saphyr = "2.0" regex = "1.10" diff --git a/src/abduct/mod.rs b/src/abduct/mod.rs index 6c5f9c7..52215c9 100644 --- a/src/abduct/mod.rs +++ b/src/abduct/mod.rs @@ -257,7 +257,7 @@ pub fn write_report(report: &AbductReport, path: &Path) -> Result<()> { fs::create_dir_all(parent) .with_context(|| format!("creating report parent directory {}", parent.display()))?; } - let json = serde_json::to_string_pretty(report).context("serializing abduct report")?; + let json = crate::json::to_canonical_string(report).context("serializing abduct report")?; fs::write(path, json).with_context(|| format!("writing report {}", path.display()))?; Ok(()) } diff --git a/src/adjudicate/mod.rs b/src/adjudicate/mod.rs index cbe2610..6e74184 100644 --- a/src/adjudicate/mod.rs +++ b/src/adjudicate/mod.rs @@ -227,7 +227,7 @@ pub fn write_report(report: &AdjudicateReport, path: &Path) -> Result<()> { fs::create_dir_all(parent) .with_context(|| format!("creating report parent directory {}", parent.display()))?; } - let json = serde_json::to_string_pretty(report).context("serializing adjudicate report")?; + let json = crate::json::to_canonical_string(report).context("serializing adjudicate report")?; fs::write(path, json).with_context(|| format!("writing report {}", path.display()))?; Ok(()) } diff --git a/src/aggregate/mod.rs b/src/aggregate/mod.rs index 45d39bc..25bab39 100644 --- a/src/aggregate/mod.rs +++ b/src/aggregate/mod.rs @@ -408,7 +408,7 @@ pub fn write_report(report: &AggregateReport, path: &Path) -> Result<()> { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } } - let json = serde_json::to_string_pretty(report).context("serializing aggregate report")?; + let json = crate::json::to_canonical_string(report).context("serializing aggregate report")?; fs::write(path, json).with_context(|| format!("writing {}", path.display()))?; Ok(()) } diff --git a/src/amuck/mod.rs b/src/amuck/mod.rs index 9ff436a..50fa439 100644 --- a/src/amuck/mod.rs +++ b/src/amuck/mod.rs @@ -249,7 +249,7 @@ pub fn write_report(report: &AmuckReport, path: &Path) -> Result<()> { fs::create_dir_all(parent) .with_context(|| format!("creating report parent directory {}", parent.display()))?; } - let json = serde_json::to_string_pretty(report).context("serializing amuck report")?; + let json = crate::json::to_canonical_string(report).context("serializing amuck report")?; fs::write(path, json).with_context(|| format!("writing report {}", path.display()))?; Ok(()) } diff --git a/src/assay/mod.rs b/src/assay/mod.rs index 4cd34f0..a0dc57f 100644 --- a/src/assay/mod.rs +++ b/src/assay/mod.rs @@ -288,7 +288,7 @@ pub fn write_report(report: &AssayReport, path: &Path) -> Result<()> { fs::create_dir_all(parent).with_context(|| format!("creating {}", parent.display()))?; } } - let json = serde_json::to_string_pretty(report).context("serializing assay report")?; + let json = crate::json::to_canonical_string(report).context("serializing assay report")?; fs::write(path, json).with_context(|| format!("writing {}", path.display()))?; Ok(()) } diff --git a/src/assemblyline.rs b/src/assemblyline.rs index 2b831ef..c07500f 100644 --- a/src/assemblyline.rs +++ b/src/assemblyline.rs @@ -533,7 +533,7 @@ pub fn write_report(report: &AssemblylineReport, path: &Path) -> Result<()> { if let Some(parent) = path.parent() { fs::create_dir_all(parent)?; } - let json = serde_json::to_string_pretty(report)?; + let json = crate::json::to_canonical_string(report)?; fs::write(path, json)?; Ok(()) } diff --git a/src/axial/mod.rs b/src/axial/mod.rs index c066e6e..6b6c791 100644 --- a/src/axial/mod.rs +++ b/src/axial/mod.rs @@ -279,7 +279,7 @@ pub fn write_report(report: &AxialReport, path: &Path) -> Result<()> { fs::create_dir_all(parent) .with_context(|| format!("creating report parent directory {}", parent.display()))?; } - let json = serde_json::to_string_pretty(report).context("serializing axial report")?; + let json = crate::json::to_canonical_string(report).context("serializing axial report")?; fs::write(path, json).with_context(|| format!("writing report {}", path.display()))?; Ok(()) } diff --git a/src/bridge/registry.rs b/src/bridge/registry.rs index 9749606..5397a43 100644 --- a/src/bridge/registry.rs +++ b/src/bridge/registry.rs @@ -109,7 +109,7 @@ impl MitigationRegistry { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } - let content = serde_json::to_string_pretty(self)?; + let content = crate::json::to_canonical_string(self)?; std::fs::write(&path, content)?; Ok(()) } diff --git a/src/json.rs b/src/json.rs new file mode 100644 index 0000000..2e25381 --- /dev/null +++ b/src/json.rs @@ -0,0 +1,77 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +//! Canonical JSON for report artefacts: I-JSON (RFC 7493) serialised as +//! JCS (RFC 8785) and followed by exactly one line feed, per the estate +//! JSON policy (`hyperpolymath/standards` `3-practice/JSON-POLICY.adoc`, +//! rule J-1). +//! +//! Canonicalisation goes through `ijson-jcs`, the tool behind the estate's +//! JSON gate, so every artefact written here passes `ijson-jcs check` by +//! construction. Equal data always yields equal bytes, which is what makes +//! report hashes reproducible outside this crate. + +use anyhow::{Context, Result}; +use serde::Serialize; + +/// Serialise `value` as canonical JSON (JCS) followed by one line feed. +/// +/// Fails if `value` is not I-JSON, for example an integer beyond ±2^53. +pub fn to_canonical_string(value: &T) -> Result { + let tree = serde_json::to_value(value).context("serializing to JSON")?; + let mut out = ijson_jcs::to_jcs_string(&tree).context("canonicalizing JSON (JCS)")?; + out.push('\n'); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde::Serialize; + + #[derive(Serialize)] + struct Sample { + zeta: u32, + alpha: &'static str, + nested: Vec, + flag: bool, + none: Option, + } + + #[test] + fn sorts_keys_compacts_and_ends_with_one_lf() { + let sample = Sample { + zeta: 1, + alpha: "a\"b\\c\n\u{1}é", + nested: vec![1.0, 0.5, 1e21, -0.0], + flag: true, + none: None, + }; + let out = to_canonical_string(&sample).expect("canonical JSON"); + assert_eq!( + out, + "{\"alpha\":\"a\\\"b\\\\c\\n\\u0001é\",\"flag\":true,\"nested\":[1,0.5,1e+21,0],\"none\":null,\"zeta\":1}\n" + ); + } + + #[test] + fn rejects_integers_outside_i_json_range() { + // I-JSON safe integers: [-(2^53 - 1), 2^53 - 1]. + assert!(to_canonical_string(&((1u64 << 53) - 1)).is_ok()); + assert!(to_canonical_string(&(1u64 << 53)).is_err()); + assert!(to_canonical_string(&u64::MAX).is_err()); + assert!(to_canonical_string(&i64::MIN).is_err()); + } + + #[test] + fn is_idempotent_on_its_own_output() { + let report = crate::assail::analyze( + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("examples/vulnerable_program.rs"), + ) + .expect("analysis should succeed"); + let once = to_canonical_string(&report).expect("canonical JSON"); + let parsed: serde_json::Value = serde_json::from_str(&once).expect("parses back"); + assert_eq!(once, to_canonical_string(&parsed).expect("canonical JSON")); + assert_eq!(parsed, serde_json::to_value(&report).expect("value")); + } +} diff --git a/src/lib.rs b/src/lib.rs index f81d783..240071c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -34,6 +34,7 @@ pub mod comment_marker; pub mod ffi_kind; pub mod i18n; pub mod jit_context; +pub mod json; pub mod kanren; pub mod mass_panic; pub mod notify; diff --git a/src/main.rs b/src/main.rs index 3b94ad8..a7bc097 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1443,7 +1443,9 @@ fn run_main() -> Result<()> { } }; - let report_json = serde_json::to_string_pretty(&report)?; + // Canonical JSON (JCS, one final LF): the attestation seal hashes + // exactly these bytes, so any verifier can recompute them. + let report_json = panic_attack::json::to_canonical_string(&report)?; if let Some(output_path) = &output { fs::write(output_path, &report_json)?; @@ -1451,7 +1453,7 @@ fn run_main() -> Result<()> { } else if cli.quiet || headless { // Machine-readable mode: emit JSON to stdout for pipeline consumers. // `--headless` makes this explicit for CI callers that cannot pass --quiet. - println!("{report_json}"); + print!("{report_json}"); } else { println!("\nAssail Summary:"); println!(" Language: {:?}", report.language); @@ -2528,13 +2530,13 @@ fn run_main() -> Result<()> { } // Output JSON - let json = serde_json::to_string_pretty(&report)?; + let json = panic_attack::json::to_canonical_string(&report)?; if let Some(out_path) = output { std::fs::write(&out_path, &json)?; qprintln!(cli.quiet, "\nReport written to: {}", out_path.display()); } else if cli.quiet { // In quiet mode with no output file, print JSON to stdout - println!("{}", json); + print!("{}", json); } } diff --git a/src/report/formatter.rs b/src/report/formatter.rs index 2ac7876..8bdcc4f 100644 --- a/src/report/formatter.rs +++ b/src/report/formatter.rs @@ -87,7 +87,7 @@ impl ReportFormatter { #[allow(dead_code)] pub fn save>(&self, report: &AssaultReport, path: P) -> Result<()> { - let serialized = serde_json::to_string_pretty(report)?; + let serialized = crate::json::to_canonical_string(report)?; fs::write(path, serialized)?; Ok(()) } diff --git a/src/report/output.rs b/src/report/output.rs index 44c4a63..1a34a1b 100644 --- a/src/report/output.rs +++ b/src/report/output.rs @@ -7,7 +7,6 @@ use crate::report::formatter::nickel_escape_string; use crate::types::AssaultReport; use anyhow::Result; use clap::ValueEnum; -use serde_json; #[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)] pub enum ReportOutputFormat { @@ -39,7 +38,7 @@ impl ReportOutputFormat { pub fn serialize(&self, report: &AssaultReport) -> Result { match self { - ReportOutputFormat::Json => Ok(serde_json::to_string_pretty(report)?), + ReportOutputFormat::Json => crate::json::to_canonical_string(report), ReportOutputFormat::Yaml => Ok(crate::yaml::to_string(report)?), // Nickel output is a compact projection for config-centric consumers. ReportOutputFormat::Nickel => Ok(format_report_as_nickel(report)), diff --git a/src/storage/mod.rs b/src/storage/mod.rs index 3680f52..2ff4cb2 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -912,7 +912,7 @@ pub fn persist_assemblyline_report( fs::create_dir_all(&base_dir)?; let file_name = format!("assemblyline-{}.json", timestamp); let path = base_dir.join(&file_name); - let content = serde_json::to_string_pretty(report)?; + let content = crate::json::to_canonical_string(report)?; fs::write(&path, content)?; stored.push(path); }