Skip to main content

car_messaging/
messaging_config.rs

1//! Per-channel approval-transport config + pairing store (Unit 2).
2//!
3//! A daemon-side durable JSON store under `~/.car/` (`messaging.json`) holding
4//! the approval transport's trust state, now keyed **per channel** (iMessage,
5//! Slack). One file, one atomic write, one fail-closed `load()` — the
6//! `store-model` technical call: a single store with per-channel sections, NOT
7//! a per-channel registry. Each channel carries today's three iMessage fields:
8//!
9//! - `enabled` — master opt-in flag, **default `false`** (per channel).
10//! - `allowlisted_handles` — the approver handle(s) that may resolve approvals
11//!   over that channel.
12//! - `active_pairing_code` — a freshly minted, high-entropy code shown ONLY in
13//!   local UI; an inbound text echoing it (constant-time compared) proves
14//!   control of the sending handle and binds it into that channel's allowlist.
15//!
16//! The per-channel format is the **FIRST format ever shipped** (verified
17//! 2026-06-23: `v0.29.0` is tagged but contains no messaging files). There is
18//! no prior on-disk flat shape to migrate from, so `load()` carries NO
19//! migration reader and NO tolerant fallback — it keeps the
20//! empty→default / NotFound→default / parse-or-error shape (MC-4).
21//!
22//! Anti-injection invariant (SC-6, per channel): there is **no setter reachable
23//! from an inbound message**. Mutation happens only through (1) the
24//! host/local-auth-gated `messaging.config.*` / `messaging.pairing.*` WS
25//! surface, or (2) `validate_and_consume_pairing_code` — the *only*
26//! inbound-reachable mutation, binding a handle ONLY on a constant-time match of
27//! the locally-minted code.
28//!
29//! Identity (`identity-shape` call): [`normalize_handle`] is channel-dispatched
30//! — the iMessage branch is byte-for-byte unchanged (phone-vs-Apple-ID
31//! heuristic); the Slack branch normalizes member/user IDs. Reusing the
32//! iMessage normalizer for Slack would silently mis-normalize Slack IDs on the
33//! allowlist trust gate, so each channel owns its normalization.
34
35use serde::{Deserialize, Serialize};
36use std::collections::BTreeMap;
37use std::path::{Path, PathBuf};
38
39pub use car_server_types::channel::{ChannelConfig, ChannelId, SlackTokenRef};
40
41/// Minted pairing-code length in bytes of entropy. 32 random bytes
42/// base64url-no-pad encode to 43 ASCII chars — identical entropy and
43/// shape to the daemon's per-launch / per-agent tokens
44/// (`car_registry::supervisor::mint_agent_token`), so audit/diff tooling
45/// treats them uniformly.
46const PAIRING_CODE_ENTROPY_BYTES: usize = 32;
47
48/// Durable, serialized form of the messaging transport config — now a map of
49/// **per-channel** sections. One `messaging.json` holds every channel's trust
50/// state under a single, atomically-written object. The map is a `BTreeMap`
51/// keyed by [`ChannelId`] so serialization order is deterministic regardless of
52/// insert order.
53///
54/// `Default` is the empty map (no channel configured yet); a read for an
55/// unconfigured channel resolves to a fail-closed [`ChannelConfig::default`]
56/// (`enabled = false`, empty allowlist) — both channels are off until the host
57/// UI flips one on (MC-5).
58#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
59pub struct MessagingConfig {
60    /// Per-channel trust state. Absent channels resolve to the fail-closed
61    /// default on read.
62    #[serde(default)]
63    pub channels: BTreeMap<ChannelId, ChannelConfig>,
64}
65
66impl MessagingConfig {
67    /// The config for `channel`, or the fail-closed default if the channel has
68    /// no section yet (read-only — does NOT insert).
69    pub fn channel(&self, channel: ChannelId) -> ChannelConfig {
70        self.channels.get(&channel).cloned().unwrap_or_default()
71    }
72
73    /// Mutable access to `channel`'s section, inserting a fail-closed default if
74    /// absent. Used only by the privileged mutators.
75    fn channel_mut(&mut self, channel: ChannelId) -> &mut ChannelConfig {
76        self.channels.entry(channel).or_default()
77    }
78}
79
80/// Length-checked constant-time byte compare. Mirrors the in-tree
81/// `car_registry::supervisor::constant_time_eq` / `handler.rs`
82/// `constant_time_eq` pattern — avoids leaking match position (and thus
83/// the secret) via timing. Used for the pairing-code echo check; NEVER
84/// use plain `==` on the secret.
85fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
86    if a.len() != b.len() {
87        return false;
88    }
89    let mut diff: u8 = 0;
90    for (x, y) in a.iter().zip(b.iter()) {
91        diff |= x ^ y;
92    }
93    diff == 0
94}
95
96/// Mint a fresh high-entropy, single-use pairing code, encoded as
97/// base64url-no-pad (43 chars). The 32 bytes are the concatenation of TWO
98/// `Uuid::new_v4()` values. A v4 UUID is CSPRNG-backed but spends 6 of its 128
99/// bits on the version/variant tag, so two of them carry ~244 effective bits of
100/// entropy (not a full 256). That is far above any brute-force concern for a
101/// single-use code that is cleared on the first successful bind — bearer-
102/// equivalent strength, same approach as
103/// `car_registry::supervisor::mint_agent_token` and the daemon's per-launch
104/// token.
105fn mint_pairing_code() -> String {
106    use base64::Engine as _;
107    let a = uuid::Uuid::new_v4();
108    let b = uuid::Uuid::new_v4();
109    let mut bytes = [0u8; PAIRING_CODE_ENTROPY_BYTES];
110    bytes[..16].copy_from_slice(a.as_bytes());
111    bytes[16..].copy_from_slice(b.as_bytes());
112    base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
113}
114
115/// Crash- and concurrency-safe write: serialize to a UNIQUE temp in the
116/// same directory, then atomically `rename(2)` over the target. Same
117/// discipline as `handler.rs:atomic_write_sync` (per-call temp name =
118/// pid + monotonic seq; rename is atomic on the same filesystem) so two
119/// writers never collide and a crash mid-write can't leave a partial
120/// file. Kept local to avoid making `handler::atomic_write_sync` `pub`.
121fn atomic_write_sync(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
122    use std::sync::atomic::{AtomicU64, Ordering};
123    static SEQ: AtomicU64 = AtomicU64::new(0);
124    let seq = SEQ.fetch_add(1, Ordering::Relaxed);
125    let mut tmp_os = path.as_os_str().to_owned();
126    tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
127    let tmp = PathBuf::from(tmp_os);
128    std::fs::write(&tmp, bytes)?;
129    std::fs::rename(&tmp, path)
130}
131
132/// In-process config/pairing store for the approval transport, now per-channel.
133///
134/// Reads/writes a single `messaging.json` under an injectable base dir. Every
135/// mutating method persists synchronously via `atomic_write_sync`, so the
136/// on-disk state is always the source of truth — a fresh
137/// `MessagingConfigStore::with_base_dir` over the same dir reloads it.
138///
139/// Method surface is channel-parameterized (`is_enabled(channel)`,
140/// `is_allowlisted(channel, handle)`, …). Back-compat shims without a channel
141/// argument default to [`ChannelId::IMessage`] so the #403 callers and the WS
142/// surface (which has no `channel` field until Unit 6) keep working.
143#[derive(Debug, Clone)]
144pub struct MessagingConfigStore {
145    base_dir: PathBuf,
146}
147
148/// Outcome of an inbound pairing-code echo. Closed set — the inbound
149/// parser maps a candidate `(handle, code)` to exactly one of these.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151pub enum PairingOutcome {
152    /// The echoed code matched the active pairing code (constant-time).
153    /// The candidate handle was bound into that channel's allowlist and the
154    /// code was cleared/rotated.
155    Bound,
156    /// No active pairing code, or the echoed code did not match.
157    /// Nothing was mutated.
158    Rejected,
159}
160
161impl MessagingConfigStore {
162    /// Open the store rooted at the CAR state root — `$CAR_HOME` when set,
163    /// otherwise `~/.car` resolved from `HOME` (or `USERPROFILE` on Windows).
164    /// Falls back to a relative `.car` if none of the three resolve (only
165    /// happens in degenerate environments — tests inject a temp dir via
166    /// `with_base_dir`).
167    pub fn from_home() -> Self {
168        Self::with_base_dir(car_home::root_or_relative())
169    }
170
171    /// Open the store rooted at an explicit base dir (the state-root
172    /// equivalent). Tests pass a `TempDir` path here so they never touch
173    /// the developer's real `~/.car/`.
174    pub fn with_base_dir(base_dir: impl Into<PathBuf>) -> Self {
175        Self {
176            base_dir: base_dir.into(),
177        }
178    }
179
180    fn config_path(&self) -> PathBuf {
181        self.base_dir.join("messaging.json")
182    }
183
184    /// Load the persisted config from disk. A missing or empty file yields the
185    /// default (no channels configured ⇒ every channel reads fail-closed). A
186    /// malformed file surfaces as an error rather than silently resetting the
187    /// trust state (which would be a silent security downgrade).
188    ///
189    /// **No migration branch (MC-4):** the per-channel format is the first
190    /// format ever written to disk, so there is no legacy flat shape to be
191    /// tolerant toward. `load()` is read-only — it never calls `save()` (a
192    /// corrupt file fails closed to `Err`, it does not silently rewrite).
193    pub fn load(&self) -> Result<MessagingConfig, String> {
194        let path = self.config_path();
195        match std::fs::read_to_string(&path) {
196            Ok(text) if text.trim().is_empty() => Ok(MessagingConfig::default()),
197            Ok(text) => {
198                serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
199            }
200            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(MessagingConfig::default()),
201            Err(e) => Err(format!("read {}: {e}", path.display())),
202        }
203    }
204
205    /// Persist a full config atomically. Internal — all public mutators
206    /// route through this so every write is atomic + durable.
207    fn save(&self, cfg: &MessagingConfig) -> Result<(), String> {
208        std::fs::create_dir_all(&self.base_dir)
209            .map_err(|e| format!("create {}: {e}", self.base_dir.display()))?;
210        let bytes = serde_json::to_vec_pretty(cfg).map_err(|e| e.to_string())?;
211        atomic_write_sync(&self.config_path(), &bytes)
212            .map_err(|e| format!("write {}: {e}", self.config_path().display()))
213    }
214
215    // ---- Read API (channel-parameterized; the orchestrator/adapters consume) ----
216
217    /// Whether `channel` is enabled (its master opt-in flag).
218    pub fn is_enabled_for(&self, channel: ChannelId) -> Result<bool, String> {
219        Ok(self.load()?.channel(channel).enabled)
220    }
221
222    /// Back-compat: `is_enabled()` defaults to iMessage.
223    pub fn is_enabled(&self) -> Result<bool, String> {
224        self.is_enabled_for(ChannelId::IMessage)
225    }
226
227    /// `channel`'s current allowlist of approver handles.
228    pub fn allowlist_for(&self, channel: ChannelId) -> Result<Vec<String>, String> {
229        Ok(self.load()?.channel(channel).allowlisted_handles)
230    }
231
232    /// Back-compat: `allowlist()` defaults to iMessage.
233    pub fn allowlist(&self) -> Result<Vec<String>, String> {
234        self.allowlist_for(ChannelId::IMessage)
235    }
236
237    /// Whether `handle` is on `channel`'s allowlist. Compares the
238    /// channel-NORMALIZED forms (iMessage: phone punctuation stripped, emails
239    /// intact; Slack: member-ID form) so a hand-typed value matches the stored
240    /// canonical form. The adapter calls this to drop non-paired senders before
241    /// any parse (SC-7).
242    pub fn is_allowlisted_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
243        let want = normalize_handle(channel, handle);
244        Ok(self
245            .load()?
246            .channel(channel)
247            .allowlisted_handles
248            .iter()
249            .any(|h| normalize_handle(channel, h) == want))
250    }
251
252    /// Back-compat: `is_allowlisted(handle)` defaults to iMessage.
253    pub fn is_allowlisted(&self, handle: &str) -> Result<bool, String> {
254        self.is_allowlisted_for(ChannelId::IMessage, handle)
255    }
256
257    // ---- Privileged mutators (reachable ONLY from the gated WS surface
258    //      or host-local code — NEVER from an inbound message) ----
259
260    /// Set `channel`'s enabled flag. Host-gated path only.
261    pub fn set_enabled_for(&self, channel: ChannelId, enabled: bool) -> Result<(), String> {
262        let mut cfg = self.load()?;
263        cfg.channel_mut(channel).enabled = enabled;
264        self.save(&cfg)
265    }
266
267    /// Back-compat: `set_enabled(enabled)` defaults to iMessage.
268    pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
269        self.set_enabled_for(ChannelId::IMessage, enabled)
270    }
271
272    /// Replace `channel`'s entire allowlist. Host-gated path only. Handles are
273    /// channel-normalized before storage.
274    ///
275    /// **v1 single-handle cardinality guard (per channel):** v1 supports exactly
276    /// ONE paired/allowlisted user per channel (the orchestrator's "sole pending
277    /// approval" logic is correct-by-invariant only under that constraint). A
278    /// request to set MORE THAN ONE distinct handle is rejected rather than
279    /// silently widening the trust set.
280    pub fn set_allowlist_for(
281        &self,
282        channel: ChannelId,
283        handles: Vec<String>,
284    ) -> Result<(), String> {
285        let normalized: Vec<String> = handles
286            .iter()
287            .map(|h| normalize_handle(channel, h))
288            .collect();
289        let deduped = dedup_preserve_order(normalized);
290        if deduped.len() > 1 {
291            return Err(format!(
292                "v1 supports a single allowlisted handle; refusing to set {} handles",
293                deduped.len()
294            ));
295        }
296        let mut cfg = self.load()?;
297        cfg.channel_mut(channel).allowlisted_handles = deduped;
298        self.save(&cfg)
299    }
300
301    /// Back-compat: `set_allowlist(handles)` defaults to iMessage.
302    pub fn set_allowlist(&self, handles: Vec<String>) -> Result<(), String> {
303        self.set_allowlist_for(ChannelId::IMessage, handles)
304    }
305
306    /// Add one handle to `channel`'s allowlist (idempotent). Host-gated path
307    /// only. The handle is channel-normalized before storage and comparison.
308    /// Returns `true` if newly added.
309    ///
310    /// **v1 single-handle cardinality guard (per channel):** if a DIFFERENT
311    /// handle is already allowlisted on this channel, this is rejected — v1 binds
312    /// exactly one paired user per channel. Re-adding the SAME handle is still
313    /// the idempotent `Ok(false)` no-op.
314    pub fn add_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
315        let normalized = normalize_handle(channel, handle);
316        let mut cfg = self.load()?;
317        let section = cfg.channel_mut(channel);
318        if section
319            .allowlisted_handles
320            .iter()
321            .any(|h| normalize_handle(channel, h) == normalized)
322        {
323            return Ok(false);
324        }
325        if !section.allowlisted_handles.is_empty() {
326            return Err(
327                "v1 supports a single allowlisted handle; remove the existing handle first"
328                    .to_string(),
329            );
330        }
331        section.allowlisted_handles.push(normalized);
332        self.save(&cfg)?;
333        Ok(true)
334    }
335
336    /// Back-compat: `add_handle(handle)` defaults to iMessage.
337    pub fn add_handle(&self, handle: &str) -> Result<bool, String> {
338        self.add_handle_for(ChannelId::IMessage, handle)
339    }
340
341    /// Remove one handle from `channel`'s allowlist (idempotent). Host-gated
342    /// path only. Compares channel-normalized forms. Returns `true` if removed.
343    pub fn remove_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
344        let target = normalize_handle(channel, handle);
345        let mut cfg = self.load()?;
346        let section = cfg.channel_mut(channel);
347        let before = section.allowlisted_handles.len();
348        section
349            .allowlisted_handles
350            .retain(|h| normalize_handle(channel, h) != target);
351        let removed = section.allowlisted_handles.len() != before;
352        if removed {
353            self.save(&cfg)?;
354        }
355        Ok(removed)
356    }
357
358    /// Back-compat: `remove_handle(handle)` defaults to iMessage.
359    pub fn remove_handle(&self, handle: &str) -> Result<bool, String> {
360        self.remove_handle_for(ChannelId::IMessage, handle)
361    }
362
363    /// Mint a fresh pairing code for `channel`, persist it as that channel's
364    /// active code, and return it for display in the local UI. Rotates any prior
365    /// active code on that channel. Host-gated path only — shown ONLY in local
366    /// UI, never produced from any inbound-derived value.
367    pub fn mint_pairing_code_for(&self, channel: ChannelId) -> Result<String, String> {
368        let mut cfg = self.load()?;
369        let code = mint_pairing_code();
370        cfg.channel_mut(channel).active_pairing_code = Some(code.clone());
371        self.save(&cfg)?;
372        Ok(code)
373    }
374
375    /// Back-compat: `mint_pairing_code()` defaults to iMessage.
376    pub fn mint_pairing_code(&self) -> Result<String, String> {
377        self.mint_pairing_code_for(ChannelId::IMessage)
378    }
379
380    /// `channel`'s active pairing code, if a pairing is in flight. Host-gated
381    /// read (status surface).
382    pub fn active_pairing_code_for(&self, channel: ChannelId) -> Result<Option<String>, String> {
383        Ok(self.load()?.channel(channel).active_pairing_code)
384    }
385
386    /// Back-compat: `active_pairing_code()` defaults to iMessage.
387    pub fn active_pairing_code(&self) -> Result<Option<String>, String> {
388        self.active_pairing_code_for(ChannelId::IMessage)
389    }
390
391    /// Persist `channel`'s keychain token REFERENCE (MC-9). Host-gated path
392    /// only — the bearer values themselves are written to the OS keychain by
393    /// the provisioning write path (`slack_adapter::provision_slack_tokens`);
394    /// THIS stores only the key NAMES (a reference) into `messaging.json`, and
395    /// its presence is the "tokens provisioned" marker. Never accepts a bearer
396    /// value, so no `xoxb-`/`xapp-` can ever land on disk through this method.
397    pub fn set_slack_token_ref_for(
398        &self,
399        channel: ChannelId,
400        token_ref: SlackTokenRef,
401    ) -> Result<(), String> {
402        let mut cfg = self.load()?;
403        cfg.channel_mut(channel).slack_token_ref = Some(token_ref);
404        self.save(&cfg)
405    }
406
407    /// `channel`'s persisted keychain token reference, if its tokens have been
408    /// provisioned. Host-gated read (the boot path reads this to construct the
409    /// live transport from the persisted refs). `None` ⇒ not yet provisioned.
410    pub fn slack_token_ref_for(&self, channel: ChannelId) -> Result<Option<SlackTokenRef>, String> {
411        Ok(self.load()?.channel(channel).slack_token_ref)
412    }
413
414    /// Persist `channel`'s Slack post-channel id (the conversation/channel id
415    /// the outbound prompt posts into, e.g. `C0123…`). Host-gated path only.
416    /// This is CONFIG, not a secret — it lands in `messaging.json`, never the
417    /// keychain. Set on the same host-gated `messaging.config.set` call as the
418    /// tokens.
419    pub fn set_slack_channel_id_for(
420        &self,
421        channel: ChannelId,
422        channel_id: &str,
423    ) -> Result<(), String> {
424        let mut cfg = self.load()?;
425        cfg.channel_mut(channel).slack_channel_id = Some(channel_id.to_string());
426        self.save(&cfg)
427    }
428
429    /// `channel`'s persisted Slack post-channel id, if set. The boot path reads
430    /// this to construct the adapter with the channel to post into (NOT the
431    /// never-written keychain key). `None` ⇒ no post-channel configured (the
432    /// adapter is built but cannot post).
433    pub fn slack_channel_id_for(&self, channel: ChannelId) -> Result<Option<String>, String> {
434        Ok(self.load()?.channel(channel).slack_channel_id)
435    }
436
437    // ---- Inbound-reachable mutation: pairing only ----
438
439    /// Validate an inbound-echoed pairing `code` from `candidate_handle` on
440    /// `channel`, using a CONSTANT-TIME compare against that channel's active
441    /// code. On a match, bind `candidate_handle` into the channel's allowlist
442    /// and clear/rotate the code; on a miss (or no active code) mutate NOTHING.
443    ///
444    /// This is the ONLY mutation an inbound message can ever cause, and it can
445    /// only ever *add a handle* to one channel — never set the enabled flag,
446    /// never set the allowlist wholesale, never read config. A bare
447    /// "approve"/"deny" or arbitrary text never reaches here.
448    pub fn validate_and_consume_pairing_code_for(
449        &self,
450        channel: ChannelId,
451        candidate_handle: &str,
452        code: &str,
453    ) -> Result<PairingOutcome, String> {
454        let mut cfg = self.load()?;
455        let section = cfg.channel_mut(channel);
456        let Some(active) = section.active_pairing_code.as_deref() else {
457            return Ok(PairingOutcome::Rejected);
458        };
459        if !constant_time_eq(active.as_bytes(), code.as_bytes()) {
460            return Ok(PairingOutcome::Rejected);
461        }
462        // Match: bind the channel-NORMALIZED handle (idempotent) and clear the
463        // code. The v1 single-handle invariant holds here too — pairing binds the
464        // first (and only) paired user; if a different handle is somehow already
465        // present we leave the allowlist as-is rather than widening it, but still
466        // consume the code (the match proved control of the handle).
467        let normalized = normalize_handle(channel, candidate_handle);
468        let already_present = section
469            .allowlisted_handles
470            .iter()
471            .any(|h| normalize_handle(channel, h) == normalized);
472        if !already_present && section.allowlisted_handles.is_empty() {
473            section.allowlisted_handles.push(normalized);
474        }
475        section.active_pairing_code = None;
476        self.save(&cfg)?;
477        Ok(PairingOutcome::Bound)
478    }
479
480    /// Back-compat: `validate_and_consume_pairing_code(handle, code)` defaults
481    /// to iMessage.
482    pub fn validate_and_consume_pairing_code(
483        &self,
484        candidate_handle: &str,
485        code: &str,
486    ) -> Result<PairingOutcome, String> {
487        self.validate_and_consume_pairing_code_for(ChannelId::IMessage, candidate_handle, code)
488    }
489}
490
491/// De-duplicate handles while preserving first-seen order. Keeps the
492/// allowlist stable for display and avoids a HashSet reorder surprise.
493fn dedup_preserve_order(handles: Vec<String>) -> Vec<String> {
494    let mut seen = std::collections::HashSet::new();
495    handles
496        .into_iter()
497        .filter(|h| seen.insert(h.clone()))
498        .collect()
499}
500
501/// Normalize a handle for storage and comparison, **dispatched by channel**
502/// (`identity-shape` call). Each channel's allowlist trust gate normalizes its
503/// own identity form; reusing one channel's normalizer for another would
504/// silently mis-normalize IDs at store and compare time — a security-relevant
505/// allowlist-matching bug, not a style nit.
506///
507/// - **iMessage** (byte-for-byte unchanged from #403): a hand-typed phone number
508///   like `+1 555-123-4567` or `(555) 123-4567` must match chat.db's canonical
509///   `+15551234567`. Only phone-SHAPED handles are touched: a handle containing
510///   `@` is an Apple-ID email, returned UNCHANGED (emails are case/format
511///   sensitive). Everything else has ASCII whitespace and the common phone
512///   punctuation (`-`, `(`, `)`, `.`) stripped, leaving a leading `+` and digits.
513/// - **Slack**: a Slack member/user ID (`U…`/`W…`). Trim surrounding whitespace
514///   and a possible `@` mention prefix; otherwise leave the ID intact (Slack IDs
515///   are opaque, case-sensitive tokens — stripping punctuation would corrupt
516///   them). The Slack adapter (Unit 4) feeds member IDs through this branch.
517pub fn normalize_handle(channel: ChannelId, handle: &str) -> String {
518    match channel {
519        ChannelId::IMessage => normalize_imessage_handle(handle),
520        ChannelId::Slack => normalize_slack_handle(handle),
521    }
522}
523
524/// iMessage handle normalization — byte-for-byte the #403 `normalize_handle`.
525fn normalize_imessage_handle(handle: &str) -> String {
526    // Apple-ID emails: leave fully intact.
527    if handle.contains('@') {
528        return handle.trim().to_string();
529    }
530    handle
531        .chars()
532        .filter(|c| !c.is_whitespace() && !matches!(c, '-' | '(' | ')' | '.'))
533        .collect()
534}
535
536/// Slack handle normalization — a member/user ID is an opaque, case-sensitive
537/// token. Trim whitespace and a leading `@` mention sigil; do NOT strip interior
538/// characters (that would corrupt the ID). Keep it simple — the Slack adapter
539/// lands in Unit 4, but the dispatch + branch must exist now.
540fn normalize_slack_handle(handle: &str) -> String {
541    handle.trim().trim_start_matches('@').to_string()
542}
543
544#[cfg(test)]
545mod tests {
546    use super::*;
547    use tempfile::TempDir;
548
549    fn store() -> (TempDir, MessagingConfigStore) {
550        let dir = TempDir::new().expect("tempdir");
551        let store = MessagingConfigStore::with_base_dir(dir.path());
552        (dir, store)
553    }
554
555    /// SC-8: a minted code binds EXACTLY the sending handle; a WRONG
556    /// code binds nothing; the validator uses the constant-time compare.
557    /// Also proves SC-2-style durability: the bound handle survives a
558    /// reload from disk. (Drives the iMessage-default back-compat surface.)
559    #[test]
560    fn pairing_code_proven_constant_time() {
561        let (dir, store) = store();
562
563        // Empty to start.
564        assert!(store.allowlist().unwrap().is_empty());
565
566        // Mint a code (daemon-side, local UI only).
567        let code = store.mint_pairing_code().unwrap();
568        assert_eq!(code.len(), 43, "base64url-no-pad of 32 bytes is 43 chars");
569        assert_eq!(
570            store.active_pairing_code().unwrap().as_deref(),
571            Some(code.as_str())
572        );
573
574        // A WRONG code binds nothing — allowlist unchanged, code still active.
575        let wrong = "not-the-code-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
576        assert_eq!(
577            wrong.len(),
578            code.len(),
579            "exercise the equal-length miss path"
580        );
581        let outcome = store
582            .validate_and_consume_pairing_code("+15550000000", wrong)
583            .unwrap();
584        assert_eq!(outcome, PairingOutcome::Rejected);
585        assert!(
586            store.allowlist().unwrap().is_empty(),
587            "wrong code must bind nothing"
588        );
589        assert!(
590            store.active_pairing_code().unwrap().is_some(),
591            "wrong code must not consume the active code"
592        );
593
594        // A different-length wrong code also rejects (constant_time_eq
595        // length guard) and binds nothing.
596        let outcome = store
597            .validate_and_consume_pairing_code("+15550000000", "short")
598            .unwrap();
599        assert_eq!(outcome, PairingOutcome::Rejected);
600        assert!(store.allowlist().unwrap().is_empty());
601
602        // The EXACT minted code binds EXACTLY the sending handle.
603        let outcome = store
604            .validate_and_consume_pairing_code("+15551234567", &code)
605            .unwrap();
606        assert_eq!(outcome, PairingOutcome::Bound);
607        assert_eq!(
608            store.allowlist().unwrap(),
609            vec!["+15551234567".to_string()],
610            "exactly the sending handle is bound — no others"
611        );
612        // Code is cleared/rotated after a successful bind.
613        assert!(store.active_pairing_code().unwrap().is_none());
614
615        // Re-echoing the now-consumed code binds nothing further.
616        let outcome = store
617            .validate_and_consume_pairing_code("+15559999999", &code)
618            .unwrap();
619        assert_eq!(outcome, PairingOutcome::Rejected);
620        assert_eq!(
621            store.allowlist().unwrap(),
622            vec!["+15551234567".to_string()],
623            "consumed code cannot bind a second handle"
624        );
625
626        // Durability: a fresh store over the same dir sees the bound handle.
627        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
628        assert_eq!(
629            reloaded.allowlist().unwrap(),
630            vec!["+15551234567".to_string()]
631        );
632    }
633
634    /// Direct proof that `constant_time_eq` is length-guarded and
635    /// position-independent (the property the timing-safe compare buys).
636    #[test]
637    fn pairing_constant_time_eq_properties() {
638        assert!(constant_time_eq(b"abc", b"abc"));
639        assert!(!constant_time_eq(b"abc", b"abd"));
640        assert!(!constant_time_eq(b"abc", b"ab")); // length mismatch
641        assert!(!constant_time_eq(b"", b"x"));
642        assert!(constant_time_eq(b"", b""));
643    }
644
645    /// SC-6 (config-store half): the store exposes NO inbound→setter
646    /// edge. Feeding ordinary text through the only inbound-reachable method —
647    /// the pairing validator — with no active code (or a non-matching code)
648    /// leaves the allowlist UNCHANGED. The privileged setters are reachable only
649    /// from host-gated code, never from this path.
650    #[test]
651    fn config_mutation_requires_host_or_local_auth() {
652        let (_dir, store) = store();
653
654        // No active pairing code: any inbound text is a no-op mutation.
655        let outcome = store
656            .validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
657            .unwrap();
658        assert_eq!(outcome, PairingOutcome::Rejected);
659        assert!(
660            store.allowlist().unwrap().is_empty(),
661            "inbound text must never mutate the allowlist"
662        );
663        assert!(!store.is_enabled().unwrap());
664
665        // Even with a pairing in flight, arbitrary text that is NOT the
666        // exact code binds nothing.
667        let _code = store.mint_pairing_code().unwrap();
668        let outcome = store
669            .validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
670            .unwrap();
671        assert_eq!(outcome, PairingOutcome::Rejected);
672        assert!(
673            store.allowlist().unwrap().is_empty(),
674            "non-matching inbound text must never mutate the allowlist"
675        );
676
677        // The privileged setters (host-gated path) DO mutate — proving
678        // the mutation power lives only on the privileged side.
679        store.set_enabled(true).unwrap();
680        store.add_handle("+15550001111").unwrap();
681        assert!(store.is_enabled().unwrap());
682        assert_eq!(store.allowlist().unwrap(), vec!["+15550001111".to_string()]);
683    }
684
685    #[test]
686    fn enabled_defaults_false_and_persists() {
687        let (dir, store) = store();
688        assert!(!store.is_enabled().unwrap(), "enabled defaults false");
689        store.set_enabled(true).unwrap();
690        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
691        assert!(reloaded.is_enabled().unwrap());
692    }
693
694    #[test]
695    fn add_remove_handle_idempotent() {
696        let (_dir, store) = store();
697        assert!(store.add_handle("+15551112222").unwrap());
698        assert!(
699            !store.add_handle("+15551112222").unwrap(),
700            "second add is a no-op"
701        );
702        assert!(store.is_allowlisted("+15551112222").unwrap());
703        assert!(!store.is_allowlisted("+19998887777").unwrap());
704        assert!(store.remove_handle("+15551112222").unwrap());
705        assert!(
706            !store.remove_handle("+15551112222").unwrap(),
707            "second remove is a no-op"
708        );
709        assert!(store.allowlist().unwrap().is_empty());
710    }
711
712    /// v1 single-handle cardinality guard: a SECOND, distinct handle is
713    /// rejected while one already exists. Re-adding the SAME handle stays the
714    /// idempotent no-op. `set_allowlist` of >1 distinct handle also rejects.
715    #[test]
716    fn single_handle_cardinality_guard() {
717        let (_dir, store) = store();
718
719        // First handle binds.
720        assert!(store.add_handle("+15551112222").unwrap());
721
722        // A DIFFERENT second handle is REJECTED (error, not a silent widen).
723        let err = store.add_handle("+19998887777").unwrap_err();
724        assert!(
725            err.contains("single allowlisted handle"),
726            "second distinct add must be rejected, got: {err}"
727        );
728        // The allowlist still holds exactly the first handle.
729        assert_eq!(store.allowlist().unwrap(), vec!["+15551112222".to_string()]);
730
731        // Re-adding the SAME handle is still the idempotent no-op (Ok(false)).
732        assert!(!store.add_handle("+15551112222").unwrap());
733
734        // set_allowlist of >1 distinct handle rejects.
735        let err = store
736            .set_allowlist(vec!["+15551112222".into(), "+19998887777".into()])
737            .unwrap_err();
738        assert!(
739            err.contains("single allowlisted handle"),
740            "set_allowlist of 2 handles must reject, got: {err}"
741        );
742
743        // After removing the existing handle, a new one can bind.
744        assert!(store.remove_handle("+15551112222").unwrap());
745        assert!(store.add_handle("+19998887777").unwrap());
746        assert_eq!(store.allowlist().unwrap(), vec!["+19998887777".to_string()]);
747    }
748
749    /// Handle normalization: a hand-typed punctuated phone number stored via
750    /// `add_handle` matches chat.db's canonical digits-only form in
751    /// `is_allowlisted`, and vice versa. Apple-ID emails are left intact.
752    /// (iMessage branch — byte-for-byte unchanged after channel dispatch.)
753    #[test]
754    fn handle_normalization_phone_matches_email_intact() {
755        let (dir, phone_store) = store();
756        let _dir = dir;
757
758        // Store a hand-typed, punctuated number.
759        assert!(phone_store.add_handle("+1 555-123-4567").unwrap());
760        // It is stored in canonical (stripped) form.
761        assert_eq!(
762            phone_store.allowlist().unwrap(),
763            vec!["+15551234567".to_string()]
764        );
765        // chat.db's canonical form matches.
766        assert!(phone_store.is_allowlisted("+15551234567").unwrap());
767        // Differently-punctuated styles of the same number also match.
768        assert!(
769            phone_store.is_allowlisted("+1 (555) 123.4567").unwrap(),
770            "differently-punctuated same number must match"
771        );
772        // A different number does not match.
773        assert!(!phone_store.is_allowlisted("+15559998888").unwrap());
774
775        // Apple-ID emails are NOT stripped — stored and matched verbatim.
776        let (_dir2, email_store) = store();
777        assert!(email_store.add_handle("alice@icloud.com").unwrap());
778        assert_eq!(
779            email_store.allowlist().unwrap(),
780            vec!["alice@icloud.com".to_string()]
781        );
782        assert!(email_store.is_allowlisted("alice@icloud.com").unwrap());
783        assert!(!email_store.is_allowlisted("aliceicloud.com").unwrap());
784    }
785
786    /// MC-5 (store level): a fresh store shows BOTH channels disabled, and
787    /// enabling one channel does NOT flip the other (channels are independent).
788    #[test]
789    fn both_channels_default_off_and_independent() {
790        let (_dir, store) = store();
791        // Fresh store: both channels off.
792        assert!(!store.is_enabled_for(ChannelId::IMessage).unwrap());
793        assert!(!store.is_enabled_for(ChannelId::Slack).unwrap());
794
795        // Enable Slack only — iMessage stays off.
796        store.set_enabled_for(ChannelId::Slack, true).unwrap();
797        assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
798        assert!(
799            !store.is_enabled_for(ChannelId::IMessage).unwrap(),
800            "enabling Slack must not flip iMessage"
801        );
802
803        // Enable iMessage too — both on now (the "both at once" multi-select).
804        store.set_enabled_for(ChannelId::IMessage, true).unwrap();
805        assert!(store.is_enabled_for(ChannelId::IMessage).unwrap());
806        assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
807    }
808
809    /// Per-channel allowlists are independent: a handle paired on one channel
810    /// is NOT allowlisted on the other, and the Slack branch normalizes IDs
811    /// without phone-stripping.
812    #[test]
813    fn per_channel_allowlists_independent_and_slack_normalizes() {
814        let (_dir, store) = store();
815        store
816            .add_handle_for(ChannelId::IMessage, "+15551112222")
817            .unwrap();
818        store
819            .add_handle_for(ChannelId::Slack, "U012ABCDEF")
820            .unwrap();
821
822        // Each handle is allowlisted only on its own channel.
823        assert!(store
824            .is_allowlisted_for(ChannelId::IMessage, "+15551112222")
825            .unwrap());
826        assert!(!store
827            .is_allowlisted_for(ChannelId::Slack, "+15551112222")
828            .unwrap());
829        assert!(store
830            .is_allowlisted_for(ChannelId::Slack, "U012ABCDEF")
831            .unwrap());
832        assert!(!store
833            .is_allowlisted_for(ChannelId::IMessage, "U012ABCDEF")
834            .unwrap());
835
836        // Slack normalization trims a leading @ mention but keeps the ID intact
837        // (no phone punctuation stripping that would corrupt an opaque ID).
838        assert!(store
839            .is_allowlisted_for(ChannelId::Slack, "@U012ABCDEF")
840            .unwrap());
841        assert_eq!(
842            store.allowlist_for(ChannelId::Slack).unwrap(),
843            vec!["U012ABCDEF".to_string()]
844        );
845    }
846
847    /// MC-4 / MC-5 durability: a `messaging.json` carrying BOTH channels
848    /// populated (each enabled + a paired handle) round-trips intact through a
849    /// drop-and-reload — neither channel's state is lost or cross-contaminated.
850    /// Proves the single-file, per-channel `BTreeMap` persistence holds for the
851    /// multi-channel case, not just the iMessage-only default.
852    #[test]
853    fn multi_channel_state_survives_reload() {
854        let dir = TempDir::new().expect("tempdir");
855
856        // Populate BOTH channels via the host-gated mutators, then drop the store.
857        {
858            let store = MessagingConfigStore::with_base_dir(dir.path());
859            store.set_enabled_for(ChannelId::IMessage, true).unwrap();
860            store
861                .add_handle_for(ChannelId::IMessage, "+15551112222")
862                .unwrap();
863            store.set_enabled_for(ChannelId::Slack, true).unwrap();
864            store
865                .add_handle_for(ChannelId::Slack, "U012ABCDEF")
866                .unwrap();
867        } // store dropped here — only the on-disk messaging.json remains.
868
869        // A fresh store loaded from the same path sees BOTH channels intact.
870        let reloaded = MessagingConfigStore::with_base_dir(dir.path());
871        assert!(
872            reloaded.is_enabled_for(ChannelId::IMessage).unwrap(),
873            "iMessage enabled flag must survive reload"
874        );
875        assert_eq!(
876            reloaded.allowlist_for(ChannelId::IMessage).unwrap(),
877            vec!["+15551112222".to_string()],
878            "iMessage handle must survive reload"
879        );
880        assert!(
881            reloaded.is_enabled_for(ChannelId::Slack).unwrap(),
882            "Slack enabled flag must survive reload"
883        );
884        assert_eq!(
885            reloaded.allowlist_for(ChannelId::Slack).unwrap(),
886            vec!["U012ABCDEF".to_string()],
887            "Slack handle must survive reload"
888        );
889    }
890
891    /// MC-4 / MC-5: the v1 single-handle cardinality guard fires INDEPENDENTLY
892    /// per channel. iMessage holding its one handle does NOT consume Slack's
893    /// slot — Slack can still bind its first handle — and the guard still
894    /// rejects a SECOND distinct handle on each channel separately.
895    #[test]
896    fn single_handle_cardinality_guard_is_per_channel() {
897        let (_dir, store) = store();
898
899        // iMessage binds its one handle.
900        assert!(store
901            .add_handle_for(ChannelId::IMessage, "+15551112222")
902            .unwrap());
903
904        // A SECOND distinct iMessage handle is rejected — guard fires on iMessage.
905        let err = store
906            .add_handle_for(ChannelId::IMessage, "+19998887777")
907            .unwrap_err();
908        assert!(
909            err.contains("single allowlisted handle"),
910            "second iMessage handle must be rejected, got: {err}"
911        );
912
913        // Slack's slot is UNAFFECTED by iMessage being full: Slack binds its first.
914        assert!(
915            store
916                .add_handle_for(ChannelId::Slack, "U012ABCDEF")
917                .unwrap(),
918            "iMessage being full must not consume Slack's slot"
919        );
920
921        // The guard ALSO fires independently on Slack: a second Slack handle rejects.
922        let err = store
923            .add_handle_for(ChannelId::Slack, "U999ZZZZZZ")
924            .unwrap_err();
925        assert!(
926            err.contains("single allowlisted handle"),
927            "second Slack handle must be rejected, got: {err}"
928        );
929
930        // Each channel still holds exactly its own one handle.
931        assert_eq!(
932            store.allowlist_for(ChannelId::IMessage).unwrap(),
933            vec!["+15551112222".to_string()]
934        );
935        assert_eq!(
936            store.allowlist_for(ChannelId::Slack).unwrap(),
937            vec!["U012ABCDEF".to_string()]
938        );
939    }
940
941    /// Security-critical fail-closed load: a corrupt/garbage `messaging.json`
942    /// makes `load()` return `Err` — it does NOT silently reset to a default
943    /// (which would be a trust-store downgrade, dropping the operator's
944    /// allowlist on the floor). The empty-file and absent-file cases still
945    /// resolve to the fail-closed default; only malformed CONTENT errors.
946    #[test]
947    fn load_fails_closed_on_malformed_json() {
948        let dir = TempDir::new().expect("tempdir");
949        let store = MessagingConfigStore::with_base_dir(dir.path());
950
951        // Write garbage to the config path the store reads from.
952        let path = store.config_path();
953        std::fs::create_dir_all(path.parent().unwrap()).unwrap();
954        std::fs::write(&path, b"{ this is not valid json at all ]]]").unwrap();
955
956        // load() must surface an Err, NOT a silent default/reset.
957        let result = store.load();
958        assert!(
959            result.is_err(),
960            "malformed messaging.json must fail closed (Err), not reset to default; got {result:?}"
961        );
962
963        // Sanity: the empty-file path still resolves to the fail-closed default
964        // (so this is specifically a malformed-CONTENT guard, not a blanket
965        // "any read errors" — empty/absent are legitimately the first-boot case).
966        std::fs::write(&path, b"   \n").unwrap();
967        assert!(
968            !store.load().unwrap().channel(ChannelId::IMessage).enabled,
969            "empty file resolves to fail-closed default, not an error"
970        );
971    }
972
973    /// MC-13 (store level): cross-channel pairing isolation. A code minted for
974    /// ONE channel does NOT validate/consume on the OTHER channel — a Slack code
975    /// echoed as an iMessage pairing binds nothing on iMessage, and vice-versa.
976    /// Each channel's active code is its own slot.
977    #[test]
978    fn pairing_code_does_not_cross_channels() {
979        let (_dir, store) = store();
980
981        // Mint a code on Slack only.
982        let slack_code = store.mint_pairing_code_for(ChannelId::Slack).unwrap();
983        // iMessage has no active code.
984        assert!(store
985            .active_pairing_code_for(ChannelId::IMessage)
986            .unwrap()
987            .is_none());
988
989        // Echoing the SLACK code on the IMESSAGE channel binds NOTHING (iMessage
990        // has no active code, and the Slack code is not its secret).
991        let outcome = store
992            .validate_and_consume_pairing_code_for(ChannelId::IMessage, "+15551234567", &slack_code)
993            .unwrap();
994        assert_eq!(outcome, PairingOutcome::Rejected);
995        assert!(
996            store.allowlist_for(ChannelId::IMessage).unwrap().is_empty(),
997            "a Slack-minted code must bind nothing on iMessage"
998        );
999        // The Slack code is untouched — it was never the iMessage channel's code.
1000        assert_eq!(
1001            store
1002                .active_pairing_code_for(ChannelId::Slack)
1003                .unwrap()
1004                .as_deref(),
1005            Some(slack_code.as_str()),
1006            "the Slack code must not be consumed by an iMessage validate attempt"
1007        );
1008
1009        // The SAME Slack code on the SLACK channel DOES bind (it is Slack's code).
1010        let outcome = store
1011            .validate_and_consume_pairing_code_for(ChannelId::Slack, "U012ABCDEF", &slack_code)
1012            .unwrap();
1013        assert_eq!(outcome, PairingOutcome::Bound);
1014        assert_eq!(
1015            store.allowlist_for(ChannelId::Slack).unwrap(),
1016            vec!["U012ABCDEF".to_string()]
1017        );
1018
1019        // Symmetric direction: mint on iMessage, echo on Slack → nothing bound.
1020        let imsg_code = store.mint_pairing_code_for(ChannelId::IMessage).unwrap();
1021        let outcome = store
1022            .validate_and_consume_pairing_code_for(ChannelId::Slack, "U999ZZZZZZ", &imsg_code)
1023            .unwrap();
1024        assert_eq!(outcome, PairingOutcome::Rejected);
1025        // Slack's allowlist still holds only its first handle; no second bound.
1026        assert_eq!(
1027            store.allowlist_for(ChannelId::Slack).unwrap(),
1028            vec!["U012ABCDEF".to_string()],
1029            "an iMessage-minted code must bind nothing on Slack"
1030        );
1031    }
1032}