Skip to main content

varve_core/
sbom.rs

1//! SBOM emission from the signed layer manifest (REQ-SBOM-001).
2//!
3//! Most SBOMs are *scanned*: a tool walks a filesystem or a lockfile and
4//! reports what it believes it found. varve's is a **transcription of signed
5//! data** — every component, version and hash in the output is copied from the
6//! DSSE-signed layer manifest that the trust root anchored. Nothing is
7//! discovered, so nothing can be missed or invented: the SBOM is exactly as
8//! trustworthy as the layer, and `varve verify` already decides that.
9//!
10//! This is what a CRA Article 13(5) "due diligence when integrating components
11//! sourced from third parties" answer looks like mechanically, and what lets a
12//! manufacturer say which components are in a shipped product inside the 24
13//! hours Article 14 allows from 2026-09-11.
14//!
15//! Output is deterministic — stable ordering, no timestamps of its own, a
16//! serial number derived from the layer digest — so re-emitting the same layer
17//! byte-identically is the expected result, and a diff means the layer changed.
18
19use crate::manifest::LayerManifest;
20
21/// Which document format to emit.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SbomFormat {
24    /// CycloneDX 1.6 JSON.
25    CycloneDx,
26}
27
28impl std::str::FromStr for SbomFormat {
29    type Err = String;
30    fn from_str(s: &str) -> Result<Self, Self::Err> {
31        match s {
32            "cyclonedx" | "cdx" => Ok(SbomFormat::CycloneDx),
33            other => Err(format!(
34                "unknown SBOM format '{other}' (supported: cyclonedx)"
35            )),
36        }
37    }
38}
39
40/// One component, as the signed manifest describes it.
41///
42/// INFALLIBLE by design. An earlier version returned `Option` and was
43/// `filter_map`'d, so an entry lacking a tool annotation — which foreign-platform
44/// entries legitimately do — or carrying a payload kind this build does not
45/// recognise disappeared from the document with no error. For a document whose
46/// entire claim is that it cannot miss a component, silent omission is the one
47/// failure mode that must be impossible. Whatever is unknown is LABELLED, never
48/// dropped: the digest is always present, and it alone identifies the artifact.
49fn component(entry: &crate::manifest::ManifestEntry) -> serde_json::Value {
50    let hex = entry
51        .digest
52        .strip_prefix("sha256:")
53        .unwrap_or(&entry.digest);
54    let mut props: Vec<serde_json::Value> = Vec::new();
55
56    // A layer holds one entry per tool PER PLATFORM, each a distinct binary
57    // with a distinct digest. Without the platform they read as duplicates.
58    if let Some(platform) = entry.annotations.get("eu.pulseengine.platform") {
59        props.push(serde_json::json!({"name": "eu.pulseengine.platform", "value": platform}));
60    }
61
62    // An unrecognised payload kind is recorded verbatim and the component is
63    // still emitted. `kind()` is NOT enforced on the install path (see
64    // kind.rs), so a layer deposited by a newer varve can reach us here.
65    let ctype = match entry.kind() {
66        Ok(crate::kind::PayloadKind::Tool) => "application",
67        // A composed layer is not a library — it is another BOM. Recorded as a
68        // platform component with a nested-BOM reference, so a consumer follows
69        // it rather than mistaking it for a shipped artifact.
70        Ok(crate::kind::PayloadKind::Layer) => "platform",
71        Ok(_) => "library",
72        Err(_) => {
73            if let Some(raw) = entry.annotations.get(crate::kind::ANN_KIND) {
74                props.push(serde_json::json!({
75                    "name": "eu.pulseengine.varve.kind.unrecognised",
76                    "value": raw
77                }));
78            }
79            "library"
80        }
81    };
82
83    // A name is required by CycloneDX. Where the manifest names the artifact we
84    // transcribe it; where it does not (a foreign-platform entry), the digest
85    // is the only honest identifier, and we say so rather than inventing one.
86    let name = match entry.annotations.get("eu.pulseengine.tool") {
87        Some(n) => n.clone(),
88        None => {
89            props.push(serde_json::json!({
90                "name": "eu.pulseengine.varve.unnamed",
91                "value": "the signed manifest names no tool for this entry; identified by digest"
92            }));
93            format!("sha256-{}", &hex[..hex.len().min(16)])
94        }
95    };
96    let version = entry
97        .annotations
98        .get("eu.pulseengine.tool.version")
99        .cloned()
100        .unwrap_or_default();
101
102    let mut c = serde_json::json!({
103        "type": ctype,
104        "name": name,
105        "version": version,
106        "hashes": [{"alg": "SHA-256", "content": hex}],
107        // The digest IS the identity here: bom-ref stays stable across emissions.
108        "bom-ref": entry.digest,
109    });
110
111    // A package URL is what lets a consumer match a component against a CVE
112    // feed — the whole point of holding an SBOM when a report is due. Derived
113    // only from signed annotations, and omitted when they do not support it.
114    if let (Some(repo), false) = (
115        entry.annotations.get("eu.pulseengine.source.repo"),
116        version.is_empty(),
117    ) && let Some((owner, name)) = repo.split_once('/')
118    {
119        c["purl"] = serde_json::json!(format!("pkg:github/{owner}/{name}@{version}"));
120    }
121
122    // A composed layer points at its own document rather than restating it.
123    if entry.kind() == Ok(crate::kind::PayloadKind::Layer) {
124        c["externalReferences"] = serde_json::json!([{
125            "type": "bom",
126            "url": format!("urn:varve:layer:{}", entry.digest)
127        }]);
128        return c;
129    }
130    // Upstream provenance, where the depositor recorded it. The repo annotation
131    // is a bare `owner/name` slug, so it is expanded into something an assessor
132    // can actually follow; the asset name is left relative to that release.
133    if let Some(repo) = entry.annotations.get("eu.pulseengine.source.repo") {
134        let mut refs = vec![serde_json::json!({
135            "type": "vcs",
136            "url": format!("https://github.com/{repo}")
137        })];
138        if let (Some(asset), Some(release)) = (
139            entry.annotations.get("eu.pulseengine.source.asset"),
140            entry.annotations.get("eu.pulseengine.source.release"),
141        ) {
142            refs.push(serde_json::json!({
143                "type": "distribution",
144                "url": format!("https://github.com/{repo}/releases/download/{release}/{asset}")
145            }));
146        }
147        c["externalReferences"] = serde_json::Value::Array(refs);
148    }
149    if !props.is_empty() {
150        c["properties"] = serde_json::Value::Array(props);
151    }
152    c
153}
154
155/// Emit an SBOM for a verified layer manifest. Deterministic: same manifest in,
156/// byte-identical document out.
157pub fn emit(manifest: &LayerManifest, manifest_digest: &str, format: SbomFormat) -> String {
158    let SbomFormat::CycloneDx = format;
159    let mut components: Vec<serde_json::Value> = manifest.entries.iter().map(component).collect();
160    // Stable order by bom-ref (the digest), so the document is diffable.
161    components.sort_by(|a, b| a["bom-ref"].as_str().cmp(&b["bom-ref"].as_str()));
162    let doc = serde_json::json!({
163        "bomFormat": "CycloneDX",
164        "specVersion": "1.6",
165        "version": 1,
166        // Derived from the layer, never random: re-emission is byte-identical.
167        "serialNumber": format!("urn:uuid:{}", uuid_from_digest(manifest_digest)),
168        "metadata": {
169            // The layer's own issued-at, not "now" — the document describes a
170            // released artifact, not the moment someone asked about it.
171            "timestamp": manifest.issued_at,
172            "component": {
173                "type": "firmware",
174                "name": format!("varve-layer-{}", manifest.layer),
175                "version": manifest.layer.to_string(),
176                "bom-ref": manifest_digest,
177            },
178            "properties": [
179                {"name": "eu.pulseengine.varve.channel", "value": manifest.channel},
180                {"name": "eu.pulseengine.varve.counter", "value": manifest.counter.to_string()},
181                {"name": "eu.pulseengine.varve.manifest-digest", "value": manifest_digest},
182            ],
183        },
184        "components": components,
185    });
186    serde_json::to_string_pretty(&doc).expect("sbom serialises")
187}
188
189/// A stable RFC-4122-shaped identifier derived from the manifest digest, so the
190/// serial number is a function of the layer rather than of the clock.
191fn uuid_from_digest(digest: &str) -> String {
192    let hex: String = digest
193        .strip_prefix("sha256:")
194        .unwrap_or(digest)
195        .chars()
196        .filter(|c| c.is_ascii_hexdigit())
197        .take(32)
198        .collect();
199    let mut h: Vec<u8> = format!("{hex:0<32}").into_bytes();
200    // RFC 9562 UUIDv8 is the custom/deterministic version, so set the version
201    // nibble to 8 and the variant to 10x. Without this the serial parses as
202    // "version 11", which no RFC defines — CycloneDX's own schema text asks for
203    // an RFC 4122 conformant serialNumber even though its pattern only checks
204    // shape. Still a pure function of the digest: two nibbles are forced.
205    h[12] = b'8';
206    h[16] = b'a';
207    let h = String::from_utf8(h).expect("hex digits stay ascii");
208    format!(
209        "{}-{}-{}-{}-{}",
210        &h[0..8],
211        &h[8..12],
212        &h[12..16],
213        &h[16..20],
214        &h[20..32]
215    )
216}
217
218#[cfg(test)]
219mod tests {
220    use super::*;
221
222    fn manifest() -> (LayerManifest, String) {
223        let bytes = crate::manifest::fixtures::manifest_with_tools(
224            "2026.08.0",
225            "qualified",
226            7,
227            "2026-08-01T00:00:00Z",
228            &[("synth", "sha256:aaaa"), ("rivet", "sha256:bbbb")],
229        );
230        let m = LayerManifest::parse(&bytes).unwrap();
231        (m, "sha256:1234567890abcdef".to_string())
232    }
233
234    // rivet: verifies REQ-SBOM-001
235    #[test]
236    fn every_component_is_transcribed_from_the_signed_manifest() {
237        let (m, digest) = manifest();
238        let doc: serde_json::Value =
239            serde_json::from_str(&emit(&m, &digest, SbomFormat::CycloneDx)).unwrap();
240        let comps = doc["components"].as_array().unwrap();
241        assert_eq!(
242            comps.len(),
243            m.entries.len(),
244            "the SBOM must describe exactly the signed entries — no more, no fewer"
245        );
246        // Every hash in the document appears in the signed manifest.
247        for c in comps {
248            let hex = c["hashes"][0]["content"].as_str().unwrap();
249            assert!(
250                m.entries
251                    .iter()
252                    .any(|e| e.digest.strip_prefix("sha256:") == Some(hex)),
253                "component hash {hex} is not in the signed manifest"
254            );
255        }
256        assert_eq!(doc["bomFormat"], "CycloneDX");
257        assert_eq!(doc["specVersion"], "1.6");
258    }
259
260    // rivet: verifies REQ-SBOM-001
261    #[test]
262    fn per_platform_entries_are_distinguishable_not_duplicates() {
263        // A layer holds one entry per tool per platform — distinct binaries
264        // with distinct digests. Listing them without the platform makes a
265        // real SBOM look like it repeats itself.
266        let bytes = crate::manifest::fixtures::manifest_with_platform_tools(
267            "2026.08.0",
268            "qualified",
269            1,
270            "2026-08-01T00:00:00Z",
271            &[
272                ("kilnd", "sha256:aaaa", Some("x86_64-unknown-linux-gnu")),
273                ("kilnd", "sha256:bbbb", Some("aarch64-apple-darwin")),
274            ],
275        );
276        let m = LayerManifest::parse(&bytes).unwrap();
277        let doc: serde_json::Value =
278            serde_json::from_str(&emit(&m, "sha256:dd", SbomFormat::CycloneDx)).unwrap();
279        let comps = doc["components"].as_array().unwrap();
280        assert_eq!(comps.len(), 2);
281        let platforms: Vec<&str> = comps
282            .iter()
283            .map(|c| c["properties"][0]["value"].as_str().unwrap())
284            .collect();
285        assert!(platforms.contains(&"x86_64-unknown-linux-gnu"));
286        assert!(platforms.contains(&"aarch64-apple-darwin"));
287    }
288
289    // rivet: verifies REQ-SBOM-001
290    #[test]
291    fn no_signed_entry_is_ever_dropped_from_the_document() {
292        // THE invariant this feature sells: "a scanner can miss a component;
293        // a transcription cannot." Clean-room review refuted an earlier version
294        // that returned Option and filter_map'd — three signed entries emitted
295        // one component, silently, exit 0. Entries can legitimately lack a tool
296        // annotation (foreign-platform entries install without one) or carry a
297        // payload kind this build does not know (a newer depositor). Neither
298        // may vanish: an SBOM that omits a component is worse than none.
299        let bytes = br#"{
300  "schemaVersion": 2,
301  "mediaType": "application/vnd.oci.image.index.v1+json",
302  "artifactType": "application/vnd.pulseengine.varve.layer.v1+json",
303  "annotations": {
304    "eu.pulseengine.varve.layer": "2026.08.0",
305    "eu.pulseengine.varve.channel": "qualified",
306    "eu.pulseengine.varve.counter": "1",
307    "org.opencontainers.image.created": "2026-08-01T00:00:00Z"
308  },
309  "manifests": [
310    { "digest": "sha256:aaaa", "annotations": { "eu.pulseengine.tool": "synth", "eu.pulseengine.tool.version": "1.0.0" } },
311    { "digest": "sha256:bbbb", "annotations": { "eu.pulseengine.tool": "future", "eu.pulseengine.varve.kind": "quantum-blob" } },
312    { "digest": "sha256:cccc", "annotations": { "eu.pulseengine.platform": "riscv64-unknown-none" } }
313  ]
314}"#;
315        let m = LayerManifest::parse(bytes).unwrap();
316        assert_eq!(m.entries.len(), 3, "fixture sanity");
317        let doc: serde_json::Value =
318            serde_json::from_str(&emit(&m, "sha256:dd", SbomFormat::CycloneDx)).unwrap();
319        let comps = doc["components"].as_array().unwrap();
320        assert_eq!(
321            comps.len(),
322            3,
323            "every signed entry must appear; got {comps:#?}"
324        );
325        // Every signed digest is present as a bom-ref.
326        for e in &m.entries {
327            assert!(
328                comps.iter().any(|c| c["bom-ref"] == e.digest.as_str()),
329                "signed entry {} is missing from the document",
330                e.digest
331            );
332        }
333    }
334
335    // rivet: verifies REQ-SBOM-001
336    #[test]
337    fn emission_is_deterministic() {
338        let (m, digest) = manifest();
339        let a = emit(&m, &digest, SbomFormat::CycloneDx);
340        let b = emit(&m, &digest, SbomFormat::CycloneDx);
341        assert_eq!(a, b, "the same layer must emit a byte-identical document");
342        // …and it carries the layer's own issued-at, not the wall clock.
343        let doc: serde_json::Value = serde_json::from_str(&a).unwrap();
344        assert_eq!(doc["metadata"]["timestamp"], "2026-08-01T00:00:00Z");
345    }
346
347    // rivet: verifies REQ-SBOM-001
348    #[test]
349    fn the_document_binds_itself_to_the_layer_it_describes() {
350        let (m, digest) = manifest();
351        let doc: serde_json::Value =
352            serde_json::from_str(&emit(&m, &digest, SbomFormat::CycloneDx)).unwrap();
353        // An SBOM that does not say which signed artifact it describes cannot
354        // be checked against one.
355        assert_eq!(doc["metadata"]["component"]["bom-ref"], digest);
356        let props = doc["metadata"]["properties"].as_array().unwrap();
357        assert!(
358            props
359                .iter()
360                .any(|p| p["name"] == "eu.pulseengine.varve.manifest-digest"
361                    && p["value"] == digest.as_str()),
362            "the manifest digest must be recorded in the document"
363        );
364        assert!(
365            props
366                .iter()
367                .any(|p| p["name"] == "eu.pulseengine.varve.counter" && p["value"] == "7"),
368            "the anti-rollback counter belongs in the document"
369        );
370    }
371
372    // rivet: verifies REQ-SBOM-001
373    #[test]
374    fn an_unknown_format_is_refused_not_guessed() {
375        assert!("cyclonedx".parse::<SbomFormat>().is_ok());
376        assert!("spdx".parse::<SbomFormat>().is_err());
377        assert!("".parse::<SbomFormat>().is_err());
378    }
379}