Skip to main content

fraiseql_cli/commands/
sbom.rs

1//! `fraiseql sbom` - Software Bill of Materials generator
2//!
3//! Generates SBOM in CycloneDX JSON or SPDX format by parsing
4//! Cargo.lock for Rust dependencies and fraiseql.toml for project metadata.
5
6use std::{fmt, fs, path::Path, str::FromStr};
7
8use anyhow::{Context, Result};
9use serde::Deserialize;
10use tracing::info;
11
12/// Output format for SBOM
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14#[non_exhaustive]
15pub enum SbomFormat {
16    /// CycloneDX JSON format (default)
17    CycloneDx,
18    /// SPDX JSON format
19    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/// Parsed Cargo.lock package entry
44#[derive(Debug, Deserialize)]
45struct CargoLockPackage {
46    name:    String,
47    version: String,
48    source:  Option<String>,
49}
50
51/// Parsed Cargo.lock file
52#[derive(Debug, Deserialize)]
53struct CargoLock {
54    package: Vec<CargoLockPackage>,
55}
56
57/// Run the SBOM command
58///
59/// # Errors
60///
61/// Returns an error if `Cargo.lock` cannot be found or parsed, if SBOM
62/// serialization fails, or if the output file cannot be written.
63pub fn run(format: SbomFormat, output: Option<&str>) -> Result<()> {
64    info!("Generating SBOM in {format} format");
65
66    // Load project metadata from fraiseql.toml (optional)
67    let (project_name, project_version) = load_project_metadata();
68
69    // Parse Cargo.lock
70    let packages = parse_cargo_lock()?;
71
72    // Generate SBOM
73    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    // Output
79    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    // Search for Cargo.lock in current dir or parent dirs
118    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
255/// Get current UTC timestamp in ISO 8601 format without external chrono dependency
256fn chrono_now_utc() -> String {
257    // Use std::time to get a basic timestamp
258    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    // Convert to date components (simplified)
263    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    // Calculate year/month/day from days since epoch (1970-01-01)
270    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
275/// Convert days since Unix epoch to (year, month, day)
276const fn days_to_date(days: u64) -> (u64, u64, u64) {
277    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
278    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)] // Reason: test code, panics are acceptable
292#[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        // Use CARGO_MANIFEST_DIR to avoid cwd race conditions under parallel test execution
354        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        // Should contain known dependencies
370        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        // 2024-01-01 = 19723 days since epoch
383        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        // Should match ISO 8601 format
391        assert!(ts.ends_with('Z'));
392        assert!(ts.contains('T'));
393        assert_eq!(ts.len(), 20); // "YYYY-MM-DDTHH:MM:SSZ"
394    }
395}