use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{Duration, Instant};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdmitDecision {
Admitted,
NotEnrolled,
OverQuota,
}
impl AdmitDecision {
pub fn is_admitted(self) -> bool {
matches!(self, AdmitDecision::Admitted)
}
}
pub trait SyncPolicy: Send + Sync {
fn drive_is_allowed(&self, drive_subject: &str) -> bool;
fn drive_within_quota(&self, drive_subject: &str) -> bool;
fn admit_decision(&self, drive_subject: &str) -> AdmitDecision {
if !self.drive_is_allowed(drive_subject) {
AdmitDecision::NotEnrolled
} else if !self.drive_within_quota(drive_subject) {
AdmitDecision::OverQuota
} else {
AdmitDecision::Admitted
}
}
fn admit_drive_write(&self, drive_subject: &str) -> bool {
self.admit_decision(drive_subject).is_admitted()
}
}
#[derive(Debug, Default, Clone, Copy)]
pub struct OpenPolicy;
impl SyncPolicy for OpenPolicy {
fn drive_is_allowed(&self, _drive_subject: &str) -> bool {
true
}
fn drive_within_quota(&self, _drive_subject: &str) -> bool {
true
}
}
#[derive(Clone, Default)]
pub struct DrivePolicy {
pub quota_bytes: Option<u64>,
}
#[derive(Default)]
pub struct AllowlistPolicy {
inner: RwLock<AllowlistState>,
}
const DEFAULT_GRACE: Duration = Duration::from_secs(600);
struct AllowlistState {
allowed: HashMap<String, DrivePolicy>,
usage: HashMap<String, u64>,
first_seen: HashMap<String, Instant>,
grace: Duration,
}
impl Default for AllowlistState {
fn default() -> Self {
Self {
allowed: HashMap::new(),
usage: HashMap::new(),
first_seen: HashMap::new(),
grace: DEFAULT_GRACE,
}
}
}
impl AllowlistPolicy {
pub fn new() -> Self {
Self::default()
}
pub fn set_drive_policies<I, S>(&self, drives: I)
where
I: IntoIterator<Item = (S, Option<u64>)>,
S: Into<String>,
{
let map = drives
.into_iter()
.map(|(subject, quota_bytes)| (subject.into(), DrivePolicy { quota_bytes }))
.collect();
if let Ok(mut guard) = self.inner.write() {
guard.allowed = map;
}
}
pub fn allowed_drive_subjects(&self) -> Vec<String> {
self.inner
.read()
.map(|guard| guard.allowed.keys().cloned().collect())
.unwrap_or_default()
}
pub fn record_drive_usage<I, S>(&self, usage: I)
where
I: IntoIterator<Item = (S, u64)>,
S: Into<String>,
{
if let Ok(mut guard) = self.inner.write() {
for (subject, bytes) in usage {
guard.usage.insert(subject.into(), bytes);
}
}
}
pub fn set_grace(&self, grace: Duration) {
if let Ok(mut guard) = self.inner.write() {
guard.grace = grace;
}
}
fn decide_at(&self, drive_subject: &str, now: Instant) -> AdmitDecision {
if self.drive_is_allowed(drive_subject) {
return if self.drive_within_quota(drive_subject) {
AdmitDecision::Admitted
} else {
AdmitDecision::OverQuota
};
}
let Ok(mut guard) = self.inner.write() else {
return AdmitDecision::NotEnrolled;
};
let grace = guard.grace;
let first = *guard
.first_seen
.entry(drive_subject.to_string())
.or_insert(now);
if now.saturating_duration_since(first) < grace {
AdmitDecision::Admitted
} else {
AdmitDecision::NotEnrolled
}
}
}
impl SyncPolicy for AllowlistPolicy {
fn drive_is_allowed(&self, drive_subject: &str) -> bool {
self.inner
.read()
.map(|guard| guard.allowed.contains_key(drive_subject))
.unwrap_or(false)
}
fn drive_within_quota(&self, drive_subject: &str) -> bool {
let Ok(guard) = self.inner.read() else {
return false;
};
let Some(policy) = guard.allowed.get(drive_subject) else {
return false; };
match policy.quota_bytes {
Some(quota) => guard.usage.get(drive_subject).copied().unwrap_or(0) < quota,
None => true,
}
}
fn admit_decision(&self, drive_subject: &str) -> AdmitDecision {
self.decide_at(drive_subject, Instant::now())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn open_policy_allows_everything() {
let p = OpenPolicy;
assert!(p.drive_is_allowed("did:ad:anything"));
assert!(p.drive_within_quota("did:ad:anything"));
}
#[test]
fn allowlist_rejects_unenrolled_and_enforces_quota() {
let p = AllowlistPolicy::new();
assert!(!p.drive_is_allowed("did:ad:a"));
assert!(!p.drive_within_quota("did:ad:a"));
p.set_drive_policies([
("did:ad:a".to_string(), Some(100u64)),
("did:ad:b".to_string(), None),
]);
assert!(p.drive_is_allowed("did:ad:a"));
assert!(p.drive_is_allowed("did:ad:b"));
assert!(!p.drive_is_allowed("did:ad:c"));
assert!(p.drive_within_quota("did:ad:a"));
p.record_drive_usage([("did:ad:a".to_string(), 100u64)]);
assert!(!p.drive_within_quota("did:ad:a"));
assert!(p.drive_within_quota("did:ad:b"));
}
#[test]
fn open_policy_admits_every_write() {
assert!(OpenPolicy.admit_drive_write("did:ad:anything"));
}
#[test]
fn admits_allowlisted_drive_and_rejects_over_quota() {
let p = AllowlistPolicy::new();
p.set_drive_policies([("did:ad:a".to_string(), Some(100u64))]);
assert!(p.admit_drive_write("did:ad:a"));
p.record_drive_usage([("did:ad:a".to_string(), 100u64)]);
assert!(!p.admit_drive_write("did:ad:a")); }
#[test]
fn grace_admits_new_drive_then_rejects_after_window() {
let p = AllowlistPolicy::new();
p.set_grace(Duration::from_secs(600));
let t0 = Instant::now();
assert!(p.decide_at("did:ad:new", t0).is_admitted());
assert!(p
.decide_at("did:ad:new", t0 + Duration::from_secs(300))
.is_admitted());
assert_eq!(
p.decide_at("did:ad:new", t0 + Duration::from_secs(601)),
AdmitDecision::NotEnrolled
);
}
#[test]
fn zero_grace_rejects_unenrolled_immediately() {
let p = AllowlistPolicy::new();
p.set_grace(Duration::ZERO);
assert_eq!(
p.decide_at("did:ad:new", Instant::now()),
AdmitDecision::NotEnrolled
);
}
#[test]
fn enrolling_during_grace_makes_admission_permanent() {
let p = AllowlistPolicy::new();
p.set_grace(Duration::from_secs(600));
let t0 = Instant::now();
assert!(p.decide_at("did:ad:d", t0).is_admitted());
p.set_drive_policies([("did:ad:d".to_string(), None)]);
assert!(p
.decide_at("did:ad:d", t0 + Duration::from_secs(10_000))
.is_admitted());
}
#[test]
fn decision_distinguishes_not_enrolled_from_over_quota() {
let p = AllowlistPolicy::new();
p.set_grace(Duration::ZERO); let t0 = Instant::now();
assert_eq!(p.decide_at("did:ad:c", t0), AdmitDecision::NotEnrolled);
p.set_drive_policies([("did:ad:a".to_string(), Some(100u64))]);
assert_eq!(p.decide_at("did:ad:a", t0), AdmitDecision::Admitted);
p.record_drive_usage([("did:ad:a".to_string(), 100u64)]);
assert_eq!(p.decide_at("did:ad:a", t0), AdmitDecision::OverQuota);
}
}