Skip to main content

cc_audit/sbom/
builder.rs

1//! SBOM builder for constructing software bill of materials.
2
3use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6/// SBOM output format.
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SbomFormat {
9    /// CycloneDX 1.5 format (default)
10    #[default]
11    CycloneDx,
12    /// SPDX 2.3 format (future)
13    Spdx,
14}
15
16impl std::str::FromStr for SbomFormat {
17    type Err = String;
18
19    fn from_str(s: &str) -> Result<Self, Self::Err> {
20        match s.to_lowercase().as_str() {
21            "cyclonedx" | "cdx" => Ok(Self::CycloneDx),
22            "spdx" => Ok(Self::Spdx),
23            _ => Err(format!("Unknown SBOM format: {}", s)),
24        }
25    }
26}
27
28/// Type of component in the SBOM.
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum ComponentType {
32    /// Application or service
33    Application,
34    /// Library or package
35    Library,
36    /// External service
37    Service,
38    /// MCP server
39    McpServer,
40    /// Claude Code skill
41    Skill,
42    /// Claude Code plugin
43    Plugin,
44    /// Claude Code subagent
45    Subagent,
46}
47
48impl ComponentType {
49    /// Convert to CycloneDX component type string.
50    pub fn to_cyclonedx_type(&self) -> &'static str {
51        match self {
52            Self::Application => "application",
53            Self::Library => "library",
54            Self::Service => "service",
55            Self::McpServer => "service",
56            Self::Skill => "application",
57            Self::Plugin => "library",
58            Self::Subagent => "application",
59        }
60    }
61}
62
63/// A component in the SBOM.
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Component {
66    /// Component name
67    pub name: String,
68
69    /// Component version
70    #[serde(skip_serializing_if = "Option::is_none")]
71    pub version: Option<String>,
72
73    /// Component type
74    #[serde(rename = "type")]
75    pub component_type: ComponentType,
76
77    /// Package URL (purl)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub purl: Option<String>,
80
81    /// Description
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub description: Option<String>,
84
85    /// Author or publisher
86    #[serde(skip_serializing_if = "Option::is_none")]
87    pub author: Option<String>,
88
89    /// License identifier
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub license: Option<String>,
92
93    /// Repository URL
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub repository: Option<String>,
96
97    /// SHA-256 hash of the component
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub hash_sha256: Option<String>,
100}
101
102impl Component {
103    /// Create a new component.
104    pub fn new(name: impl Into<String>, component_type: ComponentType) -> Self {
105        Self {
106            name: name.into(),
107            version: None,
108            component_type,
109            purl: None,
110            description: None,
111            author: None,
112            license: None,
113            repository: None,
114            hash_sha256: None,
115        }
116    }
117
118    /// Set the version.
119    pub fn with_version(mut self, version: impl Into<String>) -> Self {
120        self.version = Some(version.into());
121        self
122    }
123
124    /// Set the purl.
125    pub fn with_purl(mut self, purl: impl Into<String>) -> Self {
126        self.purl = Some(purl.into());
127        self
128    }
129
130    /// Set the description.
131    pub fn with_description(mut self, description: impl Into<String>) -> Self {
132        self.description = Some(description.into());
133        self
134    }
135
136    /// Set the author.
137    pub fn with_author(mut self, author: impl Into<String>) -> Self {
138        self.author = Some(author.into());
139        self
140    }
141
142    /// Set the license.
143    pub fn with_license(mut self, license: impl Into<String>) -> Self {
144        self.license = Some(license.into());
145        self
146    }
147
148    /// Set the repository URL.
149    pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
150        self.repository = Some(repo.into());
151        self
152    }
153
154    /// Set the SHA-256 hash.
155    pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
156        self.hash_sha256 = Some(hash.into());
157        self
158    }
159
160    /// Generate a purl for npm packages.
161    pub fn npm_purl(name: &str, version: Option<&str>) -> String {
162        match version {
163            Some(v) => format!("pkg:npm/{}@{}", name, v),
164            None => format!("pkg:npm/{}", name),
165        }
166    }
167
168    /// Generate a purl for GitHub repositories.
169    pub fn github_purl(owner: &str, repo: &str, version: Option<&str>) -> String {
170        match version {
171            Some(v) => format!("pkg:github/{}/{}@{}", owner, repo, v),
172            None => format!("pkg:github/{}/{}", owner, repo),
173        }
174    }
175}
176
177/// SBOM builder for creating software bill of materials.
178pub struct SbomBuilder {
179    /// Components in the SBOM
180    components: Vec<Component>,
181
182    /// Format to output
183    format: SbomFormat,
184
185    /// Include npm dependencies
186    include_npm: bool,
187
188    /// Include Cargo dependencies
189    include_cargo: bool,
190}
191
192impl SbomBuilder {
193    /// Create a new SBOM builder.
194    pub fn new() -> Self {
195        Self {
196            components: Vec::new(),
197            format: SbomFormat::CycloneDx,
198            include_npm: false,
199            include_cargo: false,
200        }
201    }
202
203    /// Set the output format.
204    pub fn with_format(mut self, format: SbomFormat) -> Self {
205        self.format = format;
206        self
207    }
208
209    /// Include npm dependencies.
210    pub fn with_npm(mut self, include: bool) -> Self {
211        self.include_npm = include;
212        self
213    }
214
215    /// Include Cargo dependencies.
216    pub fn with_cargo(mut self, include: bool) -> Self {
217        self.include_cargo = include;
218        self
219    }
220
221    /// Add a component.
222    pub fn add_component(&mut self, component: Component) {
223        self.components.push(component);
224    }
225
226    /// Get the components.
227    pub fn components(&self) -> &[Component] {
228        &self.components
229    }
230
231    /// Get the format.
232    pub fn format(&self) -> SbomFormat {
233        self.format
234    }
235
236    /// Should include npm dependencies.
237    pub fn include_npm(&self) -> bool {
238        self.include_npm
239    }
240
241    /// Should include Cargo dependencies.
242    pub fn include_cargo(&self) -> bool {
243        self.include_cargo
244    }
245
246    /// Build SBOM from a directory.
247    pub fn build_from_path(&mut self, path: &Path) -> Result<(), SbomError> {
248        use super::extractor::DependencyExtractor;
249
250        let extractor = DependencyExtractor::new();
251
252        // Extract MCP servers
253        for component in extractor.extract_mcp_servers(path)? {
254            self.add_component(component);
255        }
256
257        // Extract skills
258        for component in extractor.extract_skills(path)? {
259            self.add_component(component);
260        }
261
262        // Extract npm dependencies if enabled
263        if self.include_npm {
264            for component in extractor.extract_npm_dependencies(path)? {
265                self.add_component(component);
266            }
267        }
268
269        // Extract Cargo dependencies if enabled
270        if self.include_cargo {
271            for component in extractor.extract_cargo_dependencies(path)? {
272                self.add_component(component);
273            }
274        }
275
276        Ok(())
277    }
278
279    /// Generate SBOM output as JSON string.
280    pub fn to_json(&self) -> Result<String, SbomError> {
281        match self.format {
282            SbomFormat::CycloneDx => {
283                let bom = super::cyclonedx::CycloneDxBom::from_components(&self.components);
284                serde_json::to_string_pretty(&bom)
285                    .map_err(|e| SbomError::Serialization(e.to_string()))
286            }
287            SbomFormat::Spdx => {
288                let doc = super::spdx::SpdxDocument::from_components(&self.components);
289                serde_json::to_string_pretty(&doc)
290                    .map_err(|e| SbomError::Serialization(e.to_string()))
291            }
292        }
293    }
294}
295
296impl Default for SbomBuilder {
297    fn default() -> Self {
298        Self::new()
299    }
300}
301
302/// Error type for SBOM operations.
303#[derive(Debug, thiserror::Error)]
304pub enum SbomError {
305    #[error("IO error: {0}")]
306    Io(#[from] std::io::Error),
307
308    #[error("JSON parse error: {0}")]
309    JsonParse(String),
310
311    #[error("YAML parse error: {0}")]
312    YamlParse(String),
313
314    #[error("TOML parse error: {0}")]
315    TomlParse(String),
316
317    #[error("Serialization error: {0}")]
318    Serialization(String),
319
320    #[error("Unsupported format: {0}")]
321    UnsupportedFormat(String),
322}
323
324#[cfg(test)]
325mod tests {
326    use super::*;
327    use std::fs;
328    use tempfile::TempDir;
329
330    #[test]
331    fn test_component_new() {
332        let comp = Component::new("test-package", ComponentType::Library);
333        assert_eq!(comp.name, "test-package");
334        assert_eq!(comp.component_type, ComponentType::Library);
335        assert!(comp.version.is_none());
336    }
337
338    #[test]
339    fn test_component_builder() {
340        let comp = Component::new("my-mcp-server", ComponentType::McpServer)
341            .with_version("1.0.0")
342            .with_description("A test MCP server")
343            .with_author("Test Author");
344
345        assert_eq!(comp.name, "my-mcp-server");
346        assert_eq!(comp.version, Some("1.0.0".to_string()));
347        assert_eq!(comp.description, Some("A test MCP server".to_string()));
348        assert_eq!(comp.author, Some("Test Author".to_string()));
349    }
350
351    #[test]
352    fn test_npm_purl() {
353        let purl = Component::npm_purl("express", Some("4.18.0"));
354        assert_eq!(purl, "pkg:npm/express@4.18.0");
355
356        let purl_no_version = Component::npm_purl("express", None);
357        assert_eq!(purl_no_version, "pkg:npm/express");
358    }
359
360    #[test]
361    fn test_github_purl() {
362        let purl = Component::github_purl("owner", "repo", Some("v1.0.0"));
363        assert_eq!(purl, "pkg:github/owner/repo@v1.0.0");
364    }
365
366    #[test]
367    fn test_sbom_builder() {
368        let mut builder = SbomBuilder::new()
369            .with_format(SbomFormat::CycloneDx)
370            .with_npm(true);
371
372        builder.add_component(Component::new("test", ComponentType::Library));
373
374        assert_eq!(builder.components().len(), 1);
375        assert!(builder.include_npm());
376        assert!(!builder.include_cargo());
377    }
378
379    #[test]
380    fn test_sbom_format_parse() {
381        assert_eq!(
382            "cyclonedx".parse::<SbomFormat>().unwrap(),
383            SbomFormat::CycloneDx
384        );
385        assert_eq!("cdx".parse::<SbomFormat>().unwrap(), SbomFormat::CycloneDx);
386        assert_eq!("spdx".parse::<SbomFormat>().unwrap(), SbomFormat::Spdx);
387        assert!("unknown".parse::<SbomFormat>().is_err());
388    }
389
390    #[test]
391    fn test_component_type_to_cyclonedx() {
392        assert_eq!(
393            ComponentType::Application.to_cyclonedx_type(),
394            "application"
395        );
396        assert_eq!(ComponentType::Library.to_cyclonedx_type(), "library");
397        assert_eq!(ComponentType::McpServer.to_cyclonedx_type(), "service");
398        assert_eq!(ComponentType::Skill.to_cyclonedx_type(), "application");
399    }
400
401    #[test]
402    fn test_component_type_to_cyclonedx_all() {
403        assert_eq!(ComponentType::Service.to_cyclonedx_type(), "service");
404        assert_eq!(ComponentType::Plugin.to_cyclonedx_type(), "library");
405        assert_eq!(ComponentType::Subagent.to_cyclonedx_type(), "application");
406    }
407
408    #[test]
409    fn test_component_with_license() {
410        let comp = Component::new("test", ComponentType::Library).with_license("MIT");
411
412        assert_eq!(comp.license, Some("MIT".to_string()));
413    }
414
415    #[test]
416    fn test_component_with_repository() {
417        let comp = Component::new("test", ComponentType::Library)
418            .with_repository("https://github.com/test/test");
419
420        assert_eq!(
421            comp.repository,
422            Some("https://github.com/test/test".to_string())
423        );
424    }
425
426    #[test]
427    fn test_component_with_hash() {
428        let comp = Component::new("test", ComponentType::Library).with_hash("abc123def456");
429
430        assert_eq!(comp.hash_sha256, Some("abc123def456".to_string()));
431    }
432
433    #[test]
434    fn test_github_purl_without_version() {
435        let purl = Component::github_purl("owner", "repo", None);
436        assert_eq!(purl, "pkg:github/owner/repo");
437    }
438
439    #[test]
440    fn test_sbom_builder_with_cargo() {
441        let builder = SbomBuilder::new().with_cargo(true);
442
443        assert!(builder.include_cargo());
444        assert!(!builder.include_npm());
445    }
446
447    #[test]
448    fn test_sbom_builder_format() {
449        let builder = SbomBuilder::new().with_format(SbomFormat::Spdx);
450
451        assert_eq!(builder.format(), SbomFormat::Spdx);
452    }
453
454    #[test]
455    fn test_sbom_builder_default() {
456        let builder = SbomBuilder::default();
457
458        assert_eq!(builder.format(), SbomFormat::CycloneDx);
459        assert!(!builder.include_npm());
460        assert!(!builder.include_cargo());
461        assert!(builder.components().is_empty());
462    }
463
464    #[test]
465    fn test_sbom_format_default() {
466        let format = SbomFormat::default();
467        assert_eq!(format, SbomFormat::CycloneDx);
468    }
469
470    #[test]
471    fn test_sbom_format_debug() {
472        let format = SbomFormat::CycloneDx;
473        assert_eq!(format!("{:?}", format), "CycloneDx");
474    }
475
476    #[test]
477    fn test_sbom_builder_to_json() {
478        let mut builder = SbomBuilder::new();
479        builder.add_component(Component::new("test", ComponentType::Library).with_version("1.0.0"));
480
481        let json = builder.to_json().unwrap();
482        assert!(json.contains("CycloneDX"));
483        assert!(json.contains("test"));
484    }
485
486    #[test]
487    fn test_sbom_builder_to_json_spdx() {
488        let mut builder = SbomBuilder::new().with_format(SbomFormat::Spdx);
489        builder.add_component(Component::new("test", ComponentType::Library).with_version("1.0.0"));
490
491        let json = builder.to_json().unwrap();
492        assert!(json.contains("SPDX-2.3"));
493        assert!(json.contains("test"));
494    }
495
496    #[test]
497    fn test_sbom_error_display() {
498        let err1 = SbomError::JsonParse("test error".to_string());
499        assert!(err1.to_string().contains("JSON parse error"));
500
501        let err2 = SbomError::YamlParse("test error".to_string());
502        assert!(err2.to_string().contains("YAML parse error"));
503
504        let err3 = SbomError::TomlParse("test error".to_string());
505        assert!(err3.to_string().contains("TOML parse error"));
506
507        let err4 = SbomError::Serialization("test error".to_string());
508        assert!(err4.to_string().contains("Serialization error"));
509
510        let err5 = SbomError::UnsupportedFormat("test".to_string());
511        assert!(err5.to_string().contains("Unsupported format"));
512    }
513
514    #[test]
515    fn test_sbom_builder_build_from_path() {
516        let temp_dir = TempDir::new().unwrap();
517        fs::write(
518            temp_dir.path().join("mcp.json"),
519            r#"{"mcpServers": {"test-server": {"command": "npx"}}}"#,
520        )
521        .unwrap();
522
523        let mut builder = SbomBuilder::new();
524        let result = builder.build_from_path(temp_dir.path());
525
526        assert!(result.is_ok());
527        assert_eq!(builder.components().len(), 1);
528    }
529
530    #[test]
531    fn test_sbom_builder_build_from_path_with_npm() {
532        let temp_dir = TempDir::new().unwrap();
533        fs::write(
534            temp_dir.path().join("package.json"),
535            r#"{"dependencies": {"express": "^4.18.0"}}"#,
536        )
537        .unwrap();
538
539        let mut builder = SbomBuilder::new().with_npm(true);
540        let result = builder.build_from_path(temp_dir.path());
541
542        assert!(result.is_ok());
543        assert!(!builder.components().is_empty());
544    }
545
546    #[test]
547    fn test_sbom_builder_build_from_path_with_cargo() {
548        let temp_dir = TempDir::new().unwrap();
549        fs::write(
550            temp_dir.path().join("Cargo.toml"),
551            r#"[dependencies]
552serde = "1.0"
553"#,
554        )
555        .unwrap();
556
557        let mut builder = SbomBuilder::new().with_cargo(true);
558        let result = builder.build_from_path(temp_dir.path());
559
560        assert!(result.is_ok());
561        assert!(!builder.components().is_empty());
562    }
563
564    #[test]
565    fn test_component_serialization() {
566        let comp = Component::new("test", ComponentType::Library)
567            .with_version("1.0.0")
568            .with_purl("pkg:npm/test@1.0.0");
569
570        let json = serde_json::to_string(&comp).unwrap();
571        assert!(json.contains("test"));
572        assert!(json.contains("1.0.0"));
573        assert!(json.contains("pkg:npm/test@1.0.0"));
574    }
575
576    #[test]
577    fn test_component_deserialization() {
578        let json = r#"{"name":"test","type":"library","version":"1.0.0"}"#;
579        let comp: Component = serde_json::from_str(json).unwrap();
580
581        assert_eq!(comp.name, "test");
582        assert_eq!(comp.version, Some("1.0.0".to_string()));
583        assert_eq!(comp.component_type, ComponentType::Library);
584    }
585}