use crate::error::{PathErrorExt, SsgError};
use crate::plugin::{Plugin, PluginContext};
use serde_json::json;
use std::fs;
#[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); }
}