diff --git a/CHANGELOG.adoc b/CHANGELOG.adoc index 50f9828..00f0d7a 100644 --- a/CHANGELOG.adoc +++ b/CHANGELOG.adoc @@ -2,6 +2,26 @@ === [Unreleased] +==== 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. +In this mode each file is written by renaming a finished temporary file into +place (`+atomic_write::write_replacing+`, which also flushes the directory so +the rename survives a crash), so a link swapped in after those +checks is replaced rather than written through, and no reader sees a partial +report. On Unix a replaced file keeps the old file's permission bits, and the +temporary file is never more readable than the file it replaces; a new copy or +sidecar takes the report's permission bits, so a private report's companions +are private too. +Other formats are unchanged for `+assail+`. + ==== Fixed — KYAML keys holding a line separator * KYAML output now escapes U+2028 (LS) and U+2029 (PS) as `+\uXXXX+`, as it diff --git a/Cargo.lock b/Cargo.lock index d10e538..3997621 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3061,6 +3061,7 @@ dependencies = [ "proptest", "rayon", "regex", + "same-file", "serde", "serde-saphyr", "serde_json", diff --git a/Cargo.toml b/Cargo.toml index a412e58..704affe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,7 @@ sha2 = "0.11" hex = "0.4" getrandom = "0.4" walkdir = "2.5" +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 } 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/atomic_write.rs b/src/atomic_write.rs new file mode 100644 index 0000000..6a793af --- /dev/null +++ b/src/atomic_write.rs @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: MPL-2.0 +// SPDX-FileCopyrightText: 2026 Jonathan D.A. Jewell + +//! Replace a file's contents by renaming a fully written temporary file over +//! it. +//! +//! [`write_replacing`] writes the bytes to a new file in the destination's +//! directory, created with `create_new` so it never opens anything that +//! already exists, flushes it to disk, renames it onto the destination, and +//! (on Unix) flushes the directory so the rename itself survives a crash. +//! The rename replaces the directory entry itself. So, unlike [`std::fs::write`]: +//! +//! - a symbolic link at the destination is replaced, never written through; +//! - a file hard-linked to the destination keeps its old contents; +//! - a reader sees the old file or the new one, never a partial write. +//! +//! A file swapped in at the destination between a caller's checks and the +//! write is therefore replaced rather than followed. +//! +//! Permissions: on Unix the temporary file is opened with the destination's +//! mode (less the umask) in the `open` call itself, then given that mode +//! exactly, so it is never readable more widely than the file it replaces and +//! replacing a `0600` report never exposes its contents. A new +//! destination gets the same default as `fs::write` (`0666` less the umask). +//! Only the read, write and execute bits (`0o777`) carry over: set-user-ID, +//! set-group-ID and sticky bits are dropped on purpose, since a written report +//! should never gain them. Ownership is not copied, and on other platforms the +//! new file takes the directory's default permissions. +//! +//! The temporary file has a short, unpredictable name (`.pa-tmp...`), +//! so a destination name near the filesystem's length limit still works. + +use std::fs::{self, OpenOptions}; +use std::io::{self, Write}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; + +/// Write `contents` to `path` by renaming a temporary file over it (see the +/// module documentation). +pub fn write_replacing(path: &Path, contents: impl AsRef<[u8]>) -> io::Result<()> { + write_replacing_like(path, contents, None) +} + +/// [`write_replacing`] for a file that accompanies another: when `path` does +/// not exist yet, it takes the permission bits of the regular file at `like` +/// rather than the default, so a copy of a private report is private too. An +/// existing `path` keeps its own bits. +pub fn write_replacing_like( + path: &Path, + contents: impl AsRef<[u8]>, + like: Option<&Path>, +) -> io::Result<()> { + if path.file_name().is_none() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "path has no file name", + )); + } + let mode = existing_mode(path).or_else(|| like.and_then(existing_mode)); + let (tmp_path, mut file) = create_temp_in(parent_dir(path), mode)?; + let written = restore_mode(&file, mode) + .and_then(|()| file.write_all(contents.as_ref())) + .and_then(|()| file.sync_all()); + drop(file); + // Open the directory before the rename, so a directory that can be + // written but not read fails here, with the destination untouched, + // rather than after it has been replaced. + let dir = written.and_then(|()| open_parent_dir(path)); + match dir.and_then(|dir| fs::rename(&tmp_path, path).map(|()| dir)) { + // The rename is in place; make its directory entry durable too. + Ok(dir) => sync_dir(dir), + Err(err) => { + // Best effort: the temporary file is ours and holds nothing else. + let _ = fs::remove_file(&tmp_path); + Err(err) + } + } +} + +/// The read, write and execute bits of the regular file at `path`, if there +/// is one (special bits are dropped; see the module docs). A symlink or other +/// non-regular entry lends nothing. +#[cfg(unix)] +fn existing_mode(path: &Path) -> Option { + use std::os::unix::fs::PermissionsExt; + fs::symlink_metadata(path) + .ok() + .filter(fs::Metadata::is_file) + .map(|meta| meta.permissions().mode() & 0o777) +} + +#[cfg(not(unix))] +fn existing_mode(_path: &Path) -> Option { + None +} + +/// Set the kept mode exactly. The file was opened with that mode less the +/// umask, so this can only add bits the destination already had. +#[cfg(unix)] +fn restore_mode(file: &fs::File, mode: Option) -> io::Result<()> { + use std::os::unix::fs::PermissionsExt; + match mode { + Some(mode) => file.set_permissions(fs::Permissions::from_mode(mode)), + None => Ok(()), + } +} + +#[cfg(not(unix))] +fn restore_mode(_file: &fs::File, _mode: Option) -> io::Result<()> { + Ok(()) +} + +/// Open `path`'s directory so it can be flushed after the rename: on Unix a +/// file sync does not persist the directory entry that names it. Elsewhere +/// there is nothing to open. +#[cfg(unix)] +fn open_parent_dir(path: &Path) -> io::Result> { + fs::File::open(parent_dir(path)).map(Some) +} + +#[cfg(not(unix))] +fn open_parent_dir(_path: &Path) -> io::Result> { + Ok(None) +} + +/// Flush the directory opened by [`open_parent_dir`] so the rename survives a +/// crash. An error here means the new file is in place but its durability is +/// unknown; the rename is not rolled back. +fn sync_dir(dir: Option) -> io::Result<()> { + dir.map_or(Ok(()), |dir| dir.sync_all()) +} + +/// The directory `path` lives in: its parent, or `.` for a bare file name +/// (whose parent is the empty path). +fn parent_dir(path: &Path) -> &Path { + match path.parent() { + Some(parent) if !parent.as_os_str().is_empty() => parent, + _ => Path::new("."), + } +} + +/// Create a new file with an unpredictable name in `dir`. +/// +/// On Unix it is opened with `mode` (or `0666` for a new destination), less +/// the umask, in the `open` call itself, so it is never readable more widely +/// than the file it replaces, or than `fs::write` would create. +fn create_temp_in(dir: &Path, mode: Option) -> io::Result<(PathBuf, fs::File)> { + static COUNTER: AtomicU64 = AtomicU64::new(0); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(mode.unwrap_or(0o666) & 0o777); + } + #[cfg(not(unix))] + let _ = mode; + let mut last_err = None; + for _ in 0..16 { + let n = COUNTER.fetch_add(1, Ordering::Relaxed); + let mut random = [0_u8; 8]; + // A predictable name only costs a retry, so a failed RNG is not fatal. + let _ = getrandom::fill(&mut random); + let tmp = dir.join(format!( + ".pa-tmp.{}.{n}.{}", + std::process::id(), + hex::encode(random) + )); + match options.open(&tmp) { + Ok(file) => return Ok((tmp, file)), + Err(err) if err.kind() == io::ErrorKind::AlreadyExists => last_err = Some(err), + Err(err) => return Err(err), + } + } + Err(last_err.unwrap_or_else(|| io::Error::other("no free temporary name"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn creates_and_replaces_a_file() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.json"); + write_replacing(&path, "first").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "first"); + write_replacing(&path, "second").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "second"); + // No temporary file is left behind. + let entries: Vec<_> = fs::read_dir(dir.path()).unwrap().collect(); + assert_eq!(entries.len(), 1); + } + + #[test] + fn a_hard_linked_file_keeps_its_contents() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.json"); + let other = dir.path().join("other"); + fs::write(&other, "untouched").unwrap(); + fs::hard_link(&other, &path).unwrap(); + write_replacing(&path, "new").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "new"); + assert_eq!(fs::read_to_string(&other).unwrap(), "untouched"); + } + + #[cfg(unix)] + #[test] + fn a_symlink_is_replaced_not_followed() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.json"); + let target = dir.path().join("target"); + fs::write(&target, "untouched").unwrap(); + std::os::unix::fs::symlink(&target, &path).unwrap(); + write_replacing(&path, "new").unwrap(); + assert!(!fs::symlink_metadata(&path) + .unwrap() + .file_type() + .is_symlink()); + assert_eq!(fs::read_to_string(&path).unwrap(), "new"); + assert_eq!(fs::read_to_string(&target).unwrap(), "untouched"); + } + + #[cfg(unix)] + #[test] + fn a_dangling_symlink_does_not_create_its_target() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.json"); + let target = dir.path().join("report.kyaml"); + std::os::unix::fs::symlink(&target, &path).unwrap(); + write_replacing(&path, "new").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "new"); + assert!(!target.exists()); + } + + #[test] + fn a_bare_file_name_lives_in_the_working_directory() { + assert_eq!(parent_dir(Path::new("report.json")), Path::new(".")); + assert_eq!(parent_dir(Path::new("out/report.json")), Path::new("out")); + assert_eq!(parent_dir(Path::new("/report.json")), Path::new("/")); + } + + #[cfg(unix)] + #[test] + fn an_existing_files_permissions_are_kept() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for mode in [0o600, 0o640, 0o644] { + let path = dir.path().join(format!("report-{mode:o}.json")); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(mode)).unwrap(); + write_replacing(&path, "new").unwrap(); + let got = fs::metadata(&path).unwrap().permissions().mode() & 0o7777; + assert_eq!(got, mode, "mode {mode:o} not kept"); + } + } + + #[cfg(unix)] + #[test] + fn a_new_file_gets_the_fs_write_default() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let ours = dir.path().join("ours"); + let theirs = dir.path().join("theirs"); + write_replacing(&ours, "x").unwrap(); + fs::write(&theirs, "x").unwrap(); + let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o7777; + assert_eq!(mode(&ours), mode(&theirs)); + } + + #[test] + fn a_destination_name_near_the_length_limit_works() { + // 250 bytes leaves room for `.json`/`.kyaml` within a 255-byte + // component limit; the temporary name must not grow with it. + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join(format!("{}.kyaml", "r".repeat(249))); + assert_eq!(path.file_name().unwrap().len(), 255); + write_replacing(&path, "ok").unwrap(); + assert_eq!(fs::read_to_string(&path).unwrap(), "ok"); + } + + #[cfg(unix)] + #[test] + fn the_temporary_file_is_never_wider_than_the_destination() { + // Checked straight after `open`, before any `set_permissions`: a + // reader who opens the temporary file early must find it no more + // readable than the file it will replace. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + for mode in [0o600, 0o640, 0o400] { + let (tmp, _file) = create_temp_in(dir.path(), Some(mode)).unwrap(); + let got = fs::metadata(&tmp).unwrap().permissions().mode() & 0o777; + assert_eq!(got & !mode, 0, "temp {got:o} wider than {mode:o}"); + fs::remove_file(tmp).unwrap(); + } + } + + #[cfg(unix)] + #[test] + fn a_new_companion_takes_the_mode_of_the_file_it_accompanies() { + use std::os::unix::fs::PermissionsExt; + let mode = |p: &Path| fs::metadata(p).unwrap().permissions().mode() & 0o7777; + let dir = tempfile::tempdir().unwrap(); + let report = dir.path().join("report.json"); + fs::write(&report, "{}").unwrap(); + fs::set_permissions(&report, fs::Permissions::from_mode(0o600)).unwrap(); + // A new copy follows the report. + let copy = dir.path().join("report.kyaml"); + write_replacing_like(©, "new", Some(&report)).unwrap(); + assert_eq!(mode(©), 0o600); + // An existing file keeps its own bits. + fs::set_permissions(©, fs::Permissions::from_mode(0o640)).unwrap(); + write_replacing_like(©, "again", Some(&report)).unwrap(); + assert_eq!(mode(©), 0o640); + } + + #[cfg(unix)] + #[test] + fn special_bits_are_not_carried_over() { + // A report should never gain set-user-ID, set-group-ID or sticky + // bits from the file it replaces; the rwx bits still carry over. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("report.json"); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o4755)).unwrap(); + write_replacing(&path, "new").unwrap(); + let got = fs::metadata(&path).unwrap().permissions().mode() & 0o7777; + assert_eq!(got, 0o755); + } + + #[test] + fn the_directory_sync_resolves_bare_and_nested_names() { + // A crash cannot be simulated here; this checks the sync reaches a + // real directory for both shapes of path. + let dir = tempfile::tempdir().unwrap(); + sync_dir(open_parent_dir(&dir.path().join("report.json")).unwrap()).unwrap(); + sync_dir(open_parent_dir(Path::new("report.json")).unwrap()).unwrap(); + } + + #[cfg(unix)] + #[test] + fn an_unreadable_directory_fails_before_the_destination_is_replaced() { + // A directory that can be written but not read accepts the rename, + // but cannot be opened for the sync. That must fail first, leaving + // the old file in place. Privileged users bypass the permission, so + // the test skips itself when the directory opens anyway. + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let out = dir.path().join("out"); + fs::create_dir(&out).unwrap(); + let path = out.join("report.json"); + fs::write(&path, "old").unwrap(); + fs::set_permissions(&out, fs::Permissions::from_mode(0o300)).unwrap(); + let privileged = fs::File::open(&out).is_ok(); + let result = (!privileged).then(|| write_replacing(&path, "new")); + fs::set_permissions(&out, fs::Permissions::from_mode(0o700)).unwrap(); + if let Some(result) = result { + assert!(result.is_err(), "the write should fail"); + assert_eq!(fs::read_to_string(&path).unwrap(), "old"); + assert_eq!( + fs::read_dir(&out).unwrap().count(), + 1, + "temp file left behind" + ); + } + } +} diff --git a/src/lib.rs b/src/lib.rs index 6760498..1133cb3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -24,6 +24,7 @@ pub mod amuck; pub mod assail; pub mod assay; pub mod assemblyline; +pub mod atomic_write; pub mod attack; pub mod attestation; pub mod axial; diff --git a/src/main.rs b/src/main.rs index c68c2c2..0b69ebb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1335,6 +1335,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 afterwards would +/// slip past them; the writes therefore go through +/// [`panic_attack::atomic_write::write_replacing`], which replaces a path's +/// directory entry instead of writing through it. +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 replaced by a + // rename, 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)) @@ -1485,13 +1587,50 @@ 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, + }; + // In KYAML mode every output is written by renaming a finished + // temporary file into place, so a link swapped in after the checks + // above is replaced, never written through. A new copy or sidecar + // takes the report's permissions (`like`), so a private report's + // companions are private too. + let write_output = + |path: &Path, bytes: &[u8], like: Option<&Path>| -> std::io::Result<()> { + if kyaml_path.is_some() { + panic_attack::atomic_write::write_replacing_like(path, bytes, like) + } else { + fs::write(path, bytes) + } + }; + if let Some(output_path) = &output { - fs::write(output_path, &report_json)?; + write_output(output_path, report_json.as_bytes(), None)?; qprintln!( cli.quiet || headless, "Report saved to: {}", output_path.display() ); + if let Some(kyaml_path) = &kyaml_path { + write_output( + kyaml_path, + panic_attack::kyaml::to_string(&report)?.as_bytes(), + Some(output_path), + )?; + 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. @@ -1510,19 +1649,15 @@ 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)?; + write_output( + &sidecar_path, + attestation_json.as_bytes(), + output.as_deref(), + )?; qprintln!( cli.quiet || headless, "Attestation written to: {}", diff --git a/tests/coverage_php_tests.rs b/tests/coverage_php_tests.rs index fe41435..5f6993b 100644 --- a/tests/coverage_php_tests.rs +++ b/tests/coverage_php_tests.rs @@ -254,3 +254,278 @@ 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"), "