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