1use crate::plugin::{Plugin, PluginContext};
53use anyhow::Result;
54use std::fs;
55use std::path::Path;
56
57#[derive(Debug, Clone, Copy, Default)]
60pub struct SbomPlugin;
61
62impl SbomPlugin {
63 pub const fn sbom_path() -> &'static str {
65 "sbom.cdx.json"
66 }
67}
68
69impl Plugin for SbomPlugin {
70 fn name(&self) -> &'static str {
71 "sbom"
72 }
73
74 fn after_compile(&self, ctx: &PluginContext) -> Result<()> {
75 if !ctx.site_dir.exists() {
76 return Ok(());
77 }
78 let sbom = build_sbom();
79 let path = ctx.site_dir.join(Self::sbom_path());
80 fs::write(&path, serde_json::to_string_pretty(&sbom)?)?;
81 log::info!("[sbom] Wrote CycloneDX SBOM to {}", path.display());
82 Ok(())
83 }
84
85 fn has_transform(&self) -> bool {
86 true
87 }
88
89 fn transform_html(
90 &self,
91 html: &str,
92 _path: &Path,
93 _ctx: &PluginContext,
94 ) -> Result<String> {
95 if html.contains("rel=\"sbom\"") || html.contains("rel='sbom'") {
97 return Ok(html.to_string());
98 }
99 let Some(head_close) = html.find("</head>") else {
100 return Ok(html.to_string());
101 };
102 let link = format!(
103 "<link rel=\"sbom\" type=\"application/vnd.cyclonedx+json\" \
104 href=\"/{}\">\n",
105 Self::sbom_path()
106 );
107 let mut out = String::with_capacity(html.len() + link.len());
108 out.push_str(&html[..head_close]);
109 out.push_str(&link);
110 out.push_str(&html[head_close..]);
111 Ok(out)
112 }
113}
114
115fn build_sbom() -> serde_json::Value {
117 let now = current_iso_timestamp();
118 let ssg_version = env!("CARGO_PKG_VERSION");
119 serde_json::json!({
120 "bomFormat": "CycloneDX",
121 "specVersion": "1.5",
122 "version": 1,
123 "metadata": {
124 "timestamp": now,
125 "tools": [{
126 "vendor": "SSG Contributors",
127 "name": "ssg",
128 "version": ssg_version,
129 }],
130 "component": {
131 "type": "application",
132 "bom-ref": "site",
133 "name": "static-site",
134 "description": "Site generated by SSG",
135 }
136 },
137 "components": [{
138 "type": "application",
139 "bom-ref": format!("ssg@{ssg_version}"),
140 "name": "ssg",
141 "version": ssg_version,
142 "description": "Static site generator",
143 "purl": format!("pkg:cargo/ssg@{ssg_version}"),
144 "licenses": [
145 {"license": {"id": "MIT"}},
146 {"license": {"id": "Apache-2.0"}}
147 ],
148 "externalReferences": [
149 {"type": "vcs", "url": "https://github.com/sebastienrousseau/static-site-generator"},
150 {"type": "documentation", "url": "https://docs.rs/ssg"}
151 ]
152 }]
153 })
154}
155
156fn current_iso_timestamp() -> String {
160 use std::time::{SystemTime, UNIX_EPOCH};
161 let secs = SystemTime::now()
162 .duration_since(UNIX_EPOCH)
163 .map_or(0, |d| d.as_secs());
164 epoch_to_iso(secs)
165}
166
167fn epoch_to_iso(secs: u64) -> String {
169 let days = secs / 86_400;
171 let sec_in_day = secs % 86_400;
172 let hour = (sec_in_day / 3600) as u32;
173 let minute = ((sec_in_day % 3600) / 60) as u32;
174 let second = (sec_in_day % 60) as u32;
175
176 let z = days as i64 + 719_468;
179 let era = z.div_euclid(146_097);
180 let doe = (z - era * 146_097) as u64;
181 let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
182 let y = (yoe as i64) + era * 400;
183 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
184 let mp = (5 * doy + 2) / 153;
185 let day = (doy - (153 * mp + 2) / 5 + 1) as u32;
186 let month = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
187 let year = if month <= 2 { y + 1 } else { y };
188
189 format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z")
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use std::path::Path;
196 use tempfile::tempdir;
197
198 #[test]
199 fn epoch_to_iso_handles_unix_epoch() {
200 assert_eq!(epoch_to_iso(0), "1970-01-01T00:00:00Z");
201 }
202
203 #[test]
204 fn epoch_to_iso_handles_known_timestamps() {
205 assert_eq!(epoch_to_iso(1_700_000_000), "2023-11-14T22:13:20Z");
207 assert_eq!(epoch_to_iso(1_577_836_800), "2020-01-01T00:00:00Z");
209 }
210
211 #[test]
212 fn build_sbom_includes_required_cyclonedx_fields() {
213 let sbom = build_sbom();
214 assert_eq!(sbom["bomFormat"], "CycloneDX");
215 assert_eq!(sbom["specVersion"], "1.5");
216 assert_eq!(sbom["version"], 1);
217 assert!(sbom["metadata"]["timestamp"].as_str().is_some());
218 assert!(sbom["metadata"]["tools"].as_array().is_some());
219 let components = sbom["components"].as_array().unwrap();
220 assert!(!components.is_empty());
221 for c in components {
223 assert!(c["name"].as_str().is_some());
224 assert!(c["purl"].as_str().is_some());
225 }
226 }
227
228 #[test]
229 fn sbom_plugin_writes_file_after_compile() {
230 let dir = tempdir().unwrap();
231 let site = dir.path().join("site");
232 fs::create_dir_all(&site).unwrap();
233 let ctx = PluginContext::new(dir.path(), dir.path(), &site, dir.path());
234 SbomPlugin.after_compile(&ctx).unwrap();
235 let sbom_file = site.join(SbomPlugin::sbom_path());
236 assert!(sbom_file.exists());
237 let body = fs::read_to_string(&sbom_file).unwrap();
238 assert!(body.contains("\"CycloneDX\""));
239 assert!(body.contains("\"specVersion\": \"1.5\""));
240 }
241
242 #[test]
243 fn sbom_plugin_injects_link_into_head() {
244 let dir = tempdir().unwrap();
245 let ctx =
246 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
247 let html = "<html><head><title>x</title></head><body></body></html>";
248 let out = SbomPlugin
249 .transform_html(html, Path::new("x.html"), &ctx)
250 .unwrap();
251 assert!(out.contains("rel=\"sbom\""));
252 assert!(out.contains("application/vnd.cyclonedx+json"));
253 assert!(out.contains("href=\"/sbom.cdx.json\""));
254 }
255
256 #[test]
257 fn sbom_plugin_is_idempotent() {
258 let dir = tempdir().unwrap();
259 let ctx =
260 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
261 let html = r#"<html><head><link rel="sbom" type="application/vnd.cyclonedx+json" href="/sbom.cdx.json"></head><body></body></html>"#;
262 let out = SbomPlugin
263 .transform_html(html, Path::new("x.html"), &ctx)
264 .unwrap();
265 assert_eq!(out, html);
266 }
267
268 #[test]
269 fn sbom_plugin_skips_pages_without_head_tag() {
270 let dir = tempdir().unwrap();
271 let ctx =
272 PluginContext::new(dir.path(), dir.path(), dir.path(), dir.path());
273 let html = "<p>orphan content with no head</p>";
274 let out = SbomPlugin
275 .transform_html(html, Path::new("x.html"), &ctx)
276 .unwrap();
277 assert_eq!(out, html);
278 }
279
280 #[test]
281 fn sbom_plugin_after_compile_noop_when_site_missing() {
282 let dir = tempdir().unwrap();
283 let missing = dir.path().join("missing");
284 let ctx =
285 PluginContext::new(dir.path(), dir.path(), &missing, dir.path());
286 SbomPlugin.after_compile(&ctx).unwrap();
287 assert!(!missing.exists());
288 }
289
290 #[test]
291 fn sbom_path_constant() {
292 assert_eq!(SbomPlugin::sbom_path(), "sbom.cdx.json");
293 }
294}