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;
pub struct SbomData {
pub rust_sboms: Vec<(String, Vec<u8>)>,
}
impl SbomData {
#[instrument(skip_all)]
pub(crate) fn generate(context: &BuildContext) -> Result<Option<SbomData>> {
let sbom_config = context.artifact.sbom.as_ref();
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()
};
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 {
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)
}
}
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();
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)?;
}
}
if let Some(include) = sbom_config.and_then(|c| c.include.as_ref()) {
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(())
}
}
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()
)
})?;
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")
);
}
}