use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::core::error::{ERR_BUNDLE_FETCH_FAILED, ERR_BUNDLE_INVALID, ERR_BUNDLE_REJECTED};
use crate::generated::types::PolicyBundle;
pub const POLICY_DIR: &str = "policy";
pub const BUNDLE_FILE: &str = "bundle.json";
pub const META_FILE: &str = "bundle.meta.json";
pub fn policy_dir(base: &Path) -> PathBuf {
base.join(POLICY_DIR)
}
pub fn bundle_path(base: &Path) -> PathBuf {
policy_dir(base).join(BUNDLE_FILE)
}
pub fn meta_path(base: &Path) -> PathBuf {
policy_dir(base).join(META_FILE)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct BundleMeta {
pub digest: String,
#[serde(default)]
pub etag: Option<String>,
pub revision: i64,
pub organization_id: String,
pub built_at: String,
#[serde(default)]
pub last_poll_ok_at: Option<String>,
#[serde(default)]
pub last_download_verified_at: Option<String>,
#[serde(default)]
pub last_activated_at: Option<String>,
#[serde(default = "default_true")]
pub last_fetch_ok: bool,
#[serde(default)]
pub last_error: Option<String>,
#[serde(default)]
pub agent_id: Option<String>,
}
fn default_true() -> bool {
true
}
impl BundleMeta {
pub fn activated(bundle: &PolicyBundle, digest: String, etag: Option<String>) -> Self {
let now = now_rfc3339();
Self {
digest,
etag,
revision: bundle.revision,
organization_id: bundle.organization_id.clone(),
built_at: bundle.built_at.clone(),
last_poll_ok_at: Some(now.clone()),
last_download_verified_at: Some(now.clone()),
last_activated_at: Some(now),
last_fetch_ok: true,
last_error: None,
agent_id: None,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CachedBundle {
pub bundle: PolicyBundle,
pub meta: BundleMeta,
}
#[derive(Debug, thiserror::Error)]
pub enum StoreError {
#[error("policy store I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("cached bundle digest mismatch: meta claims {expected}, body hashes to {actual}")]
DigestMismatch { expected: String, actual: String },
#[error("cached bundle metadata is unreadable: {0}")]
MetaMalformed(String),
#[error("cached bundle body is not a valid policy bundle: {0}")]
BodyMalformed(String),
}
impl StoreError {
pub fn code(&self) -> &'static str {
match self {
StoreError::Io { .. } => ERR_BUNDLE_FETCH_FAILED,
StoreError::DigestMismatch { .. } | StoreError::MetaMalformed(_) => ERR_BUNDLE_REJECTED,
StoreError::BodyMalformed(_) => ERR_BUNDLE_INVALID,
}
}
}
fn io(path: &Path, source: std::io::Error) -> StoreError {
StoreError::Io {
path: path.to_path_buf(),
source,
}
}
pub fn digest_of(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
format!("sha256:{}", hex::encode(hasher.finalize()))
}
pub fn verify_digest(body: &[u8], expected: &str) -> Result<(), StoreError> {
let actual = digest_of(body);
if actual == expected {
Ok(())
} else {
Err(StoreError::DigestMismatch {
expected: expected.to_string(),
actual,
})
}
}
pub fn now_rfc3339() -> String {
chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true)
}
pub fn read_meta(base: &Path) -> Result<Option<BundleMeta>, StoreError> {
let path = meta_path(base);
let raw = match std::fs::read(&path) {
Ok(raw) => raw,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(io(&path, e)),
};
serde_json::from_slice(&raw)
.map(Some)
.map_err(|e| StoreError::MetaMalformed(e.to_string()))
}
pub fn load(base: &Path) -> Result<Option<CachedBundle>, StoreError> {
let body_path = bundle_path(base);
let meta_file = meta_path(base);
let body = match std::fs::read(&body_path) {
Ok(b) => Some(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(io(&body_path, e)),
};
let meta_raw = match std::fs::read(&meta_file) {
Ok(b) => Some(b),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
Err(e) => return Err(io(&meta_file, e)),
};
let (body, meta_raw) = match (body, meta_raw) {
(None, None) => return Ok(None),
(Some(_), None) | (None, Some(_)) => {
tracing::warn!(
target: "policy",
"orphaned policy cache (body and meta must both be present); deleting both and refetching"
);
discard(base);
return Ok(None);
}
(Some(body), Some(meta_raw)) => (body, meta_raw),
};
let meta: BundleMeta = match serde_json::from_slice(&meta_raw) {
Ok(m) => m,
Err(e) => {
discard(base);
return Err(StoreError::MetaMalformed(e.to_string()));
}
};
if let Err(e) = verify_digest(&body, &meta.digest) {
discard(base);
return Err(e);
}
let bundle: PolicyBundle =
match serde_json::from_slice(&body).and_then(super::parse_bundle_tolerant) {
Ok(b) => b,
Err(e) => {
discard(base);
return Err(StoreError::BodyMalformed(e.to_string()));
}
};
Ok(Some(CachedBundle { bundle, meta }))
}
pub fn store(base: &Path, body: &[u8], meta: &BundleMeta) -> Result<(), StoreError> {
write_body(base, body)?;
write_meta(base, meta)
}
pub fn write_body(base: &Path, body: &[u8]) -> Result<(), StoreError> {
let dir = policy_dir(base);
std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
atomic_write(&bundle_path(base), body)
}
pub fn write_meta(base: &Path, meta: &BundleMeta) -> Result<(), StoreError> {
let dir = policy_dir(base);
std::fs::create_dir_all(&dir).map_err(|e| io(&dir, e))?;
let body = serde_json::to_vec_pretty(meta).map_err(|e| {
io(
&meta_path(base),
std::io::Error::new(std::io::ErrorKind::InvalidData, e),
)
})?;
atomic_write(&meta_path(base), &body)
}
pub fn discard(base: &Path) {
for path in [bundle_path(base), meta_path(base)] {
match std::fs::remove_file(&path) {
Ok(()) => {}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => tracing::warn!(
target: "policy",
code = ERR_BUNDLE_FETCH_FAILED,
path = %path.display(),
error = %e,
"could not remove cached policy file"
),
}
}
}
fn atomic_write(path: &Path, bytes: &[u8]) -> Result<(), StoreError> {
let tmp = path.with_extension("json.tmp");
std::fs::write(&tmp, bytes).map_err(|e| io(&tmp, e))?;
std::fs::rename(&tmp, path).map_err(|e| io(path, e))
}
#[cfg(test)]
mod tests {
use super::super::test_support::*;
use super::*;
use crate::generated::types::{PolicyRuleMode, PolicyRuleSeverity};
fn fixture() -> (PolicyBundle, Vec<u8>) {
let bundle = wire_bundle(
vec![wire_rule(
"OL-CMD-001",
"*rm -rf*",
PolicyRuleMode::Enforce,
PolicyRuleSeverity::Critical,
)],
true,
);
let body = serde_json::to_vec(&bundle).expect("serialise fixture");
(bundle, body)
}
fn seed(base: &Path) -> (PolicyBundle, Vec<u8>, BundleMeta) {
let (bundle, body) = fixture();
let meta = BundleMeta::activated(
&bundle,
digest_of(&body),
Some("\"sha256:deadbeef\"".to_string()),
);
store(base, &body, &meta).expect("store");
(bundle, body, meta)
}
#[test]
fn digest_is_sha256_of_the_exact_bytes() {
assert_eq!(
digest_of(b""),
"sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
);
assert_ne!(digest_of(b"a"), digest_of(b"b"));
assert!(verify_digest(b"a", &digest_of(b"a")).is_ok());
assert!(verify_digest(b"a", &digest_of(b"b")).is_err());
}
#[test]
fn round_trips_body_and_meta() {
let dir = tempfile::tempdir().expect("tempdir");
let (bundle, _body, meta) = seed(dir.path());
let loaded = load(dir.path()).expect("load ok").expect("bundle present");
assert_eq!(loaded.bundle, bundle);
assert_eq!(loaded.meta, meta);
assert_eq!(loaded.meta.etag.as_deref(), Some("\"sha256:deadbeef\""));
assert_eq!(loaded.meta.revision, 42);
assert!(loaded.meta.last_activated_at.is_some());
assert!(loaded.meta.last_download_verified_at.is_some());
assert!(loaded.meta.last_poll_ok_at.is_some());
}
#[test]
fn empty_cache_is_not_an_error() {
let dir = tempfile::tempdir().expect("tempdir");
assert!(load(dir.path()).expect("load ok").is_none());
assert!(read_meta(dir.path()).expect("read ok").is_none());
}
#[test]
fn tampered_body_is_rejected_on_load() {
let dir = tempfile::tempdir().expect("tempdir");
seed(dir.path());
let tampered = serde_json::to_vec(&wire_bundle(vec![], true)).expect("serialise");
std::fs::write(bundle_path(dir.path()), &tampered).expect("tamper");
let err = load(dir.path()).expect_err("tampered body must not load");
assert!(matches!(err, StoreError::DigestMismatch { .. }));
assert_eq!(err.code(), ERR_BUNDLE_REJECTED);
assert!(!bundle_path(dir.path()).exists());
assert!(!meta_path(dir.path()).exists());
assert!(load(dir.path()).expect("load ok").is_none());
}
#[test]
fn malformed_meta_is_rejected_and_discarded() {
let dir = tempfile::tempdir().expect("tempdir");
seed(dir.path());
std::fs::write(meta_path(dir.path()), b"{ not json").expect("corrupt meta");
let err = load(dir.path()).expect_err("unverifiable pair must not load");
assert!(matches!(err, StoreError::MetaMalformed(_)));
assert_eq!(err.code(), ERR_BUNDLE_REJECTED);
assert!(!bundle_path(dir.path()).exists());
assert!(!meta_path(dir.path()).exists());
}
#[test]
fn body_matching_the_digest_but_not_the_schema_is_rejected() {
let dir = tempfile::tempdir().expect("tempdir");
let (bundle, _) = fixture();
let body = br#"{"schema_version":1}"#.to_vec();
let meta = BundleMeta::activated(&bundle, digest_of(&body), None);
store(dir.path(), &body, &meta).expect("store");
let err = load(dir.path()).expect_err("incomplete document must not load");
assert!(matches!(err, StoreError::BodyMalformed(_)));
assert_eq!(err.code(), ERR_BUNDLE_INVALID);
assert!(!bundle_path(dir.path()).exists());
}
#[test]
fn orphan_body_without_meta_is_treated_as_no_bundle() {
let dir = tempfile::tempdir().expect("tempdir");
let (_, body) = fixture();
write_body(dir.path(), &body).expect("write body");
assert!(bundle_path(dir.path()).exists());
assert!(!meta_path(dir.path()).exists());
assert!(load(dir.path()).expect("load ok").is_none());
assert!(!bundle_path(dir.path()).exists());
}
#[test]
fn orphan_meta_without_body_is_treated_as_no_bundle() {
let dir = tempfile::tempdir().expect("tempdir");
let (bundle, body) = fixture();
write_meta(
dir.path(),
&BundleMeta::activated(&bundle, digest_of(&body), None),
)
.expect("write meta");
assert!(load(dir.path()).expect("load ok").is_none());
assert!(!meta_path(dir.path()).exists());
}
#[test]
fn store_leaves_no_temp_files() {
let dir = tempfile::tempdir().expect("tempdir");
seed(dir.path());
let names: Vec<String> = std::fs::read_dir(policy_dir(dir.path()))
.expect("read dir")
.map(|e| e.expect("entry").file_name().to_string_lossy().into_owned())
.collect();
assert_eq!(names.len(), 2, "unexpected files: {names:?}");
assert!(names.contains(&BUNDLE_FILE.to_string()));
assert!(names.contains(&META_FILE.to_string()));
}
#[test]
fn write_meta_updates_the_poll_clock_without_touching_the_body() {
let dir = tempfile::tempdir().expect("tempdir");
let (_, body, mut meta) = seed(dir.path());
meta.last_poll_ok_at = Some("2026-07-22T10:00:00.000Z".to_string());
write_meta(dir.path(), &meta).expect("write meta");
assert_eq!(
std::fs::read(bundle_path(dir.path())).expect("read body"),
body
);
let reloaded = read_meta(dir.path()).expect("read ok").expect("meta");
assert_eq!(
reloaded.last_poll_ok_at.as_deref(),
Some("2026-07-22T10:00:00.000Z")
);
assert_eq!(
reloaded.last_download_verified_at,
meta.last_download_verified_at
);
assert_eq!(reloaded.last_activated_at, meta.last_activated_at);
}
#[test]
fn meta_tolerates_missing_and_unknown_fields() {
let dir = tempfile::tempdir().expect("tempdir");
let (_, body) = fixture();
write_body(dir.path(), &body).expect("write body");
let legacy = serde_json::json!({
"digest": digest_of(&body),
"revision": 7,
"organization_id": "0192f8a1-4c3b-7e2a-9f10-5d8c3b1a7e42",
"built_at": "2026-07-21T09:00:00Z",
"fetched_at": "2026-07-21T09:00:01Z"
});
std::fs::write(
meta_path(dir.path()),
serde_json::to_vec(&legacy).expect("serialise"),
)
.expect("write legacy meta");
let loaded = load(dir.path()).expect("load ok").expect("bundle present");
assert_eq!(loaded.meta.revision, 7);
assert!(loaded.meta.etag.is_none());
assert!(loaded.meta.last_fetch_ok, "defaults to true");
assert!(loaded.meta.last_poll_ok_at.is_none());
assert!(loaded.meta.agent_id.is_none());
}
#[test]
fn discard_is_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
discard(dir.path());
seed(dir.path());
discard(dir.path());
discard(dir.path());
assert!(load(dir.path()).expect("load ok").is_none());
}
#[test]
fn now_rfc3339_is_utc_with_a_z_suffix() {
let now = now_rfc3339();
assert!(now.ends_with('Z'), "{now}");
assert!(chrono::DateTime::parse_from_rfc3339(&now).is_ok(), "{now}");
}
}