Skip to main content

ssg/plugins/
sbom.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Build-time SBOM generation (issue #457).
5//!
6//! Emits a `CycloneDX` 1.5 JSON Software Bill of Materials at the
7//! root of the generated site (`sbom.cdx.json`) and links to it
8//! from every HTML page via `<link rel="sbom" type="application/vnd.cyclonedx+json">`.
9//!
10//! # Why ship an SBOM with the static site?
11//!
12//! Procurement teams in regulated industries (finance, healthcare,
13//! government) increasingly require SBOMs for any deployed software
14//! — including the build pipeline that produced static assets. The
15//! `scheduled.yml` workflow already generates a `CycloneDX` SBOM via
16//! `cargo cyclonedx` and attaches a Sigstore provenance attestation,
17//! but those artifacts live in CI; they're not discoverable from
18//! the deployed site. This plugin fixes that gap by **embedding**
19//! the SBOM into every site, making the supply chain machine-
20//! introspectable from the consumer's browser.
21//!
22//! # Format
23//!
24//! Minimal `CycloneDX` 1.5 (the JSON Schema is documented at
25//! <https://cyclonedx.org/docs/1.5/json/>). The component list
26//! covers the SSG package itself; transitive Cargo dependencies
27//! are out of scope here (they're in the CI-generated SBOM
28//! published as a release artifact). The rendered SBOM declares:
29//!
30//! - `bomFormat`: "`CycloneDX`"
31//! - `specVersion`: "1.5"
32//! - `serialNumber`: `urn:uuid:…`, derived from the build (see below)
33//! - `version`: 1
34//! - `metadata.timestamp`: build time (ISO 8601, UTC)
35//! - `metadata.tools[]`: SSG name + version
36//! - `metadata.component`: the site itself (type: "application")
37//! - `components[]`: SSG generator
38//!
39//! # Discoverability
40//!
41//! Every HTML page emitted by the build receives a
42//! `<link rel="sbom" type="application/vnd.cyclonedx+json"
43//!  href="<base-url-path>/sbom.cdx.json">` element in `<head>`.
44//! This is the
45//! IANA-registered link relation for SBOM discovery (registered
46//! 2023; see <https://www.iana.org/assignments/link-relations/>).
47//!
48//! # Serial number
49//!
50//! `serialNumber` is optional in the `CycloneDX` schema, but consumers
51//! use it to tell the format apart from SPDX. GitHub's attestation
52//! action, for one, recognises `CycloneDX` only when `bomFormat`,
53//! `serialNumber` and `specVersion` are all present, and otherwise
54//! fails a release with "Unsupported SBOM format". An SBOM without it
55//! is therefore valid but not attestable.
56//!
57//! The value is derived from the build rather than drawn at random, so
58//! `SOURCE_DATE_EPOCH` pins it along with the timestamp and the
59//! determinism gate still holds. Two builds that differ only in wall
60//! clock still get different serials, which is what `CycloneDX` asks for.
61//!
62//! # Idempotency
63//!
64//! The HTML transform is idempotent — pages that already contain
65//! `rel="sbom"` are left unchanged. The JSON file is rewritten on
66//! every build (so timestamps stay current).
67
68use crate::error::{PathErrorExt, SsgError};
69use crate::plugin::{Plugin, PluginContext};
70use crate::util::head_dom::inject_before_head_close;
71use std::fs;
72use std::path::Path;
73
74/// Plugin that emits a `CycloneDX` SBOM and links to it from every
75/// HTML page.
76#[derive(Debug, Clone, Copy, Default)]
77pub struct SbomPlugin;
78
79impl SbomPlugin {
80    /// Returns the relative path of the SBOM file under `site_dir`.
81    ///
82    /// # Examples
83    ///
84    /// ```rust
85    /// use ssg::sbom::SbomPlugin;
86    ///
87    /// assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
88    /// ```
89    pub const fn sbom_path() -> &'static str {
90        "sbom.cdx.json"
91    }
92}
93
94impl Plugin for SbomPlugin {
95    fn name(&self) -> &'static str {
96        "sbom"
97    }
98
99    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
100        if !ctx.site_dir.exists() {
101            return Ok(());
102        }
103        let sbom = build_sbom();
104        let path = ctx.site_dir.join(Self::sbom_path());
105        let json = serialize_sbom(&sbom).map_err(|e| SsgError::Io {
106            path: path.clone(),
107            source: std::io::Error::other(e),
108        })?;
109        fs::write(&path, json).with_path(&path)?;
110        log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
111        Ok(())
112    }
113
114    fn has_transform(&self) -> bool {
115        true
116    }
117
118    fn transform_html(
119        &self,
120        html: &str,
121        _path: &Path,
122        ctx: &PluginContext,
123    ) -> Result<String, SsgError> {
124        // Idempotent: skip if an SBOM link is already present.
125        if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
126            return Ok(html.to_string());
127        }
128        // The SBOM sits at the *site* root, which is only the server root
129        // when `base_url` has no path. On a project site served from a
130        // sub-path, a bare `/sbom.cdx.json` points at the domain apex and
131        // 404s, so carry the prefix across.
132        let link = format!(
133            "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
134             href=\"{}/{}\">\n",
135            ctx.config.as_ref().map_or_else(String::new, |c| {
136                crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
137            }),
138            Self::sbom_path()
139        );
140        Ok(inject_before_head_close(html, &link))
141    }
142}
143
144/// Derives the SBOM's `serialNumber` from the inputs that identify the
145/// build.
146///
147/// Hashed rather than random so that `SOURCE_DATE_EPOCH` pins the serial
148/// exactly as it pins the timestamp; a random UUID would reintroduce the
149/// nondeterminism that gate exists to prevent. The digest is truncated to
150/// 128 bits and stamped with the RFC 9562 version-8 (custom) and variant
151/// bits, which is what that version is for. Using the existing `sha2`
152/// dependency keeps this free of a new crate, per `AGENTS.md`.
153fn sbom_serial_number(timestamp: &str, ssg_version: &str) -> String {
154    use sha2::{Digest as _, Sha256};
155
156    // Domain-separated and length-prefixed, matching `llm_cache`, so no two
157    // different field splits can hash to the same digest.
158    let mut hasher = Sha256::new();
159    hasher.update(b"ssg-sbom-serial-v1\x00");
160    hasher.update((timestamp.len() as u64).to_le_bytes());
161    hasher.update(timestamp.as_bytes());
162    hasher.update((ssg_version.len() as u64).to_le_bytes());
163    hasher.update(ssg_version.as_bytes());
164    let digest = hasher.finalize();
165
166    let mut bytes = [0u8; 16];
167    bytes.copy_from_slice(&digest[..16]);
168    bytes[6] = (bytes[6] & 0x0f) | 0x80; // version 8
169    bytes[8] = (bytes[8] & 0x3f) | 0x80; // RFC 9562 variant
170
171    let mut hex = String::with_capacity(32);
172    for b in bytes {
173        use std::fmt::Write as _;
174        let _ = write!(hex, "{b:02x}");
175    }
176    format!(
177        "urn:uuid:{}-{}-{}-{}-{}",
178        &hex[0..8],
179        &hex[8..12],
180        &hex[12..16],
181        &hex[16..20],
182        &hex[20..32]
183    )
184}
185
186/// Builds the minimal `CycloneDX` 1.5 SBOM document for this site.
187fn build_sbom() -> serde_json::Value {
188    let now = current_iso_timestamp();
189    let ssg_version = env!("CARGO_PKG_VERSION");
190    serde_json::json!({
191        "bomFormat": "CycloneDX",
192        "specVersion": "1.5",
193        "serialNumber": sbom_serial_number(&now, ssg_version),
194        "version": 1,
195        "metadata": {
196            "timestamp": now,
197            "tools": [{
198                "vendor": "SSG Contributors",
199                "name": "ssg",
200                "version": ssg_version,
201            }],
202            "component": {
203                "type": "application",
204                "bom-ref": "site",
205                "name": "static-site",
206                "description": "Site generated by SSG",
207            }
208        },
209        "components": [{
210            "type": "application",
211            "bom-ref": format!("ssg@{ssg_version}"),
212            "name": "ssg",
213            "version": ssg_version,
214            "description": "Static site generator",
215            "purl": format!("pkg:cargo/ssg@{ssg_version}"),
216            "licenses": [
217                {"license": {"id": "MIT"}},
218                {"license": {"id": "Apache-2.0"}}
219            ],
220            "externalReferences": [
221                {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
222                {"type": "documentation", "url": "https://docs.rs/ssg"}
223            ]
224        }]
225    })
226}
227
228/// Serialize the SBOM with a fault-injection hook so tests can drive
229/// the error branch (pretty-printing a `Value` built from hardcoded
230/// strings and numbers cannot fail in practice).
231fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
232    fail_point!("sbom::serialize", |_| Err(
233        <serde_json::Error as serde::ser::Error>::custom(
234            "injected: sbom::serialize"
235        )
236    ));
237    serde_json::to_string_pretty(sbom)
238}
239
240/// Cheap ISO 8601 timestamp without pulling in a date crate.
241/// Uses `std::time::SystemTime` and converts `UNIX_EPOCH` seconds to
242/// `YYYY-MM-DDTHH:MM:SSZ` via the proleptic Gregorian calendar.
243///
244/// Reproducible builds (SECURITY.md convention, determinism.yml CI
245/// gate): a wall-clock timestamp makes `sbom.cdx.json` differ across
246/// otherwise-identical builds, so `SOURCE_DATE_EPOCH` wins when set.
247/// It pins `serialNumber` too, since that is derived from this value.
248fn current_iso_timestamp() -> String {
249    use std::time::{SystemTime, UNIX_EPOCH};
250    if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
251        if let Ok(secs) = epoch.trim().parse::<u64>() {
252            return epoch_to_iso(secs);
253        }
254    }
255    let secs = SystemTime::now()
256        .duration_since(UNIX_EPOCH)
257        .map_or(0, |d| d.as_secs());
258    epoch_to_iso(secs)
259}
260
261/// Converts seconds since UNIX epoch to ISO 8601 `YYYY-MM-DDTHH:MM:SSZ`.
262fn epoch_to_iso(secs: u64) -> String {
263    // Days since 1970-01-01 + seconds within day.
264    let days = secs / 86_400;
265    let sec_in_day = secs % 86_400;
266    let hour = (sec_in_day / 3600) as u32;
267    let minute = ((sec_in_day % 3600) / 60) as u32;
268    let second = (sec_in_day % 60) as u32;
269
270    // Convert `days` to YYYY-MM-DD via proleptic Gregorian rules.
271    // Algorithm from Howard Hinnant's date library (public domain).
272    let z = days as i64 + 719_468;
273    let era = z.div_euclid(146_097);
274    let doe = (z - era * 146_097) as u64;
275    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
276    let y = (yoe as i64) + era * 400;
277    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
278    let mp = (5 * doy + 2) / 153;
279    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
280    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
281    let year = if month <= 2 { y + 1 } else { y };
282
283    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use crate::cmd::SsgConfig;
290    use std::path::Path;
291    use tempfile::tempdir;
292
293    #[test]
294    fn epoch_to_iso_handles_unix_epoch() {
295        assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
296    }
297
298    #[test]
299    fn epoch_to_iso_handles_known_timestamps() {
300        // 1700000000 = 2023-11-14 22:13:20 UTC
301        assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
302        // 1577836800 = 2020-01-01 00:00:00 UTC
303        assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
304    }
305
306    #[test]
307    #[serial_test::serial(source_date_epoch)]
308    fn current_iso_timestamp_honours_source_date_epoch() {
309        // determinism.yml gate: SOURCE_DATE_EPOCH must pin this SBOM's
310        // timestamp, which is the one that reaches sbom.cdx.json and, via
311        // sbom_serial_number, fixes the serial with it.
312        let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
313        std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
314        let pinned = current_iso_timestamp();
315        std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
316        let fallback = current_iso_timestamp();
317        match prev {
318            Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
319            None => std::env::remove_var("SOURCE_DATE_EPOCH"),
320        }
321        assert_eq!(pinned, "2023-11-14T22:13:20Z");
322        // Unparseable epoch falls back to wall clock — assert only the
323        // shape so the test never depends on today's date.
324        assert!(fallback.ends_with('Z') && fallback.len() == 20);
325    }
326
327    #[test]
328    fn build_sbom_includes_required_cyclonedx_fields() {
329        let sbom = build_sbom();
330        assert_eq!(sbom["bomFormat"], "CycloneDX");
331        assert_eq!(sbom["specVersion"], "1.5");
332        assert_eq!(sbom["version"], 1);
333        assert!(sbom["metadata"]["timestamp"].as_str().is_some());
334        assert!(sbom["metadata"]["tools"].as_array().is_some());
335        let components = sbom["components"].as_array().unwrap();
336        assert!(!components.is_empty(), "should not be empty");
337        // Every component must have a name and a purl.
338        for c in components {
339            assert!(c["name"].as_str().is_some());
340            assert!(c["purl"].as_str().is_some());
341        }
342    }
343
344    #[test]
345    fn build_sbom_is_attestable_as_cyclonedx() {
346        // GitHub's attestation action sniffs the format with
347        // `bomFormat && serialNumber && specVersion` and rejects anything
348        // else as "Unsupported SBOM format", which fails a release at the
349        // attestation step. Assert that exact triple, not just validity.
350        let sbom = build_sbom();
351        for field in ["bomFormat", "serialNumber", "specVersion"] {
352            let value = sbom[field].as_str();
353            assert!(
354                value.is_some_and(|v| !v.is_empty()),
355                "{field} must be a non-empty string for the SBOM to be attestable"
356            );
357        }
358    }
359
360    #[test]
361    fn sbom_serial_number_has_uuid_urn_shape() {
362        let serial = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
363        // Named for what it is — the URN's body. Calling it `uuid` trips
364        // CodeQL's rust/cleartext-logging heuristic, which treats a binding
365        // of that name as sensitive and these assertion messages as logging.
366        // The value is a derived SBOM serial, published verbatim in every
367        // generated sbom.cdx.json, so there is nothing to leak.
368        let body = serial
369            .strip_prefix("urn:uuid:")
370            .expect("serialNumber must be a UUID URN");
371
372        // CycloneDX constrains serialNumber to 8-4-4-4-12 lowercase hex.
373        let groups: Vec<&str> = body.split('-').collect();
374        assert_eq!(groups.len(), 5, "expected 8-4-4-4-12, got {body}");
375        assert_eq!(
376            groups.iter().map(|g| g.len()).collect::<Vec<_>>(),
377            vec![8, 4, 4, 4, 12]
378        );
379        assert!(
380            groups.iter().all(|g| g
381                .chars()
382                .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))),
383            "UUID groups must be lowercase hex: {body}"
384        );
385
386        // RFC 9562: version nibble 8, variant bits 10xx.
387        assert_eq!(
388            groups[2].as_bytes()[0],
389            b'8',
390            "expected version 8 in {body}"
391        );
392        assert!(
393            matches!(groups[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'),
394            "expected RFC 9562 variant in {body}"
395        );
396    }
397
398    #[test]
399    fn sbom_serial_number_is_derived_not_random() {
400        // Derived, so SOURCE_DATE_EPOCH pins it along with the timestamp.
401        // A random UUID would pass the shape test above while silently
402        // breaking the determinism gate, so pin the value itself.
403        let a = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
404        let b = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
405        assert_eq!(a, b, "same inputs must produce the same serial");
406
407        // Different builds still get different serials, as CycloneDX asks.
408        let later = sbom_serial_number("2023-11-14T22:13:21Z", "0.0.63");
409        assert_ne!(a, later, "a different timestamp must change the serial");
410        let other_version =
411            sbom_serial_number("2023-11-14T22:13:20Z", "0.0.64");
412        assert_ne!(
413            a, other_version,
414            "a different ssg version must change the serial"
415        );
416    }
417
418    #[test]
419    #[serial_test::serial(source_date_epoch)]
420    fn build_sbom_serial_is_pinned_by_source_date_epoch() {
421        // determinism.yml gate: with the epoch pinned, two builds must
422        // agree on the serial as well as the timestamp.
423        let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
424        std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
425        let first = build_sbom();
426        let second = build_sbom();
427        match prev {
428            Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
429            None => std::env::remove_var("SOURCE_DATE_EPOCH"),
430        }
431        // Assert presence first: two absent fields compare equal, so without
432        // this the test would still pass if the serial were dropped entirely.
433        assert!(first["serialNumber"]
434            .as_str()
435            .is_some_and(|s| !s.is_empty()));
436        assert_eq!(first["serialNumber"], second["serialNumber"]);
437        assert_eq!(
438            first["metadata"]["timestamp"],
439            second["metadata"]["timestamp"]
440        );
441    }
442
443    #[test]
444    #[serial_test::parallel]
445    fn sbom_plugin_writes_file_after_compile() {
446        let dir = tempdir().unwrap();
447        let site = dir.path().join("site");
448        fs::create_dir_all(&site).unwrap();
449        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
450        SbomPlugin.after_compile(&ctx).unwrap();
451        let sbom_file = site.join(SbomPlugin::sbom_path());
452        assert!(sbom_file.exists());
453        let body = fs::read_to_string(&sbom_file).unwrap();
454        assert!(body.contains("\"CycloneDX\""));
455        assert!(body.contains("\"specVersion\": \"1.5\""));
456    }
457
458    /// The SBOM sits at the site root, which is only the server root when
459    /// `base_url` has no path. On a project site a bare `/sbom.cdx.json`
460    /// points at the domain apex and 404s on every page that carries it.
461    #[test]
462    fn sbom_link_carries_the_base_url_path_prefix() {
463        let dir = tempdir().unwrap();
464        let config = SsgConfig::builder()
465            .base_url("https://example.com/ssg-themes.github.io/apex".into())
466            .build()
467            .unwrap();
468        let ctx = PluginContext::with_config(
469            dir.path(),
470            dir.path(),
471            dir.path(),
472            dir.path(),
473            config,
474        );
475        let out = SbomPlugin
476            .transform_html(
477                "<html><head></head><body></body></html>",
478                Path::new("x.html"),
479                &ctx,
480            )
481            .unwrap();
482        assert!(
483            out.contains(r#"href="/ssg-themes.github.io/apex/sbom.cdx.json""#),
484            "{out}"
485        );
486    }
487
488    #[test]
489    fn sbom_plugin_injects_link_into_head() {
490        let dir = tempdir().unwrap();
491        let ctx =
492            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
493        let html = "<html><head><title>x</title></head><body></body></html>";
494        let out = SbomPlugin
495            .transform_html(html, Path::new("x.html"), &ctx)
496            .unwrap();
497        assert!(out.contains("rel=\"sbom\""));
498        assert!(out.contains("application/vnd.cyclonedx+json"));
499        assert!(out.contains("href=\"/sbom.cdx.json\""));
500    }
501
502    #[test]
503    fn sbom_plugin_is_idempotent() {
504        let dir = tempdir().unwrap();
505        let ctx =
506            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
507        let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
508        let out = SbomPlugin
509            .transform_html(html, Path::new("x.html"), &ctx)
510            .unwrap();
511        assert_eq!(out, html);
512    }
513
514    #[test]
515    fn sbom_plugin_is_idempotent_with_single_quoted_attribute() {
516        // Covers the `rel='sbom'` disjunct of the idempotency check —
517        // every other test only exercises the double-quoted form.
518        let dir = tempdir().unwrap();
519        let ctx =
520            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
521        let html = r"<html><head><link rel='sbom' type='application/vnd.cyclonedx+json' href='/sbom.cdx.json'></head><body></body></html>";
522        let out = SbomPlugin
523            .transform_html(html, Path::new("x.html"), &ctx)
524            .unwrap();
525        assert_eq!(out, html);
526    }
527
528    #[test]
529    fn sbom_plugin_skips_pages_without_head_tag() {
530        let dir = tempdir().unwrap();
531        let ctx =
532            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
533        let html = "<p>orphan content with no head</p>";
534        let out = SbomPlugin
535            .transform_html(html, Path::new("x.html"), &ctx)
536            .unwrap();
537        assert_eq!(out, html);
538    }
539
540    #[test]
541    #[serial_test::parallel]
542    fn sbom_plugin_after_compile_noop_when_site_missing() {
543        let dir = tempdir().unwrap();
544        let missing = dir.path().join("missing");
545        let ctx =
546            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
547        SbomPlugin.after_compile(&ctx).unwrap();
548        assert!(!missing.exists());
549    }
550
551    #[test]
552    fn sbom_path_constant() {
553        assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
554    }
555
556    #[test]
557    #[serial_test::parallel]
558    fn after_compile_write_failure_returns_io_error() {
559        let dir = tempdir().unwrap();
560        let site = dir.path().join("site");
561        fs::create_dir_all(&site).unwrap();
562
563        // Create a directory where the SBOM is expected to be written, causing fs::write to fail.
564        let sbom_dir = site.join(SbomPlugin::sbom_path());
565        fs::create_dir(&sbom_dir).unwrap();
566
567        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
568        let res = SbomPlugin.after_compile(&ctx);
569        assert!(res.is_err());
570        let err = res.unwrap_err();
571        assert!(
572            matches!(err, SsgError::Io { ref path, .. } if path == &sbom_dir)
573        );
574    }
575}
576
577#[cfg(all(test, feature = "test-fault-injection"))]
578mod fault_tests {
579    use super::*;
580    use crate::plugin::PluginContext;
581    use serial_test::serial;
582    use tempfile::tempdir;
583
584    /// RAII guard that disables a failpoint on drop.
585    struct FailGuard(&'static str);
586
587    impl Drop for FailGuard {
588        fn drop(&mut self) {
589            let _ = fail::cfg(self.0, "off");
590        }
591    }
592
593    #[test]
594    #[serial]
595    fn after_compile_maps_serialize_failure_to_io_error() {
596        // `serde_json::to_string_pretty` on the hardcoded `build_sbom()`
597        // literal cannot fail in practice, so the only way to exercise
598        // `after_compile`'s serialize-error branch is fault injection.
599        let _guard = FailGuard("sbom::serialize");
600        fail::cfg("sbom::serialize", "return").expect("activate failpoint");
601
602        let dir = tempdir().unwrap();
603        let site = dir.path().join("site");
604        fs::create_dir_all(&site).unwrap();
605        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
606
607        let err = SbomPlugin
608            .after_compile(&ctx)
609            .expect_err("injected serialize failure must propagate");
610        let msg = format!("{err}");
611        assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
612        assert!(msg.contains("injected: sbom::serialize"), "got: {msg}");
613    }
614}