ssg 0.0.40

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 = serde_json::to_string_pretty(&sbom)
            .map_err(|e| SsgError::io(e, &sbom_path))?;
        fs::write(&sbom_path, content).with_path(&sbom_path)?;

        Ok(())
    }
}

fn current_timestamp() -> String {
    let now = std::time::SystemTime::now();
    let duration = now
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    let secs = duration.as_secs();

    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]
    fn test_sbom_plugin_generates_valid_cyclonedx_sbom() -> Result<()> {
        let tmp = tempdir()?;
        let ctx = test_ctx(tmp.path());
        SbomPlugin.after_compile(&ctx)?;

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

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

        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]
    fn test_sbom_plugin_nonexistent_site_dir() -> Result<()> {
        let tmp = tempdir()?;
        let non_existent = tmp.path().join("non_existent_dir");
        let ctx = test_ctx(&non_existent);
        SbomPlugin.after_compile(&ctx)?;
        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
    }
}