crates/nornir-build-thing/src/history.rs
//! **The build history — `znippy-build-thing`.** The attestation half of the
//! 2026-08-06 design, and the step that happens *after* the build and *before*
//! the release.
//!
//! The receipt half already works: a built artifact carries a [`Receipt`] in
//! [`RECEIPT_SECTION`] naming the recipe that built it and every resolved
//! setting. This module is the other direction.
//!
//! ```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
//! ```
//!
//! # What it is: an attestation, not an index
//!
//! ```text
//! A1..An -> recipe + version + hash in the section, self-contained
//! znippy -> the recipe + params + hash(A1..An) aggregate, derived
//! ```
//!
//! One [`BuildAttestation`] says *recipe R with parameters P produced artifact
//! hash H*. Anyone holding the artifact can check it: hash the binary, compare.
//! No trust is required and nothing has to be online. That is the difference
//! between an attestation and an index — an index has to be believed.
//!
//! It also answers the one question an artifact never can. An artifact says
//! *which recipe built me*; the history says **what did this recipe build**,
//! which is exactly the question a CVE in a recipe asks.
//!
//! # No back-pointer, and there cannot be one
//!
//! Nothing in the artifact points at the package. The package is written after
//! the build, so the artifact's own hash does not exist until the artifact is
//! finished — a forward pointer would be a chicken-and-egg. Everything points
//! one way: the artifact names its recipe, the package names the artifact.
//!
//! # Derived, never a source of truth
//!
//! Lose the package and it regenerates from the artifacts. That is not an
//! aspiration here: [`HistoryPackage::from_artifacts`] is the **only**
//! constructor, so creating and regenerating are the same code path and cannot
//! drift. A package is a pure function of the *bytes* of the artifacts it
//! covers — see [`ArtifactRef`] for why the file name is deliberately not in it.
//!
//! # The package layout
//!
//! Files inside the `.znippy`, and the whole contract with holger's serving
//! backend. holger maps HTTP paths onto these paths and parses none of them —
//! it serves bytes, so there is no shared type and nothing to drift.
//!
//! ```text
//! index.json the manifest: one row per attestation
//! attestations/<sha256>.json one attestation per artifact, by its hash
//! by-recipe/<krate> artifact hashes this recipe built, any version
//! by-recipe/<krate>/<version> artifact hashes this exact recipe version built
//! by-atom/<atom> artifact hashes carrying this atom name
//! ```
//!
//! The `by-*` files are **derived**, and [`HistoryPackage::from_files`]
//! recomputes them rather than reading them: a hand-edited lookup that disagrees
//! with the attestations is a package that lies about what a recipe built, which
//! is the one question this package exists to answer.
//!
//! [`RECEIPT_SECTION`]: crate::contract::RECEIPT_SECTION
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::contract::Receipt;
use crate::receipt::read_receipt;
/// The package kind, written into [`INDEX_PATH`] and named by holger's backend.
pub const PACKAGE_KIND: &str = "znippy-build-thing";
/// The package schema version. A reader meeting a higher number refuses rather
/// than guessing, for the same reason the receipt does: a misread history
/// answers "what did this recipe build" *wrongly*, and a wrong answer to that
/// question during a CVE sweep is worse than no answer.
pub const PACKAGE_VERSION: u32 = 1;
/// The attestation schema version, written into every [`BuildAttestation`].
pub const ATTESTATION_VERSION: u32 = 1;
/// The manifest's path inside the archive.
pub const INDEX_PATH: &str = "index.json";
/// The directory holding one attestation per artifact hash.
pub const ATTESTATION_DIR: &str = "attestations";
/// The directory holding the derived recipe → artifacts lookup.
pub const BY_RECIPE_DIR: &str = "by-recipe";
/// The directory holding the derived atom → artifacts lookup.
pub const BY_ATOM_DIR: &str = "by-atom";
// ── the attested artifact ────────────────────────────────────────────────────
/// The artifact an attestation is about: its content hash and its size.
///
/// # Why there is no file name here
///
/// A file name is not a property of the thing built, it is a property of where
/// somebody put it. Putting it in the attestation would make regeneration
/// path-dependent — copy the binary, rename it, regenerate, and you get a
/// different package for the same artifact. The claim being made is about
/// *bytes*: recipe R produced these bytes. The name the bytes are sitting under
/// is the caller's business, and the atom they belong to is already in the
/// receipt.
///
/// `bytes` is carried because it costs nothing and makes a truncated download
/// visible before the hash is computed over 16 MB.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub struct ArtifactRef {
/// Lowercase-hex SHA-256 over the whole artifact file.
pub sha256: String,
/// The artifact's size in bytes.
pub bytes: u64,
}
// ── the symbol audit, carried as text ────────────────────────────────────────
/// **The proof, travelling as text instead of as a symbol table.**
///
/// `[profile.release] strip = true` deletes the symbol table from the delivered
/// artifact. [`receipt_proof`](crate::receipt_proof) then answers `NotMeasured`
/// for it — correctly, and forever — which means the thing we hand a customer
/// carries a claim nobody can check. Measured on gunnar-server, oden 2026-08-06:
///
/// ```text
/// strip = true 16 001 760 B NotMeasured no symbol table
/// strip = "debuginfo" 19 190 152 B Proven 26 075 symbols
/// +3 188 392 B = +19,9 %
/// ```
///
/// The SLSA answer, and the one the owner took: keep the artifact small, run the
/// audit at build time on a binary that still has its symbols, and let the
/// **verdict** travel. That is what an attestation is — the builder testifies to
/// what it saw, rather than every receiver re-measuring.
///
/// # It is anchored to bytes, not to a file name
///
/// [`SymbolProof::code`] is the SHA-256 over the *allocated* sections — see
/// [`code_digest`](crate::code_digest). Strip removes nothing allocated, so the
/// unstripped binary that was audited and the stripped artifact that ships carry
/// the same number, and [`attest_proven`] refuses to write a proof when they do
/// not. That is what makes "we audited the binary this came from" a measurement
/// rather than a claim about a path.
///
/// The receipt is inside that digest too: `.nordisk.buildthing` is an allocated
/// section, so an equal code digest already means the two binaries carry the
/// same receipt, byte for byte.
///
/// # What is genuinely lost, and it is written down here
///
/// [`SymbolProof::outsider`] is not prose about the design — it is the field an
/// artifact's holder reads to find out what they can do themselves. With
/// `recomputable = false` they can still verify every digest in this attestation
/// against the bytes in their hand; they cannot re-run the symbol audit, because
/// the symbols are gone. That is the trade, and it is in the data.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SymbolProof {
/// The verdict [`audit_artifact`](crate::receipt_proof::audit_artifact)
/// reached, verbatim.
pub verdict: crate::receipt_proof::ReceiptVerdict,
/// One entry per receipt setting the symbol table can check.
pub claims: Vec<ProofClaim>,
/// The fat/thin audit's own sentence, when the receipt declared `linkage`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub linkage: Option<String>,
/// How many symbols the audited binary carried. `0` cannot happen: a binary
/// with no symbols is [`Unprovable::NotMeasured`] and no proof is written.
pub symbols: usize,
/// The binary the audit actually read — hash and size, never a path.
pub audited: ArtifactRef,
/// The allocated-section digest. Equal for the audited binary and the
/// shipped artifact, by construction: [`attest_proven`] refuses otherwise.
pub code: crate::code_digest::CodeDigest,
/// Whether a holder of the shipped artifact could redo this audit.
///
/// `true` only when the shipped artifact still has a symbol table, i.e. it
/// *is* the audited binary. `false` under `strip = true`, which is the
/// normal delivery and the whole reason this type exists.
pub recomputable: bool,
/// What someone holding only the artifact can and cannot check.
pub outsider: Outsider,
}
/// One receipt claim, measured, in a form that survives the symbol table's
/// deletion.
///
/// `evidence` carries the families and their counts (`wgpu×5287, naga×2828`)
/// rather than a bare verdict: a `Disproven` with nothing behind it is an
/// accusation, and the person reading it a year later cannot act on it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProofClaim {
/// The settings key as the receipt spells it, `feature.` prefix and all.
pub key: String,
/// The value the receipt records.
pub claimed: String,
/// What the binary said about it.
pub verdict: crate::receipt_proof::ClaimVerdict,
/// The families counted, `wgpu×5287, naga×2828`. Empty when none matched —
/// which is the evidence for a `false` claim that holds.
pub evidence: String,
/// The sentence a report prints, as the audit itself worded it.
pub line: String,
}
/// **What a holder of the artifact can and cannot verify for themselves.**
///
/// Written into every proof rather than left to a design document, because the
/// person who needs it is holding a binary and a JSON file in 2029 and has
/// neither the tree nor the PLAN. Both lists are derived by [`Outsider::of`]
/// from the proof's own fields, so they cannot say one thing while the fields
/// say another — [`BuildAttestation::verify`] rechecks exactly that.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Outsider {
/// Checks that need only the artifact and this attestation.
pub can: Vec<String>,
/// Checks that are no longer possible, and why.
pub cannot: Vec<String>,
}
impl Outsider {
/// The two lists for a proof in this state. The **one** writer.
#[must_use]
pub fn of(recomputable: bool, verdict: crate::receipt_proof::ReceiptVerdict) -> Self {
let mut can = vec![
"hash the artifact and compare it to `artifact.sha256` — this attestation is \
about those bytes or about nothing"
.to_string(),
"read the receipt out of the artifact's .nordisk.buildthing section with an \
object parser and compare it to `receipt`, without running the artifact"
.to_string(),
"recompute `proof.code` over the artifact's allocated sections and compare it \
to the value here — that is what ties this audit to these instructions"
.to_string(),
];
let mut cannot = Vec::new();
if recomputable {
can.push(
"re-run the symbol audit: this artifact still carries its symbol table, so \
`proof.verdict` is reproducible rather than testimony"
.to_string(),
);
} else {
cannot.push(
"re-run the symbol audit. The artifact was stripped, its symbol table is \
gone, and `proof.verdict` is the builder's testimony about a binary you do \
not have. Re-deriving it independently means rebuilding from `receipt` with \
strip off"
.to_string(),
);
cannot.push(format!(
"tell this artifact apart from one whose receipt was never checked, from the \
artifact alone: audited it reports {}, unaudited it would report \
not-measured, and both look identical once stripped",
verdict_word(verdict),
));
}
cannot.push(
"learn who built it. This is an attestation, not a signature — it says two \
files carry the same program, never who made them"
.to_string(),
);
Self { can, cannot }
}
}
/// The verdict's own serde spelling, so a message and the JSON never disagree.
fn verdict_word(v: crate::receipt_proof::ReceiptVerdict) -> String {
serde_json::to_string(&v).unwrap_or_else(|_| "\"?\"".to_string())
}
impl SymbolProof {
/// Whether the receipt survived contact with the binary.
///
/// Only `Proven` passes. `NotMeasured` cannot even reach this type —
/// [`attest_proven`] refuses to write it — and every other verdict is a fact
/// about a build that did not come out as declared.
#[must_use]
pub fn holds(&self) -> bool {
self.verdict.holds()
}
/// The lines a report prints: the headline, then one per claim.
#[must_use]
pub fn lines(&self) -> Vec<String> {
let short = |h: &str| h.chars().take(12).collect::<String>();
let mut out = vec![format!(
"symbol proof: {} over {} symbols of unstripped {} ({} B), code {}{}",
verdict_word(self.verdict),
self.symbols,
short(&self.audited.sha256),
self.audited.bytes,
short(&self.code.sha256),
if self.recomputable {
""
} else {
" — NOT recomputable from the shipped artifact, which is stripped"
}
)];
out.extend(self.claims.iter().map(|c| c.line.clone()));
if let Some(l) = &self.linkage {
out.push(l.clone());
}
out
}
}
/// A reason a build-time symbol audit could not be turned into a proof.
///
/// Each one is a wiring mistake that would otherwise produce an attestation
/// saying "audited" over an audit that did not happen — worse than no
/// attestation, because it reads as evidence.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unprovable {
/// The shipped artifact could not be attested at all — see [`Unattestable`].
Unattestable(Unattestable),
/// One of the two files could not be parsed, or has no allocated sections.
Unreadable {
/// Which file.
artifact: PathBuf,
/// Why.
why: String,
},
/// The audited binary and the shipped artifact are **not the same program**.
/// Their allocated sections differ, so the audit says nothing about what
/// ships.
DifferentProgram {
/// The digest of the binary that was audited.
audited: String,
/// The digest of the artifact that ships.
shipped: String,
/// Which allocated sections differ, by name — the diagnosis.
sections: Vec<String>,
},
/// The binary handed in as the audited one has no symbol table either, so
/// nothing was measured. **Never written as a proof.**
NotMeasured {
/// The binary that was supposed to carry symbols.
audited: PathBuf,
},
/// [`deliver_stripped`] could not produce the shipped artifact.
StripFailed {
/// Where the shipped artifact was to be written.
shipped: PathBuf,
/// Why.
why: String,
},
}
impl fmt::Display for Unprovable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Unattestable(u) => write!(f, "{u}"),
Self::Unreadable { artifact, why } => {
write!(f, "{}: NOT MEASURED: {why}", artifact.display())
}
Self::DifferentProgram {
audited,
shipped,
sections,
} => write!(
f,
"the audited binary and the shipped artifact are different programs — \
allocated sections hash to {audited} and {shipped}, differing in [{}]. An \
audit of one says nothing about the other, so no proof is written",
sections.join(", ")
),
Self::NotMeasured { audited } => write!(
f,
"{}: has no symbol table, so the audit measured nothing. A proof saying \
`audited` over an audit that did not happen reads as evidence and is not — \
build the audited copy with strip off",
audited.display()
),
Self::StripFailed { shipped, why } => write!(
f,
"could not produce the stripped artifact at {}: {why}",
shipped.display()
),
}
}
}
/// The strippers tried, in order, by [`deliver_stripped`].
///
/// Each one removes sections from an existing file and re-writes the section
/// header table. **None of them re-links**, which is the entire property being
/// relied on: allocated sections come through byte-identical, so the artifact
/// that ships is provably the program that was audited.
pub const STRIPPERS: &[(&str, &[&str])] = &[
("llvm-strip", &["--strip-all"]),
("strip", &["--strip-all"]),
("objcopy", &["--strip-all"]),
];
/// **Build once, audit, then strip — the delivery path that cannot drift.**
///
/// Copies `audited` to `shipped`, removes the symbol table from the copy, and
/// attests the pair with [`attest_proven`].
///
/// # Why this exists rather than "just build it twice"
///
/// Because building it twice does not produce the same program, and that was
/// measured rather than feared. One fixture, one directory, one source, the only
/// difference `[profile.release] strip`, oden 2026-08-06:
///
/// ```text
/// strip = false -> strip = true .text DIFFERS, .data.rel.ro DIFFERS
/// same size, same address, other bytes
/// strip = false -> objcopy --strip-all
/// EVERY allocated section identical,
/// 306 984 B against rustc's 306 992 B
/// ```
///
/// So `strip` is not only a link-time deletion in this toolchain: flipping it
/// changes the code that comes out. An audit of an unstripped *rebuild* is
/// therefore an audit of a different program, and [`attest_proven`] would
/// correctly refuse it — leaving the delivery unprovable again. Stripping the
/// audited binary itself removes the rebuild, and with it the whole question.
///
/// A second build was also the slower answer: this way the delivery compiles
/// once.
///
/// # `[profile.release] strip = true` stays where it is
///
/// It is what a plain `cargo build --release` should keep doing. The delivery
/// path overrides it for its one compilation (`CARGO_PROFILE_RELEASE_STRIP=none`)
/// and does the stripping here, so the artifact that goes out is the same size —
/// eight bytes smaller, measured — and is anchored to an audit.
///
/// # Errors
///
/// [`Unprovable::StripFailed`] when the copy or the stripper fails, and anything
/// [`attest_proven`] refuses.
pub fn deliver_stripped(audited: &Path, shipped: &Path) -> Result<BuildAttestation, Unprovable> {
let fail = |why: String| Unprovable::StripFailed {
shipped: shipped.to_path_buf(),
why,
};
std::fs::copy(audited, shipped).map_err(|e| {
fail(format!(
"copy {} -> {}: {e}",
audited.display(),
shipped.display()
))
})?;
let mut tried = Vec::new();
let stripped = STRIPPERS.iter().any(|(tool, args)| {
match std::process::Command::new(tool)
.args(*args)
.arg(shipped)
.output()
{
Ok(out) if out.status.success() => true,
Ok(out) => {
tried.push(format!(
"{tool}: {}",
String::from_utf8_lossy(&out.stderr).trim()
));
false
}
Err(e) => {
tried.push(format!("{tool}: {e}"));
false
}
}
});
if !stripped {
return Err(fail(format!(
"no stripper worked — tried {}",
tried.join("; ")
)));
}
attest_proven(shipped, audited)
}
impl std::error::Error for Unprovable {}
/// **Attest a shipped artifact, and carry the symbol audit of the unstripped
/// binary it came from.**
///
/// The build-time call. `shipped` is what goes out — stripped, small, and
/// unprovable on its own. `audited` is the same program built with the symbol
/// table left in; the two are tied together by
/// [`code_digest`](crate::code_digest::code_digest), never by their names.
///
/// A `Disproven` audit is **returned, not refused**: a receipt that lies is the
/// single most valuable thing this lane can produce, and it has to reach the
/// caller as data. The caller fails the build on `!proof_holds()`. What is
/// refused is an audit that never happened — see [`Unprovable`].
///
/// # Errors
///
/// [`Unprovable`], one variant per way the proof would be a lie about itself.
pub fn attest_proven(shipped: &Path, audited: &Path) -> Result<BuildAttestation, Unprovable> {
use crate::code_digest::code_digest;
use crate::linkage::demangled_symbols;
use crate::receipt_proof::{audit_artifact, ReceiptVerdict};
let mut attestation = attest(shipped).map_err(Unprovable::Unattestable)?;
let shipped_code = code_digest(shipped).map_err(|e| Unprovable::Unreadable {
artifact: shipped.to_path_buf(),
why: format!("{e:#}"),
})?;
let audited_code = code_digest(audited).map_err(|e| Unprovable::Unreadable {
artifact: audited.to_path_buf(),
why: format!("{e:#}"),
})?;
if !shipped_code.same_program(&audited_code) {
return Err(Unprovable::DifferentProgram {
audited: audited_code.to_string(),
shipped: shipped_code.to_string(),
// Named, because "different" over two 16 MB files is not a finding
// anyone can act on and the section list always is. Best-effort: the
// refusal stands whether or not the second parse succeeds.
sections: crate::code_digest::differing_sections(audited, shipped).unwrap_or_default(),
});
}
// The audit reads the binary that still has symbols. Everything measured
// here comes out of `receipt_proof`, which comes out of `linkage`: one
// counting rule, so there is no second answer to drift from it.
let audit = audit_artifact(audited);
let verdict = audit.verdict();
if verdict == ReceiptVerdict::NotMeasured {
return Err(Unprovable::NotMeasured {
audited: audited.to_path_buf(),
});
}
let audited_bytes = std::fs::read(audited).map_err(|e| Unprovable::Unreadable {
artifact: audited.to_path_buf(),
why: e.to_string(),
})?;
// Asked of the SHIPPED artifact, which is the only thing an outsider holds.
let recomputable = demangled_symbols(shipped).is_some();
attestation.proof = Some(SymbolProof {
verdict,
claims: audit
.claims
.iter()
.map(|c| ProofClaim {
key: c.key.clone(),
claimed: c.raw.clone(),
verdict: c.verdict(),
evidence: c.families(),
line: c.line(),
})
.collect(),
linkage: audit
.linkage
.as_ref()
.map(crate::linkage::LinkageAudit::line),
symbols: audit.symbols,
audited: ArtifactRef {
sha256: nornir_hash::sha256_hex(&audited_bytes),
bytes: audited_bytes.len() as u64,
},
code: shipped_code,
recomputable,
outsider: Outsider::of(recomputable, verdict),
});
Ok(attestation)
}
/// One attestation: *this recipe, with these parameters, produced these bytes.*
///
/// The receipt is carried **verbatim** as it was read out of the artifact rather
/// than restated field by field. Restating it would be a second writer of the
/// same facts, and a second writer drifts; carrying it verbatim also makes
/// regeneration byte-identical by construction, because the bytes came out of
/// the artifact both times.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildAttestation {
/// [`ATTESTATION_VERSION`] at the time of writing.
pub attestation_version: u32,
/// The receipt, exactly as the artifact carries it.
pub receipt: Receipt,
/// What the recipe produced.
pub artifact: ArtifactRef,
/// **The symbol audit, performed at build time and carried as text.**
///
/// `None` for an artifact attested from its bytes alone — which is what
/// [`HistoryPackage::from_artifacts`] always produces, because a stripped
/// artifact has no symbol table left to audit. Present only when the builder
/// went through [`attest_proven`] with the unstripped binary in hand.
///
/// Skipped when absent so an attestation without a proof serialises to
/// exactly the bytes it did before this field existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub proof: Option<SymbolProof>,
}
impl BuildAttestation {
/// The archive path this attestation is stored at.
#[must_use]
pub fn path(&self) -> String {
format!("{ATTESTATION_DIR}/{}.json", self.artifact.sha256)
}
/// Check this attestation against an artifact on disk.
///
/// This is the whole point of the package being an attestation: the check
/// needs the artifact and nothing else — no registry, no network, no
/// signature to trust.
#[must_use]
pub fn verify(&self, artifact: &Path) -> AttestationVerdict {
let bytes = match std::fs::read(artifact) {
Ok(b) => b,
Err(e) => {
return AttestationVerdict::Unreadable(format!("{}: {e}", artifact.display()));
}
};
let actual = nornir_hash::sha256_hex(&bytes);
if actual != self.artifact.sha256 {
return AttestationVerdict::ArtifactMismatch {
attested: self.artifact.sha256.clone(),
actual,
};
}
// The receipt is inside the bytes we just hashed, so a matching hash
// already implies a matching receipt — unless the *package* was edited.
// That is the case this arm exists for, and it is cheap.
match read_receipt(artifact) {
Ok(Some(r)) if r == self.receipt => {}
Ok(Some(_)) => return AttestationVerdict::ReceiptMismatch,
Ok(None) => {
return AttestationVerdict::Unreadable(format!(
"{} carries no receipt, but its hash matches an attestation",
artifact.display()
));
}
Err(e) => {
return AttestationVerdict::Unreadable(format!("{}: {e}", artifact.display()));
}
}
// And the proof, when there is one. Same reasoning one level down: a
// matching artifact hash does not prove the *proof* was not edited, and
// the proof is the one part of the package nobody downstream can
// recompute. So both halves are rechecked here — the code digest against
// the artifact's own sections, and the outsider notice against the
// fields it is supposed to describe.
if let Some(p) = &self.proof {
let actual = match crate::code_digest::code_digest(artifact) {
Ok(d) => d,
Err(e) => {
return AttestationVerdict::Unreadable(format!(
"{}: {e:#}",
artifact.display()
));
}
};
if !actual.same_program(&p.code) {
return AttestationVerdict::CodeMismatch {
attested: p.code.to_string(),
actual: actual.to_string(),
};
}
if p.outsider != Outsider::of(p.recomputable, p.verdict) {
return AttestationVerdict::ProofTampered;
}
}
AttestationVerdict::Attested
}
/// Whether the receipt was PROVEN against a symbol table.
///
/// Deliberately not folded into [`verify`](Self::verify): "is this the
/// artifact this attestation is about" and "did this artifact's receipt
/// survive its symbol table" are two questions, and an artifact can be
/// unambiguously the right one and still carry a receipt that lies.
///
/// `false` when there is no proof at all — an unmeasured claim is not a
/// verified one, and a missing proof is exactly that.
#[must_use]
pub fn proof_holds(&self) -> bool {
self.proof.as_ref().is_some_and(SymbolProof::holds)
}
}
/// What checking an attestation against an artifact concluded.
///
/// [`AttestationVerdict::Unreadable`] is deliberately not a pass and not a
/// failure of the artifact: an unmeasured check is never an approved check.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AttestationVerdict {
/// The bytes hash to the attested value and carry the attested receipt.
Attested,
/// The artifact is not the one attested.
ArtifactMismatch {
/// The hash the package claims.
attested: String,
/// The hash the bytes actually have.
actual: String,
},
/// The hash matched but the receipt in the artifact is not the attested
/// one — the package was edited after it was written.
ReceiptMismatch,
/// The artifact's allocated sections do not hash to the digest the proof was
/// anchored to: the audit in this attestation was performed on a different
/// program.
CodeMismatch {
/// The digest the proof claims.
attested: String,
/// The digest the artifact's sections actually have.
actual: String,
},
/// The proof's outsider notice does not describe the proof's own fields —
/// somebody edited the prose without the verdict, or the other way round.
ProofTampered,
/// Nothing was measured. Never a pass.
Unreadable(String),
}
impl AttestationVerdict {
/// `true` only for [`AttestationVerdict::Attested`].
#[must_use]
pub const fn holds(&self) -> bool {
matches!(self, Self::Attested)
}
}
impl fmt::Display for AttestationVerdict {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Attested => write!(f, "ATTESTED"),
Self::ArtifactMismatch { attested, actual } => write!(
f,
"ARTIFACT MISMATCH: the package attests {attested} and the bytes hash to {actual}"
),
Self::ReceiptMismatch => write!(
f,
"RECEIPT MISMATCH: the artifact's own receipt is not the attested one — \
the package was edited after it was written"
),
Self::CodeMismatch { attested, actual } => write!(
f,
"CODE MISMATCH: the proof was anchored to {attested} and this artifact's \
allocated sections are {actual} — the symbol audit in this attestation was \
performed on a different program"
),
Self::ProofTampered => write!(
f,
"PROOF TAMPERED: the outsider notice does not describe the proof's own \
verdict and recomputability — one of the two was edited"
),
Self::Unreadable(why) => write!(f, "NOT MEASURED: {why}"),
}
}
}
// ── why an artifact cannot be attested ───────────────────────────────────────
/// A reason an artifact was not put in the package, named rather than skipped.
///
/// Every variant is a build whose history would be silently missing. A history
/// that quietly covers four of five artifacts is worse than one that covers
/// none, because the fifth looks attested until somebody asks.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Unattestable {
/// The artifact carries no receipt at all. Either it was not built by a
/// build thing, or the writer was not wired in.
NoReceipt(PathBuf),
/// The receipt names a recipe by version but not by content hash, so the
/// attestation could not say *which bytes of the recipe* ran.
UnpinnedRecipe {
/// The artifact.
artifact: PathBuf,
/// The coordinate as the receipt spells it.
coordinate: String,
},
/// A receipt field that becomes an archive path holds a path separator or a
/// `..` component. Refused rather than sanitised — a name that has to be
/// repaired to be safe is a name nobody meant to write.
UnsafeName {
/// The artifact.
artifact: PathBuf,
/// Which field.
field: &'static str,
/// The offending value.
value: String,
},
/// The file could not be read or parsed as an object file. Distinct from
/// [`Unattestable::NoReceipt`]: nothing was measured.
Unreadable {
/// The artifact.
artifact: PathBuf,
/// Why.
why: String,
},
}
impl fmt::Display for Unattestable {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoReceipt(p) => write!(
f,
"{}: carries no {} section — nothing attests what built it",
p.display(),
crate::contract::RECEIPT_SECTION
),
Self::UnpinnedRecipe {
artifact,
coordinate,
} => write!(
f,
"{}: receipt names {coordinate} with no content hash — the version says which \
recipe was intended, only the hash says which one ran",
artifact.display()
),
Self::UnsafeName {
artifact,
field,
value,
} => write!(
f,
"{}: receipt field `{field}` is {value:?}, which cannot be an archive path",
artifact.display()
),
Self::Unreadable { artifact, why } => {
write!(f, "{}: NOT MEASURED: {why}", artifact.display())
}
}
}
}
/// Is `s` safe to use as a single archive path component?
fn safe_component(s: &str) -> bool {
!s.is_empty()
&& s != "."
&& s != ".."
&& !s.contains('/')
&& !s.contains('\\')
&& !s.contains('\0')
}
// ── the package ──────────────────────────────────────────────────────────────
/// One row of [`INDEX_PATH`]. Flat on purpose: the manifest is a table a human
/// or a shell can read, and the full attestation is one fetch away at `path`.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct IndexRow {
/// The artifact's content hash — the package's primary key.
pub artifact_sha256: String,
/// The artifact's size in bytes.
pub artifact_bytes: u64,
/// The recipe coordinate, `krate:version:hash`.
pub build_thing: String,
/// The recipe crate alone, so a CVE sweep does not have to split a string.
pub build_thing_krate: String,
/// The recipe version alone.
pub build_thing_version: String,
/// The atom the artifact is.
pub atom: String,
/// The delivery target (`exe`, `container`, `iso-installer`).
pub target: String,
/// Where the full attestation sits inside the archive.
pub path: String,
}
/// The manifest at [`INDEX_PATH`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Index {
/// Always [`PACKAGE_KIND`].
pub kind: String,
/// [`PACKAGE_VERSION`] at the time of writing.
pub package_version: u32,
/// One row per attestation, sorted by `artifact_sha256`.
pub attestations: Vec<IndexRow>,
}
/// dwarves' build history: the attestations for a set of built artifacts.
///
/// Entries are kept sorted by artifact hash and deduplicated, so two callers
/// handing in the same artifacts in different orders produce byte-identical
/// package files. That is what makes "lose it and regenerate it" a check you can
/// run rather than a promise.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct HistoryPackage {
entries: Vec<BuildAttestation>,
}
impl HistoryPackage {
/// **The only constructor**, and therefore both the create path and the
/// regeneration path.
///
/// Returns the package alongside every artifact that could not be attested
/// and why. The refusals are returned rather than logged because the caller
/// — the release gate — has to be able to fail on them.
///
/// Artifacts that hash alike are the same bytes and therefore carry the same
/// receipt, so handing the same artifact in twice is not an error; it
/// collapses to one entry.
#[must_use]
pub fn from_artifacts<P: AsRef<Path>>(artifacts: &[P]) -> (Self, Vec<Unattestable>) {
let mut by_hash: BTreeMap<String, BuildAttestation> = BTreeMap::new();
let mut refused = Vec::new();
for p in artifacts {
let path = p.as_ref();
match attest(path) {
Ok(a) => {
by_hash.insert(a.artifact.sha256.clone(), a);
}
Err(u) => refused.push(u),
}
}
(
Self {
entries: by_hash.into_values().collect(),
},
refused,
)
}
/// A package over attestations already built — the path a **proof** takes.
///
/// [`from_artifacts`](Self::from_artifacts) stays the pure function of the
/// artifacts' bytes, and that is exactly why it can never carry a proof: the
/// bytes it is handed are stripped, and the symbols are gone. A builder that
/// ran [`attest_proven`] holds something the bytes no longer contain, and
/// this is where it puts it.
///
/// The consequence is stated rather than hidden: **regenerating a package
/// from the shipped artifacts recovers everything except the proofs.**
/// `history_end_to_end.rs` asserts that in both directions, so the day the
/// proof becomes recomputable the test says so.
///
/// Same de-duplication as the other constructor: attestations naming the
/// same artifact hash collapse, and the entries come out sorted so two
/// orders produce byte-identical files.
#[must_use]
pub fn from_attestations(attestations: Vec<BuildAttestation>) -> Self {
let by_hash: BTreeMap<String, BuildAttestation> = attestations
.into_iter()
.map(|a| (a.artifact.sha256.clone(), a))
.collect();
Self {
entries: by_hash.into_values().collect(),
}
}
/// Every attestation, sorted by artifact hash.
#[must_use]
pub fn attestations(&self) -> &[BuildAttestation] {
&self.entries
}
/// How many artifacts this history covers.
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
/// `true` when the history covers nothing.
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
/// The attestation for an artifact hash, if the history covers it.
#[must_use]
pub fn by_artifact(&self, sha256: &str) -> Option<&BuildAttestation> {
self.entries
.iter()
.find(|a| a.artifact.sha256.eq_ignore_ascii_case(sha256))
}
/// **What did this recipe build?** — the question the artifact cannot answer.
///
/// `version` of `None` means every version of the recipe crate, which is the
/// shape a CVE sweep wants: the advisory names a crate and a range, not one
/// version.
#[must_use]
pub fn by_recipe(&self, krate: &str, version: Option<&str>) -> Vec<&BuildAttestation> {
self.entries
.iter()
.filter(|a| a.receipt.build_thing.krate == krate)
.filter(|a| version.is_none_or(|v| a.receipt.build_thing.version == v))
.collect()
}
/// Every artifact built for a given atom.
#[must_use]
pub fn by_atom(&self, atom: &str) -> Vec<&BuildAttestation> {
self.entries
.iter()
.filter(|a| a.receipt.atom == atom)
.collect()
}
/// The manifest.
#[must_use]
pub fn index(&self) -> Index {
Index {
kind: PACKAGE_KIND.to_string(),
package_version: PACKAGE_VERSION,
attestations: self
.entries
.iter()
.map(|a| IndexRow {
artifact_sha256: a.artifact.sha256.clone(),
artifact_bytes: a.artifact.bytes,
build_thing: a.receipt.build_thing.to_string(),
build_thing_krate: a.receipt.build_thing.krate.clone(),
build_thing_version: a.receipt.build_thing.version.clone(),
atom: a.receipt.atom.clone(),
target: a.receipt.target.clone(),
path: a.path(),
})
.collect(),
}
}
/// The package's files, ready for `znippy_common::create_archive`.
///
/// This crate stays dependency-light and seals nothing itself: it returns
/// `(path, bytes)` pairs, which is exactly what znippy's archive writer
/// takes. The sealing happens in the crate that already links znippy.
///
/// Sorted by path, so two runs over the same artifacts write the same
/// archive.
///
/// # Errors
///
/// Only if an attestation fails to serialise, which would be a bug in this
/// crate rather than anything about the input.
pub fn files(&self) -> serde_json::Result<Vec<(String, Vec<u8>)>> {
let mut files: Vec<(String, Vec<u8>)> = Vec::with_capacity(self.entries.len() + 8);
files.push((
INDEX_PATH.to_string(),
serde_json::to_vec_pretty(&self.index())?,
));
for a in &self.entries {
files.push((a.path(), serde_json::to_vec_pretty(a)?));
}
for (path, hashes) in self.derived_lookups() {
files.push((path, hashes.join("\n").into_bytes()));
}
files.sort_by(|a, b| a.0.cmp(&b.0));
Ok(files)
}
/// The `by-recipe/…` and `by-atom/…` files, as path → sorted artifact hashes.
///
/// Kept as one function because it is called twice: once to write the files
/// and once, in [`HistoryPackage::from_files`], to check the ones that were
/// written. A second implementation of this is a second answer to "what did
/// this recipe build".
fn derived_lookups(&self) -> BTreeMap<String, Vec<String>> {
let mut out: BTreeMap<String, BTreeSet<String>> = BTreeMap::new();
for a in &self.entries {
let c = &a.receipt.build_thing;
let sha = a.artifact.sha256.clone();
out.entry(format!("{BY_RECIPE_DIR}/{}", c.krate))
.or_default()
.insert(sha.clone());
out.entry(format!("{BY_RECIPE_DIR}/{}/{}", c.krate, c.version))
.or_default()
.insert(sha.clone());
out.entry(format!("{BY_ATOM_DIR}/{}", a.receipt.atom))
.or_default()
.insert(sha);
}
out.into_iter()
.map(|(k, v)| (k, v.into_iter().collect()))
.collect()
}
/// Read a package back from the files of an archive.
///
/// The derived lookups are **recomputed and compared**, not trusted. A
/// package whose `by-recipe/…` disagrees with its attestations is refused,
/// because that file is the answer to the only question the package is
/// consulted for during a CVE sweep, and a wrong answer there is worse than
/// no package at all.
///
/// # Errors
///
/// When the manifest is missing, is not this kind of package, is from a
/// future schema, references an attestation that is absent, holds an
/// attestation stored under the wrong hash, or carries a derived lookup that
/// does not match the attestations.
pub fn from_files(files: &[(String, Vec<u8>)]) -> Result<Self, PackageError> {
let index_bytes = files
.iter()
.find(|(p, _)| p == INDEX_PATH)
.map(|(_, b)| b)
.ok_or(PackageError::NoIndex)?;
let index: Index = serde_json::from_slice(index_bytes)
.map_err(|e| PackageError::Malformed(format!("{INDEX_PATH}: {e}")))?;
if index.kind != PACKAGE_KIND {
return Err(PackageError::WrongKind(index.kind));
}
if index.package_version > PACKAGE_VERSION {
return Err(PackageError::FromTheFuture(index.package_version));
}
let mut entries = Vec::with_capacity(index.attestations.len());
for row in &index.attestations {
let bytes = files
.iter()
.find(|(p, _)| *p == row.path)
.map(|(_, b)| b)
.ok_or_else(|| PackageError::MissingAttestation(row.path.clone()))?;
let a: BuildAttestation = serde_json::from_slice(bytes)
.map_err(|e| PackageError::Malformed(format!("{}: {e}", row.path)))?;
if a.attestation_version > ATTESTATION_VERSION {
return Err(PackageError::FromTheFuture(a.attestation_version));
}
if a.artifact.sha256 != row.artifact_sha256 || a.path() != row.path {
return Err(PackageError::Misfiled {
path: row.path.clone(),
holds: a.artifact.sha256.clone(),
});
}
entries.push(a);
}
entries.sort_by(|a, b| a.artifact.sha256.cmp(&b.artifact.sha256));
let package = Self { entries };
for (path, expected) in package.derived_lookups() {
let found = files
.iter()
.find(|(p, _)| *p == path)
.map(|(_, b)| b)
.ok_or_else(|| PackageError::MissingLookup(path.clone()))?;
let found: Vec<String> = String::from_utf8_lossy(found)
.lines()
.map(str::to_string)
.filter(|l| !l.is_empty())
.collect();
if found != expected {
return Err(PackageError::LookupDisagrees {
path,
says: found.len(),
attestations_say: expected.len(),
});
}
}
Ok(package)
}
/// Does this history cover the artifact on disk, and does the artifact
/// agree?
///
/// This is the release gate's question, and it is asked of the *bytes about
/// to be released* rather than of a build log.
#[must_use]
pub fn verify_artifact(&self, artifact: &Path) -> AttestationVerdict {
let bytes = match std::fs::read(artifact) {
Ok(b) => b,
Err(e) => {
return AttestationVerdict::Unreadable(format!("{}: {e}", artifact.display()));
}
};
let sha = nornir_hash::sha256_hex(&bytes);
match self.by_artifact(&sha) {
Some(a) => a.verify(artifact),
None => AttestationVerdict::Unreadable(format!(
"{} hashes to {sha}, which this history does not cover — \
the package is written after the build and before the release, \
so an uncovered artifact means the order was not kept",
artifact.display()
)),
}
}
}
/// Why a package could not be read back.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PackageError {
/// No [`INDEX_PATH`] in the archive.
NoIndex,
/// The manifest names a different package kind.
WrongKind(String),
/// A schema version this reader does not understand.
FromTheFuture(u32),
/// The manifest points at an attestation the archive does not hold.
MissingAttestation(String),
/// An attestation is stored under a path that is not its own hash.
Misfiled {
/// The path it was found at.
path: String,
/// The hash it actually attests.
holds: String,
},
/// A derived lookup the writer would have produced is absent.
MissingLookup(String),
/// A derived lookup does not match the attestations it summarises.
LookupDisagrees {
/// Which lookup.
path: String,
/// How many hashes the stored file lists.
says: usize,
/// How many the attestations imply.
attestations_say: usize,
},
/// JSON that is not what it claims to be.
Malformed(String),
}
impl fmt::Display for PackageError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::NoIndex => write!(f, "no {INDEX_PATH} — this is not a {PACKAGE_KIND} package"),
Self::WrongKind(k) => write!(f, "{INDEX_PATH} says kind {k:?}, not {PACKAGE_KIND:?}"),
Self::FromTheFuture(v) => write!(
f,
"schema version {v} is newer than this reader understands \
(package {PACKAGE_VERSION}, attestation {ATTESTATION_VERSION})"
),
Self::MissingAttestation(p) => write!(f, "{INDEX_PATH} points at {p}, which is absent"),
Self::Misfiled { path, holds } => {
write!(f, "{path} holds an attestation for {holds}")
}
Self::MissingLookup(p) => write!(f, "the derived lookup {p} is absent"),
Self::LookupDisagrees {
path,
says,
attestations_say,
} => write!(
f,
"{path} lists {says} artifact(s) and the attestations imply \
{attestations_say} — a lookup that disagrees with the attestations answers \
\"what did this recipe build\" wrongly"
),
Self::Malformed(w) => write!(f, "malformed: {w}"),
}
}
}
impl std::error::Error for PackageError {}
// ── one artifact ─────────────────────────────────────────────────────────────
/// Attest a single built artifact: read its receipt, hash its bytes.
///
/// # Errors
///
/// [`Unattestable`], one variant per reason a build would otherwise be missing
/// from the history without anyone noticing.
pub fn attest(artifact: &Path) -> Result<BuildAttestation, Unattestable> {
let bytes = std::fs::read(artifact).map_err(|e| Unattestable::Unreadable {
artifact: artifact.to_path_buf(),
why: e.to_string(),
})?;
let receipt = match read_receipt(artifact) {
Ok(Some(r)) => r,
Ok(None) => return Err(Unattestable::NoReceipt(artifact.to_path_buf())),
Err(e) => {
return Err(Unattestable::Unreadable {
artifact: artifact.to_path_buf(),
why: format!("{e:#}"),
});
}
};
attest_receipt(artifact, receipt, &bytes)
}
/// The pure half of [`attest`]: everything except touching the filesystem.
///
/// Split out so the refusals can be tested without a compiler, and so a caller
/// that already holds the bytes does not read them twice.
///
/// # Errors
///
/// [`Unattestable::UnpinnedRecipe`] and [`Unattestable::UnsafeName`].
pub fn attest_receipt(
artifact: &Path,
receipt: Receipt,
bytes: &[u8],
) -> Result<BuildAttestation, Unattestable> {
if receipt.build_thing.hash.is_none() {
return Err(Unattestable::UnpinnedRecipe {
artifact: artifact.to_path_buf(),
coordinate: receipt.build_thing.to_string(),
});
}
for (field, value) in [
("build_thing.krate", &receipt.build_thing.krate),
("build_thing.version", &receipt.build_thing.version),
("atom", &receipt.atom),
] {
if !safe_component(value) {
return Err(Unattestable::UnsafeName {
artifact: artifact.to_path_buf(),
field,
value: value.clone(),
});
}
}
Ok(BuildAttestation {
attestation_version: ATTESTATION_VERSION,
artifact: ArtifactRef {
sha256: nornir_hash::sha256_hex(bytes),
bytes: bytes.len() as u64,
},
receipt,
// No proof from bytes alone. A stripped artifact has no symbol table, so
// there is nothing here to audit; the proof is attached by
// `attest_proven`, which is handed the unstripped binary as well.
proof: None,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::contract::{Coordinate, RECEIPT_VERSION};
fn receipt(krate: &str, version: &str, hash: Option<&str>, atom: &str) -> Receipt {
let mut settings = BTreeMap::new();
settings.insert("feature.gpu".to_string(), "false".to_string());
let mut inputs = BTreeMap::new();
inputs.insert(
"target".to_string(),
"x86_64-unknown-linux-musl".to_string(),
);
Receipt {
receipt_version: RECEIPT_VERSION,
build_thing: Coordinate {
krate: krate.to_string(),
version: version.to_string(),
hash: hash.map(str::to_string),
},
atom: atom.to_string(),
target: "exe".to_string(),
settings,
inputs,
}
}
/// A package over synthetic bytes. `attest_receipt` is the same function the
/// real path uses; only the reading of the receipt out of an ELF is skipped,
/// and that half is covered by `tests/history_end_to_end.rs` against real
/// binaries built by a real linker.
fn package(items: &[(Receipt, &[u8])]) -> HistoryPackage {
let mut entries: Vec<BuildAttestation> = items
.iter()
.map(|(r, b)| attest_receipt(Path::new("synthetic"), r.clone(), b).expect("attestable"))
.collect();
entries.sort_by(|a, b| a.artifact.sha256.cmp(&b.artifact.sha256));
entries.dedup_by(|a, b| a.artifact.sha256 == b.artifact.sha256);
HistoryPackage { entries }
}
/// The package is a pure function of the artifact bytes: the same inputs in
/// the opposite order seal the same files.
///
/// This is "lose it and it regenerates" as an assertion.
#[test]
fn the_same_artifacts_in_a_different_order_produce_the_same_files() {
let a = (
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA".as_slice(),
);
let b = (
receipt("korp-build-thing", "0.3.1", Some("a91f4c"), "korp-ui"),
b"BBBB".as_slice(),
);
let forward = package(&[a.clone(), b.clone()]).files().expect("files");
let reverse = package(&[b, a]).files().expect("files");
assert_eq!(forward, reverse, "insertion order leaked into the package");
}
/// LAW 2 on the guard above: it can tell two different packages apart.
///
/// Without this, `the_same_artifacts_in_a_different_order…` would pass just
/// as happily if `files()` returned a constant.
#[test]
fn the_determinism_check_reports_red_on_two_different_packages() {
let a = receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
);
let one = package(&[(a.clone(), b"AAAA")]).files().expect("files");
let two = package(&[(a, b"BBBB")]).files().expect("files");
assert_ne!(
one, two,
"the comparison calls two different packages equal, so it can never fail"
);
}
/// A package survives being written out and read back, and the read side
/// recomputes the lookups rather than trusting them.
#[test]
fn a_package_survives_the_round_trip_through_its_own_files() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt("gunnar-build-thing", "0.1.0", Some("e484a4"), "gunnar-solo"),
b"BBBB",
),
(
receipt("korp-build-thing", "0.3.1", Some("a91f4c"), "korp-ui"),
b"CCCC",
),
]);
let files = p.files().expect("files");
let back = HistoryPackage::from_files(&files).expect("read back");
assert_eq!(back, p, "a package did not survive its own round trip");
assert_eq!(back.len(), 3);
}
/// **What did this recipe build?** — both scopes, and the CVE-shaped one
/// (crate, any version) is the one that must span versions.
#[test]
fn a_recipe_lookup_answers_what_that_recipe_built() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt(
"gunnar-build-thing",
"0.2.0",
Some("ffff11"),
"gunnar-server",
),
b"BBBB",
),
(
receipt("korp-build-thing", "0.3.1", Some("a91f4c"), "korp-ui"),
b"CCCC",
),
]);
assert_eq!(p.by_recipe("gunnar-build-thing", None).len(), 2);
assert_eq!(p.by_recipe("gunnar-build-thing", Some("0.1.0")).len(), 1);
assert_eq!(p.by_recipe("korp-build-thing", None).len(), 1);
assert_eq!(
p.by_recipe("does-not-exist", None).len(),
0,
"a recipe nothing was built with must answer empty, not everything"
);
assert_eq!(p.by_atom("gunnar-server").len(), 2);
}
/// A hand-edited `by-recipe/…` is refused, and refused BY NAME.
///
/// This is the whole reason `from_files` recomputes: that file is what a CVE
/// sweep reads, and a package that under-reports there hides an affected
/// artifact.
#[test]
fn a_tampered_recipe_lookup_is_refused() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt("gunnar-build-thing", "0.1.0", Some("e484a4"), "gunnar-solo"),
b"BBBB",
),
]);
let mut files = p.files().expect("files");
let target = format!("{BY_RECIPE_DIR}/gunnar-build-thing");
let row = files
.iter_mut()
.find(|(path, _)| *path == target)
.expect("the lookup must exist for this test to mean anything");
let before = String::from_utf8(row.1.clone()).unwrap();
assert_eq!(
before.lines().count(),
2,
"fixture must list both artifacts"
);
// Drop one hash — the shape of an under-reporting sweep.
row.1 = before.lines().next().unwrap().as_bytes().to_vec();
match HistoryPackage::from_files(&files) {
Err(PackageError::LookupDisagrees {
path,
says,
attestations_say,
}) => {
assert_eq!(path, target);
assert_eq!((says, attestations_say), (1, 2));
}
other => panic!("a truncated recipe lookup was accepted: {other:?}"),
}
}
/// LAW 2: the untampered package must pass the same door, or the test above
/// would pass on a `from_files` that refuses everything.
#[test]
fn the_untampered_package_passes_the_door_the_tampered_one_fails() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt("gunnar-build-thing", "0.1.0", Some("e484a4"), "gunnar-solo"),
b"BBBB",
),
]);
HistoryPackage::from_files(&p.files().expect("files")).expect("clean package must read");
}
/// An attestation moved to another file name is refused: the path IS the
/// primary key, and a misfiled one makes a hash lookup answer with somebody
/// else's build.
#[test]
fn a_misfiled_attestation_is_refused() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt("gunnar-build-thing", "0.1.0", Some("e484a4"), "gunnar-solo"),
b"BBBB",
),
]);
let mut files = p.files().expect("files");
// Swap the two attestation bodies, keeping the paths.
let paths: Vec<String> = p
.attestations()
.iter()
.map(BuildAttestation::path)
.collect();
let a = files
.iter()
.find(|(x, _)| *x == paths[0])
.unwrap()
.1
.clone();
let b = files
.iter()
.find(|(x, _)| *x == paths[1])
.unwrap()
.1
.clone();
for (path, body) in files.iter_mut() {
if *path == paths[0] {
*body = b.clone();
} else if *path == paths[1] {
*body = a.clone();
}
}
assert!(
matches!(
HistoryPackage::from_files(&files),
Err(PackageError::Misfiled { .. })
),
"two attestations swapped under each other's hash were accepted"
);
}
/// A recipe named only by version cannot be attested. The version says which
/// recipe was *intended*; only the hash says which one ran.
#[test]
fn an_unpinned_recipe_is_refused_by_name() {
let r = receipt("gunnar-build-thing", "0.1.0", None, "gunnar-server");
match attest_receipt(Path::new("a"), r, b"AAAA") {
Err(Unattestable::UnpinnedRecipe { coordinate, .. }) => {
assert_eq!(coordinate, "gunnar-build-thing:0.1.0");
}
other => panic!("an unpinned recipe was attested: {other:?}"),
}
// LAW 2: the same receipt WITH a hash goes through, so the refusal is
// about the hash and not about the fixture.
let ok = receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
);
attest_receipt(Path::new("a"), ok, b"AAAA").expect("a pinned recipe must attest");
}
/// Every receipt field that becomes a path component is refused when it
/// cannot be one. Sanitising instead would silently file an attestation
/// somewhere nobody looks.
#[test]
fn a_receipt_field_that_would_escape_the_archive_is_refused() {
for (field, krate, version, atom) in [
("build_thing.krate", "../../etc", "0.1.0", "a"),
("build_thing.version", "k", "0.1.0/../..", "a"),
("atom", "k", "0.1.0", ".."),
("atom", "k", "0.1.0", ""),
] {
let r = receipt(krate, version, Some("e484a4"), atom);
match attest_receipt(Path::new("a"), r, b"AAAA") {
Err(Unattestable::UnsafeName { field: got, .. }) => {
assert_eq!(
got, field,
"wrong field blamed for {krate}/{version}/{atom}"
);
}
other => panic!("{krate}/{version}/{atom} was accepted: {other:?}"),
}
}
}
/// The index is sorted by hash and every row points at a file that exists.
#[test]
fn every_index_row_points_at_a_file_the_package_actually_holds() {
let p = package(&[
(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
),
(
receipt("korp-build-thing", "0.3.1", Some("a91f4c"), "korp-ui"),
b"BBBB",
),
(
receipt("holger-build-thing", "1.0.0", Some("cccccc"), "holger-full"),
b"CCCC",
),
]);
let files = p.files().expect("files");
let paths: Vec<&String> = files.iter().map(|(path, _)| path).collect();
assert!(
paths.windows(2).all(|w| w[0] <= w[1]),
"the package's files are not path-sorted, so two runs can seal two archives: {paths:?}"
);
let index = p.index();
assert_eq!(index.kind, PACKAGE_KIND);
assert!(
index
.attestations
.windows(2)
.all(|w| w[0].artifact_sha256 <= w[1].artifact_sha256),
"index rows are not sorted by artifact hash"
);
for row in &index.attestations {
assert!(
files.iter().any(|(path, _)| *path == row.path),
"index row points at {}, which the package does not hold",
row.path
);
}
}
/// A package from a future schema is refused rather than partially read.
#[test]
fn a_package_from_the_future_is_refused() {
let p = package(&[(
receipt(
"gunnar-build-thing",
"0.1.0",
Some("e484a4"),
"gunnar-server",
),
b"AAAA",
)]);
let mut files = p.files().expect("files");
let mut index = p.index();
index.package_version = PACKAGE_VERSION + 1;
for (path, body) in files.iter_mut() {
if path == INDEX_PATH {
*body = serde_json::to_vec_pretty(&index).unwrap();
}
}
assert!(matches!(
HistoryPackage::from_files(&files),
Err(PackageError::FromTheFuture(_))
));
}
/// An empty history is a package, not an error — and it reads back empty
/// rather than as "no package".
#[test]
fn an_empty_history_round_trips_as_an_empty_history() {
let p = HistoryPackage::default();
let files = p.files().expect("files");
assert_eq!(
files.len(),
1,
"an empty package is its manifest and nothing else"
);
let back = HistoryPackage::from_files(&files).expect("read back");
assert!(back.is_empty());
}
}