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

nordisk/holger

server/lib/src/sbom.rs

//! SBOM **hosting** — attach + serve SBOM documents as first-class servable
//! artifacts (parity §6/§11, `.nornir/sbom-hosting-design.md`).
//!
//! Until now SBOMs were only *generated* (in nornir, `security.rs`); holger could
//! not host one. This module lets holger STORE a CycloneDX SBOM document attached
//! to an artifact coordinate `(repository, namespace/name/version)` and serve it
//! back byte-identical over three surfaces: the `SbomService` gRPC RPCs, the
//! read-only `GET /-/sbom` HTTP door, and the `holger-server sbom` CLI verb — the
//! exact tri-surface shape the cross-repo search verb landed with.
//!
//! **Model reuse (Law #5).** The SBOM data model is NOT re-declared here — it is
//! CONSUMED from **modgunn**, the single home of the nordisk SBOM schema. This
//! module re-exports [`SbomComponent`] / [`SbomInput`] / [`SbomVuln`] / [`purl`] /
//! [`to_cyclonedx`] verbatim and adds exactly one thin seam, [`build_cyclonedx`],
//! that serializes modgunn's emitted document to the bytes the store hosts. A
//! producer that already holds a ready CycloneDX document attaches its raw bytes
//! directly; a producer holding the structured model builds through modgunn here.
//! Either way holger never forks the schema.
//!
//! **Storage.** A flat on-disk store rooted at `<data_root>/holger-sboms`, laid
//! out `holger-sboms/<repo>/<coord>.sbom.json`. The repo name and the coordinate
//! are reduced to path-safe single segments by promote's [`sanitize_repo`] /
//! [`coord_key`] (reused, not twinned), so an untrusted coordinate off the wire
//! can never escape the store root (path-traversal closed). Attach is last-wins
//! (an SBOM may be regenerated + re-attached); a missing coordinate fetches as
//! `None` (not-found, never an error).
//!
//! # ★ The binding defect, and where it is fixed
//!
//! Everything above is a HOSTING story, and hosting is all this store was ever
//! asked to do. But as a piece of SUPPLY-CHAIN EVIDENCE it has four holes, and
//! they compound:
//!
//! 1. the key is a MUTABLE COORDINATE STRING — `rust-dev/serde/1.0.0` is whatever
//!    was last written to `rust-dev/serde/1.0.0`;
//! 2. attach is LAST-WINS, so re-uploading different bytes under one coordinate
//!    silently inherits the old bytes' bill of materials;
//! 3. the document is UNSIGNED — nothing says WHO generated it;
//! 4. `modgunn::sbom::to_cyclonedx` emits a MINIMAL document: no `serialNumber`,
//!    no `bom-ref`, no component `hashes`. Detach it from the coordinate it was
//!    filed under and it does not even self-reference the artifact.
//!
//! An SBOM bound to a NAME rather than a HASH is decorative. So this module also
//! offers the digest-bound path, and the binding is NOT a fifth bespoke wrapper:
//! **CycloneDX is a legitimate in-toto predicate** ([`crate::attest::CYCLONEDX_BOM`]),
//! so an SBOM becomes a DSSE-signed in-toto Statement whose `subject[].digest` IS
//! the artifact hash, filed in [`crate::attest::AttestStore`] under that hash.
//!
//! * [`build_cyclonedx_bound`] closes hole (4): modgunn's document, plus the
//!   `serialNumber` / `bom-ref` / `hashes` that make it self-referencing.
//! * [`sbom_statement_bytes`] turns it into the statement a gate signs, closing
//!   (1), (2) and (3) — different bytes are a different digest and cannot inherit;
//!   two SBOMs for one artifact coexist instead of overwriting; and the envelope
//!   names its signer.
//!
//! **Backward compatibility: NOTHING BREAKS.** `AttachSbom` / `FetchSbom`,
//! [`SbomStore`] and [`build_cyclonedx`] are byte-for-byte unchanged and the new
//! functions are purely additive. The two paths answer different questions —
//! *"serve me the SBOM document for this coordinate"* and *"what signed bill of
//! materials is bound to these exact bytes?"* — and only the second is evidence.

use std::path::PathBuf;
use std::sync::Arc;

use traits::ArtifactId;

