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)]
14pub enum SbomFormat {
15    /// CycloneDX JSON format (default)
16    CycloneDx,
17    /// SPDX JSON format
18    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/// Parsed Cargo.lock package entry
43#[derive(Debug, Deserialize)]
44struct CargoLockPackage {
45    name:    String,
46    version: String,
47    source:  Option<String>,
48}
49
50/// Parsed Cargo.lock file
51#[derive(Debug, Deserialize)]
52struct CargoLock {
53    package: Vec<CargoLockPackage>,
54}
55
56/// Run the SBOM command
57pub fn run(format: SbomFormat, output: Option<&str>) -> Result<()> {
58    info!("Generating SBOM in {format} format");
59
60    // Load project metadata from fraiseql.toml (optional)
61    let (project_name, project_version) = load_project_metadata();
62
63    // Parse Cargo.lock
64    let packages = parse_cargo_lock()?;
65
66    // Generate SBOM
67    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    // Output
73    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    // Search for Cargo.lock in current dir or parent dirs
112    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
249/// Get current UTC timestamp in ISO 8601 format without external chrono dependency
250fn chrono_now_utc() -> String {
251    // Use std::time to get a basic timestamp
252    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    // Convert to date components (simplified)
257    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    // Calculate year/month/day from days since epoch (1970-01-01)
264    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
269/// Convert days since Unix epoch to (year, month, day)
270const fn days_to_date(days: u64) -> (u64, u64, u64) {
271    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
272    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        // Use CARGO_MANIFEST_DIR to avoid cwd race conditions under parallel test execution
347        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        // Should contain known dependencies
363        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        // 2024-01-01 = 19723 days since epoch
376        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        // Should match ISO 8601 format
384        assert!(ts.ends_with('Z'));
385        assert!(ts.contains('T'));
386        assert_eq!(ts.len(), 20); // "YYYY-MM-DDTHH:MM:SSZ"
387    }
388}