use std::collections::HashMap;
use auths_id::keri::types::Prefix;
use chrono::{DateTime, Duration, Utc};
use parking_lot::Mutex;
#[derive(Debug, thiserror::Error)]
pub enum RefreshError {
#[error("delegator log unreachable: {0}")]
Unreachable(String),
}
pub trait DelegatorLogSource: Send + Sync {
fn refresh(&self, delegator: &Prefix, now: DateTime<Utc>) -> Result<(), RefreshError>;
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshOutcome {
Refreshed,
Skipped,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FreshnessPolicy {
#[default]
FailClosed,
FailOpen {
max_staleness: Duration,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FreshnessDecision {
Fresh {
age: Duration,
},
StaleHonored {
age: Duration,
budget: Duration,
},
StaleRejected {
age: Duration,
},
NeverRefreshed,
}
impl FreshnessDecision {
pub fn is_honored(&self) -> bool {
matches!(self, Self::Fresh { .. } | Self::StaleHonored { .. })
}
}
pub fn enforce_freshness(
policy: &FreshnessPolicy,
last_refreshed: Option<DateTime<Utc>>,
now: DateTime<Utc>,
staleness_bound: Duration,
) -> FreshnessDecision {
let Some(last) = last_refreshed else {
return FreshnessDecision::NeverRefreshed;
};
let age = now.signed_duration_since(last);
if age <= staleness_bound {
return FreshnessDecision::Fresh { age };
}
match policy {
FreshnessPolicy::FailClosed => FreshnessDecision::StaleRejected { age },
FreshnessPolicy::FailOpen { max_staleness } => {
if age <= *max_staleness {
FreshnessDecision::StaleHonored {
age,
budget: *max_staleness,
}
} else {
FreshnessDecision::StaleRejected { age }
}
}
}
}
pub struct RootRefresh<S: DelegatorLogSource> {
interval: Duration,
source: S,
watermarks: Mutex<HashMap<String, DateTime<Utc>>>,
}
impl<S: DelegatorLogSource> RootRefresh<S> {
pub fn new(interval: Duration, source: S) -> Self {
Self {
interval,
source,
watermarks: Mutex::new(HashMap::new()),
}
}
pub fn staleness_bound(&self) -> Duration {
self.interval
}
pub fn last_refreshed(&self, delegator: &Prefix) -> Option<DateTime<Utc>> {
self.watermarks.lock().get(delegator.as_str()).copied()
}
pub fn refresh_if_due(
&self,
delegator: &Prefix,
now: DateTime<Utc>,
) -> Result<RefreshOutcome, RefreshError> {
let due = match self.last_refreshed(delegator) {
Some(last) => now.signed_duration_since(last) >= self.interval,
None => true,
};
if !due {
return Ok(RefreshOutcome::Skipped);
}
self.source.refresh(delegator, now)?;
self.watermarks
.lock()
.insert(delegator.as_str().to_string(), now);
Ok(RefreshOutcome::Refreshed)
}
pub fn freshness(
&self,
delegator: &Prefix,
policy: &FreshnessPolicy,
now: DateTime<Utc>,
) -> FreshnessDecision {
enforce_freshness(policy, self.last_refreshed(delegator), now, self.interval)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
use std::sync::atomic::{AtomicUsize, Ordering};
fn t0() -> DateTime<Utc> {
DateTime::parse_from_rfc3339("2030-01-01T00:00:00Z")
.unwrap()
.with_timezone(&Utc)
}
fn delegator() -> Prefix {
Prefix::new_unchecked("Edelegator".to_string())
}
struct FakeDelegatorLog {
remote_revoked: Mutex<bool>,
local_revoked: Mutex<bool>,
reachable: Mutex<bool>,
pulls: AtomicUsize,
}
impl FakeDelegatorLog {
fn new() -> Self {
Self {
remote_revoked: Mutex::new(false),
local_revoked: Mutex::new(false),
reachable: Mutex::new(true),
pulls: AtomicUsize::new(0),
}
}
fn publish_revocation(&self) {
*self.remote_revoked.lock() = true;
}
fn local_sees_revoked(&self) -> bool {
*self.local_revoked.lock()
}
fn set_reachable(&self, reachable: bool) {
*self.reachable.lock() = reachable;
}
fn pulls(&self) -> usize {
self.pulls.load(Ordering::SeqCst)
}
}
impl DelegatorLogSource for Arc<FakeDelegatorLog> {
fn refresh(&self, _delegator: &Prefix, _now: DateTime<Utc>) -> Result<(), RefreshError> {
if !*self.reachable.lock() {
return Err(RefreshError::Unreachable("offline".to_string()));
}
self.pulls.fetch_add(1, Ordering::SeqCst);
*self.local_revoked.lock() = *self.remote_revoked.lock();
Ok(())
}
}
#[test]
fn revocation_becomes_visible_within_one_interval() {
let log = Arc::new(FakeDelegatorLog::new());
let refresh = RootRefresh::new(Duration::seconds(30), Arc::clone(&log));
let d = delegator();
assert_eq!(
refresh.refresh_if_due(&d, t0()).unwrap(),
RefreshOutcome::Refreshed
);
assert!(!log.local_sees_revoked());
log.publish_revocation();
let soon = t0() + Duration::seconds(10);
assert_eq!(
refresh.refresh_if_due(&d, soon).unwrap(),
RefreshOutcome::Skipped
);
assert!(!log.local_sees_revoked());
assert_eq!(log.pulls(), 1);
let after = t0() + Duration::seconds(31);
assert_eq!(
refresh.refresh_if_due(&d, after).unwrap(),
RefreshOutcome::Refreshed
);
assert!(
log.local_sees_revoked(),
"revocation visible within one poll interval"
);
assert_eq!(log.pulls(), 2);
}
#[test]
fn fail_closed_is_the_default_and_rejects_stale_and_absent() {
let policy = FreshnessPolicy::default();
assert_eq!(policy, FreshnessPolicy::FailClosed);
let bound = Duration::seconds(30);
let fresh = enforce_freshness(&policy, Some(t0()), t0() + Duration::seconds(5), bound);
assert!(matches!(fresh, FreshnessDecision::Fresh { .. }));
assert!(fresh.is_honored());
let stale = enforce_freshness(&policy, Some(t0()), t0() + Duration::seconds(120), bound);
assert!(matches!(stale, FreshnessDecision::StaleRejected { .. }));
assert!(!stale.is_honored());
let absent = enforce_freshness(&policy, None, t0(), bound);
assert_eq!(absent, FreshnessDecision::NeverRefreshed);
assert!(!absent.is_honored());
}
#[test]
fn fail_open_honors_within_budget_and_rejects_beyond() {
let policy = FreshnessPolicy::FailOpen {
max_staleness: Duration::seconds(300),
};
let bound = Duration::seconds(30);
let within = enforce_freshness(&policy, Some(t0()), t0() + Duration::seconds(120), bound);
assert!(matches!(within, FreshnessDecision::StaleHonored { .. }));
assert!(within.is_honored());
let beyond = enforce_freshness(&policy, Some(t0()), t0() + Duration::seconds(600), bound);
assert!(matches!(beyond, FreshnessDecision::StaleRejected { .. }));
assert!(!beyond.is_honored());
}
#[test]
fn unreachable_source_surfaces_an_error_for_the_policy() {
let log = Arc::new(FakeDelegatorLog::new());
log.set_reachable(false);
let refresh = RootRefresh::new(Duration::seconds(30), Arc::clone(&log));
let err = refresh.refresh_if_due(&delegator(), t0()).unwrap_err();
assert!(matches!(err, RefreshError::Unreachable(_)));
let decision = refresh.freshness(&delegator(), &FreshnessPolicy::FailClosed, t0());
assert_eq!(decision, FreshnessDecision::NeverRefreshed);
}
}