Skip to main content

forge_guard/sbom/
mod.rs

1//! # SBOM Generation
2//!
3//! Generates Software Bill of Materials (SBOM) in CycloneDX and SPDX formats
4//! from Foundry project dependencies.
5//!
6//! Discovers dependencies via `remappings.txt`, `foundry.toml`, and `lib/` directory.
7//!
8//! ## Usage
9//!
10//! ```bash
11//! forge-guard sbom                         # CycloneDX JSON to stdout
12//! forge-guard sbom --format spdx           # SPDX JSON to stdout
13//! forge-guard sbom --output sbom.json      # Write to file
14//! forge-guard sbom --format spdx -o sbom.spdx.json
15//! ```
16
17use serde::Serialize;
18use std::collections::BTreeMap;
19use std::path::Path;
20
21/// SBOM output format.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum SbomFormat {
24    CycloneDX,
25    SPDX,
26}
27
28/// A discovered dependency component.
29#[derive(Debug, Clone)]
30pub struct SbomComponent {
31    /// Package/component name (e.g. "openzeppelin-contracts").
32    pub name: String,
33    /// Version string, if discoverable.
34    pub version: String,
35    /// The type of component (library, framework, compiler, etc.).
36    pub component_type: String,
37    /// Source URL or reference.
38    pub source_url: String,
39    /// License identifier if known (SPDX).
40    pub license: String,
41    /// Supplier/publisher name.
42    pub supplier: String,
43    /// Description of the component.
44    pub description: String,
45    /// File path relative to project root.
46    pub path: String,
47    /// Hashes for integrity verification (SHA-256).
48    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/// The complete SBOM document.
68#[derive(Debug, Clone)]
69pub struct SbomDocument {
70    /// Timestamp of generation (ISO 8601).
71    pub timestamp: String,
72    /// The project/tool that generated this SBOM.
73    pub tool_name: String,
74    /// Tool version.
75    pub tool_version: String,
76    /// Target project name.
77    pub project_name: String,
78    /// Target project version.
79    pub project_version: String,
80    /// All discovered components.
81    pub components: Vec<SbomComponent>,
82}
83
84// ── CycloneDX 1.6 JSON Schema ───────────────────────────────────
85
86/// Top-level CycloneDX document.
87#[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// ── SPDX 2.3 JSON Schema ────────────────────────────────────────
169
170/// Top-level SPDX document.
171#[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
237// ── Dependency Discovery ─────────────────────────────────────────
238
239/// Discover dependencies in a Foundry project.
240pub 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    // 1. Scan remappings.txt
245    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            // Format: @openzeppelin/contracts=lib/openzeppelin-contracts/
253            if let Some(eq_pos) = line.find('=') {
254                let (namespace, path) = line.split_at(eq_pos);
255                let path = path[1..].trim().to_string(); // after '='
256                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                    // Try to extract version from the library directory
265                    resolve_lib_version(&path, &mut component);
266                    components.push(component);
267                }
268            }
269        }
270    }
271
272    // 2. Scan foundry.toml for remappings
273    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            // Look for @org/pkg=path patterns in toml values
281            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                    // Remove trailing quotes
290                    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    // 3. Scan lib/ directory for unlisted libraries
307    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    // 4. Detect solc compiler version from foundry.toml
329    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    // 5. Detect foundry itself from foundry.toml presence
366    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
388/// Try to extract version info from a library directory.
389fn resolve_lib_version(lib_path: &str, component: &mut SbomComponent) {
390    let path = Path::new(lib_path);
391
392    // Try package.json (npm-style packages)
393    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    // Try .git/HEAD for branch/ref info
421    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    // Try Cargo.toml (Rust-style deps in lib/)
430    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    // Try foundry.toml in the lib's own directory
447    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
464// ── Format Generators ────────────────────────────────────────────
465
466/// Generate a CycloneDX 1.6 JSON string from an SBOM document.
467pub 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
548/// Generate an SPDX 2.3 JSON string from an SBOM document.
549pub 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
626// ── Helpers ──────────────────────────────────────────────────────
627
628/// Generate a v4 UUID (simple implementation — no external dependency needed).
629fn 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
647/// Build the SBOM document with the current timestamp.
648pub 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
661/// Generate SBOM in the requested format.
662pub 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    // ── CycloneDX tests ───────────────────────────────────────────
738
739    #[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        // Verify it parses as valid JSON
748        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        // Optional fields should be absent (not null)
818        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); // "urn:uuid:" + 36-char UUID
834    }
835
836    // ── SPDX tests ────────────────────────────────────────────────
837
838    #[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    // ── UUID generation tests ─────────────────────────────────────
903
904    #[test]
905    fn test_uuid_v4_format() {
906        let uuid = uuid_v4();
907        assert_eq!(uuid.len(), 36);
908        // Format: 8-4-4-4-12 hex digits
909        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        // Version nibble should be 4
917        assert_eq!(&parts[2][..1], "4");
918        // Variant bits in part[3]
919        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    // ── Build SBOM tests ──────────────────────────────────────────
931
932    #[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        // Components may be empty if no deps found, but structure is valid
940        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    // ── Empty document edge cases ─────────────────────────────────
962
963    #[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    // ── Resolve lib version tests ─────────────────────────────────
994
995    #[test]
996    fn test_resolve_lib_version_unknown() {
997        let mut component = SbomComponent::default();
998        component.name = "test-lib".into();
999        // Non-existent path should leave defaults
1000        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 => {} // ok
1016            _ => panic!("Expected CycloneDX"),
1017        }
1018    }
1019
1020    #[test]
1021    fn test_sbom_format_spdx_variant() {
1022        match SbomFormat::SPDX {
1023            SbomFormat::SPDX => {} // ok
1024            _ => 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    // ── Hash & External Reference tests ──────────────────────────
1049
1050    #[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    // ── Component ordering and edge cases ─────────────────────────
1187
1188    #[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        // Sort like discover_dependencies does
1208        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        // Order should be alphabetical after sorting
1224        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        // CycloneDX
1264        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        // SPDX
1276        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    // ── Round-trip / structural tests ────────────────────────────
1326
1327    #[test]
1328    fn test_cyclonedx_can_be_reparsed_and_inspected() {
1329        let doc = test_doc();
1330        let json = generate_cyclonedx(&doc).unwrap();
1331
1332        // Parse, re-stringify, re-parse — should be stable
1333        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    // ── Dependency discovery integration tests ────────────────────
1378
1379    #[test]
1380    fn test_discover_dependencies_without_project_files() {
1381        let root = Path::new("/nonexistent/project");
1382        let deps = discover_dependencies(root);
1383        // Should return empty list gracefully
1384        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        // Write a mock remappings.txt
1394        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        // Write a mock foundry.toml with remappings
1418        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        // Should find solc compiler and foundry framework
1428        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        // Check solc version
1435        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        // Create lib directory with subdirectories
1447        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(); // should be ignored
1449
1450        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        // Same library listed in both remappings.txt and lib/ directory
1470        // Use matching names so dedup works
1471        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        // Should appear only once
1480        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        // Create a mock package.json
1517        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        // Create a package.json where repository is an object with url
1551        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    // ── UUID edge case tests ──────────────────────────────────────
1576
1577    #[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        // Version nibble (part[2][0]) should be '4'
1594        assert_eq!(&parts[2][..1], "4");
1595        // Variant bits: first two bits of part[3] should be '10'
1596        let third = u16::from_str_radix(parts[3], 16).unwrap();
1597        // For RFC 4122, the top 2 bits should be 10xxxxxx
1598        assert!(third >= 0x8000, "Variant bits not set: {:04x}", third);
1599        assert!(third < 0xC000, "Variant bits out of range: {:04x}", third);
1600    }
1601}