ssg 0.0.47

A secure-by-default static site generator built in Rust. WCAG 2.2 AA validation, CSP/SRI hardening, native JS/CSS minification, automated CycloneDX SBOM, local LLM content pipeline, WebAssembly target, interactive islands, streaming compilation for 100K+ pages, 28-locale i18n, and one-command deployment.
Documentation
// Copyright © 2023 - 2026 Static Site Generator (SSG). All rights reserved.
// SPDX-License-Identifier: Apache-2.0 OR MIT

//! SBOM (Software Bill of Materials) generation plugin in `CycloneDX` v1.5 format.

use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use serde_json::json;
use std::fs;

/// A post-processing plugin that generates a `CycloneDX` v1.5 SBOM (`sbom.cdx.json`)
/// for the built website.
#[derive(Debug, Clone, Copy, Default)]
pub struct SbomPlugin;

impl Plugin for SbomPlugin {
    fn name(&self) -> &'static str {
        "sbom-generator"
    }

    fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
        if !ctx.site_dir.exists() {
            return Ok(());
        }

        let version = env!("CARGO_PKG_VERSION");
        let timestamp = current_timestamp();

        let sbom = json!({
            "bomFormat": "CycloneDX",
            "specVersion": "1.5",
            "version": 1,
            "metadata": {
                "timestamp": timestamp,
                "tools": [
                    {
                        "vendor": "SSG Contributors",
                        "name": "ssg",
                        "version": version
                    }
                ],
                "component": {
                    "bom-ref": "site",
                    "type": "application",
                    "name": "static-site",
                    "description": "Site generated by SSG"
                }
            },
            "components": [
                {
                    "bom-ref": format!("ssg@{version}"),
                    "type": "application",
                    "name": "ssg",
                    "version": version,
                    "description": "Static site generator",
                    "purl": format!("pkg:cargo/ssg@{version}"),
                    "externalReferences": [
                        {
                            "type": "vcs",
                            "url": "https://github.com/sebastienrousseau/static-site-generator"
                        },
                        {
                            "type": "documentation",
                            "url": "https://docs.rs/ssg"
                        }
                    ],
                    "licenses": [
                        {
                            "license": {
                                "id": "MIT"
                            }
                        },
                        {
                            "license": {
                                "id": "Apache-2.0"
                            }
                        }
                    ]
                }
            ]
        });

        let sbom_path = ctx.site_dir.join("sbom.cdx.json");
        let content =
            serialize_sbom(&sbom).map_err(|e| SsgError::io(e, &sbom_path))?;
        fs::write(&sbom_path, content).with_path(&sbom_path)?;

        Ok(())
    }
}

/// Serialize the SBOM with a fault-injection hook so tests can drive
/// the error branch (pretty-printing a `Value` cannot fail in
/// practice).
fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
    fail_point!("postprocess::sbom-serialize", |_| Err(
        <serde_json::Error as serde::ser::Error>::custom(
            "injected: postprocess::sbom-serialize"
        )
    ));
    serde_json::to_string_pretty(sbom)
}

fn current_timestamp() -> String {
    // Reproducible builds (SECURITY.md convention, determinism.yml CI
    // gate): a wall-clock timestamp makes sbom.cdx.json differ across
    // otherwise-identical builds, so `SOURCE_DATE_EPOCH` wins when set.
    if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
        if let Ok(secs) = epoch.trim().parse::<u64>() {
            return timestamp_from_secs(secs);
        }
    }
    let now = std::time::SystemTime::now();
    let duration = now
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    timestamp_from_secs(duration.as_secs())
}

