use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fmt::{Display, Formatter};
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TrustStateError {
message: String,
}
impl TrustStateError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}
impl Display for TrustStateError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for TrustStateError {}
pub trait TrustStateStore {
fn is_token_revoked(&self, token_id: &str) -> Result<bool, TrustStateError>;
fn is_agent_emergency_denied(&self, agent_id: &str) -> Result<bool, TrustStateError>;
fn consume_nonce(
&self,
nonce: &str,
valid_until: DateTime<Utc>,
) -> Result<bool, TrustStateError>;
}
pub trait TrustStateAdmin: TrustStateStore {
fn revoke_token(&self, token_id: &str) -> Result<(), TrustStateError>;
fn emergency_deny_agent(&self, agent_id: &str) -> Result<(), TrustStateError>;
fn clear_emergency_deny_list(&self) -> Result<u64, TrustStateError>;
fn revoke_tokens(&self, token_ids: &[&str]) -> Result<u64, TrustStateError> {
let mut count = 0u64;
for id in token_ids {
self.revoke_token(id)?;
count += 1;
}
Ok(count)
}
fn flush_expired_nonces(&self, _reference_time: DateTime<Utc>) -> Result<u64, TrustStateError> {
Ok(0)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TrustStateBackend {
DurableDefault,
DurablePath(PathBuf),
InMemory,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RuntimeTrustConfig {
pub backend: TrustStateBackend,
}
impl RuntimeTrustConfig {
pub fn durable_default() -> Self {
Self {
backend: TrustStateBackend::DurableDefault,
}
}
pub fn durable_path(path: impl Into<PathBuf>) -> Self {
Self {
backend: TrustStateBackend::DurablePath(path.into()),
}
}
pub fn in_memory() -> Self {
Self {
backend: TrustStateBackend::InMemory,
}
}
}
impl Default for RuntimeTrustConfig {
fn default() -> Self {
Self::in_memory()
}
}
pub fn default_trust_state_path() -> PathBuf {
if let Some(override_path) = std::env::var_os("DELEGATED_TRUST_STATE_PATH") {
return PathBuf::from(override_path);
}
if let Some(home) = std::env::var_os("HOME") {
return PathBuf::from(home)
.join(".delegated")
.join("trust-state.json");
}
PathBuf::from(".delegated").join("trust-state.json")
}
pub fn trust_state_from_runtime_config(config: &RuntimeTrustConfig) -> Box<dyn TrustStateStore> {
match &config.backend {
TrustStateBackend::DurableDefault => {
Box::new(FileBackedTrustState::new(default_trust_state_path()))
}
TrustStateBackend::DurablePath(path) => Box::new(FileBackedTrustState::new(path.clone())),
TrustStateBackend::InMemory => Box::new(InMemoryTrustState::new()),
}
}
#[derive(Debug)]
struct InMemoryTrustInner {
revoked_token_ids: HashSet<String>,
emergency_denied_agents: HashSet<String>,
consumed_nonces: HashMap<String, DateTime<Utc>>,
}
#[derive(Debug)]
pub struct InMemoryTrustState {
inner: Mutex<InMemoryTrustInner>,
backend_available: AtomicBool,
}
impl Default for InMemoryTrustState {
fn default() -> Self {
Self::new()
}
}
impl InMemoryTrustState {
pub fn new() -> Self {
Self {
inner: Mutex::new(InMemoryTrustInner {
revoked_token_ids: HashSet::new(),
emergency_denied_agents: HashSet::new(),
consumed_nonces: HashMap::new(),
}),
backend_available: AtomicBool::new(true),
}
}
pub fn set_backend_available(&self, available: bool) {
self.backend_available.store(available, Ordering::SeqCst);
}
fn ensure_available(&self) -> Result<(), TrustStateError> {
if self.backend_available.load(Ordering::SeqCst) {
Ok(())
} else {
Err(TrustStateError::new("revocation backend unavailable"))
}
}
fn lock(&self) -> Result<std::sync::MutexGuard<'_, InMemoryTrustInner>, TrustStateError> {
self.inner
.lock()
.map_err(|_| TrustStateError::new("trust state mutex poisoned"))
}
}
impl TrustStateStore for InMemoryTrustState {
fn is_token_revoked(&self, token_id: &str) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let inner = self.lock()?;
Ok(inner.revoked_token_ids.contains(token_id))
}
fn is_agent_emergency_denied(&self, agent_id: &str) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let inner = self.lock()?;
Ok(inner.emergency_denied_agents.contains(agent_id))
}
fn consume_nonce(
&self,
nonce: &str,
valid_until: DateTime<Utc>,
) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let mut inner = self.lock()?;
inner
.consumed_nonces
.retain(|_, expires_at| *expires_at >= valid_until);
if inner.consumed_nonces.contains_key(nonce) {
return Ok(false);
}
inner.consumed_nonces.insert(nonce.to_string(), valid_until);
Ok(true)
}
}
impl TrustStateAdmin for InMemoryTrustState {
fn revoke_token(&self, token_id: &str) -> Result<(), TrustStateError> {
self.ensure_available()?;
let mut inner = self.lock()?;
inner.revoked_token_ids.insert(token_id.to_string());
Ok(())
}
fn emergency_deny_agent(&self, agent_id: &str) -> Result<(), TrustStateError> {
self.ensure_available()?;
let mut inner = self.lock()?;
inner.emergency_denied_agents.insert(agent_id.to_string());
Ok(())
}
fn clear_emergency_deny_list(&self) -> Result<u64, TrustStateError> {
self.ensure_available()?;
let mut inner = self.lock()?;
let count = inner.emergency_denied_agents.len() as u64;
inner.emergency_denied_agents.clear();
Ok(count)
}
fn flush_expired_nonces(&self, reference_time: DateTime<Utc>) -> Result<u64, TrustStateError> {
self.ensure_available()?;
let mut inner = self.lock()?;
let before = inner.consumed_nonces.len();
inner
.consumed_nonces
.retain(|_, expires_at| *expires_at >= reference_time);
Ok((before - inner.consumed_nonces.len()) as u64)
}
}
#[derive(Debug, Clone)]
pub struct FileBackedTrustState {
path: PathBuf,
backend_available: bool,
}
impl FileBackedTrustState {
pub fn new(path: impl Into<PathBuf>) -> Self {
Self {
path: path.into(),
backend_available: true,
}
}
pub fn set_backend_available(&mut self, available: bool) {
self.backend_available = available;
}
pub fn revoke_token_local(&self, token_id: impl Into<String>) -> Result<(), TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let mut persisted = self.load_persisted()?;
persisted.revoked_token_ids.insert(token_id.into());
self.save_persisted(&persisted)
}
pub fn emergency_deny_agent_local(
&self,
agent_id: impl Into<String>,
) -> Result<(), TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let mut persisted = self.load_persisted()?;
persisted.emergency_denied_agents.insert(agent_id.into());
self.save_persisted(&persisted)
}
fn ensure_available(&self) -> Result<(), TrustStateError> {
if self.backend_available {
Ok(())
} else {
Err(TrustStateError::new("revocation backend unavailable"))
}
}
fn lock_path(&self) -> PathBuf {
self.path.with_extension("lock")
}
fn acquire_lock(&self) -> Result<FileLockGuard, TrustStateError> {
let lock_path = self.lock_path();
if let Some(parent) = lock_path.parent()
&& !parent.as_os_str().is_empty()
{
fs::create_dir_all(parent).map_err(|error| {
TrustStateError::new(format!(
"failed creating trust-state lock directory: {error}"
))
})?;
}
let mut attempts = 0u8;
loop {
match fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
{
Ok(_) => return Ok(FileLockGuard { lock_path }),
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
attempts += 1;
if attempts > 20 {
return Err(TrustStateError::new(
"timed out acquiring trust-state file lock",
));
}
std::thread::sleep(Duration::from_millis(10));
}
Err(error) => {
return Err(TrustStateError::new(format!(
"failed acquiring trust-state file lock: {error}"
)));
}
}
}
}
fn load_persisted(&self) -> Result<PersistedTrustState, TrustStateError> {
if !self.path.exists() {
return Ok(PersistedTrustState::default());
}
let raw = fs::read_to_string(&self.path).map_err(|error| {
TrustStateError::new(format!("failed reading trust-state file: {error}"))
})?;
serde_json::from_str(&raw).map_err(|error| {
TrustStateError::new(format!("failed parsing trust-state file: {error}"))
})
}
fn save_persisted(&self, persisted: &PersistedTrustState) -> Result<(), TrustStateError> {
let parent = self.path.parent().unwrap_or_else(|| Path::new(""));
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent).map_err(|error| {
TrustStateError::new(format!("failed creating trust-state directory: {error}"))
})?;
}
let encoded = serde_json::to_string_pretty(persisted).map_err(|error| {
TrustStateError::new(format!("failed encoding trust-state: {error}"))
})?;
fs::write(&self.path, encoded).map_err(|error| {
TrustStateError::new(format!("failed writing trust-state file: {error}"))
})
}
}
impl TrustStateStore for FileBackedTrustState {
fn is_token_revoked(&self, token_id: &str) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let persisted = self.load_persisted()?;
Ok(persisted.revoked_token_ids.contains(token_id))
}
fn is_agent_emergency_denied(&self, agent_id: &str) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let persisted = self.load_persisted()?;
Ok(persisted.emergency_denied_agents.contains(agent_id))
}
fn consume_nonce(
&self,
nonce: &str,
valid_until: DateTime<Utc>,
) -> Result<bool, TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let mut persisted = self.load_persisted()?;
persisted
.consumed_nonces
.retain(|_, expires_at| *expires_at >= valid_until);
if persisted.consumed_nonces.contains_key(nonce) {
return Ok(false);
}
persisted
.consumed_nonces
.insert(nonce.to_string(), valid_until);
self.save_persisted(&persisted)?;
Ok(true)
}
}
impl TrustStateAdmin for FileBackedTrustState {
fn revoke_token(&self, token_id: &str) -> Result<(), TrustStateError> {
self.revoke_token_local(token_id.to_string())
}
fn emergency_deny_agent(&self, agent_id: &str) -> Result<(), TrustStateError> {
self.emergency_deny_agent_local(agent_id.to_string())
}
fn clear_emergency_deny_list(&self) -> Result<u64, TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let mut persisted = self.load_persisted()?;
let count = persisted.emergency_denied_agents.len() as u64;
persisted.emergency_denied_agents.clear();
self.save_persisted(&persisted)?;
Ok(count)
}
fn flush_expired_nonces(&self, reference_time: DateTime<Utc>) -> Result<u64, TrustStateError> {
self.ensure_available()?;
let _lock = self.acquire_lock()?;
let mut persisted = self.load_persisted()?;
let before = persisted.consumed_nonces.len();
persisted
.consumed_nonces
.retain(|_, expires_at| *expires_at >= reference_time);
let removed = before - persisted.consumed_nonces.len();
if removed > 0 {
self.save_persisted(&persisted)?;
}
Ok(removed as u64)
}
}
#[derive(Debug)]
struct FileLockGuard {
lock_path: PathBuf,
}
impl Drop for FileLockGuard {
fn drop(&mut self) {
let _ = fs::remove_file(&self.lock_path);
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
struct PersistedTrustState {
revoked_token_ids: HashSet<String>,
emergency_denied_agents: HashSet<String>,
consumed_nonces: HashMap<String, DateTime<Utc>>,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn valid_until() -> DateTime<Utc> {
Utc.with_ymd_and_hms(2099, 1, 1, 0, 0, 0)
.single()
.expect("valid timestamp")
}
#[test]
fn in_memory_nonce_replay_is_blocked() {
let store = InMemoryTrustState::new();
assert!(
store
.consume_nonce("nonce-1", valid_until())
.expect("first consume should succeed")
);
assert!(
!store
.consume_nonce("nonce-1", valid_until())
.expect("second consume should be blocked")
);
}
#[test]
fn in_memory_bulk_revoke_and_clear() {
let store = InMemoryTrustState::new();
let ids = ["dlg_a", "dlg_b", "dlg_c"];
let count = store
.revoke_tokens(&ids)
.expect("bulk revoke should succeed");
assert_eq!(count, 3);
for id in &ids {
assert!(store.is_token_revoked(id).expect("query should succeed"));
}
store
.emergency_deny_agent("agent:bad")
.expect("deny should succeed");
assert_eq!(
store
.clear_emergency_deny_list()
.expect("clear should succeed"),
1
);
assert!(
!store
.is_agent_emergency_denied("agent:bad")
.expect("query should succeed")
);
}
#[test]
fn in_memory_flush_expired_nonces() {
let store = InMemoryTrustState::new();
let past = Utc
.with_ymd_and_hms(2020, 1, 1, 0, 0, 0)
.single()
.expect("valid ts");
store
.consume_nonce("old", past)
.expect("consume should succeed");
let reference = Utc
.with_ymd_and_hms(2025, 1, 1, 0, 0, 0)
.single()
.expect("valid ts");
let flushed = store
.flush_expired_nonces(reference)
.expect("flush should succeed");
assert_eq!(flushed, 1);
}
#[test]
fn file_store_persists_revocations_and_nonce_consumption() {
let path = std::env::temp_dir().join(format!(
"delegated_trust_state_{}.json",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let store = FileBackedTrustState::new(path.clone());
store
.revoke_token("dlg_abc")
.expect("revoke operation should persist");
assert!(
store
.is_token_revoked("dlg_abc")
.expect("revocation query should succeed")
);
assert!(
store
.consume_nonce("nonce-abc", valid_until())
.expect("nonce consume should succeed")
);
assert!(
!store
.consume_nonce("nonce-abc", valid_until())
.expect("nonce replay should be blocked")
);
std::fs::remove_file(&path).expect("state file should be removable");
}
#[test]
fn file_store_clear_emergency_deny_list() {
let path = std::env::temp_dir().join(format!(
"delegated_trust_state_clear_{}.json",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("time should be after epoch")
.as_nanos()
));
let store = FileBackedTrustState::new(path.clone());
store
.emergency_deny_agent("agent:bad")
.expect("deny should persist");
assert_eq!(
store
.clear_emergency_deny_list()
.expect("clear should succeed"),
1
);
assert!(
!store
.is_agent_emergency_denied("agent:bad")
.expect("query should succeed")
);
std::fs::remove_file(&path).expect("state file should be removable");
}
}