1use crate::wasm_decoder::{ImportEntry, ImportKind};
52use serde::Serialize;
53use sha2::{Digest, Sha256};
54use std::path::{Path, PathBuf};
55
56const CYCLONEDX_SPEC_VERSION: &str = "1.5";
59
60#[derive(Debug, Clone, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct CycloneDxSbom {
67 pub bom_format: String,
69 pub spec_version: String,
71 pub serial_number: String,
73 pub version: u32,
75 pub metadata: SbomMetadata,
77 pub components: Vec<Component>,
79 pub dependencies: Vec<Dependency>,
81}
82
83#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SbomMetadata {
87 pub timestamp: String,
90 pub tools: Vec<Tool>,
92}
93
94#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Tool {
98 pub vendor: String,
100 pub name: String,
102 pub version: String,
104}
105
106#[derive(Debug, Clone, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Component {
110 #[serde(rename = "type")]
112 pub component_type: String,
113 #[serde(rename = "bom-ref")]
115 pub bom_ref: String,
116 pub name: String,
118 pub version: String,
121 #[serde(skip_serializing_if = "Vec::is_empty")]
123 pub hashes: Vec<Hash>,
124 #[serde(skip_serializing_if = "Vec::is_empty")]
127 pub properties: Vec<Property>,
128}
129
130#[derive(Debug, Clone, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct Hash {
134 pub alg: String,
136 pub content: String,
138}
139
140#[derive(Debug, Clone, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct Property {
144 pub name: String,
146 pub value: String,
148}
149
150#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct Dependency {
154 #[serde(rename = "ref")]
156 pub dep_ref: String,
157 #[serde(rename = "dependsOn", skip_serializing_if = "Vec::is_empty")]
159 pub depends_on: Vec<String>,
160}
161
162#[derive(Debug, Clone)]
165pub struct SbomInputs<'a> {
166 pub synth_version: &'a str,
168 pub input_path: &'a Path,
170 pub input_bytes: &'a [u8],
172 pub output_path: &'a Path,
174 pub output_bytes: &'a [u8],
176 pub target_triple: &'a str,
178 pub backend: &'a str,
180 pub imports: &'a [ImportEntry],
182}
183
184impl CycloneDxSbom {
185 pub fn new(inputs: &SbomInputs<'_>, timestamp: String) -> Self {
191 let input_digest = sha256_hex(inputs.input_bytes);
192 let output_digest = sha256_hex(inputs.output_bytes);
193
194 let input_name = file_name_of(inputs.input_path);
195 let output_name = file_name_of(inputs.output_path);
196
197 let input_ref = format!("wasm:{input_name}");
199 let output_ref = format!("elf:{output_name}");
200
201 let input_component = Component {
203 component_type: "library".to_string(),
204 bom_ref: input_ref.clone(),
205 name: input_name,
206 version: format!("{}-bytes", inputs.input_bytes.len()),
207 hashes: vec![Hash {
208 alg: "SHA-256".to_string(),
209 content: input_digest,
210 }],
211 properties: vec![
212 Property {
213 name: "synth:artifact".to_string(),
214 value: "input-wasm".to_string(),
215 },
216 Property {
217 name: "synth:size-bytes".to_string(),
218 value: inputs.input_bytes.len().to_string(),
219 },
220 ],
221 };
222
223 let output_component = Component {
225 component_type: "application".to_string(),
226 bom_ref: output_ref.clone(),
227 name: output_name,
228 version: format!("{}-bytes", inputs.output_bytes.len()),
229 hashes: vec![Hash {
230 alg: "SHA-256".to_string(),
231 content: output_digest.clone(),
232 }],
233 properties: vec![
234 Property {
235 name: "synth:artifact".to_string(),
236 value: "output-elf".to_string(),
237 },
238 Property {
239 name: "synth:size-bytes".to_string(),
240 value: inputs.output_bytes.len().to_string(),
241 },
242 Property {
243 name: "synth:target-triple".to_string(),
244 value: inputs.target_triple.to_string(),
245 },
246 Property {
247 name: "synth:backend".to_string(),
248 value: inputs.backend.to_string(),
249 },
250 ],
251 };
252
253 let mut import_components = Vec::with_capacity(inputs.imports.len());
256 let mut import_refs = Vec::with_capacity(inputs.imports.len());
257 for imp in inputs.imports {
258 let kind = import_kind_str(&imp.kind);
259 let bom_ref = format!("import:{}/{}", imp.module, imp.name);
260 import_refs.push(bom_ref.clone());
261 import_components.push(Component {
262 component_type: "library".to_string(),
263 bom_ref,
264 name: format!("{}::{}", imp.module, imp.name),
265 version: "unknown".to_string(),
266 hashes: Vec::new(),
267 properties: vec![
268 Property {
269 name: "synth:artifact".to_string(),
270 value: "wasm-import".to_string(),
271 },
272 Property {
273 name: "synth:import-kind".to_string(),
274 value: kind.to_string(),
275 },
276 Property {
277 name: "synth:import-module".to_string(),
278 value: imp.module.clone(),
279 },
280 ],
281 });
282 }
283
284 let mut elf_depends_on = Vec::with_capacity(1 + import_refs.len());
287 elf_depends_on.push(input_ref.clone());
288 elf_depends_on.extend(import_refs.iter().cloned());
289
290 let mut dependencies = vec![
291 Dependency {
292 dep_ref: output_ref.clone(),
293 depends_on: elf_depends_on,
294 },
295 Dependency {
296 dep_ref: input_ref,
297 depends_on: import_refs.clone(),
298 },
299 ];
300 for r in import_refs {
303 dependencies.push(Dependency {
304 dep_ref: r,
305 depends_on: Vec::new(),
306 });
307 }
308
309 let mut components = Vec::with_capacity(2 + import_components.len());
310 components.push(output_component);
311 components.push(input_component);
312 components.extend(import_components);
313
314 CycloneDxSbom {
315 bom_format: "CycloneDX".to_string(),
316 spec_version: CYCLONEDX_SPEC_VERSION.to_string(),
317 serial_number: uuid_urn_from_digest(&output_digest),
321 version: 1,
322 metadata: SbomMetadata {
323 timestamp,
324 tools: vec![Tool {
325 vendor: "PulseEngine".to_string(),
326 name: "synth".to_string(),
327 version: inputs.synth_version.to_string(),
328 }],
329 },
330 components,
331 dependencies,
332 }
333 }
334
335 pub fn to_json(&self) -> String {
337 serde_json::to_string_pretty(self).expect("CycloneDxSbom is always serialisable")
340 }
341
342 pub fn sidecar_path(elf_path: &Path) -> PathBuf {
348 let mut p = elf_path.to_path_buf();
349 let stem = elf_path
350 .file_stem()
351 .map(|s| s.to_string_lossy().to_string())
352 .unwrap_or_else(|| "out".to_string());
353 p.set_file_name(format!("{stem}.cdx.json"));
354 p
355 }
356}
357
358fn sha256_hex(bytes: &[u8]) -> String {
360 let digest = Sha256::digest(bytes);
361 let mut out = String::with_capacity(digest.len() * 2);
362 for b in digest {
363 out.push_str(&format!("{b:02x}"));
364 }
365 out
366}
367
368fn file_name_of(path: &Path) -> String {
370 path.file_name()
371 .map(|s| s.to_string_lossy().to_string())
372 .unwrap_or_else(|| "unknown".to_string())
373}
374
375fn import_kind_str(kind: &ImportKind) -> &'static str {
377 match kind {
378 ImportKind::Function(_) => "function",
379 ImportKind::Memory => "memory",
380 ImportKind::Table => "table",
381 ImportKind::Global => "global",
382 }
383}
384
385fn uuid_urn_from_digest(digest_hex: &str) -> String {
390 let h: Vec<char> = digest_hex.chars().take(32).collect();
391 debug_assert_eq!(h.len(), 32, "SHA-256 hex digest is 64 chars");
392 let s: String = h.iter().collect();
393 format!(
394 "urn:uuid:{}-{}-4{}-8{}-{}",
395 &s[0..8],
396 &s[8..12],
397 &s[13..16],
398 &s[17..20],
399 &s[20..32],
400 )
401}
402
403pub fn now_rfc3339() -> String {
408 use std::time::{SystemTime, UNIX_EPOCH};
409 let secs = SystemTime::now()
410 .duration_since(UNIX_EPOCH)
411 .map(|d| d.as_secs())
412 .unwrap_or(0);
413 rfc3339_from_unix(secs)
414}
415
416fn rfc3339_from_unix(secs: u64) -> String {
419 let days = (secs / 86_400) as i64;
420 let rem = secs % 86_400;
421 let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
422
423 let z = days + 719_468;
425 let era = z.div_euclid(146_097);
426 let doe = z.rem_euclid(146_097);
427 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
428 let year = yoe + era * 400;
429 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
430 let mp = (5 * doy + 2) / 153;
431 let day = doy - (153 * mp + 2) / 5 + 1;
432 let month = if mp < 10 { mp + 3 } else { mp - 9 };
433 let year = if month <= 2 { year + 1 } else { year };
434
435 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
436}
437
438#[cfg(test)]
439mod tests {
440 use super::*;
441 use crate::wasm_decoder::{ImportEntry, ImportKind};
442 use std::path::PathBuf;
443
444 const FIXED_TS: &str = "2026-05-21T10:30:00Z";
446
447 fn sample_imports() -> Vec<ImportEntry> {
448 vec![
449 ImportEntry {
450 module: "wasi:cli/stdout".to_string(),
451 name: "write".to_string(),
452 kind: ImportKind::Function(0),
453 index: 0,
454 },
455 ImportEntry {
456 module: "env".to_string(),
457 name: "memory".to_string(),
458 kind: ImportKind::Memory,
459 index: 0,
460 },
461 ]
462 }
463
464 fn sample_sbom() -> CycloneDxSbom {
465 let imports = sample_imports();
466 let inputs = SbomInputs {
467 synth_version: "0.3.1",
468 input_path: Path::new("/tmp/vehicle-control.wasm"),
469 input_bytes: b"\0asm\x01\0\0\0fake-wasm-body",
470 output_path: Path::new("/tmp/vehicle-control.elf"),
471 output_bytes: b"\x7fELFfake-elf-body",
472 target_triple: "thumbv7em-none-eabi",
473 backend: "arm",
474 imports: &imports,
475 };
476 CycloneDxSbom::new(&inputs, FIXED_TS.to_string())
477 }
478
479 #[test]
480 fn json_has_required_cyclonedx_fields() {
481 let json = sample_sbom().to_json();
482 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
483 assert_eq!(v["bomFormat"], "CycloneDX");
484 assert_eq!(v["specVersion"], "1.5");
485 assert!(v["serialNumber"].as_str().unwrap().starts_with("urn:uuid:"));
486 assert!(v["metadata"].is_object());
487 assert!(v["components"].is_array());
488 assert!(v["dependencies"].is_array());
489 assert_eq!(v["version"], 1);
490 }
491
492 #[test]
493 fn metadata_tool_is_synth_compiler() {
494 let json = sample_sbom().to_json();
495 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
496 let tool = &v["metadata"]["tools"][0];
497 assert_eq!(tool["name"], "synth");
498 assert_eq!(tool["vendor"], "PulseEngine");
499 assert_eq!(tool["version"], "0.3.1");
500 assert_eq!(v["metadata"]["timestamp"], FIXED_TS);
501 }
502
503 #[test]
504 fn components_cover_wasm_elf_and_imports() {
505 let json = sample_sbom().to_json();
506 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
507 let comps = v["components"].as_array().unwrap();
508 assert_eq!(comps.len(), 4);
510
511 let elf = &comps[0];
512 assert_eq!(elf["type"], "application");
513 assert_eq!(elf["name"], "vehicle-control.elf");
514 assert_eq!(elf["hashes"][0]["alg"], "SHA-256");
515 assert_eq!(elf["hashes"][0]["content"].as_str().unwrap().len(), 64);
516
517 let wasm = &comps[1];
518 assert_eq!(wasm["type"], "library");
519 assert_eq!(wasm["name"], "vehicle-control.wasm");
520 assert_eq!(wasm["hashes"][0]["alg"], "SHA-256");
521
522 let import_names: Vec<&str> = comps[2..]
524 .iter()
525 .map(|c| c["name"].as_str().unwrap())
526 .collect();
527 assert!(import_names.contains(&"wasi:cli/stdout::write"));
528 assert!(import_names.contains(&"env::memory"));
529 }
530
531 #[test]
532 fn output_elf_records_target_and_backend() {
533 let json = sample_sbom().to_json();
534 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
535 let props = v["components"][0]["properties"].as_array().unwrap();
536 let find = |key: &str| {
537 props
538 .iter()
539 .find(|p| p["name"] == key)
540 .map(|p| p["value"].as_str().unwrap().to_string())
541 };
542 assert_eq!(
543 find("synth:target-triple").as_deref(),
544 Some("thumbv7em-none-eabi")
545 );
546 assert_eq!(find("synth:backend").as_deref(), Some("arm"));
547 assert_eq!(find("synth:artifact").as_deref(), Some("output-elf"));
548 }
549
550 #[test]
551 fn dependency_graph_links_elf_to_inputs() {
552 let json = sample_sbom().to_json();
553 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
554 let deps = v["dependencies"].as_array().unwrap();
555 assert_eq!(deps.len(), 4);
557
558 let elf_node = deps
560 .iter()
561 .find(|d| d["ref"].as_str().unwrap().starts_with("elf:"))
562 .expect("elf dependency node");
563 let depends: Vec<&str> = elf_node["dependsOn"]
564 .as_array()
565 .unwrap()
566 .iter()
567 .map(|x| x.as_str().unwrap())
568 .collect();
569 assert!(depends.iter().any(|d| d.starts_with("wasm:")));
570 assert!(depends.iter().any(|d| d.starts_with("import:")));
571 assert_eq!(depends.len(), 3); }
573
574 #[test]
575 fn serial_number_is_deterministic_for_same_output() {
576 let a = sample_sbom();
579 let b = sample_sbom();
580 assert_eq!(a.serial_number, b.serial_number);
581 let uuid = a.serial_number.strip_prefix("urn:uuid:").unwrap();
583 let parts: Vec<&str> = uuid.split('-').collect();
584 assert_eq!(parts.len(), 5);
585 assert!(parts[2].starts_with('4'));
586 assert!(parts[3].starts_with('8'));
587 }
588
589 #[test]
590 fn sidecar_path_strips_elf_extension() {
591 let p = CycloneDxSbom::sidecar_path(&PathBuf::from("/tmp/foo.elf"));
592 assert_eq!(p, PathBuf::from("/tmp/foo.cdx.json"));
593 }
594
595 #[test]
596 fn sidecar_path_handles_missing_extension() {
597 let p = CycloneDxSbom::sidecar_path(&PathBuf::from("out"));
598 assert_eq!(p, PathBuf::from("out.cdx.json"));
599 }
600
601 #[test]
602 fn sbom_with_no_imports_is_still_valid() {
603 let inputs = SbomInputs {
604 synth_version: "0.3.1",
605 input_path: Path::new("add.wasm"),
606 input_bytes: b"\0asm",
607 output_path: Path::new("add.elf"),
608 output_bytes: b"\x7fELF",
609 target_triple: "riscv32imac-unknown-none-elf",
610 backend: "riscv",
611 imports: &[],
612 };
613 let sbom = CycloneDxSbom::new(&inputs, FIXED_TS.to_string());
614 let v: serde_json::Value = serde_json::from_str(&sbom.to_json()).unwrap();
615 assert_eq!(v["components"].as_array().unwrap().len(), 2);
617 assert_eq!(v["dependencies"].as_array().unwrap().len(), 2);
618 }
619
620 #[test]
621 fn rfc3339_conversion_is_correct() {
622 assert_eq!(rfc3339_from_unix(1_779_359_400), "2026-05-21T10:30:00Z");
624 assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
626 assert_eq!(rfc3339_from_unix(1_709_164_800), "2024-02-29T00:00:00Z");
628 }
629}