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)]
45pub(crate) struct CargoLockPackage {
46    pub(crate) name:    String,
47    pub(crate) version: String,
48    pub(crate) 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    // Try fraiseql.toml [project] first
94    let toml_path = Path::new("fraiseql.toml");
95    if toml_path.exists() {
96        if let Ok(content) = fs::read_to_string(toml_path) {
97            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
98                let name =
99                    parsed.get("project").and_then(|p| p.get("name")).and_then(toml::Value::as_str);
100                let version = parsed
101                    .get("project")
102                    .and_then(|p| p.get("version"))
103                    .and_then(toml::Value::as_str);
104                if name.is_some() || version.is_some() {
105                    return (
106                        name.unwrap_or("unknown").to_string(),
107                        version.unwrap_or("0.0.0").to_string(),
108                    );
109                }
110            }
111        }
112    }
113
114    // Fall back to workspace Cargo.toml
115    if let Ok(lock_path) = find_cargo_lock() {
116        let cargo_toml_path = lock_path.with_file_name("Cargo.toml");
117        if let Ok(content) = fs::read_to_string(&cargo_toml_path) {
118            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
119                let name = parsed
120                    .get("package")
121                    .and_then(|p| p.get("name"))
122                    .and_then(toml::Value::as_str)
123                    .or_else(|| {
124                        parsed
125                            .get("workspace")
126                            .and_then(|w| w.get("package"))
127                            .and_then(|p| p.get("name"))
128                            .and_then(toml::Value::as_str)
129                    })
130                    .map_or_else(
131                        || {
132                            cargo_toml_path
133                                .parent()
134                                .and_then(|p| p.file_name())
135                                .and_then(|n| n.to_str())
136                                .unwrap_or("unknown")
137                                .to_string()
138                        },
139                        String::from,
140                    );
141                let version = parsed
142                    .get("package")
143                    .and_then(|p| p.get("version"))
144                    .and_then(toml::Value::as_str)
145                    .or_else(|| {
146                        parsed
147                            .get("workspace")
148                            .and_then(|w| w.get("package"))
149                            .and_then(|p| p.get("version"))
150                            .and_then(toml::Value::as_str)
151                    })
152                    .unwrap_or("0.0.0")
153                    .to_string();
154                return (name, version);
155            }
156        }
157    }
158
159    ("unknown".to_string(), "0.0.0".to_string())
160}
161
162fn parse_cargo_lock() -> Result<Vec<CargoLockPackage>> {
163    // Search for Cargo.lock in current dir or parent dirs
164    let lock_path = find_cargo_lock()?;
165
166    let content = fs::read_to_string(&lock_path)
167        .context(format!("Failed to read {}", lock_path.display()))?;
168
169    parse_cargo_lock_content(&content)
170}
171
172pub(crate) fn parse_cargo_lock_content(content: &str) -> Result<Vec<CargoLockPackage>> {
173    let lock: CargoLock = toml::from_str(content).context("Failed to parse Cargo.lock")?;
174    Ok(lock.package)
175}
176
177pub(crate) fn find_cargo_lock() -> Result<std::path::PathBuf> {
178    let mut dir = std::env::current_dir().context("Failed to get current directory")?;
179
180    loop {
181        let candidate = dir.join("Cargo.lock");
182        if candidate.exists() {
183            return Ok(candidate);
184        }
185
186        if !dir.pop() {
187            break;
188        }
189    }
190
191    anyhow::bail!(
192        "Cargo.lock not found. Run from a Rust project directory or a subdirectory of one."
193    )
194}
195
196pub(crate) fn generate_cyclonedx(
197    project_name: &str,
198    project_version: &str,
199    packages: &[CargoLockPackage],
200) -> Result<String> {
201    let components: Vec<serde_json::Value> = packages
202        .iter()
203        .map(|pkg| {
204            let mut component = serde_json::json!({
205                "type": "library",
206                "name": pkg.name,
207                "version": pkg.version,
208                "purl": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
209            });
210
211            if let Some(source) = &pkg.source {
212                if source.contains("registry") {
213                    component["externalReferences"] = serde_json::json!([{
214                        "type": "distribution",
215                        "url": format!("https://crates.io/crates/{}", pkg.name),
216                    }]);
217                }
218            }
219
220            component
221        })
222        .collect();
223
224    let sbom = serde_json::json!({
225        "bomFormat": "CycloneDX",
226        "specVersion": "1.5",
227        "version": 1,
228        "metadata": {
229            "component": {
230                "type": "application",
231                "name": project_name,
232                "version": project_version,
233            },
234            "tools": [{
235                "vendor": "FraiseQL",
236                "name": "fraiseql-cli",
237                "version": env!("CARGO_PKG_VERSION"),
238            }],
239        },
240        "components": components,
241    });
242
243    serde_json::to_string_pretty(&sbom).context("Failed to serialize CycloneDX SBOM")
244}
245
246pub(crate) fn generate_spdx(
247    project_name: &str,
248    project_version: &str,
249    packages: &[CargoLockPackage],
250) -> Result<String> {
251    let spdx_packages: Vec<serde_json::Value> = packages
252        .iter()
253        .enumerate()
254        .map(|(i, pkg)| {
255            serde_json::json!({
256                "SPDXID": format!("SPDXRef-Package-{}", i + 1),
257                "name": pkg.name,
258                "versionInfo": pkg.version,
259                "downloadLocation": pkg.source.as_deref().unwrap_or("NOASSERTION"),
260                "filesAnalyzed": false,
261                "externalRefs": [{
262                    "referenceCategory": "PACKAGE-MANAGER",
263                    "referenceType": "purl",
264                    "referenceLocator": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
265                }],
266            })
267        })
268        .collect();
269
270    let relationships: Vec<serde_json::Value> = packages
271        .iter()
272        .enumerate()
273        .map(|(i, _)| {
274            serde_json::json!({
275                "spdxElementId": "SPDXRef-DOCUMENT",
276                "relatedSpdxElement": format!("SPDXRef-Package-{}", i + 1),
277                "relationshipType": "DESCRIBES",
278            })
279        })
280        .collect();
281
282    let sbom = serde_json::json!({
283        "spdxVersion": "SPDX-2.3",
284        "dataLicense": "CC0-1.0",
285        "SPDXID": "SPDXRef-DOCUMENT",
286        "name": format!("{project_name}-{project_version}"),
287        "documentNamespace": format!("https://spdx.org/spdxdocs/{project_name}-{project_version}"),
288        "creationInfo": {
289            "created": chrono_now_utc(),
290            "creators": [
291                format!("Tool: fraiseql-cli-{}", env!("CARGO_PKG_VERSION")),
292            ],
293        },
294        "packages": spdx_packages,
295        "relationships": relationships,
296    });
297
298    serde_json::to_string_pretty(&sbom).context("Failed to serialize SPDX SBOM")
299}
300
301/// Get current UTC timestamp in ISO 8601 format without external chrono dependency
302pub(crate) fn chrono_now_utc() -> String {
303    // Use std::time to get a basic timestamp
304    let now = std::time::SystemTime::now();
305    let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
306    let secs = duration.as_secs();
307
308    // Convert to date components (simplified)
309    let days = secs / 86400;
310    let remaining = secs % 86400;
311    let hours = remaining / 3600;
312    let minutes = (remaining % 3600) / 60;
313    let seconds = remaining % 60;
314
315    // Calculate year/month/day from days since epoch (1970-01-01)
316    let (year, month, day) = days_to_date(days);
317
318    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
319}
320
321/// Convert days since Unix epoch to (year, month, day)
322pub(crate) const fn days_to_date(days: u64) -> (u64, u64, u64) {
323    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
324    let z = days + 719_468;
325    let era = z / 146_097;
326    let doe = z - era * 146_097;
327    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
328    let y = yoe + era * 400;
329    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
330    let mp = (5 * doy + 2) / 153;
331    let d = doy - (153 * mp + 2) / 5 + 1;
332    let m = if mp < 10 { mp + 3 } else { mp - 9 };
333    let y = if m <= 2 { y + 1 } else { y };
334    (y, m, d)
335}