use std::path::Path;
use serde::{Deserialize, Serialize};
pub const INTERRUPT_KEY: &str = "fyi.hatchery.interrupt";
pub const INTERRUPT_DENIED_KEY: &str = "fyi.hatchery.interrupt_denied";
pub const INTERRUPT_FROM_ENV: &str = "M4A_INTERRUPT_FROM";
const POLICY_FILE: &str = "interrupt.json";
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InterruptPolicy {
#[serde(default)]
pub from: Vec<String>,
}
impl InterruptPolicy {
pub fn load(store_dir: &Path) -> Option<Self> {
if let Ok(raw) = std::env::var(INTERRUPT_FROM_ENV) {
let from: Vec<String> =
raw.split(|c: char| c == ',' || c.is_whitespace()).map(str::trim).filter(|s| !s.is_empty()).map(str::to_string).collect();
return Some(Self { from });
}
let dirs = [Some(store_dir), store_dir.parent()];
for dir in dirs.into_iter().flatten() {
if let Ok(text) = std::fs::read_to_string(dir.join(POLICY_FILE)) {
return Some(serde_json::from_str(&text).unwrap_or_default());
}
}
None
}
pub fn allows(&self, sender: &str, me: &str) -> bool {
let sender = sender.trim().to_lowercase();
let (s_local, s_server) = split_id(&sender);
let me = me.to_lowercase();
let (_, my_server) = split_id(&me);
self.from.iter().any(|entry| {
let entry = entry.trim().to_lowercase();
if entry.starts_with('@') && entry.contains(':') {
entry == sender
} else {
let nick = entry.trim_start_matches('@');
!nick.is_empty() && nick == s_local && s_server.is_some() && s_server == my_server
}
})
}
}
fn split_id(id: &str) -> (&str, Option<&str>) {
let id = id.trim_start_matches('@');
match id.split_once(':') {
Some((local, server)) => (local, Some(server)),
None => (id, None),
}
}
pub fn mention_tags(text: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0;
while i < chars.len() {
let starts = chars[i] == '@' && (i == 0 || !(chars[i - 1].is_alphanumeric() || chars[i - 1] == '_'));
if !starts {
i += 1;
continue;
}
let mut j = i + 1;
while j < chars.len() && (chars[j].is_alphanumeric() || matches!(chars[j], '-' | '_' | '.' | ':')) {
j += 1;
}
let tag: String = chars[i + 1..j].iter().collect();
let tag = tag.trim_end_matches(['.', ':']).to_string();
if !tag.is_empty() && !out.iter().any(|t| t.eq_ignore_ascii_case(&tag)) {
out.push(tag);
}
i = j.max(i + 1);
}
out
}
pub fn authorize(
fields: Option<serde_json::Value>,
sender: &str,
me: &str,
policy: Option<&InterruptPolicy>,
) -> (Option<serde_json::Value>, bool, bool) {
let requested = fields.as_ref().is_some_and(|c| c.get(INTERRUPT_KEY) == Some(&serde_json::Value::Bool(true)));
if !requested {
return (fields, false, false);
}
let allowed = policy.is_some_and(|p| p.allows(sender, me));
if allowed {
return (fields, true, true);
}
let mut fields = fields;
if let Some(map) = fields.as_mut().and_then(serde_json::Value::as_object_mut) {
map.remove(INTERRUPT_KEY);
map.insert(INTERRUPT_DENIED_KEY.to_string(), serde_json::Value::Bool(true));
}
(fields, true, false)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InterruptReceipt {
Interrupted,
Delivered,
Denied,
}
impl InterruptReceipt {
pub fn name(self) -> &'static str {
match self {
Self::Interrupted => "interrupted",
Self::Delivered => "delivered",
Self::Denied => "denied",
}
}
pub fn emoji(self) -> &'static str {
match self {
Self::Interrupted => "⏹️",
Self::Delivered => "📨",
Self::Denied => "🚫",
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const ME: &str = "@worker-a:hq.example";
#[test]
fn only_listed_senders_may_interrupt() {
let p = InterruptPolicy { from: vec!["@owner:hq.example".into(), "hatcheryweb".into()] };
assert!(p.allows("@owner:hq.example", ME));
assert!(p.allows("@HatcheryWeb:hq.example", ME), "a bare nick matches on the receiver's own server");
assert!(!p.allows("@hatcheryweb:evil.example", ME), "a bare nick never matches another server");
assert!(!p.allows("@worker:hq.example", ME));
assert!(!InterruptPolicy::default().allows("@owner:hq.example", ME));
}
#[test]
fn a_denied_flag_is_stripped_and_marked() {
let p = InterruptPolicy { from: vec!["@owner:hq.example".into()] };
let fields = Some(json!({INTERRUPT_KEY: true, "fyi.hatchery.task": 7}));
let (kept, requested, allowed) = authorize(fields.clone(), "@owner:hq.example", ME, Some(&p));
assert_eq!((kept, requested, allowed), (fields.clone(), true, true));
let (stripped, requested, allowed) = authorize(fields.clone(), "@worker:hq.example", ME, Some(&p));
assert!(requested && !allowed);
assert_eq!(stripped, Some(json!({INTERRUPT_DENIED_KEY: true, "fyi.hatchery.task": 7})));
let (_, _, allowed) = authorize(fields, "@owner:hq.example", ME, None);
assert!(!allowed, "no list: nobody interrupts");
let plain = Some(json!({"fyi.hatchery.task": 7}));
assert_eq!(authorize(plain.clone(), "@worker:hq.example", ME, Some(&p)), (plain, false, false));
}
#[test]
fn the_policy_comes_from_env_then_store_then_root() {
let root = tempfile::tempdir().unwrap();
let store = root.path().join("sess");
std::fs::create_dir_all(&store).unwrap();
assert_eq!(InterruptPolicy::load(&store), None);
std::fs::write(root.path().join(POLICY_FILE), r#"{"from":["root-mgr"]}"#).unwrap();
assert_eq!(InterruptPolicy::load(&store).unwrap().from, ["root-mgr"]);
std::fs::write(store.join(POLICY_FILE), r#"{"from":["store-mgr"]}"#).unwrap();
assert_eq!(InterruptPolicy::load(&store).unwrap().from, ["store-mgr"]);
std::fs::write(store.join(POLICY_FILE), "{broken").unwrap();
assert_eq!(InterruptPolicy::load(&store).unwrap().from, Vec::<String>::new());
}
#[test]
fn mention_tags_are_found_once_each() {
assert_eq!(mention_tags("@worker-a @worker-b, stop. @worker-a mail me@x.y"), ["worker-a", "worker-b"]);
assert_eq!(mention_tags("see @node.a.b. and @u:hq.example:"), ["node.a.b", "u:hq.example"]);
assert!(mention_tags("no tags here").is_empty());
}
}