Skip to main content

khive_gate/
mailbox.rs

1use std::collections::BTreeSet;
2use std::fmt;
3
4use sha2::{Digest, Sha256};
5use thiserror::Error;
6
7use crate::{ActorRef, Gate, GateDecision, GateError, GateRef, GateRequest};
8
9const FINGERPRINT_VERSION: &[u8] = b"khive.mailbox-read-gate.v1\0";
10
11/// Malformed mailbox policy or selector. Labels are exact values, never namespaces.
12#[derive(Clone, Debug, Error, PartialEq, Eq)]
13pub enum MailboxPolicyError {
14    #[error("mailbox readers require an explicit, non-local actor owner")]
15    InvalidOwner,
16    #[error("mailbox reader must be a non-anonymous, non-local actor with a nonblank label of at most 255 bytes and no control characters")]
17    InvalidReader,
18    #[error("mailbox_readers accepts at most 256 entries before deduplication")]
19    TooManyReaders,
20    #[error("mailbox_actor must be a nonblank, non-local string of at most 255 bytes with no control characters")]
21    InvalidSelector,
22}
23
24/// Whether an exact mailbox actor label is eligible for explicit selection.
25///
26/// Colons and other ordinary characters are literal; this does not parse a
27/// namespace, trim the label, split a hierarchy, or match a wildcard.
28pub fn is_valid_mailbox_actor_label(label: &str) -> bool {
29    !label.trim().is_empty()
30        && label.len() <= 255
31        && !label.chars().any(char::is_control)
32        && label != "local"
33}
34
35fn valid_actor(actor: &ActorRef) -> bool {
36    !actor.is_anonymous()
37        && !actor.kind.trim().is_empty()
38        && actor.kind.len() <= 255
39        && !actor.kind.chars().any(char::is_control)
40        && is_valid_mailbox_actor_label(&actor.id)
41}
42
43/// Validate a read selector and return its owner only for a delegated view.
44///
45/// Runtime labels resolve to `ActorRef { kind: "actor", id: full_label }`.
46/// An omitted selector retains legacy behavior, including the local mailbox.
47pub fn mailbox_read_owner(req: &GateRequest) -> Result<Option<ActorRef>, MailboxPolicyError> {
48    let selector = match req.verb.as_str() {
49        "comm.inbox" | "comm.thread" => "mailbox_actor",
50        "comm.probe" => "actor",
51        _ => return Ok(None),
52    };
53    let Some(value) = req.args.get(selector) else {
54        return Ok(None);
55    };
56    let label = value
57        .as_str()
58        .filter(|label| {
59            is_valid_mailbox_actor_label(label) || (req.verb == "comm.probe" && *label == "local")
60        })
61        .ok_or(MailboxPolicyError::InvalidSelector)?;
62    // Probe has always allowed the anonymous caller's exact local mailbox.
63    let owner = if req.verb == "comm.probe" && label == "local" && req.actor.is_anonymous() {
64        ActorRef::anonymous()
65    } else {
66        ActorRef::new("actor", label)
67    };
68    Ok((owner != req.actor).then_some(owner))
69}
70
71fn apply_mailbox_policy(
72    gate: &(impl Gate + ?Sized),
73    req: &GateRequest,
74    base: GateDecision,
75) -> Result<GateDecision, GateError> {
76    let GateDecision::Allow { mut obligations } = base else {
77        return Ok(base);
78    };
79    let Some(owner) = mailbox_read_owner(req).map_err(|e| GateError::Policy(e.to_string()))? else {
80        return Ok(GateDecision::allow_with(obligations));
81    };
82    if !valid_actor(&req.actor) {
83        return Ok(GateDecision::deny("mailbox_read_not_granted"));
84    }
85    match gate.check_mailbox_read(req, &owner)? {
86        GateDecision::Allow { obligations: extra } => {
87            obligations.extend(extra);
88            Ok(GateDecision::allow_with(obligations))
89        }
90        denied => Ok(denied),
91    }
92}
93
94/// Compose ordinary admission with the separate, default-deny mailbox capability.
95///
96/// Dispatchers use this even when no mailbox policy is installed, so a generic
97/// permissive gate cannot accidentally admit an explicit cross-actor selector.
98pub fn check_with_mailbox_policy(
99    gate: &(impl Gate + ?Sized),
100    req: &GateRequest,
101) -> Result<GateDecision, GateError> {
102    apply_mailbox_policy(gate, req, gate.check(req)?)
103}
104
105/// Immutable trusted-local owner/reader policy, composed with an existing gate.
106///
107/// Grants compare both actor kind and complete id. A caller label containing
108/// colons is not split into a hierarchy. This configuration is trusted host
109/// policy, not caller authentication. Replacing it requires a new serving epoch.
110#[derive(Clone)]
111pub struct MailboxReadGate {
112    inner: GateRef,
113    owner: ActorRef,
114    readers: BTreeSet<(String, String)>,
115    fingerprint: String,
116}
117
118impl MailboxReadGate {
119    /// Construct and validate a bounded policy. Duplicate readers are harmless;
120    /// the raw list is bounded before deduplication.
121    pub fn new(
122        inner: GateRef,
123        owner: ActorRef,
124        readers: Vec<ActorRef>,
125    ) -> Result<Self, MailboxPolicyError> {
126        if !valid_actor(&owner) {
127            return Err(MailboxPolicyError::InvalidOwner);
128        }
129        if readers.len() > 256 {
130            return Err(MailboxPolicyError::TooManyReaders);
131        }
132        if readers.iter().any(|reader| !valid_actor(reader)) {
133            return Err(MailboxPolicyError::InvalidReader);
134        }
135        let readers: BTreeSet<_> = readers.into_iter().map(|a| (a.kind, a.id)).collect();
136        let mut hash = Sha256::new();
137        hash.update(FINGERPRINT_VERSION);
138        match inner.configuration_fingerprint() {
139            Some(value) => {
140                hash.update([1]);
141                hash_field(&mut hash, value);
142            }
143            None => hash.update([0]),
144        }
145        // Include the owner even for an empty list, and encode every pair
146        // structurally rather than concatenating colon-containing labels.
147        hash_field(&mut hash, &owner.kind);
148        hash_field(&mut hash, &owner.id);
149        hash.update((readers.len() as u64).to_be_bytes());
150        for (kind, id) in &readers {
151            hash_field(&mut hash, kind);
152            hash_field(&mut hash, id);
153        }
154        let fingerprint = format!("sha256:{:x}", hash.finalize());
155        Ok(Self {
156            inner,
157            owner,
158            readers,
159            fingerprint,
160        })
161    }
162}
163
164fn hash_field(hash: &mut Sha256, value: &str) {
165    hash.update((value.len() as u64).to_be_bytes());
166    hash.update(value.as_bytes());
167}
168
169impl fmt::Debug for MailboxReadGate {
170    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
171        f.debug_struct("MailboxReadGate")
172            .field("inner", &self.inner.impl_name())
173            .field("reader_count", &self.readers.len())
174            .finish_non_exhaustive()
175    }
176}
177
178impl Gate for MailboxReadGate {
179    fn check(&self, req: &GateRequest) -> Result<GateDecision, GateError> {
180        apply_mailbox_policy(self, req, self.inner.check(req)?)
181    }
182
183    fn check_mailbox_read(
184        &self,
185        req: &GateRequest,
186        owner: &ActorRef,
187    ) -> Result<GateDecision, GateError> {
188        if matches!(
189            req.verb.as_str(),
190            "comm.inbox" | "comm.thread" | "comm.probe"
191        ) && valid_actor(&req.actor)
192            && owner == &self.owner
193            && self
194                .readers
195                .contains(&(req.actor.kind.clone(), req.actor.id.clone()))
196        {
197            Ok(GateDecision::allow())
198        } else {
199            Ok(GateDecision::deny("mailbox_read_not_granted"))
200        }
201    }
202
203    fn configuration_fingerprint(&self) -> Option<&str> {
204        Some(&self.fingerprint)
205    }
206}
207
208#[cfg(test)]
209mod tests;