Skip to main content

anodizer_stage_source/
sbom.rs

1//! SBOM generation (CycloneDX 1.5 + SPDX 2.3) plus Cargo.lock parsing.
2
3use anyhow::{Context as _, Result};
4
5// ---------------------------------------------------------------------------
6// SBOM generation
7// ---------------------------------------------------------------------------
8
9/// A parsed Cargo.lock package entry.
10#[derive(Debug, Clone)]
11pub struct CargoPackage {
12    pub name: String,
13    pub version: String,
14    pub source: Option<String>,
15}
16
17/// Parse `Cargo.lock` to extract package entries.
18pub fn parse_cargo_lock(content: &str) -> Result<Vec<CargoPackage>> {
19    let parsed: toml::Value =
20        toml::from_str(content).context("sbom: failed to parse Cargo.lock as TOML")?;
21
22    let packages = parsed
23        .get("package")
24        .and_then(|p| p.as_array())
25        .map(|arr| {
26            arr.iter()
27                .filter_map(|entry| {
28                    let name = entry.get("name")?.as_str()?.to_string();
29                    let version = entry.get("version")?.as_str()?.to_string();
30                    let source = entry
31                        .get("source")
32                        .and_then(|s| s.as_str())
33                        .map(|s| s.to_string());
34                    Some(CargoPackage {
35                        name,
36                        version,
37                        source,
38                    })
39                })
40                .collect()
41        })
42        .unwrap_or_default();
43
44    Ok(packages)
45}
46
47/// Generate a CycloneDX 1.5 SBOM in JSON format.
48///
49/// `timestamp` is embedded in `metadata.timestamp` and must be supplied by the
50/// caller so that repeated pipeline runs (e.g. anodizer-action retries) emit
51/// byte-identical output. Callers should derive it from `ctx.template_vars()`
52/// (`CommitDate`) so the value is tied to the release tag, not wall-clock.
53pub fn generate_cyclonedx(
54    project_name: &str,
55    version: &str,
56    timestamp: &str,
57    packages: &[CargoPackage],
58) -> Result<serde_json::Value> {
59    let components: Vec<serde_json::Value> = packages
60        .iter()
61        .map(|pkg| {
62            let mut component = serde_json::json!({
63                "type": "library",
64                "name": pkg.name,
65                "version": pkg.version,
66                "purl": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
67            });
68
69            if let Some(ref source) = pkg.source
70                && source.starts_with("registry+")
71            {
72                component["externalReferences"] = serde_json::json!([
73                    {
74                        "type": "distribution",
75                        "url": format!("https://crates.io/crates/{}/{}", pkg.name, pkg.version)
76                    }
77                ]);
78            }
79
80            component
81        })
82        .collect();
83
84    let sbom = serde_json::json!({
85        "bomFormat": "CycloneDX",
86        "specVersion": "1.5",
87        "version": 1,
88        "metadata": {
89            "timestamp": timestamp,
90            "component": {
91                "type": "application",
92                "name": project_name,
93                "version": version,
94            },
95            "tools": {
96                "components": [
97                    {
98                        "type": "application",
99                        "name": "anodizer",
100                        "publisher": "anodizer",
101                    }
102                ]
103            }
104        },
105        "components": components,
106    });
107
108    Ok(sbom)
109}
110
111/// Generate an SPDX 2.3 SBOM in JSON format.
112///
113/// `timestamp` populates `creationInfo.created`; `namespace_uuid` populates the
114/// trailing segment of `documentNamespace`. Both are caller-supplied so the
115/// output is byte-identical across repeated pipeline runs (release asset
116/// uploads are non-idempotent when the file bytes differ from a prior
117/// upload — GitHub's ReleaseAsset API rejects re-uploads with `already_exists`
118/// when sizes diverge).
119pub fn generate_spdx(
120    project_name: &str,
121    version: &str,
122    timestamp: &str,
123    namespace_uuid: &str,
124    packages: &[CargoPackage],
125) -> Result<serde_json::Value> {
126    // The root package
127    let root_package = serde_json::json!({
128        "SPDXID": "SPDXRef-Package",
129        "name": project_name,
130        "versionInfo": version,
131        "downloadLocation": "NOASSERTION",
132        "filesAnalyzed": false,
133    });
134
135    let mut spdx_packages = vec![root_package];
136    let mut relationships = vec![serde_json::json!({
137        "spdxElementId": "SPDXRef-DOCUMENT",
138        "relatedSpdxElement": "SPDXRef-Package",
139        "relationshipType": "DESCRIBES",
140    })];
141
142    for (i, pkg) in packages.iter().enumerate() {
143        let spdx_id = format!("SPDXRef-Package-{}", i);
144
145        let download_location = if let Some(ref source) = pkg.source {
146            if source.starts_with("registry+") {
147                format!("https://crates.io/crates/{}/{}", pkg.name, pkg.version)
148            } else {
149                source.clone()
150            }
151        } else {
152            "NOASSERTION".to_string()
153        };
154
155        let pkg_entry = serde_json::json!({
156            "SPDXID": spdx_id,
157            "name": pkg.name,
158            "versionInfo": pkg.version,
159            "downloadLocation": download_location,
160            "filesAnalyzed": false,
161            "externalRefs": [
162                {
163                    "referenceCategory": "PACKAGE-MANAGER",
164                    "referenceType": "purl",
165                    "referenceLocator": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
166                }
167            ],
168        });
169
170        spdx_packages.push(pkg_entry);
171
172        relationships.push(serde_json::json!({
173            "spdxElementId": "SPDXRef-Package",
174            "relatedSpdxElement": spdx_id,
175            "relationshipType": "DEPENDS_ON",
176        }));
177    }
178
179    let sbom = serde_json::json!({
180        "spdxVersion": "SPDX-2.3",
181        "dataLicense": "CC0-1.0",
182        "SPDXID": "SPDXRef-DOCUMENT",
183        "name": format!("{}-{}", project_name, version),
184        "documentNamespace": format!(
185            "https://spdx.org/spdxdocs/{}-{}-{}",
186            project_name, version, namespace_uuid,
187        ),
188        "creationInfo": {
189            "created": timestamp,
190            "creators": ["Tool: anodizer"],
191        },
192        "packages": spdx_packages,
193        "relationships": relationships,
194    });
195
196    Ok(sbom)
197}
198
199/// Deterministic UUID v4-shaped identifier derived from `seed`.
200///
201/// Same seed always produces the same UUID. Not cryptographic — the value is
202/// only used as the trailing component of an SPDX `documentNamespace`, where
203/// the purpose is per-document uniqueness within a project, not secrecy.
204///
205/// Note: `DefaultHasher` output is not stable across Rust versions, so the
206/// same `seed` may produce different UUIDs when compiled with different Rust
207/// toolchains. Determinism is only guaranteed within a single toolchain, which
208/// is all the release-pipeline idempotency path needs.
209pub fn deterministic_uuid_from(seed: &str) -> String {
210    use std::collections::hash_map::DefaultHasher;
211    use std::hash::{Hash, Hasher};
212
213    let mut h1 = DefaultHasher::new();
214    seed.hash(&mut h1);
215    "anodizer-sbom-ns-v1".hash(&mut h1);
216    let h1 = h1.finish();
217
218    let mut h2 = DefaultHasher::new();
219    seed.hash(&mut h2);
220    "anodizer-sbom-ns-v2".hash(&mut h2);
221    let h2 = h2.finish();
222
223    format!(
224        "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
225        (h1 >> 32) as u32,
226        (h1 >> 16) as u16,
227        h1 as u16 & 0x0FFF,
228        (h2 >> 48) as u16 & 0x3FFF | 0x8000,
229        h2 & 0xFFFF_FFFF_FFFF,
230    )
231}