rastray 0.15.0

Blazing-fast static analysis CLI for security, dependency, and performance audits.
use std::path::Path;

use serde_json::{json, Value};
use thiserror::Error;

use crate::modules::dependencies::DiscoveredPackage;

const SCHEMA_VERSION_CYCLONEDX: &str = "1.5";
const SPEC_VERSION_SPDX: &str = "SPDX-2.3";
const TOOL_NAME: &str = "rastray";
const TOOL_VENDOR: &str = "rastray";

#[derive(Debug, Error)]
pub enum SbomError {
    #[error("failed to serialize SBOM as JSON: {0}")]
    Json(#[from] Box<serde_json::Error>),
    #[error("failed to write SBOM to '{path}': {source}")]
    Io {
        path: std::path::PathBuf,
        #[source]
        source: std::io::Error,
    },
}

impl From<serde_json::Error> for SbomError {
    fn from(err: serde_json::Error) -> Self {
        SbomError::Json(Box::new(err))
    }
}

pub fn render_cyclonedx(
    packages: &[DiscoveredPackage],
    tool_version: &str,
    output_path: Option<&Path>,
) -> Result<(), SbomError> {
    let doc = cyclonedx_document(packages, tool_version);
    write_json(&doc, output_path)
}

pub fn render_spdx_json(
    packages: &[DiscoveredPackage],
    tool_version: &str,
    output_path: Option<&Path>,
) -> Result<(), SbomError> {
    let doc = spdx_document(packages, tool_version);
    write_json(&doc, output_path)
}

fn cyclonedx_document(packages: &[DiscoveredPackage], tool_version: &str) -> Value {
    let components: Vec<Value> = packages
        .iter()
        .map(|p| {
            let purl = package_url(p);
            json!({
                "type": "library",
                "bom-ref": purl,
                "name": p.name,
                "version": p.version,
                "purl": purl,
            })
        })
        .collect();

    json!({
        "bomFormat": "CycloneDX",
        "specVersion": SCHEMA_VERSION_CYCLONEDX,
        "version": 1,
        "metadata": {
            "tools": [{
                "vendor": TOOL_VENDOR,
                "name": TOOL_NAME,
                "version": tool_version,
            }],
        },
        "components": components,
    })
}

fn spdx_document(packages: &[DiscoveredPackage], tool_version: &str) -> Value {
    let spdx_packages: Vec<Value> = packages
        .iter()
        .enumerate()
        .map(|(idx, p)| {
            json!({
                "SPDXID": format!("SPDXRef-Package-{idx}"),
                "name": p.name,
                "versionInfo": p.version,
                "downloadLocation": "NOASSERTION",
                "filesAnalyzed": false,
                "externalRefs": [{
                    "referenceCategory": "PACKAGE-MANAGER",
                    "referenceType": "purl",
                    "referenceLocator": package_url(p),
                }],
            })
        })
        .collect();

    json!({
        "spdxVersion": SPEC_VERSION_SPDX,
        "dataLicense": "CC0-1.0",
        "SPDXID": "SPDXRef-DOCUMENT",
        "name": "rastray-sbom",
        "documentNamespace": format!("https://github.com/balangyaoejuspher/rastray/sbom/{tool_version}"),
        "creationInfo": {
            "creators": [format!("Tool: {TOOL_NAME}-{tool_version}")],
            "created": "1970-01-01T00:00:00Z",
        },
        "packages": spdx_packages,
    })
}

fn package_url(pkg: &DiscoveredPackage) -> String {
    let typ = match pkg.ecosystem {
        "crates.io" => "cargo",
        "npm" => "npm",
        "PyPI" => "pypi",
        "Go" => "golang",
        "RubyGems" => "gem",
        "Packagist" => "composer",
        "NuGet" => "nuget",
        "SwiftURL" => "swift",
        "Pub" => "pub",
        "Hex" => "hex",
        "Maven" => "maven",
        other => other,
    };
    if pkg.ecosystem == "Maven" {
        let name_path = pkg.name.replacen(':', "/", 1);
        return format!("pkg:{typ}/{}@{}", name_path, pkg.version);
    }
    format!("pkg:{typ}/{}@{}", pkg.name, pkg.version)
}

fn write_json(doc: &Value, output_path: Option<&Path>) -> Result<(), SbomError> {
    let payload = serde_json::to_string_pretty(doc)?;
    match output_path {
        Some(path) => std::fs::write(path, &payload).map_err(|source| SbomError::Io {
            path: path.to_path_buf(),
            source,
        }),
        None => {
            println!("{payload}");
            Ok(())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::PathBuf;

    fn sample_packages() -> Vec<DiscoveredPackage> {
        vec![
            DiscoveredPackage {
                ecosystem: "crates.io",
                name: "serde".to_string(),
                version: "1.0.0".to_string(),
                source: PathBuf::from("Cargo.lock"),
            },
            DiscoveredPackage {
                ecosystem: "npm",
                name: "lodash".to_string(),
                version: "4.17.21".to_string(),
                source: PathBuf::from("package-lock.json"),
            },
        ]
    }

    #[test]
    fn cyclonedx_has_required_top_level_fields() {
        let doc = cyclonedx_document(&sample_packages(), "0.2.0");
        assert_eq!(doc["bomFormat"], "CycloneDX");
        assert_eq!(doc["specVersion"], "1.5");
        assert_eq!(doc["components"].as_array().map(Vec::len), Some(2));
    }

    #[test]
    fn cyclonedx_component_has_purl() {
        let doc = cyclonedx_document(&sample_packages(), "0.2.0");
        let first = &doc["components"][0];
        assert_eq!(first["purl"], "pkg:cargo/serde@1.0.0");
        assert_eq!(first["name"], "serde");
        assert_eq!(first["version"], "1.0.0");
    }

    #[test]
    fn spdx_has_required_top_level_fields() {
        let doc = spdx_document(&sample_packages(), "0.2.0");
        assert_eq!(doc["spdxVersion"], "SPDX-2.3");
        assert_eq!(doc["SPDXID"], "SPDXRef-DOCUMENT");
        assert_eq!(doc["packages"].as_array().map(Vec::len), Some(2));
    }

    #[test]
    fn spdx_package_has_purl_external_ref() {
        let doc = spdx_document(&sample_packages(), "0.2.0");
        let first = &doc["packages"][0];
        assert_eq!(first["name"], "serde");
        assert_eq!(first["versionInfo"], "1.0.0");
        let purl = &first["externalRefs"][0]["referenceLocator"];
        assert_eq!(purl, "pkg:cargo/serde@1.0.0");
    }

    #[test]
    fn package_url_npm_translates_ecosystem() {
        let pkg = DiscoveredPackage {
            ecosystem: "npm",
            name: "lodash".to_string(),
            version: "4.17.21".to_string(),
            source: PathBuf::from("package-lock.json"),
        };
        assert_eq!(package_url(&pkg), "pkg:npm/lodash@4.17.21");
    }

    #[test]
    fn package_url_pypi_translates_ecosystem() {
        let pkg = DiscoveredPackage {
            ecosystem: "PyPI",
            name: "requests".to_string(),
            version: "2.31.0".to_string(),
            source: PathBuf::from("requirements.txt"),
        };
        assert_eq!(package_url(&pkg), "pkg:pypi/requests@2.31.0");
    }

    #[test]
    fn package_url_go_translates_ecosystem() {
        let pkg = DiscoveredPackage {
            ecosystem: "Go",
            name: "github.com/foo/bar".to_string(),
            version: "v1.0.0".to_string(),
            source: PathBuf::from("go.sum"),
        };
        assert_eq!(package_url(&pkg), "pkg:golang/github.com/foo/bar@v1.0.0");
    }
}