1use serde::Serialize;
18use std::collections::BTreeMap;
19use std::path::Path;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SbomFormat {
24 CycloneDX,
25 SPDX,
26}
27
28#[derive(Debug, Clone)]
30pub struct SbomComponent {
31 pub name: String,
33 pub version: String,
35 pub component_type: String,
37 pub source_url: String,
39 pub license: String,
41 pub supplier: String,
43 pub description: String,
45 pub path: String,
47 pub hashes: BTreeMap<String, String>,
49}
50
51impl Default for SbomComponent {
52 fn default() -> Self {
53 Self {
54 name: String::new(),
55 version: "0.0.0".into(),
56 component_type: "library".into(),
57 source_url: String::new(),
58 license: "NOASSERTION".into(),
59 supplier: "unknown".into(),
60 description: String::new(),
61 path: String::new(),
62 hashes: BTreeMap::new(),
63 }
64 }
65}
66
67#[derive(Debug, Clone)]
69pub struct SbomDocument {
70 pub timestamp: String,
72 pub tool_name: String,
74 pub tool_version: String,
76 pub project_name: String,
78 pub project_version: String,
80 pub components: Vec<SbomComponent>,
82}
83
84#[derive(Serialize)]
88struct CycloneDoc {
89 #[serde(rename = "bomFormat")]
90 bom_format: String,
91 #[serde(rename = "specVersion")]
92 spec_version: String,
93 #[serde(rename = "serialNumber")]
94 serial_number: String,
95 version: u32,
96 metadata: CycloneMetadata,
97 components: Vec<CycloneComponent>,
98}
99
100#[derive(Serialize)]
101struct CycloneMetadata {
102 timestamp: String,
103 tools: Vec<CycloneTool>,
104 component: CycloneToolComponent,
105}
106
107#[derive(Serialize)]
108struct CycloneTool {
109 vendor: String,
110 name: String,
111 version: String,
112}
113
114#[derive(Serialize)]
115struct CycloneToolComponent {
116 #[serde(rename = "type")]
117 comp_type: String,
118 name: String,
119 version: String,
120}
121
122#[derive(Serialize)]
123struct CycloneComponent {
124 #[serde(rename = "type")]
125 comp_type: String,
126 name: String,
127 version: String,
128 #[serde(skip_serializing_if = "Option::is_none")]
129 description: Option<String>,
130 #[serde(skip_serializing_if = "Option::is_none")]
131 licenses: Option<Vec<CycloneLicense>>,
132 #[serde(skip_serializing_if = "Option::is_none")]
133 supplier: Option<CycloneSupplier>,
134 #[serde(skip_serializing_if = "Option::is_none")]
135 hashes: Option<Vec<CycloneHash>>,
136 #[serde(skip_serializing_if = "Option::is_none")]
137 external_references: Option<Vec<CycloneRef>>,
138}
139
140#[derive(Serialize)]
141struct CycloneLicense {
142 license: CycloneLicenseId,
143}
144
145#[derive(Serialize)]
146struct CycloneLicenseId {
147 id: String,
148}
149
150#[derive(Serialize)]
151struct CycloneSupplier {
152 name: String,
153}
154
155#[derive(Serialize)]
156struct CycloneHash {
157 alg: String,
158 content: String,
159}
160
161#[derive(Serialize)]
162struct CycloneRef {
163 #[serde(rename = "type")]
164 ref_type: String,
165 url: String,
166}
167
168#[derive(Serialize)]
172struct SpdxDoc {
173 #[serde(rename = "spdxVersion")]
174 spdx_version: String,
175 #[serde(rename = "dataLicense")]
176 data_license: String,
177 #[serde(rename = "SPDXID")]
178 spdx_id: String,
179 name: String,
180 #[serde(rename = "documentNamespace")]
181 document_namespace: String,
182 #[serde(rename = "creationInfo")]
183 creation_info: SpdxCreationInfo,
184 packages: Vec<SpdxPackage>,
185 #[serde(rename = "documentDescribes")]
186 document_describes: Vec<String>,
187}
188
189#[derive(Serialize)]
190struct SpdxCreationInfo {
191 creators: Vec<String>,
192 created: String,
193}
194
195#[derive(Serialize)]
196struct SpdxPackage {
197 #[serde(rename = "SPDXID")]
198 spdx_id: String,
199 name: String,
200 #[serde(rename = "versionInfo")]
201 version_info: String,
202 #[serde(rename = "supplier")]
203 supplier: String,
204 #[serde(rename = "downloadLocation")]
205 download_location: String,
206 #[serde(rename = "licenseConcluded")]
207 license_concluded: String,
208 #[serde(rename = "licenseDeclared")]
209 license_declared: String,
210 #[serde(rename = "copyrightText")]
211 copyright_text: String,
212 #[serde(rename = "description")]
213 description: String,
214 #[serde(rename = "checksums", skip_serializing_if = "Option::is_none")]
215 checksums: Option<Vec<SpdxChecksum>>,
216 #[serde(rename = "externalRefs", skip_serializing_if = "Option::is_none")]
217 external_refs: Option<Vec<SpdxExternalRef>>,
218}
219
220#[derive(Serialize)]
221struct SpdxChecksum {
222 algorithm: String,
223 #[serde(rename = "checksumValue")]
224 checksum_value: String,
225}
226
227#[derive(Serialize)]
228struct SpdxExternalRef {
229 #[serde(rename = "referenceCategory")]
230 reference_category: String,
231 #[serde(rename = "referenceType")]
232 reference_type: String,
233 #[serde(rename = "referenceLocator")]
234 reference_locator: String,
235}
236
237pub fn discover_dependencies(project_root: &Path) -> Vec<SbomComponent> {
241 let mut components: Vec<SbomComponent> = Vec::new();
242 let mut seen = std::collections::HashSet::new();
243
244 let remappings_path = project_root.join("remappings.txt");
246 if let Ok(content) = std::fs::read_to_string(&remappings_path) {
247 for line in content.lines() {
248 let line = line.trim();
249 if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
250 continue;
251 }
252 if let Some(eq_pos) = line.find('=') {
254 let (namespace, path) = line.split_at(eq_pos);
255 let path = path[1..].trim().to_string(); let name = namespace.trim().to_string();
257 if !seen.contains(&name) {
258 seen.insert(name.clone());
259 let mut component = SbomComponent {
260 name,
261 path: path.clone(),
262 ..Default::default()
263 };
264 resolve_lib_version(&path, &mut component);
266 components.push(component);
267 }
268 }
269 }
270 }
271
272 let foundry_path = project_root.join("foundry.toml");
274 if let Ok(content) = std::fs::read_to_string(&foundry_path) {
275 for line in content.lines() {
276 let line = line.trim();
277 if line.starts_with("remappings") || line.starts_with('#') {
278 continue;
279 }
280 if let Some(eq_pos) = line.find('=') {
282 let val = line[eq_pos + 1..]
283 .trim()
284 .trim_matches('"')
285 .trim_matches('\'');
286 if let Some(eq2) = val.find('=') {
287 let name = val[..eq2].trim().to_string();
288 let path = val[eq2 + 1..].trim().to_string();
289 let path = path.trim_matches('"').trim_matches('\'').to_string();
291 if !name.is_empty() && !path.is_empty() && !seen.contains(&name) {
292 seen.insert(name.clone());
293 let mut component = SbomComponent {
294 name,
295 path: path.clone(),
296 ..Default::default()
297 };
298 resolve_lib_version(&path, &mut component);
299 components.push(component);
300 }
301 }
302 }
303 }
304 }
305
306 let lib_dir = project_root.join("lib");
308 if let Ok(entries) = std::fs::read_dir(&lib_dir) {
309 for entry in entries.flatten() {
310 if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
311 let name = entry.file_name().to_string_lossy().to_string();
312 if !seen.contains(&name) && !name.starts_with('.') {
313 seen.insert(name.clone());
314 let lib_path = format!("lib/{}", name);
315 let mut component = SbomComponent {
316 name,
317 path: lib_path.clone(),
318 component_type: "library".into(),
319 ..Default::default()
320 };
321 resolve_lib_version(&lib_path, &mut component);
322 components.push(component);
323 }
324 }
325 }
326 }
327
328 if let Ok(content) = std::fs::read_to_string(&foundry_path) {
330 for line in content.lines() {
331 let line = line.trim();
332 let stripped = line
333 .strip_prefix("solc")
334 .or_else(|| line.strip_prefix("solc_version"))
335 .or_else(|| line.strip_prefix("solc-version"));
336 if let Some(rest) = stripped {
337 let version = rest
338 .trim()
339 .trim_start_matches('=')
340 .trim()
341 .trim_matches('"')
342 .trim_matches('\'')
343 .to_string();
344 if !version.is_empty() {
345 let solc_name = format!("solc-{}", version);
346 if !seen.contains(&solc_name) {
347 seen.insert(solc_name);
348 components.push(SbomComponent {
349 name: "solc".into(),
350 version,
351 component_type: "compiler".into(),
352 source_url: "https://github.com/ethereum/solidity".into(),
353 license: "GPL-3.0-only".into(),
354 supplier: "Ethereum Foundation".into(),
355 description: "Solidity compiler".into(),
356 path: String::new(),
357 hashes: BTreeMap::new(),
358 });
359 }
360 }
361 }
362 }
363 }
364
365 if foundry_path.exists() {
367 let forge_name = "foundry-rs".to_string();
368 if !seen.contains(&forge_name) {
369 seen.insert(forge_name);
370 components.push(SbomComponent {
371 name: "foundry".into(),
372 version: "latest".into(),
373 component_type: "framework".into(),
374 source_url: "https://github.com/foundry-rs/foundry".into(),
375 license: "MIT OR Apache-2.0".into(),
376 supplier: "Foundry Contributors".into(),
377 description: "Foundry smart contract development framework".into(),
378 path: String::new(),
379 hashes: BTreeMap::new(),
380 });
381 }
382 }
383
384 components.sort_by(|a, b| a.name.cmp(&b.name));
385 components
386}
387
388fn resolve_lib_version(lib_path: &str, component: &mut SbomComponent) {
390 let path = Path::new(lib_path);
391
392 let pkg_json = path.join("package.json");
394 if let Ok(content) = std::fs::read_to_string(&pkg_json) {
395 if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
396 if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
397 component.version = ver.to_string();
398 }
399 if let Some(desc) = pkg.get("description").and_then(|d| d.as_str()) {
400 component.description = desc.to_string();
401 }
402 if let Some(lic) = pkg.get("license").and_then(|l| l.as_str()) {
403 component.license = lic.to_string();
404 }
405 if let Some(repo) = pkg.get("repository").and_then(|r| r.as_str()) {
406 component.source_url = repo.to_string();
407 } else if let Some(repo) = pkg
408 .get("repository")
409 .and_then(|r| r.get("url"))
410 .and_then(|u| u.as_str())
411 {
412 component.source_url = repo.to_string();
413 }
414 if let Some(author) = pkg.get("author").and_then(|a| a.as_str()) {
415 component.supplier = author.to_string();
416 }
417 }
418 }
419
420 let git_head = path.join(".git").join("HEAD");
422 if let Ok(content) = std::fs::read_to_string(&git_head) {
423 if let Some(ref_info) = content.trim().strip_prefix("ref: ") {
424 let git_ref = format!("{}/{}", lib_path, ref_info);
425 component.source_url = git_ref;
426 }
427 }
428
429 let cargo_path = path.join("Cargo.toml");
431 if cargo_path.exists() && component.version == "0.0.0" {
432 if let Ok(content) = std::fs::read_to_string(&cargo_path) {
433 for line in content.lines() {
434 let line = line.trim();
435 if let Some(ver) = line.strip_prefix("version = ") {
436 let ver = ver.trim_matches('"').trim_matches('\'').to_string();
437 if !ver.is_empty() {
438 component.version = ver;
439 break;
440 }
441 }
442 }
443 }
444 }
445
446 let lib_foundry = path.join("foundry.toml");
448 if lib_foundry.exists() && component.version == "0.0.0" {
449 if let Ok(content) = std::fs::read_to_string(&lib_foundry) {
450 for line in content.lines() {
451 let line = line.trim();
452 if let Some(ver) = line.strip_prefix("version = ") {
453 let ver = ver.trim_matches('"').trim_matches('\'').to_string();
454 if !ver.is_empty() {
455 component.version = ver;
456 break;
457 }
458 }
459 }
460 }
461 }
462}
463
464pub fn generate_cyclonedx(doc: &SbomDocument) -> Result<String, serde_json::Error> {
468 let cyclonedx = CycloneDoc {
469 bom_format: "CycloneDX".into(),
470 spec_version: "1.6".into(),
471 serial_number: format!("urn:uuid:{}", uuid_v4()),
472 version: 1,
473 metadata: CycloneMetadata {
474 timestamp: doc.timestamp.clone(),
475 tools: vec![CycloneTool {
476 vendor: "Forge Guard".into(),
477 name: doc.tool_name.clone(),
478 version: doc.tool_version.clone(),
479 }],
480 component: CycloneToolComponent {
481 comp_type: "application".into(),
482 name: doc.project_name.clone(),
483 version: doc.project_version.clone(),
484 },
485 },
486 components: doc.components.iter().map(map_cyclone_component).collect(),
487 };
488
489 serde_json::to_string_pretty(&cyclonedx)
490}
491
492fn map_cyclone_component(c: &SbomComponent) -> CycloneComponent {
493 let licenses = if c.license != "NOASSERTION" {
494 Some(vec![CycloneLicense {
495 license: CycloneLicenseId {
496 id: c.license.clone(),
497 },
498 }])
499 } else {
500 None
501 };
502
503 let hashes = if !c.hashes.is_empty() {
504 Some(
505 c.hashes
506 .iter()
507 .map(|(alg, content)| CycloneHash {
508 alg: alg.to_uppercase(),
509 content: content.clone(),
510 })
511 .collect(),
512 )
513 } else {
514 None
515 };
516
517 let external_references = if !c.source_url.is_empty() && c.source_url != "unknown" {
518 Some(vec![CycloneRef {
519 ref_type: "vcs".into(),
520 url: c.source_url.clone(),
521 }])
522 } else {
523 None
524 };
525
526 CycloneComponent {
527 comp_type: c.component_type.clone(),
528 name: c.name.clone(),
529 version: c.version.clone(),
530 description: if c.description.is_empty() {
531 None
532 } else {
533 Some(c.description.clone())
534 },
535 licenses,
536 supplier: if c.supplier != "unknown" {
537 Some(CycloneSupplier {
538 name: c.supplier.clone(),
539 })
540 } else {
541 None
542 },
543 hashes,
544 external_references,
545 }
546}
547
548pub fn generate_spdx(doc: &SbomDocument) -> Result<String, serde_json::Error> {
550 let spdx = SpdxDoc {
551 spdx_version: "SPDX-2.3".into(),
552 data_license: "CC0-1.0".into(),
553 spdx_id: "SPDXRef-DOCUMENT".into(),
554 name: format!("{}-{}", doc.project_name, doc.project_version),
555 document_namespace: format!(
556 "https://github.com/codetibo/forge-guard/sbom/{}-{}",
557 doc.project_name.replace(' ', "-"),
558 uuid_v4()
559 ),
560 creation_info: SpdxCreationInfo {
561 creators: vec![
562 format!("Tool: forge-guard-{}", doc.tool_version),
563 "Organization: Forge Guard Contributors".into(),
564 ],
565 created: doc.timestamp.clone(),
566 },
567 packages: doc
568 .components
569 .iter()
570 .enumerate()
571 .map(|(i, c)| map_spdx_package(c, i))
572 .collect(),
573 document_describes: vec!["SPDXRef-DOCUMENT".into()],
574 };
575
576 serde_json::to_string_pretty(&spdx)
577}
578
579fn map_spdx_package(c: &SbomComponent, index: usize) -> SpdxPackage {
580 let checksums = if !c.hashes.is_empty() {
581 Some(
582 c.hashes
583 .iter()
584 .map(|(alg, value)| SpdxChecksum {
585 algorithm: alg.to_uppercase(),
586 checksum_value: value.clone(),
587 })
588 .collect(),
589 )
590 } else {
591 None
592 };
593
594 let external_refs = if !c.source_url.is_empty() && c.source_url != "unknown" {
595 Some(vec![SpdxExternalRef {
596 reference_category: "PACKAGE-MANAGER".into(),
597 reference_type: "purl".into(),
598 reference_locator: format!(
599 "pkg:github/{}",
600 c.source_url.trim_start_matches("https://github.com/")
601 ),
602 }])
603 } else {
604 None
605 };
606
607 SpdxPackage {
608 spdx_id: format!("SPDXRef-Package-{}", index + 1),
609 name: c.name.clone(),
610 version_info: c.version.clone(),
611 supplier: format!("Organization: {}", c.supplier),
612 download_location: if c.source_url.is_empty() {
613 "NOASSERTION".into()
614 } else {
615 c.source_url.clone()
616 },
617 license_concluded: c.license.clone(),
618 license_declared: c.license.clone(),
619 copyright_text: "NOASSERTION".into(),
620 description: c.description.clone(),
621 checksums,
622 external_refs,
623 }
624}
625
626fn uuid_v4() -> String {
630 use std::time::{SystemTime, UNIX_EPOCH};
631 let now = SystemTime::now()
632 .duration_since(UNIX_EPOCH)
633 .unwrap_or_default();
634 let nanos = now.as_nanos();
635 let rand1 = (nanos & 0xFFFF_FFFF_FFFF) as u64;
636 let rand2 = ((nanos >> 48) ^ 0xABCD_EF01_2345_6789) & 0xFFFF_FFFF_FFFF;
637 format!(
638 "{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
639 (rand1 >> 16) as u32,
640 (rand1 & 0xFFFF) as u16,
641 ((rand2 >> 12) & 0x0FFF) as u16,
642 ((rand2 >> 4) & 0x3FFF) as u16 | 0x8000,
643 (rand2 & 0xFFFF_FFFF_FFFF) as u64
644 )
645}
646
647pub fn build_sbom(project_root: &Path, project_name: &str, project_version: &str) -> SbomDocument {
649 let components = discover_dependencies(project_root);
650
651 SbomDocument {
652 timestamp: chrono::Utc::now().to_rfc3339(),
653 tool_name: "forge-guard".into(),
654 tool_version: env!("CARGO_PKG_VERSION").into(),
655 project_name: project_name.to_string(),
656 project_version: project_version.to_string(),
657 components,
658 }
659}
660
661pub fn generate_sbom(
663 project_root: &Path,
664 project_name: &str,
665 project_version: &str,
666 format: SbomFormat,
667) -> Result<String, serde_json::Error> {
668 let doc = build_sbom(project_root, project_name, project_version);
669 match format {
670 SbomFormat::CycloneDX => generate_cyclonedx(&doc),
671 SbomFormat::SPDX => generate_spdx(&doc),
672 }
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 fn test_doc() -> SbomDocument {
680 SbomDocument {
681 timestamp: "2026-07-28T00:00:00Z".into(),
682 tool_name: "forge-guard".into(),
683 tool_version: "0.1.8".into(),
684 project_name: "test-project".into(),
685 project_version: "1.0.0".into(),
686 components: vec![
687 SbomComponent {
688 name: "openzeppelin-contracts".into(),
689 version: "4.9.3".into(),
690 component_type: "library".into(),
691 source_url: "https://github.com/OpenZeppelin/openzeppelin-contracts".into(),
692 license: "MIT".into(),
693 supplier: "OpenZeppelin".into(),
694 description:
695 "OpenZeppelin Contracts is a library for secure smart contract development."
696 .into(),
697 path: "lib/openzeppelin-contracts".into(),
698 hashes: BTreeMap::new(),
699 },
700 SbomComponent {
701 name: "forge-std".into(),
702 version: "1.7.0".into(),
703 component_type: "library".into(),
704 source_url: "https://github.com/foundry-rs/forge-std".into(),
705 license: "MIT OR Apache-2.0".into(),
706 supplier: "Foundry Contributors".into(),
707 description: "Forge Standard Library".into(),
708 path: "lib/forge-std".into(),
709 hashes: BTreeMap::new(),
710 },
711 SbomComponent {
712 name: "solmate".into(),
713 version: "6.6.2".into(),
714 component_type: "library".into(),
715 source_url: "https://github.com/transmissions11/solmate".into(),
716 license: "AGPL-3.0-only".into(),
717 supplier: "transmissions11".into(),
718 description: "Solmate is a library of gas-optimized Solidity contracts.".into(),
719 path: "lib/solmate".into(),
720 hashes: BTreeMap::new(),
721 },
722 SbomComponent {
723 name: "solc".into(),
724 version: "0.8.23".into(),
725 component_type: "compiler".into(),
726 source_url: "https://github.com/ethereum/solidity".into(),
727 license: "GPL-3.0-only".into(),
728 supplier: "Ethereum Foundation".into(),
729 description: "Solidity compiler".into(),
730 path: String::new(),
731 hashes: BTreeMap::new(),
732 },
733 ],
734 }
735 }
736
737 #[test]
740 fn test_cyclonedx_generates_valid_json() {
741 let doc = test_doc();
742 let result = generate_cyclonedx(&doc);
743 assert!(result.is_ok());
744 let json = result.unwrap();
745 assert!(!json.is_empty());
746
747 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
749 assert_eq!(parsed["bomFormat"], "CycloneDX");
750 assert_eq!(parsed["specVersion"], "1.6");
751 }
752
753 #[test]
754 fn test_cyclonedx_metadata() {
755 let doc = test_doc();
756 let json = generate_cyclonedx(&doc).unwrap();
757 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
758
759 let metadata = &parsed["metadata"];
760 assert_eq!(metadata["timestamp"], "2026-07-28T00:00:00Z");
761 assert_eq!(metadata["component"]["name"], "test-project");
762 assert_eq!(metadata["component"]["version"], "1.0.0");
763 assert_eq!(metadata["tools"][0]["name"], "forge-guard");
764 }
765
766 #[test]
767 fn test_cyclonedx_components_count() {
768 let doc = test_doc();
769 let json = generate_cyclonedx(&doc).unwrap();
770 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
771
772 let components = parsed["components"].as_array().unwrap();
773 assert_eq!(components.len(), 4);
774 }
775
776 #[test]
777 fn test_cyclonedx_component_fields() {
778 let doc = test_doc();
779 let json = generate_cyclonedx(&doc).unwrap();
780 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
781
782 let comp = &parsed["components"][0];
783 assert_eq!(comp["name"], "openzeppelin-contracts");
784 assert_eq!(comp["version"], "4.9.3");
785 assert_eq!(comp["type"], "library");
786 assert!(comp["description"]
787 .as_str()
788 .unwrap()
789 .contains("secure smart contract"));
790 assert_eq!(comp["licenses"][0]["license"]["id"], "MIT");
791 }
792
793 #[test]
794 fn test_cyclonedx_without_optional_fields() {
795 let doc = SbomDocument {
796 timestamp: "2026-07-28T00:00:00Z".into(),
797 tool_name: "forge-guard".into(),
798 tool_version: "0.1.8".into(),
799 project_name: "minimal".into(),
800 project_version: "0.1.0".into(),
801 components: vec![SbomComponent {
802 name: "minimal-lib".into(),
803 version: "1.0.0".into(),
804 component_type: "library".into(),
805 source_url: String::new(),
806 license: "NOASSERTION".into(),
807 supplier: "unknown".into(),
808 description: String::new(),
809 path: "lib/minimal-lib".into(),
810 hashes: BTreeMap::new(),
811 }],
812 };
813 let json = generate_cyclonedx(&doc).unwrap();
814 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
815
816 let comp = &parsed["components"][0];
817 assert!(comp.get("description").is_none());
819 assert!(comp.get("licenses").is_none());
820 assert!(comp.get("supplier").is_none());
821 assert!(comp.get("hashes").is_none());
822 assert!(comp.get("external_references").is_none());
823 }
824
825 #[test]
826 fn test_cyclonedx_serial_number_format() {
827 let doc = test_doc();
828 let json = generate_cyclonedx(&doc).unwrap();
829 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
830
831 let serial = parsed["serialNumber"].as_str().unwrap();
832 assert!(serial.starts_with("urn:uuid:"));
833 assert_eq!(serial.len(), 45); }
835
836 #[test]
839 fn test_spdx_generates_valid_json() {
840 let doc = test_doc();
841 let result = generate_spdx(&doc);
842 assert!(result.is_ok());
843 let json = result.unwrap();
844 assert!(!json.is_empty());
845
846 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
847 assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
848 assert_eq!(parsed["dataLicense"], "CC0-1.0");
849 }
850
851 #[test]
852 fn test_spdx_creation_info() {
853 let doc = test_doc();
854 let json = generate_spdx(&doc).unwrap();
855 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
856
857 let info = &parsed["creationInfo"];
858 assert_eq!(info["created"], "2026-07-28T00:00:00Z");
859 let creators = info["creators"].as_array().unwrap();
860 assert!(creators
861 .iter()
862 .any(|c| c.as_str().unwrap().contains("forge-guard")));
863 }
864
865 #[test]
866 fn test_spdx_packages_count() {
867 let doc = test_doc();
868 let json = generate_spdx(&doc).unwrap();
869 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
870
871 let packages = parsed["packages"].as_array().unwrap();
872 assert_eq!(packages.len(), 4);
873 }
874
875 #[test]
876 fn test_spdx_package_fields() {
877 let doc = test_doc();
878 let json = generate_spdx(&doc).unwrap();
879 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
880
881 let pkg = &parsed["packages"][0];
882 assert_eq!(pkg["name"], "openzeppelin-contracts");
883 assert_eq!(pkg["versionInfo"], "4.9.3");
884 assert!(pkg["SPDXID"]
885 .as_str()
886 .unwrap()
887 .starts_with("SPDXRef-Package-"));
888 assert_eq!(pkg["licenseConcluded"], "MIT");
889 assert_eq!(pkg["licenseDeclared"], "MIT");
890 }
891
892 #[test]
893 fn test_spdx_supplier_format() {
894 let doc = test_doc();
895 let json = generate_spdx(&doc).unwrap();
896 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
897
898 let pkg = &parsed["packages"][0];
899 assert_eq!(pkg["supplier"], "Organization: OpenZeppelin");
900 }
901
902 #[test]
905 fn test_uuid_v4_format() {
906 let uuid = uuid_v4();
907 assert_eq!(uuid.len(), 36);
908 let parts: Vec<&str> = uuid.split('-').collect();
910 assert_eq!(parts.len(), 5);
911 assert_eq!(parts[0].len(), 8);
912 assert_eq!(parts[1].len(), 4);
913 assert_eq!(parts[2].len(), 4);
914 assert_eq!(parts[3].len(), 4);
915 assert_eq!(parts[4].len(), 12);
916 assert_eq!(&parts[2][..1], "4");
918 let variant_first = u16::from_str_radix(parts[3], 16).unwrap();
920 assert!(variant_first >= 0x8000);
921 }
922
923 #[test]
924 fn test_uuid_v4_uniqueness() {
925 let u1 = uuid_v4();
926 let u2 = uuid_v4();
927 assert_ne!(u1, u2);
928 }
929
930 #[test]
933 fn test_build_sbom_structure() {
934 let doc = build_sbom(Path::new("."), "test-project", "1.0.0");
935 assert_eq!(doc.project_name, "test-project");
936 assert_eq!(doc.project_version, "1.0.0");
937 assert_eq!(doc.tool_name, "forge-guard");
938 assert_eq!(doc.tool_version, env!("CARGO_PKG_VERSION"));
939 assert!(doc.components.len() <= 50);
941 }
942
943 #[test]
944 fn test_generate_sbom_cyclonedx() {
945 let result = generate_sbom(Path::new("."), "p", "0.1.0", SbomFormat::CycloneDX);
946 assert!(result.is_ok());
947 let json = result.unwrap();
948 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
949 assert_eq!(parsed["bomFormat"], "CycloneDX");
950 }
951
952 #[test]
953 fn test_generate_sbom_spdx() {
954 let result = generate_sbom(Path::new("."), "p", "0.1.0", SbomFormat::SPDX);
955 assert!(result.is_ok());
956 let json = result.unwrap();
957 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
958 assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
959 }
960
961 #[test]
964 fn test_empty_components_cyclonedx() {
965 let doc = SbomDocument {
966 timestamp: "2026-07-28T00:00:00Z".into(),
967 tool_name: "forge-guard".into(),
968 tool_version: "0.1.8".into(),
969 project_name: "empty".into(),
970 project_version: "0.0.0".into(),
971 components: vec![],
972 };
973 let json = generate_cyclonedx(&doc).unwrap();
974 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
975 assert!(parsed["components"].as_array().unwrap().is_empty());
976 }
977
978 #[test]
979 fn test_empty_components_spdx() {
980 let doc = SbomDocument {
981 timestamp: "2026-07-28T00:00:00Z".into(),
982 tool_name: "forge-guard".into(),
983 tool_version: "0.1.8".into(),
984 project_name: "empty".into(),
985 project_version: "0.0.0".into(),
986 components: vec![],
987 };
988 let json = generate_spdx(&doc).unwrap();
989 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
990 assert!(parsed["packages"].as_array().unwrap().is_empty());
991 }
992
993 #[test]
996 fn test_resolve_lib_version_unknown() {
997 let mut component = SbomComponent::default();
998 component.name = "test-lib".into();
999 resolve_lib_version("/nonexistent/path", &mut component);
1001 assert_eq!(component.version, "0.0.0");
1002 }
1003
1004 #[test]
1005 fn test_component_default_version_is_zero() {
1006 let c = SbomComponent::default();
1007 assert_eq!(c.version, "0.0.0");
1008 assert_eq!(c.license, "NOASSERTION");
1009 assert_eq!(c.supplier, "unknown");
1010 }
1011
1012 #[test]
1013 fn test_sbom_format_cyclonedx_variant() {
1014 match SbomFormat::CycloneDX {
1015 SbomFormat::CycloneDX => {} _ => panic!("Expected CycloneDX"),
1017 }
1018 }
1019
1020 #[test]
1021 fn test_sbom_format_spdx_variant() {
1022 match SbomFormat::SPDX {
1023 SbomFormat::SPDX => {} _ => panic!("Expected SPDX"),
1025 }
1026 }
1027
1028 #[test]
1029 fn test_cyclonedx_serial_number_unique_per_call() {
1030 let doc = test_doc();
1031 let json1 = generate_cyclonedx(&doc).unwrap();
1032 let json2 = generate_cyclonedx(&doc).unwrap();
1033 let p1: serde_json::Value = serde_json::from_str(&json1).unwrap();
1034 let p2: serde_json::Value = serde_json::from_str(&json2).unwrap();
1035 assert_ne!(p1["serialNumber"], p2["serialNumber"]);
1036 }
1037
1038 #[test]
1039 fn test_spdx_namespace_unique_per_call() {
1040 let doc = test_doc();
1041 let json1 = generate_spdx(&doc).unwrap();
1042 let json2 = generate_spdx(&doc).unwrap();
1043 let p1: serde_json::Value = serde_json::from_str(&json1).unwrap();
1044 let p2: serde_json::Value = serde_json::from_str(&json2).unwrap();
1045 assert_ne!(p1["documentNamespace"], p2["documentNamespace"]);
1046 }
1047
1048 #[test]
1051 fn test_cyclonedx_with_hashes() {
1052 let mut hashes: BTreeMap<String, String> = BTreeMap::new();
1053 hashes.insert("SHA-256".into(), "abc123def456".into());
1054 hashes.insert("SHA-512".into(), "fed654cba321".into());
1055
1056 let doc = SbomDocument {
1057 timestamp: "2026-07-28T00:00:00Z".into(),
1058 tool_name: "forge-guard".into(),
1059 tool_version: "0.1.9".into(),
1060 project_name: "hash-test".into(),
1061 project_version: "1.0.0".into(),
1062 components: vec![SbomComponent {
1063 name: "hashed-lib".into(),
1064 version: "2.0.0".into(),
1065 component_type: "library".into(),
1066 source_url: "https://github.com/example/hashed-lib".into(),
1067 license: "MIT".into(),
1068 supplier: "Example".into(),
1069 description: "A library with hashes".into(),
1070 path: "lib/hashed-lib".into(),
1071 hashes,
1072 }],
1073 };
1074 let json = generate_cyclonedx(&doc).unwrap();
1075 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1076 let comp = &parsed["components"][0];
1077
1078 let hashes_arr = comp["hashes"].as_array().unwrap();
1079 assert_eq!(hashes_arr.len(), 2);
1080
1081 let algos: Vec<&str> = hashes_arr
1082 .iter()
1083 .map(|h| h["alg"].as_str().unwrap())
1084 .collect();
1085 assert!(algos.contains(&"SHA-256"));
1086 assert!(algos.contains(&"SHA-512"));
1087
1088 let sha256 = hashes_arr.iter().find(|h| h["alg"] == "SHA-256").unwrap();
1089 assert_eq!(sha256["content"], "abc123def456");
1090 }
1091
1092 #[test]
1093 fn test_cyclonedx_external_references_with_source_url() {
1094 let doc = SbomDocument {
1095 timestamp: "2026-07-28T00:00:00Z".into(),
1096 tool_name: "forge-guard".into(),
1097 tool_version: "0.1.9".into(),
1098 project_name: "ref-test".into(),
1099 project_version: "1.0.0".into(),
1100 components: vec![SbomComponent {
1101 name: "referenced-lib".into(),
1102 version: "1.0.0".into(),
1103 component_type: "library".into(),
1104 source_url: "https://github.com/org/repo".into(),
1105 license: "MIT".into(),
1106 supplier: "Org".into(),
1107 description: String::new(),
1108 path: "lib/referenced-lib".into(),
1109 hashes: BTreeMap::new(),
1110 }],
1111 };
1112 let json = generate_cyclonedx(&doc).unwrap();
1113 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1114 let comp = &parsed["components"][0];
1115
1116 let refs = comp["external_references"].as_array().unwrap();
1117 assert_eq!(refs.len(), 1);
1118 assert_eq!(refs[0]["type"], "vcs");
1119 assert_eq!(refs[0]["url"], "https://github.com/org/repo");
1120 }
1121
1122 #[test]
1123 fn test_spdx_with_checksums() {
1124 let mut hashes: BTreeMap<String, String> = BTreeMap::new();
1125 hashes.insert("SHA256".into(), "abcd1234".into());
1126
1127 let doc = SbomDocument {
1128 timestamp: "2026-07-28T00:00:00Z".into(),
1129 tool_name: "forge-guard".into(),
1130 tool_version: "0.1.9".into(),
1131 project_name: "checksum-test".into(),
1132 project_version: "1.0.0".into(),
1133 components: vec![SbomComponent {
1134 name: "checksummed-lib".into(),
1135 version: "3.0.0".into(),
1136 component_type: "library".into(),
1137 source_url: "https://github.com/example/checksummed-lib".into(),
1138 license: "Apache-2.0".into(),
1139 supplier: "Example Inc".into(),
1140 description: String::new(),
1141 path: "lib/checksummed-lib".into(),
1142 hashes,
1143 }],
1144 };
1145 let json = generate_spdx(&doc).unwrap();
1146 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1147 let pkg = &parsed["packages"][0];
1148
1149 let checksums = pkg["checksums"].as_array().unwrap();
1150 assert_eq!(checksums.len(), 1);
1151 assert_eq!(checksums[0]["algorithm"], "SHA256");
1152 assert_eq!(checksums[0]["checksumValue"], "abcd1234");
1153 }
1154
1155 #[test]
1156 fn test_spdx_external_refs_from_source_url() {
1157 let doc = SbomDocument {
1158 timestamp: "2026-07-28T00:00:00Z".into(),
1159 tool_name: "forge-guard".into(),
1160 tool_version: "0.1.9".into(),
1161 project_name: "spdx-ref-test".into(),
1162 project_version: "1.0.0".into(),
1163 components: vec![SbomComponent {
1164 name: "spdx-ref-lib".into(),
1165 version: "1.0.0".into(),
1166 component_type: "library".into(),
1167 source_url: "https://github.com/owner/spdx-ref-lib".into(),
1168 license: "MIT".into(),
1169 supplier: "Owner".into(),
1170 description: String::new(),
1171 path: "lib/spdx-ref-lib".into(),
1172 hashes: BTreeMap::new(),
1173 }],
1174 };
1175 let json = generate_spdx(&doc).unwrap();
1176 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1177 let pkg = &parsed["packages"][0];
1178
1179 let refs = pkg["externalRefs"].as_array().unwrap();
1180 assert_eq!(refs.len(), 1);
1181 assert_eq!(refs[0]["referenceCategory"], "PACKAGE-MANAGER");
1182 assert_eq!(refs[0]["referenceType"], "purl");
1183 assert_eq!(refs[0]["referenceLocator"], "pkg:github/owner/spdx-ref-lib");
1184 }
1185
1186 #[test]
1189 fn test_components_are_sorted_alphabetically() {
1190 let mut components = vec![
1191 SbomComponent {
1192 name: "zebra-lib".into(),
1193 version: "1.0.0".into(),
1194 ..Default::default()
1195 },
1196 SbomComponent {
1197 name: "alpha-lib".into(),
1198 version: "1.0.0".into(),
1199 ..Default::default()
1200 },
1201 SbomComponent {
1202 name: "beta-lib".into(),
1203 version: "1.0.0".into(),
1204 ..Default::default()
1205 },
1206 ];
1207 components.sort_by(|a, b| a.name.cmp(&b.name));
1209
1210 let mut doc = test_doc();
1211 doc.components = components;
1212
1213 let json = generate_cyclonedx(&doc).unwrap();
1214 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1215
1216 let names: Vec<&str> = parsed["components"]
1217 .as_array()
1218 .unwrap()
1219 .iter()
1220 .map(|c| c["name"].as_str().unwrap())
1221 .collect();
1222
1223 assert_eq!(names[0], "alpha-lib");
1225 assert_eq!(names[1], "beta-lib");
1226 assert_eq!(names[2], "zebra-lib");
1227 }
1228
1229 #[test]
1230 fn test_component_names_with_special_characters() {
1231 let doc = SbomDocument {
1232 timestamp: "2026-07-28T00:00:00Z".into(),
1233 tool_name: "forge-guard".into(),
1234 tool_version: "0.1.9".into(),
1235 project_name: "special-chars".into(),
1236 project_version: "0.1.0".into(),
1237 components: vec![
1238 SbomComponent {
1239 name: "@openzeppelin/contracts-upgradeable".into(),
1240 version: "4.9.3".into(),
1241 component_type: "library".into(),
1242 source_url: String::new(),
1243 license: "MIT".into(),
1244 supplier: "OpenZeppelin".into(),
1245 description: "Upgradeable contracts".into(),
1246 path: "lib/oz-upgradeable".into(),
1247 hashes: BTreeMap::new(),
1248 },
1249 SbomComponent {
1250 name: "forge-std-1.7.0-alpha".into(),
1251 version: "1.7.0-alpha".into(),
1252 component_type: "library".into(),
1253 source_url: String::new(),
1254 license: "MIT OR Apache-2.0".into(),
1255 supplier: "Foundry".into(),
1256 description: "Testing library".into(),
1257 path: "lib/forge-std".into(),
1258 hashes: BTreeMap::new(),
1259 },
1260 ],
1261 };
1262
1263 let json_cdx = generate_cyclonedx(&doc).unwrap();
1265 let parsed_cdx: serde_json::Value = serde_json::from_str(&json_cdx).unwrap();
1266 let names_cdx: Vec<&str> = parsed_cdx["components"]
1267 .as_array()
1268 .unwrap()
1269 .iter()
1270 .map(|c| c["name"].as_str().unwrap())
1271 .collect();
1272 assert!(names_cdx.contains(&"@openzeppelin/contracts-upgradeable"));
1273 assert!(names_cdx.contains(&"forge-std-1.7.0-alpha"));
1274
1275 let json_spdx = generate_spdx(&doc).unwrap();
1277 let parsed_spdx: serde_json::Value = serde_json::from_str(&json_spdx).unwrap();
1278 let names_spdx: Vec<&str> = parsed_spdx["packages"]
1279 .as_array()
1280 .unwrap()
1281 .iter()
1282 .map(|p| p["name"].as_str().unwrap())
1283 .collect();
1284 assert!(names_spdx.contains(&"@openzeppelin/contracts-upgradeable"));
1285 assert!(names_spdx.contains(&"forge-std-1.7.0-alpha"));
1286 }
1287
1288 #[test]
1289 fn test_many_components_does_not_truncate() {
1290 let mut components = Vec::new();
1291 for i in 0..50 {
1292 components.push(SbomComponent {
1293 name: format!("dep-{:03}", i),
1294 version: format!("{}.{}.{}", i / 100, (i / 10) % 10, i % 10),
1295 component_type: "library".into(),
1296 source_url: format!("https://github.com/example/dep-{:03}", i),
1297 license: "MIT".into(),
1298 supplier: "Example".into(),
1299 description: format!("Dependency number {}", i),
1300 path: format!("lib/dep-{:03}", i),
1301 hashes: BTreeMap::new(),
1302 });
1303 }
1304
1305 let doc = SbomDocument {
1306 timestamp: "2026-07-28T00:00:00Z".into(),
1307 tool_name: "forge-guard".into(),
1308 tool_version: "0.1.9".into(),
1309 project_name: "many-comps".into(),
1310 project_version: "1.0.0".into(),
1311 components,
1312 };
1313
1314 let json = generate_cyclonedx(&doc).unwrap();
1315 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1316 let comps = parsed["components"].as_array().unwrap();
1317 assert_eq!(comps.len(), 50);
1318
1319 let json_spdx = generate_spdx(&doc).unwrap();
1320 let parsed_spdx: serde_json::Value = serde_json::from_str(&json_spdx).unwrap();
1321 let pkgs = parsed_spdx["packages"].as_array().unwrap();
1322 assert_eq!(pkgs.len(), 50);
1323 }
1324
1325 #[test]
1328 fn test_cyclonedx_can_be_reparsed_and_inspected() {
1329 let doc = test_doc();
1330 let json = generate_cyclonedx(&doc).unwrap();
1331
1332 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1334 let re_stringified = serde_json::to_string(&parsed).unwrap();
1335 let re_parsed: serde_json::Value = serde_json::from_str(&re_stringified).unwrap();
1336
1337 assert_eq!(re_parsed["bomFormat"], "CycloneDX");
1338 assert_eq!(re_parsed["metadata"]["component"]["name"], "test-project");
1339 assert_eq!(re_parsed["components"].as_array().unwrap().len(), 4);
1340 }
1341
1342 #[test]
1343 fn test_spdx_can_be_reparsed_and_inspected() {
1344 let doc = test_doc();
1345 let json = generate_spdx(&doc).unwrap();
1346
1347 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1348 let re_stringified = serde_json::to_string(&parsed).unwrap();
1349 let re_parsed: serde_json::Value = serde_json::from_str(&re_stringified).unwrap();
1350
1351 assert_eq!(re_parsed["spdxVersion"], "SPDX-2.3");
1352 assert_eq!(re_parsed["packages"].as_array().unwrap().len(), 4);
1353 }
1354
1355 #[test]
1356 fn test_spdx_document_describes() {
1357 let doc = test_doc();
1358 let json = generate_spdx(&doc).unwrap();
1359 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1360
1361 let describes = parsed["documentDescribes"].as_array().unwrap();
1362 assert_eq!(describes.len(), 1);
1363 assert_eq!(describes[0], "SPDXRef-DOCUMENT");
1364 }
1365
1366 #[test]
1367 fn test_spdx_copyright_text_default() {
1368 let doc = test_doc();
1369 let json = generate_spdx(&doc).unwrap();
1370 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1371
1372 for pkg in parsed["packages"].as_array().unwrap() {
1373 assert_eq!(pkg["copyrightText"], "NOASSERTION");
1374 }
1375 }
1376
1377 #[test]
1380 fn test_discover_dependencies_without_project_files() {
1381 let root = Path::new("/nonexistent/project");
1382 let deps = discover_dependencies(root);
1383 assert!(deps.is_empty());
1385 }
1386
1387 #[test]
1388 fn test_discover_dependencies_with_remappings_file() {
1389 let dir = std::env::temp_dir().join(format!("sbom-test-{}", std::process::id()));
1390 let _ = std::fs::remove_dir_all(&dir);
1391 std::fs::create_dir_all(&dir).unwrap();
1392
1393 std::fs::write(
1395 dir.join("remappings.txt"),
1396 "@openzeppelin/contracts=lib/openzeppelin-contracts/\n\
1397 @forge-std/=lib/forge-std/\n",
1398 )
1399 .unwrap();
1400
1401 let deps = discover_dependencies(&dir);
1402 assert_eq!(deps.len(), 2);
1403
1404 let names: Vec<&str> = deps.iter().map(|d| d.name.as_str()).collect();
1405 assert!(names.contains(&"@forge-std/"));
1406 assert!(names.contains(&"@openzeppelin/contracts"));
1407
1408 let _ = std::fs::remove_dir_all(&dir);
1409 }
1410
1411 #[test]
1412 fn test_discover_dependencies_from_foundry_toml() {
1413 let dir = std::env::temp_dir().join(format!("sbom-test2-{}", std::process::id()));
1414 let _ = std::fs::remove_dir_all(&dir);
1415 std::fs::create_dir_all(&dir).unwrap();
1416
1417 std::fs::write(
1419 dir.join("foundry.toml"),
1420 "[profile.default]\n\
1421 src = 'src'\n\
1422 solc = '0.8.23'\n",
1423 )
1424 .unwrap();
1425
1426 let deps = discover_dependencies(&dir);
1427 assert!(deps.iter().any(|d| d.name == "solc"), "Should find solc");
1429 assert!(
1430 deps.iter().any(|d| d.name == "foundry"),
1431 "Should find foundry"
1432 );
1433
1434 let solc = deps.iter().find(|d| d.name == "solc").unwrap();
1436 assert_eq!(solc.version, "0.8.23");
1437
1438 let _ = std::fs::remove_dir_all(&dir);
1439 }
1440
1441 #[test]
1442 fn test_discover_dependencies_from_lib_directory() {
1443 let dir = std::env::temp_dir().join(format!("sbom-test3-{}", std::process::id()));
1444 let _ = std::fs::remove_dir_all(&dir);
1445
1446 std::fs::create_dir_all(dir.join("lib").join("manual-lib")).unwrap();
1448 std::fs::create_dir_all(dir.join("lib").join(".hidden-lib")).unwrap(); let deps = discover_dependencies(&dir);
1451 assert!(
1452 deps.iter().any(|d| d.name == "manual-lib"),
1453 "Should find manual-lib"
1454 );
1455 assert!(
1456 !deps.iter().any(|d| d.name == ".hidden-lib"),
1457 "Should NOT find hidden lib"
1458 );
1459
1460 let _ = std::fs::remove_dir_all(&dir);
1461 }
1462
1463 #[test]
1464 fn test_discover_dependencies_no_duplicates() {
1465 let dir = std::env::temp_dir().join(format!("sbom-test4-{}", std::process::id()));
1466 let _ = std::fs::remove_dir_all(&dir);
1467 std::fs::create_dir_all(&dir).unwrap();
1468
1469 std::fs::write(
1472 dir.join("remappings.txt"),
1473 "openzeppelin-contracts=lib/openzeppelin-contracts/\n",
1474 )
1475 .unwrap();
1476 std::fs::create_dir_all(dir.join("lib").join("openzeppelin-contracts")).unwrap();
1477
1478 let deps = discover_dependencies(&dir);
1479 let oz_count = deps
1481 .iter()
1482 .filter(|d| d.name == "openzeppelin-contracts")
1483 .count();
1484 assert_eq!(oz_count, 1, "openzeppelin should appear only once");
1485
1486 let _ = std::fs::remove_dir_all(&dir);
1487 }
1488
1489 #[test]
1490 fn test_discover_dependencies_remappings_skip_comments() {
1491 let dir = std::env::temp_dir().join(format!("sbom-test5-{}", std::process::id()));
1492 let _ = std::fs::remove_dir_all(&dir);
1493 std::fs::create_dir_all(&dir).unwrap();
1494
1495 std::fs::write(
1496 dir.join("remappings.txt"),
1497 "# This is a comment\n\n\
1498 // Another comment style\n\
1499 @valid/lib=lib/valid-lib/\n",
1500 )
1501 .unwrap();
1502
1503 let deps = discover_dependencies(&dir);
1504 assert_eq!(deps.len(), 1);
1505 assert_eq!(deps[0].name, "@valid/lib");
1506
1507 let _ = std::fs::remove_dir_all(&dir);
1508 }
1509
1510 #[test]
1511 fn test_resolve_lib_version_from_package_json() {
1512 let dir = std::env::temp_dir().join(format!("sbom-test6-{}", std::process::id()));
1513 let _ = std::fs::remove_dir_all(&dir);
1514 std::fs::create_dir_all(&dir).unwrap();
1515
1516 std::fs::write(
1518 dir.join("package.json"),
1519 r#"{
1520 "name": "test-pkg",
1521 "version": "3.2.1",
1522 "description": "A test package",
1523 "license": "MIT",
1524 "repository": "https://github.com/user/test-pkg",
1525 "author": "Test Author"
1526 }"#,
1527 )
1528 .unwrap();
1529
1530 let mut component = SbomComponent::default();
1531 component.name = "test-pkg".into();
1532
1533 resolve_lib_version(dir.to_str().unwrap(), &mut component);
1534
1535 assert_eq!(component.version, "3.2.1");
1536 assert_eq!(component.description, "A test package");
1537 assert_eq!(component.license, "MIT");
1538 assert_eq!(component.source_url, "https://github.com/user/test-pkg");
1539 assert_eq!(component.supplier, "Test Author");
1540
1541 let _ = std::fs::remove_dir_all(&dir);
1542 }
1543
1544 #[test]
1545 fn test_resolve_lib_version_from_package_json_repo_object() {
1546 let dir = std::env::temp_dir().join(format!("sbom-test7-{}", std::process::id()));
1547 let _ = std::fs::remove_dir_all(&dir);
1548 std::fs::create_dir_all(&dir).unwrap();
1549
1550 std::fs::write(
1552 dir.join("package.json"),
1553 r#"{
1554 "name": "test-pkg",
1555 "version": "1.0.0",
1556 "repository": {
1557 "type": "git",
1558 "url": "https://github.com/user/test-pkg.git"
1559 }
1560 }"#,
1561 )
1562 .unwrap();
1563
1564 let mut component = SbomComponent::default();
1565 component.name = "test-pkg".into();
1566
1567 resolve_lib_version(dir.to_str().unwrap(), &mut component);
1568
1569 assert_eq!(component.version, "1.0.0");
1570 assert_eq!(component.source_url, "https://github.com/user/test-pkg.git");
1571
1572 let _ = std::fs::remove_dir_all(&dir);
1573 }
1574
1575 #[test]
1578 fn test_uuid_v4_contains_only_hex_and_dashes() {
1579 let uuid = uuid_v4();
1580 for ch in uuid.chars() {
1581 assert!(
1582 ch.is_ascii_hexdigit() || ch == '-',
1583 "UUID contains invalid character: '{}'",
1584 ch
1585 );
1586 }
1587 }
1588
1589 #[test]
1590 fn test_uuid_v4_version_and_variant_bits() {
1591 let uuid = uuid_v4();
1592 let parts: Vec<&str> = uuid.split('-').collect();
1593 assert_eq!(&parts[2][..1], "4");
1595 let third = u16::from_str_radix(parts[3], 16).unwrap();
1597 assert!(third >= 0x8000, "Variant bits not set: {:04x}", third);
1599 assert!(third < 0xC000, "Variant bits out of range: {:04x}", third);
1600 }
1601}