use serde::{Deserialize, Serialize};
use super::builder::{Component, ComponentType};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpdxDocument {
pub spdx_version: String,
pub data_license: String,
#[serde(rename = "SPDXID")]
pub spdx_id: String,
pub name: String,
pub document_namespace: String,
pub creation_info: CreationInfo,
pub packages: Vec<SpdxPackage>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub relationships: Vec<Relationship>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CreationInfo {
pub created: String,
pub creators: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SpdxPackage {
#[serde(rename = "SPDXID")]
pub spdx_id: String,
pub name: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version_info: Option<String>,
pub download_location: String,
pub files_analyzed: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_concluded: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub license_declared: Option<String>,
pub copyright_text: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub supplier: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub external_refs: Vec<ExternalRef>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub checksums: Vec<Checksum>,
#[serde(skip_serializing_if = "Option::is_none")]
pub primary_package_purpose: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExternalRef {
pub reference_category: String,
pub reference_type: String,
pub reference_locator: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Checksum {
pub algorithm: String,
pub checksum_value: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Relationship {
pub spdx_element_id: String,
pub relationship_type: String,
pub related_spdx_element: String,
}
impl SpdxDocument {
pub fn from_components(components: &[Component]) -> Self {
let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let uuid = uuid::Uuid::new_v4();
let packages: Vec<SpdxPackage> = components
.iter()
.enumerate()
.map(|(i, c)| SpdxPackage::from_component(c, i))
.collect();
let mut relationships: Vec<Relationship> = packages
.iter()
.map(|p| Relationship {
spdx_element_id: "SPDXRef-DOCUMENT".to_string(),
relationship_type: "DESCRIBES".to_string(),
related_spdx_element: p.spdx_id.clone(),
})
.collect();
if !packages.is_empty() {
relationships.push(Relationship {
spdx_element_id: packages[0].spdx_id.clone(),
relationship_type: "DEPENDENCY_OF".to_string(),
related_spdx_element: "SPDXRef-DOCUMENT".to_string(),
});
}
Self {
spdx_version: "SPDX-2.3".to_string(),
data_license: "CC0-1.0".to_string(),
spdx_id: "SPDXRef-DOCUMENT".to_string(),
name: "cc-audit SBOM".to_string(),
document_namespace: format!("https://github.com/ryo-ebata/cc-audit/spdx/{}", uuid),
creation_info: CreationInfo {
created: timestamp,
creators: vec![format!("Tool: cc-audit-{}", env!("CARGO_PKG_VERSION"))],
},
packages,
relationships,
}
}
}
impl SpdxPackage {
fn from_component(component: &Component, index: usize) -> Self {
let spdx_id = format!("SPDXRef-Package-{}", index + 1);
let mut external_refs = Vec::new();
if let Some(ref purl) = component.purl {
external_refs.push(ExternalRef {
reference_category: "PACKAGE-MANAGER".to_string(),
reference_type: "purl".to_string(),
reference_locator: purl.clone(),
});
}
let mut checksums = Vec::new();
if let Some(ref hash) = component.hash_sha256 {
checksums.push(Checksum {
algorithm: "SHA256".to_string(),
checksum_value: hash.clone(),
});
}
let download_location = component
.repository
.clone()
.unwrap_or_else(|| "NOASSERTION".to_string());
let supplier = component.author.as_ref().map(|a| format!("Person: {}", a));
Self {
spdx_id,
name: component.name.clone(),
version_info: component.version.clone(),
download_location,
files_analyzed: false,
license_concluded: component.license.clone(),
license_declared: component.license.clone(),
copyright_text: "NOASSERTION".to_string(),
supplier,
description: component.description.clone(),
external_refs,
checksums,
primary_package_purpose: Some(component_type_to_spdx_purpose(
&component.component_type,
)),
}
}
}
fn component_type_to_spdx_purpose(component_type: &ComponentType) -> String {
match component_type {
ComponentType::Application => "APPLICATION".to_string(),
ComponentType::Library => "LIBRARY".to_string(),
ComponentType::Service => "SOURCE".to_string(), ComponentType::McpServer => "APPLICATION".to_string(),
ComponentType::Skill => "APPLICATION".to_string(),
ComponentType::Plugin => "LIBRARY".to_string(),
ComponentType::Subagent => "APPLICATION".to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_spdx_document_from_components() {
let components = vec![
Component::new("test-package", ComponentType::Library)
.with_version("1.0.0")
.with_purl("pkg:npm/test-package@1.0.0"),
];
let doc = SpdxDocument::from_components(&components);
assert_eq!(doc.spdx_version, "SPDX-2.3");
assert_eq!(doc.data_license, "CC0-1.0");
assert_eq!(doc.packages.len(), 1);
assert_eq!(doc.packages[0].name, "test-package");
assert_eq!(doc.packages[0].version_info, Some("1.0.0".to_string()));
}
#[test]
fn test_spdx_package_external_refs() {
let component =
Component::new("test", ComponentType::Library).with_purl("pkg:npm/test@1.0.0");
let package = SpdxPackage::from_component(&component, 0);
assert_eq!(package.external_refs.len(), 1);
assert_eq!(package.external_refs[0].reference_type, "purl");
assert_eq!(
package.external_refs[0].reference_locator,
"pkg:npm/test@1.0.0"
);
}
#[test]
fn test_spdx_package_checksums() {
let component = Component::new("test", ComponentType::Library).with_hash("abc123def456");
let package = SpdxPackage::from_component(&component, 0);
assert_eq!(package.checksums.len(), 1);
assert_eq!(package.checksums[0].algorithm, "SHA256");
assert_eq!(package.checksums[0].checksum_value, "abc123def456");
}
#[test]
fn test_component_type_to_spdx_purpose() {
assert_eq!(
component_type_to_spdx_purpose(&ComponentType::Application),
"APPLICATION"
);
assert_eq!(
component_type_to_spdx_purpose(&ComponentType::Library),
"LIBRARY"
);
assert_eq!(
component_type_to_spdx_purpose(&ComponentType::McpServer),
"APPLICATION"
);
}
#[test]
fn test_spdx_serialization() {
let components = vec![Component::new("test", ComponentType::Library)];
let doc = SpdxDocument::from_components(&components);
let json = serde_json::to_string_pretty(&doc).unwrap();
assert!(json.contains("SPDX-2.3"));
assert!(json.contains("test"));
}
}