1use serde::{Deserialize, Serialize};
4use std::path::Path;
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
8pub enum SbomFormat {
9 #[default]
11 CycloneDx,
12 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "lowercase")]
31pub enum ComponentType {
32 Application,
34 Library,
36 Service,
38 McpServer,
40 Skill,
42 Plugin,
44 Subagent,
46}
47
48impl ComponentType {
49 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#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct Component {
66 pub name: String,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub version: Option<String>,
72
73 #[serde(rename = "type")]
75 pub component_type: ComponentType,
76
77 #[serde(skip_serializing_if = "Option::is_none")]
79 pub purl: Option<String>,
80
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub description: Option<String>,
84
85 #[serde(skip_serializing_if = "Option::is_none")]
87 pub author: Option<String>,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub license: Option<String>,
92
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub repository: Option<String>,
96
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub hash_sha256: Option<String>,
100}
101
102impl Component {
103 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 pub fn with_version(mut self, version: impl Into<String>) -> Self {
120 self.version = Some(version.into());
121 self
122 }
123
124 pub fn with_purl(mut self, purl: impl Into<String>) -> Self {
126 self.purl = Some(purl.into());
127 self
128 }
129
130 pub fn with_description(mut self, description: impl Into<String>) -> Self {
132 self.description = Some(description.into());
133 self
134 }
135
136 pub fn with_author(mut self, author: impl Into<String>) -> Self {
138 self.author = Some(author.into());
139 self
140 }
141
142 pub fn with_license(mut self, license: impl Into<String>) -> Self {
144 self.license = Some(license.into());
145 self
146 }
147
148 pub fn with_repository(mut self, repo: impl Into<String>) -> Self {
150 self.repository = Some(repo.into());
151 self
152 }
153
154 pub fn with_hash(mut self, hash: impl Into<String>) -> Self {
156 self.hash_sha256 = Some(hash.into());
157 self
158 }
159
160 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 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
177pub struct SbomBuilder {
179 components: Vec<Component>,
181
182 format: SbomFormat,
184
185 include_npm: bool,
187
188 include_cargo: bool,
190}
191
192impl SbomBuilder {
193 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 pub fn with_format(mut self, format: SbomFormat) -> Self {
205 self.format = format;
206 self
207 }
208
209 pub fn with_npm(mut self, include: bool) -> Self {
211 self.include_npm = include;
212 self
213 }
214
215 pub fn with_cargo(mut self, include: bool) -> Self {
217 self.include_cargo = include;
218 self
219 }
220
221 pub fn add_component(&mut self, component: Component) {
223 self.components.push(component);
224 }
225
226 pub fn components(&self) -> &[Component] {
228 &self.components
229 }
230
231 pub fn format(&self) -> SbomFormat {
233 self.format
234 }
235
236 pub fn include_npm(&self) -> bool {
238 self.include_npm
239 }
240
241 pub fn include_cargo(&self) -> bool {
243 self.include_cargo
244 }
245
246 pub fn build_from_path(&mut self, path: &Path) -> Result<(), SbomError> {
248 use super::extractor::DependencyExtractor;
249
250 let extractor = DependencyExtractor::new();
251
252 for component in extractor.extract_mcp_servers(path)? {
254 self.add_component(component);
255 }
256
257 for component in extractor.extract_skills(path)? {
259 self.add_component(component);
260 }
261
262 if self.include_npm {
264 for component in extractor.extract_npm_dependencies(path)? {
265 self.add_component(component);
266 }
267 }
268
269 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 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#[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}