diff --git a/rust/Cargo.lock b/rust/Cargo.lock index dff4d9c..b93ed9c 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1972,7 +1972,7 @@ dependencies = [ [[package]] name = "nepenthe-core" -version = "0.1.0" +version = "0.1.1" dependencies = [ "astral-reqwest-middleware", "astral-reqwest-retry", diff --git a/rust/src/cli.rs b/rust/src/cli.rs index 8b2f3ff..9cfaffa 100644 --- a/rust/src/cli.rs +++ b/rust/src/cli.rs @@ -88,6 +88,8 @@ enum Command { Compose(ComposeArgs), /// Report the licenses of a lock's packages and flag denied ones. License(LicenseArgs), + /// Generate a CycloneDX SBOM (JSON) from a lock or a published environment. + Sbom(SbomArgs), /// Re-derive a lock's content address to verify its integrity. Verify(VerifyArgs), /// Build a container image (SIF or OCI) from a published environment. @@ -282,6 +284,34 @@ struct ManifestArgs { output: Option, } +#[derive(Args)] +struct SbomArgs { + /// Generate from a local lock file (no registry needed). + #[arg(long, conflicts_with_all = ["env", "registry"])] + lock: Option, + /// Environment name to resolve from a registry (with `--registry`). + #[arg(long, requires = "registry")] + env: Option, + /// Registry root URL to resolve from (with `--env`). + #[arg(long, requires = "env")] + registry: Option, + /// Target platform (defaults to the current platform). + #[arg(long)] + platform: Option, + /// Python axis value, if the environment fans out over python. + #[arg(long)] + python: Option, + /// Variant axis value (e.g. `cpu`/`gpu`), if any. + #[arg(long)] + variant: Option, + /// Version label to resolve. + #[arg(long, default_value = "latest")] + label: String, + /// File to write the SBOM to (defaults to stdout). + #[arg(short, long)] + output: Option, +} + #[derive(Args)] struct VerifyArgs { /// Verify a local lock file's content address (optionally against `--expect`). @@ -768,6 +798,7 @@ async fn run_command(command: Command) -> CliResult { Command::DiffVersions(args) => diff_versions(args), Command::Compose(args) => compose(args).await, Command::License(args) => license(args), + Command::Sbom(args) => sbom(args), Command::Verify(args) => verify(args), Command::Image(ImageCommand::Build(args)) => image_build(args).await, Command::Cache(CacheCommand::Clean { all }) => cache_clean(all), @@ -970,6 +1001,43 @@ fn manifest(args: ManifestArgs) -> CliResult { Ok(()) } +fn sbom(args: SbomArgs) -> CliResult { + // Load the lock bytes from either source: a local file, or a registry + // release resolved by coordinates + label. + let lock_bytes = if let Some(lock_path) = &args.lock { + std::fs::read(lock_path)? + } else if let (Some(env), Some(registry_url)) = (&args.env, &args.registry) { + let registry = Registry::new(SpecStore::new(), registry_url.clone()); + let platform = args + .platform + .clone() + .unwrap_or_else(|| Platform::current().to_string()); + let mut coords = Coordinates::new(env.clone(), platform); + if let Some(py) = &args.python { + coords = coords.with_python(py.clone()); + } + if let Some(v) = &args.variant { + coords = coords.with_variant(v.clone()); + } + let label = Label::parse(&args.label); + registry.pull(&coords, &label)? + } else { + return Err("pass --lock , or --env --registry ".into()); + }; + + let lock = install::parse_lock(&lock_bytes)?; + let json = crate::sbom::to_cyclonedx(&lock)?; + + match &args.output { + Some(path) => { + std::fs::write(path, json.as_bytes())?; + eprintln!("wrote SBOM → {}", path.display()); + } + None => println!("{json}"), + } + Ok(()) +} + fn license(args: LicenseArgs) -> CliResult { // Load the lock bytes from either source: a local file, or a registry // release resolved by coordinates + label. diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 84d32e0..e1537b2 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -34,6 +34,7 @@ pub mod producer; pub mod project; pub mod registry; pub mod run; +pub mod sbom; pub mod selector; pub mod solve; diff --git a/rust/src/sbom.rs b/rust/src/sbom.rs new file mode 100644 index 0000000..bd051f6 --- /dev/null +++ b/rust/src/sbom.rs @@ -0,0 +1,225 @@ +//! Generate a [CycloneDX](https://cyclonedx.org/) 1.5 SBOM (JSON) from a solved +//! lock. +//! +//! A lock already pins every conda package with its exact version, build, and +//! content hash — a software bill of materials is a direct projection of that. +//! The document is **deterministic**: components are keyed by package URL and +//! emitted in sorted order, and no generation timestamp is included, so the same +//! lock always yields byte-identical SBOM output (itself a useful property to +//! attest). + +use std::collections::BTreeMap; + +use rattler_lock::LockFile; +use serde::Serialize; + +/// Errors raised while generating an SBOM. +#[derive(Debug)] +pub enum SbomError { + /// Reading conda records out of the lock failed. + Lock(String), + /// Serialising the SBOM document to JSON failed. + Serialize(serde_json::Error), +} + +impl std::fmt::Display for SbomError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SbomError::Lock(msg) => write!(f, "{msg}"), + SbomError::Serialize(e) => write!(f, "serialising SBOM failed: {e}"), + } + } +} + +impl std::error::Error for SbomError {} + +#[derive(Serialize)] +struct Bom { + #[serde(rename = "bomFormat")] + bom_format: &'static str, + #[serde(rename = "specVersion")] + spec_version: &'static str, + version: u32, + metadata: Metadata, + components: Vec, +} + +#[derive(Serialize)] +struct Metadata { + tools: Vec, +} + +#[derive(Serialize)] +struct Tool { + vendor: &'static str, + name: &'static str, + version: &'static str, +} + +#[derive(Serialize)] +struct Component { + #[serde(rename = "bom-ref")] + bom_ref: String, + #[serde(rename = "type")] + kind: &'static str, + name: String, + version: String, + purl: String, + #[serde(skip_serializing_if = "Vec::is_empty")] + hashes: Vec, + #[serde(skip_serializing_if = "Vec::is_empty")] + licenses: Vec, +} + +#[derive(Serialize)] +struct Hash { + alg: &'static str, + content: String, +} + +#[derive(Serialize)] +struct LicenseChoice { + license: License, +} + +#[derive(Serialize)] +struct License { + name: String, +} + +/// Build a conda package URL (purl): `pkg:conda/@` with `build` +/// and `subdir` qualifiers. conda names, versions, and builds use a purl-safe +/// character set, so no percent-encoding is required. +fn conda_purl(name: &str, version: &str, build: &str, subdir: &str) -> String { + format!("pkg:conda/{name}@{version}?build={build}&subdir={subdir}") +} + +/// Render `lock` as a CycloneDX 1.5 JSON SBOM. Every distinct conda package +/// across all environments and platforms in the lock becomes one component, +/// deduplicated and sorted by package URL. +pub fn to_cyclonedx(lock: &LockFile) -> Result { + let mut components: BTreeMap = BTreeMap::new(); + + for (_env_name, env) in lock.environments() { + for platform in env.platforms() { + let records = env + .conda_repodata_records(platform) + .map_err(|e| SbomError::Lock(format!("converting lock records: {e}")))? + .unwrap_or_default(); + for record in records { + let pr = &record.package_record; + let name = pr.name.as_normalized().to_string(); + let version = pr.version.as_str().to_string(); + let build = pr.build.clone(); + let subdir = pr.subdir.clone(); + let purl = conda_purl(&name, &version, &build, &subdir); + if components.contains_key(&purl) { + continue; + } + let hashes = pr + .sha256 + .as_ref() + .map(|digest| { + vec![Hash { + alg: "SHA-256", + content: hex::encode(digest), + }] + }) + .unwrap_or_default(); + let licenses = pr + .license + .as_ref() + .filter(|l| !l.is_empty()) + .map(|name| { + vec![LicenseChoice { + license: License { name: name.clone() }, + }] + }) + .unwrap_or_default(); + components.insert( + purl.clone(), + Component { + bom_ref: purl.clone(), + kind: "library", + name, + version, + purl, + hashes, + licenses, + }, + ); + } + } + } + + let bom = Bom { + bom_format: "CycloneDX", + spec_version: "1.5", + version: 1, + metadata: Metadata { + tools: vec![Tool { + vendor: "nepenthe", + name: "nepenthe", + version: env!("CARGO_PKG_VERSION"), + }], + }, + components: components.into_values().collect(), + }; + + serde_json::to_string_pretty(&bom).map_err(SbomError::Serialize) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn conda_purl_carries_build_and_subdir() { + assert_eq!( + conda_purl("numpy", "2.1.0", "py311h0001_0", "linux-64"), + "pkg:conda/numpy@2.1.0?build=py311h0001_0&subdir=linux-64" + ); + } + + /// A minimal real lock (one package) renders a valid, deterministic + /// CycloneDX document with the package as a component. + #[test] + fn renders_cyclonedx_from_a_lock() { + let yaml = r#"version: 6 +environments: + default: + channels: + - url: https://conda.anaconda.org/conda-forge/ + packages: + linux-64: + - conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda +packages: +- conda: https://conda.anaconda.org/conda-forge/linux-64/ca-certificates-2024.8.30-hbcca054_0.conda + sha256: 0a8c9a0b0a0d0e0f0102030405060708090a0b0c0d0e0f101112131415161718 + md5: 9c12429eb8e07e7c5d36a8b8b0d0e0f0 + license: ISC + size: 159003 + timestamp: 1725018903918 +"#; + let lock = LockFile::from_str_with_base_directory(yaml, None).expect("valid lock"); + let json = to_cyclonedx(&lock).expect("renders"); + let doc: serde_json::Value = serde_json::from_str(&json).expect("valid json"); + + assert_eq!(doc["bomFormat"], "CycloneDX"); + assert_eq!(doc["specVersion"], "1.5"); + let components = doc["components"].as_array().expect("components array"); + assert_eq!(components.len(), 1); + let c = &components[0]; + assert_eq!(c["name"], "ca-certificates"); + assert_eq!(c["type"], "library"); + assert_eq!( + c["purl"], + "pkg:conda/ca-certificates@2024.8.30?build=hbcca054_0&subdir=linux-64" + ); + assert_eq!(c["licenses"][0]["license"]["name"], "ISC"); + assert_eq!(c["hashes"][0]["alg"], "SHA-256"); + + // Deterministic: same lock → byte-identical output. + assert_eq!(json, to_cyclonedx(&lock).expect("renders again")); + } +}