xtask/src/build_history.rs
//! **`cargo xtask build-history`** — step 3 of the four, made runnable.
//!
//! ```text
//! 1 the recipe crate is published wave-0, before the thing it builds
//! 2 the build runs the artifact gets its receipt
//! 3 znippy-build-thing is created history / audit log, BEFORE release
//! 4 release the artifact goes out carrying itself
//! ```
//!
//! Step 3 before step 4 is the point: the history exists when the release
//! happens, not afterwards. And the package is always sealed **after** the
//! build, because the artifact's own hash does not exist until the artifact is
//! finished.
//!
//! ```text
//! seal <artifacts> -> the .znippy. Refuses on any artifact it cannot attest.
//! verify <artifacts> -> the release gate: every artifact covered and matching.
//! regenerate <artifacts> -> reseal from the artifacts and diff against the sealed
//! package. "Derived, never a source of truth" as a check.
//! show -> what did this recipe build?
//! ```
//!
//! # Where the work is not
//!
//! Nothing here computes an attestation or decides a layout — that is
//! `nornir_build_thing::history`, the dep-light leaf every member repo already
//! links. This module is the two things the leaf deliberately does not do: it
//! links znippy and seals the archive, and it turns outcomes into exit codes.
//!
//! # Exit codes
//!
//! `0` only when the thing asked for actually held. An artifact that could not
//! be measured exits non-zero exactly like one that failed: an unmeasured check
//! is never an approved check, and a release gate that passes on "could not
//! tell" is not a gate.
use std::path::{Path, PathBuf};
use nornir_build_thing::history::{HistoryPackage, Unattestable, PACKAGE_KIND};
/// znippy compression level for a sealed history. The package is a few kB of
/// JSON; this is not a throughput decision.
const LEVEL: i32 = 3;
/// What the verb was asked to do.
struct Args {
verb: String,
package: PathBuf,
artifacts: Vec<PathBuf>,
recipe: Option<String>,
allow_unattested: bool,
}
fn usage() -> i32 {
eprintln!("dwarves xtask build-history — the forge's build history, sealed as a znippy");
eprintln!();
eprintln!("usage: cargo xtask build-history <verb> --package <file.znippy> [artifacts…]");
eprintln!();
eprintln!("verbs:");
eprintln!(" seal attest every artifact and seal the package. Step 3: run it");
eprintln!(" AFTER the build and BEFORE the release.");
eprintln!(" verify the release gate — every artifact must be covered by the");
eprintln!(" sealed package and hash to what it attests.");
eprintln!(" regenerate reseal from the artifacts and diff against the sealed");
eprintln!(" package. The package is derived; this is the proof.");
eprintln!(" show what did this recipe build? --recipe <krate>[:<version>]");
eprintln!();
eprintln!("flags:");
eprintln!(" --package <file.znippy> the history package (required)");
eprintln!(" --recipe <krate>[:<ver>] narrow `show` to one recipe");
eprintln!(" --allow-unattested seal anyway when an artifact carries no receipt.");
eprintln!(" Off by default: a history that quietly covers");
eprintln!(" four of five artifacts looks complete.");
2
}
fn parse(args: &[String]) -> Result<Args, i32> {
let mut verb = String::new();
let mut package = None;
let mut recipe = None;
let mut allow_unattested = false;
let mut artifacts = Vec::new();
let mut it = args.iter();
while let Some(a) = it.next() {
match a.as_str() {
"--package" => package = it.next().map(PathBuf::from),
"--recipe" => recipe = it.next().cloned(),
"--allow-unattested" => allow_unattested = true,
"-h" | "--help" => return Err(usage()),
other if other.starts_with('-') => {
eprintln!("build-history: unknown flag {other}");
return Err(usage());
}
other if verb.is_empty() => verb = other.to_string(),
other => artifacts.push(PathBuf::from(other)),
}
}
let Some(package) = package else {
eprintln!("build-history: --package <file.znippy> is required");
return Err(usage());
};
Ok(Args {
verb,
package,
artifacts,
recipe,
allow_unattested,
})
}
/// Seal a package's files into a `.znippy`.
fn seal_files(path: &Path, files: &[(String, Vec<u8>)]) -> anyhow::Result<()> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
znippy_common::meta_sink_append::create_archive(path, files, LEVEL)?;
Ok(())
}
/// Read a sealed package's files back out.
fn read_files(path: &Path) -> anyhow::Result<Vec<(String, Vec<u8>)>> {
use znippy_common::{ZnippyArchive, ZnippyReader};
let archive = ZnippyArchive::open(path)?;
let mut out = Vec::new();
for p in archive.list_files()? {
let bytes = archive.extract_file(&p)?;
out.push((p, bytes));
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(out)
}
/// Report every artifact that could not be attested, and say whether that is
/// fatal.
fn report_refusals(refused: &[Unattestable], allow: bool) -> bool {
if refused.is_empty() {
return true;
}
eprintln!(
"build-history: {} artifact(s) could not be attested:",
refused.len()
);
for r in refused {
eprintln!(" {r}");
}
if allow {
eprintln!(
"build-history: --allow-unattested was given, so the history is sealed WITHOUT them"
);
return true;
}
eprintln!(
"build-history: refusing to seal a partial history. A history that covers some of the \
artifacts looks exactly like one that covers all of them, and the difference only \
surfaces when somebody asks about the one that is missing. Pass --allow-unattested to \
seal anyway."
);
false
}
fn seal(args: &Args) -> i32 {
if args.artifacts.is_empty() {
eprintln!("build-history seal: no artifacts given");
return 2;
}
let (package, refused) = HistoryPackage::from_artifacts(&args.artifacts);
if !report_refusals(&refused, args.allow_unattested) {
return 1;
}
let files = match package.files() {
Ok(f) => f,
Err(e) => {
eprintln!("build-history seal: {e}");
return 1;
}
};
if let Err(e) = seal_files(&args.package, &files) {
eprintln!("build-history seal: {e:#}");
return 1;
}
let bytes = std::fs::metadata(&args.package)
.map(|m| m.len())
.unwrap_or(0);
println!(
"build-history: sealed {} attestation(s) into {} ({bytes} bytes, {} files)",
package.len(),
args.package.display(),
files.len()
);
for a in package.attestations() {
println!(
" {} {} {} <- {}",
&a.artifact.sha256[..16],
a.receipt.atom,
a.receipt.target,
a.receipt.build_thing
);
}
0
}
/// Open a sealed package, or explain why not.
fn open(path: &Path) -> Result<HistoryPackage, i32> {
let files = match read_files(path) {
Ok(f) => f,
Err(e) => {
eprintln!("build-history: cannot read {}: {e:#}", path.display());
return Err(1);
}
};
HistoryPackage::from_files(&files).map_err(|e| {
eprintln!(
"build-history: {} is not a usable {PACKAGE_KIND}: {e}",
path.display()
);
1
})
}
fn verify(args: &Args) -> i32 {
if args.artifacts.is_empty() {
eprintln!(
"build-history verify: no artifacts given — a gate with nothing to check is not a gate"
);
return 2;
}
let package = match open(&args.package) {
Ok(p) => p,
Err(c) => return c,
};
let mut failed = 0usize;
for a in &args.artifacts {
let verdict = package.verify_artifact(a);
if verdict.holds() {
println!(" ATTESTED {}", a.display());
} else {
failed += 1;
eprintln!(" FAILED {}: {verdict}", a.display());
}
}
if failed == 0 {
println!(
"build-history: {} artifact(s) attested by {}",
args.artifacts.len(),
args.package.display()
);
0
} else {
eprintln!(
"build-history: {failed} of {} artifact(s) are NOT attested — the history must be \
sealed after the build and before the release, so this means the order was not kept",
args.artifacts.len()
);
1
}
}
fn regenerate(args: &Args) -> i32 {
if args.artifacts.is_empty() {
eprintln!("build-history regenerate: no artifacts given");
return 2;
}
let sealed = match read_files(&args.package) {
Ok(f) => f,
Err(e) => {
eprintln!(
"build-history: cannot read {}: {e:#}",
args.package.display()
);
let _ = e;
return 1;
}
};
let (package, refused) = HistoryPackage::from_artifacts(&args.artifacts);
if !report_refusals(&refused, args.allow_unattested) {
return 1;
}
let fresh = match package.files() {
Ok(f) => f,
Err(e) => {
eprintln!("build-history regenerate: {e}");
return 1;
}
};
if fresh == sealed {
println!(
"build-history: {} regenerates byte-identically from {} artifact(s) — it is derived, \
not a source of truth",
args.package.display(),
args.artifacts.len()
);
return 0;
}
eprintln!(
"build-history: {} does NOT match what these artifacts regenerate to",
args.package.display()
);
let names =
|v: &[(String, Vec<u8>)]| -> Vec<String> { v.iter().map(|(p, _)| p.clone()).collect() };
let (sn, fnames) = (names(&sealed), names(&fresh));
for p in &fnames {
if !sn.contains(p) {
eprintln!(" only in the regenerated package: {p}");
}
}
for p in &sn {
if !fnames.contains(p) {
eprintln!(" only in the sealed package: {p}");
}
}
for (p, b) in &fresh {
if let Some((_, s)) = sealed.iter().find(|(sp, _)| sp == p) {
if s != b {
eprintln!(
" differs: {p} ({} sealed vs {} regenerated bytes)",
s.len(),
b.len()
);
}
}
}
1
}
fn show(args: &Args) -> i32 {
let package = match open(&args.package) {
Ok(p) => p,
Err(c) => return c,
};
let selected = match &args.recipe {
None => package.attestations().iter().collect::<Vec<_>>(),
Some(spec) => {
let (krate, version) = match spec.split_once(':') {
Some((k, v)) => (k, Some(v)),
None => (spec.as_str(), None),
};
package.by_recipe(krate, version)
}
};
if selected.is_empty() {
// Not an error: "this recipe built nothing" is an answer, and it is the
// answer a CVE sweep is hoping for.
println!(
"build-history: nothing in {} matches",
args.package.display()
);
return 0;
}
println!(
"build-history: {} of {} attestation(s) in {}",
selected.len(),
package.len(),
args.package.display()
);
for a in selected {
println!(
" {} {} {} <- {} ({} bytes)",
a.artifact.sha256,
a.receipt.atom,
a.receipt.target,
a.receipt.build_thing,
a.artifact.bytes
);
}
0
}
/// Dispatch. Returns the process exit code.
pub fn run(args: &[String]) -> i32 {
let args = match parse(args) {
Ok(a) => a,
Err(c) => return c,
};
match args.verb.as_str() {
"seal" => seal(&args),
"verify" => verify(&args),
"regenerate" => regenerate(&args),
"show" => show(&args),
"" => usage(),
other => {
eprintln!("build-history: unknown verb `{other}`");
usage()
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use nornir_build_thing::history::AttestationVerdict;
/// `--package` is not optional, and its absence is a usage error rather than
/// a default path nobody meant.
#[test]
fn a_missing_package_flag_is_refused_rather_than_defaulted() {
assert!(parse(&["seal".to_string()]).is_err());
let ok = parse(&[
"seal".to_string(),
"--package".to_string(),
"h.znippy".to_string(),
"a".to_string(),
"b".to_string(),
])
.expect("parses");
assert_eq!(ok.verb, "seal");
assert_eq!(ok.artifacts.len(), 2, "artifacts are positional");
assert!(!ok.allow_unattested);
}
/// A verb with nothing to check exits non-zero. A gate that passes when it
/// was handed nothing is the shape of every hollow green in this tree.
#[test]
fn a_gate_with_no_artifacts_does_not_pass() {
let args = parse(&[
"verify".to_string(),
"--package".to_string(),
"h.znippy".to_string(),
])
.expect("parses");
assert_ne!(verify(&args), 0);
assert_ne!(seal(&args), 0);
assert_ne!(regenerate(&args), 0);
}
/// Refusals are fatal by default and only waived when asked.
#[test]
fn an_unattestable_artifact_stops_the_seal_unless_it_is_waived() {
let refused = vec![Unattestable::NoReceipt(PathBuf::from("a.bin"))];
assert!(!report_refusals(&refused, false));
assert!(report_refusals(&refused, true));
assert!(
report_refusals(&[], false),
"no refusals must not be reported as a refusal"
);
}
/// The verdict type's `holds` is what the exit code turns on, so it gets an
/// assertion of its own rather than being trusted.
#[test]
fn only_an_attested_verdict_holds() {
assert!(AttestationVerdict::Attested.holds());
assert!(!AttestationVerdict::ReceiptMismatch.holds());
assert!(!AttestationVerdict::Unreadable("x".into()).holds());
assert!(!AttestationVerdict::ArtifactMismatch {
attested: "a".into(),
actual: "b".into(),
}
.holds());
}
}