auditable_cyclonedx/
lib.rs

1#![forbid(unsafe_code)]
2
3use std::str::FromStr;
4
5pub use auditable_serde;
6use auditable_serde::{Package, Source};
7pub use cyclonedx_bom;
8
9use cyclonedx_bom::models::property::{Properties, Property};
10use cyclonedx_bom::prelude::*;
11use cyclonedx_bom::{
12    external_models::uri::Purl,
13    models::{
14        component::Classification,
15        component::Component,
16        dependency::{Dependencies, Dependency},
17        metadata::Metadata,
18    },
19};
20
21/// Converts the metadata embedded by `cargo auditable` to a minimal CycloneDX document
22/// that is heavily optimized to reduce the size
23pub fn auditable_to_minimal_cdx(input: &auditable_serde::VersionInfo) -> Bom {
24    let mut bom = Bom {
25        serial_number: None, // the serial number would mess with reproducible builds
26        ..Default::default()
27    };
28
29    // The toplevel component goes into its own field, as per the spec:
30    // https://cyclonedx.org/docs/1.5/json/#metadata_component
31    let (root_idx, root_pkg) = root_package(input);
32    let root_component = pkg_to_component(root_pkg, root_idx);
33    let metadata = Metadata {
34        component: Some(root_component),
35        ..Default::default()
36    };
37    bom.metadata = Some(metadata);
38
39    // Fill in the component list, excluding the toplevel component (already encoded)
40    let components: Vec<Component> = input
41        .packages
42        .iter()
43        .enumerate()
44        .filter(|(_idx, pkg)| !pkg.root)
45        .map(|(idx, pkg)| pkg_to_component(pkg, idx))
46        .collect();
47    let components = Components(components);
48    bom.components = Some(components);
49
50    // Populate the dependency tree. Actually really easy, it's the same format as ours!
51    let dependencies: Vec<Dependency> = input
52        .packages
53        .iter()
54        .enumerate()
55        .map(|(idx, pkg)| Dependency {
56            dependency_ref: idx.to_string(),
57            dependencies: pkg.dependencies.iter().map(|idx| idx.to_string()).collect(),
58        })
59        .collect();
60    let dependencies = Dependencies(dependencies);
61    bom.dependencies = Some(dependencies);
62
63    // Validate the generated SBOM if running in debug mode (or release with debug assertions)
64    if cfg!(debug_assertions) {
65        assert!(bom.validate().passed());
66    }
67    bom
68}
69
70fn pkg_to_component(pkg: &auditable_serde::Package, idx: usize) -> Component {
71    let component_type = if pkg.root {
72        Classification::Application
73    } else {
74        Classification::Library
75    };
76    // The only requirement for `bom_ref` according to the spec is that it's unique,
77    // so we just keep the unique numbering already used in the original
78    let bom_ref = idx.to_string();
79    let mut result = Component::new(
80        component_type,
81        &pkg.name,
82        &pkg.version.to_string(),
83        Some(bom_ref),
84    );
85    // PURL encodes the package origin (registry, git, local) - sort of, anyway
86    let purl = purl(pkg);
87    let purl = Purl::from_str(&purl).unwrap();
88    result.purl = Some(purl);
89    // Record the dependency kind
90    match pkg.kind {
91        // `Runtime` is the default and does not need to be recorded.
92        auditable_serde::DependencyKind::Runtime => (),
93        auditable_serde::DependencyKind::Build => {
94            let p = Property::new("cdx:rustc:dependency_kind".to_owned(), "build");
95            result.properties = Some(Properties(vec![p]));
96        }
97    }
98    result
99}
100
101fn root_package(input: &auditable_serde::VersionInfo) -> (usize, &Package) {
102    // we can unwrap here because VersionInfo is already validated during deserialization
103    input
104        .packages
105        .iter()
106        .enumerate()
107        .find(|(_idx, pkg)| pkg.root)
108        .expect("VersionInfo contains no root package!")
109}
110
111fn purl(pkg: &auditable_serde::Package) -> String {
112    // The purl crate exposed by `cyclonedx-bom` doesn't support the qualifiers we need,
113    // so we just build the PURL as a string.
114    // Yeah, we could use *yet another* dependency to build the PURL,
115    // but we use it such trivial ways that it isn't worth the trouble.
116    // Specifically, the crate names that crates.io accepts don't need percent-encoding
117    // and the fixed values we put in arguments don't either
118    // (but percent-encoding is underspecified and not interoperable anyway,
119    // see e.g. https://github.com/package-url/purl-spec/pull/261)
120    let mut purl = format!("pkg:cargo/{}@{}", pkg.name, pkg.version);
121    purl.push_str(match &pkg.source {
122        Source::CratesIo => "", // this is the default, nothing to qualify
123        Source::Git => "&vcs_url=redacted",
124        Source::Local => "&download_url=redacted",
125        Source::Registry => "&repository_url=redacted",
126        Source::Other(_) => "&download_url=redacted",
127        unknown => panic!("Unknown source: {:?}", unknown),
128    });
129    purl
130}