use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use traits::ArtifactId;
use crate::security::{self, Decision, OsvScanner, ScanEngine};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Enforcement {
Warn,
Block,
}
pub struct PolicyGate {
scanner: OsvScanner,
enforcement: Enforcement,
}
impl PolicyGate {
pub fn new(scanner: OsvScanner, enforcement: Enforcement) -> Self {
Self { scanner, enforcement }
}
pub fn from_env() -> anyhow::Result<Option<PolicyGate>> {
let enforcement = match std::env::var("HOLGER_PROMOTE_ENFORCE") {
Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
"block" => Enforcement::Block,
"warn" => Enforcement::Warn,
"" | "off" => return Ok(None),
other => {
anyhow::bail!("HOLGER_PROMOTE_ENFORCE must be block|warn|off, got '{other}'")
}
},
Err(_) => return Ok(None),
};
let db = match std::env::var("HOLGER_PROMOTE_ADVISORY_DB") {
Ok(path) if !path.trim().is_empty() => {
security::advisory_db_from_osv_file(path.trim())? }
_ => security::AdvisoryDb::default(), };
let deny_licenses = std::env::var("HOLGER_PROMOTE_DENY_LICENSES")
.ok()
.map(|s| {
s.split(',')
.map(|x| x.trim().to_string())
.filter(|x| !x.is_empty())
.collect::<Vec<_>>()
})
.unwrap_or_default();
let scanner = OsvScanner::new(security::promotion_policy(deny_licenses), db);
Ok(Some(PolicyGate::new(scanner, enforcement)))
}
pub fn evaluate(&self, ecosystem: &str, id: &ArtifactId) -> (Decision, Vec<String>) {
let art = security::artifact_from_coords(ecosystem, &id.name, &id.version, false);
let verdict = self.scanner.scan(&art);
let reasons = verdict
.findings
.iter()
.map(|f| format!("{} [{:?}] {}", f.id, f.severity, f.summary))
.collect();
(verdict.decision, reasons)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PromotionRecord {
pub artifact: String,
pub from_repo: String,
pub to_repo: String,
pub content_address: String,
pub size: u64,
pub promoted_by: String,
pub promoted_at: i64,
}
#[derive(Debug)]
pub enum PromoteError {
AlreadyPromoted,
PolicyBlocked(Vec<String>),
Io(std::io::Error),
}
impl std::fmt::Display for PromoteError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PromoteError::AlreadyPromoted => {
write!(f, "artifact already promoted (immutable — overwrite refused)")
}
PromoteError::PolicyBlocked(reasons) => {
write!(f, "promotion blocked by policy gate: {}", reasons.join("; "))
}
PromoteError::Io(e) => write!(f, "promotion store io error: {e}"),
}
}
}
impl std::error::Error for PromoteError {}
impl From<std::io::Error> for PromoteError {
fn from(e: std::io::Error) -> Self {
PromoteError::Io(e)
}
}
fn artifact_label(id: &ArtifactId) -> String {
match id.namespace.as_deref() {
Some(ns) if !ns.is_empty() => format!("{}/{}@{}", ns, id.name, id.version),
_ => format!("{}@{}", id.name, id.version),
}
}
pub(crate) fn coord_key(id: &ArtifactId) -> String {
let sanitize = |s: &str| {
s.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
.collect::<String>()
};
format!(
"{}__{}__{}",
sanitize(id.namespace.as_deref().unwrap_or("")),
sanitize(&id.name),
sanitize(&id.version),
)
}
pub(crate) fn sanitize_repo(to_repo: &str) -> String {
let s: String = to_repo
.chars()
.map(|c| if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') { c } else { '_' })
.collect();
match s.as_str() {
"" | "." | ".." => "_".to_string(),
_ => s,
}
}
pub struct PromotionEngine {
prod_root: PathBuf,
gate: Option<PolicyGate>,
jobs: Option<Arc<jera::jobs::JobStore>>,
require_property: Option<(String, String)>,
}
impl PromotionEngine {
pub fn new(prod_root: impl Into<PathBuf>) -> Self {
Self { prod_root: prod_root.into(), gate: None, jobs: None, require_property: None }
}
pub fn with_required_property(mut self, key: String, value: String) -> Self {
self.require_property = Some((key, value));
self
}
pub fn required_property(&self) -> Option<&(String, String)> {
self.require_property.as_ref()
}
pub fn parse_property_requirement(raw: &str) -> Option<(String, String)> {
crate::properties::parse_predicate(raw)
}
pub fn property_requirement_met(&self, props: &crate::properties::PropertyMap) -> bool {
match &self.require_property {
Some((key, value)) => crate::properties::map_matches(props, key, value),
None => true,
}
}
pub fn with_gate(mut self, gate: PolicyGate) -> Self {
self.gate = Some(gate);
self
}
pub fn with_job_ledger(mut self, store: Arc<jera::jobs::JobStore>) -> Self {
self.jobs = Some(store);
self
}
pub fn has_gate(&self) -> bool {
self.gate.is_some()
}
pub fn has_job_ledger(&self) -> bool {
self.jobs.is_some()
}
pub fn gate_check(&self, ecosystem: &str, id: &ArtifactId) -> Result<(), PromoteError> {
let Some(gate) = self.gate.as_ref() else {
return Ok(()); };
let (decision, reasons) = gate.evaluate(ecosystem, id);
match (gate.enforcement, decision) {
(Enforcement::Block, Decision::Block) => Err(PromoteError::PolicyBlocked(reasons)),
_ => Ok(()),
}
}
fn blob_path(&self, hash: &str) -> PathBuf {
self.prod_root.join("blobs").join(hash)
}
fn ref_path(&self, to_repo: &str, id: &ArtifactId) -> PathBuf {
self.prod_root
.join("refs")
.join(sanitize_repo(to_repo))
.join(format!("{}.ref", coord_key(id)))
}
pub fn is_promoted(&self, to_repo: &str, id: &ArtifactId) -> bool {
self.ref_path(to_repo, id).exists()
}
pub fn seal(
&self,
from_repo: &str,
to_repo: &str,
id: &ArtifactId,
data: &[u8],
promoted_by: &str,
promoted_at: i64,
) -> Result<PromotionRecord, PromoteError> {
let job = self.jobs.as_ref().map(|store| {
store.start(
"promote",
&artifact_label(id),
to_repo,
serde_json::json!({
"from_repo": from_repo,
"to_repo": to_repo,
"size": data.len(),
"promoted_by": promoted_by,
}),
)
});
let out = self.seal_inner(from_repo, to_repo, id, data, promoted_by, promoted_at);
if let Some(job) = job {
match &out {
Ok(rec) => job.finish(
serde_json::json!({
"content_address": rec.content_address,
"size": rec.size,
}),
&rec.content_address,
),
Err(e) => job.fail_with_detail(serde_json::json!({ "error": e.to_string() })),
}
}
out
}
fn seal_inner(
&self,
from_repo: &str,
to_repo: &str,
id: &ArtifactId,
data: &[u8],
promoted_by: &str,
promoted_at: i64,
) -> Result<PromotionRecord, PromoteError> {
let content_address = blake3::hash(data).to_hex().to_string();
let ref_path = self.ref_path(to_repo, id);
if ref_path.exists() {
return Err(PromoteError::AlreadyPromoted);
}
let blob_path = self.blob_path(&content_address);
if let Some(parent) = blob_path.parent() {
fs::create_dir_all(parent)?;
}
if !blob_path.exists() {
let tmp = blob_path.with_extension("tmp");
fs::write(&tmp, data)?;
fs::rename(&tmp, &blob_path)?;
}
let record = PromotionRecord {
artifact: artifact_label(id),
from_repo: from_repo.to_string(),
to_repo: to_repo.to_string(),
content_address,
size: data.len() as u64,
promoted_by: promoted_by.to_string(),
promoted_at,
};
if let Some(parent) = ref_path.parent() {
fs::create_dir_all(parent)?;
}
let json = record.to_json();
match fs::OpenOptions::new().write(true).create_new(true).open(&ref_path) {
Ok(mut f) => {
use std::io::Write;
f.write_all(json.as_bytes())?;
f.flush()?;
}
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
return Err(PromoteError::AlreadyPromoted);
}
Err(e) => return Err(PromoteError::Io(e)),
}
Ok(record)
}
pub fn fetch(&self, to_repo: &str, id: &ArtifactId) -> Result<Option<Vec<u8>>, PromoteError> {
let ref_path = self.ref_path(to_repo, id);
if !ref_path.exists() {
return Ok(None);
}
let ref_json = fs::read_to_string(&ref_path)?;
let want = PromotionRecord::content_address_from_json(&ref_json)
.ok_or_else(|| PromoteError::Io(std::io::Error::other("corrupt ref: no content_address")))?;
let data = fs::read(self.blob_path(&want))?;
let got = blake3::hash(&data).to_hex().to_string();
if got != want {
return Err(PromoteError::Io(std::io::Error::other(
"integrity: promoted blob blake3 does not match the sealed ref",
)));
}
Ok(Some(data))
}
}
impl PromotionRecord {
fn to_json(&self) -> String {
let esc = |s: &str| s.replace('\\', "\\\\").replace('"', "\\\"");
format!(
r#"{{"artifact":"{}","from_repo":"{}","to_repo":"{}","content_address":"{}","size":{},"promoted_by":"{}","promoted_at":{}}}"#,
esc(&self.artifact),
esc(&self.from_repo),
esc(&self.to_repo),
esc(&self.content_address),
self.size,
esc(&self.promoted_by),
self.promoted_at,
)
}
fn content_address_from_json(json: &str) -> Option<String> {
let needle = "\"content_address\":\"";
let start = json.find(needle)? + needle.len();
let rest = &json[start..];
let end = rest.find('"')?;
Some(rest[..end].to_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn id(name: &str, version: &str) -> ArtifactId {
ArtifactId { namespace: None, name: name.into(), version: version.into() }
}
#[test]
fn seal_transitions_and_content_addresses() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path());
let data = b"the-promoted-crate-bytes";
let expected = blake3::hash(data).to_hex().to_string();
let rec = engine
.seal("rust-dev", "rust-prod", &id("serde", "1.0.0"), data, "alice@corp", 1_700_000_000)
.expect("seal ok");
assert_eq!(rec.content_address, expected, "record carries the blake3 content address");
assert_eq!(rec.size, data.len() as u64);
assert_eq!(rec.from_repo, "rust-dev");
assert_eq!(rec.to_repo, "rust-prod");
assert_eq!(rec.promoted_by, "alice@corp");
assert!(dir.path().join("blobs").join(&expected).is_file(), "blob stored under blake3 hash");
let back = engine.fetch("rust-prod", &id("serde", "1.0.0")).unwrap();
assert_eq!(back.as_deref(), Some(data.as_slice()));
}
#[test]
fn promoted_artifact_is_immutable() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path());
engine
.seal("dev", "prod", &id("tokio", "1.38.0"), b"v1", "admin", 1)
.expect("first seal ok");
assert!(engine.is_promoted("prod", &id("tokio", "1.38.0")));
let err = engine
.seal("dev", "prod", &id("tokio", "1.38.0"), b"tampered", "admin", 2)
.expect_err("re-promote must be refused");
assert!(matches!(err, PromoteError::AlreadyPromoted));
let err = engine
.seal("dev", "prod", &id("tokio", "1.38.0"), b"v1", "admin", 3)
.expect_err("re-promote must be refused even if identical");
assert!(matches!(err, PromoteError::AlreadyPromoted));
assert_eq!(engine.fetch("prod", &id("tokio", "1.38.0")).unwrap().as_deref(), Some(b"v1".as_slice()));
}
#[test]
fn crafted_to_repo_cannot_escape_prod_root() {
let root = tempfile::tempdir().unwrap();
let prod = root.path().join("prod");
let engine = PromotionEngine::new(&prod);
let outside = root.path().join("outside");
fs::create_dir_all(&outside).unwrap();
for (i, evil) in ["../../../../outside/pwn", "..", ".", "a/b/c", "../outside"]
.into_iter()
.enumerate()
{
let coord = id("serde", &format!("1.0.{i}"));
let rec = engine
.seal(evil, evil, &coord, b"bytes", "attacker", 1)
.expect("seal returns (path is contained, not that it fails)");
assert_eq!(rec.to_repo, evil);
let ref_path = engine.ref_path(evil, &coord);
let canon = ref_path.canonicalize().expect("ref exists");
assert!(
canon.starts_with(prod.canonicalize().unwrap()),
"ref for {evil:?} escaped prod_root: {canon:?}"
);
}
assert_eq!(fs::read_dir(&outside).unwrap().count(), 0, "no file escaped prod_root");
}
fn ledger(root: &std::path::Path) -> Arc<jera::jobs::JobStore> {
Arc::new(jera::jobs::JobStore::open(root).expect("open jera job ledger"))
}
#[test]
fn seal_records_a_done_job_in_the_ledger() {
let store_dir = tempfile::tempdir().unwrap();
let led_dir = tempfile::tempdir().unwrap();
let store = ledger(led_dir.path());
let engine = PromotionEngine::new(store_dir.path()).with_job_ledger(store.clone());
assert!(engine.has_job_ledger());
let data = b"promoted-bytes";
let expected = blake3::hash(data).to_hex().to_string();
let rec = engine
.seal("rust-dev", "rust-prod", &id("serde", "1.0.0"), data, "alice@corp", 1)
.expect("seal ok");
assert_eq!(rec.content_address, expected);
let jobs = store.list(&jera::jobs::JobSelector::All).expect("list jobs");
assert_eq!(jobs.len(), 1, "exactly one promotion job recorded");
let job = &jobs[0];
assert_eq!(job.kind, "promote");
assert_eq!(job.status, "done", "successful seal settles done");
assert_eq!(job.target, artifact_label(&id("serde", "1.0.0")));
assert_eq!(job.workspace, "rust-prod");
assert_eq!(job.result_ref, expected, "done job carries the content address");
assert!(job.elapsed_ms.is_some(), "a terminal job has timing");
}
#[test]
fn refused_reseal_records_a_failed_job() {
let store_dir = tempfile::tempdir().unwrap();
let led_dir = tempfile::tempdir().unwrap();
let store = ledger(led_dir.path());
let engine = PromotionEngine::new(store_dir.path()).with_job_ledger(store.clone());
engine.seal("dev", "prod", &id("tokio", "1.38.0"), b"v1", "admin", 1).expect("first seal");
engine
.seal("dev", "prod", &id("tokio", "1.38.0"), b"v2", "admin", 2)
.expect_err("re-promote refused");
let jobs = store.list(&jera::jobs::JobSelector::All).expect("list jobs");
assert_eq!(jobs.len(), 2, "both attempts recorded");
let statuses: std::collections::BTreeSet<_> = jobs.iter().map(|j| j.status.as_str()).collect();
assert!(statuses.contains("done"), "the first seal is done");
assert!(statuses.contains("failed"), "the refused re-seal is failed");
}
#[test]
fn seal_without_ledger_writes_no_job() {
let store_dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(store_dir.path());
assert!(!engine.has_job_ledger());
engine.seal("dev", "prod", &id("anyhow", "1.0.0"), b"bytes", "admin", 1).expect("seal ok");
}
#[test]
fn identical_content_dedups_but_refs_are_distinct() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path());
let data = b"shared-bytes";
engine.seal("dev", "prod", &id("a", "1"), data, "admin", 1).unwrap();
engine.seal("dev", "prod", &id("b", "1"), data, "admin", 1).unwrap();
let blobs: Vec<_> = fs::read_dir(dir.path().join("blobs"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_none())
.collect();
assert_eq!(blobs.len(), 1, "identical content stored once");
assert!(engine.is_promoted("prod", &id("a", "1")));
assert!(engine.is_promoted("prod", &id("b", "1")));
}
use crate::security::{Advisory, AdvisoryDb, LicensePolicy, Policy, ProvenancePolicy, Severity};
fn real_blocking_scanner() -> OsvScanner {
let db = AdvisoryDb::new(
"holger-promote-gate-test-db",
vec![Advisory::basic(
"RUSTSEC-TEST-0001",
"cargo",
"badcrate",
vec!["1.0.0".into()],
Severity::High,
"test high-severity advisory",
)],
);
let policy = Policy {
version: "gate-test".into(),
license: LicensePolicy { allow: Vec::new(), deny: Vec::new(), on_unknown: Decision::Pass },
provenance: ProvenancePolicy::Ignore,
..Default::default()
};
OsvScanner::new(policy, db)
}
#[test]
fn gate_blocks_vulnerable_and_passes_clean() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path())
.with_gate(PolicyGate::new(real_blocking_scanner(), Enforcement::Block));
assert!(engine.has_gate());
let bad = engine.gate_check("cargo", &id("badcrate", "1.0.0"));
assert!(
matches!(bad, Err(PromoteError::PolicyBlocked(_))),
"a High-severity advisory must block the promotion, got {bad:?}"
);
let clean = engine.gate_check("cargo", &id("serde", "1.0.0"));
assert!(clean.is_ok(), "a clean coordinate must pass the gate");
}
#[test]
fn gate_from_imported_osv_snapshot_blocks_affected() {
let dir = tempfile::tempdir().unwrap();
let osv = dir.path().join("advisories.json");
std::fs::write(
&osv,
r#"{ "id": "CVE-2099-0001", "summary": "test advisory",
"affected": [ { "package": { "ecosystem": "crates.io", "name": "badcrate" },
"versions": ["1.0.0"] } ],
"database_specific": { "severity": "HIGH" } }"#,
)
.unwrap();
let db = security::advisory_db_from_osv_file(osv.to_str().unwrap()).expect("import OSV");
let scanner = OsvScanner::new(security::promotion_policy(Vec::new()), db);
let engine = PromotionEngine::new(dir.path())
.with_gate(PolicyGate::new(scanner, Enforcement::Block));
assert!(
matches!(
engine.gate_check("cargo", &id("badcrate", "1.0.0")),
Err(PromoteError::PolicyBlocked(_))
),
"the imported advisory must block the affected coordinate"
);
assert!(
engine.gate_check("cargo", &id("goodcrate", "9.9.9")).is_ok(),
"an unaffected coordinate passes the imported-feed gate"
);
}
#[test]
fn warn_gate_permits_even_a_blocking_verdict() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path())
.with_gate(PolicyGate::new(real_blocking_scanner(), Enforcement::Warn));
assert!(
engine.gate_check("cargo", &id("badcrate", "1.0.0")).is_ok(),
"Warn enforcement must permit the promotion"
);
}
#[test]
fn no_gate_is_pass_through_default_unchanged() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path());
assert!(!engine.has_gate());
assert!(engine.gate_check("cargo", &id("badcrate", "1.0.0")).is_ok());
assert!(engine
.seal("dev", "prod", &id("badcrate", "1.0.0"), b"bytes", "admin", 1)
.is_ok());
}
#[test]
fn tampered_blob_fails_integrity_on_read() {
let dir = tempfile::tempdir().unwrap();
let engine = PromotionEngine::new(dir.path());
let data = b"honest-bytes";
let rec = engine.seal("dev", "prod", &id("x", "1"), data, "admin", 1).unwrap();
fs::write(dir.path().join("blobs").join(&rec.content_address), b"evil").unwrap();
assert!(engine.fetch("prod", &id("x", "1")).is_err(), "tamper must fail integrity");
}
#[test]
fn parse_property_requirement_forms() {
assert_eq!(
PromotionEngine::parse_property_requirement("signed=true"),
Some(("signed".into(), "true".into()))
);
assert_eq!(
PromotionEngine::parse_property_requirement(" signed "),
Some(("signed".into(), String::new())),
"bare key ⇒ any value"
);
assert_eq!(PromotionEngine::parse_property_requirement(""), None, "empty ⇒ no requirement");
assert_eq!(PromotionEngine::parse_property_requirement("=true"), None, "empty key ⇒ none");
}
#[test]
fn property_requirement_met_gates_on_the_map() {
use crate::properties::PropertyMap;
let engine = PromotionEngine::new("/tmp/unused-prod")
.with_required_property("signed".into(), "true".into());
let mut empty = PropertyMap::new();
assert!(!engine.property_requirement_met(&empty), "no property ⇒ refused");
empty.insert("signed".into(), vec!["false".into()]);
assert!(!engine.property_requirement_met(&empty), "wrong value ⇒ refused");
let mut ok = PropertyMap::new();
ok.insert("signed".into(), vec!["true".into()]);
assert!(engine.property_requirement_met(&ok), "signed=true ⇒ allowed");
let open = PromotionEngine::new("/tmp/unused-prod2");
assert!(open.property_requirement_met(&PropertyMap::new()), "no requirement ⇒ unconditional");
assert_eq!(open.required_property(), None);
}
}