Skip to main content

cc_audit/sbom/
spdx.rs

1//! SPDX 2.3 format support for SBOM generation.
2
3use serde::{Deserialize, Serialize};
4
5use super::builder::{Component, ComponentType};
6
7/// SPDX 2.3 document structure.
8#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct SpdxDocument {
11    /// SPDX version (always "SPDX-2.3")
12    pub spdx_version: String,
13
14    /// Data license (CC0-1.0 for SPDX)
15    pub data_license: String,
16
17    /// SPDX identifier for the document
18    #[serde(rename = "SPDXID")]
19    pub spdx_id: String,
20
21    /// Document name
22    pub name: String,
23
24    /// Document namespace (unique URL)
25    pub document_namespace: String,
26
27    /// Creation information
28    pub creation_info: CreationInfo,
29
30    /// Packages in the document
31    pub packages: Vec<SpdxPackage>,
32
33    /// Relationships between packages
34    #[serde(skip_serializing_if = "Vec::is_empty")]
35    pub relationships: Vec<Relationship>,
36}
37
38/// SPDX creation information.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CreationInfo {
41    /// Creation timestamp (ISO 8601)
42    pub created: String,
43
44    /// Tool(s) used to create the SPDX document
45    pub creators: Vec<String>,
46}
47
48/// SPDX package representation.
49#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct SpdxPackage {
52    /// SPDX identifier for the package
53    #[serde(rename = "SPDXID")]
54    pub spdx_id: String,
55
56    /// Package name
57    pub name: String,
58
59    /// Package version
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub version_info: Option<String>,
62
63    /// Download location (NOASSERTION if unknown)
64    pub download_location: String,
65
66    /// Files analyzed flag
67    pub files_analyzed: bool,
68
69    /// License concluded
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub license_concluded: Option<String>,
72
73    /// License declared
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub license_declared: Option<String>,
76
77    /// Copyright text
78    pub copyright_text: String,
79
80    /// Package supplier
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub supplier: Option<String>,
83
84    /// Package description
85    #[serde(skip_serializing_if = "Option::is_none")]
86    pub description: Option<String>,
87
88    /// External references (e.g., purl)
89    #[serde(skip_serializing_if = "Vec::is_empty")]
90    pub external_refs: Vec<ExternalRef>,
91
92    /// Checksums
93    #[serde(skip_serializing_if = "Vec::is_empty")]
94    pub checksums: Vec<Checksum>,
95
96    /// Primary package purpose
97    #[serde(skip_serializing_if = "Option::is_none")]
98    pub primary_package_purpose: Option<String>,
99}
100
101/// SPDX external reference.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ExternalRef {
105    /// Reference category
106    pub reference_category: String,
107
108    /// Reference type
109    pub reference_type: String,
110
111    /// Reference locator (e.g., purl)
112    pub reference_locator: String,
113}
114
115/// SPDX checksum.
116#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct Checksum {
119    /// Algorithm used
120    pub algorithm: String,
121
122    /// Checksum value
123    pub checksum_value: String,
124}
125
126/// SPDX relationship between packages.
127#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Relationship {
130    /// SPDX ID of the element
131    pub spdx_element_id: String,
132
133    /// Relationship type
134    pub relationship_type: String,
135
136    /// Related SPDX element ID
137    pub related_spdx_element: String,
138}
139
140impl SpdxDocument {
141    /// Create a new SPDX document from components.
142    pub fn from_components(components: &[Component]) -> Self {
143        let timestamp = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
144        let uuid = uuid::Uuid::new_v4();
145
146        let packages: Vec<SpdxPackage> = components
147            .iter()
148            .enumerate()
149            .map(|(i, c)| SpdxPackage::from_component(c, i))
150            .collect();
151
152        // Create relationships (all packages DESCRIBED_BY the document)
153        let mut relationships: Vec<Relationship> = packages
154            .iter()
155            .map(|p| Relationship {
156                spdx_element_id: "SPDXRef-DOCUMENT".to_string(),
157                relationship_type: "DESCRIBES".to_string(),
158                related_spdx_element: p.spdx_id.clone(),
159            })
160            .collect();
161
162        // Add root package relationship if there are packages
163        if !packages.is_empty() {
164            relationships.push(Relationship {
165                spdx_element_id: packages[0].spdx_id.clone(),
166                relationship_type: "DEPENDENCY_OF".to_string(),
167                related_spdx_element: "SPDXRef-DOCUMENT".to_string(),
168            });
169        }
170
171        Self {
172            spdx_version: "SPDX-2.3".to_string(),
173            data_license: "CC0-1.0".to_string(),
174            spdx_id: "SPDXRef-DOCUMENT".to_string(),
175            name: "cc-audit SBOM".to_string(),
176            document_namespace: format!("https://github.com/ryo-ebata/cc-audit/spdx/{}", uuid),
177            creation_info: CreationInfo {
178                created: timestamp,
179                creators: vec![format!("Tool: cc-audit-{}", env!("CARGO_PKG_VERSION"))],
180            },
181            packages,
182            relationships,
183        }
184    }
185}
186
187impl SpdxPackage {
188    /// Create an SPDX package from a component.
189    fn from_component(component: &Component, index: usize) -> Self {
190        let spdx_id = format!("SPDXRef-Package-{}", index + 1);
191
192        let mut external_refs = Vec::new();
193        if let Some(ref purl) = component.purl {
194            external_refs.push(ExternalRef {
195                reference_category: "PACKAGE-MANAGER".to_string(),
196                reference_type: "purl".to_string(),
197                reference_locator: purl.clone(),
198            });
199        }
200
201        let mut checksums = Vec::new();
202        if let Some(ref hash) = component.hash_sha256 {
203            checksums.push(Checksum {
204                algorithm: "SHA256".to_string(),
205                checksum_value: hash.clone(),
206            });
207        }
208
209        let download_location = component
210            .repository
211            .clone()
212            .unwrap_or_else(|| "NOASSERTION".to_string());
213
214        let supplier = component.author.as_ref().map(|a| format!("Person: {}", a));
215
216        Self {
217            spdx_id,
218            name: component.name.clone(),
219            version_info: component.version.clone(),
220            download_location,
221            files_analyzed: false,
222            license_concluded: component.license.clone(),
223            license_declared: component.license.clone(),
224            copyright_text: "NOASSERTION".to_string(),
225            supplier,
226            description: component.description.clone(),
227            external_refs,
228            checksums,
229            primary_package_purpose: Some(component_type_to_spdx_purpose(
230                &component.component_type,
231            )),
232        }
233    }
234}
235
236/// Convert component type to SPDX primary package purpose.
237fn component_type_to_spdx_purpose(component_type: &ComponentType) -> String {
238    match component_type {
239        ComponentType::Application => "APPLICATION".to_string(),
240        ComponentType::Library => "LIBRARY".to_string(),
241        ComponentType::Service => "SOURCE".to_string(), // SPDX doesn't have SERVICE
242        ComponentType::McpServer => "APPLICATION".to_string(),
243        ComponentType::Skill => "APPLICATION".to_string(),
244        ComponentType::Plugin => "LIBRARY".to_string(),
245        ComponentType::Subagent => "APPLICATION".to_string(),
246    }
247}
248
249#[cfg(test)]
250mod tests {
251    use super::*;
252
253    #[test]
254    fn test_spdx_document_from_components() {
255        let components = vec![
256            Component::new("test-package", ComponentType::Library)
257                .with_version("1.0.0")
258                .with_purl("pkg:npm/test-package@1.0.0"),
259        ];
260
261        let doc = SpdxDocument::from_components(&components);
262
263        assert_eq!(doc.spdx_version, "SPDX-2.3");
264        assert_eq!(doc.data_license, "CC0-1.0");
265        assert_eq!(doc.packages.len(), 1);
266        assert_eq!(doc.packages[0].name, "test-package");
267        assert_eq!(doc.packages[0].version_info, Some("1.0.0".to_string()));
268    }
269
270    #[test]
271    fn test_spdx_package_external_refs() {
272        let component =
273            Component::new("test", ComponentType::Library).with_purl("pkg:npm/test@1.0.0");
274
275        let package = SpdxPackage::from_component(&component, 0);
276
277        assert_eq!(package.external_refs.len(), 1);
278        assert_eq!(package.external_refs[0].reference_type, "purl");
279        assert_eq!(
280            package.external_refs[0].reference_locator,
281            "pkg:npm/test@1.0.0"
282        );
283    }
284
285    #[test]
286    fn test_spdx_package_checksums() {
287        let component = Component::new("test", ComponentType::Library).with_hash("abc123def456");
288
289        let package = SpdxPackage::from_component(&component, 0);
290
291        assert_eq!(package.checksums.len(), 1);
292        assert_eq!(package.checksums[0].algorithm, "SHA256");
293        assert_eq!(package.checksums[0].checksum_value, "abc123def456");
294    }
295
296    #[test]
297    fn test_component_type_to_spdx_purpose() {
298        assert_eq!(
299            component_type_to_spdx_purpose(&ComponentType::Application),
300            "APPLICATION"
301        );
302        assert_eq!(
303            component_type_to_spdx_purpose(&ComponentType::Library),
304            "LIBRARY"
305        );
306        assert_eq!(
307            component_type_to_spdx_purpose(&ComponentType::McpServer),
308            "APPLICATION"
309        );
310    }
311
312    #[test]
313    fn test_spdx_serialization() {
314        let components = vec![Component::new("test", ComponentType::Library)];
315        let doc = SpdxDocument::from_components(&components);
316
317        let json = serde_json::to_string_pretty(&doc).unwrap();
318        assert!(json.contains("SPDX-2.3"));
319        assert!(json.contains("test"));
320    }
321}