Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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);
    }
}