1use std::path::PathBuf;
8use std::sync::{Arc, Mutex, PoisonError};
9use std::time::SystemTime;
10
11use talon_security::senders::SenderAllowlist;
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15struct Stamp {
16 modified: Option<SystemTime>,
17 len: u64,
18}
19
20enum Mode {
21 File {
22 path: PathBuf,
23 required: bool,
24 cache: Mutex<Option<(Option<Stamp>, Arc<SenderAllowlist>)>>,
25 },
26 Fixed(Arc<SenderAllowlist>),
27}
28
29pub struct AllowlistSource {
31 mode: Mode,
32}
33
34impl std::fmt::Debug for AllowlistSource {
35 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
36 match &self.mode {
37 Mode::File { path, required, .. } => f
38 .debug_struct("AllowlistSource")
39 .field("path", path)
40 .field("required", required)
41 .finish(),
42 Mode::Fixed(_) => f.write_str("AllowlistSource(fixed)"),
43 }
44 }
45}
46
47impl AllowlistSource {
48 pub fn from_file(path: PathBuf, required: bool) -> Self {
51 Self {
52 mode: Mode::File {
53 path,
54 required,
55 cache: Mutex::new(None),
56 },
57 }
58 }
59
60 pub fn fixed(list: SenderAllowlist) -> Self {
62 Self {
63 mode: Mode::Fixed(Arc::new(list)),
64 }
65 }
66
67 pub fn current(&self) -> Arc<SenderAllowlist> {
69 match &self.mode {
70 Mode::Fixed(list) => Arc::clone(list),
71 Mode::File {
72 path,
73 required,
74 cache,
75 } => {
76 let stamp = std::fs::metadata(path).ok().map(|m| Stamp {
77 modified: m.modified().ok(),
78 len: m.len(),
79 });
80 let mut cache = cache.lock().unwrap_or_else(PoisonError::into_inner);
81 if let Some((cached, list)) = cache.as_ref()
82 && *cached == stamp
83 {
84 return Arc::clone(list);
85 }
86 let list = Arc::new(SenderAllowlist::load_with_policy(path, *required));
87 *cache = Some((stamp, Arc::clone(&list)));
88 list
89 }
90 }
91 }
92}
93
94#[cfg(test)]
95mod tests {
96 use super::*;
97
98 #[test]
99 fn missing_file_allows_everyone_unless_required() {
100 let dir = tempfile::tempdir().unwrap();
101 let path = dir.path().join("sender-allowlist.json");
102 let open = AllowlistSource::from_file(path.clone(), false);
103 assert!(open.current().is_trigger_allowed("tg:1", "alice"));
104 let closed = AllowlistSource::from_file(path, true);
105 assert!(!closed.current().is_trigger_allowed("tg:1", "alice"));
106 }
107
108 #[test]
109 fn edits_are_picked_up() {
110 let dir = tempfile::tempdir().unwrap();
111 let path = dir.path().join("sender-allowlist.json");
112 let src = AllowlistSource::from_file(path.clone(), false);
113 assert!(!src.current().should_drop_message("tg:1", "mallory"));
114 std::fs::write(
115 &path,
116 r#"{"default":{"allow":"*","mode":"trigger"},"chats":{"tg:1":{"allow":["alice"],"mode":"drop"}}}"#,
117 )
118 .unwrap();
119 let list = src.current();
120 assert!(list.should_drop_message("tg:1", "mallory"));
121 assert!(!list.should_drop_message("tg:1", "alice"));
122 }
123}