use crate::BuildContext; use crate::module_writer::ModuleWriter; #[cfg(feature = "sbom")] use anyhow::anyhow; use anyhow::{Context, Result}; use std::collections::HashSet; use std::path::{Path, PathBuf}; use tracing::instrument; #[cfg(feature = "sbom")] use cargo_cyclonedx::config::SbomConfig as CyclonedxConfig; #[cfg(feature = "sbom")] use cargo_cyclonedx::generator::SbomGenerator; /// Pre-generated SBOM data that can be reused across multiple wheel writes. /// /// Since the Rust dependency graph is the same regardless of the target Python /// interpreter, we generate the SBOM once per `BuildContext` build and reuse /// the resulting bytes for every wheel. pub struct SbomData { /// Generated Rust SBOM entries: (package_name, json_bytes). pub rust_sboms: Vec<(String, Vec)>, } impl SbomData { /// Generate Rust SBOMs from the build context. #[instrument(skip_all)] pub(crate) fn generate(context: &BuildContext) -> Result> { // Check if Rust SBOM generation is explicitly disabled let rust_sbom_enabled = context.artifact.sbom.rust.unwrap_or(true); #[cfg(feature = "sbom")] { if !rust_sbom_enabled { return Ok(Some(SbomData { rust_sboms: Vec::new(), })); } let config = CyclonedxConfig { target: Some(cargo_cyclonedx::config::Target::AllTargets), ..CyclonedxConfig::empty_config() }; // cargo-cyclonedx depends on cargo_metadata 0.18, while maturin uses // cargo_metadata 0.23. The Metadata structs are incompatible at the // type level but share the same JSON representation, so we bridge // them via a serde round-trip. let json = serde_json::to_value(context.project.cargo_metadata.as_ref())?; let metadata = serde_json::from_value(json) .context("Failed to convert cargo metadata for SBOM generation")?; let sboms = SbomGenerator::create_sboms(metadata, &config) .map_err(|e| anyhow!("Failed to generate Rust SBOM: {}", e))?; let mut rust_sboms = Vec::new(); for sbom in sboms { // Only keep the SBOM for the crate being built into a wheel. // Each member's SBOM already contains the full transitive // dependency graph, so filtering is safe. if sbom.package_name != context.project.crate_name { continue; } let mut buf = Vec::new(); sbom.bom .output_as_json_v1_5(&mut buf) .map_err(|e| anyhow!("Failed to serialize SBOM: {}", e))?; rust_sboms.push((sbom.package_name, buf)); } Ok(Some(SbomData { rust_sboms })) } #[cfg(not(feature = "sbom"))] { let _ = rust_sbom_enabled; Ok(None) } } /// Writes SBOMs into the wheel via the given writer. pub(crate) fn write( sbom_data: Option<&SbomData>, context: &BuildContext, writer: &mut impl ModuleWriter, dist_info_dir: &Path, ) -> Result<()> { // 1. Write pre-generated Rust SBOMs if let Some(data) = sbom_data { for (package_name, json_bytes) in &data.rust_sboms { let target = dist_info_dir.join(format!("sboms/{package_name}.cyclonedx.json")); writer.add_bytes(&target, None, json_bytes.clone(), false)?; } } // 2. Include additional SBOM files (only when explicitly configured) if let Some(include) = context.artifact.sbom.include.as_ref() { // Canonicalize project root once and enforce all includes stay within it. let project_root = context .project .project_layout .project_root .canonicalize() .context("Failed to canonicalize project root for SBOM includes")?; let mut seen_filenames = HashSet::new(); for path in include { let resolved_path = resolve_sbom_include(path, &project_root)?; let filename = resolved_path.file_name().context("Invalid SBOM path")?; if !seen_filenames.insert(filename.to_os_string()) { anyhow::bail!( "Duplicate SBOM filename '{}' from include path '{}'. \ Multiple includes must have unique filenames.", filename.to_string_lossy(), path.display() ); } let target = dist_info_dir.join("sboms").join(filename); writer.add_file(&target, &resolved_path, false)?; } } Ok(()) } } /// Validate and resolve an SBOM include path. /// /// Absolute paths are used as-is (after canonicalization). /// Relative paths are resolved against the project root and must stay within it. /// /// Returns the canonicalized path on success. pub(crate) fn resolve_sbom_include(path: &Path, project_root: &Path) -> Result { let is_absolute = path.is_absolute(); let resolved_path = if is_absolute { path.to_path_buf() } else { project_root.join(path) }; let resolved_path = resolved_path.canonicalize().with_context(|| { format!( "Failed to canonicalize SBOM include path '{}'", resolved_path.display() ) })?; // Only enforce the project-root constraint for relative paths // to prevent directory traversal (e.g. "../../etc/passwd"). // Absolute paths are intentionally allowed to reference files // outside the project root. if !is_absolute && !resolved_path.starts_with(project_root) { anyhow::bail!( "SBOM include path '{}' escapes the project root '{}'", resolved_path.display(), project_root.display() ); } Ok(resolved_path) } #[cfg(test)] mod tests { use super::*; use fs_err as fs; use tempfile::tempdir; #[test] fn test_reject_path_escaping_project_root() { let dir = tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let result = resolve_sbom_include(Path::new("../../etc/passwd"), &root); assert!(result.is_err()); } #[test] fn test_accept_valid_relative_path() { let dir = tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let sbom_file = root.join("my_sbom.json"); fs::write(&sbom_file, "{}").unwrap(); let result = resolve_sbom_include(Path::new("my_sbom.json"), &root).unwrap(); assert_eq!(result, sbom_file); } #[test] fn test_accept_nested_path() { let dir = tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let nested = root.join("sboms/vendor"); fs::create_dir_all(&nested).unwrap(); let sbom_file = nested.join("report.json"); fs::write(&sbom_file, "{}").unwrap(); let result = resolve_sbom_include(Path::new("sboms/vendor/report.json"), &root).unwrap(); assert_eq!(result, sbom_file); } #[test] fn test_accept_absolute_path_outside_root() { let dir = tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let outside = tempdir().unwrap(); let outside_file = outside.path().join("external.json"); fs::write(&outside_file, "{}").unwrap(); let result = resolve_sbom_include(&outside_file, &root).unwrap(); assert_eq!(result, outside_file.canonicalize().unwrap()); } #[test] fn test_reject_nonexistent_path() { let dir = tempdir().unwrap(); let root = dir.path().canonicalize().unwrap(); let result = resolve_sbom_include(Path::new("does_not_exist.json"), &root); assert!(result.is_err()); assert!( result .unwrap_err() .to_string() .contains("Failed to canonicalize") ); } }