Skip to content

Commit 9d9e8b2

Browse files
committed
add cyclonedx sbom from lock or release
Signed-off-by: Tim Paine <3105306+timkpaine@users.noreply.github.com>
1 parent e431b65 commit 9d9e8b2

3 files changed

Lines changed: 294 additions & 0 deletions

File tree

rust/src/cli.rs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,8 @@ enum Command {
8686
DiffVersions(DiffVersionsArgs),
8787
/// Compose several published environments into one lock.
8888
Compose(ComposeArgs),
89+
/// Generate a CycloneDX SBOM (JSON) from a lock or a published environment.
90+
Sbom(SbomArgs),
8991
/// Build a container image (SIF or OCI) from a published environment.
9092
#[command(subcommand)]
9193
Image(ImageCommand),
@@ -274,6 +276,34 @@ struct ManifestArgs {
274276
output: Option<PathBuf>,
275277
}
276278

279+
#[derive(Args)]
280+
struct SbomArgs {
281+
/// Generate from a local lock file (no registry needed).
282+
#[arg(long, conflicts_with_all = ["env", "registry"])]
283+
lock: Option<PathBuf>,
284+
/// Environment name to resolve from a registry (with `--registry`).
285+
#[arg(long, requires = "registry")]
286+
env: Option<String>,
287+
/// Registry root URL to resolve from (with `--env`).
288+
#[arg(long, requires = "env")]
289+
registry: Option<String>,
290+
/// Target platform (defaults to the current platform).
291+
#[arg(long)]
292+
platform: Option<String>,
293+
/// Python axis value, if the environment fans out over python.
294+
#[arg(long)]
295+
python: Option<String>,
296+
/// Variant axis value (e.g. `cpu`/`gpu`), if any.
297+
#[arg(long)]
298+
variant: Option<String>,
299+
/// Version label to resolve.
300+
#[arg(long, default_value = "latest")]
301+
label: String,
302+
/// File to write the SBOM to (defaults to stdout).
303+
#[arg(short, long)]
304+
output: Option<PathBuf>,
305+
}
306+
277307
#[derive(Args)]
278308
struct PublishArgs {
279309
#[command(flatten)]
@@ -694,6 +724,7 @@ async fn run_command(command: Command) -> CliResult {
694724
Command::List(args) => list(args),
695725
Command::DiffVersions(args) => diff_versions(args),
696726
Command::Compose(args) => compose(args).await,
727+
Command::Sbom(args) => sbom(args),
697728
Command::Image(ImageCommand::Build(args)) => image_build(args).await,
698729
Command::Cache(CacheCommand::Clean { all }) => cache_clean(all),
699730
}
@@ -892,6 +923,43 @@ fn manifest(args: ManifestArgs) -> CliResult {
892923
Ok(())
893924
}
894925

926+
fn sbom(args: SbomArgs) -> CliResult {
927+
// Load the lock bytes from either source: a local file, or a registry
928+
// release resolved by coordinates + label.
929+
let lock_bytes = if let Some(lock_path) = &args.lock {
930+
std::fs::read(lock_path)?
931+
} else if let (Some(env), Some(registry_url)) = (&args.env, &args.registry) {
932+
let registry = Registry::new(SpecStore::new(), registry_url.clone());
933+
let platform = args
934+
.platform
935+
.clone()
936+
.unwrap_or_else(|| Platform::current().to_string());
937+
let mut coords = Coordinates::new(env.clone(), platform);
938+
if let Some(py) = &args.python {
939+
coords = coords.with_python(py.clone());
940+
}
941+
if let Some(v) = &args.variant {
942+
coords = coords.with_variant(v.clone());
943+
}
944+
let label = Label::parse(&args.label);
945+
registry.pull(&coords, &label)?
946+
} else {
947+
return Err("pass --lock <file>, or --env <name> --registry <url>".into());
948+
};
949+
950+
let lock = install::parse_lock(&lock_bytes)?;
951+
let json = crate::sbom::to_cyclonedx(&lock)?;
952+
953+
match &args.output {
954+
Some(path) => {
955+
std::fs::write(path, json.as_bytes())?;
956+
eprintln!("wrote SBOM → {}", path.display());
957+
}
958+
None => println!("{json}"),
959+
}
960+
Ok(())
961+
}
962+
895963
fn publish(args: PublishArgs) -> CliResult {
896964
let registry = args.coords.build_registry();
897965
let coords = args.coords.coordinates();

rust/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pub mod producer;
3333
pub mod project;
3434
pub mod registry;
3535
pub mod run;
36+
pub mod sbom;
3637
pub mod selector;
3738
pub mod solve;
3839

rust/src/sbom.rs

Lines changed: 225 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,225 @@
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

Comments
 (0)