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