use std::collections::HashMap;
use std::path::Path;
use cyclonedx_bom::models::component::Classification;
use cyclonedx_bom::models::dependency::{Dependencies, Dependency};
use cyclonedx_bom::models::metadata::Metadata;
use cyclonedx_bom::models::property::{Properties, Property};
use cyclonedx_bom::models::tool::{Tool, Tools};
use cyclonedx_bom::prelude::{Bom, Component, Components, NormalizedString};
use itertools::Itertools;
use percent_encoding::{AsciiSet, CONTROLS, percent_encode};
use rustc_hash::FxHashSet;
use uv_configuration::{
DependencyGroupsWithDefaults, ExtrasSpecificationWithDefaults, InstallOptions,
};
use uv_fs::PortablePath;
use uv_normalize::PackageName;
use uv_pep508::MarkerTree;
use uv_preview::{Preview, PreviewFeature};
use uv_warnings::warn_user;
use crate::lock::export::{ExportableRequirement, ExportableRequirements};
use crate::lock::{LockErrorKind, Package, PackageId, RegistrySource, Source};
use crate::{Installable, LockError};
const PURL_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'"')
.add(b'#')
.add(b'%')
.add(b'<')
.add(b'>')
.add(b'`')
.add(b'?')
.add(b'{')
.add(b'}')
.add(b';')
.add(b'=')
.add(b'+')
.add(b'@')
.add(b'\\')
.add(b'[')
.add(b']')
.add(b'^')
.add(b'|');
#[derive(Default)]
struct ComponentBuilder<'a> {
id_counter: usize, package_to_component_map: HashMap<&'a PackageId, Component>,
}
impl<'a> ComponentBuilder<'a> {
fn create_bom_ref(&mut self, name: &str, version: Option<&str>) -> String {
self.id_counter += 1;
let id = self.id_counter;
if let Some(version) = version {
format!("{name}-{id}@{version}")
} else {
format!("{name}-{id}")
}
}
fn get_version_string(package: &Package) -> Option<String> {
package
.id
.version
.as_ref()
.map(std::string::ToString::to_string)
}
fn get_package_name(package: &Package) -> &str {
package.id.name.as_str()
}
fn create_purl(package: &Package) -> Option<String> {
let name = percent_encode(Self::get_package_name(package).as_bytes(), PURL_ENCODE_SET);
let version = Self::get_version_string(package)
.map(|v| format!("@{}", percent_encode(v.as_bytes(), PURL_ENCODE_SET)))
.unwrap_or_default();
let (purl_type, qualifiers) = match &package.id.source {
Source::Registry(registry_source) => {
let qualifiers = match registry_source {
RegistrySource::Url(url) => {
if !url.as_ref().starts_with("https://pypi.org/") {
vec![("repository_url", url.as_ref())]
} else {
vec![]
}
}
RegistrySource::Path(_) => vec![],
};
("pypi", qualifiers)
}
Source::Git(url, _) => ("pypi", vec![("vcs_url", url.as_ref())]),
Source::Direct(url, _) => ("pypi", vec![("download_url", url.as_ref())]),
Source::Path(_) | Source::Directory(_) | Source::Editable(_) | Source::Virtual(_) => {
return None;
}
};
let qualifiers = if qualifiers.is_empty() {
String::new()
} else {
Self::format_qualifiers(&qualifiers)
};
Some(format!("pkg:{purl_type}/{name}{version}{qualifiers}"))
}
fn format_qualifiers(qualifiers: &[(&str, &str)]) -> String {
let joined_qualifiers = qualifiers
.iter()
.map(|(key, value)| {
format!(
"{key}={}",
percent_encode(value.as_bytes(), PURL_ENCODE_SET)
)
})
.join("&");
format!("?{joined_qualifiers}")
}
fn create_component(
&mut self,
package: &'a Package,
package_type: PackageType,
marker: Option<&MarkerTree>,
) -> Component {
let component = self.create_component_from_package(package, package_type, marker);
self.package_to_component_map
.insert(&package.id, component.clone());
component
}
fn create_synthetic_root_component(&mut self, root: Option<&Package>) -> Component {
let name = root.map(Self::get_package_name).unwrap_or("uv-workspace");
let bom_ref = self.create_bom_ref(name, None);
let properties = Properties(vec![Property::new("uv:package:is_synthetic_root", "true")]);
Component {
component_type: Classification::Library,
name: NormalizedString::new(name),
version: None,
bom_ref: Some(bom_ref),
purl: None,
mime_type: None,
supplier: None,
author: None,
publisher: None,
group: None,
description: None,
scope: None,
hashes: None,
licenses: None,
copyright: None,
cpe: None,
swid: None,
modified: None,
pedigree: None,
external_references: None,
properties: Some(properties),
components: None,
evidence: None,
signature: None,
model_card: None,
data: None,
}
}
fn create_component_from_package(
&mut self,
package: &Package,
package_type: PackageType,
marker: Option<&MarkerTree>,
) -> Component {
let name = Self::get_package_name(package);
let version = Self::get_version_string(package);
let bom_ref = self.create_bom_ref(name, version.as_deref());
let purl = Self::create_purl(package).and_then(|purl_string| purl_string.parse().ok());
let mut properties = vec![];
match package_type {
PackageType::Workspace(path) => {
properties.push(Property::new(
"uv:workspace:path",
&PortablePath::from(path).to_string(),
));
}
PackageType::Root => {
properties.push(Property::new("uv:package:is_project_root", "true"));
}
PackageType::Dependency => {}
}
if let Some(marker_contents) = marker.and_then(|marker| marker.contents()) {
properties.push(Property::new(
"uv:package:marker",
&marker_contents.to_string(),
));
}
Component {
component_type: Classification::Library,
name: NormalizedString::new(name),
version: version.as_deref().map(NormalizedString::new),
bom_ref: Some(bom_ref),
purl,
mime_type: None,
supplier: None,
author: None,
publisher: None,
group: None,
description: None,
scope: None,
hashes: None,
licenses: None,
copyright: None,
cpe: None,
swid: None,
modified: None,
pedigree: None,
external_references: None,
properties: if !properties.is_empty() {
Some(Properties(properties))
} else {
None
},
components: None,
evidence: None,
signature: None,
model_card: None,
data: None,
}
}
fn get_component(&self, id: &PackageId) -> Option<&Component> {
self.package_to_component_map.get(id)
}
}
pub fn from_lock<'lock>(
target: &impl Installable<'lock>,
prune: &[PackageName],
extras: &ExtrasSpecificationWithDefaults,
groups: &DependencyGroupsWithDefaults,
annotate: bool,
install_options: &'lock InstallOptions,
preview: Preview,
all_packages: bool,
) -> Result<Bom, LockError> {
if !preview.is_enabled(PreviewFeature::SbomExport) {
warn_user!(
"`uv export --format=cyclonedx1.5` is experimental and may change without warning. Pass `--preview-features {}` to disable this warning.",
PreviewFeature::SbomExport
);
}
let ExportableRequirements(mut nodes) = ExportableRequirements::from_lock(
target,
prune,
extras,
groups,
annotate,
install_options,
)?;
nodes.sort_unstable_by_key(|node| &node.package.id);
let root = match target.roots().collect::<Vec<_>>().as_slice() {
[single_root] => nodes
.iter()
.find(|node| &node.package.id.name == *single_root)
.map(|node| node.package),
_ => None,
}
.or_else(|| target.lock().root());
let mut component_builder = ComponentBuilder::default();
let mut metadata = Metadata {
component: root
.map(|package| component_builder.create_component(package, PackageType::Root, None)),
timestamp: cyclonedx_bom::prelude::DateTime::now().ok(),
tools: Some(Tools::List(vec![Tool {
vendor: Some(NormalizedString::new("Astral Software Inc.")),
name: Some(NormalizedString::new("uv")),
version: Some(NormalizedString::new(uv_version::version())),
hashes: None,
external_references: None,
}])),
..Metadata::default()
};
let workspace_member_ids = nodes
.iter()
.filter_map(|node| {
if target.lock().members().contains(&node.package.id.name) {
Some(&node.package.id)
} else {
None
}
})
.collect::<FxHashSet<_>>();
let mut components = nodes
.iter()
.filter(|node| root.is_none_or(|root_pkg| root_pkg.id != node.package.id)) .map(|node| {
let package_type = if workspace_member_ids.contains(&node.package.id) {
let path = match &node.package.id.source {
Source::Path(path)
| Source::Directory(path)
| Source::Editable(path)
| Source::Virtual(path) => path,
Source::Registry(_) | Source::Git(_, _) | Source::Direct(_, _) => {
return Err(LockErrorKind::NonLocalWorkspaceMember {
id: node.package.id.clone(),
}
.into());
}
};
PackageType::Workspace(path)
} else {
PackageType::Dependency
};
Ok(component_builder.create_component(node.package, package_type, Some(&node.marker)))
})
.collect::<Result<Vec<_>, LockError>>()?;
let mut dependencies = create_dependencies(&nodes, &component_builder);
if all_packages || metadata.component.is_none() {
let synthetic_root = component_builder.create_synthetic_root_component(root);
let synthetic_root_bom_ref = synthetic_root
.bom_ref
.clone()
.expect("bom-ref should always exist");
let root = metadata.component.replace(synthetic_root);
let mut synthetic_root_deps = workspace_member_ids
.iter()
.filter_map(|c| component_builder.get_component(c))
.map(|c| c.bom_ref.clone().expect("bom-ref should always exist"))
.collect::<Vec<_>>();
if let Some(ref root_component) = root
&& let Some(ref root_bom_ref) = root_component.bom_ref
{
synthetic_root_deps.push(root_bom_ref.clone());
}
if let Some(workspace_root) = root {
components.push(workspace_root);
}
dependencies.push(Dependency {
dependency_ref: synthetic_root_bom_ref,
dependencies: synthetic_root_deps
.into_iter()
.sorted_unstable()
.unique()
.collect(),
});
}
let bom = Bom {
metadata: Some(metadata),
components: Some(Components(components)),
dependencies: Some(Dependencies(dependencies)),
..Bom::default()
};
Ok(bom)
}
fn create_dependencies(
nodes: &[ExportableRequirement<'_>],
component_builder: &ComponentBuilder,
) -> Vec<Dependency> {
nodes
.iter()
.map(|node| {
let component = component_builder
.get_component(&node.package.id)
.expect("All nodes should have been added to map");
let immediate_deps = &node.package.dependencies;
let optional_deps = node.package.optional_dependencies.values().flatten();
let dep_groups = node.package.dependency_groups.values().flatten();
let package_deps = immediate_deps
.iter()
.chain(optional_deps)
.chain(dep_groups)
.filter_map(|dep| component_builder.get_component(&dep.package_id));
let bom_refs = package_deps
.map(|p| p.bom_ref.clone().expect("bom-ref should always exist"))
.sorted_unstable()
.unique()
.collect();
Dependency {
dependency_ref: component
.bom_ref
.clone()
.expect("bom-ref should always exist"),
dependencies: bom_refs,
}
})
.collect()
}
#[derive(Copy, Clone, Debug, Eq, PartialEq)]
enum PackageType<'a> {
Root,
Workspace(&'a Path),
Dependency,
}