#[cfg(feature = "scanners")]
use crate::attest::{statement_bytes, ArtifactDigest};
use crate::attest::{Statement, CYCLONEDX_BOM};
use crate::promote::{coord_key, is_own_storage_key, sanitize_repo};

// ── Model reuse (Law #5): consume modgunn's SBOM schema, never twin it. ──────
#[cfg(feature = "scanners")]
pub use modgunn::sbom::{purl, to_cyclonedx, SbomComponent, SbomInput, SbomVuln};

/// Serialize an SBOM (built from the shared modgunn model) into the bytes the
/// store hosts: a CycloneDX 1.5 JSON document. This is holger's ONE SBOM
/// serialization seam — it emits `modgunn::sbom::to_cyclonedx`, so the hosted
/// schema is exactly modgunn's and can never drift. A structured producer builds
/// its document through here; a producer holding a ready CycloneDX doc attaches
/// its raw bytes.
#[cfg(feature = "scanners")]
pub fn build_cyclonedx(input: &SbomInput) -> Vec<u8> {
    // `to_cyclonedx` returns a `serde_json::Value` built from literals; it always
    // serializes.
    serde_json::to_vec(&to_cyclonedx(input)).expect("CycloneDX Value always serializes")
}

/// ★ The SAME CycloneDX document, made SELF-REFERENCING against an artifact
/// digest.
///
/// `modgunn::sbom::to_cyclonedx` emits `metadata.component = {type, name}` and
/// nothing else, so a document lifted out of this store names no serial number,
/// no `bom-ref` anything can point at, and no hash of the thing it describes.
/// Three CycloneDX fields fix that, and all three are standard — this ADDS to
/// modgunn's schema, it does not fork it (Law #5): the component array, the
/// vulnerabilities array and every existing key are left exactly as modgunn
/// emitted them, so a reader of the old document reads this one unchanged.
///
/// * `serialNumber` — the BOM's own identity, a `urn:uuid`. Derived
///   DETERMINISTICALLY (blake3 over the subject digest and modgunn's document
///   bytes, RFC 4122 version/variant bits set) rather than randomly, because a
///   random serial would make two runs of the same generator over the same
///   artifact produce two different documents, and a byte-unstable predicate
///   cannot be compared, deduplicated or re-signed.
/// * `metadata.component.bom-ref` — `<alg>:<hex>`, the anchor a
///   `vulnerabilities[].affects[].ref` can name.
/// * `metadata.component.hashes` — the artifact digest itself, in CycloneDX's own
///   spelling (`SHA-256`, uppercase with a dash: the CycloneDX `hash-alg` enum,
///   which is NOT in-toto's `sha256`).
///
/// The in-toto `subject[].digest` remains the AUTHORITATIVE binding — it is what
/// the DSSE signature covers. This is defence in depth for the case where the
/// document travels without its envelope, which is exactly how an SBOM usually
/// travels.
#[cfg(feature = "scanners")]
pub fn build_cyclonedx_bound(input: &SbomInput, subject: &ArtifactDigest) -> Vec<u8> {
    let mut doc = to_cyclonedx(input);
    let base = serde_json::to_vec(&doc).expect("CycloneDX Value always serializes");

    let mut seed = Vec::with_capacity(base.len() + 80);
    seed.extend_from_slice(subject.alg().as_bytes());
    seed.push(b':');
    seed.extend_from_slice(subject.hex().as_bytes());
    seed.extend_from_slice(&base);
    doc["serialNumber"] = serde_json::Value::String(urn_uuid_from(blake3::hash(&seed).as_bytes()));

    if let Some(component) = doc.pointer_mut("/metadata/component") {
        component["bom-ref"] =
            serde_json::Value::String(format!("{}:{}", subject.alg(), subject.hex()));
        component["hashes"] = serde_json::json!([{
            "alg": cyclonedx_hash_alg(subject.alg()),
            "content": subject.hex(),
        }]);
    }
    serde_json::to_vec(&doc).expect("CycloneDX Value always serializes")
}

/// CycloneDX's `hash-alg` spelling for an in-toto digest algorithm name. The two
/// standards disagree on case and punctuation (`sha256` vs `SHA-256`), and
/// emitting in-toto's spelling into a CycloneDX `hashes[].alg` produces a document
/// that fails schema validation in every CycloneDX tool while looking fine here.
#[cfg(feature = "scanners")]
fn cyclonedx_hash_alg(alg: &str) -> &'static str {
    match alg {
        "sha512" => "SHA-512",
        "blake3" => "BLAKE3",
        // sha256 is the only other algorithm `ArtifactDigest` will construct.
        _ => "SHA-256",
    }
}

