1use crate::wasm_decoder::{ImportEntry, ImportKind};
66use serde::Serialize;
67use sha2::{Digest, Sha256};
68use std::path::{Path, PathBuf};
69
70const CYCLONEDX_SPEC_VERSION: &str = "1.5";
73
74#[derive(Debug, Clone, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct CycloneDxSbom {
81 pub bom_format: String,
83 pub spec_version: String,
85 pub serial_number: String,
87 pub version: u32,
89 pub metadata: SbomMetadata,
91 pub components: Vec<Component>,
93 pub dependencies: Vec<Dependency>,
95}
96
97#[derive(Debug, Clone, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct SbomMetadata {
101 pub timestamp: String,
105 pub tools: Vec<Tool>,
107 #[serde(skip_serializing_if = "Vec::is_empty")]
111 pub properties: Vec<Property>,
112}
113
114#[derive(Debug, Clone, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct Tool {
118 pub vendor: String,
120 pub name: String,
122 pub version: String,
124}
125
126#[derive(Debug, Clone, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Component {
130 #[serde(rename = "type")]
132 pub component_type: String,
133 #[serde(rename = "bom-ref")]
135 pub bom_ref: String,
136 pub name: String,
138 pub version: String,
141 #[serde(skip_serializing_if = "Vec::is_empty")]
143 pub hashes: Vec<Hash>,
144 #[serde(skip_serializing_if = "Vec::is_empty")]
147 pub properties: Vec<Property>,
148}
149
150#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct Hash {
154 pub alg: String,
156 pub content: String,
158}
159
160#[derive(Debug, Clone, Serialize)]
162#[serde(rename_all = "camelCase")]
163pub struct Property {
164 pub name: String,
166 pub value: String,
168}
169
170#[derive(Debug, Clone, Serialize)]
172#[serde(rename_all = "camelCase")]
173pub struct Dependency {
174 #[serde(rename = "ref")]
176 pub dep_ref: String,
177 #[serde(rename = "dependsOn", skip_serializing_if = "Vec::is_empty")]
179 pub depends_on: Vec<String>,
180}
181
182#[derive(Debug, Clone)]
185pub struct SbomInputs<'a> {
186 pub synth_version: &'a str,
188 pub input_path: &'a Path,
190 pub input_bytes: &'a [u8],
192 pub output_path: &'a Path,
194 pub output_bytes: &'a [u8],
196 pub target_triple: &'a str,
198 pub backend: &'a str,
200 pub imports: &'a [ImportEntry],
202}
203
204impl CycloneDxSbom {
205 pub fn new(inputs: &SbomInputs<'_>, timestamp: String) -> Self {
211 let input_digest = sha256_hex(inputs.input_bytes);
212 let output_digest = sha256_hex(inputs.output_bytes);
213
214 let input_name = file_name_of(inputs.input_path);
215 let output_name = file_name_of(inputs.output_path);
216
217 let input_ref = format!("wasm:{input_name}");
219 let output_ref = format!("elf:{output_name}");
220
221 let input_component = Component {
223 component_type: "library".to_string(),
224 bom_ref: input_ref.clone(),
225 name: input_name,
226 version: format!("{}-bytes", inputs.input_bytes.len()),
227 hashes: vec![Hash {
228 alg: "SHA-256".to_string(),
229 content: input_digest,
230 }],
231 properties: vec![
232 Property {
233 name: "synth:artifact".to_string(),
234 value: "input-wasm".to_string(),
235 },
236 Property {
237 name: "synth:size-bytes".to_string(),
238 value: inputs.input_bytes.len().to_string(),
239 },
240 ],
241 };
242
243 let output_component = Component {
245 component_type: "application".to_string(),
246 bom_ref: output_ref.clone(),
247 name: output_name,
248 version: format!("{}-bytes", inputs.output_bytes.len()),
249 hashes: vec![Hash {
250 alg: "SHA-256".to_string(),
251 content: output_digest.clone(),
252 }],
253 properties: vec![
254 Property {
255 name: "synth:artifact".to_string(),
256 value: "output-elf".to_string(),
257 },
258 Property {
259 name: "synth:size-bytes".to_string(),
260 value: inputs.output_bytes.len().to_string(),
261 },
262 Property {
263 name: "synth:target-triple".to_string(),
264 value: inputs.target_triple.to_string(),
265 },
266 Property {
267 name: "synth:backend".to_string(),
268 value: inputs.backend.to_string(),
269 },
270 ],
271 };
272
273 let mut import_components = Vec::with_capacity(inputs.imports.len());
276 let mut import_refs = Vec::with_capacity(inputs.imports.len());
277 for imp in inputs.imports {
278 let kind = import_kind_str(&imp.kind);
279 let bom_ref = format!("import:{}/{}", imp.module, imp.name);
280 import_refs.push(bom_ref.clone());
281 import_components.push(Component {
282 component_type: "library".to_string(),
283 bom_ref,
284 name: format!("{}::{}", imp.module, imp.name),
285 version: "unknown".to_string(),
286 hashes: Vec::new(),
287 properties: vec![
288 Property {
289 name: "synth:artifact".to_string(),
290 value: "wasm-import".to_string(),
291 },
292 Property {
293 name: "synth:import-kind".to_string(),
294 value: kind.to_string(),
295 },
296 Property {
297 name: "synth:import-module".to_string(),
298 value: imp.module.clone(),
299 },
300 ],
301 });
302 }
303
304 let mut elf_depends_on = Vec::with_capacity(1 + import_refs.len());
307 elf_depends_on.push(input_ref.clone());
308 elf_depends_on.extend(import_refs.iter().cloned());
309
310 let mut dependencies = vec![
311 Dependency {
312 dep_ref: output_ref.clone(),
313 depends_on: elf_depends_on,
314 },
315 Dependency {
316 dep_ref: input_ref,
317 depends_on: import_refs.clone(),
318 },
319 ];
320 for r in import_refs {
323 dependencies.push(Dependency {
324 dep_ref: r,
325 depends_on: Vec::new(),
326 });
327 }
328
329 let mut components = Vec::with_capacity(2 + import_components.len());
330 components.push(output_component);
331 components.push(input_component);
332 components.extend(import_components);
333
334 CycloneDxSbom {
335 bom_format: "CycloneDX".to_string(),
336 spec_version: CYCLONEDX_SPEC_VERSION.to_string(),
337 serial_number: uuid_urn_from_digest(&output_digest),
341 version: 1,
342 metadata: SbomMetadata {
343 timestamp,
344 tools: vec![Tool {
345 vendor: "PulseEngine".to_string(),
346 name: "synth".to_string(),
347 version: inputs.synth_version.to_string(),
348 }],
349 properties: Vec::new(),
350 },
351 components,
352 dependencies,
353 }
354 }
355
356 pub fn with_build_environment(mut self, vars: &[(String, String)]) -> Self {
361 let mut kept: Vec<&(String, String)> = vars
362 .iter()
363 .filter(|(k, _)| k.starts_with("SYNTH_"))
364 .collect();
365 kept.sort();
366 self.metadata.properties = kept
367 .into_iter()
368 .map(|(k, v)| Property {
369 name: format!("synth:build-env:{k}"),
370 value: v.clone(),
371 })
372 .collect();
373 self
374 }
375
376 pub fn to_json(&self) -> String {
377 serde_json::to_string_pretty(self).expect("CycloneDxSbom is always serialisable")
380 }
381
382 pub fn sidecar_path(elf_path: &Path) -> PathBuf {
388 let mut p = elf_path.to_path_buf();
389 let stem = elf_path
390 .file_stem()
391 .map(|s| s.to_string_lossy().to_string())
392 .unwrap_or_else(|| "out".to_string());
393 p.set_file_name(format!("{stem}.cdx.json"));
394 p
395 }
396}
397
398fn sha256_hex(bytes: &[u8]) -> String {
400 let digest = Sha256::digest(bytes);
401 let mut out = String::with_capacity(digest.len() * 2);
402 for b in digest {
403 out.push_str(&format!("{b:02x}"));
404 }
405 out
406}
407
408fn file_name_of(path: &Path) -> String {
410 path.file_name()
411 .map(|s| s.to_string_lossy().to_string())
412 .unwrap_or_else(|| "unknown".to_string())
413}
414
415fn import_kind_str(kind: &ImportKind) -> &'static str {
417 match kind {
418 ImportKind::Function(_) => "function",
419 ImportKind::Memory => "memory",
420 ImportKind::Table => "table",
421 ImportKind::Global => "global",
422 }
423}
424
425fn uuid_urn_from_digest(digest_hex: &str) -> String {
430 let h: Vec<char> = digest_hex.chars().take(32).collect();
431 debug_assert_eq!(h.len(), 32, "SHA-256 hex digest is 64 chars");
432 let s: String = h.iter().collect();
433 format!(
434 "urn:uuid:{}-{}-4{}-8{}-{}",
435 &s[0..8],
436 &s[8..12],
437 &s[13..16],
438 &s[17..20],
439 &s[20..32],
440 )
441}
442
443pub fn build_timestamp_rfc3339() -> Result<String, String> {
454 timestamp_from_source_date_epoch(std::env::var("SOURCE_DATE_EPOCH").ok().as_deref())
455}
456
457pub fn timestamp_from_source_date_epoch(value: Option<&str>) -> Result<String, String> {
460 match value {
461 None => Ok(now_rfc3339()),
462 Some(raw) => raw
463 .trim()
464 .parse::<u64>()
465 .map(rfc3339_from_unix)
466 .map_err(|_| {
467 format!(
468 "SOURCE_DATE_EPOCH={raw:?} is not a non-negative integer number of \
469 seconds since the Unix epoch — refusing to fall back to the wall \
470 clock, which would make the SBOM non-reproducible (#1292)"
471 )
472 }),
473 }
474}
475
476pub fn synth_build_environment() -> Vec<(String, String)> {
478 let mut vars: Vec<(String, String)> = std::env::vars()
479 .filter(|(k, _)| k.starts_with("SYNTH_"))
480 .collect();
481 vars.sort();
482 vars
483}
484
485pub fn now_rfc3339() -> String {
486 use std::time::{SystemTime, UNIX_EPOCH};
487 let secs = SystemTime::now()
488 .duration_since(UNIX_EPOCH)
489 .map(|d| d.as_secs())
490 .unwrap_or(0);
491 rfc3339_from_unix(secs)
492}
493
494fn rfc3339_from_unix(secs: u64) -> String {
497 let days = (secs / 86_400) as i64;
498 let rem = secs % 86_400;
499 let (hour, minute, second) = (rem / 3600, (rem % 3600) / 60, rem % 60);
500
501 let z = days + 719_468;
503 let era = z.div_euclid(146_097);
504 let doe = z.rem_euclid(146_097);
505 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
506 let year = yoe + era * 400;
507 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
508 let mp = (5 * doy + 2) / 153;
509 let day = doy - (153 * mp + 2) / 5 + 1;
510 let month = if mp < 10 { mp + 3 } else { mp - 9 };
511 let year = if month <= 2 { year + 1 } else { year };
512
513 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519 use crate::wasm_decoder::{ImportEntry, ImportKind};
520 use std::path::PathBuf;
521
522 const FIXED_TS: &str = "2026-05-21T10:30:00Z";
524
525 fn sample_imports() -> Vec<ImportEntry> {
526 vec![
527 ImportEntry {
528 module: "wasi:cli/stdout".to_string(),
529 name: "write".to_string(),
530 kind: ImportKind::Function(0),
531 index: 0,
532 },
533 ImportEntry {
534 module: "env".to_string(),
535 name: "memory".to_string(),
536 kind: ImportKind::Memory,
537 index: 0,
538 },
539 ]
540 }
541
542 fn sample_sbom() -> CycloneDxSbom {
543 let imports = sample_imports();
544 let inputs = SbomInputs {
545 synth_version: "0.3.1",
546 input_path: Path::new("/tmp/vehicle-control.wasm"),
547 input_bytes: b"\0asm\x01\0\0\0fake-wasm-body",
548 output_path: Path::new("/tmp/vehicle-control.elf"),
549 output_bytes: b"\x7fELFfake-elf-body",
550 target_triple: "thumbv7em-none-eabi",
551 backend: "arm",
552 imports: &imports,
553 };
554 CycloneDxSbom::new(&inputs, FIXED_TS.to_string())
555 }
556
557 #[test]
558 fn json_has_required_cyclonedx_fields() {
559 let json = sample_sbom().to_json();
560 let v: serde_json::Value = serde_json::from_str(&json).expect("valid JSON");
561 assert_eq!(v["bomFormat"], "CycloneDX");
562 assert_eq!(v["specVersion"], "1.5");
563 assert!(v["serialNumber"].as_str().unwrap().starts_with("urn:uuid:"));
564 assert!(v["metadata"].is_object());
565 assert!(v["components"].is_array());
566 assert!(v["dependencies"].is_array());
567 assert_eq!(v["version"], 1);
568 }
569
570 #[test]
571 fn metadata_tool_is_synth_compiler() {
572 let json = sample_sbom().to_json();
573 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
574 let tool = &v["metadata"]["tools"][0];
575 assert_eq!(tool["name"], "synth");
576 assert_eq!(tool["vendor"], "PulseEngine");
577 assert_eq!(tool["version"], "0.3.1");
578 assert_eq!(v["metadata"]["timestamp"], FIXED_TS);
579 }
580
581 #[test]
582 fn components_cover_wasm_elf_and_imports() {
583 let json = sample_sbom().to_json();
584 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
585 let comps = v["components"].as_array().unwrap();
586 assert_eq!(comps.len(), 4);
588
589 let elf = &comps[0];
590 assert_eq!(elf["type"], "application");
591 assert_eq!(elf["name"], "vehicle-control.elf");
592 assert_eq!(elf["hashes"][0]["alg"], "SHA-256");
593 assert_eq!(elf["hashes"][0]["content"].as_str().unwrap().len(), 64);
594
595 let wasm = &comps[1];
596 assert_eq!(wasm["type"], "library");
597 assert_eq!(wasm["name"], "vehicle-control.wasm");
598 assert_eq!(wasm["hashes"][0]["alg"], "SHA-256");
599
600 let import_names: Vec<&str> = comps[2..]
602 .iter()
603 .map(|c| c["name"].as_str().unwrap())
604 .collect();
605 assert!(import_names.contains(&"wasi:cli/stdout::write"));
606 assert!(import_names.contains(&"env::memory"));
607 }
608
609 #[test]
610 fn output_elf_records_target_and_backend() {
611 let json = sample_sbom().to_json();
612 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
613 let props = v["components"][0]["properties"].as_array().unwrap();
614 let find = |key: &str| {
615 props
616 .iter()
617 .find(|p| p["name"] == key)
618 .map(|p| p["value"].as_str().unwrap().to_string())
619 };
620 assert_eq!(
621 find("synth:target-triple").as_deref(),
622 Some("thumbv7em-none-eabi")
623 );
624 assert_eq!(find("synth:backend").as_deref(), Some("arm"));
625 assert_eq!(find("synth:artifact").as_deref(), Some("output-elf"));
626 }
627
628 #[test]
629 fn dependency_graph_links_elf_to_inputs() {
630 let json = sample_sbom().to_json();
631 let v: serde_json::Value = serde_json::from_str(&json).unwrap();
632 let deps = v["dependencies"].as_array().unwrap();
633 assert_eq!(deps.len(), 4);
635
636 let elf_node = deps
638 .iter()
639 .find(|d| d["ref"].as_str().unwrap().starts_with("elf:"))
640 .expect("elf dependency node");
641 let depends: Vec<&str> = elf_node["dependsOn"]
642 .as_array()
643 .unwrap()
644 .iter()
645 .map(|x| x.as_str().unwrap())
646 .collect();
647 assert!(depends.iter().any(|d| d.starts_with("wasm:")));
648 assert!(depends.iter().any(|d| d.starts_with("import:")));
649 assert_eq!(depends.len(), 3); }
651
652 #[test]
653 fn serial_number_is_deterministic_for_same_output() {
654 let a = sample_sbom();
657 let b = sample_sbom();
658 assert_eq!(a.serial_number, b.serial_number);
659 let uuid = a.serial_number.strip_prefix("urn:uuid:").unwrap();
661 let parts: Vec<&str> = uuid.split('-').collect();
662 assert_eq!(parts.len(), 5);
663 assert!(parts[2].starts_with('4'));
664 assert!(parts[3].starts_with('8'));
665 }
666
667 #[test]
668 fn sidecar_path_strips_elf_extension() {
669 let p = CycloneDxSbom::sidecar_path(&PathBuf::from("/tmp/foo.elf"));
670 assert_eq!(p, PathBuf::from("/tmp/foo.cdx.json"));
671 }
672
673 #[test]
674 fn sidecar_path_handles_missing_extension() {
675 let p = CycloneDxSbom::sidecar_path(&PathBuf::from("out"));
676 assert_eq!(p, PathBuf::from("out.cdx.json"));
677 }
678
679 #[test]
680 fn sbom_with_no_imports_is_still_valid() {
681 let inputs = SbomInputs {
682 synth_version: "0.3.1",
683 input_path: Path::new("add.wasm"),
684 input_bytes: b"\0asm",
685 output_path: Path::new("add.elf"),
686 output_bytes: b"\x7fELF",
687 target_triple: "riscv32imac-unknown-none-elf",
688 backend: "riscv",
689 imports: &[],
690 };
691 let sbom = CycloneDxSbom::new(&inputs, FIXED_TS.to_string());
692 let v: serde_json::Value = serde_json::from_str(&sbom.to_json()).unwrap();
693 assert_eq!(v["components"].as_array().unwrap().len(), 2);
695 assert_eq!(v["dependencies"].as_array().unwrap().len(), 2);
696 }
697
698 #[test]
699 fn rfc3339_conversion_is_correct() {
700 assert_eq!(rfc3339_from_unix(1_779_359_400), "2026-05-21T10:30:00Z");
702 assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
704 assert_eq!(rfc3339_from_unix(1_709_164_800), "2024-02-29T00:00:00Z");
706 }
707 #[test]
710 fn source_date_epoch_pins_the_timestamp_1292() {
711 assert_eq!(
713 timestamp_from_source_date_epoch(Some("1700000000")).unwrap(),
714 "2023-11-14T22:13:20Z"
715 );
716 assert_eq!(
717 timestamp_from_source_date_epoch(Some("0")).unwrap(),
718 "1970-01-01T00:00:00Z"
719 );
720 }
721
722 #[test]
723 fn malformed_source_date_epoch_is_an_error_not_a_fallback_1292() {
724 for bad in ["", "yesterday", "-5", "1.5"] {
725 let err = timestamp_from_source_date_epoch(Some(bad)).unwrap_err();
726 assert!(err.contains("#1292"), "{bad:?}: {err}");
727 }
728 }
729
730 #[test]
731 fn build_environment_is_recorded_sorted_and_synth_only_1293() {
732 let inputs = sample_inputs_for_env_test();
733 let sbom = CycloneDxSbom::new(&inputs, "2023-11-14T22:13:20Z".to_string())
734 .with_build_environment(&[
735 ("SYNTH_SPILL_REALLOC".to_string(), "0".to_string()),
736 ("HOME".to_string(), "/leak".to_string()),
737 ("SYNTH_CONST_CSE".to_string(), "0".to_string()),
738 ]);
739 let names: Vec<&str> = sbom
740 .metadata
741 .properties
742 .iter()
743 .map(|p| p.name.as_str())
744 .collect();
745 assert_eq!(
746 names,
747 [
748 "synth:build-env:SYNTH_CONST_CSE",
749 "synth:build-env:SYNTH_SPILL_REALLOC"
750 ]
751 );
752 let json = sbom.to_json();
753 assert!(json.contains("synth:build-env:SYNTH_CONST_CSE"), "{json}");
754 assert!(
755 !json.contains("/leak"),
756 "a non-SYNTH_ variable leaked into the SBOM"
757 );
758 }
759
760 #[test]
761 fn default_build_sbom_has_no_metadata_properties_1293() {
762 let inputs = sample_inputs_for_env_test();
763 let json = CycloneDxSbom::new(&inputs, "2023-11-14T22:13:20Z".to_string())
764 .with_build_environment(&[])
765 .to_json();
766 let meta = &json[json.find("\"metadata\"").unwrap()..json.find("\"components\"").unwrap()];
767 assert!(
768 !meta.contains("properties"),
769 "an SBOM with no SYNTH_* levers set must serialize exactly as before: {meta}"
770 );
771 }
772
773 fn sample_inputs_for_env_test() -> SbomInputs<'static> {
774 SbomInputs {
775 synth_version: "0.0.0-test",
776 input_path: std::path::Path::new("in.wasm"),
777 input_bytes: b"\0asm",
778 output_path: std::path::Path::new("out.elf"),
779 output_bytes: b"\x7fELF",
780 target_triple: "thumbv7em-none-eabi",
781 backend: "arm",
782 imports: &[],
783 }
784 }
785}