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 =
serialize_sbom(&sbom).map_err(|e| SsgError::io(e, &sbom_path))?;
fs::write(&sbom_path, content).with_path(&sbom_path)?;
Ok(())
}
}
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 {
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())
}
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() {
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");
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); }
#[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() {
assert_eq!(timestamp_from_secs(1_709_209_496), "2024-02-29T12:24:56Z");
}
#[test]
fn test_timestamp_from_secs_year_2000_century_leap() {
assert_eq!(timestamp_from_secs(951_868_800), "2000-03-01T00:00:00Z");
}
#[test]
fn test_timestamp_from_secs_non_leap_century() {
assert_eq!(timestamp_from_secs(1_782_864_000), "2026-07-01T00:00:00Z");
}
#[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;
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}"
);
}
}