maturin 1.13.1

Build and publish crates with pyo3, cffi and uniffi bindings as well as rust binaries as python packages
Documentation
use crate::BuildContext;
use crate::module_writer::ModuleWriter;
use anyhow::{Context, Result, anyhow};
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<u8>)>,
}

impl SbomData {
    /// Generate Rust SBOMs from the build context.
    #[instrument(skip_all)]
    pub(crate) fn generate(context: &BuildContext) -> Result<Option<SbomData>> {
        let sbom_config = context.artifact.sbom.as_ref();

        // Check if Rust SBOM generation is explicitly disabled
        let rust_sbom_enabled = sbom_config.and_then(|c| c.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)?;
            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<()> {
        let sbom_config = context.artifact.sbom.as_ref();

        // 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) = sbom_config.and_then(|c| c.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<PathBuf> {
    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")
        );
    }
}