1use serde::{Deserialize, Serialize};
4
5use super::builder::{Component, ComponentType};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
9#[serde(rename_all = "camelCase")]
10pub struct SpdxDocument {
11 pub spdx_version: String,
13
14 pub data_license: String,
16
17 #[serde(rename = "SPDXID")]
19 pub spdx_id: String,
20
21 pub name: String,
23
24 pub document_namespace: String,
26
27 pub creation_info: CreationInfo,
29
30 pub packages: Vec<SpdxPackage>,
32
33 #[serde(skip_serializing_if = "Vec::is_empty")]
35 pub relationships: Vec<Relationship>,
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct CreationInfo {
41 pub created: String,
43
44 pub creators: Vec<String>,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
50#[serde(rename_all = "camelCase")]
51pub struct SpdxPackage {
52 #[serde(rename = "SPDXID")]
54 pub spdx_id: String,
55
56 pub name: String,
58
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub version_info: Option<String>,
62
63 pub download_location: String,
65
66 pub files_analyzed: bool,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub license_concluded: Option<String>,
72
73 #[serde(skip_serializing_if = "Option::is_none")]
75 pub license_declared: Option<String>,
76
77 pub copyright_text: String,
79
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub supplier: Option<String>,
83
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub description: Option<String>,
87
88 #[serde(skip_serializing_if = "Vec::is_empty")]
90 pub external_refs: Vec<ExternalRef>,
91
92 #[serde(skip_serializing_if = "Vec::is_empty")]
94 pub checksums: Vec<Checksum>,
95
96 #[serde(skip_serializing_if = "Option::is_none")]
98 pub primary_package_purpose: Option<String>,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize)]
103#[serde(rename_all = "camelCase")]
104pub struct ExternalRef {
105 pub reference_category: String,
107
108 pub reference_type: String,
110
111 pub reference_locator: String,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize)]
117#[serde(rename_all = "camelCase")]
118pub struct Checksum {
119 pub algorithm: String,
121
122 pub checksum_value: String,
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128#[serde(rename_all = "camelCase")]
129pub struct Relationship {
130 pub spdx_element_id: String,
132
133 pub relationship_type: String,
135
136 pub related_spdx_element: String,
138}
139
140impl SpdxDocument {
141 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 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 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 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
236fn 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(), 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}