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 => Err(SbomError::UnsupportedFormat("SPDX".to_string())),
288        }
289    }
290}
291
292impl Default for SbomBuilder {
293    fn default() -> Self {
294        Self::new()
295    }
296}
297
298/// Error type for SBOM operations.
299#[derive(Debug, thiserror::Error)]
300pub enum SbomError {
301    #[error("IO error: {0}")]
302    Io(#[from] std::io::Error),
303
304    #[error("JSON parse error: {0}")]
305    JsonParse(String),
306
307    #[error("YAML parse error: {0}")]
308    YamlParse(String),
309
310    #[error("TOML parse error: {0}")]
311    TomlParse(String),
312
313    #[error("Serialization error: {0}")]
314    Serialization(String),
315
316    #[error("Unsupported format: {0}")]
317    UnsupportedFormat(String),
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323    use std::fs;
324    use tempfile::TempDir;
325
326    #[test]
327    fn test_component_new() {
328        let comp = Component::new("test-package", ComponentType::Library);
329        assert_eq!(comp.name, "test-package");
330        assert_eq!(comp.component_type, ComponentType::Library);
331        assert!(comp.version.is_none());
332    }
333
334    #[test]
335    fn test_component_builder() {
336        let comp = Component::new("my-mcp-server", ComponentType::McpServer)
337            .with_version("1.0.0")
338            .with_description("A test MCP server")
339            .with_author("Test Author");
340
341        assert_eq!(comp.name, "my-mcp-server");
342        assert_eq!(comp.version, Some("1.0.0".to_string()));
343        assert_eq!(comp.description, Some("A test MCP server".to_string()));
344        assert_eq!(comp.author, Some("Test Author".to_string()));
345    }
346
347    #[test]
348    fn test_npm_purl() {
349        let purl = Component::npm_purl("express", Some("4.18.0"));
350        assert_eq!(purl, "pkg:npm/express@4.18.0");
351
352        let purl_no_version = Component::npm_purl("express", None);
353        assert_eq!(purl_no_version, "pkg:npm/express");
354    }
355
356    #[test]
357    fn test_github_purl() {
358        let purl = Component::github_purl("owner", "repo", Some("v1.0.0"));
359        assert_eq!(purl, "pkg:github/owner/repo@v1.0.0");
360    }
361
362    #[test]
363    fn test_sbom_builder() {
364        let mut builder = SbomBuilder::new()
365            .with_format(SbomFormat::CycloneDx)
366            .with_npm(true);
367
368        builder.add_component(Component::new("test", ComponentType::Library));
369
370        assert_eq!(builder.components().len(), 1);
371        assert!(builder.include_npm());
372        assert!(!builder.include_cargo());
373    }
374
375    #[test]
376    fn test_sbom_format_parse() {
377        assert_eq!(
378            "cyclonedx".parse::<SbomFormat>().unwrap(),
379            SbomFormat::CycloneDx
380        );
381        assert_eq!("cdx".parse::<SbomFormat>().unwrap(), SbomFormat::CycloneDx);
382        assert_eq!("spdx".parse::<SbomFormat>().unwrap(), SbomFormat::Spdx);
383        assert!("unknown".parse::<SbomFormat>().is_err());
384    }
385
386    #[test]
387    fn test_component_type_to_cyclonedx() {
388        assert_eq!(
389            ComponentType::Application.to_cyclonedx_type(),
390            "application"
391        );
392        assert_eq!(ComponentType::Library.to_cyclonedx_type(), "library");
393        assert_eq!(ComponentType::McpServer.to_cyclonedx_type(), "service");
394        assert_eq!(ComponentType::Skill.to_cyclonedx_type(), "application");
395    }
396
397    #[test]
398    fn test_component_type_to_cyclonedx_all() {
399        assert_eq!(ComponentType::Service.to_cyclonedx_type(), "service");
400        assert_eq!(ComponentType::Plugin.to_cyclonedx_type(), "library");
401        assert_eq!(ComponentType::Subagent.to_cyclonedx_type(), "application");
402    }
403
404    #[test]
405    fn test_component_with_license() {
406        let comp = Component::new("test", ComponentType::Library).with_license("MIT");
407
408        assert_eq!(comp.license, Some("MIT".to_string()));
409    }
410
411    #[test]
412    fn test_component_with_repository() {
413        let comp = Component::new("test", ComponentType::Library)
414            .with_repository("https://github.com/test/test");
415
416        assert_eq!(
417            comp.repository,
418            Some("https://github.com/test/test".to_string())
419        );
420    }
421
422    #[test]
423    fn test_component_with_hash() {
424        let comp = Component::new("test", ComponentType::Library).with_hash("abc123def456");
425
426        assert_eq!(comp.hash_sha256, Some("abc123def456".to_string()));
427    }
428
429    #[test]
430    fn test_github_purl_without_version() {
431        let purl = Component::github_purl("owner", "repo", None);
432        assert_eq!(purl, "pkg:github/owner/repo");
433    }
434
435    #[test]
436    fn test_sbom_builder_with_cargo() {
437        let builder = SbomBuilder::new().with_cargo(true);
438
439        assert!(builder.include_cargo());
440        assert!(!builder.include_npm());
441    }
442
443    #[test]
444    fn test_sbom_builder_format() {
445        let builder = SbomBuilder::new().with_format(SbomFormat::Spdx);
446
447        assert_eq!(builder.format(), SbomFormat::Spdx);
448    }
449
450    #[test]
451    fn test_sbom_builder_default() {
452        let builder = SbomBuilder::default();
453
454        assert_eq!(builder.format(), SbomFormat::CycloneDx);
455        assert!(!builder.include_npm());
456        assert!(!builder.include_cargo());
457        assert!(builder.components().is_empty());
458    }
459
460    #[test]
461    fn test_sbom_format_default() {
462        let format = SbomFormat::default();
463        assert_eq!(format, SbomFormat::CycloneDx);
464    }
465
466    #[test]
467    fn test_sbom_format_debug() {
468        let format = SbomFormat::CycloneDx;
469        assert_eq!(format!("{:?}", format), "CycloneDx");
470    }
471
472    #[test]
473    fn test_sbom_builder_to_json() {
474        let mut builder = SbomBuilder::new();
475        builder.add_component(Component::new("test", ComponentType::Library).with_version("1.0.0"));
476
477        let json = builder.to_json().unwrap();
478        assert!(json.contains("CycloneDX"));
479        assert!(json.contains("test"));
480    }
481
482    #[test]
483    fn test_sbom_builder_to_json_spdx_error() {
484        let builder = SbomBuilder::new().with_format(SbomFormat::Spdx);
485
486        let result = builder.to_json();
487        assert!(result.is_err());
488        assert!(result.unwrap_err().to_string().contains("SPDX"));
489    }
490
491    #[test]
492    fn test_sbom_error_display() {
493        let err1 = SbomError::JsonParse("test error".to_string());
494        assert!(err1.to_string().contains("JSON parse error"));
495
496        let err2 = SbomError::YamlParse("test error".to_string());
497        assert!(err2.to_string().contains("YAML parse error"));
498
499        let err3 = SbomError::TomlParse("test error".to_string());
500        assert!(err3.to_string().contains("TOML parse error"));
501
502        let err4 = SbomError::Serialization("test error".to_string());
503        assert!(err4.to_string().contains("Serialization error"));
504
505        let err5 = SbomError::UnsupportedFormat("test".to_string());
506        assert!(err5.to_string().contains("Unsupported format"));
507    }
508
509    #[test]
510    fn test_sbom_builder_build_from_path() {
511        let temp_dir = TempDir::new().unwrap();
512        fs::write(
513            temp_dir.path().join("mcp.json"),
514            r#"{"mcpServers": {"test-server": {"command": "npx"}}}"#,
515        )
516        .unwrap();
517
518        let mut builder = SbomBuilder::new();
519        let result = builder.build_from_path(temp_dir.path());
520
521        assert!(result.is_ok());
522        assert_eq!(builder.components().len(), 1);
523    }
524
525    #[test]
526    fn test_sbom_builder_build_from_path_with_npm() {
527        let temp_dir = TempDir::new().unwrap();
528        fs::write(
529            temp_dir.path().join("package.json"),
530            r#"{"dependencies": {"express": "^4.18.0"}}"#,
531        )
532        .unwrap();
533
534        let mut builder = SbomBuilder::new().with_npm(true);
535        let result = builder.build_from_path(temp_dir.path());
536
537        assert!(result.is_ok());
538        assert!(!builder.components().is_empty());
539    }
540
541    #[test]
542    fn test_sbom_builder_build_from_path_with_cargo() {
543        let temp_dir = TempDir::new().unwrap();
544        fs::write(
545            temp_dir.path().join("Cargo.toml"),
546            r#"[dependencies]
547serde = "1.0"
548"#,
549        )
550        .unwrap();
551
552        let mut builder = SbomBuilder::new().with_cargo(true);
553        let result = builder.build_from_path(temp_dir.path());
554
555        assert!(result.is_ok());
556        assert!(!builder.components().is_empty());
557    }
558
559    #[test]
560    fn test_component_serialization() {
561        let comp = Component::new("test", ComponentType::Library)
562            .with_version("1.0.0")
563            .with_purl("pkg:npm/test@1.0.0");
564
565        let json = serde_json::to_string(&comp).unwrap();
566        assert!(json.contains("test"));
567        assert!(json.contains("1.0.0"));
568        assert!(json.contains("pkg:npm/test@1.0.0"));
569    }
570
571    #[test]
572    fn test_component_deserialization() {
573        let json = r#"{"name":"test","type":"library","version":"1.0.0"}"#;
574        let comp: Component = serde_json::from_str(json).unwrap();
575
576        assert_eq!(comp.name, "test");
577        assert_eq!(comp.version, Some("1.0.0".to_string()));
578        assert_eq!(comp.component_type, ComponentType::Library);
579    }
580}