1use crate::error::{PathErrorExt, SsgError};
54use crate::plugin::{Plugin, PluginContext};
55use crate::util::head_dom::inject_before_head_close;
56use std::fs;
57use std::path::Path;
58
59#[derive(Debug, Clone, Copy, Default)]
62pub struct SbomPlugin;
63
64impl SbomPlugin {
65 pub const fn sbom_path() -> &'static str {
75 "sbom.cdx.json"
76 }
77}
78
79impl Plugin for SbomPlugin {
80 fn name(&self) -> &'static str {
81 "sbom"
82 }
83
84 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
85 if !ctx.site_dir.exists() {
86 return Ok(());
87 }
88 let sbom = build_sbom();
89 let path = ctx.site_dir.join(Self::sbom_path());
90 let json = serialize_sbom(&sbom).map_err(|e| SsgError::Io {
91 path: path.clone(),
92 source: std::io::Error::other(e),
93 })?;
94 fs::write(&path, json).with_path(&path)?;
95 log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
96 Ok(())
97 }
98
99 fn has_transform(&self) -> bool {
100 true
101 }
102
103 fn transform_html(
104 &self,
105 html: &str,
106 _path: &Path,
107 ctx: &PluginContext,
108 ) -> Result<String, SsgError> {
109 if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
111 return Ok(html.to_string());
112 }
113 let link = format!(
118 "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
119 href=\"{}/{}\">\n",
120 ctx.config.as_ref().map_or_else(String::new, |c| {
121 crate::plugins_group::csp::base_url_path_prefix(&c.base_url)
122 }),
123 Self::sbom_path()
124 );
125 Ok(inject_before_head_close(html, &link))
126 }
127}
128
129fn build_sbom() -> serde_json::Value {
131 let now = current_iso_timestamp();
132 let ssg_version = env!("CARGO_PKG_VERSION");
133 serde_json::json!({
134 "bomFormat": "CycloneDX",
135 "specVersion": "1.5",
136 "version": 1,
137 "metadata": {
138 "timestamp": now,
139 "tools": [{
140 "vendor": "SSG Contributors",
141 "name": "ssg",
142 "version": ssg_version,
143 }],
144 "component": {
145 "type": "application",
146 "bom-ref": "site",
147 "name": "static-site",
148 "description": "Site generated by SSG",
149 }
150 },
151 "components": [{
152 "type": "application",
153 "bom-ref": format!("ssg@{ssg_version}"),
154 "name": "ssg",
155 "version": ssg_version,
156 "description": "Static site generator",
157 "purl": format!("pkg:cargo/ssg@{ssg_version}"),
158 "licenses": [
159 {"license": {"id": "MIT"}},
160 {"license": {"id": "Apache-2.0"}}
161 ],
162 "externalReferences": [
163 {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
164 {"type": "documentation", "url": "https://docs.rs/ssg"}
165 ]
166 }]
167 })
168}
169
170fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
174 fail_point!("sbom::serialize", |_| Err(
175 <serde_json::Error as serde::ser::Error>::custom(
176 "injected: sbom::serialize"
177 )
178 ));
179 serde_json::to_string_pretty(sbom)
180}
181
182fn current_iso_timestamp() -> String {
191 use std::time::{SystemTime, UNIX_EPOCH};
192 if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
193 if let Ok(secs) = epoch.trim().parse::<u64>() {
194 return epoch_to_iso(secs);
195 }
196 }
197 let secs = SystemTime::now()
198 .duration_since(UNIX_EPOCH)
199 .map_or(0, |d| d.as_secs());
200 epoch_to_iso(secs)
201}
202
203fn epoch_to_iso(secs: u64) -> String {
205 let days = secs / 86_400;
207 let sec_in_day = secs % 86_400;
208 let hour = (sec_in_day / 3600) as u32;
209 let minute = ((sec_in_day % 3600) / 60) as u32;
210 let second = (sec_in_day % 60) as u32;
211
212 let z = days as i64 + 719_468;
215 let era = z.div_euclid(146_097);
216 let doe = (z - era * 146_097) as u64;
217 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
218 let y = (yoe as i64) + era * 400;
219 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
220 let mp = (5 * doy + 2) / 153;
221 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
222 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
223 let year = if month <= 2 { y + 1 } else { y };
224
225 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
226}
227
228#[cfg(test)]
229mod tests {
230 use super::*;
231 use crate::cmd::SsgConfig;
232 use std::path::Path;
233 use tempfile::tempdir;
234
235 #[test]
236 fn epoch_to_iso_handles_unix_epoch() {
237 assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
238 }
239
240 #[test]
241 fn epoch_to_iso_handles_known_timestamps() {
242 assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
244 assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
246 }
247
248 #[test]
249 #[serial_test::serial(source_date_epoch)]
250 fn current_iso_timestamp_honours_source_date_epoch() {
251 let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
256 std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
257 let pinned = current_iso_timestamp();
258 std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
259 let fallback = current_iso_timestamp();
260 match prev {
261 Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
262 None => std::env::remove_var("SOURCE_DATE_EPOCH"),
263 }
264 assert_eq!(pinned, "2023-11-14T22:13:20Z");
265 assert!(fallback.ends_with('Z') && fallback.len() == 20);
268 }
269
270 #[test]
271 fn build_sbom_includes_required_cyclonedx_fields() {
272 let sbom = build_sbom();
273 assert_eq!(sbom["bomFormat"], "CycloneDX");
274 assert_eq!(sbom["specVersion"], "1.5");
275 assert_eq!(sbom["version"], 1);
276 assert!(sbom["metadata"]["timestamp"].as_str().is_some());
277 assert!(sbom["metadata"]["tools"].as_array().is_some());
278 let components = sbom["components"].as_array().unwrap();
279 assert!(!components.is_empty());
280 for c in components {
282 assert!(c["name"].as_str().is_some());
283 assert!(c["purl"].as_str().is_some());
284 }
285 }
286
287 #[test]
288 #[serial_test::parallel]
289 fn sbom_plugin_writes_file_after_compile() {
290 let dir = tempdir().unwrap();
291 let site = dir.path().join("site");
292 fs::create_dir_all(&site).unwrap();
293 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
294 SbomPlugin.after_compile(&ctx).unwrap();
295 let sbom_file = site.join(SbomPlugin::sbom_path());
296 assert!(sbom_file.exists());
297 let body = fs::read_to_string(&sbom_file).unwrap();
298 assert!(body.contains("\"CycloneDX\""));
299 assert!(body.contains("\"specVersion\": \"1.5\""));
300 }
301
302 #[test]
306 fn sbom_link_carries_the_base_url_path_prefix() {
307 let dir = tempdir().unwrap();
308 let config = SsgConfig::builder()
309 .base_url("https://example.com/ssg-themes.github.io/apex".into())
310 .build()
311 .unwrap();
312 let ctx = PluginContext::with_config(
313 dir.path(),
314 dir.path(),
315 dir.path(),
316 dir.path(),
317 config,
318 );
319 let out = SbomPlugin
320 .transform_html(
321 "<html><head></head><body></body></html>",
322 Path::new("x.html"),
323 &ctx,
324 )
325 .unwrap();
326 assert!(
327 out.contains(r#"href="/ssg-themes.github.io/apex/sbom.cdx.json""#),
328 "{out}"
329 );
330 }
331
332 #[test]
333 fn sbom_plugin_injects_link_into_head() {
334 let dir = tempdir().unwrap();
335 let ctx =
336 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
337 let html = "<html><head><title>x</title></head><body></body></html>";
338 let out = SbomPlugin
339 .transform_html(html, Path::new("x.html"), &ctx)
340 .unwrap();
341 assert!(out.contains("rel=\"sbom\""));
342 assert!(out.contains("application/vnd.cyclonedx+json"));
343 assert!(out.contains("href=\"/sbom.cdx.json\""));
344 }
345
346 #[test]
347 fn sbom_plugin_is_idempotent() {
348 let dir = tempdir().unwrap();
349 let ctx =
350 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
351 let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
352 let out = SbomPlugin
353 .transform_html(html, Path::new("x.html"), &ctx)
354 .unwrap();
355 assert_eq!(out, html);
356 }
357
358 #[test]
359 fn sbom_plugin_is_idempotent_with_single_quoted_attribute() {
360 let dir = tempdir().unwrap();
363 let ctx =
364 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
365 let html = r"<html><head><link rel='sbom' type='application/vnd.cyclonedx+json' href='/sbom.cdx.json'></head><body></body></html>";
366 let out = SbomPlugin
367 .transform_html(html, Path::new("x.html"), &ctx)
368 .unwrap();
369 assert_eq!(out, html);
370 }
371
372 #[test]
373 fn sbom_plugin_skips_pages_without_head_tag() {
374 let dir = tempdir().unwrap();
375 let ctx =
376 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
377 let html = "<p>orphan content with no head</p>";
378 let out = SbomPlugin
379 .transform_html(html, Path::new("x.html"), &ctx)
380 .unwrap();
381 assert_eq!(out, html);
382 }
383
384 #[test]
385 #[serial_test::parallel]
386 fn sbom_plugin_after_compile_noop_when_site_missing() {
387 let dir = tempdir().unwrap();
388 let missing = dir.path().join("missing");
389 let ctx =
390 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
391 SbomPlugin.after_compile(&ctx).unwrap();
392 assert!(!missing.exists());
393 }
394
395 #[test]
396 fn sbom_path_constant() {
397 assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
398 }
399
400 #[test]
401 #[serial_test::parallel]
402 fn after_compile_write_failure_returns_io_error() {
403 let dir = tempdir().unwrap();
404 let site = dir.path().join("site");
405 fs::create_dir_all(&site).unwrap();
406
407 let sbom_dir = site.join(SbomPlugin::sbom_path());
409 fs::create_dir(&sbom_dir).unwrap();
410
411 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
412 let res = SbomPlugin.after_compile(&ctx);
413 assert!(res.is_err());
414 let err = res.unwrap_err();
415 assert!(
416 matches!(err, SsgError::Io { ref path, .. } if path == &sbom_dir)
417 );
418 }
419}
420
421#[cfg(all(test, feature = "test-fault-injection"))]
422mod fault_tests {
423 use super::*;
424 use crate::plugin::PluginContext;
425 use serial_test::serial;
426 use tempfile::tempdir;
427
428 struct FailGuard(&'static str);
430
431 impl Drop for FailGuard {
432 fn drop(&mut self) {
433 let _ = fail::cfg(self.0, "off");
434 }
435 }
436
437 #[test]
438 #[serial]
439 fn after_compile_maps_serialize_failure_to_io_error() {
440 let _guard = FailGuard("sbom::serialize");
444 fail::cfg("sbom::serialize", "return").expect("activate failpoint");
445
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
451 let err = SbomPlugin
452 .after_compile(&ctx)
453 .expect_err("injected serialize failure must propagate");
454 let msg = format!("{err}");
455 assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
456 assert!(msg.contains("injected: sbom::serialize"), "got: {msg}");
457 }
458}