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}