1use crate::error::{PathErrorExt, SsgError};
69use crate::plugin::{Plugin, PluginContext};
70use crate::util::head_dom::inject_before_head_close;
71use std::fs;
72use std::path::Path;
73
74#[derive(Debug, Clone, Copy, Default)]
77pub struct SbomPlugin;
78
79impl SbomPlugin {
80 pub const fn sbom_path() -> &'static str {
90 "sbom.cdx.json"
91 }
92}
93
94impl Plugin for SbomPlugin {
95 fn name(&self) -> &'static str {
96 "sbom"
97 }
98
99 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
100 if !ctx.site_dir.exists() {
101 return Ok(());
102 }
103 let sbom = build_sbom();
104 let path = ctx.site_dir.join(Self::sbom_path());
105 let json = serialize_sbom(&sbom).map_err(|e| SsgError::Io {
106 path: path.clone(),
107 source: std::io::Error::other(e),
108 })?;
109 fs::write(&path, json).with_path(&path)?;
110 log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
111 Ok(())
112 }
113
114 fn has_transform(&self) -> bool {
115 true
116 }
117
118 fn transform_html(
119 &self,
120 html: &str,
121 _path: &Path,
122 ctx: &PluginContext,
123 ) -> Result<String, SsgError> {
124 if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
126 return Ok(html.to_string());
127 }
128 let link = format!(
133 "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
134 href=\"{}/{}\">\n",
135 ctx.config.as_ref().map_or_else(String::new, |c| {
136 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
137 }),
138 Self::sbom_path()
139 );
140 Ok(inject_before_head_close(html, &link))
141 }
142}
143
144fn sbom_serial_number(timestamp: &str, ssg_version: &str) -> String {
154 use sha2::{Digest as _, Sha256};
155
156 let mut hasher = Sha256::new();
159 hasher.update(b"ssg-sbom-serial-v1\x00");
160 hasher.update((timestamp.len() as u64).to_le_bytes());
161 hasher.update(timestamp.as_bytes());
162 hasher.update((ssg_version.len() as u64).to_le_bytes());
163 hasher.update(ssg_version.as_bytes());
164 let digest = hasher.finalize();
165
166 let mut bytes = [0u8; 16];
167 bytes.copy_from_slice(&digest[..16]);
168 bytes[6] = (bytes[6] & 0x0f) | 0x80; bytes[8] = (bytes[8] & 0x3f) | 0x80; let mut hex = String::with_capacity(32);
172 for b in bytes {
173 use std::fmt::Write as _;
174 let _ = write!(hex, "{b:02x}");
175 }
176 format!(
177 "urn:uuid:{}-{}-{}-{}-{}",
178 &hex[0..8],
179 &hex[8..12],
180 &hex[12..16],
181 &hex[16..20],
182 &hex[20..32]
183 )
184}
185
186fn build_sbom() -> serde_json::Value {
188 let now = current_iso_timestamp();
189 let ssg_version = env!("CARGO_PKG_VERSION");
190 serde_json::json!({
191 "bomFormat": "CycloneDX",
192 "specVersion": "1.5",
193 "serialNumber": sbom_serial_number(&now, ssg_version),
194 "version": 1,
195 "metadata": {
196 "timestamp": now,
197 "tools": [{
198 "vendor": "SSG Contributors",
199 "name": "ssg",
200 "version": ssg_version,
201 }],
202 "component": {
203 "type": "application",
204 "bom-ref": "site",
205 "name": "static-site",
206 "description": "Site generated by SSG",
207 }
208 },
209 "components": [{
210 "type": "application",
211 "bom-ref": format!("ssg@{ssg_version}"),
212 "name": "ssg",
213 "version": ssg_version,
214 "description": "Static site generator",
215 "purl": format!("pkg:cargo/ssg@{ssg_version}"),
216 "licenses": [
217 {"license": {"id": "MIT"}},
218 {"license": {"id": "Apache-2.0"}}
219 ],
220 "externalReferences": [
221 {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
222 {"type": "documentation", "url": "https://docs.rs/ssg"}
223 ]
224 }]
225 })
226}
227
228fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
232 fail_point!("sbom::serialize", |_| Err(
233 <serde_json::Error as serde::ser::Error>::custom(
234 "injected: sbom::serialize"
235 )
236 ));
237 serde_json::to_string_pretty(sbom)
238}
239
240fn current_iso_timestamp() -> String {
249 use std::time::{SystemTime, UNIX_EPOCH};
250 if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
251 if let Ok(secs) = epoch.trim().parse::<u64>() {
252 return epoch_to_iso(secs);
253 }
254 }
255 let secs = SystemTime::now()
256 .duration_since(UNIX_EPOCH)
257 .map_or(0, |d| d.as_secs());
258 epoch_to_iso(secs)
259}
260
261fn epoch_to_iso(secs: u64) -> String {
263 let days = secs / 86_400;
265 let sec_in_day = secs % 86_400;
266 let hour = (sec_in_day / 3600) as u32;
267 let minute = ((sec_in_day % 3600) / 60) as u32;
268 let second = (sec_in_day % 60) as u32;
269
270 let z = days as i64 + 719_468;
273 let era = z.div_euclid(146_097);
274 let doe = (z - era * 146_097) as u64;
275 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
276 let y = (yoe as i64) + era * 400;
277 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
278 let mp = (5 * doy + 2) / 153;
279 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
280 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
281 let year = if month <= 2 { y + 1 } else { y };
282
283 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
284}
285
286#[cfg(test)]
287mod tests {
288 use super::*;
289 use crate::cmd::SsgConfig;
290 use std::path::Path;
291 use tempfile::tempdir;
292
293 #[test]
294 fn epoch_to_iso_handles_unix_epoch() {
295 assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
296 }
297
298 #[test]
299 fn epoch_to_iso_handles_known_timestamps() {
300 assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
302 assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
304 }
305
306 #[test]
307 #[serial_test::serial(source_date_epoch)]
308 fn current_iso_timestamp_honours_source_date_epoch() {
309 let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
313 std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
314 let pinned = current_iso_timestamp();
315 std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
316 let fallback = current_iso_timestamp();
317 match prev {
318 Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
319 None => std::env::remove_var("SOURCE_DATE_EPOCH"),
320 }
321 assert_eq!(pinned, "2023-11-14T22:13:20Z");
322 assert!(fallback.ends_with('Z') && fallback.len() == 20);
325 }
326
327 #[test]
328 fn build_sbom_includes_required_cyclonedx_fields() {
329 let sbom = build_sbom();
330 assert_eq!(sbom["bomFormat"], "CycloneDX");
331 assert_eq!(sbom["specVersion"], "1.5");
332 assert_eq!(sbom["version"], 1);
333 assert!(sbom["metadata"]["timestamp"].as_str().is_some());
334 assert!(sbom["metadata"]["tools"].as_array().is_some());
335 let components = sbom["components"].as_array().unwrap();
336 assert!(!components.is_empty(), "should not be empty");
337 for c in components {
339 assert!(c["name"].as_str().is_some());
340 assert!(c["purl"].as_str().is_some());
341 }
342 }
343
344 #[test]
345 fn build_sbom_is_attestable_as_cyclonedx() {
346 let sbom = build_sbom();
351 for field in ["bomFormat", "serialNumber", "specVersion"] {
352 let value = sbom[field].as_str();
353 assert!(
354 value.is_some_and(|v| !v.is_empty()),
355 "{field} must be a non-empty string for the SBOM to be attestable"
356 );
357 }
358 }
359
360 #[test]
361 fn sbom_serial_number_has_uuid_urn_shape() {
362 let serial = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
363 let body = serial
369 .strip_prefix("urn:uuid:")
370 .expect("serialNumber must be a UUID URN");
371
372 let groups: Vec<&str> = body.split('-').collect();
374 assert_eq!(groups.len(), 5, "expected 8-4-4-4-12, got {body}");
375 assert_eq!(
376 groups.iter().map(|g| g.len()).collect::<Vec<_>>(),
377 vec![8, 4, 4, 4, 12]
378 );
379 assert!(
380 groups.iter().all(|g| g
381 .chars()
382 .all(|c| c.is_ascii_digit() || ('a'..='f').contains(&c))),
383 "UUID groups must be lowercase hex: {body}"
384 );
385
386 assert_eq!(
388 groups[2].as_bytes()[0],
389 b'8',
390 "expected version 8 in {body}"
391 );
392 assert!(
393 matches!(groups[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'),
394 "expected RFC 9562 variant in {body}"
395 );
396 }
397
398 #[test]
399 fn sbom_serial_number_is_derived_not_random() {
400 let a = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
404 let b = sbom_serial_number("2023-11-14T22:13:20Z", "0.0.63");
405 assert_eq!(a, b, "same inputs must produce the same serial");
406
407 let later = sbom_serial_number("2023-11-14T22:13:21Z", "0.0.63");
409 assert_ne!(a, later, "a different timestamp must change the serial");
410 let other_version =
411 sbom_serial_number("2023-11-14T22:13:20Z", "0.0.64");
412 assert_ne!(
413 a, other_version,
414 "a different ssg version must change the serial"
415 );
416 }
417
418 #[test]
419 #[serial_test::serial(source_date_epoch)]
420 fn build_sbom_serial_is_pinned_by_source_date_epoch() {
421 let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
424 std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
425 let first = build_sbom();
426 let second = build_sbom();
427 match prev {
428 Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
429 None => std::env::remove_var("SOURCE_DATE_EPOCH"),
430 }
431 assert!(first["serialNumber"]
434 .as_str()
435 .is_some_and(|s| !s.is_empty()));
436 assert_eq!(first["serialNumber"], second["serialNumber"]);
437 assert_eq!(
438 first["metadata"]["timestamp"],
439 second["metadata"]["timestamp"]
440 );
441 }
442
443 #[test]
444 #[serial_test::parallel]
445 fn sbom_plugin_writes_file_after_compile() {
446 let dir = tempdir().unwrap();
447 let site = dir.path().join("site");
448 fs::create_dir_all(&site).unwrap();
449 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
450 SbomPlugin.after_compile(&ctx).unwrap();
451 let sbom_file = site.join(SbomPlugin::sbom_path());
452 assert!(sbom_file.exists());
453 let body = fs::read_to_string(&sbom_file).unwrap();
454 assert!(body.contains("\"CycloneDX\""));
455 assert!(body.contains("\"specVersion\": \"1.5\""));
456 }
457
458 #[test]
462 fn sbom_link_carries_the_base_url_path_prefix() {
463 let dir = tempdir().unwrap();
464 let config = SsgConfig::builder()
465 .base_url("https://example.com/ssg-themes.github.io/apex".into())
466 .build()
467 .unwrap();
468 let ctx = PluginContext::with_config(
469 dir.path(),
470 dir.path(),
471 dir.path(),
472 dir.path(),
473 config,
474 );
475 let out = SbomPlugin
476 .transform_html(
477 "<html><head></head><body></body></html>",
478 Path::new("x.html"),
479 &ctx,
480 )
481 .unwrap();
482 assert!(
483 out.contains(r#"href="/ssg-themes.github.io/apex/sbom.cdx.json""#),
484 "{out}"
485 );
486 }
487
488 #[test]
489 fn sbom_plugin_injects_link_into_head() {
490 let dir = tempdir().unwrap();
491 let ctx =
492 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
493 let html = "<html><head><title>x</title></head><body></body></html>";
494 let out = SbomPlugin
495 .transform_html(html, Path::new("x.html"), &ctx)
496 .unwrap();
497 assert!(out.contains("rel=\"sbom\""));
498 assert!(out.contains("application/vnd.cyclonedx+json"));
499 assert!(out.contains("href=\"/sbom.cdx.json\""));
500 }
501
502 #[test]
503 fn sbom_plugin_is_idempotent() {
504 let dir = tempdir().unwrap();
505 let ctx =
506 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
507 let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
508 let out = SbomPlugin
509 .transform_html(html, Path::new("x.html"), &ctx)
510 .unwrap();
511 assert_eq!(out, html);
512 }
513
514 #[test]
515 fn sbom_plugin_is_idempotent_with_single_quoted_attribute() {
516 let dir = tempdir().unwrap();
519 let ctx =
520 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
521 let html = r"<html><head><link rel='sbom' type='application/vnd.cyclonedx+json' href='/sbom.cdx.json'></head><body></body></html>";
522 let out = SbomPlugin
523 .transform_html(html, Path::new("x.html"), &ctx)
524 .unwrap();
525 assert_eq!(out, html);
526 }
527
528 #[test]
529 fn sbom_plugin_skips_pages_without_head_tag() {
530 let dir = tempdir().unwrap();
531 let ctx =
532 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
533 let html = "<p>orphan content with no head</p>";
534 let out = SbomPlugin
535 .transform_html(html, Path::new("x.html"), &ctx)
536 .unwrap();
537 assert_eq!(out, html);
538 }
539
540 #[test]
541 #[serial_test::parallel]
542 fn sbom_plugin_after_compile_noop_when_site_missing() {
543 let dir = tempdir().unwrap();
544 let missing = dir.path().join("missing");
545 let ctx =
546 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
547 SbomPlugin.after_compile(&ctx).unwrap();
548 assert!(!missing.exists());
549 }
550
551 #[test]
552 fn sbom_path_constant() {
553 assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
554 }
555
556 #[test]
557 #[serial_test::parallel]
558 fn after_compile_write_failure_returns_io_error() {
559 let dir = tempdir().unwrap();
560 let site = dir.path().join("site");
561 fs::create_dir_all(&site).unwrap();
562
563 let sbom_dir = site.join(SbomPlugin::sbom_path());
565 fs::create_dir(&sbom_dir).unwrap();
566
567 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
568 let res = SbomPlugin.after_compile(&ctx);
569 assert!(res.is_err());
570 let err = res.unwrap_err();
571 assert!(
572 matches!(err, SsgError::Io { ref path, .. } if path == &sbom_dir)
573 );
574 }
575}
576
577#[cfg(all(test, feature = "test-fault-injection"))]
578mod fault_tests {
579 use super::*;
580 use crate::plugin::PluginContext;
581 use serial_test::serial;
582 use tempfile::tempdir;
583
584 struct FailGuard(&'static str);
586
587 impl Drop for FailGuard {
588 fn drop(&mut self) {
589 let _ = fail::cfg(self.0, "off");
590 }
591 }
592
593 #[test]
594 #[serial]
595 fn after_compile_maps_serialize_failure_to_io_error() {
596 let _guard = FailGuard("sbom::serialize");
600 fail::cfg("sbom::serialize", "return").expect("activate failpoint");
601
602 let dir = tempdir().unwrap();
603 let site = dir.path().join("site");
604 fs::create_dir_all(&site).unwrap();
605 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
606
607 let err = SbomPlugin
608 .after_compile(&ctx)
609 .expect_err("injected serialize failure must propagate");
610 let msg = format!("{err}");
611 assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
612 assert!(msg.contains("injected: sbom::serialize"), "got: {msg}");
613 }
614}