use std::path::PathBuf;
use std::sync::Arc;
use traits::ArtifactId;
use crate::promote::{coord_key, sanitize_repo};
pub use modgunn::sbom::{purl, to_cyclonedx, SbomComponent, SbomInput, SbomVuln};
pub fn build_cyclonedx(input: &SbomInput) -> Vec<u8> {
serde_json::to_vec(&to_cyclonedx(input)).expect("CycloneDX Value always serializes")
}
pub struct SbomStore {
root: PathBuf,
}
impl SbomStore {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
fn doc_path(&self, repo: &str, id: &ArtifactId) -> PathBuf {
self.root
.join(sanitize_repo(repo))
.join(format!("{}.sbom.json", coord_key(id)))
}
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())
}
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),
}
}
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(),
}
}
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");
assert_eq!(addr, blake3::hash(&doc).to_hex().to_string());
assert!(store.has("rust-dev", &coord));
}
#[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));
}
#[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();
assert!(store.fetch("rust-dev", &id(None, "serde", "1.0.1")).unwrap().is_none());
assert!(store.fetch("other-repo", &id(None, "serde", "1.0.0")).unwrap().is_none());
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()),
);
}
#[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:?}",
);
store.attach("../../../tmp/pwn", &evil, b"contained").unwrap();
assert_eq!(
store.fetch("../../../tmp/pwn", &evil).unwrap().as_deref(),
Some(b"contained".as_slice()),
);
}
#[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);
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");
}
}
pub type SharedSbomStore = Arc<SbomStore>;