Repository navigation
feat(assail): write a KYAML copy beside the sealed JSON report #264
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
b016dc9
c497763
fd8807b
81d1d90
4b38403
646f03a
f2629a5
995e9d4
364d1be
f382867
e895f6a
beeaea4
87292d6
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -1344,6 +1344,108 @@ fn parse_axis(value: &str) -> Option<AttackAxis> { | |
| } | ||
| } | ||
|
|
||
| /// Where `assail --attest` writes its attestation sidecar: beside the report | ||
| /// at `output` as `<stem>.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<PathBuf> { | ||
| 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) => { | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| 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() | ||
| ), | ||
| } | ||
| } | ||
|
hyperpolymath marked this conversation as resolved.
Resolved
|
||
| // 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,13 +1596,33 @@ 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!( | ||
| cli.quiet || headless, | ||
| "Report saved to: {}", | ||
| output_path.display() | ||
| ); | ||
| if let Some(kyaml_path) = &kyaml_path { | ||
| fs::write(kyaml_path, panic_attack::kyaml::to_string(&report)?)?; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When an existing Useful? React with 👍 / 👎.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Confirmed. This PR merged before the finding was handled, so the fix is #274. The preflight now opens every output for writing, without truncating it; an absent output is created and then removed. The kernel makes the decision, so ACLs count too. That PR adds two tests, a read-only copy and an unwritable directory. Run unprivileged, both fail without the fix and pass with it. Generated by Claude Code |
||
| 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, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.