/// Render 16 bytes as an RFC 4122 version-4 `urn:uuid:` string.
///
/// Hand-rolled rather than pulling `uuid`: it is one format string plus two bit
/// masks, and this crate's dependency closure is weighed by the airgap tiers
/// (`auth_ship_small`). The version/variant nibbles are forced so the value is a
/// WELL-FORMED v4 UUID — a CycloneDX `serialNumber` is schema-constrained to that
/// shape, and a raw 32-hex string there validates nowhere.
#[cfg(feature = "scanners")]
fn urn_uuid_from(bytes: &[u8; 32]) -> String {
    let mut b = [0u8; 16];
    b.copy_from_slice(&bytes[..16]);
    b[6] = (b[6] & 0x0f) | 0x40; // version 4
    b[8] = (b[8] & 0x3f) | 0x80; // RFC 4122 variant
    let h = hex::encode(b);
    format!(
        "urn:uuid:{}-{}-{}-{}-{}",
        &h[0..8],
        &h[8..12],
        &h[12..16],
        &h[16..20],
        &h[20..32]
    )
}

/// ★ The in-toto **Statement v1** bytes a gate signs to bind this SBOM to these
/// bytes: `subject = [{name, digest}]`, `predicateType` = CycloneDX, `predicate` =
/// the document from [`build_cyclonedx_bound`].
///
/// This is the whole fix in one line of data flow. `subject_name` is context for a
/// human reading the statement and binds NOTHING — the digest does the binding,
/// which is why [`crate::attest::AttestStore::accept`] refuses a statement whose
/// subject digest names other bytes however well the name matches.
///
/// The bytes are canonical (key-sorted, compact) via
/// [`crate::attest::statement_bytes`], the same folding
/// `nornir_build_thing::provenance::slsa_statement_bytes` uses — so a producer in
/// the forge and a verifier here agree byte-for-byte, which is the property
/// `modgunn::provenance::verify_sealed_provenance` relies on.
#[cfg(feature = "scanners")]
pub fn sbom_statement_bytes(
    input: &SbomInput,
    subject_name: &str,
    subject: &ArtifactDigest,
) -> Vec<u8> {
    let doc = build_cyclonedx_bound(input, subject);
    // The document was just serialized from a `Value`; it re-parses.
    let predicate: serde_json::Value =
        serde_json::from_slice(&doc).expect("a document this function just emitted re-parses");
    statement_bytes(
        &[(subject_name.to_string(), subject.clone())],
        CYCLONEDX_BOM,
        predicate,
    )
}

/// Recover the CycloneDX document from an attestation's statement, or `None` when
/// the statement is not a CycloneDX one. The counterpart of
/// [`sbom_statement_bytes`]: this is how a promotion gate reads the bill of
/// materials back out of the evidence plane rather than out of a coordinate.
pub fn cyclonedx_from_statement(statement: &Statement) -> Option<&serde_json::Value> {
    (statement.predicate_type == CYCLONEDX_BOM).then_some(&statement.predicate)
}

/// The on-disk store of hosted SBOM documents.
///
/// Layout under `root`:
/// ```text
/// <repo>/<coord>.sbom.json    — the hosted SBOM document bytes (last-wins)
/// ```
/// `<repo>` and `<coord>` are path-safe single segments (see module docs), so a
/// crafted coordinate can never write or read outside `root`.
pub struct SbomStore {
    root: PathBuf,
    /// The repositories this server serves, or `None` for a caller that has
    /// already resolved the repository itself (CLI, in-process, tests).
    roster: Option<std::sync::Arc<std::collections::BTreeSet<String>>>,
}

