1use crate::error::{PathErrorExt, SsgError};
7use crate::plugin::{Plugin, PluginContext};
8use serde_json::json;
9use std::fs;
10
11#[deprecated(
20 since = "0.0.58",
21 note = "use `ssg::sbom::SbomPlugin`; this wrote the same file and was overwritten"
22)]
23#[derive(Debug, Clone, Copy, Default)]
24pub struct SbomPlugin;
25
26#[allow(deprecated)]
27impl Plugin for SbomPlugin {
28 fn name(&self) -> &'static str {
29 "sbom-generator"
30 }
31
32 fn after_compile(&self, ctx: &PluginContext) -> Result<(), SsgError> {
33 if !ctx.site_dir.exists() {
34 return Ok(());
35 }
36
37 let version = env!("CARGO_PKG_VERSION");
38 let timestamp = current_timestamp();
39
40 let sbom = json!({
41 "bomFormat": "CycloneDX",
42 "specVersion": "1.5",
43 "version": 1,
44 "metadata": {
45 "timestamp": timestamp,
46 "tools": [
47 {
48 "vendor": "SSG Contributors",
49 "name": "ssg",
50 "version": version
51 }
52 ],
53 "component": {
54 "bom-ref": "site",
55 "type": "application",
56 "name": "static-site",
57 "description": "Site generated by SSG"
58 }
59 },
60 "components": [
61 {
62 "bom-ref": format!("ssg@{version}"),
63 "type": "application",
64 "name": "ssg",
65 "version": version,
66 "description": "Static site generator",
67 "purl": format!("pkg:cargo/ssg@{version}"),
68 "externalReferences": [
69 {
70 "type": "vcs",
71 "url": "https://github.com/sebastienrousseau/static-site-generator"
72 },
73 {
74 "type": "documentation",
75 "url": "https://docs.rs/ssg"
76 }
77 ],
78 "licenses": [
79 {
80 "license": {
81 "id": "MIT"
82 }
83 },
84 {
85 "license": {
86 "id": "Apache-2.0"
87 }
88 }
89 ]
90 }
91 ]
92 });
93
94 let sbom_path = ctx.site_dir.join("sbom.cdx.json");
95 let content =
96 serialize_sbom(&sbom).map_err(|e| SsgError::io(e, &sbom_path))?;
97 fs::write(&sbom_path, content).with_path(&sbom_path)?;
98
99 Ok(())
100 }
101}
102
103fn serialize_sbom(sbom: &serde_json::Value) -> serde_json::Result<String> {
107 fail_point!("postprocess::sbom-serialize", |_| Err(
108 <serde_json::Error as serde::ser::Error>::custom(
109 "injected: postprocess::sbom-serialize"
110 )
111 ));
112 serde_json::to_string_pretty(sbom)
113}
114
115fn current_timestamp() -> String {
116 if let Ok(epoch) = std::env::var("SOURCE_DATE_EPOCH") {
120 if let Ok(secs) = epoch.trim().parse::<u64>() {
121 return timestamp_from_secs(secs);
122 }
123 }
124 let now = std::time::SystemTime::now();
125 let duration = now
126 .duration_since(std::time::UNIX_EPOCH)
127 .unwrap_or_default();
128 timestamp_from_secs(duration.as_secs())
129}
130
131fn timestamp_from_secs(secs: u64) -> String {
135 let days_since_epoch = secs / 86400;
136 let seconds_of_day = secs % 86400;
137
138 let hours = seconds_of_day / 3600;
139 let minutes = (seconds_of_day % 3600) / 60;
140 let seconds = seconds_of_day % 60;
141
142 let mut year = 1970;
143 let mut days = days_since_epoch;
144
145 loop {
146 let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
147 let days_in_year = if is_leap { 366 } else { 365 };
148 if days >= days_in_year {
149 days -= days_in_year;
150 year += 1;
151 } else {
152 break;
153 }
154 }
155
156 let is_leap = (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0);
157 let month_days = if is_leap {
158 [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
159 } else {
160 [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
161 };
162
163 let mut month = 1;
164 for &d in &month_days {
165 if days >= d {
166 days -= d;
167 month += 1;
168 } else {
169 break;
170 }
171 }
172 let day = days + 1;
173
174 format!(
175 "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
176 year, month, day, hours, minutes, seconds
177 )
178}
179
180#[cfg(test)]
181mod tests {
182 #![allow(deprecated)]
186 use super::*;
187 use anyhow::Result;
188 use tempfile::tempdir;
189
190 fn test_ctx(dir: &std::path::Path) -> PluginContext {
191 PluginContext::new(dir, dir, dir, dir)
192 }
193
194 #[test]
195 fn test_sbom_plugin_name() {
196 assert_eq!(SbomPlugin.name(), "sbom-generator");
197 }
198
199 #[test]
200 #[serial_test::serial(source_date_epoch)]
201 fn current_timestamp_honours_source_date_epoch() {
202 let prev = std::env::var("SOURCE_DATE_EPOCH").ok();
205 std::env::set_var("SOURCE_DATE_EPOCH", "1700000000");
206 let pinned = current_timestamp();
207 std::env::set_var("SOURCE_DATE_EPOCH", "not-a-number");
208 let fallback = current_timestamp();
209 match prev {
210 Some(v) => std::env::set_var("SOURCE_DATE_EPOCH", v),
211 None => std::env::remove_var("SOURCE_DATE_EPOCH"),
212 }
213 assert_eq!(pinned, "2023-11-14T22:13:20Z");
214 assert!(fallback.ends_with('Z') && fallback.len() == 20);
217 }
218
219 #[test]
220 #[serial_test::parallel]
221 fn test_sbom_plugin_generates_valid_cyclonedx_sbom() -> Result<()> {
222 let tmp = tempdir().unwrap();
223 let ctx = test_ctx(tmp.path());
224 SbomPlugin.after_compile(&ctx).unwrap();
225
226 let sbom_path = tmp.path().join("sbom.cdx.json");
227 assert!(sbom_path.exists());
228
229 let content = fs::read_to_string(&sbom_path).unwrap();
230 let sbom: serde_json::Value = serde_json::from_str(&content).unwrap();
231
232 assert_eq!(sbom["bomFormat"], "CycloneDX");
233 assert_eq!(sbom["specVersion"], "1.5");
234 assert_eq!(sbom["metadata"]["component"]["name"], "static-site");
235 assert!(sbom["components"].as_array().is_some());
236
237 let components = sbom["components"].as_array().unwrap();
238 assert!(!components.is_empty());
239 assert_eq!(components[0]["name"], "ssg");
240
241 Ok(())
242 }
243
244 #[test]
245 #[serial_test::parallel]
246 fn test_sbom_plugin_nonexistent_site_dir() -> Result<()> {
247 let tmp = tempdir().unwrap();
248 let non_existent = tmp.path().join("non_existent_dir");
249 let ctx = test_ctx(&non_existent);
250 SbomPlugin.after_compile(&ctx).unwrap();
251 let sbom_path = non_existent.join("sbom.cdx.json");
252 assert!(!sbom_path.exists());
253 Ok(())
254 }
255
256 #[test]
257 fn test_current_timestamp_format() {
258 let ts = current_timestamp();
259 assert!(ts.contains('T'));
260 assert!(ts.ends_with('Z'));
261 assert_eq!(ts.len(), 20); }
263
264 #[test]
269 fn test_timestamp_from_secs_epoch_start() {
270 assert_eq!(timestamp_from_secs(0), "1970-01-01T00:00:00Z");
271 }
272
273 #[test]
274 fn test_timestamp_from_secs_leap_day() {
275 assert_eq!(timestamp_from_secs(1_709_209_496), "2024-02-29T12:24:56Z");
277 }
278
279 #[test]
280 fn test_timestamp_from_secs_year_2000_century_leap() {
281 assert_eq!(timestamp_from_secs(951_868_800), "2000-03-01T00:00:00Z");
284 }
285
286 #[test]
287 fn test_timestamp_from_secs_non_leap_century() {
288 assert_eq!(timestamp_from_secs(1_782_864_000), "2026-07-01T00:00:00Z");
290 }
291
292 #[test]
297 #[serial_test::parallel]
298 fn test_after_compile_errors_when_sbom_path_is_a_directory() {
299 let tmp = tempdir().unwrap();
300 fs::create_dir_all(tmp.path().join("sbom.cdx.json")).unwrap();
301 let ctx = test_ctx(tmp.path());
302 let err = SbomPlugin.after_compile(&ctx).unwrap_err();
303 assert!(format!("{err}").contains("sbom.cdx.json"));
304 }
305}
306
307#[cfg(all(test, feature = "test-fault-injection"))]
308mod fault_tests {
309 #![allow(deprecated)]
312 use super::*;
313 use serial_test::serial;
314 use tempfile::tempdir;
315
316 struct FailGuard(&'static str);
318
319 impl Drop for FailGuard {
320 fn drop(&mut self) {
321 let _ = fail::cfg(self.0, "off");
322 }
323 }
324
325 #[test]
326 #[serial]
327 fn after_compile_maps_serialize_failure_to_io_error() {
328 let _guard = FailGuard("postprocess::sbom-serialize");
329 fail::cfg("postprocess::sbom-serialize", "return")
330 .expect("activate failpoint");
331
332 let tmp = tempdir().unwrap();
333 let ctx =
334 PluginContext::new(tmp.path(), tmp.path(), tmp.path(), tmp.path());
335 let err = SbomPlugin
336 .after_compile(&ctx)
337 .expect_err("injected serialize failure must propagate");
338 let msg = format!("{err}");
339 assert!(msg.contains("sbom.cdx.json"), "got: {msg}");
340 assert!(
341 msg.contains("injected: postprocess::sbom-serialize"),
342 "got: {msg}"
343 );
344 }
345}