use std::collections::{BTreeSet, HashMap};
use std::hash::{BuildHasher, Hasher};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant, SystemTime};
#[derive(Debug, Clone)]
pub struct NonceScope {
command: String,
paths: BTreeSet<String>,
}
impl NonceScope {
pub fn command(&self) -> &str {
&self.command
}
pub fn paths(&self) -> &BTreeSet<String> {
&self.paths
}
}
#[derive(Clone, Debug)]
pub struct NonceStore {
inner: Arc<Mutex<NonceStoreInner>>,
ttl: Duration,
}
#[derive(Debug)]
struct NonceStoreInner {
nonces: HashMap<String, (Instant, NonceScope)>,
}
impl NonceStore {
pub fn new() -> Self {
Self::with_ttl(Duration::from_secs(60))
}
pub fn with_ttl(ttl: Duration) -> Self {
Self {
inner: Arc::new(Mutex::new(NonceStoreInner {
nonces: HashMap::new(),
})),
ttl,
}
}
pub fn lookup(&self, nonce: &str) -> Result<NonceScope, String> {
let now = Instant::now();
let ttl = self.ttl;
#[allow(clippy::expect_used)]
let inner = self.inner.lock().expect("nonce store poisoned");
match inner.nonces.get(nonce) {
Some((created, scope)) => {
if now.duration_since(*created) >= ttl {
Err("nonce expired".to_string())
} else {
Ok(scope.clone())
}
}
None => Err("invalid nonce".to_string()),
}
}
pub fn issue(&self, command: &str, paths: &[&str]) -> String {
let nonce = generate_nonce();
let now = Instant::now();
let ttl = self.ttl;
let scope = NonceScope {
command: command.to_string(),
paths: paths.iter().map(|p| p.to_string()).collect(),
};
#[allow(clippy::expect_used)]
let mut inner = self.inner.lock().expect("nonce store poisoned");
inner.nonces.retain(|_, (created, _)| now.duration_since(*created) < ttl);
inner.nonces.insert(nonce.clone(), (now, scope));
nonce
}
pub fn validate(&self, nonce: &str, command: &str, paths: &[&str]) -> Result<(), String> {
let now = Instant::now();
let ttl = self.ttl;
#[allow(clippy::expect_used)]
let inner = self.inner.lock().expect("nonce store poisoned");
match inner.nonces.get(nonce) {
Some((created, scope)) => {
if now.duration_since(*created) >= ttl {
return Err("nonce expired".to_string());
}
if scope.command != command {
return Err(format!(
"nonce scope mismatch: issued for command '{}', got '{}'",
scope.command, command
));
}
if let Some(unauthorized) = paths.iter().find(|p| !scope.paths.contains(**p)) {
return Err(format!(
"nonce scope mismatch: unauthorized path '{}' (authorized: {:?})",
unauthorized,
scope.paths.iter().collect::<Vec<_>>()
));
}
Ok(())
}
None => Err("invalid nonce".to_string()),
}
}
pub fn ttl(&self) -> Duration {
self.ttl
}
}
impl Default for NonceStore {
fn default() -> Self {
Self::new()
}
}
fn generate_nonce() -> String {
let hasher_state = std::collections::hash_map::RandomState::new();
let mut hasher = hasher_state.build_hasher();
let now = SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default();
hasher.write_u128(now.as_nanos());
let hasher_state2 = std::collections::hash_map::RandomState::new();
let mut hasher2 = hasher_state2.build_hasher();
hasher2.write_u64(0xdeadbeef);
hasher.write_u64(hasher2.finish());
format!("{:08x}", hasher.finish() as u32)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn issue_and_validate() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["/tmp/important"]);
assert_eq!(nonce.len(), 8);
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
let result = store.validate(&nonce, "rm", &["/tmp/important"]);
assert!(result.is_ok());
}
#[test]
fn idempotent_reuse() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["bigdir/"]);
let first = store.validate(&nonce, "rm", &["bigdir/"]);
let second = store.validate(&nonce, "rm", &["bigdir/"]);
assert!(first.is_ok());
assert!(second.is_ok());
}
#[test]
fn expired_nonce_fails() {
let store = NonceStore::with_ttl(Duration::from_millis(0));
let nonce = store.issue("rm", &["ephemeral"]);
std::thread::sleep(Duration::from_millis(1));
let result = store.validate(&nonce, "rm", &["ephemeral"]);
assert_eq!(result, Err("nonce expired".to_string()));
}
#[test]
fn invalid_nonce_fails() {
let store = NonceStore::new();
let result = store.validate("bogus123", "rm", &["anything"]);
assert_eq!(result, Err("invalid nonce".to_string()));
}
#[test]
fn nonces_are_unique() {
let store = NonceStore::new();
let a = store.issue("rm", &["first"]);
let b = store.issue("rm", &["second"]);
assert_ne!(a, b);
}
#[test]
fn clone_shares_state() {
let store = NonceStore::new();
let cloned = store.clone();
let nonce = store.issue("rm", &["/shared"]);
let result = cloned.validate(&nonce, "rm", &["/shared"]);
assert!(result.is_ok());
}
#[test]
fn gc_cleans_expired() {
let store = NonceStore::with_ttl(Duration::from_millis(10));
let old_nonce = store.issue("rm", &["old"]);
std::thread::sleep(Duration::from_millis(20));
let _new = store.issue("rm", &["new"]);
let result = store.validate(&old_nonce, "rm", &["old"]);
assert!(result.is_err());
}
#[test]
fn path_mismatch_rejected() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["fileA.txt"]);
let result = store.validate(&nonce, "rm", &["fileB.txt"]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("nonce scope mismatch"));
}
#[test]
fn subset_accepted() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["a.txt", "b.txt", "c.txt"]);
let result = store.validate(&nonce, "rm", &["a.txt", "b.txt"]);
assert!(result.is_ok());
}
#[test]
fn superset_rejected() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["a.txt", "b.txt"]);
let result = store.validate(&nonce, "rm", &["a.txt", "b.txt", "c.txt"]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("unauthorized"));
}
#[test]
fn command_mismatch_rejected() {
let store = NonceStore::new();
let nonce = store.issue("rm", &["file.txt"]);
let result = store.validate(&nonce, "kaish-trash empty", &[]);
assert!(result.is_err());
assert!(result.unwrap_err().contains("command"));
}
#[test]
fn empty_paths_command_only() {
let store = NonceStore::new();
let nonce = store.issue("kaish-trash empty", &[]);
let result = store.validate(&nonce, "kaish-trash empty", &[]);
assert!(result.is_ok());
}
#[test]
fn empty_paths_rejects_nonempty() {
let store = NonceStore::new();
let nonce = store.issue("kaish-trash empty", &[]);
let result = store.validate(&nonce, "kaish-trash empty", &["sneaky.txt"]);
assert!(result.is_err());
}
}