Skip to main content

ssg/plugins/postprocess/
sbom.rs

1// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! SBOM (Software Bill of Materials) generation plugin in `CycloneDX` v1.5 format.
5
6use crate::error::{PathErrorExt, SsgError};
7use crate::plugin::{Plugin, PluginContext};
8use serde_json::json;
9use std::fs;
10
11/// A post-processing plugin that generates a `CycloneDX` v1.5 SBOM (`sbom.cdx.json`)
12/// for the built website.
13///
14/// Superseded by [`crate::sbom::SbomPlugin`], which writes the same file and
15/// additionally links it from every document head. Both were registered, and
16/// because this one runs first its output was overwritten on every build — the
17/// dependency tree was serialised twice and one copy discarded. It is no longer
18/// registered by the default pipeline and will be removed in a later release.
19#[deprecated(
20    since = "0.0.58",
21    note = "use `ssg::sbom::SbomPlugin`; this wrote the same file and was overwritten"
22)]
23#[derive(Debug, Clone, Copy, Default)]
24pub struct SbomPlugin;
25
26#[allow(deprecated)]
27impl Plugin for SbomPlugin {
28    fn name(&self) -> &'static str {
29        "sbom-generator"
30    }
31
32    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
33        if !ctx.site_dir.exists() {
34            return Ok(());
35        }
36
37        let version = env!("CARGO_PKG_VERSION");
38        let timestamp = current_timestamp();
39
40        let sbom = json!({
41            "bomFormat": "CycloneDX",
42            "specVersion": "1.5",
43            "version": 1,
44            "metadata": {
45                "timestamp": timestamp,
46                "tools": [
47                    {
48                        "vendor": "SSG Contributors",
49                        "name": "ssg",
50                        "version": version
51                    }
52                ],
53                "component": {
54                    "bom-ref": "site",
55                    "type": "application",
56                    "name": "static-site",
57                    "description": "Site generated by SSG"
58                }
59            },
60            "components": [
61                {
62                    "bom-ref": format!("ssg@{version}"),
63                    "type": "application",
64                    "name": "ssg",
65                    "version": version,
66                    "description": "Static site generator",
67                    "purl": format!("pkg:cargo/ssg@{version}"),
68                    "externalReferences": [
69                        {
70                            "type": "vcs",
71                            "url": "https://github.com/sebastienrousseau/static-site-generator"
72                        },
73                        {
74                            "type": "documentation",
75                            "url": "https://docs.rs/ssg"
76                        }
77                    ],
78                    "licenses": [
79                        {
80                            "license": {
81                                "id": "MIT"
82                            }
83                        },
84                        {
85                            "license": {
86                                "id": "Apache-2.0"
87                            }
88                        }
89                    ]
90                }
91            ]
92        });
93
94        let sbom_path = ctx.site_dir.join("sbom.cdx.json");
95        let content =
96            serialize_sbom(&sbom).map_err(|e| SsgError::io(e, &sbom_path))?;
97        fs::write(&sbom_path, content).with_path(&sbom_path)?;
98
99        Ok(())
100    }
101}
102
103/// Serialize the SBOM with a fault-injection hook so tests can drive
104/// the error branch (pretty-printing a `Value` cannot fail in
105/// practice).
106fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
107    fail_point!("postprocess::sbom-serialize", |_| Err(
108        <serde_json::Error as serde::ser::Error>::custom(
109            "injected: postprocess::sbom-serialize"
110        )
111    ));
112    serde_json::to_string_pretty(sbom)
113}
114
115fn current_timestamp() -> String {
116    // Reproducible builds (SECURITY.md convention, determinism.yml CI
117    // gate): a wall-clock timestamp makes sbom.cdx.json differ across
118    // otherwise-identical builds, so `SOURCE_DATE_EPOCH` wins when set.
119    if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
120        if let Ok(secs) = epoch.trim().parse::<u64>() {
121            return timestamp_from_secs(secs);
122        }
123    }
124    let now = std::time::SystemTime::now();
125    let duration = now
126        .duration_since(std::time::UNIX_EPOCH)
127        .unwrap_or_default();
128    timestamp_from_secs(duration.as_secs())
129}
130
131/// Formats a Unix-epoch second count as `YYYY-MM-DDThh:mm:ssZ`.
132/// Split from [`current_timestamp`] so the leap-year arithmetic is
133/// testable with fixed inputs rather than depending on today's date.
134fn timestamp_from_secs(secs: u64) -> String {
135    let days_since_epoch = secs / 86400;
136    let seconds_of_day = secs % 86400;
137
138    let hours = seconds_of_day / 3600;
139    let minutes = (seconds_of_day % 3600) / 60;
140    let seconds = seconds_of_day % 60;
141
142    let mut year = 1970;
143    let mut days = days_since_epoch;
144
145    loop {
146        let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
147        let days_in_year = if is_leap { 366 } else { 365 };
148        if days >= days_in_year {
149            days -= days_in_year;
150            year += 1;
151        } else {
152            break;
153        }
154    }
155
156    let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
157    let month_days = if is_leap {
158        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
159    } else {
160        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
161    };
162
163    let mut month = 1;
164    for &d in &month_days {
165        if days >= d {
166            days -= d;
167            month += 1;
168        } else {
169            break;
170        }
171    }
172    let day = days + 1;
173
174    format!(
175        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
176        year, month, day, hours, minutes, seconds
177    )
178}
179
180#[cfg(test)]
181mod tests {
182    // These tests exercise the deprecated plugin deliberately: it is
183    // still shipped for one release, and this is what keeps it working
184    // until removal.
185    #![allow(deprecated)]
186    use super::*;
187    use anyhow::Result;
188    use tempfile::tempdir;
189
190    fn test_ctx(dir: &std::path::Path) -> PluginContext {
191        PluginContext::new(dir, dir, dir, dir)
192    }
193
194    #[test]
195    fn test_sbom_plugin_name() {
196        assert_eq!(SbomPlugin.name(), "sbom-generator");
197    }
198
199    #[test]
200    #[serial_test::serial(source_date_epoch)]
201    fn current_timestamp_honours_source_date_epoch() {
202        // determinism.yml gate: SOURCE_DATE_EPOCH must pin the SBOM
203        // timestamp so double builds hash identically.
204        let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
205        std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
206        let pinned = current_timestamp();
207        std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
208        let fallback = current_timestamp();
209        match prev {
210            Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
211            None => std::env::remove_var("SOURCE_DATE_EPOCH"),
212        }
213        assert_eq!(pinned, "2023-11-14T22:13:20Z");
214        // Unparseable epoch falls back to wall clock — assert only the
215        // shape so the test never depends on today's date.
216        assert!(fallback.ends_with('Z') && fallback.len() == 20);
217    }
218
219    #[test]
220    #[serial_test::parallel]
221    fn test_sbom_plugin_generates_valid_cyclonedx_sbom() -> Result<()> {
222        let tmp = tempdir().unwrap();
223        let ctx = test_ctx(tmp.path());
224        SbomPlugin.after_compile(&ctx).unwrap();
225
226        let sbom_path = tmp.path().join("sbom.cdx.json");
227        assert!(sbom_path.exists());
228
229        let content = fs::read_to_string(&sbom_path).unwrap();
230        let sbom: serde_json::Value = serde_json::from_str(&content).unwrap();
231
232        assert_eq!(sbom["bomFormat"], "CycloneDX");
233        assert_eq!(sbom["specVersion"], "1.5");
234        assert_eq!(sbom["metadata"]["component"]["name"], "static-site");
235        assert!(sbom["components"].as_array().is_some());
236
237        let components = sbom["components"].as_array().unwrap();
238        assert!(!components.is_empty());
239        assert_eq!(components[0]["name"], "ssg");
240
241        Ok(())
242    }
243
244    #[test]
245    #[serial_test::parallel]
246    fn test_sbom_plugin_nonexistent_site_dir() -> Result<()> {
247        let tmp = tempdir().unwrap();
248        let non_existent = tmp.path().join("non_existent_dir");
249        let ctx = test_ctx(&non_existent);
250        SbomPlugin.after_compile(&ctx).unwrap();
251        let sbom_path = non_existent.join("sbom.cdx.json");
252        assert!(!sbom_path.exists());
253        Ok(())
254    }
255
256    #[test]
257    fn test_current_timestamp_format() {
258        let ts = current_timestamp();
259        assert!(ts.contains('T'));
260        assert!(ts.ends_with('Z'));
261        assert_eq!(ts.len(), 20); // YYYY-MM-DDThh:mm:ssZ is exactly 20 chars
262    }
263
264    // -----------------------------------------------------------------
265    // timestamp_from_secs: deterministic leap-year arithmetic
266    // -----------------------------------------------------------------
267
268    #[test]
269    fn test_timestamp_from_secs_epoch_start() {
270        assert_eq!(timestamp_from_secs(0), "1970-01-01T00:00:00Z");
271    }
272
273    #[test]
274    fn test_timestamp_from_secs_leap_day() {
275        // 2024-02-29T12:24:56Z — exercises the leap-year month table.
276        assert_eq!(timestamp_from_secs(1_709_209_496), "2024-02-29T12:24:56Z");
277    }
278
279    #[test]
280    fn test_timestamp_from_secs_year_2000_century_leap() {
281        // 2000 is divisible by 400 → leap; 2000-03-01 lands after the
282        // 29-day February.
283        assert_eq!(timestamp_from_secs(951_868_800), "2000-03-01T00:00:00Z");
284    }
285
286    #[test]
287    fn test_timestamp_from_secs_non_leap_century() {
288        // A plain non-leap year: 2026-07-01.
289        assert_eq!(timestamp_from_secs(1_782_864_000), "2026-07-01T00:00:00Z");
290    }
291
292    // -----------------------------------------------------------------
293    // Error path: sbom.cdx.json exists as a directory
294    // -----------------------------------------------------------------
295
296    #[test]
297    #[serial_test::parallel]
298    fn test_after_compile_errors_when_sbom_path_is_a_directory() {
299        let tmp = tempdir().unwrap();
300        fs::create_dir_all(tmp.path().join("sbom.cdx.json")).unwrap();
301        let ctx = test_ctx(tmp.path());
302        let err = SbomPlugin.after_compile(&ctx).unwrap_err();
303        assert!(format!("{err}").contains("sbom.cdx.json"));
304    }
305}
306
307#[cfg(all(test, feature = "test-fault-injection"))]
308mod fault_tests {
309    // Same rationale as the module above: the deprecated plugin is still
310    // shipped for one release and these tests keep it honest.
311    #![allow(deprecated)]
312    use super::*;
313    use serial_test::serial;
314    use tempfile::tempdir;
315
316    /// RAII guard that disables a failpoint on drop.
317    struct FailGuard(&'static str);
318
319    impl Drop for FailGuard {
320        fn drop(&mut self) {
321            let _ = fail::cfg(self.0, "off");
322        }
323    }
324
325    #[test]
326    #[serial]
327    fn after_compile_maps_serialize_failure_to_io_error() {
328        let _guard = FailGuard("postprocess::sbom-serialize");
329        fail::cfg("postprocess::sbom-serialize", "return")
330            .expect("activate failpoint");
331
332        let tmp = tempdir().unwrap();
333        let ctx =
334            PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
335        let err = SbomPlugin
336            .after_compile(&ctx)
337            .expect_err("injected serialize failure must propagate");
338        let msg = format!("{err}");
339        assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
340        assert!(
341            msg.contains("injected: postprocess::sbom-serialize"),
342            "got: {msg}"
343        );
344    }
345}