Skip to main content

zeroclaw_channels/
allowlist.rs

1//! Shared `allowed_users` matching used by every chat channel.
2//!
3//! Each channel (Slack, Discord, IRC, Telegram, Matrix, …) carries an
4//! `allowed_users: Vec<String>` allowlist with the same semantics:
5//!
6//! - `["*"]` (or any list containing `"*"`) means "anyone".
7//! - Empty list means "deny everyone" (channel is on but no inbound is
8//!   accepted yet — matches the "configured but not opened" stance the
9//!   channel docs use).
10//! - Otherwise, exact match against the user's identifier wins.
11//!
12//! IRC nicks are case-insensitive per RFC 2812; Matrix MXIDs are also
13//! case-insensitive. Most other channels (Slack user IDs, Discord
14//! snowflakes, Telegram usernames) are case-sensitive. The
15//! [`Match::Sensitive`] / [`Match::CaseInsensitive`] selector encodes
16//! that per-channel choice without growing a parallel impl.
17
18/// Case-sensitivity selector for the allowlist comparison. The chat
19/// platform defines which one applies; the helper does not infer.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum Match {
22    /// Exact `==` match.
23    Sensitive,
24    /// `eq_ignore_ascii_case` — IRC nicks, Matrix MXIDs.
25    CaseInsensitive,
26}
27
28/// Return `true` when `user` is allowed under `allowed`.
29///
30/// Single source of truth for the per-channel `is_user_allowed` checks.
31/// Callers spell their channel's case-sensitivity by passing the
32/// matching [`Match`] variant; the helper handles the wildcard, empty,
33/// and per-entry comparisons identically across every channel.
34#[must_use]
35pub fn is_user_allowed(allowed: &[String], user: &str, mode: Match) -> bool {
36    if allowed.iter().any(|u| u == "*") {
37        return true;
38    }
39    match mode {
40        Match::Sensitive => allowed.iter().any(|u| u == user),
41        Match::CaseInsensitive => allowed.iter().any(|u| u.eq_ignore_ascii_case(user)),
42    }
43}
44
45/// Return `true` when `user` is allowed under `allowed`, using a
46/// caller-provided `(entry, user) -> bool` comparison for the per-entry
47/// check.
48///
49/// Same single-source-of-truth shape as [`is_user_allowed`] — wildcard `"*"`
50/// admits everyone and the comparison runs against the caller's
51/// freshly-resolved `allowed` slice, so no allowlist state is cached. This
52/// covers the channels whose identity matching cannot be expressed by the
53/// two [`Match`] modes: E.164 phone normalization (WhatsApp), domain-class
54/// email matching (`@host` admitting a whole domain), etc. The `match_fn`
55/// owns only the per-entry comparison; the wildcard short-circuit stays here
56/// so every channel keeps identical wildcard semantics.
57#[must_use]
58pub fn is_user_allowed_by(
59    allowed: &[String],
60    user: &str,
61    match_fn: impl Fn(&str, &str) -> bool,
62) -> bool {
63    if allowed.iter().any(|u| u == "*") {
64        return true;
65    }
66    allowed.iter().any(|entry| match_fn(entry, user))
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn wildcard_allows_anyone() {
75        let list = vec!["*".to_string()];
76        assert!(is_user_allowed(&list, "alice", Match::Sensitive));
77        assert!(is_user_allowed(&list, "ALICE", Match::Sensitive));
78    }
79
80    #[test]
81    fn empty_list_denies_everyone() {
82        assert!(!is_user_allowed(&[], "alice", Match::Sensitive));
83        assert!(!is_user_allowed(&[], "alice", Match::CaseInsensitive));
84    }
85
86    #[test]
87    fn exact_match_case_sensitive() {
88        let list = vec!["alice".to_string()];
89        assert!(is_user_allowed(&list, "alice", Match::Sensitive));
90        assert!(!is_user_allowed(&list, "Alice", Match::Sensitive));
91    }
92
93    #[test]
94    fn exact_match_case_insensitive() {
95        let list = vec!["Alice".to_string()];
96        assert!(is_user_allowed(&list, "alice", Match::CaseInsensitive));
97        assert!(is_user_allowed(&list, "ALICE", Match::CaseInsensitive));
98    }
99
100    // --- is_user_allowed_by (caller-provided matcher) ---------------
101
102    #[test]
103    fn by_empty_denies_and_wildcard_admits() {
104        let eq = |e: &str, u: &str| e == u;
105        assert!(!is_user_allowed_by(&[], "alice", eq));
106        assert!(is_user_allowed_by(&["*".to_string()], "anyone", eq));
107    }
108
109    #[test]
110    fn by_email_domain_class() {
111        // Mirrors email_channel / gmail_push: "@host" / bare "host" match the
112        // whole domain; "user@host" is a full case-insensitive address.
113        let matcher = |allowed: &str, email: &str| -> bool {
114            let email_lower = email.to_lowercase();
115            if allowed.starts_with('@') {
116                email_lower.ends_with(&allowed.to_lowercase())
117            } else if allowed.contains('@') {
118                allowed.eq_ignore_ascii_case(email)
119            } else {
120                email_lower.ends_with(&format!("@{}", allowed.to_lowercase()))
121            }
122        };
123        let list = vec!["@example.com".to_string(), "boss@corp.io".to_string()];
124        assert!(is_user_allowed_by(&list, "anyone@Example.com", matcher));
125        assert!(is_user_allowed_by(&list, "BOSS@corp.io", matcher));
126        assert!(!is_user_allowed_by(&list, "user@evil.com", matcher));
127    }
128
129    #[test]
130    fn by_phone_e164_normalized() {
131        // Mirrors whatsapp_web E.164 normalization (digits only, leading +).
132        let norm = |s: &str| -> String {
133            let mut out = String::new();
134            let mut chars = s.chars();
135            if let Some('+') = chars.clone().next() {
136                out.push('+');
137                chars.next();
138            }
139            out.extend(chars.filter(|c| c.is_ascii_digit()));
140            out
141        };
142        let matcher = |entry: &str, phone: &str| norm(entry) == norm(phone);
143        let list = vec!["+1-555-0100".to_string()];
144        assert!(is_user_allowed_by(&list, "+1 555 0100", matcher));
145        assert!(!is_user_allowed_by(&list, "+15550101", matcher));
146    }
147}