1use std::{fmt, fs, path::Path, str::FromStr};
7
8use anyhow::{Context, Result};
9use serde::Deserialize;
10use tracing::info;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum SbomFormat {
15 CycloneDx,
17 Spdx,
19}
20
21impl fmt::Display for SbomFormat {
22 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
23 match self {
24 Self::CycloneDx => write!(f, "cyclonedx"),
25 Self::Spdx => write!(f, "spdx"),
26 }
27 }
28}
29
30impl FromStr for SbomFormat {
31 type Err = String;
32
33 fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
34 match s.to_lowercase().as_str() {
35 "cyclonedx" | "cdx" => Ok(Self::CycloneDx),
36 "spdx" => Ok(Self::Spdx),
37 other => Err(format!("Unknown SBOM format: {other}. Choose: cyclonedx, spdx")),
38 }
39 }
40}
41
42#[derive(Debug, Deserialize)]
44struct CargoLockPackage {
45 name: String,
46 version: String,
47 source: Option<String>,
48}
49
50#[derive(Debug, Deserialize)]
52struct CargoLock {
53 package: Vec<CargoLockPackage>,
54}
55
56pub fn run(format: SbomFormat, output: Option<&str>) -> Result<()> {
58 info!("Generating SBOM in {format} format");
59
60 let (project_name, project_version) = load_project_metadata();
62
63 let packages = parse_cargo_lock()?;
65
66 let sbom = match format {
68 SbomFormat::CycloneDx => generate_cyclonedx(&project_name, &project_version, &packages)?,
69 SbomFormat::Spdx => generate_spdx(&project_name, &project_version, &packages)?,
70 };
71
72 match output {
74 Some(path) => {
75 fs::write(path, &sbom).context(format!("Failed to write SBOM to {path}"))?;
76 println!("SBOM written to {path}");
77 },
78 None => {
79 println!("{sbom}");
80 },
81 }
82
83 Ok(())
84}
85
86fn load_project_metadata() -> (String, String) {
87 let toml_path = Path::new("fraiseql.toml");
88 if toml_path.exists() {
89 if let Ok(content) = fs::read_to_string(toml_path) {
90 if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
91 let name = parsed
92 .get("project")
93 .and_then(|p| p.get("name"))
94 .and_then(toml::Value::as_str)
95 .unwrap_or("unknown")
96 .to_string();
97 let version = parsed
98 .get("project")
99 .and_then(|p| p.get("version"))
100 .and_then(toml::Value::as_str)
101 .unwrap_or("0.0.0")
102 .to_string();
103 return (name, version);
104 }
105 }
106 }
107 ("unknown".to_string(), "0.0.0".to_string())
108}
109
110fn parse_cargo_lock() -> Result<Vec<CargoLockPackage>> {
111 let lock_path = find_cargo_lock()?;
113
114 let content = fs::read_to_string(&lock_path)
115 .context(format!("Failed to read {}", lock_path.display()))?;
116
117 parse_cargo_lock_content(&content)
118}
119
120fn parse_cargo_lock_content(content: &str) -> Result<Vec<CargoLockPackage>> {
121 let lock: CargoLock = toml::from_str(content).context("Failed to parse Cargo.lock")?;
122 Ok(lock.package)
123}
124
125fn find_cargo_lock() -> Result<std::path::PathBuf> {
126 let mut dir = std::env::current_dir().context("Failed to get current directory")?;
127
128 loop {
129 let candidate = dir.join("Cargo.lock");
130 if candidate.exists() {
131 return Ok(candidate);
132 }
133
134 if !dir.pop() {
135 break;
136 }
137 }
138
139 anyhow::bail!(
140 "Cargo.lock not found. Run from a Rust project directory or a subdirectory of one."
141 )
142}
143
144fn generate_cyclonedx(
145 project_name: &str,
146 project_version: &str,
147 packages: &[CargoLockPackage],
148) -> Result<String> {
149 let components: Vec<serde_json::Value> = packages
150 .iter()
151 .map(|pkg| {
152 let mut component = serde_json::json!({
153 "type": "library",
154 "name": pkg.name,
155 "version": pkg.version,
156 "purl": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
157 });
158
159 if let Some(source) = &pkg.source {
160 if source.contains("registry") {
161 component["externalReferences"] = serde_json::json!([{
162 "type": "distribution",
163 "url": format!("https://crates.io/crates/{}", pkg.name),
164 }]);
165 }
166 }
167
168 component
169 })
170 .collect();
171
172 let sbom = serde_json::json!({
173 "bomFormat": "CycloneDX",
174 "specVersion": "1.5",
175 "version": 1,
176 "metadata": {
177 "component": {
178 "type": "application",
179 "name": project_name,
180 "version": project_version,
181 },
182 "tools": [{
183 "vendor": "FraiseQL",
184 "name": "fraiseql-cli",
185 "version": env!("CARGO_PKG_VERSION"),
186 }],
187 },
188 "components": components,
189 });
190
191 serde_json::to_string_pretty(&sbom).context("Failed to serialize CycloneDX SBOM")
192}
193
194fn generate_spdx(
195 project_name: &str,
196 project_version: &str,
197 packages: &[CargoLockPackage],
198) -> Result<String> {
199 let spdx_packages: Vec<serde_json::Value> = packages
200 .iter()
201 .enumerate()
202 .map(|(i, pkg)| {
203 serde_json::json!({
204 "SPDXID": format!("SPDXRef-Package-{}", i + 1),
205 "name": pkg.name,
206 "versionInfo": pkg.version,
207 "downloadLocation": pkg.source.as_deref().unwrap_or("NOASSERTION"),
208 "filesAnalyzed": false,
209 "externalRefs": [{
210 "referenceCategory": "PACKAGE-MANAGER",
211 "referenceType": "purl",
212 "referenceLocator": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
213 }],
214 })
215 })
216 .collect();
217
218 let relationships: Vec<serde_json::Value> = packages
219 .iter()
220 .enumerate()
221 .map(|(i, _)| {
222 serde_json::json!({
223 "spdxElementId": "SPDXRef-DOCUMENT",
224 "relatedSpdxElement": format!("SPDXRef-Package-{}", i + 1),
225 "relationshipType": "DESCRIBES",
226 })
227 })
228 .collect();
229
230 let sbom = serde_json::json!({
231 "spdxVersion": "SPDX-2.3",
232 "dataLicense": "CC0-1.0",
233 "SPDXID": "SPDXRef-DOCUMENT",
234 "name": format!("{project_name}-{project_version}"),
235 "documentNamespace": format!("https://spdx.org/spdxdocs/{project_name}-{project_version}"),
236 "creationInfo": {
237 "created": chrono_now_utc(),
238 "creators": [
239 format!("Tool: fraiseql-cli-{}", env!("CARGO_PKG_VERSION")),
240 ],
241 },
242 "packages": spdx_packages,
243 "relationships": relationships,
244 });
245
246 serde_json::to_string_pretty(&sbom).context("Failed to serialize SPDX SBOM")
247}
248
249fn chrono_now_utc() -> String {
251 let now = std::time::SystemTime::now();
253 let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
254 let secs = duration.as_secs();
255
256 let days = secs / 86400;
258 let remaining = secs % 86400;
259 let hours = remaining / 3600;
260 let minutes = (remaining % 3600) / 60;
261 let seconds = remaining % 60;
262
263 let (year, month, day) = days_to_date(days);
265
266 format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
267}
268
269const fn days_to_date(days: u64) -> (u64, u64, u64) {
271 let z = days + 719_468;
273 let era = z / 146_097;
274 let doe = z - era * 146_097;
275 let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
276 let y = yoe + era * 400;
277 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
278 let mp = (5 * doy + 2) / 153;
279 let d = doy - (153 * mp + 2) / 5 + 1;
280 let m = if mp < 10 { mp + 3 } else { mp - 9 };
281 let y = if m <= 2 { y + 1 } else { y };
282 (y, m, d)
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288
289 #[test]
290 fn test_sbom_format_from_str() {
291 assert_eq!(SbomFormat::from_str("cyclonedx").unwrap(), SbomFormat::CycloneDx);
292 assert_eq!(SbomFormat::from_str("cdx").unwrap(), SbomFormat::CycloneDx);
293 assert_eq!(SbomFormat::from_str("spdx").unwrap(), SbomFormat::Spdx);
294 assert!(SbomFormat::from_str("csv").is_err());
295 }
296
297 #[test]
298 fn test_generate_cyclonedx() {
299 let packages = vec![
300 CargoLockPackage {
301 name: "serde".to_string(),
302 version: "1.0.200".to_string(),
303 source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
304 },
305 CargoLockPackage {
306 name: "tokio".to_string(),
307 version: "1.42.0".to_string(),
308 source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
309 },
310 ];
311
312 let result = generate_cyclonedx("test-app", "1.0.0", &packages).unwrap();
313 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
314
315 assert_eq!(parsed["bomFormat"], "CycloneDX");
316 assert_eq!(parsed["specVersion"], "1.5");
317 assert_eq!(parsed["metadata"]["component"]["name"], "test-app");
318 assert_eq!(parsed["components"].as_array().unwrap().len(), 2);
319 assert_eq!(parsed["components"][0]["name"], "serde");
320 assert!(
321 parsed["components"][0]["purl"]
322 .as_str()
323 .unwrap()
324 .contains("pkg:cargo/serde@1.0.200")
325 );
326 }
327
328 #[test]
329 fn test_generate_spdx() {
330 let packages = vec![CargoLockPackage {
331 name: "anyhow".to_string(),
332 version: "1.0.0".to_string(),
333 source: Some("registry+https://github.com/rust-lang/crates.io-index".to_string()),
334 }];
335
336 let result = generate_spdx("test-app", "0.1.0", &packages).unwrap();
337 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
338
339 assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
340 assert_eq!(parsed["packages"].as_array().unwrap().len(), 1);
341 assert_eq!(parsed["packages"][0]["name"], "anyhow");
342 }
343
344 #[test]
345 fn test_find_cargo_lock() {
346 let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
348 let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
349 let cargo_lock = workspace_root.join("Cargo.lock");
350 assert!(cargo_lock.exists(), "Should find Cargo.lock in workspace root");
351 }
352
353 #[test]
354 fn test_parse_cargo_lock() {
355 let manifest_dir = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
356 let workspace_root = manifest_dir.parent().unwrap().parent().unwrap();
357 let cargo_lock = workspace_root.join("Cargo.lock");
358 let content = std::fs::read_to_string(&cargo_lock).unwrap();
359 let packages = parse_cargo_lock_content(&content).unwrap();
360 assert!(!packages.is_empty(), "Cargo.lock should contain packages");
361
362 let has_serde = packages.iter().any(|p| p.name == "serde");
364 assert!(has_serde, "Should contain serde dependency");
365 }
366
367 #[test]
368 fn test_days_to_date_epoch() {
369 let (y, m, d) = days_to_date(0);
370 assert_eq!((y, m, d), (1970, 1, 1));
371 }
372
373 #[test]
374 fn test_days_to_date_known() {
375 let (y, m, d) = days_to_date(19_723);
377 assert_eq!((y, m, d), (2024, 1, 1));
378 }
379
380 #[test]
381 fn test_chrono_now_utc_format() {
382 let ts = chrono_now_utc();
383 assert!(ts.ends_with('Z'));
385 assert!(ts.contains('T'));
386 assert_eq!(ts.len(), 20); }
388}