use std::collections::{BTreeMap, BTreeSet};
use std::path::Path;
use std::sync::Arc;
use crate::catalog::loader::LoadError;
use crate::catalog::schema::{BackendKind, Capability, Class, Engine, KeyAlgorithm};
use crate::catalog::{Catalog, Config, ResolvedPolicy, load};
use crate::state::{BrokerState, Generation};
#[derive(Debug, Clone)]
pub struct ReloadInputs {
pub catalog_path: std::path::PathBuf,
pub policy_path: std::path::PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct ReloadOutcome {
pub previous_generation: u64,
pub new_generation: u64,
pub key_count: usize,
pub grant_count: usize,
}
#[derive(Debug, thiserror::Error)]
pub enum ReloadError {
#[error("reading catalog from {path}: {source}")]
ReadCatalog {
path: String,
source: std::io::Error,
},
#[error("reading policy from {path}: {source}")]
ReadPolicy {
path: String,
source: std::io::Error,
},
#[error("catalog/policy reload input changed while reading {path}; retry reload")]
TornSnapshot {
path: String,
},
#[error("validating reloaded catalog/policy: {0}")]
Validate(#[from] LoadError),
#[error("reload touches a restart-only routing dimension: {0}")]
RoutingShapeChanged(String),
#[error("reload unavailable: broker has no configured catalog/policy paths")]
NoInputs,
}
impl ReloadError {
#[must_use]
pub const fn audit_reason(&self) -> &'static str {
match self {
Self::ReadCatalog { .. } => "catalog_read_failed",
Self::ReadPolicy { .. } => "policy_read_failed",
Self::TornSnapshot { .. } => "inputs_changed_during_read",
Self::Validate(_) => "validation_failed",
Self::RoutingShapeChanged(_) => "routing_shape_changed",
Self::NoInputs => "no_reload_inputs",
}
}
}
#[derive(Debug, PartialEq, Eq)]
struct BackendShape {
kind: BackendKind,
addr: String,
engines: Vec<Engine>,
capabilities: Vec<Capability>,
requires: Vec<Capability>,
}
#[derive(Debug, PartialEq, Eq)]
struct KeyShape {
class: Class,
key_type: Option<KeyAlgorithm>,
backend: String,
engine: Option<Engine>,
path: String,
public_path: Option<String>,
}
fn routing_shape(
catalog: &Catalog,
) -> (BTreeMap<String, BackendShape>, BTreeMap<String, KeyShape>) {
let backends = catalog
.backends
.iter()
.map(|(name, b)| {
(
name.clone(),
BackendShape {
kind: b.kind,
addr: b.addr.clone(),
engines: b.engines.clone(),
capabilities: b.capabilities.clone(),
requires: b.requires.clone(),
},
)
})
.collect();
let keys = catalog
.keys
.iter()
.map(|(name, k)| {
(
name.clone(),
KeyShape {
class: k.class,
key_type: k.key_type,
backend: k.backend.clone(),
engine: k.engine,
path: k.path.clone(),
public_path: k.public_path.clone(),
},
)
})
.collect();
(backends, keys)
}
fn ensure_reloadable(current: &Catalog, candidate: &Catalog) -> Result<(), ReloadError> {
let (cur_backends, cur_keys) = routing_shape(current);
let (new_backends, new_keys) = routing_shape(candidate);
if cur_backends != new_backends {
return Err(ReloadError::RoutingShapeChanged(
"the backend set or a backend's kind/addr/engines/capabilities/requires changed"
.to_string(),
));
}
if cur_keys != new_keys {
return Err(ReloadError::RoutingShapeChanged(
"a key was added/removed or a key's class/backend/path/engine/key_type/public_path changed"
.to_string(),
));
}
Ok(())
}
fn spiffe_bundle_publishers(catalog: &Catalog) -> BTreeMap<String, (String, String)> {
catalog
.keys
.iter()
.filter_map(|(name, entry)| {
let svid_kind = entry.labels.get("svid_kind")?;
if !matches!(svid_kind, "jwt" | "x509") {
return None;
}
let trust_domain = entry.labels.get("trust_domain")?;
Some((
name.clone(),
(svid_kind.to_string(), trust_domain.to_string()),
))
})
.collect()
}
fn bundle_changed_trust_domains(current: &Catalog, candidate: &Catalog) -> Vec<String> {
let current_publishers = spiffe_bundle_publishers(current);
let candidate_publishers = spiffe_bundle_publishers(candidate);
if current_publishers == candidate_publishers {
return Vec::new();
}
current_publishers
.values()
.chain(candidate_publishers.values())
.map(|(_, trust_domain)| trust_domain.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect()
}
#[cfg(unix)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct FileFingerprint {
dev: u64,
ino: u64,
len: u64,
mtime_sec: i64,
mtime_nsec: i64,
ctime_sec: i64,
ctime_nsec: i64,
}
#[cfg(unix)]
fn file_fingerprint(path: &Path) -> std::io::Result<FileFingerprint> {
use std::os::unix::fs::MetadataExt;
let metadata = std::fs::metadata(path)?;
Ok(FileFingerprint {
dev: metadata.dev(),
ino: metadata.ino(),
len: metadata.len(),
mtime_sec: metadata.mtime(),
mtime_nsec: metadata.mtime_nsec(),
ctime_sec: metadata.ctime(),
ctime_nsec: metadata.ctime_nsec(),
})
}
#[cfg(not(unix))]
#[derive(Debug, Clone, PartialEq, Eq)]
struct FileFingerprint {
len: u64,
modified: Option<std::time::SystemTime>,
}
#[cfg(not(unix))]
fn file_fingerprint(path: &Path) -> std::io::Result<FileFingerprint> {
let metadata = std::fs::metadata(path)?;
Ok(FileFingerprint {
len: metadata.len(),
modified: metadata.modified().ok(),
})
}
fn read_reload_inputs_with_observer(
inputs: &ReloadInputs,
observer: impl FnOnce(),
) -> Result<(String, String), ReloadError> {
let catalog_before =
file_fingerprint(&inputs.catalog_path).map_err(|source| ReloadError::ReadCatalog {
path: inputs.catalog_path.display().to_string(),
source,
})?;
let policy_before =
file_fingerprint(&inputs.policy_path).map_err(|source| ReloadError::ReadPolicy {
path: inputs.policy_path.display().to_string(),
source,
})?;
let catalog_json = std::fs::read_to_string(&inputs.catalog_path).map_err(|source| {
ReloadError::ReadCatalog {
path: inputs.catalog_path.display().to_string(),
source,
}
})?;
let policy_json =
std::fs::read_to_string(&inputs.policy_path).map_err(|source| ReloadError::ReadPolicy {
path: inputs.policy_path.display().to_string(),
source,
})?;
observer();
let catalog_after =
file_fingerprint(&inputs.catalog_path).map_err(|source| ReloadError::ReadCatalog {
path: inputs.catalog_path.display().to_string(),
source,
})?;
if catalog_before != catalog_after {
return Err(ReloadError::TornSnapshot {
path: inputs.catalog_path.display().to_string(),
});
}
let policy_after =
file_fingerprint(&inputs.policy_path).map_err(|source| ReloadError::ReadPolicy {
path: inputs.policy_path.display().to_string(),
source,
})?;
if policy_before != policy_after {
return Err(ReloadError::TornSnapshot {
path: inputs.policy_path.display().to_string(),
});
}
Ok((catalog_json, policy_json))
}
fn read_reload_inputs(inputs: &ReloadInputs) -> Result<(String, String), ReloadError> {
read_reload_inputs_with_observer(inputs, || {})
}
struct ValidatedCandidate {
catalog: Catalog,
policy: ResolvedPolicy,
config: Config,
outcome: ReloadOutcome,
bundle_changed_trust_domains: Vec<String>,
}
fn validate_candidate(state: &BrokerState) -> Result<ValidatedCandidate, ReloadError> {
let inputs = state.reload_inputs().ok_or(ReloadError::NoInputs)?;
let (catalog_json, policy_json) = read_reload_inputs(inputs)?;
let (catalog, policy, config, warnings) = load(&catalog_json, &policy_json)?;
for w in &warnings {
tracing::warn!(warning = %w, "reload: catalog/policy load warning");
}
let current = state.load_generation();
ensure_reloadable(current.catalog(), &catalog)?;
let previous_generation = current.id();
let new_generation = previous_generation.saturating_add(1);
let bundle_changed_trust_domains = bundle_changed_trust_domains(current.catalog(), &catalog);
let outcome = ReloadOutcome {
previous_generation,
new_generation,
key_count: catalog.keys.len(),
grant_count: policy.grant_count(),
};
Ok(ValidatedCandidate {
catalog,
policy,
config,
outcome,
bundle_changed_trust_domains,
})
}
pub fn check_reload(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
validate_candidate(state).map(|c| c.outcome)
}
pub fn reload_generation(state: &BrokerState) -> Result<ReloadOutcome, ReloadError> {
let _reload_guard = state
.reload_lock()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let candidate = validate_candidate(state)?;
let ValidatedCandidate {
catalog,
policy,
config,
outcome,
bundle_changed_trust_domains,
} = candidate;
let next = Generation::new(outcome.new_generation, Arc::new(catalog), policy, config);
state.swap_generation(Arc::new(next));
for trust_domain in bundle_changed_trust_domains {
state.events().bundle_changed(trust_domain);
}
Ok(outcome)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use async_trait::async_trait;
use basil_proto::KeyType;
use super::{
ReloadError, ReloadInputs, check_reload, read_reload_inputs_with_observer,
reload_generation,
};
use crate::backend::{Backend, BackendError, NewKey};
use crate::catalog::load;
use crate::manager::BackendManager;
use crate::state::{BrokerState, INITIAL_GENERATION_ID};
struct NoopBackend;
#[async_trait]
impl Backend for NoopBackend {
fn kind(&self) -> &'static str {
"noop"
}
async fn new_key(&self, _key_type: KeyType) -> Result<NewKey, BackendError> {
Err(BackendError::Unsupported("new_key"))
}
async fn public_key(&self, _key_id: &str) -> Result<Vec<u8>, BackendError> {
Err(BackendError::Unsupported("public_key"))
}
async fn sign(&self, _key_id: &str, _message: &[u8]) -> Result<Vec<u8>, BackendError> {
Err(BackendError::Unsupported("sign"))
}
async fn verify(
&self,
_key_id: &str,
_message: &[u8],
_signature: &[u8],
) -> Result<bool, BackendError> {
Err(BackendError::Unsupported("verify"))
}
}
fn catalog_json(writable: bool) -> String {
format!(
r#"{{
"schemaVersion": 1,
"backends": {{ "bao": {{ "kind": "vault", "addr": "http://127.0.0.1:8200" }} }},
"keys": {{
"web.signer": {{
"class": "asymmetric", "keyType": "ed25519", "backend": "bao",
"path": "signer", "writable": {writable}, "description": "a signer"
}}
}}
}}"#
)
}
fn catalog_json_repathed() -> String {
r#"{
"schemaVersion": 1,
"backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
"keys": {
"web.signer": {
"class": "asymmetric", "keyType": "ed25519", "backend": "bao",
"path": "signer-v2", "writable": true, "description": "a signer"
}
}
}"#
.to_string()
}
fn policy_json(grant_sign: bool) -> String {
let rules = if grant_sign {
r#"[ { "id": "r1", "subjects": ["svc.web"], "action": ["op:sign"], "target": ["web.signer"] } ]"#
} else {
"[]"
};
format!(
r#"{{
"schemaVersion": 2,
"subjects": {{ "svc.web": {{ "allOf": [ {{ "kind": "unix", "uid": 1000 }} ] }} }},
"roles": {{}},
"rules": {rules},
"config": {{}}
}}"#
)
}
fn state_with_files(catalog: &str, policy: &str) -> (Arc<BrokerState>, ReloadInputs) {
let dir = std::env::temp_dir().join(format!(
"basil-reload-test-{}-{}",
std::process::id(),
uuid::Uuid::new_v4()
));
std::fs::create_dir_all(&dir).expect("create temp dir");
let catalog_path = dir.join("catalog.json");
let policy_path = dir.join("policy.json");
std::fs::write(&catalog_path, catalog).expect("write catalog");
std::fs::write(&policy_path, policy).expect("write policy");
let (cat, pol, cfg, warnings) = load(catalog, policy).expect("fixture loads");
assert!(warnings.is_empty());
let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
backends.insert("bao".into(), Box::new(NoopBackend));
let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
let inputs = ReloadInputs {
catalog_path,
policy_path,
};
let state = Arc::new(
BrokerState::new(cat, pol, cfg, manager, "noop").with_reload_inputs(inputs.clone()),
);
(state, inputs)
}
fn write_files(inputs: &ReloadInputs, catalog: &str, policy: &str) {
std::fs::write(&inputs.catalog_path, catalog).expect("rewrite catalog");
std::fs::write(&inputs.policy_path, policy).expect("rewrite policy");
}
#[test]
fn valid_reload_swaps_generation_and_stays_coherent() {
let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
let pinned = state.load_generation();
assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
write_files(&inputs, &catalog_json(true), &policy_json(true));
let outcome = reload_generation(&state).expect("valid reload applies");
assert_eq!(outcome.previous_generation, INITIAL_GENERATION_ID);
assert_eq!(outcome.new_generation, INITIAL_GENERATION_ID + 1);
assert_eq!(outcome.key_count, 1);
assert_eq!(outcome.grant_count, 1);
assert_eq!(pinned.id(), INITIAL_GENERATION_ID);
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
}
#[test]
fn invalid_policy_is_rejected_and_previous_generation_keeps_serving() {
let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
write_files(
&inputs,
&catalog_json(true),
r#"{ "schemaVersion": 2, "subjects": { "svc.web": { "allOf": [ { "kind": "unix", "uid": 1000 } ] } }, "roles": {}, "rules": [ { "id": "bad", "subjects": ["svc.web"], "action": ["role:nonexistent"], "target": ["web.signer"] } ], "config": {} }"#,
);
let err = reload_generation(&state).expect_err("malformed policy rejected");
assert!(matches!(err, ReloadError::Validate(_)));
assert_eq!(err.audit_reason(), "validation_failed");
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
}
#[test]
fn reload_input_change_during_read_is_rejected() {
let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
let err = read_reload_inputs_with_observer(&inputs, || {
std::fs::write(
&inputs.policy_path,
policy_json(true).replace("\"rules\"", "\"rules_changed\""),
)
.expect("race policy rewrite");
})
.expect_err("changed policy fingerprint rejects torn read");
assert!(matches!(err, ReloadError::TornSnapshot { .. }));
assert_eq!(err.audit_reason(), "inputs_changed_during_read");
assert_eq!(
state.active_generation_id(),
INITIAL_GENERATION_ID,
"helper rejection leaves the serving generation untouched"
);
}
#[test]
fn non_profile_jwt_svid_issuer_is_rejected_on_reload() {
let base_catalog = r#"{
"schemaVersion": 1,
"backends": { "bao": { "kind": "vault", "addr": "http://127.0.0.1:8200" } },
"keys": {
"spiffe.jwt": {
"class": "asymmetric", "keyType": "rsa-2048", "backend": "bao", "path": "jwt",
"labels": ["svid_kind=jwt", "trust_domain=example.org"],
"writable": false, "description": "jwt issuer"
}
}
}"#;
let (state, inputs) = state_with_files(base_catalog, &policy_json(false));
let bad_catalog = base_catalog.replace("rsa-2048", "ed25519");
write_files(&inputs, &bad_catalog, &policy_json(false));
let err = reload_generation(&state).expect_err("non-profile jwt issuer rejected");
assert!(matches!(
err,
ReloadError::Validate(_) | ReloadError::RoutingShapeChanged(_)
));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
}
#[test]
fn restart_only_routing_change_is_rejected() {
let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
let err = reload_generation(&state).expect_err("repath rejected");
assert!(matches!(err, ReloadError::RoutingShapeChanged(_)));
assert_eq!(err.audit_reason(), "routing_shape_changed");
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
}
#[test]
fn check_reload_validates_without_swapping() {
let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
write_files(&inputs, &catalog_json(true), &policy_json(true));
let dry = check_reload(&state).expect("dry-run validates");
assert_eq!(dry.previous_generation, INITIAL_GENERATION_ID);
assert_eq!(dry.new_generation, INITIAL_GENERATION_ID + 1);
assert_eq!(dry.key_count, 1);
assert_eq!(dry.grant_count, 1);
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
let applied = reload_generation(&state).expect("real reload applies");
assert_eq!(applied, dry);
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 1);
}
#[test]
fn check_reload_rejects_what_real_reload_rejects() {
let (state, inputs) = state_with_files(&catalog_json(true), &policy_json(true));
write_files(&inputs, &catalog_json_repathed(), &policy_json(true));
let dry = check_reload(&state).expect_err("dry-run rejects repath");
assert!(matches!(dry, ReloadError::RoutingShapeChanged(_)));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
let real = reload_generation(&state).expect_err("real reload rejects repath");
assert!(matches!(real, ReloadError::RoutingShapeChanged(_)));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
}
#[test]
fn concurrent_reloads_are_serialized_with_monotonic_generations() {
let (state, inputs) = state_with_files(&catalog_json(false), &policy_json(false));
write_files(&inputs, &catalog_json(true), &policy_json(true));
let outcomes = std::thread::scope(|scope| {
let first = scope.spawn(|| reload_generation(&state));
let second = scope.spawn(|| reload_generation(&state));
[first, second].map(|h| h.join().expect("reload thread panicked"))
});
let mut transitions: Vec<(u64, u64)> = outcomes
.into_iter()
.map(|o| {
let o = o.expect("both concurrent reloads apply");
(o.previous_generation, o.new_generation)
})
.collect();
transitions.sort_unstable();
assert_eq!(
transitions,
vec![
(INITIAL_GENERATION_ID, INITIAL_GENERATION_ID + 1),
(INITIAL_GENERATION_ID + 1, INITIAL_GENERATION_ID + 2),
]
);
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID + 2);
}
#[test]
fn reload_without_inputs_fails_closed() {
let (cat, pol, cfg, _) =
load(&catalog_json(true), &policy_json(true)).expect("fixture loads");
let mut backends: BTreeMap<String, Box<dyn Backend>> = BTreeMap::new();
backends.insert("bao".into(), Box::new(NoopBackend));
let manager = BackendManager::new(cat.clone(), backends).expect("manager builds");
let state = BrokerState::new(cat, pol, cfg, manager, "noop");
let err = reload_generation(&state).expect_err("no inputs → fail closed");
assert!(matches!(err, ReloadError::NoInputs));
assert_eq!(state.active_generation_id(), INITIAL_GENERATION_ID);
}
}