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
11 changes: 11 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 1 addition & 1 deletion src/abduct/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/adjudicate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/aggregate/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/amuck/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/assay/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/assemblyline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -534,7 +534,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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/axial/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
2 changes: 1 addition & 1 deletion src/bridge/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}
Expand Down
77 changes: 77 additions & 0 deletions src/json.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
// SPDX-License-Identifier: MPL-2.0
// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell <j.d.a.jewell@open.ac.uk>

//! 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<T: Serialize + ?Sized>(value: &T) -> Result<String> {
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<f64>,
flag: bool,
none: Option<u8>,
}

#[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"));
}
}
1 change: 1 addition & 0 deletions src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
10 changes: 6 additions & 4 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1443,15 +1443,17 @@ 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)?;
qprintln!(cli.quiet, "Report saved to: {}", output_path.display());
} 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);
Expand Down Expand Up @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/report/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ impl ReportFormatter {

#[allow(dead_code)]
pub fn save<P: AsRef<Path>>(&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(())
}
Expand Down
3 changes: 1 addition & 2 deletions src/report/output.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -39,7 +38,7 @@ impl ReportOutputFormat {

pub fn serialize(&self, report: &AssaultReport) -> Result<String> {
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)),
Expand Down
2 changes: 1 addition & 1 deletion src/storage/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
Loading