Skip to main content

ssg/
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//! - `version`: 1
33//! - `metadata.timestamp`: build time (ISO 8601, UTC)
34//! - `metadata.tools[]`: SSG name + version
35//! - `metadata.component`: the site itself (type: "application")
36//! - `components[]`: SSG generator
37//!
38//! # Discoverability
39//!
40//! Every HTML page emitted by the build receives a
41//! `<link rel="sbom" type="application/vnd.cyclonedx+json"
42//!  href="/sbom.cdx.json">` element in `<head>`. This is the
43//! IANA-registered link relation for SBOM discovery (registered
44//! 2023; see <https://www.iana.org/assignments/link-relations/>).
45//!
46//! # Idempotency
47//!
48//! The HTML transform is idempotent — pages that already contain
49//! `rel="sbom"` are left unchanged. The JSON file is rewritten on
50//! every build (so timestamps stay current).
51
52use crate::plugin::{Plugin, PluginContext};
53use anyhow::Result;
54use std::fs;
55use std::path::Path;
56
57/// Plugin that emits a `CycloneDX` SBOM and links to it from every
58/// HTML page.
59#[derive(Debug, Clone, Copy, Default)]
60pub struct SbomPlugin;
61
62impl SbomPlugin {
63    /// Returns the relative path of the SBOM file under `site_dir`.
64    pub const fn sbom_path() -> &'static str {
65        "sbom.cdx.json"
66    }
67}
68
69impl Plugin for SbomPlugin {
70    fn name(&self) -> &'static str {
71        "sbom"
72    }
73
74    fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
75        if !ctx.site_dir.exists() {
76            return Ok(());
77        }
78        let sbom = build_sbom();
79        let path = ctx.site_dir.join(Self::sbom_path());
80        fs::write(&path, serde_json::to_string_pretty(&sbom)?)?;
81        log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
82        Ok(())
83    }
84
85    fn has_transform(&self) -> bool {
86        true
87    }
88
89    fn transform_html(
90        &self,
91        html: &str,
92        _path: &Path,
93        _ctx: &PluginContext,
94    ) -> Result<String> {
95        // Idempotent: skip if an SBOM link is already present.
96        if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
97            return Ok(html.to_string());
98        }
99        let Some(head_close) = html.find("</head>") else {
100            return Ok(html.to_string());
101        };
102        let link = format!(
103            "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
104             href=\"/{}\">\n",
105            Self::sbom_path()
106        );
107        let mut out = String::with_capacity(html.len() + link.len());
108        out.push_str(&html[..head_close]);
109        out.push_str(&link);
110        out.push_str(&html[head_close..]);
111        Ok(out)
112    }
113}
114
115/// Builds the minimal `CycloneDX` 1.5 SBOM document for this site.
116fn build_sbom() -> serde_json::Value {
117    let now = current_iso_timestamp();
118    let ssg_version = env!("CARGO_PKG_VERSION");
119    serde_json::json!({
120        "bomFormat": "CycloneDX",
121        "specVersion": "1.5",
122        "version": 1,
123        "metadata": {
124            "timestamp": now,
125            "tools": [{
126                "vendor": "SSG Contributors",
127                "name": "ssg",
128                "version": ssg_version,
129            }],
130            "component": {
131                "type": "application",
132                "bom-ref": "site",
133                "name": "static-site",
134                "description": "Site generated by SSG",
135            }
136        },
137        "components": [{
138            "type": "application",
139            "bom-ref": format!("ssg@{ssg_version}"),
140            "name": "ssg",
141            "version": ssg_version,
142            "description": "Static site generator",
143            "purl": format!("pkg:cargo/ssg@{ssg_version}"),
144            "licenses": [
145                {"license": {"id": "MIT"}},
146                {"license": {"id": "Apache-2.0"}}
147            ],
148            "externalReferences": [
149                {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
150                {"type": "documentation", "url": "https://docs.rs/ssg"}
151            ]
152        }]
153    })
154}
155
156/// Cheap ISO 8601 timestamp without pulling in a date crate.
157/// Uses `std::time::SystemTime` and converts `UNIX_EPOCH` seconds to
158/// `YYYY-MM-DDTHH:MM:SSZ` via the proleptic Gregorian calendar.
159fn current_iso_timestamp() -> String {
160    use std::time::{SystemTime, UNIX_EPOCH};
161    let secs = SystemTime::now()
162        .duration_since(UNIX_EPOCH)
163        .map_or(0, |d| d.as_secs());
164    epoch_to_iso(secs)
165}
166
167/// Converts seconds since UNIX epoch to ISO 8601 `YYYY-MM-DDTHH:MM:SSZ`.
168fn epoch_to_iso(secs: u64) -> String {
169    // Days since 1970-01-01 + seconds within day.
170    let days = secs / 86_400;
171    let sec_in_day = secs % 86_400;
172    let hour = (sec_in_day / 3600) as u32;
173    let minute = ((sec_in_day % 3600) / 60) as u32;
174    let second = (sec_in_day % 60) as u32;
175
176    // Convert `days` to YYYY-MM-DD via proleptic Gregorian rules.
177    // Algorithm from Howard Hinnant's date library (public domain).
178    let z = days as i64 + 719_468;
179    let era = z.div_euclid(146_097);
180    let doe = (z - era * 146_097) as u64;
181    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
182    let y = (yoe as i64) + era * 400;
183    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
184    let mp = (5 * doy + 2) / 153;
185    let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
186    let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
187    let year = if month <= 2 { y + 1 } else { y };
188
189    format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use std::path::Path;
196    use tempfile::tempdir;
197
198    #[test]
199    fn epoch_to_iso_handles_unix_epoch() {
200        assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
201    }
202
203    #[test]
204    fn epoch_to_iso_handles_known_timestamps() {
205        // 1700000000 = 2023-11-14 22:13:20 UTC
206        assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
207        // 1577836800 = 2020-01-01 00:00:00 UTC
208        assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
209    }
210
211    #[test]
212    fn build_sbom_includes_required_cyclonedx_fields() {
213        let sbom = build_sbom();
214        assert_eq!(sbom["bomFormat"], "CycloneDX");
215        assert_eq!(sbom["specVersion"], "1.5");
216        assert_eq!(sbom["version"], 1);
217        assert!(sbom["metadata"]["timestamp"].as_str().is_some());
218        assert!(sbom["metadata"]["tools"].as_array().is_some());
219        let components = sbom["components"].as_array().unwrap();
220        assert!(!components.is_empty());
221        // Every component must have a name and a purl.
222        for c in components {
223            assert!(c["name"].as_str().is_some());
224            assert!(c["purl"].as_str().is_some());
225        }
226    }
227
228    #[test]
229    fn sbom_plugin_writes_file_after_compile() {
230        let dir = tempdir().unwrap();
231        let site = dir.path().join("site");
232        fs::create_dir_all(&site).unwrap();
233        let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
234        SbomPlugin.after_compile(&ctx).unwrap();
235        let sbom_file = site.join(SbomPlugin::sbom_path());
236        assert!(sbom_file.exists());
237        let body = fs::read_to_string(&sbom_file).unwrap();
238        assert!(body.contains("\"CycloneDX\""));
239        assert!(body.contains("\"specVersion\": \"1.5\""));
240    }
241
242    #[test]
243    fn sbom_plugin_injects_link_into_head() {
244        let dir = tempdir().unwrap();
245        let ctx =
246            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
247        let html = "<html><head><title>x</title></head><body></body></html>";
248        let out = SbomPlugin
249            .transform_html(html, Path::new("x.html"), &ctx)
250            .unwrap();
251        assert!(out.contains("rel=\"sbom\""));
252        assert!(out.contains("application/vnd.cyclonedx+json"));
253        assert!(out.contains("href=\"/sbom.cdx.json\""));
254    }
255
256    #[test]
257    fn sbom_plugin_is_idempotent() {
258        let dir = tempdir().unwrap();
259        let ctx =
260            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
261        let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
262        let out = SbomPlugin
263            .transform_html(html, Path::new("x.html"), &ctx)
264            .unwrap();
265        assert_eq!(out, html);
266    }
267
268    #[test]
269    fn sbom_plugin_skips_pages_without_head_tag() {
270        let dir = tempdir().unwrap();
271        let ctx =
272            PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
273        let html = "<p>orphan content with no head</p>";
274        let out = SbomPlugin
275            .transform_html(html, Path::new("x.html"), &ctx)
276            .unwrap();
277        assert_eq!(out, html);
278    }
279
280    #[test]
281    fn sbom_plugin_after_compile_noop_when_site_missing() {
282        let dir = tempdir().unwrap();
283        let missing = dir.path().join("missing");
284        let ctx =
285            PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
286        SbomPlugin.after_compile(&ctx).unwrap();
287        assert!(!missing.exists());
288    }
289
290    #[test]
291    fn sbom_path_constant() {
292        assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
293    }
294}