crates/nornir-build-thing/src/provenance.rs
//! **Build provenance** — a portable, content-addressed attestation of exactly
//! what a [`BuildVariant`](crate::variant::BuildVariant) build produced (task
//! #36).
//!
//! Mirrors JFrog "build-info" / SLSA supply-chain provenance: the
//! [`crate::variant`] descriptor holds only the *possible* build points; a
//! **build-provenance record** captures what actually happened when one was
//! materialized — the chosen variant, the resolved inputs (VCS rev + dirty
//! state, cargo argv, features, profile, target), the [`Toolchain`], the
//! [`HostInfo`] (cores + memory), the outcome, and the content hashes of every
//! produced artifact.
//!
//! # The chain
//!
//! ```text
//! build → provenance record → warehouse sink → digest → znippy archive (sealed) → verify
//! ```
//!
//! * [`BuildProvenance`] — the record. Fully serde; round-trips through JSON.
//! * [`BuildProvenance::attest`] — folds the record to a canonical byte string
//! and a SHA-256 [`Attestation::digest`]; the seal binds to that digest.
//! * [`ProvenanceSealer`] — the **seal seam**. The real cryptographic sealer is
//! znippy's detached-CMS signer, wired by nornir behind its `build-provenance`
//! feature (`nornir::build::seal`); this lean crate stays dep-light and never
//! fabricates a signature.
//!
//! This crate has **no heavy deps** (no gix / arrow / iceberg / eframe): VCS
//! state is read through the `git` CLI and host facts through `std` + `/proc`,
//! so a leaf repo's xtask can pull it cheaply.
use std::collections::BTreeMap;
use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::variant::{BuildVariant, BuildVariantSet};
/// Schema tag stamped into every record so a reader can reject an unknown shape.
pub const PROVENANCE_SCHEMA: &str = "nornir.build-provenance/v1";
/// The toolchain that performed a build.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Toolchain {
/// The `rustc` version line (e.g. `rustc 1.85.0 (…)`).
pub rustc: String,
/// The host target triple `rustc` runs on.
pub host: String,
/// The `cargo` version line.
pub cargo: String,
/// The **container image** the build ran in, without a digest
/// (`nordisk-cargo-toolchain`). `None` when the build ran on the host: a
/// build that used no container must not claim one.
///
/// A version line is not an image. `rustc 1.97.1` is the same string
/// whichever container produced it, which is why the question "which
/// artifacts were built with the WRONG toolchain image" could not be asked
/// of this record until this field existed.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image: Option<String>,
/// The image's **manifest digest**: bare lowercase hex, no `sha256:` prefix
/// — the spelling `CargoToolchain::image_sha256` already uses, so the two
/// never have to be translated between.
///
/// `None` when the image was named but not pinned. That is recorded as an
/// absence rather than filled in: half an answer beats a wrong one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub image_sha256: Option<String>,
}
impl Toolchain {
/// Name the container this toolchain ran in, and its manifest digest.
///
/// Additive and chainable, so no existing caller changes: a build that ran
/// on the host simply never calls it and the fields stay `None`.
#[must_use]
pub fn with_container_image(
mut self,
image: Option<String>,
image_sha256: Option<String>,
) -> Self {
self.image = image;
self.image_sha256 = image_sha256;
self
}
/// Probe the ambient toolchain via `rustc -vV` / `cargo -vV`. Best-effort.
pub fn detect() -> Self {
let rustc_vv = version_output("rustc");
let cargo_vv = version_output("cargo");
Toolchain {
rustc: first_line(&rustc_vv),
host: rustc_vv
.lines()
.find_map(|l| l.strip_prefix("host: "))
.unwrap_or("")
.trim()
.to_string(),
cargo: first_line(&cargo_vv),
// `detect` probes THIS process's ambient toolchain, which by
// definition is not in a container.
image: None,
image_sha256: None,
}
}
}
/// The build **host** facts (cores + memory) — capacity the build consumed, so a
/// provenance reader knows the machine that produced the artifact.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct HostInfo {
/// Logical CPU cores available to the build.
pub cores: u32,
/// Total physical memory in bytes (0 when unknown).
pub memory_bytes: u64,
}
impl HostInfo {
/// Probe cores via `std::thread::available_parallelism` and total memory via
/// `/proc/meminfo` (Linux). Best-effort: unknown fields are 0.
pub fn detect() -> Self {
let cores = std::thread::available_parallelism().map(|n| n.get() as u32).unwrap_or(0);
HostInfo { cores, memory_bytes: total_memory_bytes() }
}
}
fn total_memory_bytes() -> u64 {
// `MemTotal: <kB> kB` from /proc/meminfo (Linux). 0 elsewhere.
std::fs::read_to_string("/proc/meminfo")
.ok()
.and_then(|s| {
s.lines().find_map(|l| {
let rest = l.strip_prefix("MemTotal:")?;
let kb: u64 = rest.split_whitespace().next()?.parse().ok()?;
Some(kb * 1024)
})
})
.unwrap_or(0)
}
fn version_output(bin: &str) -> String {
Command::new(bin)
.arg("-vV")
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
.unwrap_or_default()
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
/// One artifact a build produced, content-addressed by SHA-256.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Artifact {
/// Path — relative to the repo root when the file lives under it, else absolute.
pub path: String,
/// Size in bytes.
pub bytes: u64,
/// Lowercase-hex SHA-256 of the file's contents.
pub sha256: String,
}
impl Artifact {
/// Hash the file at `abs_path`, recording its path relative to `repo_root`
/// when possible.
pub fn hash_file(repo_root: &Path, abs_path: &Path) -> Result<Self> {
let bytes = std::fs::read(abs_path)
.with_context(|| format!("read artifact {}", abs_path.display()))?;
let sha256 = hex(&Sha256::digest(&bytes));
let path = abs_path
.strip_prefix(repo_root)
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| abs_path.to_string_lossy().into_owned());
Ok(Artifact { path, bytes: bytes.len() as u64, sha256 })
}
}
/// Whether a build succeeded. Serializes as `"success"` / `"failed"`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Outcome {
Success,
Failed,
}
/// The resolved inputs a build consumed — everything the outcome depends on.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Inputs {
/// The exact materialized `cargo` argv.
pub cargo_args: Vec<String>,
/// Enabled features.
pub features: Vec<String>,
/// Canonical profile name (`dev` / `release` / custom).
pub profile: String,
/// Cross-compile target triple, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
/// Extra `--cfg` threaded via `RUSTFLAGS`, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rustflags_cfg: Option<String>,
/// VCS commit the build was cut from.
pub vcs_rev: String,
/// VCS branch (best-effort).
pub vcs_branch: String,
/// `true` when the working tree had uncommitted changes at build time.
pub vcs_dirty: bool,
/// Stable digest of the working-tree porcelain status (`clean` when clean).
pub worktree_digest: String,
/// **Every source tree the build compiled**, each pinned to its commit: the
/// repo itself first, then each declared sibling checkout its `path = "../…"`
/// dependencies reach into (`[build.toolchain] siblings`).
///
/// `vcs_rev` above names ONE commit, and a gunnar or holger binary is built
/// from nine to twelve repos — the other eight to eleven were not in the
/// record at all, so the SLSA `resolvedDependencies` could not name them.
/// Empty on a record that predates the field (and on a host build whose repo
/// has no remote), which then keeps the legacy single `git+nordisk/<repo>`
/// material.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sources: Vec<SourceRev>,
}
/// One source tree a build consumed, pinned: its forge URL and its commit.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct SourceRev {
/// The checkout's directory name (`edda`, `znippy`, …).
pub name: String,
/// The SLSA material URI — `git+https://<host>/<org>/<repo>`, normalised
/// from the checkout's default remote (an `ssh://git@host:port/…` remote
/// and its `https://` twin name one repo, so they spell one URI).
pub uri: String,
/// The HEAD commit (`unknown` when unreadable).
pub commit: String,
/// `true` when the checkout had uncommitted changes at build time.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub dirty: bool,
}
impl SourceRev {
/// Read the source facts of the checkout at `dir`. `None` when it has no
/// default remote — a material without a URI is not a material anyone can
/// fetch, so it is left out rather than invented.
pub fn read(name: &str, dir: &Path) -> Option<Self> {
let remote = git_remote_url(dir)?;
let (commit, _) = git_head(dir);
let (dirty, _) = git_worktree_freshness(dir);
Some(SourceRev { name: name.to_string(), uri: source_uri(&remote), commit, dirty })
}
/// The root repo plus each named sibling (resolved beside `repo_root`).
/// Siblings that do not exist or have no remote are skipped.
pub fn read_all(repo_name: &str, repo_root: &Path, siblings: &[String]) -> Vec<Self> {
let mut out: Vec<Self> = SourceRev::read(repo_name, repo_root).into_iter().collect();
if let Some(parent) = repo_root.parent() {
for s in siblings {
if out.iter().any(|o| &o.name == s) {
continue;
}
out.extend(SourceRev::read(s, &parent.join(s)));
}
}
out
}
}
/// Normalise a git remote to the SLSA `git+https://host/path` spelling: the
/// scheme, the user and the port go, so the ssh and https remotes of one repo
/// name one material. `.git` is dropped for the same reason.
pub fn source_uri(remote: &str) -> String {
let r = remote.trim();
let rest = if let Some((_, after)) = r.split_once("://") {
after.to_string()
} else if let Some((userhost, path)) = r.split_once(':') {
// scp form `git@host:org/repo`
format!("{userhost}/{path}")
} else {
return format!("git+{r}");
};
let rest = rest.rsplit_once('@').map(|(_, h)| h.to_string()).unwrap_or(rest);
let (host, path) = rest.split_once('/').unwrap_or((rest.as_str(), ""));
let host = host.split(':').next().unwrap_or(host);
let path = path.trim_end_matches('/').trim_end_matches(".git");
format!("git+https://{host}/{path}")
}
/// A build-provenance record: the full, serde-round-tripping description of one
/// materialized [`BuildVariant`] build.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BuildProvenance {
/// Always [`PROVENANCE_SCHEMA`].
pub schema: String,
/// Stable per-record identity (UUID v4).
pub id: String,
/// The repo whose variant was built (its directory / config name).
pub repo: String,
/// The chosen variant descriptor, verbatim — *what* was built.
pub variant: BuildVariant,
/// The resolved inputs.
pub inputs: Inputs,
/// The toolchain that ran the build.
pub toolchain: Toolchain,
/// The build host (cores + memory).
pub host: HostInfo,
/// Build outcome.
pub outcome: Outcome,
/// Content hashes of every produced artifact.
pub artifacts: Vec<Artifact>,
/// RFC 3339 UTC timestamp of capture.
pub built_at: String,
/// Best-effort `user@host` of the builder.
pub builder: String,
/// The **build-thing recipe** that planned this build — `krate:version:hash`,
/// where `hash` is the recipe crate's content hash.
///
/// `variant` above says which variant was chosen; `buildType` in the SLSA
/// statement carries its NAME. Neither says which recipe CODE produced the
/// plan, so two artifacts built from the same variant name by different
/// recipe code were indistinguishable — and that is exactly the day the
/// coordinate is needed.
///
/// `None` when the caller did not supply one. A record written before this
/// field existed reads back `None`, so a consumer gets *unchecked* rather
/// than a wrong answer.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub build_thing: Option<crate::contract::Coordinate>,
/// The **build platform's identity** — the SLSA `runDetails.builder.id` — as
/// the door that ran the build names itself (e.g. the dwarves build door).
///
/// `builder` above is `user@host`: WHO ran it. SLSA asks for the PLATFORM a
/// verifier allow-lists, and only the door knows which one it is. `None`
/// keeps the historical `nornir-build-thing://<user@host>` id.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub builder_id: Option<String>,
}
impl BuildProvenance {
/// Capture a provenance record for a materialized build. `artifact_paths` are
/// the absolute paths to the produced artifacts (cargo's `compiler-artifact`
/// `executable`s); each is hashed. VCS state is read in-process via the
/// `nornir-git` leaf (pure `gix`, no `git` CLI), host facts via `std` + `/proc`.
pub fn capture(
variant: &BuildVariant,
repo: &str,
repo_root: &Path,
toolchain: Toolchain,
host: HostInfo,
outcome: Outcome,
artifact_paths: &[std::path::PathBuf],
) -> Result<Self> {
let (vcs_rev, vcs_branch) = git_head(repo_root);
let (vcs_dirty, worktree_digest) = git_worktree_freshness(repo_root);
let inputs = Inputs {
cargo_args: variant.cargo_args(),
features: variant.features.clone(),
profile: String::from(variant.profile.clone()),
target: variant.target.clone(),
rustflags_cfg: variant.cfg.clone(),
vcs_rev,
vcs_branch,
vcs_dirty,
worktree_digest,
// The repo's own source material. Siblings are the container road's
// (it alone knows which trees it mounted) — `with_sources`.
sources: SourceRev::read(&repo_name(repo_root), repo_root).into_iter().collect(),
};
let mut artifacts = Vec::with_capacity(artifact_paths.len());
for p in artifact_paths {
artifacts.push(Artifact::hash_file(repo_root, p)?);
}
Ok(BuildProvenance {
schema: PROVENANCE_SCHEMA.to_string(),
id: crate::provenance::record_id(),
repo: repo.to_string(),
variant: variant.clone(),
inputs,
toolchain,
host,
outcome,
artifacts,
built_at: chrono::Utc::now().to_rfc3339(),
builder: builder_identity(),
// Supplied by the caller through `with_build_thing`, because only the
// recipe crate knows its own content hash (it is a build-time
// constant). Adding it as a `capture` parameter would have broken
// every caller for a field most of them cannot fill.
build_thing: None,
builder_id: None,
})
}
/// Record every source tree the build consumed (root first). Replaces the
/// list `capture` read, so the caller states the whole set once.
#[must_use]
pub fn with_sources(mut self, sources: Vec<SourceRev>) -> Self {
self.inputs.sources = sources;
self
}
/// Name the build platform (the SLSA `builder.id`) — see [`Self::builder_id`].
#[must_use]
pub fn with_builder_id(mut self, id: impl Into<String>) -> Self {
self.builder_id = Some(id.into());
self
}
/// Name the build-thing recipe that planned this build.
///
/// Additive and chainable — see [`Self::build_thing`].
#[must_use]
pub fn with_build_thing(mut self, coordinate: Option<crate::contract::Coordinate>) -> Self {
self.build_thing = coordinate;
self
}
/// Pretty JSON serialization (round-trips through [`Self::from_json`]).
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("serialize build provenance")
}
/// Parse a record from JSON.
pub fn from_json(text: &str) -> Result<Self> {
serde_json::from_str(text).context("parse build provenance")
}
/// The canonical byte string the attestation digest is taken over: compact,
/// key-sorted JSON so the same record folds to the same bytes anywhere.
pub fn canonical_bytes(&self) -> Result<Vec<u8>> {
let value: serde_json::Value =
serde_json::to_value(self).context("provenance to value")?;
let canon = canonicalize(&value);
serde_json::to_vec(&canon).context("canonical provenance bytes")
}
/// Fold the record into an [`Attestation`] (canonical bytes + SHA-256 digest).
pub fn attest(self) -> Result<Attestation> {
let canonical = self.canonical_bytes()?;
let digest: [u8; 32] = Sha256::digest(&canonical).into();
Ok(Attestation { record: self, canonical, digest, seal: None, dsse: None })
}
/// Map this record to an **in-toto SLSA Provenance v1** statement — the payload
/// a DSSE seal signs, so `cosign` / `slsa-verifier` parse it. The field names
/// are the SLSA v1 names verbatim (`_type`, `subject`, `predicateType`,
/// `predicate.buildDefinition.{buildType,externalParameters,resolvedDependencies}`,
/// `predicate.runDetails.builder.id`), which is the whole point: external tools
/// must be able to read it.
///
/// * `subject` — one entry per produced artifact, content-addressed by
/// `digest.sha256` (the artifact's hex SHA-256).
/// * `buildType` — the chosen variant (as a URI carrying its name).
/// * `externalParameters` — the `--opt`/`--param` choices (features, profile,
/// target, cargo argv, `--cfg`).
/// * `resolvedDependencies` — everything the build CONSUMED: the source repo
/// (+ `gitCommit`), the working-tree freshness digest, the build-thing
/// recipe referenced by its content hash, and the container image + its
/// manifest digest. The last two are emitted only when the record carries
/// them, so a record written before they existed emits neither.
/// * `internalParameters` — the `rustc`/`cargo`/`host` the record was given.
/// On the container road that IS the container's, read off the sentinels
/// (`cargo_container.rs`); on the host road it is the build host's. Which
/// one it was is no longer ambiguous, because the image is now named
/// separately in `resolvedDependencies`.
/// * `runDetails.builder.id` — the forge / builder identity.
pub fn to_slsa_statement(&self) -> serde_json::Value {
use serde_json::json;
let subject: Vec<serde_json::Value> = self
.artifacts
.iter()
// The subject NAME is the file name: the path is where the forge's
// target dir happened to be, which says nothing to a consumer and
// differs between two boxes producing the same bytes.
.map(|a| json!({ "name": artifact_file_name(&a.path), "digest": { "sha256": a.sha256 } }))
.collect();
// The source trees, each pinned to its VCS revision. SLSA
// resolvedDependencies entries use `uri` + `digest` (git commits go under
// the `gitCommit` digest key). A record without `sources` keeps the one
// legacy material it always had.
let mut resolved: Vec<serde_json::Value> = if self.inputs.sources.is_empty() {
vec![json!({
"uri": format!("git+nordisk/{}", self.repo),
"digest": { "gitCommit": self.inputs.vcs_rev },
})]
} else {
self.inputs
.sources
.iter()
.map(|s| json!({ "name": s.name, "uri": s.uri, "digest": { "gitCommit": s.commit } }))
.collect()
};
// The working-tree freshness is a distinct, verifiable input — record it as a
// named dependency rather than silently folding it away.
resolved.push(json!({
"name": "worktree",
"digest": { "sha256": self.inputs.worktree_digest },
}));
// The recipe that planned the build, referenced by the digest of its own
// CODE. Emitted only when it is known: a statement must never name a
// material the record cannot prove.
if let Some(c) = &self.build_thing {
let mut e = serde_json::Map::new();
e.insert("name".into(), json!("build-thing-recipe"));
e.insert("uri".into(), json!(format!("nornir+buildthing://{}@{}", c.krate, c.version)));
if let Some(h) = &c.hash {
e.insert("digest".into(), json!({ "sha256": h }));
}
resolved.push(serde_json::Value::Object(e));
}
// The container the build ran in. SLSA puts the things a build CONSUMED
// in `resolvedDependencies`, and an image is consumed exactly the way a
// source tree is. An unpinned image is named without a digest rather
// than given one it does not have.
if let Some(img) = &self.toolchain.image {
let mut e = serde_json::Map::new();
e.insert("name".into(), json!("toolchain-image"));
e.insert("uri".into(), json!(img));
if let Some(d) = &self.toolchain.image_sha256 {
e.insert("digest".into(), json!({ "sha256": d }));
}
resolved.push(serde_json::Value::Object(e));
}
let external = json!({
"variant": self.variant.name,
"repo": self.repo,
"features": self.inputs.features,
"profile": self.inputs.profile,
"target": self.inputs.target,
"cargoArgs": self.inputs.cargo_args,
"rustflagsCfg": self.inputs.rustflags_cfg,
});
json!({
"_type": IN_TOTO_STATEMENT_TYPE,
"subject": subject,
"predicateType": SLSA_PREDICATE_TYPE,
"predicate": {
"buildDefinition": {
// A TYPE, versioned — what kind of build this is and how to
// read `externalParameters`; the variant itself is a
// parameter (`externalParameters.variant`), not a type.
"buildType": BUILD_TYPE_VARIANT_V1,
"externalParameters": external,
"internalParameters": {
"rustc": self.toolchain.rustc,
"cargo": self.toolchain.cargo,
"host": self.toolchain.host,
"dirtySources": self.inputs.sources.iter().filter(|s| s.dirty).map(|s| s.name.clone()).collect::<Vec<_>>(),
"vcsDirty": self.inputs.vcs_dirty,
},
"resolvedDependencies": resolved,
},
"runDetails": {
"builder": {
"id": self
.builder_id
.clone()
.unwrap_or_else(|| format!("nornir-build-thing://{}", self.builder)),
"builderDependencies": [],
},
"metadata": {
"invocationId": self.id,
"startedOn": self.built_at,
},
},
},
})
}
/// The canonical (key-sorted, compact) bytes of the SLSA statement — the exact
/// payload a DSSE seal signs, so producer and verifier fold to the same bytes.
pub fn slsa_statement_bytes(&self) -> Result<Vec<u8>> {
let canon = canonicalize(&self.to_slsa_statement());
serde_json::to_vec(&canon).context("canonical SLSA statement bytes")
}
}
/// A detached cryptographic seal over an attestation digest (a znippy detached
/// CMS `SignedData`). The seal never contains the record bytes — it binds to the
/// 32-byte [`Attestation::digest`], so it can travel separately.
///
/// **Unchanged** by the DSSE work: `cms_der` is still a plain field, so every
/// consumer that reads it keeps compiling. The DSSE (SLSA-interop) seal is a
/// SEPARATE, additive slot on [`Attestation`] ([`Attestation::dsse`]) rather than
/// a variant of this type — an additive field never breaks a reader, whereas
/// turning `cms_der` into an enum arm did (it broke modgunn's default build).
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Seal {
/// Signature algorithm name (`ed25519` / `p256`).
pub alg: String,
/// commonName of the signer certificate.
pub signer_cn: String,
/// The detached CMS `SignedData` (DER) over the digest.
pub cms_der: Vec<u8>,
}
/// A **DSSE** seal: the spec envelope JSON plus the signer certificate(s) a verifier
/// needs to recover the public key (a DSSE envelope itself carries only a `keyid`).
/// The envelope's payload is the in-toto SLSA provenance statement
/// ([`BuildProvenance::to_slsa_statement`]); its subject is the produced artifact,
/// so it is content-addressed and verifiable by standard SLSA software. It rides in
/// its own [`Attestation::dsse`] slot beside (never replacing) the CMS [`Seal`].
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DsseSeal {
/// Signature algorithm name (`ed25519` / `p256`).
pub alg: String,
/// commonName of the signer certificate.
pub signer_cn: String,
/// The DSSE `payloadType` — [`DSSE_PAYLOAD_TYPE_INTOTO`] for in-toto/SLSA.
pub payload_type: String,
/// The full DSSE envelope JSON: `{payload, payloadType, signatures:[{sig, keyid}]}`.
pub envelope_json: String,
/// The signer certificate(s) (DER) — needed to recover the public key + identity
/// for airgap verification. `keyid` in the envelope is each cert's fingerprint hex.
pub signer_certs: Vec<Vec<u8>>,
}
/// The seal seam: turn a 32-byte attestation digest into a CMS [`Seal`]. The real
/// implementation is znippy's CMS signer (nornir's `build-provenance` feature);
/// this crate defines only the trait so it stays dependency-light.
pub trait ProvenanceSealer {
fn seal(&self, digest: &[u8; 32]) -> Result<Seal>;
}
/// The **DSSE** seal seam: sign a payload (the in-toto SLSA statement bytes) as a
/// DSSE envelope. The real implementation is znippy's `dsse_sign` (nornir's
/// `build-provenance` feature); this crate defines only the trait so it stays
/// dependency-light — exactly like [`ProvenanceSealer`].
pub trait DsseSealer {
/// Sign `payload` (with media type `payload_type`) as a DSSE envelope and
/// return the [`DsseSeal`] (envelope JSON + signer certs + alg/cn).
fn seal_dsse(&self, payload_type: &str, payload: &[u8]) -> Result<DsseSeal>;
}
/// The SLSA `buildType` of a cargo variant build — versioned: a change to what
/// `externalParameters` means is a `/v2`, never a silent edit of `/v1`.
/// `externalParameters` = `{variant, repo, features, profile, target, cargoArgs,
/// rustflagsCfg}`; `resolvedDependencies` = every source tree (`gitCommit`), the
/// worktree digest, the recipe and the toolchain image (`sha256` manifest digest).
pub const BUILD_TYPE_VARIANT_V1: &str =
"https://git.gunnar.rs/nordisk/edda/buildtypes/nornir-build-thing/variant/v1";
/// The file name of a recorded artifact path (the whole string when it has none).
fn artifact_file_name(path: &str) -> String {
Path::new(path)
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.unwrap_or_else(|| path.to_string())
}
/// The in-toto **Statement** type URI — the `_type` of every SLSA provenance.
pub const IN_TOTO_STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
/// The SLSA **Provenance v1** predicate type URI.
pub const SLSA_PREDICATE_TYPE: &str = "https://slsa.dev/provenance/v1";
/// The DSSE `payloadType` for an in-toto statement — what cosign/slsa-verifier key on.
pub const DSSE_PAYLOAD_TYPE_INTOTO: &str = "application/vnd.in-toto+json";
/// An attestation: a record, the canonical bytes that were folded, the digest,
/// and (once sealed) the detached seal.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Attestation {
/// The provenance record.
pub record: BuildProvenance,
/// The exact canonical bytes the digest was taken over.
#[serde(skip)]
pub canonical: Vec<u8>,
/// SHA-256 over [`Self::canonical`]. Serialized as lowercase hex.
#[serde(with = "digest_hex")]
pub digest: [u8; 32],
/// The detached CMS seal, once applied (the airgap default).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub seal: Option<Seal>,
/// The detached **DSSE** (in-toto SLSA) seal, once applied — an additive slot
/// beside `seal`, so a consumer that only knows the CMS seal is unaffected and
/// an attestation can carry either (or, in principle, both).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dsse: Option<DsseSeal>,
}
impl Attestation {
/// Seal this attestation with `sealer` (CMS), binding a detached signature to
/// the [`Self::digest`]. This is the airgap default and is unchanged.
pub fn seal_with(mut self, sealer: &dyn ProvenanceSealer) -> Result<Self> {
self.seal = Some(sealer.seal(&self.digest)?);
Ok(self)
}
/// Seal this attestation with a **DSSE** sealer: map the record to an in-toto
/// SLSA provenance statement ([`BuildProvenance::to_slsa_statement`]) and sign
/// its canonical bytes as a DSSE envelope, so the result is verifiable by
/// standard SLSA software. The DSSE seal binds the STATEMENT (whose subject is
/// the produced artifact), not the record digest — the SLSA-correct binding.
pub fn seal_dsse_with(mut self, sealer: &dyn DsseSealer) -> Result<Self> {
let payload = self.record.slsa_statement_bytes()?;
self.dsse = Some(sealer.seal_dsse(DSSE_PAYLOAD_TYPE_INTOTO, &payload)?);
Ok(self)
}
/// True once a real seal (CMS or DSSE) has been applied.
pub fn is_sealed(&self) -> bool {
self.seal.is_some() || self.dsse.is_some()
}
/// Lowercase-hex form of the digest.
pub fn digest_hex(&self) -> String {
hex(&self.digest)
}
/// Recompute the digest from the record and confirm it still matches.
pub fn digest_matches_record(&self) -> Result<bool> {
let canonical = self.record.canonical_bytes()?;
let digest: [u8; 32] = Sha256::digest(&canonical).into();
Ok(digest == self.digest)
}
/// Serialize the whole attestation (record + digest + seal) to pretty JSON.
pub fn to_json(&self) -> Result<String> {
serde_json::to_string_pretty(self).context("serialize attestation")
}
/// Parse an attestation from JSON, recomputing the canonical bytes.
pub fn from_json(text: &str) -> Result<Self> {
let mut att: Attestation = serde_json::from_str(text).context("parse attestation")?;
att.canonical = att.record.canonical_bytes()?;
Ok(att)
}
}
/// The result of [`build_variant`]: the produced artifacts + the unsealed
/// provenance attestation. `success` mirrors [`BuildProvenance::outcome`].
#[derive(Debug, Clone)]
pub struct BuildOutcome {
/// The variant name that was built.
pub variant: String,
/// Absolute paths to the produced artifacts.
pub artifacts: Vec<std::path::PathBuf>,
/// The unsealed provenance attestation (seal it via [`Attestation::seal_with`]).
pub attestation: Attestation,
/// Whether cargo exited zero.
pub success: bool,
}
/// **A cargo run that exits zero while producing no executable is not a
/// success** — the ONE judge both cargo roads pass their result through before
/// they hand a [`BuildOutcome`] back.
///
/// `success` on a `BuildOutcome` has only ever meant *cargo's exit code was
/// zero*, on both roads: the host one reads `status.success()`
/// ([`build_variant_observed_with_info`]) and the container one reads
/// `out.exit_code == Some(0)`
/// ([`build_variant_in_container`](crate::cargo_container::build_variant_in_container)).
/// Neither ever asked whether a binary came out. For a variant whose kind IS a
/// binary that is a green with nothing behind it, and the failure then surfaces
/// wherever somebody first tries to use the file — three frames away, in words
/// that blame the consumer.
///
/// MEASURED on t14s, 2026-09-12, which is why this exists. The appliance compose
/// (`dwarves buildthing gunnar --variant gunnar-installer-open`) died at step 3
/// of 3 with
///
/// ```text
/// chained variant 'gunnar-server' has kind 'Exe', which produced no single
/// artifact to stage
/// ```
///
/// — raised by [`stage_variant_artifacts`](crate::image_build::stage_variant_artifacts),
/// which is the STAGER, not the builder. The build above it had returned
/// `success = true`, so the `ensure!(outcome.success, …)` one line earlier did
/// not fire and the reader was left with a sentence about `kind 'Exe'` for a
/// declaration that was correct. The real event — the build reported no
/// executable — had happened and been discarded one call earlier.
///
/// # Only [`ArtifactKind::Exe`](crate::variant::ArtifactKind::Exe) is judged
///
/// Deliberately, and this is the line that keeps the check honest rather than
/// merely loud: cargo reports a produced file in `"reason":"compiler-artifact"`
/// with a NON-NULL `executable`, and it only ever fills that in for a binary
/// target. A `--lib` build and the usual `cdylib` wasm build both report
/// `executable: null` and carry their output in `filenames`, so demanding an
/// artifact from them would refuse builds that are perfectly fine. `Exe` is also
/// exactly the kind the stager later requires a single path from, so this judge
/// and that requirement are the same rule said once, early.
///
/// `units` is cargo's compiler-artifact count and is in the message on purpose:
/// it is what separates the two causes from each other, and the caller has it.
pub fn ensure_exe_was_produced(
variant: &BuildVariant,
road: &str,
exit_ok: bool,
units: usize,
artifacts: &[std::path::PathBuf],
) -> Result<()> {
use crate::variant::ArtifactKind;
if !exit_ok || variant.kind != ArtifactKind::Exe || !artifacts.is_empty() {
return Ok(());
}
let cause = if units == 0 {
"cargo's json stream carried NO compiler artifacts at all — either no compiler ran, \
or the stream never reached this process. On the container road the second one is \
the transport, and it is the measured one: the container writes cargo's stream to \
a file on the bound target dir precisely because the engine's stdout copy of it \
has been seen to arrive empty, so an empty read here means neither channel had \
anything, which looks exactly like a silent build"
} else {
"cargo compiled units but none of them reported an `executable`, which is what a \
selection that resolves to no binary target looks like — check the variant's \
`package`/`bin` against the manifest"
};
anyhow::bail!(
"variant '{}' (kind exe) built in {road}: cargo exited 0 but produced no executable \
— {units} compiler artifact(s), none of them a runnable file. Exit zero is not \
evidence that a binary exists; the only thing that reports one is cargo's \
`--message-format=json` stream (`\"reason\":\"compiler-artifact\"` with a non-null \
`executable`). {cause}.",
variant.name
)
}
/// **The crate's headline API.** Validate `name` against `set`, materialize +
/// run `cargo build` in `repo_root` (with `--message-format=json` so produced
/// executables are content-hashed), and return a [`BuildOutcome`] carrying the
/// artifacts + an unsealed provenance [`Attestation`].
///
/// This is what a leaf repo's xtask calls:
/// `nornir_build_thing::build_variant(&set, "full", repo_root)`.
///
/// This is the build-info-free shorthand: it routes the build info to a no-op
/// [`crate::build_info::NullBuildInfoSink`], so existing callers are unchanged.
/// To capture the columnar build-info stream (§f2/§f3), call
/// [`build_variant_with_info`] with a [`crate::build_info::BuildInfoSink`].
pub fn build_variant(set: &BuildVariantSet, name: &str, repo_root: &Path) -> Result<BuildOutcome> {
build_variant_with_info(set, name, repo_root, &crate::build_info::NullBuildInfoSink)
}
/// [`build_variant`] that ALSO emits the build info (design §f2): the produced
/// artifact is *the thing*; the [`crate::build_info::BuildInfoRow`] stream is the
/// record ABOUT the build. Live `step`/`log`/`metric` rows are emitted as the
/// build runs, then the Artifactory-parity manifest rows (`provenance`/`dep`/
/// `artifact`) are emitted from the captured [`BuildProvenance`]. The `sink` is
/// transport-agnostic — the in-process [`crate::build_info::InMemBuildInfoSink`]
/// for a single box, a Flight-backed sink for cross-host (a later slice). Sink
/// emission is best-effort: a sink error never fails the build.
pub fn build_variant_with_info(
set: &BuildVariantSet,
name: &str,
repo_root: &Path,
sink: &dyn crate::build_info::BuildInfoSink,
) -> Result<BuildOutcome> {
build_variant_observed_with_info(set, name, repo_root, sink, &crate::progress::NullObserver)
}
/// [`build_variant`] with a LIVE [`BuildObserver`](crate::progress::BuildObserver) —
/// the entry point a UI drives from a worker thread while the main thread renders
/// the event stream. Same build, same [`BuildOutcome`]; the observer additionally
/// sees each compiled unit, each compiler **diagnostic verbatim**, each artifact,
/// and a terminal `Finished`/`Failed`.
pub fn build_variant_observed(
set: &BuildVariantSet,
name: &str,
repo_root: &Path,
observer: &dyn crate::progress::BuildObserver,
) -> Result<BuildOutcome> {
build_variant_observed_with_info(
set,
name,
repo_root,
&crate::build_info::NullBuildInfoSink,
observer,
)
}
/// **The one cargo-driving loop in this crate.** Every other build entry point
/// delegates here; `sink` takes the columnar build-info stream and `observer` takes
/// the live [`BuildEvent`](crate::progress::BuildEvent) stream. Both are
/// best-effort: neither can fail the build.
///
/// Note what changed when the observer landed: cargo's
/// `--message-format=json-render-diagnostics` stream carries `CompilerMessage`s, and
/// this loop used to **discard them**, so a red build had its own error text nowhere.
/// They are now surfaced verbatim as [`BuildEvent::Diagnostic`](crate::progress::BuildEvent::Diagnostic).
pub fn build_variant_observed_with_info(
set: &BuildVariantSet,
name: &str,
repo_root: &Path,
sink: &dyn crate::build_info::BuildInfoSink,
observer: &dyn crate::progress::BuildObserver,
) -> Result<BuildOutcome> {
use crate::progress::BuildEvent;
use std::io::BufReader;
use std::process::Stdio;
// Any early return (unknown variant, invalid variant, cargo not spawnable) is a
// terminal `Failed` for the observer — a UI must never be left waiting on a
// build that never started.
let fail = |e: anyhow::Error| -> anyhow::Error {
observer.on_event(BuildEvent::Failed { variant: name.to_string(), error: format!("{e:#}") });
e
};
let started = std::time::Instant::now();
let variant = set.require(name).map_err(&fail)?;
set.validate(variant).map_err(&fail)?;
observer.on_event(BuildEvent::Started {
variant: name.to_string(),
command_line: variant.command_line(),
repo_root: repo_root.display().to_string(),
});
// The message format is chosen by the observer (see
// `BuildObserver::wants_diagnostics`): `json` yields `CompilerMessage`s carrying
// the rendered diagnostic and prints nothing, `json-render-diagnostics` withholds
// the message and lets cargo print to stderr. The historical default is the
// latter, so an existing caller's terminal output is byte-for-byte unchanged.
let format = if observer.wants_diagnostics() {
"--message-format=json"
} else {
"--message-format=json-render-diagnostics"
};
let mut cmd = Command::new("cargo");
cmd.current_dir(repo_root)
.args(variant.cargo_args())
.arg(format)
.stdout(Stdio::piped());
if let Some(cfg) = &variant.cfg {
let inherited = std::env::var("RUSTFLAGS").unwrap_or_default();
let flags = format!("{inherited} --cfg {cfg}");
cmd.env("RUSTFLAGS", flags.trim().to_string());
}
let mut child = cmd
.spawn()
.with_context(|| format!("spawn cargo for variant '{name}'"))
.map_err(&fail)?;
let reader = BufReader::new(child.stdout.take().expect("piped stdout"));
let mut artifact_paths = Vec::new();
let mut units = 0usize;
for msg in cargo_metadata::Message::parse_stream(reader) {
match msg.map_err(|e| fail(anyhow::Error::new(e)))? {
cargo_metadata::Message::CompilerArtifact(a) => {
units += 1;
observer.on_event(BuildEvent::Compiled { unit: a.target.name.clone(), nth: units });
if let Some(exe) = a.executable {
let p = exe.into_std_path_buf();
observer.on_event(BuildEvent::Artifact { path: p.display().to_string() });
artifact_paths.push(p);
}
}
// The compiler's own words. Previously dropped on the floor, which is
// why a failed build could only ever say "cargo exited non-zero".
cargo_metadata::Message::CompilerMessage(m) => {
observer.on_event(BuildEvent::Diagnostic {
level: diagnostic_level(&m.message.level).to_string(),
rendered: m
.message
.rendered
.clone()
.unwrap_or_else(|| m.message.message.clone()),
});
}
_ => {}
}
}
let status = child.wait().context("wait for cargo").map_err(&fail)?;
let success = status.success();
// The judge, BEFORE a provenance record is captured: attesting to a build
// that produced nothing would put the green in the warehouse too.
ensure_exe_was_produced(variant, "the host cargo run", success, units, &artifact_paths)
.map_err(&fail)?;
let outcome = if success { Outcome::Success } else { Outcome::Failed };
let variant = set.require(name)?;
let record = BuildProvenance::capture(
variant,
&repo_name(repo_root),
repo_root,
Toolchain::detect(),
HostInfo::detect(),
outcome,
&artifact_paths,
)?;
// ② the build info — emit the columnar stream (design §f2/§f3). The live
// feed (step/log/metric) first, then the manifest rows from the record.
// Best-effort: a sink failure must never fail the build (L2, additive).
let ts = crate::build_info::now_micros();
let elapsed = started.elapsed().as_micros() as i64;
let feed = crate::build_info::build_feed_rows(
&record.id,
ts,
name,
success,
artifact_paths.len(),
record.host.cores,
elapsed,
);
let _ = sink.emit(&feed);
let _ = sink.emit(&crate::build_info::manifest_rows(&record, ts));
let attestation = record.attest()?;
observer.on_event(crate::progress::BuildEvent::Finished {
variant: name.to_string(),
success,
elapsed_ms: (elapsed / 1000).max(0) as u64,
artifacts: artifact_paths.len(),
});
Ok(BuildOutcome { variant: name.to_string(), artifacts: artifact_paths, attestation, success })
}
/// A fresh id for a captured provenance record — the ONE place this crate mints one.
///
/// On a host it is a `uuid` v4. On `wasm32-unknown-unknown` there is no OS randomness
/// source at all (`uuid/v4` is a hard COMPILE error there, not a runtime one), and
/// that target is where this crate ships as a build-thing plugin — which declares and
/// validates and captures nothing, so it never reaches here. The wasm build therefore
/// substitutes a deterministic per-module counter rather than dragging a randomness
/// backend into a sandbox that is deliberately granted no capabilities. It is prefixed
/// so an id that somehow DID escape a plugin is identifiable on sight instead of
/// passing for a real uuid.
pub fn record_id() -> String {
#[cfg(not(target_arch = "wasm32"))]
{
uuid::Uuid::new_v4().to_string()
}
#[cfg(target_arch = "wasm32")]
{
use core::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
format!("wasm-plugin-{:016x}", N.fetch_add(1, Ordering::Relaxed))
}
}
/// rustc's own level word for a diagnostic. `cargo_metadata`'s `DiagnosticLevel` is
/// a non-exhaustive enum with no `Display`, so the mapping lives here in ONE place
/// rather than being re-derived by every observer.
fn diagnostic_level(level: &cargo_metadata::diagnostic::DiagnosticLevel) -> &'static str {
use cargo_metadata::diagnostic::DiagnosticLevel as L;
match level {
L::Error => "error",
L::Warning => "warning",
L::Note => "note",
L::Help => "help",
L::Ice => "error: internal compiler error",
L::FailureNote => "failure-note",
_ => "unknown",
}
}
/// The build's repo name — the repo-root directory name (or its path). Shared with
/// the image/vm build path ([`crate::image_build`]).
pub(crate) fn repo_name(repo_root: &Path) -> String {
repo_root
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| repo_root.display().to_string())
}
pub(crate) fn builder_identity() -> String {
let user = std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.unwrap_or_default();
let host = std::env::var("HOSTNAME")
.ok()
.filter(|h| !h.is_empty())
.or_else(|| std::fs::read_to_string("/proc/sys/kernel/hostname").ok())
.map(|h| h.trim().to_string())
.unwrap_or_default();
match (user.is_empty(), host.is_empty()) {
(false, false) => format!("{user}@{host}"),
(false, true) => user,
(true, false) => host,
(true, true) => "unknown".to_string(),
}
}
// ── git via the nornir-git leaf (pure gix — the ONE git-inspection home) ─────
//
// Routed through `nornir-git`'s `gitio`, which reads HEAD + working-tree
// freshness in-process via `gix` (LAW: every git operation goes through the
// nornir-git leaf, never the `git` CLI). Host-only: nornir-git pulls `gix` + the
// russh SSH bridge, neither of which builds for `wasm32` — where this crate ships
// as a build-thing PLUGIN that captures no provenance — so on wasm the readers
// degrade to the same "unknown"/"clean" answers the old CLI path returned when
// git was absent.
#[cfg(all(feature = "provenance", not(target_arch = "wasm32")))]
pub(crate) fn git_head(repo_root: &Path) -> (String, String) {
nornir_git::gitio::head_sha_and_branch(repo_root)
.unwrap_or_else(|_| ("unknown".to_string(), "unknown".to_string()))
}
#[cfg(all(feature = "provenance", not(target_arch = "wasm32")))]
pub(crate) fn git_remote_url(repo_root: &Path) -> Option<String> {
nornir_git::gitio::default_remote_url(repo_root).ok().flatten()
}
/// No git linked (wasm, or the `provenance` feature off): no remote is known, so
/// no source material is claimed.
#[cfg(any(not(feature = "provenance"), target_arch = "wasm32"))]
pub(crate) fn git_remote_url(_repo_root: &Path) -> Option<String> {
None
}
#[cfg(target_arch = "wasm32")]
pub(crate) fn git_head(_repo_root: &Path) -> (String, String) {
("unknown".to_string(), "unknown".to_string())
}
/// Host build WITHOUT the `provenance` feature: the record is still written, but
/// its HEAD is `unknown` — and that is said out loud, by name, every time, so a
/// record without its commit is never silent. Turn the feature on in the crate
/// that captures (`features = ["provenance"]`).
#[cfg(all(not(feature = "provenance"), not(target_arch = "wasm32")))]
pub(crate) fn git_head(repo_root: &Path) -> (String, String) {
eprintln!(
"[nornir-build-thing] warning: provenance-without-git — the record for {} carries vcs_rev=unknown \
because nornir-build-thing was built without its `provenance` feature; the capturing crate \
must take it with features = [\"provenance\"]",
repo_root.display()
);
("unknown".to_string(), "unknown".to_string())
}
/// Working-tree freshness: dirty flag + a stable digest over the porcelain status
/// (`clean` when the tree is clean). The commit SHA can't see uncommitted edits —
/// this digest can. Computed in-process by `nornir_git::gitio::worktree_freshness`.
#[cfg(all(feature = "provenance", not(target_arch = "wasm32")))]
pub(crate) fn git_worktree_freshness(repo_root: &Path) -> (bool, String) {
match nornir_git::gitio::worktree_freshness(repo_root) {
Ok(f) => (f.dirty, f.digest),
// Not a git repo / unreadable: treat as clean-unknown rather than failing.
Err(_) => (false, nornir_git::gitio::CLEAN_WORKTREE_DIGEST.to_string()),
}
}
#[cfg(any(not(feature = "provenance"), target_arch = "wasm32"))]
pub(crate) fn git_worktree_freshness(_repo_root: &Path) -> (bool, String) {
(false, "clean".to_string())
}
/// Recursively sort object keys so serialization is byte-stable. Shared with the
/// image/vm attestation path ([`crate::image_build`]).
pub(crate) fn canonicalize(v: &serde_json::Value) -> serde_json::Value {
match v {
serde_json::Value::Object(map) => {
let sorted: BTreeMap<String, serde_json::Value> =
map.iter().map(|(k, val)| (k.clone(), canonicalize(val))).collect();
serde_json::to_value(sorted).unwrap_or(serde_json::Value::Null)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.iter().map(canonicalize).collect())
}
other => other.clone(),
}
}
pub(crate) fn hex(bytes: &[u8]) -> String {
nornir_hash::hex_encode(bytes)
}
/// serde helper: (de)serialize a `[u8; 32]` digest as lowercase hex.
pub(crate) mod digest_hex {
use serde::{Deserialize, Deserializer, Serializer};
pub fn serialize<S: Serializer>(digest: &[u8; 32], s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&super::hex(digest))
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<[u8; 32], D::Error> {
let s = String::deserialize(d)?;
if s.len() != 64 {
return Err(serde::de::Error::custom("digest must be 64 hex chars"));
}
let mut out = [0u8; 32];
for (i, b) in out.iter_mut().enumerate() {
*b = u8::from_str_radix(&s[i * 2..i * 2 + 2], 16)
.map_err(|e| serde::de::Error::custom(format!("bad hex: {e}")))?;
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
/// Without the `provenance` feature a host record's HEAD is `unknown`, and
/// the named `provenance-without-git` warning is printed (run with
/// `--no-default-features -- --nocapture` to see it).
#[cfg(not(feature = "provenance"))]
#[test]
fn without_provenance_head_is_unknown() {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
assert_eq!(git_head(root).0, "unknown", "without `provenance` no git is linked, so HEAD is unknown");
}
use super::*;
use crate::variant::Profile;
/// The driving example: holger's `full` split executable.
fn full_variant() -> BuildVariant {
BuildVariant {
name: "full".to_string(),
package: Some("holger-full".to_string()),
bin: Some("holger-full".to_string()),
features: vec!["full".to_string(), "served".to_string(), "gui".to_string()],
no_default_features: false,
profile: Profile::Release,
target: Some("x86_64-unknown-linux-musl".to_string()),
cfg: Some("holger_dist".to_string()),
..Default::default()
}
}
/// Capture a record against a temp "repo" with a fake artifact file (no cargo
/// run needed). Proves the attestation captures the variant, the resolved
/// inputs, the host facts, and the artifact content hash.
fn capture_full() -> (tempfile::TempDir, BuildProvenance) {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let target = root.join("target/release");
std::fs::create_dir_all(&target).unwrap();
let art = target.join("holger-full");
std::fs::write(&art, b"ELF holger-full bytes").unwrap();
let rec = BuildProvenance::capture(
&full_variant(),
"holger",
root,
Toolchain { rustc: "rustc 1.85.0".into(), host: "x86_64-unknown-linux-gnu".into(), cargo: "cargo 1.85.0".into(), ..Default::default() },
HostInfo { cores: 32, memory_bytes: 128 * 1024 * 1024 * 1024 },
Outcome::Success,
&[art],
)
.unwrap();
(dir, rec)
}
/// ★ RED-FIRST — **exit zero with no executable is not a success.** Before
/// this judge existed the function did not exist either: both cargo roads
/// computed `success` from the exit code alone and handed back a
/// [`BuildOutcome`] with an empty `artifacts`, and the first thing that
/// NOTICED was the stager three frames later, saying `kind 'Exe' produced no
/// single artifact to stage` about a declaration that was correct.
///
/// Every arm is asserted in both directions on purpose, because the easy way
/// to write this check is one that can never fail (always `Ok`) or one that
/// can never pass (refusing a `--lib` build, which legitimately reports
/// `executable: null`).
#[test]
fn a_cargo_run_that_exits_zero_and_produced_no_executable_is_refused() {
use crate::variant::ArtifactKind;
let exe = BuildVariant { name: "srv".into(), ..Default::default() };
assert_eq!(exe.kind, ArtifactKind::Exe, "the default kind IS the judged one");
let art = vec![std::path::PathBuf::from("/t/release/srv")];
// The bug: cargo said 0, 818 units went by, no executable came back.
let e = ensure_exe_was_produced(&exe, "the container img@sha256:abc", true, 818, &[])
.expect_err("exit 0 with no executable must refuse");
let m = format!("{e}");
assert!(m.contains("'srv'") && m.contains("no executable"), "{m}");
assert!(m.contains("818 compiler artifact"), "the unit count is in the words: {m}");
assert!(m.contains("img@sha256:abc"), "the road is named: {m}");
assert!(m.contains("no binary target"), "units > 0 names the selection cause: {m}");
// Zero units is the OTHER cause, and it must read differently — that is
// the whole reason `units` is a parameter.
let e = ensure_exe_was_produced(&exe, "the container img", true, 0, &[]);
let m = format!("{}", e.expect_err("still a refusal"));
assert!(m.contains("transport"), "0 units names the stream cause: {m}");
assert!(!m.contains("no binary target"), "the two causes must not both be printed: {m}");
// …and the three greens, each of which a too-eager check would turn red.
ensure_exe_was_produced(&exe, "host", true, 818, &art).expect("an exe that exists passes");
ensure_exe_was_produced(&exe, "host", false, 0, &[])
.expect("a build that already failed is red by its exit code, not by this");
let lib = BuildVariant { name: "l".into(), kind: ArtifactKind::Lib, ..Default::default() };
ensure_exe_was_produced(&lib, "host", true, 12, &[])
.expect("a --lib build reports `executable: null` and is fine");
let wasm =
BuildVariant { name: "w".into(), kind: ArtifactKind::Wasm, ..Default::default() };
ensure_exe_was_produced(&wasm, "host", true, 12, &[])
.expect("a cdylib wasm build reports `executable: null` and is fine");
}
#[test]
fn attestation_captures_variant_inputs_host_and_hashes() {
let (_dir, rec) = capture_full();
// WHAT: the whole `full` variant descriptor, verbatim.
assert_eq!(rec.variant.name, "full");
assert_eq!(rec.variant.bin.as_deref(), Some("holger-full"));
// INPUTS: materialized argv + resolved axes.
assert_eq!(rec.inputs.cargo_args, rec.variant.cargo_args());
assert_eq!(rec.inputs.profile, "release");
assert_eq!(rec.inputs.rustflags_cfg.as_deref(), Some("holger_dist"));
assert!(rec.inputs.features.contains(&"full".to_string()));
// HOST: cores + memory captured.
assert_eq!(rec.host.cores, 32);
assert_eq!(rec.host.memory_bytes, 128 * 1024 * 1024 * 1024);
// HASHES: the artifact is content-addressed, sized, repo-relative.
assert_eq!(rec.artifacts.len(), 1);
let a = &rec.artifacts[0];
assert_eq!(a.path, "target/release/holger-full");
assert_eq!(a.bytes, b"ELF holger-full bytes".len() as u64);
assert_eq!(a.sha256, hex(&Sha256::digest(b"ELF holger-full bytes")));
assert_eq!(rec.schema, PROVENANCE_SCHEMA);
assert_eq!(rec.outcome, Outcome::Success);
}
#[test]
fn record_round_trips_through_json() {
let (_dir, rec) = capture_full();
let back = BuildProvenance::from_json(&rec.to_json().unwrap()).unwrap();
assert_eq!(rec, back);
}
#[test]
fn attestation_digest_is_stable_and_round_trips() {
let (_dir, rec) = capture_full();
let d1 = rec.clone().attest().unwrap().digest;
let d2 = rec.clone().attest().unwrap().digest;
assert_eq!(d1, d2);
let att = rec.attest().unwrap();
assert!(!att.is_sealed(), "a fresh attestation is unsealed — no faked seal");
assert!(att.digest_matches_record().unwrap());
let back = Attestation::from_json(&att.to_json().unwrap()).unwrap();
assert_eq!(back.digest, att.digest);
assert!(back.digest_matches_record().unwrap());
}
#[test]
fn tampering_the_record_breaks_the_digest() {
let (_dir, rec) = capture_full();
let mut att = rec.attest().unwrap();
assert!(att.digest_matches_record().unwrap());
att.record.inputs.vcs_rev = "0".repeat(40);
assert!(!att.digest_matches_record().unwrap());
}
// ── DSSE / SLSA additions ──
/// The CMS `Seal` keeps its HISTORICAL byte-shape — `cms_der` is still a plain
/// field — so a consumer that reads it keeps compiling and an old
/// `{alg, signer_cn, cms_der}` string still deserializes. This is what makes the
/// DSSE addition non-breaking (the earlier enum form broke modgunn's build).
#[test]
fn cms_seal_keeps_its_historical_shape() {
let seal = Seal { alg: "ed25519".into(), signer_cn: "nornir-build-signer".into(), cms_der: vec![1, 2, 3, 4] };
let v: serde_json::Value = serde_json::from_str(&serde_json::to_string(&seal).unwrap()).unwrap();
assert_eq!(v["alg"], "ed25519");
assert!(v["cms_der"].is_array());
assert!(v.get("body").is_none(), "no wrapper key");
let old = r#"{"alg":"p256","signer_cn":"who","cms_der":[9,8,7]}"#;
let back: Seal = serde_json::from_str(old).unwrap();
assert_eq!(back.cms_der, vec![9u8, 8, 7]);
}
/// DSSE rides in `Attestation::dsse`, an ADDITIVE slot beside the CMS `seal`, so
/// an attestation can carry either. A CMS-only attestation serializes with no
/// `dsse` key (back-compat); a DSSE one round-trips and reports `is_sealed`.
#[test]
fn dsse_seal_is_an_additive_slot_beside_cms() {
let (_dir, rec) = capture_full();
let mut att = rec.attest().unwrap();
assert!(!att.is_sealed());
// A CMS-only attestation carries no `dsse` key at all.
let j: serde_json::Value = serde_json::from_str(&att.to_json().unwrap()).unwrap();
assert!(j.get("dsse").is_none(), "CMS/unsealed attestation must not emit a dsse key");
att.dsse = Some(DsseSeal {
alg: "ed25519".into(),
signer_cn: "dsse-signer".into(),
payload_type: DSSE_PAYLOAD_TYPE_INTOTO.into(),
envelope_json: r#"{"payload":"eA==","payloadType":"t","signatures":[]}"#.into(),
signer_certs: vec![vec![0xab, 0xcd]],
});
assert!(att.is_sealed(), "a DSSE-only attestation is sealed");
let back = Attestation::from_json(&att.to_json().unwrap()).unwrap();
assert_eq!(back.dsse.as_ref().unwrap().payload_type, "application/vnd.in-toto+json");
assert!(back.seal.is_none());
}
/// The SLSA statement must use the exact SLSA v1 field names and content-address
/// the artifact — that is what lets slsa-verifier / cosign read it.
#[test]
fn slsa_statement_uses_spec_field_names_and_content_addresses_artifact() {
let (_dir, rec) = capture_full();
let art_sha = rec.artifacts[0].sha256.clone();
let art_path = rec.artifacts[0].path.clone();
let stmt = rec.to_slsa_statement();
assert_eq!(stmt["_type"], "https://in-toto.io/Statement/v1");
assert_eq!(stmt["predicateType"], "https://slsa.dev/provenance/v1");
// subject content-addresses the produced artifact by sha256.
assert_eq!(stmt["subject"][0]["name"], "holger-full", "the subject is the file name, not {art_path}");
assert_eq!(stmt["subject"][0]["digest"]["sha256"], art_sha);
let bd = &stmt["predicate"]["buildDefinition"];
assert_eq!(bd["buildType"], BUILD_TYPE_VARIANT_V1, "buildType is a versioned TYPE");
assert_eq!(bd["externalParameters"]["variant"], "full", "the variant is a parameter");
// externalParameters = the --opt/--param choices.
assert_eq!(bd["externalParameters"]["profile"], "release");
assert!(bd["externalParameters"]["features"]
.as_array()
.unwrap()
.iter()
.any(|f| f == "full"));
// resolvedDependencies pin the source to its VCS revision (gitCommit).
assert!(bd["resolvedDependencies"][0]["digest"]["gitCommit"].is_string());
// runDetails.builder.id — the forge identity.
assert!(stmt["predicate"]["runDetails"]["builder"]["id"]
.as_str()
.unwrap()
.starts_with("nornir-build-thing://"));
// Canonical bytes are stable (same statement folds to the same bytes).
assert_eq!(rec.slsa_statement_bytes().unwrap(), rec.slsa_statement_bytes().unwrap());
}
// ═══════════════════════════════════════════════════════════════════════
// The two joints that were missing, and the question they exist for.
//
// MEASURED, 2026-09-23: nobody could answer "which artifacts were built
// with the wrong toolchain image" when `:latest` had drifted from its pin.
// The record could not answer it either: it named the variant as a string
// and the toolchain as a rustc VERSION LINE, and neither says which
// container ran, nor which recipe CODE produced the plan.
// ═══════════════════════════════════════════════════════════════════════
/// ★ RED-FIRST — the record must be able to say WHICH IMAGE built this.
#[test]
fn the_container_image_and_its_digest_reach_the_record_and_the_statement() {
let (_d, rec) = capture_full();
// Absent by default: a host-road build ran in no container, and saying
// "unknown" would be a claim. `None` is the honest shape.
assert_eq!(rec.toolchain.image, None);
assert_eq!(rec.toolchain.image_sha256, None);
let st = rec.to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"]
.as_array()
.unwrap()
.clone();
assert!(
!deps.iter().any(|d| d["name"] == "toolchain-image"),
"a build with no container must not claim one"
);
// With a container, the image is a MATERIAL — which is where SLSA puts
// the things a build consumed.
let mut rec = rec;
rec.toolchain = rec.toolchain.clone().with_container_image(
Some("nordisk-cargo-toolchain".into()),
Some("3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143".into()),
);
let st = rec.to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
let img = deps.iter().find(|d| d["name"] == "toolchain-image").expect("image material");
assert_eq!(img["uri"], "nordisk-cargo-toolchain");
assert_eq!(
img["digest"]["sha256"],
"3d5115baa07d03ca51eb3e44ebb86b4e64e679bd88f38e156ef9da12c35d2143"
);
}
/// An image named but NOT pinned must still be recorded — and must not grow
/// a digest out of nowhere. Half an answer beats a wrong one.
#[test]
fn an_unpinned_image_is_recorded_without_a_digest() {
let (_d, mut rec) = capture_full();
rec.toolchain =
rec.toolchain.clone().with_container_image(Some("nordisk-cargo-toolchain".into()), None);
let st = rec.to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
let img = deps.iter().find(|d| d["name"] == "toolchain-image").unwrap();
assert_eq!(img["uri"], "nordisk-cargo-toolchain");
assert!(img.get("digest").is_none(), "an unpinned image must not invent a digest");
}
/// ★ RED-FIRST — the record must be able to say WHICH RECIPE planned this.
///
/// `buildType` carries the variant's NAME. Two artifacts built from the same
/// variant name by different recipe code are indistinguishable by it, and
/// that is exactly the day the coordinate is needed.
#[test]
fn the_build_thing_coordinate_reaches_the_record_and_the_statement() {
let (_d, rec) = capture_full();
assert_eq!(rec.build_thing, None, "absent until a caller supplies it");
let coord = crate::contract::Coordinate::parse("holger-build-thing:0.3.1:abc123").unwrap();
let rec = rec.with_build_thing(Some(coord.clone()));
assert_eq!(rec.build_thing.as_ref().unwrap().hash.as_deref(), Some("abc123"));
let st = rec.to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
let r = deps.iter().find(|d| d["name"] == "build-thing-recipe").expect("recipe material");
assert_eq!(r["uri"], "nornir+buildthing://holger-build-thing@0.3.1");
assert_eq!(r["digest"]["sha256"], "abc123");
}
/// A coordinate typed by hand before a first build has no hash to pin to.
/// It is still worth recording WHICH recipe — just not a digest it lacks.
#[test]
fn a_coordinate_without_a_hash_is_a_uri_with_no_digest() {
let (_d, rec) = capture_full();
let coord = crate::contract::Coordinate::parse("holger-build-thing:0.3.1").unwrap();
let st = rec.with_build_thing(Some(coord)).to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
let r = deps.iter().find(|d| d["name"] == "build-thing-recipe").unwrap();
assert_eq!(r["uri"], "nornir+buildthing://holger-build-thing@0.3.1");
assert!(r.get("digest").is_none());
}
/// Both fields are ADDITIVE: a record written before they existed still
/// parses, and reads back as `None` — the reader gets "unchecked", never a
/// wrong answer.
#[test]
fn an_older_record_without_these_fields_still_parses_as_absent() {
let old = r#"{
"schema":"nornir.build-provenance/v1","id":"x","repo":"holger",
"variant":{"name":"full"},
"inputs":{"cargo_args":[],"features":[],"profile":"release",
"vcs_rev":"abc","vcs_branch":"main","vcs_dirty":false,
"worktree_digest":"clean"},
"toolchain":{"rustc":"rustc 1.85.0","host":"x86_64-unknown-linux-gnu","cargo":"cargo 1.85.0"},
"host":{"cores":32,"memory_bytes":0},
"outcome":"success","artifacts":[],
"built_at":"2026-01-01T00:00:00Z","builder":"alice@t14s"
}"#;
let rec = BuildProvenance::from_json(old).expect("an older record must still parse");
assert_eq!(rec.build_thing, None);
assert_eq!(rec.toolchain.image, None);
assert_eq!(rec.toolchain.image_sha256, None);
// And it emits no material it cannot prove.
let deps = rec.to_slsa_statement()["predicate"]["buildDefinition"]
["resolvedDependencies"]
.as_array()
.unwrap()
.clone();
assert!(!deps.iter().any(|d| d["name"] == "toolchain-image"));
assert!(!deps.iter().any(|d| d["name"] == "build-thing-recipe"));
}
/// Absent fields must not change the canonical bytes of a record that does
/// not set them — otherwise every existing attestation digest would move.
#[test]
fn adding_the_fields_does_not_move_an_existing_records_digest() {
let (_d, rec) = capture_full();
let canon = String::from_utf8(rec.canonical_bytes().unwrap()).unwrap();
assert!(!canon.contains("build_thing"), "an absent field must not be serialized: {canon}");
assert!(!canon.contains("image_sha256"), "an absent field must not be serialized");
}
/// The two new materials must not displace the ones already there.
#[test]
fn the_source_and_worktree_materials_are_still_first() {
let (_d, rec) = capture_full();
let coord = crate::contract::Coordinate::parse("holger-build-thing:0.3.1:abc").unwrap();
let mut rec = rec.with_build_thing(Some(coord));
rec.toolchain =
rec.toolchain.clone().with_container_image(Some("img".into()), Some("dd".into()));
let st = rec.to_slsa_statement();
let deps = st["predicate"]["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
assert!(deps[0]["uri"].as_str().unwrap().starts_with("git+nordisk/"), "{deps:?}");
assert_eq!(deps[1]["name"], "worktree");
assert_eq!(deps.len(), 4);
}
/// The ssh and https remotes of one repo spell ONE material URI.
#[test]
fn a_remote_normalises_to_one_slsa_uri() {
for r in [
"ssh://git@git.gunnar.rs:2222/nordisk/edda",
"ssh://git@git.gunnar.rs:2222/nordisk/edda.git",
"https://git.gunnar.rs/nordisk/edda",
"https://git.gunnar.rs/nordisk/edda.git/",
"git@git.gunnar.rs:nordisk/edda.git",
] {
assert_eq!(source_uri(r), "git+https://git.gunnar.rs/nordisk/edda", "{r}");
}
}
/// With `sources` and a door-named builder the statement names EVERY source
/// tree by forge URL + commit, the toolchain image by digest, and the door.
#[test]
fn the_statement_names_every_source_the_image_and_the_door() {
let (_d, rec) = capture_full();
let mut rec = rec
.with_sources(vec![
SourceRev { name: "holger".into(), uri: "git+https://git.gunnar.rs/nordisk/holger".into(), commit: "a".repeat(40), dirty: false },
SourceRev { name: "edda".into(), uri: "git+https://git.gunnar.rs/nordisk/edda".into(), commit: "b".repeat(40), dirty: true },
])
.with_builder_id("https://git.gunnar.rs/nordisk/dwarves/buildthing@v1");
rec.toolchain = rec.toolchain.clone().with_container_image(Some("nordisk-cargo-toolchain".into()), Some("c".repeat(64)));
let st = rec.to_slsa_statement();
let p = &st["predicate"];
assert_eq!(p["runDetails"]["builder"]["id"], "https://git.gunnar.rs/nordisk/dwarves/buildthing@v1");
let deps = p["buildDefinition"]["resolvedDependencies"].as_array().unwrap();
assert_eq!(deps[0]["uri"], "git+https://git.gunnar.rs/nordisk/holger");
assert_eq!(deps[0]["digest"]["gitCommit"], "a".repeat(40));
assert_eq!(deps[1]["uri"], "git+https://git.gunnar.rs/nordisk/edda");
let img = deps.iter().find(|d| d["name"] == "toolchain-image").unwrap();
assert_eq!(img["digest"]["sha256"], "c".repeat(64));
assert_eq!(p["buildDefinition"]["internalParameters"]["dirtySources"][0], "edda");
// …and the record round-trips with both new fields.
let back = BuildProvenance::from_json(&rec.to_json().unwrap()).unwrap();
assert_eq!(back, rec);
}
}