use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub use crate::channel::{ChannelConfig, ChannelId, SlackTokenRef};
const PAIRING_CODE_ENTROPY_BYTES: usize = 32;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct MessagingConfig {
#[serde(default)]
pub channels: BTreeMap<ChannelId, ChannelConfig>,
}
impl MessagingConfig {
pub fn channel(&self, channel: ChannelId) -> ChannelConfig {
self.channels.get(&channel).cloned().unwrap_or_default()
}
fn channel_mut(&mut self, channel: ChannelId) -> &mut ChannelConfig {
self.channels.entry(channel).or_default()
}
}
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
fn mint_pairing_code() -> String {
use base64::Engine as _;
let a = uuid::Uuid::new_v4();
let b = uuid::Uuid::new_v4();
let mut bytes = [0u8; PAIRING_CODE_ENTROPY_BYTES];
bytes[..16].copy_from_slice(a.as_bytes());
bytes[16..].copy_from_slice(b.as_bytes());
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
}
fn atomic_write_sync(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
use std::sync::atomic::{AtomicU64, Ordering};
static SEQ: AtomicU64 = AtomicU64::new(0);
let seq = SEQ.fetch_add(1, Ordering::Relaxed);
let mut tmp_os = path.as_os_str().to_owned();
tmp_os.push(format!(".tmp.{}.{}", std::process::id(), seq));
let tmp = PathBuf::from(tmp_os);
std::fs::write(&tmp, bytes)?;
std::fs::rename(&tmp, path)
}
#[derive(Debug, Clone)]
pub struct MessagingConfigStore {
base_dir: PathBuf,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PairingOutcome {
Bound,
Rejected,
}
impl MessagingConfigStore {
pub fn from_home() -> Self {
let base = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("."))
.join(".car");
Self::with_base_dir(base)
}
pub fn with_base_dir(base_dir: impl Into<PathBuf>) -> Self {
Self {
base_dir: base_dir.into(),
}
}
fn config_path(&self) -> PathBuf {
self.base_dir.join("messaging.json")
}
pub fn load(&self) -> Result<MessagingConfig, String> {
let path = self.config_path();
match std::fs::read_to_string(&path) {
Ok(text) if text.trim().is_empty() => Ok(MessagingConfig::default()),
Ok(text) => {
serde_json::from_str(&text).map_err(|e| format!("parse {}: {e}", path.display()))
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(MessagingConfig::default()),
Err(e) => Err(format!("read {}: {e}", path.display())),
}
}
fn save(&self, cfg: &MessagingConfig) -> Result<(), String> {
std::fs::create_dir_all(&self.base_dir)
.map_err(|e| format!("create {}: {e}", self.base_dir.display()))?;
let bytes = serde_json::to_vec_pretty(cfg).map_err(|e| e.to_string())?;
atomic_write_sync(&self.config_path(), &bytes)
.map_err(|e| format!("write {}: {e}", self.config_path().display()))
}
pub fn is_enabled_for(&self, channel: ChannelId) -> Result<bool, String> {
Ok(self.load()?.channel(channel).enabled)
}
pub fn is_enabled(&self) -> Result<bool, String> {
self.is_enabled_for(ChannelId::IMessage)
}
pub fn allowlist_for(&self, channel: ChannelId) -> Result<Vec<String>, String> {
Ok(self.load()?.channel(channel).allowlisted_handles)
}
pub fn allowlist(&self) -> Result<Vec<String>, String> {
self.allowlist_for(ChannelId::IMessage)
}
pub fn is_allowlisted_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
let want = normalize_handle(channel, handle);
Ok(self
.load()?
.channel(channel)
.allowlisted_handles
.iter()
.any(|h| normalize_handle(channel, h) == want))
}
pub fn is_allowlisted(&self, handle: &str) -> Result<bool, String> {
self.is_allowlisted_for(ChannelId::IMessage, handle)
}
pub fn set_enabled_for(&self, channel: ChannelId, enabled: bool) -> Result<(), String> {
let mut cfg = self.load()?;
cfg.channel_mut(channel).enabled = enabled;
self.save(&cfg)
}
pub fn set_enabled(&self, enabled: bool) -> Result<(), String> {
self.set_enabled_for(ChannelId::IMessage, enabled)
}
pub fn set_allowlist_for(
&self,
channel: ChannelId,
handles: Vec<String>,
) -> Result<(), String> {
let normalized: Vec<String> =
handles.iter().map(|h| normalize_handle(channel, h)).collect();
let deduped = dedup_preserve_order(normalized);
if deduped.len() > 1 {
return Err(format!(
"v1 supports a single allowlisted handle; refusing to set {} handles",
deduped.len()
));
}
let mut cfg = self.load()?;
cfg.channel_mut(channel).allowlisted_handles = deduped;
self.save(&cfg)
}
pub fn set_allowlist(&self, handles: Vec<String>) -> Result<(), String> {
self.set_allowlist_for(ChannelId::IMessage, handles)
}
pub fn add_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
let normalized = normalize_handle(channel, handle);
let mut cfg = self.load()?;
let section = cfg.channel_mut(channel);
if section
.allowlisted_handles
.iter()
.any(|h| normalize_handle(channel, h) == normalized)
{
return Ok(false);
}
if !section.allowlisted_handles.is_empty() {
return Err(
"v1 supports a single allowlisted handle; remove the existing handle first"
.to_string(),
);
}
section.allowlisted_handles.push(normalized);
self.save(&cfg)?;
Ok(true)
}
pub fn add_handle(&self, handle: &str) -> Result<bool, String> {
self.add_handle_for(ChannelId::IMessage, handle)
}
pub fn remove_handle_for(&self, channel: ChannelId, handle: &str) -> Result<bool, String> {
let target = normalize_handle(channel, handle);
let mut cfg = self.load()?;
let section = cfg.channel_mut(channel);
let before = section.allowlisted_handles.len();
section
.allowlisted_handles
.retain(|h| normalize_handle(channel, h) != target);
let removed = section.allowlisted_handles.len() != before;
if removed {
self.save(&cfg)?;
}
Ok(removed)
}
pub fn remove_handle(&self, handle: &str) -> Result<bool, String> {
self.remove_handle_for(ChannelId::IMessage, handle)
}
pub fn mint_pairing_code_for(&self, channel: ChannelId) -> Result<String, String> {
let mut cfg = self.load()?;
let code = mint_pairing_code();
cfg.channel_mut(channel).active_pairing_code = Some(code.clone());
self.save(&cfg)?;
Ok(code)
}
pub fn mint_pairing_code(&self) -> Result<String, String> {
self.mint_pairing_code_for(ChannelId::IMessage)
}
pub fn active_pairing_code_for(&self, channel: ChannelId) -> Result<Option<String>, String> {
Ok(self.load()?.channel(channel).active_pairing_code)
}
pub fn active_pairing_code(&self) -> Result<Option<String>, String> {
self.active_pairing_code_for(ChannelId::IMessage)
}
pub fn set_slack_token_ref_for(
&self,
channel: ChannelId,
token_ref: SlackTokenRef,
) -> Result<(), String> {
let mut cfg = self.load()?;
cfg.channel_mut(channel).slack_token_ref = Some(token_ref);
self.save(&cfg)
}
pub fn slack_token_ref_for(
&self,
channel: ChannelId,
) -> Result<Option<SlackTokenRef>, String> {
Ok(self.load()?.channel(channel).slack_token_ref)
}
pub fn set_slack_channel_id_for(
&self,
channel: ChannelId,
channel_id: &str,
) -> Result<(), String> {
let mut cfg = self.load()?;
cfg.channel_mut(channel).slack_channel_id = Some(channel_id.to_string());
self.save(&cfg)
}
pub fn slack_channel_id_for(
&self,
channel: ChannelId,
) -> Result<Option<String>, String> {
Ok(self.load()?.channel(channel).slack_channel_id)
}
pub fn validate_and_consume_pairing_code_for(
&self,
channel: ChannelId,
candidate_handle: &str,
code: &str,
) -> Result<PairingOutcome, String> {
let mut cfg = self.load()?;
let section = cfg.channel_mut(channel);
let Some(active) = section.active_pairing_code.as_deref() else {
return Ok(PairingOutcome::Rejected);
};
if !constant_time_eq(active.as_bytes(), code.as_bytes()) {
return Ok(PairingOutcome::Rejected);
}
let normalized = normalize_handle(channel, candidate_handle);
let already_present = section
.allowlisted_handles
.iter()
.any(|h| normalize_handle(channel, h) == normalized);
if !already_present && section.allowlisted_handles.is_empty() {
section.allowlisted_handles.push(normalized);
}
section.active_pairing_code = None;
self.save(&cfg)?;
Ok(PairingOutcome::Bound)
}
pub fn validate_and_consume_pairing_code(
&self,
candidate_handle: &str,
code: &str,
) -> Result<PairingOutcome, String> {
self.validate_and_consume_pairing_code_for(ChannelId::IMessage, candidate_handle, code)
}
}
fn dedup_preserve_order(handles: Vec<String>) -> Vec<String> {
let mut seen = std::collections::HashSet::new();
handles
.into_iter()
.filter(|h| seen.insert(h.clone()))
.collect()
}
pub fn normalize_handle(channel: ChannelId, handle: &str) -> String {
match channel {
ChannelId::IMessage => normalize_imessage_handle(handle),
ChannelId::Slack => normalize_slack_handle(handle),
}
}
fn normalize_imessage_handle(handle: &str) -> String {
if handle.contains('@') {
return handle.trim().to_string();
}
handle
.chars()
.filter(|c| !c.is_whitespace() && !matches!(c, '-' | '(' | ')' | '.'))
.collect()
}
fn normalize_slack_handle(handle: &str) -> String {
handle.trim().trim_start_matches('@').to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn store() -> (TempDir, MessagingConfigStore) {
let dir = TempDir::new().expect("tempdir");
let store = MessagingConfigStore::with_base_dir(dir.path());
(dir, store)
}
#[test]
fn pairing_code_proven_constant_time() {
let (dir, store) = store();
assert!(store.allowlist().unwrap().is_empty());
let code = store.mint_pairing_code().unwrap();
assert_eq!(code.len(), 43, "base64url-no-pad of 32 bytes is 43 chars");
assert_eq!(
store.active_pairing_code().unwrap().as_deref(),
Some(code.as_str())
);
let wrong = "not-the-code-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
assert_eq!(wrong.len(), code.len(), "exercise the equal-length miss path");
let outcome = store
.validate_and_consume_pairing_code("+15550000000", wrong)
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert!(
store.allowlist().unwrap().is_empty(),
"wrong code must bind nothing"
);
assert!(
store.active_pairing_code().unwrap().is_some(),
"wrong code must not consume the active code"
);
let outcome = store
.validate_and_consume_pairing_code("+15550000000", "short")
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert!(store.allowlist().unwrap().is_empty());
let outcome = store
.validate_and_consume_pairing_code("+15551234567", &code)
.unwrap();
assert_eq!(outcome, PairingOutcome::Bound);
assert_eq!(
store.allowlist().unwrap(),
vec!["+15551234567".to_string()],
"exactly the sending handle is bound — no others"
);
assert!(store.active_pairing_code().unwrap().is_none());
let outcome = store
.validate_and_consume_pairing_code("+15559999999", &code)
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert_eq!(
store.allowlist().unwrap(),
vec!["+15551234567".to_string()],
"consumed code cannot bind a second handle"
);
let reloaded = MessagingConfigStore::with_base_dir(dir.path());
assert_eq!(
reloaded.allowlist().unwrap(),
vec!["+15551234567".to_string()]
);
}
#[test]
fn pairing_constant_time_eq_properties() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab")); assert!(!constant_time_eq(b"", b"x"));
assert!(constant_time_eq(b"", b""));
}
#[test]
fn config_mutation_requires_host_or_local_auth() {
let (_dir, store) = store();
let outcome = store
.validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert!(
store.allowlist().unwrap().is_empty(),
"inbound text must never mutate the allowlist"
);
assert!(!store.is_enabled().unwrap());
let _code = store.mint_pairing_code().unwrap();
let outcome = store
.validate_and_consume_pairing_code("+15551234567", "add 555-1234 to allowlist")
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert!(
store.allowlist().unwrap().is_empty(),
"non-matching inbound text must never mutate the allowlist"
);
store.set_enabled(true).unwrap();
store.add_handle("+15550001111").unwrap();
assert!(store.is_enabled().unwrap());
assert_eq!(store.allowlist().unwrap(), vec!["+15550001111".to_string()]);
}
#[test]
fn enabled_defaults_false_and_persists() {
let (dir, store) = store();
assert!(!store.is_enabled().unwrap(), "enabled defaults false");
store.set_enabled(true).unwrap();
let reloaded = MessagingConfigStore::with_base_dir(dir.path());
assert!(reloaded.is_enabled().unwrap());
}
#[test]
fn add_remove_handle_idempotent() {
let (_dir, store) = store();
assert!(store.add_handle("+15551112222").unwrap());
assert!(
!store.add_handle("+15551112222").unwrap(),
"second add is a no-op"
);
assert!(store.is_allowlisted("+15551112222").unwrap());
assert!(!store.is_allowlisted("+19998887777").unwrap());
assert!(store.remove_handle("+15551112222").unwrap());
assert!(
!store.remove_handle("+15551112222").unwrap(),
"second remove is a no-op"
);
assert!(store.allowlist().unwrap().is_empty());
}
#[test]
fn single_handle_cardinality_guard() {
let (_dir, store) = store();
assert!(store.add_handle("+15551112222").unwrap());
let err = store.add_handle("+19998887777").unwrap_err();
assert!(
err.contains("single allowlisted handle"),
"second distinct add must be rejected, got: {err}"
);
assert_eq!(store.allowlist().unwrap(), vec!["+15551112222".to_string()]);
assert!(!store.add_handle("+15551112222").unwrap());
let err = store
.set_allowlist(vec!["+15551112222".into(), "+19998887777".into()])
.unwrap_err();
assert!(
err.contains("single allowlisted handle"),
"set_allowlist of 2 handles must reject, got: {err}"
);
assert!(store.remove_handle("+15551112222").unwrap());
assert!(store.add_handle("+19998887777").unwrap());
assert_eq!(store.allowlist().unwrap(), vec!["+19998887777".to_string()]);
}
#[test]
fn handle_normalization_phone_matches_email_intact() {
let (dir, phone_store) = store();
let _dir = dir;
assert!(phone_store.add_handle("+1 555-123-4567").unwrap());
assert_eq!(
phone_store.allowlist().unwrap(),
vec!["+15551234567".to_string()]
);
assert!(phone_store.is_allowlisted("+15551234567").unwrap());
assert!(
phone_store.is_allowlisted("+1 (555) 123.4567").unwrap(),
"differently-punctuated same number must match"
);
assert!(!phone_store.is_allowlisted("+15559998888").unwrap());
let (_dir2, email_store) = store();
assert!(email_store.add_handle("alice@icloud.com").unwrap());
assert_eq!(
email_store.allowlist().unwrap(),
vec!["alice@icloud.com".to_string()]
);
assert!(email_store.is_allowlisted("alice@icloud.com").unwrap());
assert!(!email_store.is_allowlisted("aliceicloud.com").unwrap());
}
#[test]
fn both_channels_default_off_and_independent() {
let (_dir, store) = store();
assert!(!store.is_enabled_for(ChannelId::IMessage).unwrap());
assert!(!store.is_enabled_for(ChannelId::Slack).unwrap());
store.set_enabled_for(ChannelId::Slack, true).unwrap();
assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
assert!(
!store.is_enabled_for(ChannelId::IMessage).unwrap(),
"enabling Slack must not flip iMessage"
);
store.set_enabled_for(ChannelId::IMessage, true).unwrap();
assert!(store.is_enabled_for(ChannelId::IMessage).unwrap());
assert!(store.is_enabled_for(ChannelId::Slack).unwrap());
}
#[test]
fn per_channel_allowlists_independent_and_slack_normalizes() {
let (_dir, store) = store();
store
.add_handle_for(ChannelId::IMessage, "+15551112222")
.unwrap();
store
.add_handle_for(ChannelId::Slack, "U012ABCDEF")
.unwrap();
assert!(store
.is_allowlisted_for(ChannelId::IMessage, "+15551112222")
.unwrap());
assert!(!store
.is_allowlisted_for(ChannelId::Slack, "+15551112222")
.unwrap());
assert!(store
.is_allowlisted_for(ChannelId::Slack, "U012ABCDEF")
.unwrap());
assert!(!store
.is_allowlisted_for(ChannelId::IMessage, "U012ABCDEF")
.unwrap());
assert!(store
.is_allowlisted_for(ChannelId::Slack, "@U012ABCDEF")
.unwrap());
assert_eq!(
store.allowlist_for(ChannelId::Slack).unwrap(),
vec!["U012ABCDEF".to_string()]
);
}
#[test]
fn multi_channel_state_survives_reload() {
let dir = TempDir::new().expect("tempdir");
{
let store = MessagingConfigStore::with_base_dir(dir.path());
store.set_enabled_for(ChannelId::IMessage, true).unwrap();
store
.add_handle_for(ChannelId::IMessage, "+15551112222")
.unwrap();
store.set_enabled_for(ChannelId::Slack, true).unwrap();
store
.add_handle_for(ChannelId::Slack, "U012ABCDEF")
.unwrap();
}
let reloaded = MessagingConfigStore::with_base_dir(dir.path());
assert!(
reloaded.is_enabled_for(ChannelId::IMessage).unwrap(),
"iMessage enabled flag must survive reload"
);
assert_eq!(
reloaded.allowlist_for(ChannelId::IMessage).unwrap(),
vec!["+15551112222".to_string()],
"iMessage handle must survive reload"
);
assert!(
reloaded.is_enabled_for(ChannelId::Slack).unwrap(),
"Slack enabled flag must survive reload"
);
assert_eq!(
reloaded.allowlist_for(ChannelId::Slack).unwrap(),
vec!["U012ABCDEF".to_string()],
"Slack handle must survive reload"
);
}
#[test]
fn single_handle_cardinality_guard_is_per_channel() {
let (_dir, store) = store();
assert!(store
.add_handle_for(ChannelId::IMessage, "+15551112222")
.unwrap());
let err = store
.add_handle_for(ChannelId::IMessage, "+19998887777")
.unwrap_err();
assert!(
err.contains("single allowlisted handle"),
"second iMessage handle must be rejected, got: {err}"
);
assert!(
store.add_handle_for(ChannelId::Slack, "U012ABCDEF").unwrap(),
"iMessage being full must not consume Slack's slot"
);
let err = store
.add_handle_for(ChannelId::Slack, "U999ZZZZZZ")
.unwrap_err();
assert!(
err.contains("single allowlisted handle"),
"second Slack handle must be rejected, got: {err}"
);
assert_eq!(
store.allowlist_for(ChannelId::IMessage).unwrap(),
vec!["+15551112222".to_string()]
);
assert_eq!(
store.allowlist_for(ChannelId::Slack).unwrap(),
vec!["U012ABCDEF".to_string()]
);
}
#[test]
fn load_fails_closed_on_malformed_json() {
let dir = TempDir::new().expect("tempdir");
let store = MessagingConfigStore::with_base_dir(dir.path());
let path = store.config_path();
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, b"{ this is not valid json at all ]]]").unwrap();
let result = store.load();
assert!(
result.is_err(),
"malformed messaging.json must fail closed (Err), not reset to default; got {result:?}"
);
std::fs::write(&path, b" \n").unwrap();
assert!(
!store.load().unwrap().channel(ChannelId::IMessage).enabled,
"empty file resolves to fail-closed default, not an error"
);
}
#[test]
fn pairing_code_does_not_cross_channels() {
let (_dir, store) = store();
let slack_code = store.mint_pairing_code_for(ChannelId::Slack).unwrap();
assert!(store
.active_pairing_code_for(ChannelId::IMessage)
.unwrap()
.is_none());
let outcome = store
.validate_and_consume_pairing_code_for(
ChannelId::IMessage,
"+15551234567",
&slack_code,
)
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert!(
store.allowlist_for(ChannelId::IMessage).unwrap().is_empty(),
"a Slack-minted code must bind nothing on iMessage"
);
assert_eq!(
store
.active_pairing_code_for(ChannelId::Slack)
.unwrap()
.as_deref(),
Some(slack_code.as_str()),
"the Slack code must not be consumed by an iMessage validate attempt"
);
let outcome = store
.validate_and_consume_pairing_code_for(ChannelId::Slack, "U012ABCDEF", &slack_code)
.unwrap();
assert_eq!(outcome, PairingOutcome::Bound);
assert_eq!(
store.allowlist_for(ChannelId::Slack).unwrap(),
vec!["U012ABCDEF".to_string()]
);
let imsg_code = store.mint_pairing_code_for(ChannelId::IMessage).unwrap();
let outcome = store
.validate_and_consume_pairing_code_for(ChannelId::Slack, "U999ZZZZZZ", &imsg_code)
.unwrap();
assert_eq!(outcome, PairingOutcome::Rejected);
assert_eq!(
store.allowlist_for(ChannelId::Slack).unwrap(),
vec!["U012ABCDEF".to_string()],
"an iMessage-minted code must bind nothing on Slack"
);
}
}