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 => 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#[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}