|
| 1 | +//! Generate a [CycloneDX](https://cyclonedx.org/) 1.5 SBOM (JSON) from a solved |
| 2 | +//! lock. |
| 3 | +//! |
| 4 | +//! A lock already pins every conda package with its exact version, build, and |
| 5 | +//! content hash — a software bill of materials is a direct projection of that. |
| 6 | +//! The document is **deterministic**: components are keyed by package URL and |
| 7 | +//! emitted in sorted order, and no generation timestamp is included, so the same |
| 8 | +//! lock always yields byte-identical SBOM output (itself a useful property to |
| 9 | +//! attest). |
| 10 | +
|
| 11 | +use std::collections::BTreeMap; |
| 12 | + |
| 13 | +use rattler_lock::LockFile; |
| 14 | +use serde::Serialize; |
| 15 | + |
| 16 | +/// Errors raised while generating an SBOM. |
| 17 | +#[derive(Debug)] |
| 18 | +pub enum SbomError { |
| 19 | + /// Reading conda records out of the lock failed. |
| 20 | + Lock(String), |
| 21 | + /// Serialising the SBOM document to JSON failed. |
| 22 | + Serialize(serde_json::Error), |
| 23 | +} |
| 24 | + |
| 25 | +impl std::fmt::Display for SbomError { |
| 26 | + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| 27 | + match self { |
| 28 | + SbomError::Lock(msg) => write!(f, "{msg}"), |
| 29 | + SbomError::Serialize(e) => write!(f, "serialising SBOM failed: {e}"), |
| 30 | + } |
| 31 | + } |
| 32 | +} |
| 33 | + |
| 34 | +impl std::error::Error for SbomError {} |
| 35 | + |
| 36 | +#[derive(Serialize)] |
| 37 | +struct Bom { |
| 38 | + #[serde(rename = "bomFormat")] |
| 39 | + bom_format: &'static str, |
| 40 | + #[serde(rename = "specVersion")] |
| 41 | + spec_version: &'static str, |
| 42 | + version: u32, |
| 43 | + metadata: Metadata, |
| 44 | + components: Vec<Component>, |
| 45 | +} |
| 46 | + |
| 47 | +#[derive(Serialize)] |
| 48 | +struct Metadata { |
| 49 | + tools: Vec<Tool>, |
| 50 | +} |
| 51 | + |
| 52 | +#[derive(Serialize)] |
| 53 | +struct Tool { |
| 54 | + vendor: &'static str, |
| 55 | + name: &'static str, |
| 56 | + version: &'static str, |
| 57 | +} |
| 58 | + |
| 59 | +#[derive(Serialize)] |
| 60 | +struct Component { |
| 61 | + #[serde(rename = "bom-ref")] |
| 62 | + bom_ref: String, |
| 63 | + #[serde(rename = "type")] |
| 64 | + kind: &'static str, |
| 65 | + name: String, |
| 66 | + version: String, |
| 67 | + purl: String, |
| 68 | + #[serde(skip_serializing_if = "Vec::is_empty")] |
| 69 | + hashes: Vec<Hash>, |
| 70 | + #[serde(skip_serializing_if = "Vec::is_empty")] |
| 71 | + licenses: Vec<LicenseChoice>, |
| 72 | +} |
| 73 | + |
| 74 | +#[derive(Serialize)] |
| 75 | +struct Hash { |
| 76 | + alg: &'static str, |
| 77 | + content: String, |
| 78 | +} |
| 79 | + |
| 80 | +#[derive(Serialize)] |
| 81 | +struct LicenseChoice { |
| 82 | + license: License, |
| 83 | +} |
| 84 | + |
| 85 | +#[derive(Serialize)] |
| 86 | +struct License { |
| 87 | + name: String, |
| 88 | +} |
| 89 | + |
| 90 | +/// Build a conda package URL (purl): `pkg:conda/<name>@<version>` with `build` |
| 91 | +/// and `subdir` qualifiers. conda names, versions, and builds use a purl-safe |
| 92 | +/// character set, so no percent-encoding is required. |
| 93 | +fn conda_purl(name: &str, version: &str, build: &str, subdir: &str) -> String { |
| 94 | + format!("pkg:conda/{name}@{version}?build={build}&subdir={subdir}") |
| 95 | +} |
| 96 | + |
| 97 | +/// Render `lock` as a CycloneDX 1.5 JSON SBOM. Every distinct conda package |
| 98 | +/// across all environments and platforms in the lock becomes one component, |
| 99 | +/// deduplicated and sorted by package URL. |
| 100 | +pub fn to_cyclonedx(lock: &LockFile) -> Result<String, SbomError> { |
| 101 | + let mut components: BTreeMap<String, Component> = BTreeMap::new(); |
| 102 | + |
| 103 | + for (_env_name, env) in lock.environments() { |
| 104 | + for platform in env.platforms() { |
| 105 | + let records = env |
| 106 | + .conda_repodata_records(platform) |
| 107 | + .map_err(|e| SbomError::Lock(format!("converting lock records: {e}")))? |
| 108 | + .unwrap_or_default(); |
| 109 | + for record in records { |
| 110 | + let pr = &record.package_record; |
| 111 | + let name = pr.name.as_normalized().to_string(); |
| 112 | + let version = pr.version.as_str().to_string(); |
| 113 | + let build = pr.build.clone(); |
| 114 | + let subdir = pr.subdir.clone(); |
| 115 | + let purl = conda_purl(&name, &version, &build, &subdir); |
| 116 | + if components.contains_key(&purl) { |
| 117 | + continue; |
| 118 | + } |
| 119 | + let hashes = pr |
| 120 | + .sha256 |
| 121 | + .as_ref() |
| 122 | + .map(|digest| { |
| 123 | + vec![Hash { |
| 124 | + alg: "SHA-256", |
| 125 | + content: hex::encode(digest), |
| 126 | + }] |
| 127 | + }) |
| 128 | + .unwrap_or_default(); |
| 129 | + let licenses = pr |
| 130 | + .license |
| 131 | + .as_ref() |
| 132 | + .filter(|l| !l.is_empty()) |
| 133 | + .map(|name| { |
| 134 | + vec![LicenseChoice { |
| 135 | + license: License { name: name.clone() }, |
| 136 | + }] |
| 137 | + }) |
| 138 | + .unwrap_or_default(); |
| 139 | + components.insert( |
| 140 | + purl.clone(), |
| 141 | + Component { |
| 142 | + bom_ref: purl.clone(), |
| 143 | + kind: "library", |
| 144 | + name, |
| 145 | + version, |
| 146 | + purl, |
| 147 | + hashes, |
| 148 | + licenses, |
| 149 | + }, |
| 150 | + ); |
| 151 | + } |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + let bom = Bom { |
| 156 | + bom_format: "CycloneDX", |
| 157 | + spec_version: "1.5", |
| 158 | + version: 1, |
| 159 | + metadata: Metadata { |
| 160 | + tools: vec![Tool { |
| 161 | + vendor: "nepenthe", |
| 162 | + name: "nepenthe", |
| 163 | + version: env!("CARGO_PKG_VERSION"), |
| 164 | + }], |
| 165 | + }, |
| 166 | + components: components.into_values().collect(), |
| 167 | + }; |
| 168 | + |
| 169 | + serde_json::to_string_pretty(&bom).map_err(SbomError::Serialize) |
| 170 | +} |
| 171 | + |
| 172 | +#[cfg(test)] |
| 173 | +mod tests { |
| 174 | + use super::*; |
| 175 | + |
| 176 | + #[test] |
| 177 | + fn conda_purl_carries_build_and_subdir() { |
| 178 | + assert_eq!( |
| 179 | + conda_purl("numpy", "2.1.0", "py311h0001_0", "linux-64"), |
| 180 | + "pkg:conda/numpy@2.1.0?build=py311h0001_0&subdir=linux-64" |
| 181 | + ); |
| 182 | + } |
| 183 | + |
| 184 | + /// A minimal real lock (one package) renders a valid, deterministic |
| 185 | + /// CycloneDX document with the package as a component. |
| 186 | + #[test] |
| 187 | + fn renders_cyclonedx_from_a_lock() { |
| 188 | + let yaml = r#"version: 6 |
| 189 | +environments: |
| 190 | + default: |
| 191 | + channels: |
| 192 | + - url: https://conda.anaconda.org/conda-forge/ |
| 193 | + packages: |
| 194 | + linux-64: |
| 195 | + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda |
| 196 | +packages: |
| 197 | +- conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda |
| 198 | + sha256: 0a8c9a0b0a0d0e0f0102030405060708090a0b0c0d0e0f101112131415161718 |
| 199 | + md5: 9c12429eb8e07e7c5d36a8b8b0d0e0f0 |
| 200 | + license: ISC |
| 201 | + size: 159003 |
| 202 | + timestamp: 1725018903918 |
| 203 | +"#; |
| 204 | + let lock = LockFile::from_str_with_base_directory(yaml, None).expect("valid lock"); |
| 205 | + let json = to_cyclonedx(&lock).expect("renders"); |
| 206 | + let doc: serde_json::Value = serde_json::from_str(&json).expect("valid json"); |
| 207 | + |
| 208 | + assert_eq!(doc["bomFormat"], "CycloneDX"); |
| 209 | + assert_eq!(doc["specVersion"], "1.5"); |
| 210 | + let components = doc["components"].as_array().expect("components array"); |
| 211 | + assert_eq!(components.len(), 1); |
| 212 | + let c = &components[0]; |
| 213 | + assert_eq!(c["name"], "ca-certificates"); |
| 214 | + assert_eq!(c["type"], "library"); |
| 215 | + assert_eq!( |
| 216 | + c["purl"], |
| 217 | + "pkg:conda/ca-certificates@2024.8.30?build=hbcca054_0&subdir=linux-64" |
| 218 | + ); |
| 219 | + assert_eq!(c["licenses"][0]["license"]["name"], "ISC"); |
| 220 | + assert_eq!(c["hashes"][0]["alg"], "SHA-256"); |
| 221 | + |
| 222 | + // Deterministic: same lock → byte-identical output. |
| 223 | + assert_eq!(json, to_cyclonedx(&lock).expect("renders again")); |
| 224 | + } |
| 225 | +} |
0 commit comments