holger-server-lib 0.6.9

Holger server library: config, wiring, gRPC service, Rust API
//! SBOM **hosting** — attach + serve SBOM documents as first-class servable
//! artifacts (parity §6/§11, `.nornir/sbom-hosting-design.md`).
//!
//! Until now SBOMs were only *generated* (in nornir, `security.rs`); holger could
//! not host one. This module lets holger STORE a CycloneDX SBOM document attached
//! to an artifact coordinate `(repository, namespace/name/version)` and serve it
//! back byte-identical over three surfaces: the `SbomService` gRPC RPCs, the
//! read-only `GET /-/sbom` HTTP door, and the `holger-server sbom` CLI verb — the
//! exact tri-surface shape the cross-repo search verb landed with.
//!
//! **Model reuse (Law #5).** The SBOM data model is NOT re-declared here — it is
//! CONSUMED from **modgunn**, the single home of the nordisk SBOM schema. This
//! module re-exports [`SbomComponent`] / [`SbomInput`] / [`SbomVuln`] / [`purl`] /
//! [`to_cyclonedx`] verbatim and adds exactly one thin seam, [`build_cyclonedx`],
//! that serializes modgunn's emitted document to the bytes the store hosts. A
//! producer that already holds a ready CycloneDX document attaches its raw bytes
//! directly; a producer holding the structured model builds through modgunn here.
//! Either way holger never forks the schema.
//!
//! **Storage.** A flat on-disk store rooted at `<data_root>/holger-sboms`, laid
//! out `holger-sboms/<repo>/<coord>.sbom.json`. The repo name and the coordinate
//! are reduced to path-safe single segments by promote's [`sanitize_repo`] /
//! [`coord_key`] (reused, not twinned), so an untrusted coordinate off the wire
//! can never escape the store root (path-traversal closed). Attach is last-wins
//! (an SBOM may be regenerated + re-attached); a missing coordinate fetches as
//! `None` (not-found, never an error).

use std::path::PathBuf;
use std::sync::Arc;

use traits::ArtifactId;

use crate::promote::{coord_key, sanitize_repo};

// ── Model reuse (Law #5): consume modgunn's SBOM schema, never twin it. ──────
pub use modgunn::sbom::{purl, to_cyclonedx, SbomComponent, SbomInput, SbomVuln};

/// Serialize an SBOM (built from the shared modgunn model) into the bytes the
/// store hosts: a CycloneDX 1.5 JSON document. This is holger's ONE SBOM
/// serialization seam — it emits `modgunn::sbom::to_cyclonedx`, so the hosted
/// schema is exactly modgunn's and can never drift. A structured producer builds
/// its document through here; a producer holding a ready CycloneDX doc attaches
/// its raw bytes.
pub fn build_cyclonedx(input: &SbomInput) -> Vec<u8> {
    // `to_cyclonedx` returns a `serde_json::Value` built from literals; it always
    // serializes.
    serde_json::to_vec(&to_cyclonedx(input)).expect("CycloneDX Value always serializes")
}

/// The on-disk store of hosted SBOM documents.
///
/// Layout under `root`:
/// ```text
/// <repo>/<coord>.sbom.json    — the hosted SBOM document bytes (last-wins)
/// ```
/// `<repo>` and `<coord>` are path-safe single segments (see module docs), so a
/// crafted coordinate can never write or read outside `root`.
pub struct SbomStore {
    root: PathBuf,
}

impl SbomStore {
    /// A store rooted at `root` (directories are created on first attach). The
    /// server roots it at `<data_root>/holger-sboms`; the CLI derives the same
    /// path from the config's audit dir, so all surfaces share one store.
    pub fn new(root: impl Into<PathBuf>) -> Self {
        Self { root: root.into() }
    }

    /// The on-disk path hosting the SBOM for `(repo, id)`. Both components are
    /// sanitized to exactly one path segment, so the result always stays a direct
    /// child of `root/<repo>/` — path traversal is structurally impossible.
    fn doc_path(&self, repo: &str, id: &ArtifactId) -> PathBuf {
        self.root
            .join(sanitize_repo(repo))
            .join(format!("{}.sbom.json", coord_key(id)))
    }

    /// Attach (host) an SBOM `document` for the coordinate `(repo, id)`. Writes
    /// the raw bytes (last-wins: a re-attach overwrites), and returns the blake3
    /// hex content address of the document. The write is staged to a sibling temp
    /// file and renamed into place so a concurrent fetch never observes a
    /// half-written document.
    pub fn attach(&self, repo: &str, id: &ArtifactId, document: &[u8]) -> std::io::Result<String> {
        let path = self.doc_path(repo, id);
        if let Some(dir) = path.parent() {
            std::fs::create_dir_all(dir)?;
        }
        let tmp = path.with_extension("json.tmp");
        std::fs::write(&tmp, document)?;
        std::fs::rename(&tmp, &path)?;
        Ok(blake3::hash(document).to_hex().to_string())
    }

    /// Fetch the hosted SBOM document for `(repo, id)`, byte-identical to what was
    /// attached. `Ok(None)` when no SBOM is attached to that coordinate (a missing
    /// SBOM is not-found, never an I/O error).
    pub fn fetch(&self, repo: &str, id: &ArtifactId) -> std::io::Result<Option<Vec<u8>>> {
        match std::fs::read(self.doc_path(repo, id)) {
            Ok(bytes) => Ok(Some(bytes)),
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
            Err(e) => Err(e),
        }
    }

