mail4agent_messenger_shell/
interrupt.rs1use std::path::Path;
16
17use serde::{Deserialize, Serialize};
18
19pub const INTERRUPT_KEY: &str = "fyi.hatchery.interrupt";
20pub const INTERRUPT_DENIED_KEY: &str = "fyi.hatchery.interrupt_denied";
22pub const INTERRUPT_FROM_ENV: &str = "M4A_INTERRUPT_FROM";
23const POLICY_FILE: &str = "interrupt.json";
24
25#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
26pub struct InterruptPolicy {
27 #[serde(default)]
28 pub from: Vec<String>,
29}
30
31impl InterruptPolicy {
32 pub fn load(store_dir: &Path) -> Option<Self> {
34 if let Ok(raw) = std::env::var(INTERRUPT_FROM_ENV) {
35 let from: Vec<String> =
36 raw.split(|c: char| c == ',' || c.is_whitespace()).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
37 return Some(Self { from });
38 }
39 let dirs = [Some(store_dir), store_dir.parent()];
40 for dir in dirs.into_iter().flatten() {
41 if let Ok(text) = std::fs::read_to_string(dir.join(POLICY_FILE)) {
42 return Some(serde_json::from_str(&text).unwrap_or_default());
44 }
45 }
46 None
47 }
48
49 pub fn allows(&self, sender: &str, me: &str) -> bool {
51 let sender = sender.trim().to_lowercase();
52 let (s_local, s_server) = split_id(&sender);
53 let me = me.to_lowercase();
54 let (_, my_server) = split_id(&me);
55 self.from.iter().any(|entry| {
56 let entry = entry.trim().to_lowercase();
57 if entry.starts_with('@') && entry.contains(':') {
58 entry == sender
59 } else {
60 let nick = entry.trim_start_matches('@');
61 !nick.is_empty() && nick == s_local && s_server.is_some() && s_server == my_server
62 }
63 })
64 }
65}
66
67fn split_id(id: &str) -> (&str, Option<&str>) {
68 let id = id.trim_start_matches('@');
69 match id.split_once(':') {
70 Some((local, server)) => (local, Some(server)),
71 None => (id, None),
72 }
73}
74
75pub fn mention_tags(text: &str) -> Vec<String> {
79 let mut out: Vec<String> = Vec::new();
80 let chars: Vec<char> = text.chars().collect();
81 let mut i = 0;
82 while i < chars.len() {
83 let starts = chars[i] == '@' && (i == 0 || !(chars[i - 1].is_alphanumeric() || chars[i - 1] == '_'));
84 if !starts {
85 i += 1;
86 continue;
87 }
88 let mut j = i + 1;
89 while j < chars.len() && (chars[j].is_alphanumeric() || matches!(chars[j], '-' | '_' | '.' | ':')) {
90 j += 1;
91 }
92 let tag: String = chars[i + 1..j].iter().collect();
93 let tag = tag.trim_end_matches(['.', ':']).to_string();
94 if !tag.is_empty() && !out.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
95 out.push(tag);
96 }
97 i = j.max(i + 1);
98 }
99 out
100}
101
102pub fn authorize(
105 fields: Option<serde_json::Value>,
106 sender: &str,
107 me: &str,
108 policy: Option<&InterruptPolicy>,
109) -> (Option<serde_json::Value>, bool, bool) {
110 let requested = fields.as_ref().is_some_and(|c| c.get(INTERRUPT_KEY) == Some(&serde_json::Value::Bool(true)));
111 if !requested {
112 return (fields, false, false);
113 }
114 let allowed = policy.is_some_and(|p| p.allows(sender, me));
115 if allowed {
116 return (fields, true, true);
117 }
118 let mut fields = fields;
119 if let Some(map) = fields.as_mut().and_then(serde_json::Value::as_object_mut) {
120 map.remove(INTERRUPT_KEY);
121 map.insert(INTERRUPT_DENIED_KEY.to_string(), serde_json::Value::Bool(true));
122 }
123 (fields, true, false)
124}
125
126#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum InterruptReceipt {
130 Interrupted,
132 Delivered,
134 Denied,
136}
137
138impl InterruptReceipt {
139 pub fn name(self) -> &'static str {
140 match self {
141 Self::Interrupted => "interrupted",
142 Self::Delivered => "delivered",
143 Self::Denied => "denied",
144 }
145 }
146 pub fn emoji(self) -> &'static str {
148 match self {
149 Self::Interrupted => "⏹️",
150 Self::Delivered => "📨",
151 Self::Denied => "🚫",
152 }
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use serde_json::json;
160
161 const ME: &str = "@worker-a:hq.example";
162
163 #[test]
164 fn only_listed_senders_may_interrupt() {
165 let p = InterruptPolicy { from: vec!["@owner:hq.example".into(), "hatcheryweb".into()] };
166 assert!(p.allows("@owner:hq.example", ME));
167 assert!(p.allows("@HatcheryWeb:hq.example", ME), "a bare nick matches on the receiver's own server");
168 assert!(!p.allows("@hatcheryweb:evil.example", ME), "a bare nick never matches another server");
169 assert!(!p.allows("@worker:hq.example", ME));
170 assert!(!InterruptPolicy::default().allows("@owner:hq.example", ME));
171 }
172
173 #[test]
174 fn a_denied_flag_is_stripped_and_marked() {
175 let p = InterruptPolicy { from: vec!["@owner:hq.example".into()] };
176 let fields = Some(json!({INTERRUPT_KEY: true, "fyi.hatchery.task": 7}));
177 let (kept, requested, allowed) = authorize(fields.clone(), "@owner:hq.example", ME, Some(&p));
178 assert_eq!((kept, requested, allowed), (fields.clone(), true, true));
179 let (stripped, requested, allowed) = authorize(fields.clone(), "@worker:hq.example", ME, Some(&p));
180 assert!(requested && !allowed);
181 assert_eq!(stripped, Some(json!({INTERRUPT_DENIED_KEY: true, "fyi.hatchery.task": 7})));
182 let (_, _, allowed) = authorize(fields, "@owner:hq.example", ME, None);
183 assert!(!allowed, "no list: nobody interrupts");
184 let plain = Some(json!({"fyi.hatchery.task": 7}));
185 assert_eq!(authorize(plain.clone(), "@worker:hq.example", ME, Some(&p)), (plain, false, false));
186 }
187
188 #[test]
189 fn the_policy_comes_from_env_then_store_then_root() {
190 let root = tempfile::tempdir().unwrap();
191 let store = root.path().join("sess");
192 std::fs::create_dir_all(&store).unwrap();
193 assert_eq!(InterruptPolicy::load(&store), None);
194 std::fs::write(root.path().join(POLICY_FILE), r#"{"from":["root-mgr"]}"#).unwrap();
195 assert_eq!(InterruptPolicy::load(&store).unwrap().from, ["root-mgr"]);
196 std::fs::write(store.join(POLICY_FILE), r#"{"from":["store-mgr"]}"#).unwrap();
197 assert_eq!(InterruptPolicy::load(&store).unwrap().from, ["store-mgr"]);
198 std::fs::write(store.join(POLICY_FILE), "{broken").unwrap();
199 assert_eq!(InterruptPolicy::load(&store).unwrap().from, Vec::<String>::new());
200 }
201
202 #[test]
203 fn mention_tags_are_found_once_each() {
204 assert_eq!(mention_tags("@worker-a @worker-b, stop. @worker-a mail me@x.y"), ["worker-a", "worker-b"]);
205 assert_eq!(mention_tags("see @node.a.b. and @u:hq.example:"), ["node.a.b", "u:hq.example"]);
206 assert!(mention_tags("no tags here").is_empty());
207 }
208}