use crate::config::Describe;
use std::cmp::min;
use std::collections::HashSet;
use crate::config::FilenamePattern;
use crate::config::PlatformSuffix;
use crate::config::SbomConfig;
use crate::config::{IncludedDependencies, ParseMode};
use crate::format::Format;
use crate::purl::get_purl;
use cargo_metadata;
use cargo_metadata::DependencyKind;
use cargo_metadata::Metadata as CargoMetadata;
use cargo_metadata::Node;
use cargo_metadata::NodeDep;
use cargo_metadata::Package;
use cargo_metadata::PackageId;
use cargo_lock::package::Checksum;
use cargo_lock::Lockfile;
use cargo_metadata::camino::Utf8PathBuf;
use cyclonedx_bom::external_models::normalized_string::NormalizedString;
use cyclonedx_bom::external_models::spdx::SpdxExpression;
use cyclonedx_bom::external_models::uri::Uri;
use cyclonedx_bom::models::attached_text::AttachedText;
use cyclonedx_bom::models::bom::Bom;
use cyclonedx_bom::models::component::{Classification, Component, Components, Scope};
use cyclonedx_bom::models::dependency::{Dependencies, Dependency};
use cyclonedx_bom::models::external_reference::{
ExternalReference, ExternalReferenceType, ExternalReferences,
};
use cyclonedx_bom::models::license::{License, LicenseChoice, Licenses};
use cyclonedx_bom::models::metadata::Metadata;
use cyclonedx_bom::models::metadata::MetadataError;
use cyclonedx_bom::models::organization::OrganizationalContact;
use cyclonedx_bom::models::property::{Properties, Property};
use cyclonedx_bom::models::tool::{Tool, Tools};
use cyclonedx_bom::validation::Validate;
use once_cell::sync::Lazy;
use regex::Regex;
use log::Level;
use std::collections::BTreeMap;
use std::collections::HashMap;
use std::convert::TryFrom;
use std::fs::File;
use std::io::BufWriter;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use thiserror::Error;
use validator::ValidateEmail;
type PackageMap = BTreeMap<PackageId, Package>;
type ResolveMap = BTreeMap<PackageId, Node>;
type DependencyKindMap = BTreeMap<PackageId, DependencyKind>;
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Copy, Clone, Hash)]
enum PrivateDepKind {
Development,
Build,
Runtime,
}
impl From<PrivateDepKind> for DependencyKind {
fn from(priv_kind: PrivateDepKind) -> Self {
match priv_kind {
PrivateDepKind::Development => DependencyKind::Development,
PrivateDepKind::Build => DependencyKind::Build,
PrivateDepKind::Runtime => DependencyKind::Normal,
}
}
}
impl From<&DependencyKind> for PrivateDepKind {
fn from(kind: &DependencyKind) -> Self {
match kind {
DependencyKind::Normal => PrivateDepKind::Runtime,
DependencyKind::Development => PrivateDepKind::Development,
DependencyKind::Build => PrivateDepKind::Build,
_ => panic!("Unknown dependency kind"),
}
}
}
pub struct SbomGenerator {
config: SbomConfig,
workspace_root: Utf8PathBuf,
crate_hashes: HashMap<cargo_metadata::PackageId, Checksum>,
}
#[derive(Debug, Clone)]
pub struct TargetKinds(
HashMap<String, Vec<String>>,
);
impl SbomGenerator {
pub fn create_sboms(
meta: CargoMetadata,
config: &SbomConfig,
) -> Result<Vec<GeneratedSbom>, GeneratorError> {
log::trace!("Processing the workspace {}", meta.workspace_root);
let members: Vec<PackageId> = meta.workspace_members;
let packages = index_packages(meta.packages);
let resolve = index_resolve(meta.resolve.unwrap().nodes);
let mut result = Vec::with_capacity(members.len());
for member in members.iter() {
log::trace!("Processing the package {}", member);
let dep_kinds = index_dep_kinds(member, &resolve);
let (dependencies, pruned_resolve) =
if config.included_dependencies() == IncludedDependencies::AllDependencies {
all_dependencies(member, &packages, &resolve, config)
} else {
top_level_dependencies(member, &packages, &resolve, config)
};
let manifest_path = packages[member].manifest_path.clone().into_std_path_buf();
let mut crate_hashes = HashMap::new();
match locate_cargo_lock(&manifest_path) {
Ok(path) => match Lockfile::load(path) {
Ok(lockfile_contents) => crate_hashes = package_hashes(&lockfile_contents),
Err(err) => log::warn!(
"Failed to parse `Cargo.lock`: {err}\n\
Hashes will not be included in the SBOM."
),
},
Err(err) => log::warn!(
"Failed to locate `Cargo.lock`: {err}\n\
Hashes will not be included in the SBOM."
),
}
let generator = SbomGenerator {
config: config.clone(),
workspace_root: meta.workspace_root.to_owned(),
crate_hashes,
};
let (bom, target_kinds) =
generator.create_bom(member, &dependencies, &pruned_resolve, &dep_kinds)?;
let generated = GeneratedSbom {
bom,
manifest_path,
package_name: packages[member].name.clone(),
sbom_config: generator.config,
target_kinds,
};
result.push(generated);
}
Ok(result)
}
fn create_bom(
&self,
package: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
dep_kinds: &DependencyKindMap,
) -> Result<(Bom, TargetKinds), GeneratorError> {
let mut bom = Bom::default();
let root_package = &packages[package];
let components: Vec<_> = packages
.values()
.filter(|p| &p.id != package)
.map(|component| self.create_component(component, root_package, dep_kinds))
.collect();
bom.components = Some(Components(components));
let (metadata, target_kinds) = self.create_metadata(&packages[package])?;
bom.metadata = Some(metadata);
bom.dependencies = Some(create_dependencies(resolve));
Ok((bom, target_kinds))
}
fn create_component(
&self,
package: &Package,
root_package: &Package,
dep_kinds: &DependencyKindMap,
) -> Component {
let name = package.name.to_owned().trim().to_string();
let version = package.version.to_string();
let purl = match get_purl(package, root_package, &self.workspace_root, None) {
Ok(purl) => Some(purl),
Err(e) => {
log::warn!("Package {} has an invalid Purl: {} ", package.name, e);
None
}
};
let mut component = Component::new(
Classification::Library,
&name,
&version,
Some(package.id.to_string()),
);
component.purl = purl;
component.scope = match dep_kinds
.get(&package.id)
.unwrap_or(&DependencyKind::Normal)
{
DependencyKind::Normal => Some(Scope::Required),
_ => Some(Scope::Excluded),
};
component.external_references = Self::get_external_references(package);
component.licenses = self.get_licenses(package);
component.hashes = self.get_hashes(package);
component.description = package
.description
.as_ref()
.map(|s| NormalizedString::new(s));
if !package.authors.is_empty() {
component.author = Some(NormalizedString::new(&package.authors.join(", ")));
}
component
}
fn create_toplevel_component(&self, package: &Package) -> (Component, TargetKinds) {
let mut top_component = self.create_component(package, package, &DependencyKindMap::new());
top_component.component_type = Self::get_classification(package);
let mut subcomponents: Vec<Component> = Vec::new();
let mut target_kinds = HashMap::new();
for tgt in filter_targets(&package.targets) {
#[allow(clippy::if_same_then_else)]
let cdx_type = if tgt.is_bin() {
Classification::Application
} else if tgt.kind.iter().any(|kind| kind == "proc-macro") {
Classification::Library
} else if tgt.kind.iter().any(|kind| kind.contains("lib")) {
Classification::Library
} else {
log::warn!(
"Target {} is neither a binary nor a library! Kinds: {}",
tgt.name,
tgt.kind.join(", ")
);
continue;
};
let bom_ref = format!(
"{} bin-target-{}",
top_component.bom_ref.as_ref().unwrap(),
subcomponents.len(), );
target_kinds.insert(bom_ref.clone(), tgt.kind.clone());
let mut subcomponent = Component::new(
cdx_type,
&tgt.name,
&package.version.to_string(),
Some(bom_ref),
);
let package_dir = package
.manifest_path
.parent()
.expect("manifest_path in `cargo metadata` output is not a file!");
if let Ok(relative_path) = tgt.src_path.strip_prefix(package_dir) {
subcomponent.purl =
get_purl(package, package, &self.workspace_root, Some(relative_path)).ok();
} else {
log::warn!(
"Source path \"{}\" is not a subpath of workspace root \"{}\"",
tgt.src_path,
self.workspace_root
);
}
subcomponents.push(subcomponent);
}
top_component.components = Some(Components(subcomponents));
(top_component, TargetKinds(target_kinds))
}
fn get_classification(pkg: &Package) -> Classification {
if pkg.targets.iter().any(|tgt| tgt.is_bin()) {
return Classification::Application;
}
Classification::Library
}
fn get_external_references(package: &Package) -> Option<ExternalReferences> {
let mut references = Vec::new();
if let Some(documentation) = &package.documentation {
match Uri::try_from(documentation.to_string()) {
Ok(uri) => references.push(ExternalReference::new(
ExternalReferenceType::Documentation,
uri,
)),
Err(e) => log::warn!(
"Package {} has an invalid documentation URI ({}): {} ",
package.name,
documentation,
e
),
}
}
if let Some(website) = &package.homepage {
match Uri::try_from(website.to_string()) {
Ok(uri) => {
references.push(ExternalReference::new(ExternalReferenceType::Website, uri))
}
Err(e) => log::warn!(
"Package {} has an invalid homepage URI ({}): {} ",
package.name,
website,
e
),
}
}
if let Some(other) = &package.links {
match Uri::try_from(other.to_string()) {
Ok(uri) => {
references.push(ExternalReference::new(ExternalReferenceType::Other, uri))
}
Err(e) => log::warn!(
"Package {} has an invalid links URI ({}): {} ",
package.name,
other,
e
),
}
}
if let Some(vcs) = &package.repository {
match Uri::try_from(vcs.to_string()) {
Ok(uri) => references.push(ExternalReference::new(ExternalReferenceType::Vcs, uri)),
Err(e) => log::warn!(
"Package {} has an invalid repository URI ({}): {} ",
package.name,
vcs,
e
),
}
}
if !references.is_empty() {
return Some(ExternalReferences(references));
}
None
}
fn get_licenses(&self, package: &Package) -> Option<Licenses> {
let mut licenses = vec![];
if let Some(license) = &package.license {
let parse_mode = self
.config
.license_parser
.as_ref()
.map(|opts| opts.mode)
.unwrap_or_default();
log::trace!(
"Using license parser mode [{:?}] for package [{}@{}]",
parse_mode,
package.name,
package.version
);
let result = match parse_mode {
ParseMode::Strict => SpdxExpression::try_from(license.to_string()),
ParseMode::Lax => SpdxExpression::parse_lax(license.to_string()),
};
match result {
Ok(expression) => licenses.push(LicenseChoice::Expression(expression)),
Err(err) => {
let level = match &self.config.license_parser {
Some(opts) if opts.accept_named.contains(license) => Level::Info,
_ => Level::Warn,
};
log::log!(
level,
"Package {} has an invalid license expression ({}), using as named license: {}",
package.name,
license,
err,
);
licenses.push(LicenseChoice::License(License::named_license(license)))
}
}
}
if licenses.is_empty() {
if let Some(license_file) = package.license_file().as_ref() {
match std::fs::read_to_string(license_file.as_path()) {
Ok(content) => {
let mut license = License::named_license("Unknown");
let encoded_text = AttachedText::new(None, content);
license.text = Some(encoded_text);
licenses.push(LicenseChoice::License(license));
}
Err(error) => {
log::warn!(
"Failed to read license file '{}' for package {}: {}",
package.name,
license_file,
error
);
}
}
}
}
if licenses.is_empty() {
log::trace!(
"Package {} has no licenses or license file specified",
package.name
);
return None;
}
Some(Licenses(licenses))
}
fn get_hashes(&self, package: &Package) -> Option<cyclonedx_bom::models::hash::Hashes> {
match self.crate_hashes.get(&package.id) {
Some(hash) => Some(cyclonedx_bom::models::hash::Hashes(vec![to_bom_hash(hash)])),
None => {
log::debug!(
"Hash for package ID {} not found in Cargo.lock",
&package.id
);
None
}
}
}
fn create_metadata(
&self,
package: &Package,
) -> Result<(Metadata, TargetKinds), GeneratorError> {
let authors = Self::create_authors(package);
let mut metadata = Metadata::new()?;
if !authors.is_empty() {
metadata.authors = Some(authors);
}
let (component, target_kinds) = self.create_toplevel_component(package);
metadata.component = Some(component);
let tool = Tool::new("CycloneDX", "cargo-cyclonedx", env!("CARGO_PKG_VERSION"));
metadata.tools = Some(Tools::List(vec![tool]));
use crate::config::Target::*;
let properties = match self.config.target.as_ref().unwrap() {
SingleTarget(target) => vec![Property::new("cdx:rustc:sbom:target:triple", target)],
AllTargets => vec![Property::new("cdx:rustc:sbom:target:all_targets", "true")],
};
metadata.properties = Some(Properties(properties));
Ok((metadata, target_kinds))
}
fn create_authors(package: &Package) -> Vec<OrganizationalContact> {
let mut authors = vec![];
let mut invalid_authors = vec![];
for author in &package.authors {
match Self::parse_author(author) {
Ok(author) => authors.push(author),
Err(e) => invalid_authors.push((author, e)),
}
}
invalid_authors
.into_iter()
.for_each(|(author, error)| log::warn!("Invalid author {}: {:?}", author, error));
authors
}
fn parse_author(author: &str) -> Result<OrganizationalContact, GeneratorError> {
static AUTHORS_REGEX: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"^(?P<author>[^<]+)\s*(<(?P<email>[^>]+)>)?$")
.expect("Failed to compile regex.")
});
match AUTHORS_REGEX.captures(author) {
Some(captures) => {
let name = captures.name("author").map_or("", |m| m.as_str().trim());
let email = captures.name("email").map(|m| m.as_str());
if let Some(email) = email {
if !email.validate_email() {
return Err(GeneratorError::AuthorParseError(
"Invalid email, does not conform to HTML5 spec".to_string(),
));
}
}
Ok(OrganizationalContact::new(name, email))
}
None => Ok(OrganizationalContact::new(author, None)),
}
}
}
fn filter_targets(
targets: &[cargo_metadata::Target],
) -> impl Iterator<Item = &cargo_metadata::Target> {
targets.iter().filter(|tgt| {
!(tgt.is_bench() || tgt.is_example() || tgt.is_test() || tgt.is_custom_build())
})
}
fn index_packages(packages: Vec<Package>) -> PackageMap {
packages
.into_iter()
.map(|pkg| (pkg.id.clone(), pkg))
.collect()
}
fn index_resolve(packages: Vec<Node>) -> ResolveMap {
packages
.into_iter()
.map(|pkg| (pkg.id.clone(), pkg))
.collect()
}
fn index_dep_kinds(root: &PackageId, resolve: &ResolveMap) -> DependencyKindMap {
let mut id_to_dep_kind: HashMap<PackageId, PrivateDepKind> = HashMap::new();
id_to_dep_kind.insert(root.clone(), PrivateDepKind::Runtime);
type DepNode = (PackageId, PrivateDepKind, PrivateDepKind);
let mut nodes_to_visit: Vec<DepNode> = vec![];
nodes_to_visit.push((
root.clone(),
PrivateDepKind::Runtime,
PrivateDepKind::Runtime,
));
let mut visited_nodes: HashSet<DepNode> = HashSet::new();
while let Some((pkg_id, node_kind, path_node_kind)) = nodes_to_visit.pop() {
visited_nodes.insert((pkg_id.clone(), node_kind, path_node_kind));
let dep_kind_on_previous_visit = id_to_dep_kind.get(&pkg_id);
if dep_kind_on_previous_visit.is_none()
|| path_node_kind > *dep_kind_on_previous_visit.unwrap()
{
let _ = id_to_dep_kind.insert(pkg_id.clone(), path_node_kind);
}
let node = &resolve[&pkg_id];
for child_dep in &node.deps {
for dep_kind in &child_dep.dep_kinds {
let current_kind = PrivateDepKind::from(&dep_kind.kind);
let new_path_node_kind = min(current_kind, path_node_kind);
let dep_node: DepNode = (child_dep.pkg.clone(), current_kind, new_path_node_kind);
if !visited_nodes.contains(&dep_node) {
nodes_to_visit.push(dep_node);
}
}
}
}
id_to_dep_kind
.iter()
.map(|(x, y)| ((*x).clone(), DependencyKind::from(*y)))
.collect()
}
#[derive(Error, Debug)]
pub enum GeneratorError {
#[error("Expected a root package in the cargo config: {config_filepath}")]
RootPackageMissingError { config_filepath: String },
#[error("Could not process the cargo config: {config_filepath}")]
CargoConfigError {
config_filepath: String,
#[source]
error: anyhow::Error,
},
#[error("Error retrieving package information: {package_id}")]
PackageError {
package_id: cargo_metadata::PackageId,
#[source]
error: anyhow::Error,
},
#[error("Error creating Metadata")]
MetadataError(#[from] MetadataError),
#[error("Could not parse author string: {}", .0)]
AuthorParseError(String),
}
fn create_dependencies(resolve: &ResolveMap) -> Dependencies {
let deps = resolve
.values()
.map(|node| Dependency {
dependency_ref: node.id.to_string(),
dependencies: node.dependencies.iter().map(|d| d.to_string()).collect(),
})
.collect();
Dependencies(deps)
}
fn top_level_dependencies(
root: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
config: &SbomConfig,
) -> (PackageMap, ResolveMap) {
log::trace!("Adding top-level dependencies to SBOM");
let root_node = add_filtered_dependencies(&resolve[root], config);
let mut pkg_result = PackageMap::new();
pkg_result.insert(root.to_owned(), packages[root].to_owned());
for id in &root_node.dependencies {
pkg_result.insert((*id).to_owned(), packages[id].to_owned());
}
let mut resolve_result = ResolveMap::new();
for id in &root_node.dependencies {
let mut node = resolve[id].clone();
node.deps = Vec::new();
node.dependencies = Vec::new();
resolve_result.insert((*id).to_owned(), node);
}
resolve_result.insert(root.to_owned(), root_node);
(pkg_result, resolve_result)
}
fn all_dependencies(
root: &PackageId,
packages: &PackageMap,
resolve: &ResolveMap,
config: &SbomConfig,
) -> (PackageMap, ResolveMap) {
log::trace!("Adding all dependencies to SBOM");
let mut current_queue: Vec<&Node> = vec![&resolve[root]];
let mut next_queue: Vec<&Node> = Vec::new();
let mut out_resolve = ResolveMap::new();
while !current_queue.is_empty() {
for node in current_queue.drain(..) {
if !out_resolve.contains_key(&node.id) {
out_resolve.insert(node.id.to_owned(), add_filtered_dependencies(node, config));
next_queue.extend(
filtered_dependencies(&node.deps, config).map(|dep| &resolve[&dep.pkg]),
);
}
}
std::mem::swap(&mut current_queue, &mut next_queue);
}
let out_packages = packages
.iter()
.filter(|(id, _pkg)| out_resolve.contains_key(id))
.map(|(id, pkg)| (id.to_owned(), pkg.to_owned()))
.collect();
(out_packages, out_resolve)
}
fn add_filtered_dependencies(node: &Node, config: &SbomConfig) -> Node {
let mut node = node.clone();
node.deps = filtered_dependencies(&node.deps, config).cloned().collect();
node.dependencies = node.deps.iter().map(|d| d.pkg.to_owned()).collect();
node
}
fn filtered_dependencies<'a>(
input: &'a [NodeDep],
config: &'a SbomConfig,
) -> impl Iterator<Item = &'a NodeDep> {
input.iter().filter(|p| {
p.dep_kinds.iter().any(|dep| {
if let Some(true) = config.only_normal_deps {
dep.kind == DependencyKind::Normal
} else {
dep.kind != DependencyKind::Development
}
})
})
}
#[derive(Debug)]
pub struct GeneratedSbom {
pub bom: Bom,
pub manifest_path: PathBuf,
pub package_name: String,
pub sbom_config: SbomConfig,
pub target_kinds: TargetKinds,
}
impl GeneratedSbom {
pub fn write_to_files(self) -> Result<(), SbomWriterError> {
match self.sbom_config.describe.unwrap_or_default() {
Describe::Crate => {
let path = self.manifest_path.with_file_name(self.filename(None, &[]));
Self::write_to_file(self.bom, &path, &self.sbom_config)
}
pattern @ (Describe::Binaries | Describe::AllCargoTargets) => {
for (sbom, target_kind) in
Self::per_artifact_sboms(&self.bom, &self.target_kinds, pattern)
{
let meta = sbom.metadata.as_ref().unwrap();
let name = meta.component.as_ref().unwrap().name.as_ref();
let path = self
.manifest_path
.with_file_name(self.filename(Some(name), &target_kind));
Self::write_to_file(sbom, &path, &self.sbom_config)?;
}
Ok(())
}
}
}
fn write_to_file(bom: Bom, path: &Path, config: &SbomConfig) -> Result<(), SbomWriterError> {
if cfg!(debug_assertions) {
let result = bom.validate();
if result.has_errors() {
panic!(
"The generated SBOM failed validation: {:?}",
result.errors()
);
}
}
use cyclonedx_bom::models::bom::SpecVersion::*;
let spec_version = config.spec_version.unwrap_or(V1_3);
log::info!("Outputting {}", path.display());
let file = File::create(path)?;
let mut writer = BufWriter::new(file);
match config.format() {
Format::Json => {
bom.output_as_json(&mut writer, spec_version)
.map_err(SbomWriterError::JsonWriteError)?;
}
Format::Xml => {
bom.output_as_xml(&mut writer, spec_version)
.map_err(SbomWriterError::XmlWriteError)?;
}
}
writer.flush()?;
Ok(())
}
fn per_artifact_sboms<'a>(
bom: &'a Bom,
target_kinds: &'a TargetKinds,
describe: Describe,
) -> impl Iterator<Item = (Bom, Vec<String>)> + 'a {
let meta = bom.metadata.as_ref().unwrap();
let crate_component = meta.component.as_ref().unwrap();
let components = crate_component.components.as_ref().unwrap();
components
.0
.iter()
.filter(move |component| {
let target_kind = &target_kinds.0[component.bom_ref.as_ref().unwrap()];
match describe {
Describe::Binaries => {
target_kind.contains(&"bin".to_owned())
|| target_kind.contains(&"cdylib".to_owned())
}
Describe::AllCargoTargets => true, Describe::Crate => unreachable!(),
}
})
.map(move |component| {
let target_kind = &target_kinds.0[component.bom_ref.as_ref().unwrap()];
let mut new_bom = bom.clone();
let metadata = new_bom.metadata.as_mut().unwrap();
let toplevel_component = metadata.component.as_mut().unwrap();
toplevel_component.name = component.name.clone();
toplevel_component.component_type = component.component_type.clone();
toplevel_component.purl.clone_from(&component.purl);
match describe {
Describe::Crate => (),
Describe::Binaries | Describe::AllCargoTargets => {
toplevel_component.components = None
}
}
(new_bom, target_kind.clone())
})
}
fn filename(&self, binary_name: Option<&str>, target_kind: &[String]) -> String {
let output_options = self.sbom_config.output_options();
let describe = self.sbom_config.describe.unwrap_or_default();
let mut prefix = match describe {
Describe::Crate => self.package_name.clone(),
Describe::Binaries => binary_name.unwrap().to_owned(),
Describe::AllCargoTargets => binary_name.unwrap().to_owned(),
};
let mut extension = ".cdx";
match output_options.filename {
FilenamePattern::CrateName => (), FilenamePattern::Custom(name_override) => {
prefix = name_override.to_string();
extension = ""; }
}
let target_kind_suffix = if !target_kind.is_empty() {
debug_assert!(matches!(
describe,
Describe::Binaries | Describe::AllCargoTargets
));
format!("_{}", target_kind.join("-"))
} else {
"".to_owned()
};
let platform_suffix = match output_options.platform_suffix {
PlatformSuffix::NotIncluded => "".to_owned(),
PlatformSuffix::Included => {
extension = ".cdx"; let target_string = self.sbom_config.target.as_ref().unwrap();
format!("_{}", target_string.as_str())
}
};
format!(
"{}{}{}{}.{}",
prefix,
target_kind_suffix,
platform_suffix,
extension,
self.sbom_config.format()
)
}
}
fn locate_cargo_lock(manifest_path: &Path) -> Result<PathBuf, std::io::Error> {
let manifest_path = manifest_path.canonicalize()?;
let ancestors = manifest_path.as_path().ancestors();
for path in ancestors {
let potential_lockfile = path.join("Cargo.lock");
if potential_lockfile.is_file() {
return Ok(potential_lockfile);
}
}
Err(std::io::Error::new(
std::io::ErrorKind::NotFound,
"Could not find Cargo.lock in any parent directories",
))
}
fn package_hashes(lockfile: &Lockfile) -> HashMap<cargo_metadata::PackageId, Checksum> {
let mut result = HashMap::new();
for pkg in &lockfile.packages {
if let Some(hash) = pkg.checksum.as_ref() {
result.insert(cargo_metadata::PackageId { repr: pkgid(pkg) }, hash.clone());
}
}
result
}
fn pkgid(pkg: &cargo_lock::Package) -> String {
match pkg.source.as_ref() {
Some(source) => format!("{}#{}@{}", source, pkg.name, pkg.version),
None => format!("{}@{}", pkg.name, pkg.version),
}
}
fn to_bom_hash(hash: &Checksum) -> cyclonedx_bom::models::hash::Hash {
use cyclonedx_bom::models::hash::{Hash, HashAlgorithm, HashValue};
match hash {
Checksum::Sha256(_) => {
Hash {
alg: HashAlgorithm::SHA_256,
content: HashValue(format!("{hash:x}")),
}
}
}
}
#[derive(Error, Debug)]
pub enum SbomWriterError {
#[error("I/O error")]
IoError(#[source] std::io::Error),
#[error("Error writing JSON file")]
JsonWriteError(#[source] cyclonedx_bom::errors::JsonWriteError),
#[error("Error writing XML file")]
XmlWriteError(#[source] cyclonedx_bom::errors::XmlWriteError),
#[error("Error serializing to XML")]
SerializeXmlError(#[source] std::io::Error),
}
impl From<std::io::Error> for SbomWriterError {
fn from(value: std::io::Error) -> Self {
Self::IoError(value)
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn it_should_parse_author_and_email() {
let actual = SbomGenerator::parse_author("First Last <user@domain.tld>")
.expect("Failed to parse author");
let expected = OrganizationalContact::new("First Last", Some("user@domain.tld"));
assert_eq!(actual, expected);
}
#[test]
fn it_should_parse_author_only() {
let actual = SbomGenerator::parse_author("First Last").expect("Failed to parse author");
let expected = OrganizationalContact::new("First Last", None);
assert_eq!(actual, expected);
}
#[test]
fn it_should_fail_to_parse_invalid_email() {
let actual = SbomGenerator::parse_author("First Last <userdomain.tld>")
.expect_err("Failed to throw error");
match actual {
GeneratorError::AuthorParseError(e) => assert_eq!(
e,
"Invalid email, does not conform to HTML5 spec".to_string()
),
e => panic!("Expected AuthorParse error got: {:?}", e),
}
}
#[test]
fn it_should_parse_author_inside_brackets() {
let actual = SbomGenerator::parse_author("<First Last user@domain.tld>")
.expect("Failed to parse author");
let expected = OrganizationalContact::new("<First Last user@domain.tld>", None);
assert_eq!(actual, expected);
}
}