use serde::Serialize;
use std::collections::BTreeMap;
use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SbomFormat {
CycloneDX,
SPDX,
}
#[derive(Debug, Clone)]
pub struct SbomComponent {
pub name: String,
pub version: String,
pub component_type: String,
pub source_url: String,
pub license: String,
pub supplier: String,
pub description: String,
pub path: String,
pub hashes: BTreeMap<String, String>,
}
impl Default for SbomComponent {
fn default() -> Self {
Self {
name: String::new(),
version: "0.0.0".into(),
component_type: "library".into(),
source_url: String::new(),
license: "NOASSERTION".into(),
supplier: "unknown".into(),
description: String::new(),
path: String::new(),
hashes: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct SbomDocument {
pub timestamp: String,
pub tool_name: String,
pub tool_version: String,
pub project_name: String,
pub project_version: String,
pub components: Vec<SbomComponent>,
}
#[derive(Serialize)]
struct CycloneDoc {
#[serde(rename = "bomFormat")]
bom_format: String,
#[serde(rename = "specVersion")]
spec_version: String,
#[serde(rename = "serialNumber")]
serial_number: String,
version: u32,
metadata: CycloneMetadata,
components: Vec<CycloneComponent>,
}
#[derive(Serialize)]
struct CycloneMetadata {
timestamp: String,
tools: Vec<CycloneTool>,
component: CycloneToolComponent,
}
#[derive(Serialize)]
struct CycloneTool {
vendor: String,
name: String,
version: String,
}
#[derive(Serialize)]
struct CycloneToolComponent {
#[serde(rename = "type")]
comp_type: String,
name: String,
version: String,
}
#[derive(Serialize)]
struct CycloneComponent {
#[serde(rename = "type")]
comp_type: String,
name: String,
version: String,
#[serde(skip_serializing_if = "Option::is_none")]
description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
licenses: Option<Vec<CycloneLicense>>,
#[serde(skip_serializing_if = "Option::is_none")]
supplier: Option<CycloneSupplier>,
#[serde(skip_serializing_if = "Option::is_none")]
hashes: Option<Vec<CycloneHash>>,
#[serde(skip_serializing_if = "Option::is_none")]
external_references: Option<Vec<CycloneRef>>,
}
#[derive(Serialize)]
struct CycloneLicense {
license: CycloneLicenseId,
}
#[derive(Serialize)]
struct CycloneLicenseId {
id: String,
}
#[derive(Serialize)]
struct CycloneSupplier {
name: String,
}
#[derive(Serialize)]
struct CycloneHash {
alg: String,
content: String,
}
#[derive(Serialize)]
struct CycloneRef {
#[serde(rename = "type")]
ref_type: String,
url: String,
}
#[derive(Serialize)]
struct SpdxDoc {
#[serde(rename = "spdxVersion")]
spdx_version: String,
#[serde(rename = "dataLicense")]
data_license: String,
#[serde(rename = "SPDXID")]
spdx_id: String,
name: String,
#[serde(rename = "documentNamespace")]
document_namespace: String,
#[serde(rename = "creationInfo")]
creation_info: SpdxCreationInfo,
packages: Vec<SpdxPackage>,
#[serde(rename = "documentDescribes")]
document_describes: Vec<String>,
}
#[derive(Serialize)]
struct SpdxCreationInfo {
creators: Vec<String>,
created: String,
}
#[derive(Serialize)]
struct SpdxPackage {
#[serde(rename = "SPDXID")]
spdx_id: String,
name: String,
#[serde(rename = "versionInfo")]
version_info: String,
#[serde(rename = "supplier")]
supplier: String,
#[serde(rename = "downloadLocation")]
download_location: String,
#[serde(rename = "licenseConcluded")]
license_concluded: String,
#[serde(rename = "licenseDeclared")]
license_declared: String,
#[serde(rename = "copyrightText")]
copyright_text: String,
#[serde(rename = "description")]
description: String,
#[serde(rename = "checksums", skip_serializing_if = "Option::is_none")]
checksums: Option<Vec<SpdxChecksum>>,
#[serde(rename = "externalRefs", skip_serializing_if = "Option::is_none")]
external_refs: Option<Vec<SpdxExternalRef>>,
}
#[derive(Serialize)]
struct SpdxChecksum {
algorithm: String,
#[serde(rename = "checksumValue")]
checksum_value: String,
}
#[derive(Serialize)]
struct SpdxExternalRef {
#[serde(rename = "referenceCategory")]
reference_category: String,
#[serde(rename = "referenceType")]
reference_type: String,
#[serde(rename = "referenceLocator")]
reference_locator: String,
}
pub fn discover_dependencies(project_root: &Path) -> Vec<SbomComponent> {
let mut components: Vec<SbomComponent> = Vec::new();
let mut seen = std::collections::HashSet::new();
let remappings_path = project_root.join("remappings.txt");
if let Ok(content) = std::fs::read_to_string(&remappings_path) {
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with("//") {
continue;
}
if let Some(eq_pos) = line.find('=') {
let (namespace, path) = line.split_at(eq_pos);
let path = path[1..].trim().to_string(); let name = namespace.trim().to_string();
if !seen.contains(&name) {
seen.insert(name.clone());
let mut component = SbomComponent {
name,
path: path.clone(),
..Default::default()
};
resolve_lib_version(&path, &mut component);
components.push(component);
}
}
}
}
let foundry_path = project_root.join("foundry.toml");
if let Ok(content) = std::fs::read_to_string(&foundry_path) {
for line in content.lines() {
let line = line.trim();
if line.starts_with("remappings") || line.starts_with('#') {
continue;
}
if let Some(eq_pos) = line.find('=') {
let val = line[eq_pos + 1..]
.trim()
.trim_matches('"')
.trim_matches('\'');
if let Some(eq2) = val.find('=') {
let name = val[..eq2].trim().to_string();
let path = val[eq2 + 1..].trim().to_string();
let path = path.trim_matches('"').trim_matches('\'').to_string();
if !name.is_empty() && !path.is_empty() && !seen.contains(&name) {
seen.insert(name.clone());
let mut component = SbomComponent {
name,
path: path.clone(),
..Default::default()
};
resolve_lib_version(&path, &mut component);
components.push(component);
}
}
}
}
}
let lib_dir = project_root.join("lib");
if let Ok(entries) = std::fs::read_dir(&lib_dir) {
for entry in entries.flatten() {
if entry.file_type().map(|t| t.is_dir()).unwrap_or(false) {
let name = entry.file_name().to_string_lossy().to_string();
if !seen.contains(&name) && !name.starts_with('.') {
seen.insert(name.clone());
let lib_path = format!("lib/{}", name);
let mut component = SbomComponent {
name,
path: lib_path.clone(),
component_type: "library".into(),
..Default::default()
};
resolve_lib_version(&lib_path, &mut component);
components.push(component);
}
}
}
}
if let Ok(content) = std::fs::read_to_string(&foundry_path) {
for line in content.lines() {
let line = line.trim();
let stripped = line
.strip_prefix("solc")
.or_else(|| line.strip_prefix("solc_version"))
.or_else(|| line.strip_prefix("solc-version"));
if let Some(rest) = stripped {
let version = rest
.trim()
.trim_start_matches('=')
.trim()
.trim_matches('"')
.trim_matches('\'')
.to_string();
if !version.is_empty() {
let solc_name = format!("solc-{}", version);
if !seen.contains(&solc_name) {
seen.insert(solc_name);
components.push(SbomComponent {
name: "solc".into(),
version,
component_type: "compiler".into(),
source_url: "https://github.com/ethereum/solidity".into(),
license: "GPL-3.0-only".into(),
supplier: "Ethereum Foundation".into(),
description: "Solidity compiler".into(),
path: String::new(),
hashes: BTreeMap::new(),
});
}
}
}
}
}
if foundry_path.exists() {
let forge_name = "foundry-rs".to_string();
if !seen.contains(&forge_name) {
seen.insert(forge_name);
components.push(SbomComponent {
name: "foundry".into(),
version: "latest".into(),
component_type: "framework".into(),
source_url: "https://github.com/foundry-rs/foundry".into(),
license: "MIT OR Apache-2.0".into(),
supplier: "Foundry Contributors".into(),
description: "Foundry smart contract development framework".into(),
path: String::new(),
hashes: BTreeMap::new(),
});
}
}
components.sort_by(|a, b| a.name.cmp(&b.name));
components
}
fn resolve_lib_version(lib_path: &str, component: &mut SbomComponent) {
let path = Path::new(lib_path);
let pkg_json = path.join("package.json");
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Ok(pkg) = serde_json::from_str::<serde_json::Value>(&content) {
if let Some(ver) = pkg.get("version").and_then(|v| v.as_str()) {
component.version = ver.to_string();
}
if let Some(desc) = pkg.get("description").and_then(|d| d.as_str()) {
component.description = desc.to_string();
}
if let Some(lic) = pkg.get("license").and_then(|l| l.as_str()) {
component.license = lic.to_string();
}
if let Some(repo) = pkg.get("repository").and_then(|r| r.as_str()) {
component.source_url = repo.to_string();
} else if let Some(repo) = pkg
.get("repository")
.and_then(|r| r.get("url"))
.and_then(|u| u.as_str())
{
component.source_url = repo.to_string();
}
if let Some(author) = pkg.get("author").and_then(|a| a.as_str()) {
component.supplier = author.to_string();
}
}
}
let git_head = path.join(".git").join("HEAD");
if let Ok(content) = std::fs::read_to_string(&git_head) {
if let Some(ref_info) = content.trim().strip_prefix("ref: ") {
let git_ref = format!("{}/{}", lib_path, ref_info);
component.source_url = git_ref;
}
}
let cargo_path = path.join("Cargo.toml");
if cargo_path.exists() && component.version == "0.0.0" {
if let Ok(content) = std::fs::read_to_string(&cargo_path) {
for line in content.lines() {
let line = line.trim();
if let Some(ver) = line.strip_prefix("version = ") {
let ver = ver.trim_matches('"').trim_matches('\'').to_string();
if !ver.is_empty() {
component.version = ver;
break;
}
}
}
}
}
let lib_foundry = path.join("foundry.toml");
if lib_foundry.exists() && component.version == "0.0.0" {
if let Ok(content) = std::fs::read_to_string(&lib_foundry) {
for line in content.lines() {
let line = line.trim();
if let Some(ver) = line.strip_prefix("version = ") {
let ver = ver.trim_matches('"').trim_matches('\'').to_string();
if !ver.is_empty() {
component.version = ver;
break;
}
}
}
}
}
}
pub fn generate_cyclonedx(doc: &SbomDocument) -> Result<String, serde_json::Error> {
let cyclonedx = CycloneDoc {
bom_format: "CycloneDX".into(),
spec_version: "1.6".into(),
serial_number: format!("urn:uuid:{}", uuid_v4()),
version: 1,
metadata: CycloneMetadata {
timestamp: doc.timestamp.clone(),
tools: vec![CycloneTool {
vendor: "Forge Guard".into(),
name: doc.tool_name.clone(),
version: doc.tool_version.clone(),
}],
component: CycloneToolComponent {
comp_type: "application".into(),
name: doc.project_name.clone(),
version: doc.project_version.clone(),
},
},
components: doc.components.iter().map(map_cyclone_component).collect(),
};
serde_json::to_string_pretty(&cyclonedx)
}
fn map_cyclone_component(c: &SbomComponent) -> CycloneComponent {
let licenses = if c.license != "NOASSERTION" {
Some(vec![CycloneLicense {
license: CycloneLicenseId {
id: c.license.clone(),
},
}])
} else {
None
};
let hashes = if !c.hashes.is_empty() {
Some(
c.hashes
.iter()
.map(|(alg, content)| CycloneHash {
alg: alg.to_uppercase(),
content: content.clone(),
})
.collect(),
)
} else {
None
};
let external_references = if !c.source_url.is_empty() && c.source_url != "unknown" {
Some(vec![CycloneRef {
ref_type: "vcs".into(),
url: c.source_url.clone(),
}])
} else {
None
};
CycloneComponent {
comp_type: c.component_type.clone(),
name: c.name.clone(),
version: c.version.clone(),
description: if c.description.is_empty() {
None
} else {
Some(c.description.clone())
},
licenses,
supplier: if c.supplier != "unknown" {
Some(CycloneSupplier {
name: c.supplier.clone(),
})
} else {
None
},
hashes,
external_references,
}
}
pub fn generate_spdx(doc: &SbomDocument) -> Result<String, serde_json::Error> {
let spdx = SpdxDoc {
spdx_version: "SPDX-2.3".into(),
data_license: "CC0-1.0".into(),
spdx_id: "SPDXRef-DOCUMENT".into(),
name: format!("{}-{}", doc.project_name, doc.project_version),
document_namespace: format!(
"https://github.com/codetibo/forge-guard/sbom/{}-{}",
doc.project_name.replace(' ', "-"),
uuid_v4()
),
creation_info: SpdxCreationInfo {
creators: vec![
format!("Tool: forge-guard-{}", doc.tool_version),
"Organization: Forge Guard Contributors".into(),
],
created: doc.timestamp.clone(),
},
packages: doc
.components
.iter()
.enumerate()
.map(|(i, c)| map_spdx_package(c, i))
.collect(),
document_describes: vec!["SPDXRef-DOCUMENT".into()],
};
serde_json::to_string_pretty(&spdx)
}
fn map_spdx_package(c: &SbomComponent, index: usize) -> SpdxPackage {
let checksums = if !c.hashes.is_empty() {
Some(
c.hashes
.iter()
.map(|(alg, value)| SpdxChecksum {
algorithm: alg.to_uppercase(),
checksum_value: value.clone(),
})
.collect(),
)
} else {
None
};
let external_refs = if !c.source_url.is_empty() && c.source_url != "unknown" {
Some(vec![SpdxExternalRef {
reference_category: "PACKAGE-MANAGER".into(),
reference_type: "purl".into(),
reference_locator: format!(
"pkg:github/{}",
c.source_url.trim_start_matches("https://github.com/")
),
}])
} else {
None
};
SpdxPackage {
spdx_id: format!("SPDXRef-Package-{}", index + 1),
name: c.name.clone(),
version_info: c.version.clone(),
supplier: format!("Organization: {}", c.supplier),
download_location: if c.source_url.is_empty() {
"NOASSERTION".into()
} else {
c.source_url.clone()
},
license_concluded: c.license.clone(),
license_declared: c.license.clone(),
copyright_text: "NOASSERTION".into(),
description: c.description.clone(),
checksums,
external_refs,
}
}
fn uuid_v4() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default();
let nanos = now.as_nanos();
let rand1 = (nanos & 0xFFFF_FFFF_FFFF) as u64;
let rand2 = ((nanos >> 48) ^ 0xABCD_EF01_2345_6789) & 0xFFFF_FFFF_FFFF;
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
(rand1 >> 16) as u32,
(rand1 & 0xFFFF) as u16,
((rand2 >> 12) & 0x0FFF) as u16,
((rand2 >> 4) & 0x3FFF) as u16 | 0x8000,
(rand2 & 0xFFFF_FFFF_FFFF) as u64
)
}
pub fn build_sbom(project_root: &Path, project_name: &str, project_version: &str) -> SbomDocument {
let components = discover_dependencies(project_root);
SbomDocument {
timestamp: chrono::Utc::now().to_rfc3339(),
tool_name: "forge-guard".into(),
tool_version: env!("CARGO_PKG_VERSION").into(),
project_name: project_name.to_string(),
project_version: project_version.to_string(),
components,
}
}
pub fn generate_sbom(
project_root: &Path,
project_name: &str,
project_version: &str,
format: SbomFormat,
) -> Result<String, serde_json::Error> {
let doc = build_sbom(project_root, project_name, project_version);
match format {
SbomFormat::CycloneDX => generate_cyclonedx(&doc),
SbomFormat::SPDX => generate_spdx(&doc),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn test_doc() -> SbomDocument {
SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.8".into(),
project_name: "test-project".into(),
project_version: "1.0.0".into(),
components: vec![
SbomComponent {
name: "openzeppelin-contracts".into(),
version: "4.9.3".into(),
component_type: "library".into(),
source_url: "https://github.com/OpenZeppelin/openzeppelin-contracts".into(),
license: "MIT".into(),
supplier: "OpenZeppelin".into(),
description:
"OpenZeppelin Contracts is a library for secure smart contract development."
.into(),
path: "lib/openzeppelin-contracts".into(),
hashes: BTreeMap::new(),
},
SbomComponent {
name: "forge-std".into(),
version: "1.7.0".into(),
component_type: "library".into(),
source_url: "https://github.com/foundry-rs/forge-std".into(),
license: "MIT OR Apache-2.0".into(),
supplier: "Foundry Contributors".into(),
description: "Forge Standard Library".into(),
path: "lib/forge-std".into(),
hashes: BTreeMap::new(),
},
SbomComponent {
name: "solmate".into(),
version: "6.6.2".into(),
component_type: "library".into(),
source_url: "https://github.com/transmissions11/solmate".into(),
license: "AGPL-3.0-only".into(),
supplier: "transmissions11".into(),
description: "Solmate is a library of gas-optimized Solidity contracts.".into(),
path: "lib/solmate".into(),
hashes: BTreeMap::new(),
},
SbomComponent {
name: "solc".into(),
version: "0.8.23".into(),
component_type: "compiler".into(),
source_url: "https://github.com/ethereum/solidity".into(),
license: "GPL-3.0-only".into(),
supplier: "Ethereum Foundation".into(),
description: "Solidity compiler".into(),
path: String::new(),
hashes: BTreeMap::new(),
},
],
}
}
#[test]
fn test_cyclonedx_generates_valid_json() {
let doc = test_doc();
let result = generate_cyclonedx(&doc);
assert!(result.is_ok());
let json = result.unwrap();
assert!(!json.is_empty());
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["bomFormat"], "CycloneDX");
assert_eq!(parsed["specVersion"], "1.6");
}
#[test]
fn test_cyclonedx_metadata() {
let doc = test_doc();
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let metadata = &parsed["metadata"];
assert_eq!(metadata["timestamp"], "2026-07-28T00:00:00Z");
assert_eq!(metadata["component"]["name"], "test-project");
assert_eq!(metadata["component"]["version"], "1.0.0");
assert_eq!(metadata["tools"][0]["name"], "forge-guard");
}
#[test]
fn test_cyclonedx_components_count() {
let doc = test_doc();
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let components = parsed["components"].as_array().unwrap();
assert_eq!(components.len(), 4);
}
#[test]
fn test_cyclonedx_component_fields() {
let doc = test_doc();
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let comp = &parsed["components"][0];
assert_eq!(comp["name"], "openzeppelin-contracts");
assert_eq!(comp["version"], "4.9.3");
assert_eq!(comp["type"], "library");
assert!(comp["description"]
.as_str()
.unwrap()
.contains("secure smart contract"));
assert_eq!(comp["licenses"][0]["license"]["id"], "MIT");
}
#[test]
fn test_cyclonedx_without_optional_fields() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.8".into(),
project_name: "minimal".into(),
project_version: "0.1.0".into(),
components: vec![SbomComponent {
name: "minimal-lib".into(),
version: "1.0.0".into(),
component_type: "library".into(),
source_url: String::new(),
license: "NOASSERTION".into(),
supplier: "unknown".into(),
description: String::new(),
path: "lib/minimal-lib".into(),
hashes: BTreeMap::new(),
}],
};
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let comp = &parsed["components"][0];
assert!(comp.get("description").is_none());
assert!(comp.get("licenses").is_none());
assert!(comp.get("supplier").is_none());
assert!(comp.get("hashes").is_none());
assert!(comp.get("external_references").is_none());
}
#[test]
fn test_cyclonedx_serial_number_format() {
let doc = test_doc();
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let serial = parsed["serialNumber"].as_str().unwrap();
assert!(serial.starts_with("urn:uuid:"));
assert_eq!(serial.len(), 45); }
#[test]
fn test_spdx_generates_valid_json() {
let doc = test_doc();
let result = generate_spdx(&doc);
assert!(result.is_ok());
let json = result.unwrap();
assert!(!json.is_empty());
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
assert_eq!(parsed["dataLicense"], "CC0-1.0");
}
#[test]
fn test_spdx_creation_info() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let info = &parsed["creationInfo"];
assert_eq!(info["created"], "2026-07-28T00:00:00Z");
let creators = info["creators"].as_array().unwrap();
assert!(creators
.iter()
.any(|c| c.as_str().unwrap().contains("forge-guard")));
}
#[test]
fn test_spdx_packages_count() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let packages = parsed["packages"].as_array().unwrap();
assert_eq!(packages.len(), 4);
}
#[test]
fn test_spdx_package_fields() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let pkg = &parsed["packages"][0];
assert_eq!(pkg["name"], "openzeppelin-contracts");
assert_eq!(pkg["versionInfo"], "4.9.3");
assert!(pkg["SPDXID"]
.as_str()
.unwrap()
.starts_with("SPDXRef-Package-"));
assert_eq!(pkg["licenseConcluded"], "MIT");
assert_eq!(pkg["licenseDeclared"], "MIT");
}
#[test]
fn test_spdx_supplier_format() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let pkg = &parsed["packages"][0];
assert_eq!(pkg["supplier"], "Organization: OpenZeppelin");
}
#[test]
fn test_uuid_v4_format() {
let uuid = uuid_v4();
assert_eq!(uuid.len(), 36);
let parts: Vec<&str> = uuid.split('-').collect();
assert_eq!(parts.len(), 5);
assert_eq!(parts[0].len(), 8);
assert_eq!(parts[1].len(), 4);
assert_eq!(parts[2].len(), 4);
assert_eq!(parts[3].len(), 4);
assert_eq!(parts[4].len(), 12);
assert_eq!(&parts[2][..1], "4");
let variant_first = u16::from_str_radix(parts[3], 16).unwrap();
assert!(variant_first >= 0x8000);
}
#[test]
fn test_uuid_v4_uniqueness() {
let u1 = uuid_v4();
let u2 = uuid_v4();
assert_ne!(u1, u2);
}
#[test]
fn test_build_sbom_structure() {
let doc = build_sbom(Path::new("."), "test-project", "1.0.0");
assert_eq!(doc.project_name, "test-project");
assert_eq!(doc.project_version, "1.0.0");
assert_eq!(doc.tool_name, "forge-guard");
assert_eq!(doc.tool_version, env!("CARGO_PKG_VERSION"));
assert!(doc.components.len() <= 50);
}
#[test]
fn test_generate_sbom_cyclonedx() {
let result = generate_sbom(Path::new("."), "p", "0.1.0", SbomFormat::CycloneDX);
assert!(result.is_ok());
let json = result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["bomFormat"], "CycloneDX");
}
#[test]
fn test_generate_sbom_spdx() {
let result = generate_sbom(Path::new("."), "p", "0.1.0", SbomFormat::SPDX);
assert!(result.is_ok());
let json = result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["spdxVersion"], "SPDX-2.3");
}
#[test]
fn test_empty_components_cyclonedx() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.8".into(),
project_name: "empty".into(),
project_version: "0.0.0".into(),
components: vec![],
};
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["components"].as_array().unwrap().is_empty());
}
#[test]
fn test_empty_components_spdx() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.8".into(),
project_name: "empty".into(),
project_version: "0.0.0".into(),
components: vec![],
};
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(parsed["packages"].as_array().unwrap().is_empty());
}
#[test]
fn test_resolve_lib_version_unknown() {
let mut component = SbomComponent::default();
component.name = "test-lib".into();
resolve_lib_version("/nonexistent/path", &mut component);
assert_eq!(component.version, "0.0.0");
}
#[test]
fn test_component_default_version_is_zero() {
let c = SbomComponent::default();
assert_eq!(c.version, "0.0.0");
assert_eq!(c.license, "NOASSERTION");
assert_eq!(c.supplier, "unknown");
}
#[test]
fn test_sbom_format_cyclonedx_variant() {
match SbomFormat::CycloneDX {
SbomFormat::CycloneDX => {} _ => panic!("Expected CycloneDX"),
}
}
#[test]
fn test_sbom_format_spdx_variant() {
match SbomFormat::SPDX {
SbomFormat::SPDX => {} _ => panic!("Expected SPDX"),
}
}
#[test]
fn test_cyclonedx_serial_number_unique_per_call() {
let doc = test_doc();
let json1 = generate_cyclonedx(&doc).unwrap();
let json2 = generate_cyclonedx(&doc).unwrap();
let p1: serde_json::Value = serde_json::from_str(&json1).unwrap();
let p2: serde_json::Value = serde_json::from_str(&json2).unwrap();
assert_ne!(p1["serialNumber"], p2["serialNumber"]);
}
#[test]
fn test_spdx_namespace_unique_per_call() {
let doc = test_doc();
let json1 = generate_spdx(&doc).unwrap();
let json2 = generate_spdx(&doc).unwrap();
let p1: serde_json::Value = serde_json::from_str(&json1).unwrap();
let p2: serde_json::Value = serde_json::from_str(&json2).unwrap();
assert_ne!(p1["documentNamespace"], p2["documentNamespace"]);
}
#[test]
fn test_cyclonedx_with_hashes() {
let mut hashes: BTreeMap<String, String> = BTreeMap::new();
hashes.insert("SHA-256".into(), "abc123def456".into());
hashes.insert("SHA-512".into(), "fed654cba321".into());
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "hash-test".into(),
project_version: "1.0.0".into(),
components: vec![SbomComponent {
name: "hashed-lib".into(),
version: "2.0.0".into(),
component_type: "library".into(),
source_url: "https://github.com/example/hashed-lib".into(),
license: "MIT".into(),
supplier: "Example".into(),
description: "A library with hashes".into(),
path: "lib/hashed-lib".into(),
hashes,
}],
};
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let comp = &parsed["components"][0];
let hashes_arr = comp["hashes"].as_array().unwrap();
assert_eq!(hashes_arr.len(), 2);
let algos: Vec<&str> = hashes_arr
.iter()
.map(|h| h["alg"].as_str().unwrap())
.collect();
assert!(algos.contains(&"SHA-256"));
assert!(algos.contains(&"SHA-512"));
let sha256 = hashes_arr.iter().find(|h| h["alg"] == "SHA-256").unwrap();
assert_eq!(sha256["content"], "abc123def456");
}
#[test]
fn test_cyclonedx_external_references_with_source_url() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "ref-test".into(),
project_version: "1.0.0".into(),
components: vec![SbomComponent {
name: "referenced-lib".into(),
version: "1.0.0".into(),
component_type: "library".into(),
source_url: "https://github.com/org/repo".into(),
license: "MIT".into(),
supplier: "Org".into(),
description: String::new(),
path: "lib/referenced-lib".into(),
hashes: BTreeMap::new(),
}],
};
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let comp = &parsed["components"][0];
let refs = comp["external_references"].as_array().unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0]["type"], "vcs");
assert_eq!(refs[0]["url"], "https://github.com/org/repo");
}
#[test]
fn test_spdx_with_checksums() {
let mut hashes: BTreeMap<String, String> = BTreeMap::new();
hashes.insert("SHA256".into(), "abcd1234".into());
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "checksum-test".into(),
project_version: "1.0.0".into(),
components: vec![SbomComponent {
name: "checksummed-lib".into(),
version: "3.0.0".into(),
component_type: "library".into(),
source_url: "https://github.com/example/checksummed-lib".into(),
license: "Apache-2.0".into(),
supplier: "Example Inc".into(),
description: String::new(),
path: "lib/checksummed-lib".into(),
hashes,
}],
};
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let pkg = &parsed["packages"][0];
let checksums = pkg["checksums"].as_array().unwrap();
assert_eq!(checksums.len(), 1);
assert_eq!(checksums[0]["algorithm"], "SHA256");
assert_eq!(checksums[0]["checksumValue"], "abcd1234");
}
#[test]
fn test_spdx_external_refs_from_source_url() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "spdx-ref-test".into(),
project_version: "1.0.0".into(),
components: vec![SbomComponent {
name: "spdx-ref-lib".into(),
version: "1.0.0".into(),
component_type: "library".into(),
source_url: "https://github.com/owner/spdx-ref-lib".into(),
license: "MIT".into(),
supplier: "Owner".into(),
description: String::new(),
path: "lib/spdx-ref-lib".into(),
hashes: BTreeMap::new(),
}],
};
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let pkg = &parsed["packages"][0];
let refs = pkg["externalRefs"].as_array().unwrap();
assert_eq!(refs.len(), 1);
assert_eq!(refs[0]["referenceCategory"], "PACKAGE-MANAGER");
assert_eq!(refs[0]["referenceType"], "purl");
assert_eq!(refs[0]["referenceLocator"], "pkg:github/owner/spdx-ref-lib");
}
#[test]
fn test_components_are_sorted_alphabetically() {
let mut components = vec![
SbomComponent {
name: "zebra-lib".into(),
version: "1.0.0".into(),
..Default::default()
},
SbomComponent {
name: "alpha-lib".into(),
version: "1.0.0".into(),
..Default::default()
},
SbomComponent {
name: "beta-lib".into(),
version: "1.0.0".into(),
..Default::default()
},
];
components.sort_by(|a, b| a.name.cmp(&b.name));
let mut doc = test_doc();
doc.components = components;
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let names: Vec<&str> = parsed["components"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert_eq!(names[0], "alpha-lib");
assert_eq!(names[1], "beta-lib");
assert_eq!(names[2], "zebra-lib");
}
#[test]
fn test_component_names_with_special_characters() {
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "special-chars".into(),
project_version: "0.1.0".into(),
components: vec![
SbomComponent {
name: "@openzeppelin/contracts-upgradeable".into(),
version: "4.9.3".into(),
component_type: "library".into(),
source_url: String::new(),
license: "MIT".into(),
supplier: "OpenZeppelin".into(),
description: "Upgradeable contracts".into(),
path: "lib/oz-upgradeable".into(),
hashes: BTreeMap::new(),
},
SbomComponent {
name: "forge-std-1.7.0-alpha".into(),
version: "1.7.0-alpha".into(),
component_type: "library".into(),
source_url: String::new(),
license: "MIT OR Apache-2.0".into(),
supplier: "Foundry".into(),
description: "Testing library".into(),
path: "lib/forge-std".into(),
hashes: BTreeMap::new(),
},
],
};
let json_cdx = generate_cyclonedx(&doc).unwrap();
let parsed_cdx: serde_json::Value = serde_json::from_str(&json_cdx).unwrap();
let names_cdx: Vec<&str> = parsed_cdx["components"]
.as_array()
.unwrap()
.iter()
.map(|c| c["name"].as_str().unwrap())
.collect();
assert!(names_cdx.contains(&"@openzeppelin/contracts-upgradeable"));
assert!(names_cdx.contains(&"forge-std-1.7.0-alpha"));
let json_spdx = generate_spdx(&doc).unwrap();
let parsed_spdx: serde_json::Value = serde_json::from_str(&json_spdx).unwrap();
let names_spdx: Vec<&str> = parsed_spdx["packages"]
.as_array()
.unwrap()
.iter()
.map(|p| p["name"].as_str().unwrap())
.collect();
assert!(names_spdx.contains(&"@openzeppelin/contracts-upgradeable"));
assert!(names_spdx.contains(&"forge-std-1.7.0-alpha"));
}
#[test]
fn test_many_components_does_not_truncate() {
let mut components = Vec::new();
for i in 0..50 {
components.push(SbomComponent {
name: format!("dep-{:03}", i),
version: format!("{}.{}.{}", i / 100, (i / 10) % 10, i % 10),
component_type: "library".into(),
source_url: format!("https://github.com/example/dep-{:03}", i),
license: "MIT".into(),
supplier: "Example".into(),
description: format!("Dependency number {}", i),
path: format!("lib/dep-{:03}", i),
hashes: BTreeMap::new(),
});
}
let doc = SbomDocument {
timestamp: "2026-07-28T00:00:00Z".into(),
tool_name: "forge-guard".into(),
tool_version: "0.1.9".into(),
project_name: "many-comps".into(),
project_version: "1.0.0".into(),
components,
};
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let comps = parsed["components"].as_array().unwrap();
assert_eq!(comps.len(), 50);
let json_spdx = generate_spdx(&doc).unwrap();
let parsed_spdx: serde_json::Value = serde_json::from_str(&json_spdx).unwrap();
let pkgs = parsed_spdx["packages"].as_array().unwrap();
assert_eq!(pkgs.len(), 50);
}
#[test]
fn test_cyclonedx_can_be_reparsed_and_inspected() {
let doc = test_doc();
let json = generate_cyclonedx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let re_stringified = serde_json::to_string(&parsed).unwrap();
let re_parsed: serde_json::Value = serde_json::from_str(&re_stringified).unwrap();
assert_eq!(re_parsed["bomFormat"], "CycloneDX");
assert_eq!(re_parsed["metadata"]["component"]["name"], "test-project");
assert_eq!(re_parsed["components"].as_array().unwrap().len(), 4);
}
#[test]
fn test_spdx_can_be_reparsed_and_inspected() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let re_stringified = serde_json::to_string(&parsed).unwrap();
let re_parsed: serde_json::Value = serde_json::from_str(&re_stringified).unwrap();
assert_eq!(re_parsed["spdxVersion"], "SPDX-2.3");
assert_eq!(re_parsed["packages"].as_array().unwrap().len(), 4);
}
#[test]
fn test_spdx_document_describes() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let describes = parsed["documentDescribes"].as_array().unwrap();
assert_eq!(describes.len(), 1);
assert_eq!(describes[0], "SPDXRef-DOCUMENT");
}
#[test]
fn test_spdx_copyright_text_default() {
let doc = test_doc();
let json = generate_spdx(&doc).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
for pkg in parsed["packages"].as_array().unwrap() {
assert_eq!(pkg["copyrightText"], "NOASSERTION");
}
}
#[test]
fn test_discover_dependencies_without_project_files() {
let root = Path::new("/nonexistent/project");
let deps = discover_dependencies(root);
assert!(deps.is_empty());
}
#[test]
fn test_discover_dependencies_with_remappings_file() {
let dir = std::env::temp_dir().join(format!("sbom-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("remappings.txt"),
"@openzeppelin/contracts=lib/openzeppelin-contracts/\n\
@forge-std/=lib/forge-std/\n",
)
.unwrap();
let deps = discover_dependencies(&dir);
assert_eq!(deps.len(), 2);
let names: Vec<&str> = deps.iter().map(|d| d.name.as_str()).collect();
assert!(names.contains(&"@forge-std/"));
assert!(names.contains(&"@openzeppelin/contracts"));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_discover_dependencies_from_foundry_toml() {
let dir = std::env::temp_dir().join(format!("sbom-test2-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("foundry.toml"),
"[profile.default]\n\
src = 'src'\n\
solc = '0.8.23'\n",
)
.unwrap();
let deps = discover_dependencies(&dir);
assert!(deps.iter().any(|d| d.name == "solc"), "Should find solc");
assert!(
deps.iter().any(|d| d.name == "foundry"),
"Should find foundry"
);
let solc = deps.iter().find(|d| d.name == "solc").unwrap();
assert_eq!(solc.version, "0.8.23");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_discover_dependencies_from_lib_directory() {
let dir = std::env::temp_dir().join(format!("sbom-test3-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("lib").join("manual-lib")).unwrap();
std::fs::create_dir_all(dir.join("lib").join(".hidden-lib")).unwrap();
let deps = discover_dependencies(&dir);
assert!(
deps.iter().any(|d| d.name == "manual-lib"),
"Should find manual-lib"
);
assert!(
!deps.iter().any(|d| d.name == ".hidden-lib"),
"Should NOT find hidden lib"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_discover_dependencies_no_duplicates() {
let dir = std::env::temp_dir().join(format!("sbom-test4-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("remappings.txt"),
"openzeppelin-contracts=lib/openzeppelin-contracts/\n",
)
.unwrap();
std::fs::create_dir_all(dir.join("lib").join("openzeppelin-contracts")).unwrap();
let deps = discover_dependencies(&dir);
let oz_count = deps
.iter()
.filter(|d| d.name == "openzeppelin-contracts")
.count();
assert_eq!(oz_count, 1, "openzeppelin should appear only once");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_discover_dependencies_remappings_skip_comments() {
let dir = std::env::temp_dir().join(format!("sbom-test5-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("remappings.txt"),
"# This is a comment\n\n\
// Another comment style\n\
@valid/lib=lib/valid-lib/\n",
)
.unwrap();
let deps = discover_dependencies(&dir);
assert_eq!(deps.len(), 1);
assert_eq!(deps[0].name, "@valid/lib");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_resolve_lib_version_from_package_json() {
let dir = std::env::temp_dir().join(format!("sbom-test6-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
r#"{
"name": "test-pkg",
"version": "3.2.1",
"description": "A test package",
"license": "MIT",
"repository": "https://github.com/user/test-pkg",
"author": "Test Author"
}"#,
)
.unwrap();
let mut component = SbomComponent::default();
component.name = "test-pkg".into();
resolve_lib_version(dir.to_str().unwrap(), &mut component);
assert_eq!(component.version, "3.2.1");
assert_eq!(component.description, "A test package");
assert_eq!(component.license, "MIT");
assert_eq!(component.source_url, "https://github.com/user/test-pkg");
assert_eq!(component.supplier, "Test Author");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_resolve_lib_version_from_package_json_repo_object() {
let dir = std::env::temp_dir().join(format!("sbom-test7-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join("package.json"),
r#"{
"name": "test-pkg",
"version": "1.0.0",
"repository": {
"type": "git",
"url": "https://github.com/user/test-pkg.git"
}
}"#,
)
.unwrap();
let mut component = SbomComponent::default();
component.name = "test-pkg".into();
resolve_lib_version(dir.to_str().unwrap(), &mut component);
assert_eq!(component.version, "1.0.0");
assert_eq!(component.source_url, "https://github.com/user/test-pkg.git");
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn test_uuid_v4_contains_only_hex_and_dashes() {
let uuid = uuid_v4();
for ch in uuid.chars() {
assert!(
ch.is_ascii_hexdigit() || ch == '-',
"UUID contains invalid character: '{}'",
ch
);
}
}
#[test]
fn test_uuid_v4_version_and_variant_bits() {
let uuid = uuid_v4();
let parts: Vec<&str> = uuid.split('-').collect();
assert_eq!(&parts[2][..1], "4");
let third = u16::from_str_radix(parts[3], 16).unwrap();
assert!(third >= 0x8000, "Variant bits not set: {:04x}", third);
assert!(third < 0xC000, "Variant bits out of range: {:04x}", third);
}
}