Skip to main content

talon/
allowlist.rs

1//! Live view of the sender allowlist.
2//!
3//! An operator can edit `sender-allowlist.json` without a restart:
4//! [`AllowlistSource`] picks up changes on the next check, but only
5//! re-parses when the file's modification time or size changed.
6
7use std::path::PathBuf;
8use std::sync::{Arc, Mutex, PoisonError};
9use std::time::SystemTime;
10
11use talon_security::senders::SenderAllowlist;
12
13/// File identity used to detect edits.
14#[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
29/// Source of the current [`SenderAllowlist`].
30pub 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    /// Read `path`; with `required`, a missing or invalid file denies every
49    /// sender (`TALON_SENDER_ALLOWLIST_REQUIRED`).
50    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    /// Always the given allowlist (tests).
61    pub fn fixed(list: SenderAllowlist) -> Self {
62        Self {
63            mode: Mode::Fixed(Arc::new(list)),
64        }
65    }
66
67    /// The allowlist as currently on disk.
68    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}