1use crate::manifest::LayerManifest;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SbomFormat {
24 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
40fn 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 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 let ctype = match entry.kind() {
66 Ok(crate::kind::PayloadKind::Tool) => "application",
67 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 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 "bom-ref": entry.digest,
109 });
110
111 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 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 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
155pub 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 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 "serialNumber": format!("urn:uuid:{}", uuid_from_digest(manifest_digest)),
168 "metadata": {
169 "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
189fn 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 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 #[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 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 #[test]
262 fn per_platform_entries_are_distinguishable_not_duplicates() {
263 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 #[test]
291 fn no_signed_entry_is_ever_dropped_from_the_document() {
292 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 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 #[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 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 #[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 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 #[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}