    /// Whether an SBOM is attached to `(repo, id)` (a cheap existence probe).
    pub fn has(&self, repo: &str, id: &ArtifactId) -> bool {
        self.doc_path(repo, id).exists()
    }
}

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

    fn id(ns: Option<&str>, name: &str, ver: &str) -> ArtifactId {
        ArtifactId {
            namespace: ns.map(str::to_string),
            name: name.into(),
            version: ver.into(),
        }
    }

    /// A CycloneDX document built through the shared modgunn model, used as the
    /// hosted payload in the round-trip tests.
    fn sample_doc() -> Vec<u8> {
        build_cyclonedx(&SbomInput {
            subject: "rust-dev".into(),
            components: vec![SbomComponent {
                ecosystem: "cargo".into(),
                name: "serde".into(),
                version: "1.0.0".into(),
                license: Some("MIT OR Apache-2.0".into()),
            }],
            vulns: vec![],
        })
    }

    #[test]
    fn attach_then_fetch_is_byte_identical() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "serde", "1.0.0");
        let doc = sample_doc();

        let addr = store.attach("rust-dev", &coord, &doc).unwrap();
        let got = store.fetch("rust-dev", &coord).unwrap();
        assert_eq!(got.as_deref(), Some(doc.as_slice()), "fetch returns the exact attached bytes");
        // The content address is the blake3 of the hosted bytes.
        assert_eq!(addr, blake3::hash(&doc).to_hex().to_string());
        assert!(store.has("rust-dev", &coord));
    }

    /// RED-when-broken: a coordinate with NO attached SBOM must fetch as `None`,
    /// never leak another coordinate's document or error.
    #[test]
    fn missing_sbom_is_not_found() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(None, "never-attached", "9.9.9");
        assert!(store.fetch("rust-dev", &coord).unwrap().is_none());
        assert!(!store.has("rust-dev", &coord));
    }

    /// RED-when-broken: an SBOM attached to coordinate A must NOT be returned for
    /// a DIFFERENT coordinate B (a broken key would leak A's SBOM to every fetch).
    #[test]
    fn wrong_coordinate_does_not_match() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let doc = sample_doc();
        store.attach("rust-dev", &id(None, "serde", "1.0.0"), &doc).unwrap();

        // Same name, different version → no match.
        assert!(store.fetch("rust-dev", &id(None, "serde", "1.0.1")).unwrap().is_none());
        // Same coordinate, different repo → no match (repo is part of the key).
        assert!(store.fetch("other-repo", &id(None, "serde", "1.0.0")).unwrap().is_none());
        // Different name → no match.
        assert!(store.fetch("rust-dev", &id(None, "tokio", "1.0.0")).unwrap().is_none());
    }

    #[test]
    fn re_attach_overwrites_last_wins() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let coord = id(Some("org.example"), "widget", "2.3.4");
        store.attach("maven-dev", &coord, b"first").unwrap();
        store.attach("maven-dev", &coord, b"second-and-final").unwrap();
        assert_eq!(
            store.fetch("maven-dev", &coord).unwrap().as_deref(),
            Some(b"second-and-final".as_slice()),
        );
    }

    /// A hostile repo name / coordinate laced with path separators and `..` must
    /// NOT escape the store root — the sanitized path stays a direct grandchild of
    /// `root`, and it round-trips like any other coordinate.
    #[test]
    fn hostile_coordinate_cannot_escape_the_root() {
        let tmp = tempfile::tempdir().unwrap();
        let store = SbomStore::new(tmp.path());
        let evil = id(Some("../../.."), "../../etc/passwd", "../x");
        let path = store.doc_path("../../../tmp/pwn", &evil);
        assert!(
            path.starts_with(tmp.path()),
            "sanitized SBOM path must stay under the store root: {path:?}",
        );
        // And it still stores + fetches by that (safe) coordinate.
        store.attach("../../../tmp/pwn", &evil, b"contained").unwrap();
        assert_eq!(
            store.fetch("../../../tmp/pwn", &evil).unwrap().as_deref(),
            Some(b"contained".as_slice()),
        );
    }

    /// The hosting seam emits modgunn's schema verbatim — `build_cyclonedx` is
    /// exactly `serde_json::to_vec(modgunn::sbom::to_cyclonedx(..))`. RED-when-broken
    /// if holger ever forks the SBOM model instead of consuming modgunn's (Law #5).
    #[test]
    fn build_cyclonedx_is_modgunn_schema_verbatim() {
        let input = SbomInput {
            subject: "app".into(),
            components: vec![SbomComponent {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                license: None,
            }],
            vulns: vec![SbomVuln {
                ecosystem: "npm".into(),
                name: "left-pad".into(),
                version: "1.3.0".into(),
                ids: vec!["OSV-2020-0001".into()],
                summary: "example".into(),
            }],
        };
        let want = serde_json::to_vec(&modgunn::sbom::to_cyclonedx(&input)).unwrap();
        assert_eq!(build_cyclonedx(&input), want);
        // Sanity: the emitted document really is a CycloneDX BOM with the purl.
        let v: serde_json::Value = serde_json::from_slice(&build_cyclonedx(&input)).unwrap();
        assert_eq!(v["bomFormat"], "CycloneDX");
        assert_eq!(v["components"][0]["purl"], "pkg:npm/left-pad@1.3.0");
    }
}

/// A shared `Arc<SbomStore>` handle for wiring into the gRPC state + HTTP gateway.
pub type SharedSbomStore = Arc<SbomStore>;