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//! Path convention: when the compiler emits `foo.elf`, the SBOM is written to
49//! `foo.cdx.json` next to it.
50
51use crate::wasm_decoder::{ImportEntry, ImportKind};
52use serde::Serialize;
53use sha2::{Digest, Sha256};
54use std::path::{Path, PathBuf};
55
56/// The CycloneDX spec version this module emits. rivet #107's `sbom-record`
57/// ingests 1.5; bump deliberately if the schema shape changes.
58const CYCLONEDX_SPEC_VERSION: &str = "1.5";
59
60/// A complete CycloneDX 1.5 SBOM document.
61///
62/// Field names use `#[serde(rename_all = "camelCase")]` to match the
63/// CycloneDX JSON schema (`bomFormat`, `specVersion`, `serialNumber`, ...).
64#[derive(Debug, Clone, Serialize)]
65#[serde(rename_all = "camelCase")]
66pub struct CycloneDxSbom {
67    /// Always the literal string `"CycloneDX"`.
68    pub bom_format: String,
69    /// CycloneDX spec version — `"1.5"`.
70    pub spec_version: String,
71    /// `urn:uuid:...` serial number, derived from the output ELF digest.
72    pub serial_number: String,
73    /// Document revision; `1` for a freshly emitted SBOM.
74    pub version: u32,
75    /// Build metadata: timestamp + the synth compiler tool entry.
76    pub metadata: SbomMetadata,
77    /// Components: the input WASM, the output ELF, and one per WASM import.
78    pub components: Vec<Component>,
79    /// Dependency graph linking the output ELF to its inputs.
80    pub dependencies: Vec<Dependency>,
81}
82
83/// CycloneDX `metadata` block.
84#[derive(Debug, Clone, Serialize)]
85#[serde(rename_all = "camelCase")]
86pub struct SbomMetadata {
87    /// ISO-8601 / RFC-3339 UTC timestamp of when the SBOM was emitted.
88    /// The one intentionally non-deterministic field.
89    pub timestamp: String,
90    /// The tool(s) that produced the described artifact — here, synth itself.
91    pub tools: Vec<Tool>,
92}
93
94/// CycloneDX `metadata.tools` entry — "what built it".
95#[derive(Debug, Clone, Serialize)]
96#[serde(rename_all = "camelCase")]
97pub struct Tool {
98    /// Tool vendor / publishing organisation.
99    pub vendor: String,
100    /// Tool name — `"synth"`.
101    pub name: String,
102    /// Tool version (`CARGO_PKG_VERSION` or a release tag).
103    pub version: String,
104}
105
106/// A CycloneDX `component`.
107#[derive(Debug, Clone, Serialize)]
108#[serde(rename_all = "camelCase")]
109pub struct Component {
110    /// CycloneDX component type (`application`, `library`, ...).
111    #[serde(rename = "type")]
112    pub component_type: String,
113    /// Stable reference used by the `dependencies` graph.
114    #[serde(rename = "bom-ref")]
115    pub bom_ref: String,
116    /// Human-readable component name.
117    pub name: String,
118    /// Component version (file size for binary artifacts, `"unknown"` for
119    /// imports whose version synth cannot know).
120    pub version: String,
121    /// Cryptographic hashes of the component, when synth has the bytes.
122    #[serde(skip_serializing_if = "Vec::is_empty")]
123    pub hashes: Vec<Hash>,
124    /// Free-form key/value properties — byte size, target triple, backend,
125    /// import kind, etc.
126    #[serde(skip_serializing_if = "Vec::is_empty")]
127    pub properties: Vec<Property>,
128}
129
130/// A CycloneDX `hashes` entry.
131#[derive(Debug, Clone, Serialize)]
132#[serde(rename_all = "camelCase")]
133pub struct Hash {
134    /// Hash algorithm — always `"SHA-256"` here.
135    pub alg: String,
136    /// Lower-case hex digest.
137    pub content: String,
138}
139
140/// A CycloneDX `properties` entry — a namespaced key/value pair.
141#[derive(Debug, Clone, Serialize)]
142#[serde(rename_all = "camelCase")]
143pub struct Property {
144    /// Property name, namespaced under `synth:`.
145    pub name: String,
146    /// Property value, always serialised as a string.
147    pub value: String,
148}
149
150/// A CycloneDX `dependencies` graph node.
151#[derive(Debug, Clone, Serialize)]
152#[serde(rename_all = "camelCase")]
153pub struct Dependency {
154    /// The `bom-ref` this node describes.
155    #[serde(rename = "ref")]
156    pub dep_ref: String,
157    /// `bom-ref`s this component depends on.
158    #[serde(rename = "dependsOn", skip_serializing_if = "Vec::is_empty")]
159    pub depends_on: Vec<String>,
160}
161
162/// Inputs needed to construct a build SBOM. Grouped into a struct so the
163/// constructor signature stays readable as the SBOM scope grows.
164#[derive(Debug, Clone)]
165pub struct SbomInputs<'a> {
166    /// `CARGO_PKG_VERSION` (or release tag) of the synth that compiled.
167    pub synth_version: &'a str,
168    /// Path of the input WASM/WAT file (used for the component name).
169    pub input_path: &'a Path,
170    /// Raw bytes of the input WASM module (post WAT→WASM, pre-compile).
171    pub input_bytes: &'a [u8],
172    /// Path of the emitted ELF binary.
173    pub output_path: &'a Path,
174    /// Raw bytes of the emitted ELF binary.
175    pub output_bytes: &'a [u8],
176    /// LLVM-style target triple of the output (e.g. `thumbv7em-none-eabi`).
177    pub target_triple: &'a str,
178    /// Name of the backend that produced the ELF (e.g. `arm`, `riscv`).
179    pub backend: &'a str,
180    /// Imports decoded from the WASM module — one component each.
181    pub imports: &'a [ImportEntry],
182}
183
184impl CycloneDxSbom {
185    /// Build a CycloneDX 1.5 SBOM describing one synth compilation.
186    ///
187    /// `timestamp` is the RFC-3339 UTC string for `metadata.timestamp`; the
188    /// caller supplies it so tests can pin a fixed value and production code
189    /// can pass [`now_rfc3339`].
190    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        // bom-refs. The output ELF is the root of the dependency graph.
198        let input_ref = format!("wasm:{input_name}");
199        let output_ref = format!("elf:{output_name}");
200
201        // The input WASM module component.
202        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        // The output ELF binary component.
224        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        // One component per WASM import. These are the closest synth can get
254        // to "what's linked into" the binary at compile time.
255        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        // Dependency graph: the ELF depends on the WASM module and on every
285        // import; the WASM module in turn depends on its imports.
286        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        // Imports are leaf nodes; declare them with no outgoing edges so the
301        // graph is complete (every bom-ref appears as a `ref`).
302        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            // Derive a stable urn:uuid from the output digest so the SBOM is
318            // reproducible for a given binary (CycloneDX requires the v4
319            // variant/version nibbles, which we stamp in).
320            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    /// Serialise to pretty-printed CycloneDX 1.5 JSON.
336    pub fn to_json(&self) -> String {
337        // `serde_json` preserves struct field declaration order, so the
338        // output is deterministic (the timestamp aside).
339        serde_json::to_string_pretty(self).expect("CycloneDxSbom is always serialisable")
340    }
341
342    /// Compute the SBOM path next to an output ELF: replaces the file
343    /// extension with `.cdx.json` (the conventional CycloneDX-JSON suffix).
344    /// Examples:
345    /// - `foo.elf` -> `foo.cdx.json`
346    /// - `out`     -> `out.cdx.json`
347    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
358/// Lower-case hex SHA-256 of a byte slice.
359fn 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
368/// Extract a display file name from a path, falling back to `"unknown"`.
369fn 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
375/// Map an [`ImportKind`] to a stable lower-case string for SBOM properties.
376fn 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
385/// Build a CycloneDX `urn:uuid:` serial number deterministically from a
386/// SHA-256 hex digest. The first 32 hex chars of the digest fill the UUID,
387/// with the version nibble forced to `4` and the variant nibble to `8` so the
388/// result is a well-formed RFC-4122 v4 UUID (CycloneDX requires this shape).
389fn 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
403/// RFC-3339 UTC timestamp for "now", e.g. `2026-05-21T10:30:00Z`.
404///
405/// Hand-rolled from `SystemTime` (civil-date conversion) to avoid pulling a
406/// date/time crate into `synth-core`, which is upstream of every backend.
407pub 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
416/// Convert a Unix timestamp (seconds) to an RFC-3339 UTC string.
417/// Uses the standard civil-from-days algorithm (Howard Hinnant).
418fn 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    // days since 1970-01-01 -> civil (year, month, day)
424    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    /// Fixed timestamp so the rest of the document is fully deterministic.
445    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        // 1 ELF + 1 WASM + 2 imports.
509        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        // Imports are represented as components.
523        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        // ELF root + WASM + 2 imports = 4 graph nodes.
556        assert_eq!(deps.len(), 4);
557
558        // The ELF node depends on the WASM module and every import.
559        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); // wasm + 2 imports
572    }
573
574    #[test]
575    fn serial_number_is_deterministic_for_same_output() {
576        // Two SBOMs over identical bytes share a serial number; only the
577        // timestamp may differ between emissions.
578        let a = sample_sbom();
579        let b = sample_sbom();
580        assert_eq!(a.serial_number, b.serial_number);
581        // Well-formed RFC-4122 v4 shape.
582        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        // Just the ELF + WASM components, two dependency nodes.
616        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        // 2026-05-21T10:30:00Z == 1779359400 unix seconds.
623        assert_eq!(rfc3339_from_unix(1_779_359_400), "2026-05-21T10:30:00Z");
624        // Epoch.
625        assert_eq!(rfc3339_from_unix(0), "1970-01-01T00:00:00Z");
626        // A leap-year date: 2024-02-29T00:00:00Z == 1709164800.
627        assert_eq!(rfc3339_from_unix(1_709_164_800), "2024-02-29T00:00:00Z");
628    }
629}