Skip to main content

synth_core/
sbom.rs

1//! CycloneDX SBOM emission — a build-time Software Bill of Materials for a
2//! compiled ELF. Companion to `safety_manifest.rs`: where the safety manifest
3//! records *how* the binary is hardened, the SBOM records *what went into it*.
4//!
5//! ## Scope
6//!
7//! synth is a compiler, not a linker, so this is a **build SBOM** — it
8//! documents the compilation transaction, not a transitive dependency graph:
9//!
10//! - `metadata.tools` — the synth compiler itself ("what built it").
11//! - the input WASM module — a component with SHA-256 + byte size.
12//! - the output ELF binary — a component with SHA-256, size, target triple,
13//!   and the backend that produced it.
14//! - the WASM module's imports — each imported function/module/memory/etc.
15//!   becomes a component, and the output ELF `dependsOn` each of them. This
16//!   is the closest synth can get to "what's in the software" without a full
17//!   linker view.
18//!
19//! Explicitly NOT in scope: full transitive scanning of the WASM module,
20//! AIBOM/ML-BOM. See `docs/sbom.md`.
21//!
22//! ## rivet #107 linkage
23//!
24//! The emitted document is CycloneDX 1.5 JSON. The sibling PulseEngine repo
25//! `rivet` (issue #107) defines an `sbom-record` artifact type that ingests a
26//! CycloneDX SBOM:
27//!
28//! ```yaml
29//! - id: SBOM-vehicle-control-v1
30//!   type: sbom-record
31//!   format: cyclonedx
32//!   sbom-ref: "sbom/vehicle-control-v1.0.0.cdx.json"
33//!   component-count: 142
34//! ```
35//!
36//! The file synth writes here is exactly what `rivet import --format
37//! cyclonedx` consumes, becoming one `sbom-record` in the rivet traceability
38//! chain.
39//!
40//! ## Determinism
41//!
42//! For a fixed set of inputs the document is byte-stable except for
43//! `metadata.timestamp`, which is wall-clock by design (a CycloneDX SBOM
44//! records when the build happened). The `serialNumber` is derived
45//! deterministically from the output ELF's SHA-256, so it too is stable for a
46//! given binary.
47//!
48//! #1292 (RQ-68-REPRO): when `SOURCE_DATE_EPOCH` is set the timestamp is THAT
49//! instant instead of the wall clock — the reproducible-builds.org convention,
50//! which keeps the by-design meaning ("when this was built") while letting two
51//! builds of the same source produce the same SBOM. A malformed value is an
52//! error, as that specification asks, never a silent fall-back to the clock.
53//!
54//! #1293: `metadata.properties` records every `SYNTH_*` environment variable
55//! SET for the compilation (`synth:build-env:<NAME>`). A census of the variables
56//! non-test source reads found 14 of 36 that change emitted bytes on a 60-module
57//! sample — a lower bound, since it covered three configs and the values `1` and
58//! `0` only — so the list is read from the environment at build time rather than
59//! hard-coded, and cannot drift when a lever is added. When none is set the
60//! field is omitted, so a default build's SBOM is byte-identical to before.
61//!
62//! Path convention: when the compiler emits `foo.elf`, the SBOM is written to
63//! `foo.cdx.json` next to it.
64
65use crate::wasm_decoder::{ImportEntry, ImportKind};
66use serde::Serialize;
67use sha2::{Digest, Sha256};
68use std::path::{Path, PathBuf};
69
70/// The CycloneDX spec version this module emits. rivet #107's `sbom-record`
71/// ingests 1.5; bump deliberately if the schema shape changes.
72const CYCLONEDX_SPEC_VERSION: &str = "1.5";
73
74/// A complete CycloneDX 1.5 SBOM document.
75///
76/// Field names use `#[serde(rename_all = "camelCase")]` to match the
77/// CycloneDX JSON schema (`bomFormat`, `specVersion`, `serialNumber`, ...).
78#[derive(Debug, Clone, Serialize)]
79#[serde(rename_all = "camelCase")]
80pub struct CycloneDxSbom {
81    /// Always the literal string `"CycloneDX"`.
82    pub bom_format: String,
83    /// CycloneDX spec version — `"1.5"`.
84    pub spec_version: String,
85    /// `urn:uuid:...` serial number, derived from the output ELF digest.
86    pub serial_number: String,
87    /// Document revision; `1` for a freshly emitted SBOM.
88    pub version: u32,
89    /// Build metadata: timestamp + the synth compiler tool entry.
90    pub metadata: SbomMetadata,
91    /// Components: the input WASM, the output ELF, and one per WASM import.
92    pub components: Vec<Component>,
93    /// Dependency graph linking the output ELF to its inputs.
94    pub dependencies: Vec<Dependency>,
95}
96
97/// CycloneDX `metadata` block.
98#[derive(Debug, Clone, Serialize)]
99#[serde(rename_all = "camelCase")]
100pub struct SbomMetadata {
101    /// ISO-8601 / RFC-3339 UTC timestamp of when the SBOM was emitted — the
102    /// wall clock by default, or `SOURCE_DATE_EPOCH` when set (#1292), which
103    /// makes it deterministic for a reproducible build.
104    pub timestamp: String,
105    /// The tool(s) that produced the described artifact — here, synth itself.
106    pub tools: Vec<Tool>,
107    /// #1293: the build environment that can change emitted bytes — every
108    /// `SYNTH_*` variable set for this compilation, sorted by name. Omitted
109    /// when empty so a default build's SBOM is unchanged.
110    #[serde(skip_serializing_if = "Vec::is_empty")]
111    pub properties: Vec<Property>,
112}
113
114/// CycloneDX `metadata.tools` entry — "what built it".
115#[derive(Debug, Clone, Serialize)]
116#[serde(rename_all = "camelCase")]
117pub struct Tool {
118    /// Tool vendor / publishing organisation.
119    pub vendor: String,
120    /// Tool name — `"synth"`.
121    pub name: String,
122    /// Tool version (`CARGO_PKG_VERSION` or a release tag).
123    pub version: String,
124}
125
126/// A CycloneDX `component`.
127#[derive(Debug, Clone, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Component {
130    /// CycloneDX component type (`application`, `library`, ...).
131    #[serde(rename = "type")]
132    pub component_type: String,
133    /// Stable reference used by the `dependencies` graph.
134    #[serde(rename = "bom-ref")]
135    pub bom_ref: String,
136    /// Human-readable component name.
137    pub name: String,
138    /// Component version (file size for binary artifacts, `"unknown"` for
139    /// imports whose version synth cannot know).
140    pub version: String,
141    /// Cryptographic hashes of the component, when synth has the bytes.
142    #[serde(skip_serializing_if = "Vec::is_empty")]
143    pub hashes: Vec<Hash>,
144    /// Free-form key/value properties — byte size, target triple, backend,
145    /// import kind, etc.
146    #[serde(skip_serializing_if = "Vec::is_empty")]
147    pub properties: Vec<Property>,
148}
149
150/// A CycloneDX `hashes` entry.
151#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct Hash {
154    /// Hash algorithm — always `"SHA-256"` here.
155    pub alg: String,
156    /// Lower-case hex digest.
157    pub content: String,
158}
159
160/// A CycloneDX `properties` entry — a namespaced key/value pair.
161#[derive(Debug, Clone, Serialize)]
162#[serde(rename_all = "camelCase")]
163pub struct Property {
164    /// Property name, namespaced under `synth:`.
165    pub name: String,
166    /// Property value, always serialised as a string.
167    pub value: String,
168}
169
170/// A CycloneDX `dependencies` graph node.
171#[derive(Debug, Clone, Serialize)]
172#[serde(rename_all = "camelCase")]
173pub struct Dependency {
174    /// The `bom-ref` this node describes.
175    #[serde(rename = "ref")]
176    pub dep_ref: String,
177    /// `bom-ref`s this component depends on.
178    #[serde(rename = "dependsOn", skip_serializing_if = "Vec::is_empty")]
179    pub depends_on: Vec<String>,
180}
181
182/// Inputs needed to construct a build SBOM. Grouped into a struct so the
183/// constructor signature stays readable as the SBOM scope grows.
184#[derive(Debug, Clone)]
185pub struct SbomInputs<'a> {
186    /// `CARGO_PKG_VERSION` (or release tag) of the synth that compiled.
187    pub synth_version: &'a str,
188    /// Path of the input WASM/WAT file (used for the component name).
189    pub input_path: &'a Path,
190    /// Raw bytes of the input WASM module (post WAT→WASM, pre-compile).
191    pub input_bytes: &'a [u8],
192    /// Path of the emitted ELF binary.
193    pub output_path: &'a Path,
194    /// Raw bytes of the emitted ELF binary.
195    pub output_bytes: &'a [u8],
196    /// LLVM-style target triple of the output (e.g. `thumbv7em-none-eabi`).
197    pub target_triple: &'a str,
198    /// Name of the backend that produced the ELF (e.g. `arm`, `riscv`).
199    pub backend: &'a str,
200    /// Imports decoded from the WASM module — one component each.
201    pub imports: &'a [ImportEntry],
202}
203
204impl CycloneDxSbom {
205    /// Build a CycloneDX 1.5 SBOM describing one synth compilation.
206    ///
207    /// `timestamp` is the RFC-3339 UTC string for `metadata.timestamp`; the
208    /// caller supplies it so tests can pin a fixed value and production code
209    /// can pass [`now_rfc3339`].
210    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        // bom-refs. The output ELF is the root of the dependency graph.
218        let input_ref = format!("wasm:{input_name}");
219        let output_ref = format!("elf:{output_name}");
220
221        // The input WASM module component.
222        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        // The output ELF binary component.
244        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        // One component per WASM import. These are the closest synth can get
274        // to "what's linked into" the binary at compile time.
275        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        // Dependency graph: the ELF depends on the WASM module and on every
305        // import; the WASM module in turn depends on its imports.
306        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        // Imports are leaf nodes; declare them with no outgoing edges so the
321        // graph is complete (every bom-ref appears as a `ref`).
322        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            // Derive a stable urn:uuid from the output digest so the SBOM is
338            // reproducible for a given binary (CycloneDX requires the v4
339            // variant/version nibbles, which we stamp in).
340            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    /// Serialise to pretty-printed CycloneDX 1.5 JSON.
357    /// #1293: record the byte-changing build environment. `vars` are
358    /// `(NAME, value)` pairs; only `SYNTH_*` names are kept, sorted, so the
359    /// property order never depends on the order the OS returned them in.
360    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` preserves struct field declaration order, so the
378        // output is deterministic (the timestamp aside).
379        serde_json::to_string_pretty(self).expect("CycloneDxSbom is always serialisable")
380    }
381
382    /// Compute the SBOM path next to an output ELF: replaces the file
383    /// extension with `.cdx.json` (the conventional CycloneDX-JSON suffix).
384    /// Examples:
385    /// - `foo.elf` -> `foo.cdx.json`
386    /// - `out`     -> `out.cdx.json`
387    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
398/// Lower-case hex SHA-256 of a byte slice.
399fn 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
408/// Extract a display file name from a path, falling back to `"unknown"`.
409fn 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
415/// Map an [`ImportKind`] to a stable lower-case string for SBOM properties.
416fn 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
425/// Build a CycloneDX `urn:uuid:` serial number deterministically from a
426/// SHA-256 hex digest. The first 32 hex chars of the digest fill the UUID,
427/// with the version nibble forced to `4` and the variant nibble to `8` so the
428/// result is a well-formed RFC-4122 v4 UUID (CycloneDX requires this shape).
429fn 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
443/// RFC-3339 UTC timestamp for "now", e.g. `2026-05-21T10:30:00Z`.
444///
445/// Hand-rolled from `SystemTime` (civil-date conversion) to avoid pulling a
446/// date/time crate into `synth-core`, which is upstream of every backend.
447/// #1292: the SBOM build timestamp. `SOURCE_DATE_EPOCH` (seconds since the Unix
448/// epoch) wins when set; otherwise the wall clock. A value that is set but is
449/// not a non-negative integer is an error — the reproducible-builds.org
450/// specification asks for that rather than a silent fall-back, because a
451/// fall-back reintroduces exactly the non-determinism the variable exists to
452/// remove.
453pub fn build_timestamp_rfc3339() -> Result<String, String> {
454    timestamp_from_source_date_epoch(std::env::var("SOURCE_DATE_EPOCH").ok().as_deref())
455}
456
457/// Pure core of [`build_timestamp_rfc3339`], so it is testable without
458/// mutating the process environment.
459pub 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
476/// #1293: every `SYNTH_*` variable in the current process environment, sorted.
477pub 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
494/// Convert a Unix timestamp (seconds) to an RFC-3339 UTC string.
495/// Uses the standard civil-from-days algorithm (Howard Hinnant).
496fn 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    // days since 1970-01-01 -> civil (year, month, day)
502    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    /// Fixed timestamp so the rest of the document is fully deterministic.
523    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        // 1 ELF + 1 WASM + 2 imports.
587        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        // Imports are represented as components.
601        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        // ELF root + WASM + 2 imports = 4 graph nodes.
634        assert_eq!(deps.len(), 4);
635
636        // The ELF node depends on the WASM module and every import.
637        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); // wasm + 2 imports
650    }
651
652    #[test]
653    fn serial_number_is_deterministic_for_same_output() {
654        // Two SBOMs over identical bytes share a serial number; only the
655        // timestamp may differ between emissions.
656        let a = sample_sbom();
657        let b = sample_sbom();
658        assert_eq!(a.serial_number, b.serial_number);
659        // Well-formed RFC-4122 v4 shape.
660        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        // Just the ELF + WASM components, two dependency nodes.
694        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        // 2026-05-21T10:30:00Z == 1779359400 unix seconds.
701        assert_eq!(rfc3339_from_unix(1_779_359_400), "2026-05-21T10:30:00Z");
702        // Epoch.
703        assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
704        // A leap-year date: 2024-02-29T00:00:00Z == 1709164800.
705        assert_eq!(rfc3339_from_unix(1_709_164_800), "2024-02-29T00:00:00Z");
706    }
707    // -- #1292 / #1293 (RQ-68-REPRO) --------------------------------------
708
709    #[test]
710    fn source_date_epoch_pins_the_timestamp_1292() {
711        // 1700000000 = 2023-11-14T22:13:20Z.
712        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}