diff --git a/lockfile/src/cyclonedx.rs b/lockfile/src/cyclonedx.rs index deec8d500..b4ee42689 100644 --- a/lockfile/src/cyclonedx.rs +++ b/lockfile/src/cyclonedx.rs @@ -6,16 +6,10 @@ use anyhow::anyhow; use phylum_types::types::package::PackageType; use purl::GenericPurl; use serde::Deserialize; -use thiserror::Error; -use crate::{Package, PackageVersion, Parse, ThirdPartyVersion}; +use crate::{determine_package_version, formatted_package_name, Package, Parse, UnknownEcosystem}; -// Define a custom error for unknown ecosystems. -#[derive(Error, Debug)] -#[error("Could not determine ecosystem")] -struct UnknownEcosystem; - -// Define the generic trait for components. +/// Define the generic trait for components. trait Component { fn component_type(&self) -> &str; fn name(&self) -> &str; @@ -27,21 +21,21 @@ trait Component { Self: Sized; } -// CycloneDX BOM. +/// CycloneDX BOM. #[derive(Clone, Debug, Deserialize)] #[serde(rename_all = "camelCase")] struct Bom { components: Option, } -// Struct for wrapping a list of components from XML. +/// Struct for wrapping a list of components from XML. #[derive(Clone, Debug, Deserialize)] struct Components { #[serde(rename = "component")] components: Vec, } -// Represents a single XML component. +/// Represents a single XML component. #[derive(Clone, Debug, Deserialize)] struct XmlComponent { #[serde(rename = "type")] @@ -74,15 +68,12 @@ impl Component for XmlComponent { self.purl.as_deref() } - fn components(&self) -> Option<&[Self]> - where - Self: Sized, - { + fn components(&self) -> Option<&[Self]> { self.components.as_ref().map(|comps| comps.components.as_slice()) } } -// Represents a single JSON component. +/// Represents a single JSON component. #[derive(Clone, Debug, Deserialize)] struct JsonComponent { #[serde(rename = "type")] @@ -91,7 +82,8 @@ struct JsonComponent { version: String, scope: Option, purl: Option, - components: Option>, + #[serde(default)] + components: Vec, } impl Component for JsonComponent { @@ -115,15 +107,12 @@ impl Component for JsonComponent { self.purl.as_deref() } - fn components(&self) -> Option<&[Self]> - where - Self: Sized, - { - self.components.as_deref() + fn components(&self) -> Option<&[Self]> { + Some(&self.components) } } -// Filter components based on their type and scope. +/// Filter components based on the type and scope. fn filter_components(components: &[T]) -> impl Iterator { components .iter() @@ -132,7 +121,8 @@ fn filter_components(components: &[T]) -> impl Iterator scope == "required", None => true, @@ -149,7 +139,7 @@ fn filter_components(components: &[T]) -> impl Iterator(component: &T) -> anyhow::Result { let purl_str = component .purl() @@ -158,11 +148,7 @@ fn from_purl(component: &T) -> anyhow::Result { let package_type = PackageType::from_str(purl.package_type()).map_err(|_| UnknownEcosystem)?; // Determine the package name based on its type and namespace. - let name = match (package_type, purl.namespace()) { - (PackageType::Maven, Some(ns)) => format!("{}:{}", ns, purl.name()), - (PackageType::Npm | PackageType::Golang, Some(ns)) => format!("{}/{}", ns, purl.name()), - _ => purl.name().into(), - }; + let name = formatted_package_name(&package_type, &purl); // Extract the package version let pkg_version = purl @@ -171,25 +157,7 @@ fn from_purl(component: &T) -> anyhow::Result { .map_err(|_| anyhow!("No version found for `{}`", name))?; // Use the qualifiers from the PURL to determine the version details. - let version = purl - .qualifiers() - .iter() - .find_map(|(key, value)| match key.as_ref() { - "repository_url" => Some(PackageVersion::ThirdParty(ThirdPartyVersion { - version: pkg_version.into(), - registry: value.to_string(), - })), - "download_url" => Some(PackageVersion::DownloadUrl(value.to_string())), - "vcs_url" => { - if value.starts_with("git+") { - Some(PackageVersion::Git(value.to_string())) - } else { - None - } - }, - _ => None, - }) - .unwrap_or(PackageVersion::FirstParty(pkg_version.into())); + let version = determine_package_version(pkg_version, &purl); Ok(Package { name, version, package_type }) } @@ -198,18 +166,21 @@ pub struct CycloneDX; impl Parse for CycloneDX { fn parse(&self, data: &str) -> anyhow::Result> { - if let Ok(lock) = serde_json::from_str::(data) { - let parsed: Bom> = serde_json::from_value(lock)?; - parsed.components.map_or(Ok(vec![]), |comp| { - let component_iter = filter_components(&comp); - component_iter.map(from_purl).collect() - }) - } else { - let parsed: Bom> = serde_xml_rs::from_str(data)?; - parsed.components.map_or(Ok(vec![]), |comp| { - let component_iter = filter_components(&comp.components); - component_iter.map(from_purl).collect() - }) + match serde_json::from_str::(data) { + Ok(lock) => { + let parsed: Bom> = serde_json::from_value(lock)?; + parsed.components.map_or(Ok(Vec::new()), |comp| { + let component_iter = filter_components(&comp); + component_iter.map(from_purl).collect() + }) + }, + Err(_) => { + let parsed: Bom> = serde_xml_rs::from_str(data)?; + parsed.components.map_or(Ok(Vec::new()), |comp| { + let component_iter = filter_components(&comp.components); + component_iter.map(from_purl).collect() + }) + }, } } @@ -225,49 +196,11 @@ impl Parse for CycloneDX { #[cfg(test)] mod tests { - use super::*; + use crate::PackageVersion; #[test] fn parse_cyclonedx_1_5_json() { - let sample_data = r#" - { - "bomFormat": "CycloneDX", - "specVersion": "1.5", - "components": [ - { - "type": "framework", - "name": "FrameworkA", - "version": "1.0", - "scope": "required", - "purl": "pkg:npm/FrameworkA@1.0", - "components": [ - { - "type": "library", - "name": "LibA", - "version": "1.1", - "scope": "required", - "purl": "pkg:npm/LibA@1.1" - }, - { - "type": "library", - "name": "LibB", - "version": "1.2", - "purl": "pkg:pypi/LibB@1.2" - } - ] - }, - { - "type": "application", - "name": "AppA", - "version": "1.0", - "scope": "required", - "purl": "pkg:pypi/AppA@1.0" - } - ] - } - "#; - let expected_pkgs = vec![ Package { name: "FrameworkA".into(), @@ -291,7 +224,7 @@ mod tests { }, ]; - let pkgs = CycloneDX.parse(sample_data).unwrap(); + let pkgs = CycloneDX.parse(include_str!("../../tests/fixtures/bom.1.5.json")).unwrap(); assert_eq!(pkgs, expected_pkgs); } diff --git a/lockfile/src/lib.rs b/lockfile/src/lib.rs index c96797b56..a1c8bde7a 100644 --- a/lockfile/src/lib.rs +++ b/lockfile/src/lib.rs @@ -13,11 +13,13 @@ pub use javascript::{PackageLock, Pnpm, YarnLock}; #[cfg(feature = "generator")] use lockfile_generator::Generator; use phylum_types::types::package::PackageType; +use purl::GenericPurl; pub use python::{PipFile, Poetry, PyRequirements}; pub use ruby::GemLock; use serde::de::IntoDeserializer; use serde::{Deserialize, Serialize}; pub use spdx::Spdx; +use thiserror::Error; use walkdir::WalkDir; mod cargo; @@ -332,6 +334,85 @@ pub fn find_lockable_files_at(root: impl AsRef) -> Vec<(PathBuf, LockfileF lockfiles } +/// Define a custom error for unknown ecosystems. +#[derive(Error, Debug)] +#[error("Could not determine ecosystem")] +pub(crate) struct UnknownEcosystem; + +/// Generates a formatted package name based on the given package type and Purl. +/// +/// This function formats package names differently depending on the package +/// type: +/// +/// - For `Maven` packages, the format is `"namespace:name"`. +/// - For `Npm` and `Golang` packages, the format is `"namespace/name"`. +/// - For other package types, or if no namespace is provided, it defaults to +/// the package name. +/// +/// # Arguments +/// +/// - `package_type`: The type of the package. +/// - `purl`: A reference to the Purl struct which contains details about the +/// package. +/// +/// # Returns +/// +/// - A `String` representation of the formatted package name. +pub(crate) fn formatted_package_name( + package_type: &PackageType, + purl: &GenericPurl, +) -> String { + match (package_type, purl.namespace()) { + (PackageType::Maven, Some(ns)) => format!("{}:{}", ns, purl.name()), + (PackageType::Npm | PackageType::Golang, Some(ns)) => format!("{}/{}", ns, purl.name()), + _ => purl.name().into(), + } +} + +/// Determines the package version from Purl qualifiers. +/// +/// This function parses the qualifiers of a Purl object and returns the +/// corresponding `PackageVersion` based on the provided key: +/// +/// - "repository_url": returns a `ThirdParty` version. +/// - "download_url": returns a `DownloadUrl` version. +/// - "vcs_url": checks if it starts with "git+" and returns a `Git` version. +/// - For other keys or in absence of any known key, it defaults to the +/// `FirstParty` version. +/// +/// # Arguments +/// +/// - `purl`: A reference to the Purl struct which contains package details. +/// - `pkg_version`: The default version to use if no specific qualifier is +/// found. +/// +/// # Returns +/// +/// - A `PackageVersion` representing the determined version. +pub(crate) fn determine_package_version( + pkg_version: &str, + purl: &GenericPurl, +) -> PackageVersion { + purl.qualifiers() + .iter() + .find_map(|(key, value)| match key.as_ref() { + "repository_url" => Some(PackageVersion::ThirdParty(ThirdPartyVersion { + version: pkg_version.to_string(), + registry: value.to_string(), + })), + "download_url" => Some(PackageVersion::DownloadUrl(value.to_string())), + "vcs_url" => { + if value.starts_with("git+") { + Some(PackageVersion::Git(value.to_string())) + } else { + None + } + }, + _ => None, + }) + .unwrap_or(PackageVersion::FirstParty(pkg_version.into())) +} + #[cfg(test)] mod tests { use std::fs::{self, File}; @@ -458,7 +539,7 @@ mod tests { (LockfileFormat::Go, 1), (LockfileFormat::Cargo, 3), (LockfileFormat::Spdx, 6), - (LockfileFormat::CycloneDX, 4), + (LockfileFormat::CycloneDX, 5), ] { let mut parsed_lockfiles = Vec::new(); for lockfile in fs::read_dir("../tests/fixtures").unwrap().flatten() { diff --git a/lockfile/src/spdx.rs b/lockfile/src/spdx.rs index a376667a5..7f5fa5e1e 100644 --- a/lockfile/src/spdx.rs +++ b/lockfile/src/spdx.rs @@ -7,15 +7,13 @@ use nom::Finish; use phylum_types::types::package::PackageType; use purl::GenericPurl; use serde::Deserialize; -use thiserror::Error; use urlencoding::decode; use crate::parsers::spdx; -use crate::{Package, PackageVersion, Parse, ThirdPartyVersion}; - -#[derive(Error, Debug)] -#[error("Could not determine ecosystem")] -struct UnknownEcosystem; +use crate::{ + determine_package_version, formatted_package_name, Package, PackageVersion, Parse, + UnknownEcosystem, +}; #[derive(Deserialize, Debug)] #[serde(rename_all = "camelCase")] @@ -84,11 +82,8 @@ fn from_purl(pkg_url: &str, pkg_info: &PackageInformation) -> anyhow::Result format!("{}:{}", ns, purl.name()), - (PackageType::Npm | PackageType::Golang, Some(ns)) => format!("{}/{}", ns, purl.name()), - _ => purl.name().into(), - }; + // Determine the package name based on its type and namespace. + let name = formatted_package_name(&package_type, &purl); let pkg_version = pkg_info .version_info @@ -96,25 +91,8 @@ fn from_purl(pkg_url: &str, pkg_info: &PackageInformation) -> anyhow::Result Some(PackageVersion::ThirdParty(ThirdPartyVersion { - version: pkg_version.clone(), - registry: value.to_string(), - })), - "download_url" => Some(PackageVersion::DownloadUrl(value.to_string())), - "vcs_url" => { - if value.starts_with("git+") { - Some(PackageVersion::Git(value.to_string())) - } else { - None - } - }, - _ => None, - }) - .unwrap_or(PackageVersion::FirstParty(pkg_version.into())); + // Use the qualifiers from the PURL to determine the version details. + let version = determine_package_version(pkg_version, &purl); Ok(Package { name, version, package_type }) } @@ -208,6 +186,7 @@ mod tests { use serde_json::json; use super::*; + use crate::PackageVersion; #[test] fn parse_spdx_2_2_json() { diff --git a/tests/fixtures/bom.1.5.json b/tests/fixtures/bom.1.5.json new file mode 100644 index 000000000..4393ca115 --- /dev/null +++ b/tests/fixtures/bom.1.5.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://cyclonedx.org/schema/bom-1.5.schema.json", + "bomFormat": "CycloneDX", + "specVersion": "1.5", + "components": [ + { + "type": "framework", + "name": "FrameworkA", + "version": "1.0", + "scope": "required", + "purl": "pkg:npm/FrameworkA@1.0", + "components": [ + { + "type": "library", + "name": "LibA", + "version": "1.1", + "scope": "required", + "purl": "pkg:npm/LibA@1.1" + }, + { + "type": "library", + "name": "LibB", + "version": "1.2", + "purl": "pkg:pypi/LibB@1.2" + } + ] + }, + { + "type": "application", + "name": "AppA", + "version": "1.0", + "scope": "required", + "purl": "pkg:pypi/AppA@1.0" + } + ] +} \ No newline at end of file