impl SbomStore {
    /// A store rooted at `root` (directories are created on first attach). The
    /// server roots it at `<data_root>/holger-sboms`; the CLI derives the same
    /// path from the config's audit dir, so all surfaces share one store.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self {
            root: root.into(),
            roster: None,
        }
    }

    /// The same store, closed to `roster` — the repositories the server serves.
    /// This is the constructor the SERVER uses.
    pub fn with_roster(
        root: impl Into<PathBuf>,
        roster: std::sync::Arc<std::collections::BTreeSet<String>>,
    ) -> Self {
        Self {
            root: root.into(),
            roster: Some(roster),
        }
    }

    /// The path for `(repo, id)`, or `None` when this coordinate must not be
    /// touched.
    ///
    /// Identical rule to the property store, for the identical reason: a door
    /// authorizes the caller's RAW repository string while the store would write
    /// under `sanitize_repo` of it, and that map is many-to-one. `rust/prod`
    /// matches no per-repo ACL rule and takes the permissive default, then lands
    /// in the real `rust_prod`. An SBOM is evidence; overwriting another
    /// repository's evidence is the whole attack. A name that is not already its
    /// own storage key is refused, never redirected — and with a roster, so is a
    /// repository this server does not serve.
    fn storage_path(&self, repo: &str, id: &ArtifactId) -> Option<PathBuf> {
        if !is_own_storage_key(repo) {
            return None;
        }
        if let Some(roster) = &self.roster {
            if !roster.contains(repo) {
                return None;
            }
        }
        Some(
            self.root
                .join(sanitize_repo(repo))
                .join(format!("{}.sbom.json", coord_key(id))),
        )
    }

    /// Attach (host) an SBOM `document` for the coordinate `(repo, id)`. Writes
    /// the raw bytes (last-wins: a re-attach overwrites), and returns the blake3
    /// hex content address of the document. The write is staged to a sibling temp
    /// file and renamed into place so a concurrent fetch never observes a
    /// half-written document.
    pub fn attach(&self, repo: &str, id: &ArtifactId, document: &[u8]) -> std::io::Result<String> {
        let Some(path) = self.storage_path(repo, id) else {
            return Err(std::io::Error::new(
                std::io::ErrorKind::InvalidInput,
                format!(
                    "refusing to host an SBOM for repository {repo:?}: it is not a \
                     repository this server serves under exactly that name (a name that \
                     is not already path-safe would overwrite the evidence of a \
                     DIFFERENT repository than the one authorized)"
                ),
            ));
        };
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)?;
        }
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, document)?;
        std::fs::rename(&tmp, &path)?;
        Ok(blake3::hash(document).to_hex().to_string())
    }

    /// Fetch the hosted SBOM document for `(repo, id)`, byte-identical to what was
    /// attached. `Ok(None)` when no SBOM is attached to that coordinate (a missing
    /// SBOM is not-found, never an I/O error).
    pub fn fetch(&self, repo: &str, id: &ArtifactId) -> std::io::Result<Option<Vec<u8>>> {
        let Some(path) = self.storage_path(repo, id) else {
            // A coordinate this store refuses to write reads as not-found, never
            // as the document it would have collapsed onto.
            return Ok(None);
        };
        match std::fs::read(path) {
            Ok(bytes) => Ok(Some(bytes)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    /// Whether an SBOM is attached to `(repo, id)` (a cheap existence probe).
    pub fn has(&self, repo: &str, id: &ArtifactId) -> bool {
        self.storage_path(repo, id)
            .map(|p| p.exists())
            .unwrap_or(false)
    }
}

#[cfg(all(test, feature = "scanners"))]
mod tests {
    use super::*;

    fn id(ns: Option<&str>, name: &str, ver: &str) -> ArtifactId {
        ArtifactId {
            namespace: ns.map(str::to_string),
            name: name.into(),
            version: ver.into(),
        }
    }

    /// A CycloneDX document built through the shared modgunn model, used as the
    /// hosted payload in the round-trip tests.
    fn sample_doc() -> Vec<u8> {
        build_cyclonedx(&SbomInput {
            subject: "rust-dev".into(),
            components: vec![SbomComponent {
                ecosystem: "cargo".into(),
                name: "serde".into(),
                version: "1.0.0".into(),
                license: Some("MIT OR Apache-2.0".into()),
            }],
            vulns: vec![],
        })
    }

    #[test]
    fn attach_then_fetch_is_byte_identical() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "serde", "1.0.0");
        let doc = sample_doc();

        let addr = store.attach("rust-dev", &coord, &doc).unwrap();
        let got = store.fetch("rust-dev", &coord).unwrap();
        assert_eq!(
            got.as_deref(),
            Some(doc.as_slice()),
            "fetch returns the exact attached bytes"
        );
        // The content address is the blake3 of the hosted bytes.
        assert_eq!(addr, blake3::hash(&doc).to_hex().to_string());
        assert!(store.has("rust-dev", &coord));
    }

    /// RED-when-broken: a coordinate with NO attached SBOM must fetch as `None`,
    /// never leak another coordinate's document or error.
    #[test]
    fn missing_sbom_is_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "never-attached", "9.9.9");
        assert!(store.fetch("rust-dev", &coord).unwrap().is_none());
        assert!(!store.has("rust-dev", &coord));
    }

    /// RED-when-broken: an SBOM attached to coordinate A must NOT be returned for
    /// a DIFFERENT coordinate B (a broken key would leak A's SBOM to every fetch).
    #[test]
    fn wrong_coordinate_does_not_match() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let doc = sample_doc();
        store
            .attach("rust-dev", &id(None, "serde", "1.0.0"), &doc)
            .unwrap();

        // Same name, different version → no match.
        assert!(store
            .fetch("rust-dev", &id(None, "serde", "1.0.1"))
            .unwrap()
            .is_none());
        // Same coordinate, different repo → no match (repo is part of the key).
        assert!(store
            .fetch("other-repo", &id(None, "serde", "1.0.0"))
            .unwrap()
            .is_none());
        // Different name → no match.
        assert!(store
            .fetch("rust-dev", &id(None, "tokio", "1.0.0"))
            .unwrap()
            .is_none());
    }

    #[test]
    fn re_attach_overwrites_last_wins() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(Some("org.example"), "widget", "2.3.4");
        store.attach("maven-dev", &coord, b"first").unwrap();
        store
            .attach("maven-dev", &coord, b"second-and-final")
            .unwrap();
        assert_eq!(
            store.fetch("maven-dev", &coord).unwrap().as_deref(),
            Some(b"second-and-final".as_slice()),
        );
    }

    /// A hostile repo name or coordinate is REFUSED, not sanitized onto a real
    /// one.
    ///
    /// Containment inside the root was never the whole question. `sanitize_repo`
    /// is many-to-one, so `rust/prod` collapses onto the real `rust_prod` — and
    /// the door that authorized the write consulted the raw string, which matches
    /// no per-repo ACL rule and takes the permissive default. Sanitizing turned a
    /// traversal into a cross-repository evidence overwrite. Refusing turns it
    /// into a 400.
    #[test]
    fn a_hostile_or_aliased_coordinate_is_refused_rather_than_redirected() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let real = id(None, "serde", "1.0.0");
        store
            .attach("rust_prod", &real, b"the real evidence")
            .unwrap();

        // A path-laced coordinate: refused outright.
        let evil = id(Some("../../.."), "../../etc/passwd", "../x");
        let err = store
            .attach("../../../tmp/pwn", &evil, b"contained")
            .expect_err("a hostile coordinate must be refused, not sanitized");
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
        assert!(store.fetch("../../../tmp/pwn", &evil).unwrap().is_none());
        assert!(!store.has("../../../tmp/pwn", &evil));

        // An alias of a REAL repository: refused, and it cannot read the real
        // repository's evidence either.
        for alias in ["rust/prod", "rust:prod", "rust prod"] {
            let err = store
                .attach(alias, &real, b"forged evidence")
                .expect_err("an alias must not reach the repository it sanitizes onto");
            assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
            assert!(
                store.fetch(alias, &real).unwrap().is_none(),
                "and the alias must not READ the real repository's SBOM"
            );
        }
        assert_eq!(
            store.fetch("rust_prod", &real).unwrap().as_deref(),
            Some(b"the real evidence".as_slice()),
            "the real repository's evidence is untouched"
        );
    }

    /// With a roster, hosting an SBOM for a repository the server does not serve
    /// is refused instead of creating a directory for it.
    #[test]
    fn a_roster_refuses_an_sbom_for_a_repository_this_server_does_not_serve() {
        let tmp = tempfile::tempdir().unwrap();
        let roster: std::sync::Arc<std::collections::BTreeSet<String>> =
            std::sync::Arc::new(["rust-prod".to_string()].into_iter().collect());
        let store = SbomStore::with_roster(tmp.path(), roster);
        let coord = id(None, "serde", "1.0.0");

        store.attach("rust-prod", &coord, b"ok").unwrap();
        let err = store
            .attach("rust-dev", &coord, b"nope")
            .expect_err("an unknown repository must be refused");
        assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
        assert_eq!(
            std::fs::read_dir(tmp.path()).unwrap().count(),
            1,
            "no directory was created for the repository that does not exist"
        );
    }

    /// An accepted coordinate is still always a direct grandchild of the root.
    #[test]
    fn an_accepted_sbom_coordinate_is_two_segments_below_the_root() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let path = store
            .storage_path("maven-prod", &id(Some("org.example"), "widget", "2.3.4"))
            .unwrap();
        assert!(path.starts_with(tmp.path()));
        assert_eq!(
            path.strip_prefix(tmp.path()).unwrap().components().count(),
            2
        );
    }

    /// An EMPTY document is a real hosted document: it fetches back as
    /// `Some(vec![])` and `has` reports it present — never confused with the
    /// not-attached case. RED-when-broken if emptiness is ever collapsed into
    /// "no SBOM here", which would make a deliberately empty BOM unservable.
    #[test]
    fn an_empty_document_is_hosted_not_absent() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let attached = id(None, "empty-bom", "1.0.0");
        let never = id(None, "no-bom", "1.0.0");

        let addr = store.attach("rust-dev", &attached, b"").unwrap();
        assert_eq!(addr, blake3::hash(b"").to_hex().to_string());
        assert_eq!(
            store.fetch("rust-dev", &attached).unwrap(),
            Some(Vec::new()),
            "an empty document reads back as empty bytes"
        );
        assert!(store.has("rust-dev", &attached));
        assert_eq!(
            store.fetch("rust-dev", &never).unwrap(),
            None,
            "…unlike an absent one"
        );
        assert!(!store.has("rust-dev", &never));
    }

    /// A re-attach replaces the document WHOLLY: a shorter second document must not
    /// leave the tail of the first behind. That is what the temp-file-then-rename
    /// buys, and it is invisible in a same-length overwrite — so it is tested with a
    /// long document replaced by a one-byte one.
    #[test]
    fn a_shorter_re_attach_leaves_no_tail_of_the_old_document() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "widget", "1.0.0");

        store.attach("maven-dev", &coord, &[b'a'; 4096]).unwrap();
        let addr = store.attach("maven-dev", &coord, b"b").unwrap();

        let got = store.fetch("maven-dev", &coord).unwrap().unwrap();
        assert_eq!(
            got, b"b",
            "the new document is the whole file, not a prefix of it"
        );
        assert_eq!(
            addr,
            blake3::hash(b"b").to_hex().to_string(),
            "the address follows the bytes"
        );
    }

    /// A binary, non-UTF-8 document of real size round-trips byte-identical — the
    /// store hosts bytes, not text, and never re-encodes what it was handed.
    #[test]
    fn a_large_binary_document_round_trips_byte_identical() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(Some("org.example"), "big", "1.0.0");

        // Deterministic pseudo-random bytes (an LCG) — no rand dependency, no flake.
        let mut state: u32 = 0x1234_5678;
        let doc: Vec<u8> = (0..1 << 20)
            .map(|_| {
                state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
                (state >> 24) as u8
            })
            .collect();

        let addr = store.attach("rust-dev", &coord, &doc).unwrap();
        let got = store.fetch("rust-dev", &coord).unwrap().unwrap();
        assert_eq!(got.len(), doc.len(), "size preserved");
        assert_eq!(got, doc, "every byte preserved");
        assert_eq!(addr, blake3::hash(&doc).to_hex().to_string());
    }

    /// The write is staged and renamed, so a crash-leftover `.tmp` sibling must not
    /// be served in place of the committed document — and a completed attach leaves
    /// no staging file of its own.
    #[test]
    fn a_leftover_staging_file_is_never_served() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "serde", "1.0.0");
        store.attach("rust-dev", &coord, b"committed").unwrap();

        let doc = store.storage_path("rust-dev", &coord).unwrap();
        std::fs::write(doc.with_extension("json.tmp"), b"half-written").unwrap();
        assert_eq!(
            store.fetch("rust-dev", &coord).unwrap().as_deref(),
            Some(b"committed".as_slice()),
            "only the committed document is served"
        );

        std::fs::remove_file(doc.with_extension("json.tmp")).unwrap();
        store.attach("rust-dev", &coord, b"second").unwrap();
        let files: Vec<String> = std::fs::read_dir(doc.parent().unwrap())
            .unwrap()
            .flatten()
            .map(|e| e.file_name().to_string_lossy().to_string())
            .collect();
        assert_eq!(
            files.len(),
            1,
            "a finished attach leaves exactly the document: {files:?}"
        );
    }

    /// The hosting seam emits modgunn's schema verbatim — `build_cyclonedx` is
    /// exactly `serde_json::to_vec(modgunn::sbom::to_cyclonedx(..))`. RED-when-broken
    /// if holger ever forks the SBOM model instead of consuming modgunn's (Law #5).
    #[test]
    fn build_cyclonedx_is_modgunn_schema_verbatim() {
        let input = SbomInput {
            subject: "app".into(),
            components: vec![SbomComponent {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                license: None,
            }],
            vulns: vec![SbomVuln {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                ids: vec!["OSV-2020-0001".into()],
                summary: "example".into(),
            }],
        };
        let want = serde_json::to_vec(&modgunn::sbom::to_cyclonedx(&input)).unwrap();
        assert_eq!(build_cyclonedx(&input), want);
        // Sanity: the emitted document really is a CycloneDX BOM with the purl.
        let v: serde_json::Value = serde_json::from_slice(&build_cyclonedx(&input)).unwrap();
        assert_eq!(v["bomFormat"], "CycloneDX");
        assert_eq!(v["components"][0]["purl"], "pkg:npm/left-pad@1.3.0");
    }

    // ── The digest binding (the fix for the four holes in the module docs) ────

    const D_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
    const D_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";

    fn sample_input() -> SbomInput {
        SbomInput {
            subject: "serde".into(),
            components: vec![SbomComponent {
                ecosystem: "cargo".into(),
                name: "serde".into(),
                version: "1.0.0".into(),
                license: Some("MIT OR Apache-2.0".into()),
            }],
            vulns: vec![],
        }
    }

    /// RED-when-broken, and it WAS broken: the hosted document carried no
    /// `serialNumber`, no `bom-ref` and no component `hashes`, so lifted out of the
    /// store it did not self-reference the artifact at all. Each of the three is
    /// asserted separately, so dropping only one still fails.
    #[test]
    fn a_bound_document_self_references_the_artifact_digest() {
        let d = ArtifactDigest::sha256(D_A).unwrap();
        let v: serde_json::Value =
            serde_json::from_slice(&build_cyclonedx_bound(&sample_input(), &d)).unwrap();

        assert!(
            v["serialNumber"].as_str().unwrap().starts_with("urn:uuid:"),
            "the BOM must carry its own identity: {}",
            v["serialNumber"]
        );
        assert_eq!(
            v["metadata"]["component"]["bom-ref"],
            format!("sha256:{D_A}")
        );
        assert_eq!(v["metadata"]["component"]["hashes"][0]["content"], D_A);
        // CycloneDX's `hash-alg` spelling, NOT in-toto's — the two standards
        // disagree and emitting the wrong one fails every CycloneDX validator.
        assert_eq!(v["metadata"]["component"]["hashes"][0]["alg"], "SHA-256");

        // And the unbound path is untouched: no such fields, byte-for-byte as before.
        let plain: serde_json::Value =
            serde_json::from_slice(&build_cyclonedx(&sample_input())).unwrap();
        assert!(
            plain.get("serialNumber").is_none(),
            "the legacy document is unchanged"
        );
    }

    /// The serial number is DERIVED, not random: two runs over the same artifact
    /// and the same components produce IDENTICAL bytes, and a different artifact
    /// produces a different serial. A randomly-serialled document could not be
    /// compared, deduplicated or re-signed to the same envelope.
    #[test]
    fn the_bound_document_is_byte_stable_and_keyed_to_the_subject() {
        let a = ArtifactDigest::sha256(D_A).unwrap();
        let b = ArtifactDigest::sha256(D_B).unwrap();
        let once = build_cyclonedx_bound(&sample_input(), &a);
        let twice = build_cyclonedx_bound(&sample_input(), &a);
        assert_eq!(once, twice, "the same input must fold to the same bytes");
        assert_ne!(
            build_cyclonedx_bound(&sample_input(), &b),
            once,
            "a different artifact must not produce the same document"
        );
    }

    /// modgunn's schema survives the enrichment: every key `to_cyclonedx` emitted
    /// is present and equal. RED-when-broken if holger ever starts FORKING the SBOM
    /// model instead of adding standard CycloneDX fields beside it (Law #5).
    #[test]
    fn binding_adds_to_modgunn_schema_and_rewrites_none_of_it() {
        let d = ArtifactDigest::sha256(D_A).unwrap();
        let plain: serde_json::Value = modgunn::sbom::to_cyclonedx(&sample_input());
        let bound: serde_json::Value =
            serde_json::from_slice(&build_cyclonedx_bound(&sample_input(), &d)).unwrap();
        for (k, v) in plain.as_object().unwrap() {
            if k == "metadata" {
                // metadata.component gains two keys; everything already there is equal.
                for (mk, mv) in v["component"].as_object().unwrap() {
                    assert_eq!(
                        &bound["metadata"]["component"][mk], mv,
                        "metadata.component.{mk}"
                    );
                }
                continue;
            }
            assert_eq!(&bound[k], v, "modgunn's `{k}` must survive verbatim");
        }
    }

    /// ★ The statement binds the SBOM to a HASH, and it is the hash — not the name
    /// — that decides. A statement built for artifact A does NOT attest artifact B
    /// even though the human-readable subject name is identical, which is precisely
    /// the substitution the coordinate-keyed store cannot see.
    #[test]
    fn the_sbom_statement_attests_the_digest_and_not_the_name() {
        let a = ArtifactDigest::sha256(D_A).unwrap();
        let b = ArtifactDigest::sha256(D_B).unwrap();
        let bytes = sbom_statement_bytes(&sample_input(), "serde-1.0.0.crate", &a);
        let st = Statement::parse(&bytes).expect("a well-formed in-toto statement");

        assert_eq!(st.predicate_type, CYCLONEDX_BOM);
        assert!(st.attests(&a));
        assert!(
            !st.attests(&b),
            "the same NAME over other BYTES is not the same evidence"
        );
        assert_eq!(st.subjects[0].name, "serde-1.0.0.crate");
    }

    /// The document goes in and comes back out of the evidence plane unchanged —
    /// round-tripping through the statement is lossless, so a promotion gate reads
    /// the same bill of materials the generator emitted.
    #[test]
    fn the_cyclonedx_document_round_trips_through_the_statement() {
        let d = ArtifactDigest::sha256(D_A).unwrap();
        let bytes = sbom_statement_bytes(&sample_input(), "serde", &d);
        let st = Statement::parse(&bytes).unwrap();
        let back = cyclonedx_from_statement(&st).expect("a CycloneDX predicate");
        let want: serde_json::Value =
            serde_json::from_slice(&build_cyclonedx_bound(&sample_input(), &d)).unwrap();
        assert_eq!(back, &want);
        assert_eq!(back["bomFormat"], "CycloneDX");
    }

    /// A statement of some OTHER predicate type yields no document — the extractor
    /// never guesses that an arbitrary predicate is a BOM.
    #[test]
    fn a_non_cyclonedx_statement_yields_no_document() {
        let d = ArtifactDigest::sha256(D_A).unwrap();
        let bytes = crate::attest::statement_bytes(
            &[("serde".to_string(), d)],
            crate::attest::SLSA_PROVENANCE_V1,
            serde_json::json!({"buildType": "x"}),
        );
        let st = Statement::parse(&bytes).unwrap();
        assert!(cyclonedx_from_statement(&st).is_none());
    }

    /// The LEGACY coordinate-keyed surface is untouched by any of the above —
    /// `AttachSbom` / `FetchSbom` keep their exact behaviour, including last-wins.
    /// Stated as its own test so a future edit to the digest path cannot quietly
    /// change the hosting path's contract.
    #[test]
    fn the_legacy_coordinate_store_is_unchanged() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "serde", "1.0.0");
        store.attach("rust-dev", &coord, b"first").unwrap();
        store.attach("rust-dev", &coord, b"second").unwrap();
        assert_eq!(
            store.fetch("rust-dev", &coord).unwrap().as_deref(),
            Some(b"second".as_slice()),
            "still last-wins, still byte-identical, still keyed by the coordinate"
        );
    }
}

/// A shared `Arc<SbomStore>` handle for wiring into the gRPC state + HTTP gateway.
pub type SharedSbomStore = Arc<SbomStore>;