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)]
45pub(crate) struct CargoLockPackage {
46 pub(crate) name: String,
47 pub(crate) version: String,
48 pub(crate) 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");
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 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 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
301pub(crate) fn chrono_now_utc() -> String {
303 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 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 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
321pub(crate) const fn days_to_date(days: u64) -> (u64, u64, u64) {
323 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}