fraiseql-cli 2.3.2

CLI tools for FraiseQL v2 - Schema compilation and development utilities
Documentation
//! `fraiseql sbom` - Software Bill of Materials generator
//!
//! Generates SBOM in CycloneDX JSON or SPDX format by parsing
//! Cargo.lock for Rust dependencies and fraiseql.toml for project metadata.

use std::{fmt, fs, path::Path, str::FromStr};

use anyhow::{Context, Result};
use serde::Deserialize;
use tracing::info;

/// Output format for SBOM
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SbomFormat {
    /// CycloneDX JSON format (default)
    CycloneDx,
    /// SPDX JSON format
    Spdx,
}

impl fmt::Display for SbomFormat {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::CycloneDx => write!(f, "cyclonedx"),
            Self::Spdx => write!(f, "spdx"),
        }
    }
}

impl FromStr for SbomFormat {
    type Err = String;

    fn from_str(s: &str) -> std::result::Result<Self, Self::Err> {
        match s.to_lowercase().as_str() {
            "cyclonedx" | "cdx" => Ok(Self::CycloneDx),
            "spdx" => Ok(Self::Spdx),
            other => Err(format!("Unknown SBOM format: {other}. Choose: cyclonedx, spdx")),
        }
    }
}

/// Parsed Cargo.lock package entry
#[derive(Debug, Deserialize)]
pub(crate) struct CargoLockPackage {
    pub(crate) name:    String,
    pub(crate) version: String,
    pub(crate) source:  Option<String>,
}

/// Parsed Cargo.lock file
#[derive(Debug, Deserialize)]
struct CargoLock {
    package: Vec<CargoLockPackage>,
}

/// Run the SBOM command
///
/// # Errors
///
/// Returns an error if `Cargo.lock` cannot be found or parsed, if SBOM
/// serialization fails, or if the output file cannot be written.
pub fn run(format: SbomFormat, output: Option<&str>) -> Result<()> {
    info!("Generating SBOM in {format} format");

    // Load project metadata from fraiseql.toml (optional)
    let (project_name, project_version) = load_project_metadata();

    // Parse Cargo.lock
    let packages = parse_cargo_lock()?;

    // Generate SBOM
    let sbom = match format {
        SbomFormat::CycloneDx => generate_cyclonedx(&project_name, &project_version, &packages)?,
        SbomFormat::Spdx => generate_spdx(&project_name, &project_version, &packages)?,
    };

    // Output
    match output {
        Some(path) => {
            fs::write(path, &sbom).context(format!("Failed to write SBOM to {path}"))?;
            println!("SBOM written to {path}");
        },
        None => {
            println!("{sbom}");
        },
    }

    Ok(())
}

fn load_project_metadata() -> (String, String) {
    // Try fraiseql.toml [project] first
    let toml_path = Path::new("fraiseql.toml");
    if toml_path.exists() {
        if let Ok(content) = fs::read_to_string(toml_path) {
            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
                let name =
                    parsed.get("project").and_then(|p| p.get("name")).and_then(toml::Value::as_str);
                let version = parsed
                    .get("project")
                    .and_then(|p| p.get("version"))
                    .and_then(toml::Value::as_str);
                if name.is_some() || version.is_some() {
                    return (
                        name.unwrap_or("unknown").to_string(),
                        version.unwrap_or("0.0.0").to_string(),
                    );
                }
            }
        }
    }

    // Fall back to workspace Cargo.toml
    if let Ok(lock_path) = find_cargo_lock() {
        let cargo_toml_path = lock_path.with_file_name("Cargo.toml");
        if let Ok(content) = fs::read_to_string(&cargo_toml_path) {
            if let Ok(parsed) = toml::from_str::<toml::Value>(&content) {
                let name = parsed
                    .get("package")
                    .and_then(|p| p.get("name"))
                    .and_then(toml::Value::as_str)
                    .or_else(|| {
                        parsed
                            .get("workspace")
                            .and_then(|w| w.get("package"))
                            .and_then(|p| p.get("name"))
                            .and_then(toml::Value::as_str)
                    })
                    .map_or_else(
                        || {
                            cargo_toml_path
                                .parent()
                                .and_then(|p| p.file_name())
                                .and_then(|n| n.to_str())
                                .unwrap_or("unknown")
                                .to_string()
                        },
                        String::from,
                    );
                let version = parsed
                    .get("package")
                    .and_then(|p| p.get("version"))
                    .and_then(toml::Value::as_str)
                    .or_else(|| {
                        parsed
                            .get("workspace")
                            .and_then(|w| w.get("package"))
                            .and_then(|p| p.get("version"))
                            .and_then(toml::Value::as_str)
                    })
                    .unwrap_or("0.0.0")
                    .to_string();
                return (name, version);
            }
        }
    }

    ("unknown".to_string(), "0.0.0".to_string())
}

fn parse_cargo_lock() -> Result<Vec<CargoLockPackage>> {
    // Search for Cargo.lock in current dir or parent dirs
    let lock_path = find_cargo_lock()?;

    let content = fs::read_to_string(&lock_path)
        .context(format!("Failed to read {}", lock_path.display()))?;

    parse_cargo_lock_content(&content)
}

