mail4agent-messenger-shell 0.4.30

Client shell that opens the sealed messenger store for one session.
Documentation
//! Who may interrupt a busy session, and the receipt of an interrupt letter.
//!
//! A letter whose event carries `fyi.hatchery.interrupt: true` asks the receiving session to cancel
//! its live turn and take the letter at once (an ordinary letter waits for the next turn). Only
//! senders on the allow-list may do that: the owner and the managers. The list is, first match wins:
//!
//! * `M4A_INTERRUPT_FROM` (comma or space separated);
//! * `interrupt.json` in the session's store directory, then in the store root: `{"from": [...]}`.
//!
//! An entry is a full user id (`@nick:server`, exact match) or a bare nick, which matches only a
//! sender on the receiver's own server. No list: nobody may interrupt. A letter from anyone else
//! loses the flag (the shell strips it before the wake chain sees the letter), is delivered as an
//! ordinary letter, and gets the `denied` receipt.

use std::path::Path;

use serde::{Deserialize, Serialize};

pub const INTERRUPT_KEY: &str = "fyi.hatchery.interrupt";
/// Set by the receiver in place of [`INTERRUPT_KEY`] when the sender may not 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 {
    /// The list in force for a session store (module doc). `None`: nothing configured.
    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)) {
                // A damaged file allows nobody.
                return Some(serde_json::from_str(&text).unwrap_or_default());
            }
        }
        None
    }

    /// Whether `sender` (a full user id) may interrupt a session whose own id is `me`.
    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),
    }
}

/// The `@nick` / `@nick:server` tags of `text`, in order, without duplicates: whom an interrupt
/// letter addresses. A nick may hold letters, digits, `-`, `_` and `.` (a trailing `.` is
/// punctuation).
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
}

/// Content fields of the source event with the interrupt decided: `(fields, requested, allowed)`.
/// A denied flag is replaced by [`INTERRUPT_DENIED_KEY`], so no adapter can act on it.
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)
}

/// What became of an interrupt letter: the receipt the receiver gives the sender.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum InterruptReceipt {
    /// The live turn was cancelled and the letter delivered at once.
    Interrupted,
    /// Delivered without a cancel (the session was idle, or its channel cannot cancel).
    Delivered,
    /// The sender may not interrupt: delivered as an ordinary letter.
    Denied,
}

impl InterruptReceipt {
    pub fn name(self) -> &'static str {
        match self {
            Self::Interrupted => "interrupted",
            Self::Delivered => "delivered",
            Self::Denied => "denied",
        }
    }
    /// The reaction the receiver puts on the sender's message.
    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());
    }
}