diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index dae856e..9169f48 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,18 +2,20 @@ === [Unreleased] -==== Added — `+attest verify --report+` and `+--require-bound+` - -* `+attest verify --report +` rehashes the report file, streaming it -through a fixed-size buffer, and fails if it -does not match the seal's `+report_hash+`. Without it, verification only checks -that the stored digests agree with each other, so a report edited after sealing -still verified. -* `+attest verify --require-bound+` fails on legacy seals (no `+hash_scheme+`), -whose intent and evidence cannot be rehashed. -* The output now says whether the report was checked. Library callers use -`+VerifyOptions+` (carrying the report digest from `+sha256_reader+`) with -`+verify_attestation_file_with+` / `+verify_attestation_str_with+`; `+VerifyResult::Ok+` gains `+report_checked+`. +==== Added — `+assail --output-format kyaml+` writes a KYAML copy + +* With `+--output-format kyaml+` and `+--output report.json+`, `+assail+` writes +`+report.kyaml+` beside the JSON report. The JSON stays the report of record: it +is what `+--attest+` seals. `+--output+` must end in `+.json+` (any letter case), +and no two of the files written (report, copy, and the `+--attest+` sidecar) +may be the same file: a symbolic link at any of their paths, or two existing +paths that are hard links to one file, is refused before anything is written. +So is a path that exists but is not a regular file (a directory, say) or that +cannot be looked up, such as a derived name over the filesystem's length limit. +These checks run before the writes, so a link swapped in between a check and +its write is not caught, and a crash can leave a partial file: the planned +generation-directory publish is meant to close both. +Other formats are unchanged for `+assail+`. ==== Fixed — KYAML keys holding a line separator diff --git a/Cargo.lock b/Cargo.lock index 68bef8d..10045ee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3062,6 +3062,7 @@ dependencies = [ "proptest", "rayon", "regex", + "same-file", "serde", "serde-saphyr", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index 06e46e3..ef2e7d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,8 @@ sha2 = "0.11" hex = "0.4" getrandom = "0.4" walkdir = "2.5" -toml_edit = { version = "0.25", default-features = false, features = ["parse"] } +same-file = "1.0" # assail: refuse a KYAML copy that aliases the sealed JSON report +toml_edit = { version = "0.23", default-features = false, features = ["parse"], optional = true } dirs = "7.0" ed25519-dalek = { version = "3.0", optional = true } ureq = { version = "3.3", optional = true } diff --git a/README.adoc b/README.adoc index ff2f193..2bcedff 100644 --- a/README.adoc +++ b/README.adoc @@ -172,7 +172,7 @@ Globally applied flags let automation pipelines control verbosity, output format | Flag | Default | Description | `--report-view` | `Accordion` | Controls the presentation mode (`Accordion`, `Summary`, `Timeline`) that the CLI writes and that PanLL mirrors. | `--expand-sections` | `false` | Expands extra detail blocks in the formatted report. -| `--output-format` | `json` | Chooses how saved reports are serialized: `json` (I-JSON/JCS), `yaml`, `kyaml` (the estate YAML dialect, byte-compatible with `yq -o kyaml`), `nickel` or `sarif`. The CLI reuses this when it writes PanLL exports or temporal diffs. `assail` always writes JSON. +| `--output-format` | `json` | Chooses how saved reports are serialized: `json` (I-JSON/JCS), `yaml`, `kyaml` (the estate YAML dialect, byte-compatible with `yq -o kyaml`), `nickel` or `sarif`. The CLI reuses this when it writes PanLL exports or temporal diffs. `assail` always writes the JSON report (the one `--attest` seals); with `kyaml` it also writes a `.kyaml` copy beside it, so `--output` must name a `.json` file. | `--pivot` | `false` | Rotates the narrative axis for boards that expect events-first storytelling. | `--store` | unset | Directory where generated reports, exports, and artifacts should land. | `--quiet` | `false` | Suppresses informational log lines for automation. diff --git a/src/main.rs b/src/main.rs index 263cc0a..1f1ee91 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1344,6 +1344,108 @@ fn parse_axis(value: &str) -> Option { } } +/// Where `assail --attest` writes its attestation sidecar: beside the report +/// at `output` as `.attestation.json`, or in the working directory when +/// the report goes to stdout. The writer and the alias check below both use +/// this, so the two cannot drift apart. +fn attestation_sidecar_path(output: Option<&Path>) -> PathBuf { + match output { + Some(out) => { + let stem = out.file_stem().unwrap_or_default().to_string_lossy(); + let parent = out.parent().unwrap_or(Path::new(".")); + parent.join(format!("{}.attestation.json", stem)) + } + None => PathBuf::from("assail-report.attestation.json"), + } +} + +/// Where `assail --output-format kyaml` writes its KYAML copy of the report +/// at `out`: beside it, with a `.kyaml` extension. `sidecar` is the +/// attestation sidecar when `--attest` is on. +/// +/// The JSON at `out` is the report of record and the attestation seals its +/// bytes, so no two of the files written here may be the same file. They +/// share a directory and are named `S.json`, `S.kyaml` and +/// `S.attestation.json`, so they can alias in only three ways, and each is +/// refused before anything is written: +/// - equal names ignoring letter case: `--output` must end in `.json` (in any +/// case), which keeps the three names distinct on case-insensitive +/// filesystems too; +/// - a symbolic link at any of the paths, which could point at another, even +/// a dangling link that an earlier write would bring into existence; +/// - two existing paths that are already one file, such as hard links. +/// +/// These checks run before the writes, so a link swapped in between a check +/// and its write (a time-of-check/time-of-use race) still slips past them. +/// Closing that race, and keeping a crash from leaving a partial report, is +/// the job of the generation-directory publish planned as a follow-up. +fn kyaml_copy_path(out: &Path, sidecar: Option<&Path>) -> Result { + let path = out.with_extension("kyaml"); + let named_json = out + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("json")); + anyhow::ensure!( + named_json, + "with --output-format kyaml, assail --output names the JSON report and \ + must end in .json; the KYAML copy is written beside it as {}", + path.display() + ); + let mut outputs = vec![("report", out), ("KYAML copy", path.as_path())]; + if let Some(sidecar) = sidecar { + outputs.push(("attestation sidecar", sidecar)); + } + // Look every path up before writing anything. Only "not found" counts as + // absent: any other failure (a name over the filesystem's length limit, + // a permission error) would make a later write fail after an earlier one + // had already landed, so it stops the run here instead. + let mut exists = Vec::with_capacity(outputs.len()); + for &(role, candidate) in &outputs { + match fs::symlink_metadata(candidate) { + Ok(meta) => { + anyhow::ensure!( + !meta.file_type().is_symlink(), + "refusing to write the {role} through the symbolic link {} \ + (it could alias another output)", + candidate.display() + ); + // A directory or other special entry can't be written as a + // file, so the write would fail after earlier ones landed. + anyhow::ensure!( + meta.is_file(), + "the {role} path {} exists and is not a regular file; \ + nothing was written", + candidate.display() + ); + exists.push(true); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => exists.push(false), + Err(err) => anyhow::bail!( + "cannot use {} as the {role}: {err}; nothing was written", + candidate.display() + ), + } + } + // No path is a symlink now, so only a hard link can join two of them. + for (i, &(role_a, a)) in outputs.iter().enumerate() { + for (j, &(role_b, b)) in outputs.iter().enumerate().skip(i + 1) { + if exists[i] && exists[j] { + anyhow::ensure!( + !same_file::is_same_file(a, b).with_context(|| format!( + "comparing {} with {}", + a.display(), + b.display() + ))?, + "the {role_b} {} is the same file as the {role_a} {}; \ + refusing to overwrite it", + b.display(), + a.display() + ); + } + } + } + Ok(path) +} + fn default_amuck_report_path() -> PathBuf { let ts = chrono::Utc::now().format("%Y%m%d%H%M%S"); PathBuf::from(format!("reports/amuck-{}.json", ts)) @@ -1494,6 +1596,18 @@ fn run_main() -> Result<()> { // exactly these bytes, so any verifier can recompute them. let report_json = panic_attack::json::to_canonical_string(&report)?; + // `--output-format kyaml` adds a KYAML copy beside the JSON report. + // The JSON stays the report of record: the seal covers its bytes. + let sidecar_path = chain_builder + .is_some() + .then(|| attestation_sidecar_path(output.as_deref())); + let kyaml_path = match &output { + Some(out) if cli.output_format == ReportOutputFormat::Kyaml => { + Some(kyaml_copy_path(out, sidecar_path.as_deref())?) + } + _ => None, + }; + if let Some(output_path) = &output { fs::write(output_path, &report_json)?; qprintln!( @@ -1501,6 +1615,14 @@ fn run_main() -> Result<()> { "Report saved to: {}", output_path.display() ); + if let Some(kyaml_path) = &kyaml_path { + fs::write(kyaml_path, panic_attack::kyaml::to_string(&report)?)?; + qprintln!( + cli.quiet || headless, + "KYAML copy saved to: {}", + kyaml_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. @@ -1519,18 +1641,10 @@ fn run_main() -> Result<()> { } // Seal and write attestation sidecar - if let Some(builder) = chain_builder { + if let (Some(builder), Some(sidecar_path)) = (chain_builder, sidecar_path) { let envelope = builder.seal(report_json.as_bytes(), signing_key.as_deref())?; let attestation_json = panic_attack::json::to_canonical_string(&envelope)?; - let sidecar_path = if let Some(out) = &output { - let stem = out.file_stem().unwrap_or_default().to_string_lossy(); - let parent = out.parent().unwrap_or(Path::new(".")); - parent.join(format!("{}.attestation.json", stem)) - } else { - PathBuf::from("assail-report.attestation.json") - }; - fs::write(&sidecar_path, attestation_json)?; qprintln!( cli.quiet || headless, diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index 9e3e00f..14ba67a 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -254,3 +254,254 @@ fn oversize_source_is_not_reported_as_analyzed() { .contains("16 MiB")); assert!(report.coverage.incomplete()); } + +#[test] +fn kyaml_output_format_writes_a_copy_beside_the_sealed_json() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("clean.php"), "` on a one-file project. +fn assail_kyaml(project: &std::path::Path, out: &std::path::Path) -> std::process::Output { + Command::new(env!("CARGO_BIN_EXE_panic-attack")) + .args(["--output-format", "kyaml", "assail"]) + .arg(project) + .arg("--output") + .arg(out) + .output() + .unwrap() +} + +#[test] +fn kyaml_output_format_refuses_a_kyaml_name_in_any_case() { + // On a case-insensitive filesystem report.KYAML and report.kyaml are + // one file, so the copy would overwrite the report. + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join("clean.php"), " report.kyaml link. The JSON + // write would create report.kyaml and the KYAML write would then + // overwrite the sealed bytes. + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir(&project).unwrap(); + std::fs::write(project.join("clean.php"), " std::process::Output { + Command::new(env!("CARGO_BIN_EXE_panic-attack")) + .args(["--output-format", "kyaml", "assail"]) + .arg(project) + .arg("--output") + .arg(out) + .arg("--attest") + .output() + .unwrap() +} + +/// A temp dir holding a one-file project in `project/`. +fn project_dir() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let project = dir.path().join("project"); + std::fs::create_dir(&project).unwrap(); + std::fs::write(project.join("clean.php"), "