Skip to content

Commit

Permalink
Abstract common code between sbom formats
Browse files Browse the repository at this point in the history
  • Loading branch information
ejortega committed Aug 17, 2023
1 parent f12d7a0 commit f787003
Show file tree
Hide file tree
Showing 4 changed files with 161 additions and 132 deletions.
135 changes: 34 additions & 101 deletions lockfile/src/cyclonedx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -27,21 +21,21 @@ trait Component {
Self: Sized;
}

// CycloneDX BOM.
/// CycloneDX BOM.
#[derive(Clone, Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct Bom<T> {
components: Option<T>,
}

// Struct for wrapping a list of components from XML.
/// Struct for wrapping a list of components from XML.
#[derive(Clone, Debug, Deserialize)]
struct Components<T> {
#[serde(rename = "component")]
components: Vec<T>,
}

// Represents a single XML component.
/// Represents a single XML component.
#[derive(Clone, Debug, Deserialize)]
struct XmlComponent {
#[serde(rename = "type")]
Expand Down Expand Up @@ -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")]
Expand All @@ -91,7 +82,8 @@ struct JsonComponent {
version: String,
scope: Option<String>,
purl: Option<String>,
components: Option<Vec<JsonComponent>>,
#[serde(default)]
components: Vec<JsonComponent>,
}

impl Component for JsonComponent {
Expand All @@ -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<T: Component>(components: &[T]) -> impl Iterator<Item = &'_ T> {
components
.iter()
Expand All @@ -132,7 +121,8 @@ fn filter_components<T: Component>(components: &[T]) -> impl Iterator<Item = &'_
|| comp.component_type() == "framework"
|| comp.component_type() == "library";

// Check if the scope is "required" or not specified (required)
// The scope is optional and can be required, optional, or excluded
// If the scope is None, the spec implies required
let scope_check = match comp.scope() {
Some(scope) => scope == "required",
None => true,
Expand All @@ -149,7 +139,7 @@ fn filter_components<T: Component>(components: &[T]) -> impl Iterator<Item = &'_
})
}

// Convert a component's Package URL (PURL) into a Package object.
/// Convert a component's package URL (PURL) into a package object.
fn from_purl<T: Component>(component: &T) -> anyhow::Result<Package> {
let purl_str = component
.purl()
Expand All @@ -158,11 +148,7 @@ fn from_purl<T: Component>(component: &T) -> anyhow::Result<Package> {
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
Expand All @@ -171,25 +157,7 @@ fn from_purl<T: Component>(component: &T) -> anyhow::Result<Package> {
.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 })
}
Expand All @@ -198,18 +166,21 @@ pub struct CycloneDX;

impl Parse for CycloneDX {
fn parse(&self, data: &str) -> anyhow::Result<Vec<Package>> {
if let Ok(lock) = serde_json::from_str::<serde_json::Value>(data) {
let parsed: Bom<Vec<JsonComponent>> = 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<Components<XmlComponent>> = 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::<serde_json::Value>(data) {
Ok(lock) => {
let parsed: Bom<Vec<JsonComponent>> = 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<Components<XmlComponent>> = 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()
})
},
}
}

Expand All @@ -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/[email protected]",
"components": [
{
"type": "library",
"name": "LibA",
"version": "1.1",
"scope": "required",
"purl": "pkg:npm/[email protected]"
},
{
"type": "library",
"name": "LibB",
"version": "1.2",
"purl": "pkg:pypi/[email protected]"
}
]
},
{
"type": "application",
"name": "AppA",
"version": "1.0",
"scope": "required",
"purl": "pkg:pypi/[email protected]"
}
]
}
"#;

let expected_pkgs = vec![
Package {
name: "FrameworkA".into(),
Expand All @@ -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);
}

Expand Down
83 changes: 82 additions & 1 deletion lockfile/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -332,6 +334,85 @@ pub fn find_lockable_files_at(root: impl AsRef<Path>) -> 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>,
) -> 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<String>,
) -> 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};
Expand Down Expand Up @@ -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() {
Expand Down
Loading

0 comments on commit f787003

Please sign in to comment.