/// Formats a Unix-epoch second count as `YYYY-MM-DDThh:mm:ssZ`.
/// Split from [`current_timestamp`] so the leap-year arithmetic is
/// testable with fixed inputs rather than depending on today's date.
fn timestamp_from_secs(secs: u64) -> String {
    let days_since_epoch = secs / 86400;
    let seconds_of_day = secs % 86400;

    let hours = seconds_of_day / 3600;
    let minutes = (seconds_of_day % 3600) / 60;
    let seconds = seconds_of_day % 60;

    let mut year = 1970;
    let mut days = days_since_epoch;

    loop {
        let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
        let days_in_year = if is_leap { 366 } else { 365 };
        if days >= days_in_year {
            days -= days_in_year;
            year += 1;
        } else {
            break;
        }
    }

    let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
    let month_days = if is_leap {
        [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    } else {
        [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
    };

    let mut month = 1;
    for &d in &month_days {
        if days >= d {
            days -= d;
            month += 1;
        } else {
            break;
        }
    }
    let day = days + 1;

    format!(
        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
        year, month, day, hours, minutes, seconds
    )
}

#[cfg(test)]
mod tests {
    use super::*;
    use anyhow::Result;
    use tempfile::tempdir;

    fn test_ctx(dir: &std::path::Path) -> PluginContext {
        PluginContext::new(dir, dir, dir, dir)
    }

    #[test]
    fn test_sbom_plugin_name() {
        assert_eq!(SbomPlugin.name(), "sbom-generator");
    }

    #[test]
    #[serial_test::serial(source_date_epoch)]
    fn current_timestamp_honours_source_date_epoch() {
        // determinism.yml gate: SOURCE_DATE_EPOCH must pin the SBOM
        // timestamp so double builds hash identically.
        let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
        std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
        let pinned = current_timestamp();
        std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
        let fallback = current_timestamp();
        match prev {
            Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
            None => std::env::remove_var("SOURCE_DATE_EPOCH"),
        }
        assert_eq!(pinned, "2023-11-14T22:13:20Z");
        // Unparseable epoch falls back to wall clock — assert only the
        // shape so the test never depends on today's date.
        assert!(fallback.ends_with('Z') && fallback.len() == 20);
    }

    #[test]
    #[serial_test::parallel]
    fn test_sbom_plugin_generates_valid_cyclonedx_sbom() -> Result<()> {
        let tmp = tempdir().unwrap();
        let ctx = test_ctx(tmp.path());
        SbomPlugin.after_compile(&ctx).unwrap();

        let sbom_path = tmp.path().join("sbom.cdx.json");
        assert!(sbom_path.exists());

        let content = fs::read_to_string(&sbom_path).unwrap();
        let sbom: serde_json::Value = serde_json::from_str(&content).unwrap();

        assert_eq!(sbom["bomFormat"], "CycloneDX");
        assert_eq!(sbom["specVersion"], "1.5");
        assert_eq!(sbom["metadata"]["component"]["name"], "static-site");
        assert!(sbom["components"].as_array().is_some());

        let components = sbom["components"].as_array().unwrap();
        assert!(!components.is_empty());
        assert_eq!(components[0]["name"], "ssg");

        Ok(())
    }

    #[test]
    #[serial_test::parallel]
    fn test_sbom_plugin_nonexistent_site_dir() -> Result<()> {
        let tmp = tempdir().unwrap();
        let non_existent = tmp.path().join("non_existent_dir");
        let ctx = test_ctx(&non_existent);
        SbomPlugin.after_compile(&ctx).unwrap();
        let sbom_path = non_existent.join("sbom.cdx.json");
        assert!(!sbom_path.exists());
        Ok(())
    }

    #[test]
    fn test_current_timestamp_format() {
        let ts = current_timestamp();
        assert!(ts.contains('T'));
        assert!(ts.ends_with('Z'));
        assert_eq!(ts.len(), 20); // YYYY-MM-DDThh:mm:ssZ is exactly 20 chars
    }

    // -----------------------------------------------------------------
    // timestamp_from_secs: deterministic leap-year arithmetic
    // -----------------------------------------------------------------

    #[test]
    fn test_timestamp_from_secs_epoch_start() {
        assert_eq!(timestamp_from_secs(0), "1970-01-01T00:00:00Z");
    }

    #[test]
    fn test_timestamp_from_secs_leap_day() {
        // 2024-02-29T12:24:56Z — exercises the leap-year month table.
        assert_eq!(timestamp_from_secs(1_709_209_496), "2024-02-29T12:24:56Z");
    }

    #[test]
    fn test_timestamp_from_secs_year_2000_century_leap() {
        // 2000 is divisible by 400 → leap; 2000-03-01 lands after the
        // 29-day February.
        assert_eq!(timestamp_from_secs(951_868_800), "2000-03-01T00:00:00Z");
    }

    #[test]
    fn test_timestamp_from_secs_non_leap_century() {
        // A plain non-leap year: 2026-07-01.
        assert_eq!(timestamp_from_secs(1_782_864_000), "2026-07-01T00:00:00Z");
    }

    // -----------------------------------------------------------------
    // Error path: sbom.cdx.json exists as a directory
    // -----------------------------------------------------------------

    #[test]
    #[serial_test::parallel]
    fn test_after_compile_errors_when_sbom_path_is_a_directory() {
        let tmp = tempdir().unwrap();
        fs::create_dir_all(tmp.path().join("sbom.cdx.json")).unwrap();
        let ctx = test_ctx(tmp.path());
        let err = SbomPlugin.after_compile(&ctx).unwrap_err();
        assert!(format!("{err}").contains("sbom.cdx.json"));
    }
}

#[cfg(all(test, feature = "test-fault-injection"))]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod fault_tests {
    use super::*;
    use serial_test::serial;
    use tempfile::tempdir;

    /// RAII guard that disables a failpoint on drop.
    struct FailGuard(&'static str);

    impl Drop for FailGuard {
        fn drop(&mut self) {
            let _ = fail::cfg(self.0, "off");
        }
    }

    #[test]
    #[serial]
    fn after_compile_maps_serialize_failure_to_io_error() {
        let _guard = FailGuard("postprocess::sbom-serialize");
        fail::cfg("postprocess::sbom-serialize", "return")
            .expect("activate failpoint");

        let tmp = tempdir().unwrap();
        let ctx =
            PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
        let err = SbomPlugin
            .after_compile(&ctx)
            .expect_err("injected serialize failure must propagate");
        let msg = format!("{err}");
        assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
        assert!(
            msg.contains("injected: postprocess::sbom-serialize"),
            "got: {msg}"
        );
    }
}