use std::path::PathBuf;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::SystemTime;
use talon_security::senders::SenderAllowlist;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Stamp {
modified: Option<SystemTime>,
len: u64,
}
enum Mode {
File {
path: PathBuf,
required: bool,
cache: Mutex<Option<(Option<Stamp>, Arc<SenderAllowlist>)>>,
},
Fixed(Arc<SenderAllowlist>),
}
pub struct AllowlistSource {
mode: Mode,
}
impl std::fmt::Debug for AllowlistSource {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.mode {
Mode::File { path, required, .. } => f
.debug_struct("AllowlistSource")
.field("path", path)
.field("required", required)
.finish(),
Mode::Fixed(_) => f.write_str("AllowlistSource(fixed)"),
}
}
}
impl AllowlistSource {
pub fn from_file(path: PathBuf, required: bool) -> Self {
Self {
mode: Mode::File {
path,
required,
cache: Mutex::new(None),
},
}
}
pub fn fixed(list: SenderAllowlist) -> Self {
Self {
mode: Mode::Fixed(Arc::new(list)),
}
}
pub fn current(&self) -> Arc<SenderAllowlist> {
match &self.mode {
Mode::Fixed(list) => Arc::clone(list),
Mode::File {
path,
required,
cache,
} => {
let stamp = std::fs::metadata(path).ok().map(|m| Stamp {
modified: m.modified().ok(),
len: m.len(),
});
let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
if let Some((cached, list)) = cache.as_ref()
&& *cached == stamp
{
return Arc::clone(list);
}
let list = Arc::new(SenderAllowlist::load_with_policy(path, *required));
*cache = Some((stamp, Arc::clone(&list)));
list
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_allows_everyone_unless_required() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sender-allowlist.json");
let open = AllowlistSource::from_file(path.clone(), false);
assert!(open.current().is_trigger_allowed("tg:1", "alice"));
let closed = AllowlistSource::from_file(path, true);
assert!(!closed.current().is_trigger_allowed("tg:1", "alice"));
}
#[test]
fn edits_are_picked_up() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sender-allowlist.json");
let src = AllowlistSource::from_file(path.clone(), false);
assert!(!src.current().should_drop_message("tg:1", "mallory"));
std::fs::write(
&path,
r#"{"default":{"allow":"*","mode":"trigger"},"chats":{"tg:1":{"allow":["alice"],"mode":"drop"}}}"#,
)
.unwrap();
let list = src.current();
assert!(list.should_drop_message("tg:1", "mallory"));
assert!(!list.should_drop_message("tg:1", "alice"));
}
}