pub(crate) fn parse_cargo_lock_content(content: &str) -> Result<Vec<CargoLockPackage>> {
    let lock: CargoLock = toml::from_str(content).context("Failed to parse Cargo.lock")?;
    Ok(lock.package)
}

pub(crate) fn find_cargo_lock() -> Result<std::path::PathBuf> {
    let mut dir = std::env::current_dir().context("Failed to get current directory")?;

    loop {
        let candidate = dir.join("Cargo.lock");
        if candidate.exists() {
            return Ok(candidate);
        }

        if !dir.pop() {
            break;
        }
    }

    anyhow::bail!(
        "Cargo.lock not found. Run from a Rust project directory or a subdirectory of one."
    )
}

pub(crate) fn generate_cyclonedx(
    project_name: &str,
    project_version: &str,
    packages: &[CargoLockPackage],
) -> Result<String> {
    let components: Vec<serde_json::Value> = packages
        .iter()
        .map(|pkg| {
            let mut component = serde_json::json!({
                "type": "library",
                "name": pkg.name,
                "version": pkg.version,
                "purl": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
            });

            if let Some(source) = &pkg.source {
                if source.contains("registry") {
                    component["externalReferences"] = serde_json::json!([{
                        "type": "distribution",
                        "url": format!("https://crates.io/crates/{}", pkg.name),
                    }]);
                }
            }

            component
        })
        .collect();

    let sbom = serde_json::json!({
        "bomFormat": "CycloneDX",
        "specVersion": "1.5",
        "version": 1,
        "metadata": {
            "component": {
                "type": "application",
                "name": project_name,
                "version": project_version,
            },
            "tools": [{
                "vendor": "FraiseQL",
                "name": "fraiseql-cli",
                "version": env!("CARGO_PKG_VERSION"),
            }],
        },
        "components": components,
    });

    serde_json::to_string_pretty(&sbom).context("Failed to serialize CycloneDX SBOM")
}

pub(crate) fn generate_spdx(
    project_name: &str,
    project_version: &str,
    packages: &[CargoLockPackage],
) -> Result<String> {
    let spdx_packages: Vec<serde_json::Value> = packages
        .iter()
        .enumerate()
        .map(|(i, pkg)| {
            serde_json::json!({
                "SPDXID": format!("SPDXRef-Package-{}", i + 1),
                "name": pkg.name,
                "versionInfo": pkg.version,
                "downloadLocation": pkg.source.as_deref().unwrap_or("NOASSERTION"),
                "filesAnalyzed": false,
                "externalRefs": [{
                    "referenceCategory": "PACKAGE-MANAGER",
                    "referenceType": "purl",
                    "referenceLocator": format!("pkg:cargo/{}@{}", pkg.name, pkg.version),
                }],
            })
        })
        .collect();

    let relationships: Vec<serde_json::Value> = packages
        .iter()
        .enumerate()
        .map(|(i, _)| {
            serde_json::json!({
                "spdxElementId": "SPDXRef-DOCUMENT",
                "relatedSpdxElement": format!("SPDXRef-Package-{}", i + 1),
                "relationshipType": "DESCRIBES",
            })
        })
        .collect();

    let sbom = serde_json::json!({
        "spdxVersion": "SPDX-2.3",
        "dataLicense": "CC0-1.0",
        "SPDXID": "SPDXRef-DOCUMENT",
        "name": format!("{project_name}-{project_version}"),
        "documentNamespace": format!("https://spdx.org/spdxdocs/{project_name}-{project_version}"),
        "creationInfo": {
            "created": chrono_now_utc(),
            "creators": [
                format!("Tool: fraiseql-cli-{}", env!("CARGO_PKG_VERSION")),
            ],
        },
        "packages": spdx_packages,
        "relationships": relationships,
    });

    serde_json::to_string_pretty(&sbom).context("Failed to serialize SPDX SBOM")
}

/// Get current UTC timestamp in ISO 8601 format without external chrono dependency
pub(crate) fn chrono_now_utc() -> String {
    // Use std::time to get a basic timestamp
    let now = std::time::SystemTime::now();
    let duration = now.duration_since(std::time::UNIX_EPOCH).unwrap_or_default();
    let secs = duration.as_secs();

    // Convert to date components (simplified)
    let days = secs / 86400;
    let remaining = secs % 86400;
    let hours = remaining / 3600;
    let minutes = (remaining % 3600) / 60;
    let seconds = remaining % 60;

    // Calculate year/month/day from days since epoch (1970-01-01)
    let (year, month, day) = days_to_date(days);

    format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z")
}

/// Convert days since Unix epoch to (year, month, day)
pub(crate) const fn days_to_date(days: u64) -> (u64, u64, u64) {
    // Algorithm from http://howardhinnant.github.io/date_algorithms.html
    let z = days + 719_468;
    let era = z / 146_097;
    let doe = z - era * 146_097;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
    let y = yoe + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y, m, d)
}