Skip to main content

meerkat_mobkit/
contact_directory.rs

1//! Contact directory — maps mob IDs to transport info for cross-mob communication.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::auth::peer_keys::decode_pubkey_b64;
8
9/// Transport for reaching an external mob.
10#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub enum MobTransport {
12    /// Same process — use InprocRegistry namespace lookup.
13    Inproc,
14    /// Remote process — TCP connection.
15    Tcp(String),
16    /// Remote process — Unix domain socket.
17    Uds(String),
18}
19
20/// Entry in the contact directory for one external mob.
21///
22/// The optional `pubkey` is the peer gateway's Ed25519 signing pubkey —
23/// 32 bytes, used by meerkat-comms to verify envelope signatures on real
24/// (TCP/UDS) transports. Inproc peers leave it unset and rely on the
25/// router's identity map. For non-inproc peers, callers that want signed
26/// envelopes either populate `pubkey` from a TOFU bootstrap (fetched via
27/// `mobkit/peer_pubkey`) or fail closed at wire time.
28#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ContactEntry {
30    pub mob_id: String,
31    pub transport: MobTransport,
32    /// 32-byte Ed25519 signing pubkey. `None` is allowed for inproc; for
33    /// real transports, [`UnifiedRuntime::wire_local`] rejects when this
34    /// is `None`.
35    #[serde(default, skip_serializing_if = "Option::is_none")]
36    pub pubkey: Option<[u8; 32]>,
37}
38
39/// Error loading or parsing a contact directory.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ContactDirectoryError {
42    /// TOML parsing failed.
43    Parse(String),
44    /// Invalid transport string.
45    InvalidTransport { mob_id: String, value: String },
46    /// Pubkey field present but did not decode as 32 bytes of base64.
47    InvalidPubkey { mob_id: String, reason: String },
48}
49
50impl std::fmt::Display for ContactDirectoryError {
51    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
52        match self {
53            Self::Parse(reason) => write!(f, "contact directory parse error: {reason}"),
54            Self::InvalidTransport { mob_id, value } => {
55                write!(f, "invalid transport for mob '{mob_id}': {value}")
56            }
57            Self::InvalidPubkey { mob_id, reason } => {
58                write!(f, "invalid pubkey for mob '{mob_id}': {reason}")
59            }
60        }
61    }
62}
63
64impl std::error::Error for ContactDirectoryError {}
65
66/// The contact directory — maps mob IDs to connection info.
67///
68/// Loaded from TOML config at startup. Immutable after construction.
69#[derive(Debug, Clone, Default)]
70pub struct ContactDirectory {
71    entries: BTreeMap<String, ContactEntry>,
72}
73
74impl ContactDirectory {
75    /// Parse a contact directory from TOML.
76    ///
77    /// Two value shapes per mob are accepted, side by side:
78    ///
79    /// ```toml
80    /// [mobs]
81    /// # Bare-string form (backward compatible, no pubkey).
82    /// google-workspace = "inproc"
83    ///
84    /// # Table form — required for non-inproc peers that want signed
85    /// # envelopes. `pubkey` is base64 of the 32-byte Ed25519 verifying
86    /// # key, optionally prefixed with `ed25519:` for parity with
87    /// # meerkat-comms trust files.
88    /// home-assistant = { transport = "tcp://192.168.1.50:9002", pubkey = "ed25519:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=" }
89    /// ```
90    pub fn from_toml(text: &str) -> Result<Self, ContactDirectoryError> {
91        let table: toml::Value =
92            toml::from_str(text).map_err(|e| ContactDirectoryError::Parse(e.to_string()))?;
93
94        let mobs = table
95            .get("mobs")
96            .and_then(|v| v.as_table())
97            .cloned()
98            .unwrap_or_default();
99
100        let mut entries = BTreeMap::new();
101        for (mob_id, value) in mobs {
102            let entry = parse_entry(&mob_id, &value)?;
103            entries.insert(mob_id, entry);
104        }
105
106        Ok(Self { entries })
107    }
108
109    /// Look up a mob by ID.
110    pub fn get(&self, mob_id: &str) -> Option<&ContactEntry> {
111        self.entries.get(mob_id)
112    }
113
114    /// Check if a mob ID is in the directory.
115    pub fn contains(&self, mob_id: &str) -> bool {
116        self.entries.contains_key(mob_id)
117    }
118
119    /// List all entries.
120    pub fn list(&self) -> Vec<&ContactEntry> {
121        self.entries.values().collect()
122    }
123}
124
125fn parse_entry(mob_id: &str, value: &toml::Value) -> Result<ContactEntry, ContactDirectoryError> {
126    if let Some(s) = value.as_str() {
127        let transport =
128            parse_transport(s).ok_or_else(|| ContactDirectoryError::InvalidTransport {
129                mob_id: mob_id.to_string(),
130                value: s.to_string(),
131            })?;
132        return Ok(ContactEntry {
133            mob_id: mob_id.to_string(),
134            transport,
135            pubkey: None,
136        });
137    }
138
139    if let Some(tbl) = value.as_table() {
140        let transport_str = tbl
141            .get("transport")
142            .and_then(|v| v.as_str())
143            .ok_or_else(|| ContactDirectoryError::InvalidTransport {
144                mob_id: mob_id.to_string(),
145                value: format!("{value}"),
146            })?;
147        let transport = parse_transport(transport_str).ok_or_else(|| {
148            ContactDirectoryError::InvalidTransport {
149                mob_id: mob_id.to_string(),
150                value: transport_str.to_string(),
151            }
152        })?;
153        let pubkey =
154            match tbl.get("pubkey").and_then(|v| v.as_str()) {
155                Some(s) => Some(decode_pubkey_b64(s).map_err(|err| {
156                    ContactDirectoryError::InvalidPubkey {
157                        mob_id: mob_id.to_string(),
158                        reason: err.to_string(),
159                    }
160                })?),
161                None => None,
162            };
163        return Ok(ContactEntry {
164            mob_id: mob_id.to_string(),
165            transport,
166            pubkey,
167        });
168    }
169
170    Err(ContactDirectoryError::InvalidTransport {
171        mob_id: mob_id.to_string(),
172        value: format!("{value}"),
173    })
174}
175
176fn parse_transport(s: &str) -> Option<MobTransport> {
177    if s == "inproc" {
178        return Some(MobTransport::Inproc);
179    }
180    if let Some(addr) = s.strip_prefix("tcp://") {
181        return Some(MobTransport::Tcp(addr.to_string()));
182    }
183    if let Some(path) = s.strip_prefix("uds://") {
184        return Some(MobTransport::Uds(path.to_string()));
185    }
186    None
187}
188
189/// Parse `"member::mob_id"` into `(member, mob_id)`.
190///
191/// Uses `::` as separator (not `@`) to avoid collision with email-based
192/// member IDs like `personal:luka@king.com`.
193///
194/// Only matches if `mob_id` is a known entry in the directory —
195/// bare member names and unknown mob IDs return `None`.
196pub fn parse_cross_mob_address<'a>(
197    address: &'a str,
198    directory: &ContactDirectory,
199) -> Option<(&'a str, &'a str)> {
200    let sep = address.rfind("::")?;
201    let member = &address[..sep];
202    let mob_id = &address[sep + 2..];
203    if member.is_empty() || mob_id.is_empty() {
204        return None;
205    }
206    if !directory.contains(mob_id) {
207        return None;
208    }
209    Some((member, mob_id))
210}
211
212#[cfg(test)]
213#[allow(clippy::unwrap_used)]
214mod tests {
215    use super::*;
216
217    #[test]
218    fn parse_valid_toml() {
219        let dir = ContactDirectory::from_toml(
220            r#"
221            [mobs]
222            google-workspace = "inproc"
223            home-assistant = "tcp://192.168.1.50:9002"
224            smart-home = "uds:///var/run/meerkat/smart-home.sock"
225            "#,
226        )
227        .unwrap();
228        assert_eq!(dir.list().len(), 3);
229        assert_eq!(
230            dir.get("google-workspace").unwrap().transport,
231            MobTransport::Inproc
232        );
233        assert_eq!(
234            dir.get("home-assistant").unwrap().transport,
235            MobTransport::Tcp("192.168.1.50:9002".to_string())
236        );
237        assert_eq!(
238            dir.get("smart-home").unwrap().transport,
239            MobTransport::Uds("/var/run/meerkat/smart-home.sock".to_string())
240        );
241        // Bare-string form leaves pubkey unset (backward compatible).
242        for entry in dir.list() {
243            assert!(entry.pubkey.is_none(), "{} pubkey", entry.mob_id);
244        }
245    }
246
247    #[test]
248    fn parse_table_form_carries_pubkey() {
249        let pubkey_b64 = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";
250        let dir = ContactDirectory::from_toml(&format!(
251            r#"
252            [mobs]
253            home-assistant = {{ transport = "tcp://192.168.1.50:9002", pubkey = "{pubkey_b64}" }}
254            "#,
255        ))
256        .unwrap();
257        let entry = dir.get("home-assistant").unwrap();
258        assert!(matches!(entry.transport, MobTransport::Tcp(_)));
259        assert_eq!(entry.pubkey, Some([42u8; 32]));
260    }
261
262    #[test]
263    fn parse_table_form_accepts_ed25519_prefix() {
264        let dir = ContactDirectory::from_toml(
265            r#"
266            [mobs]
267            home-assistant = { transport = "tcp://1.2.3.4:9000", pubkey = "ed25519:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=" }
268            "#,
269        )
270        .unwrap();
271        assert_eq!(dir.get("home-assistant").unwrap().pubkey, Some([42u8; 32]));
272    }
273
274    #[test]
275    fn parse_table_form_rejects_bad_pubkey() {
276        let result = ContactDirectory::from_toml(
277            r#"
278            [mobs]
279            home-assistant = { transport = "tcp://1.2.3.4:9000", pubkey = "not-base64!!" }
280            "#,
281        );
282        assert!(matches!(
283            result,
284            Err(ContactDirectoryError::InvalidPubkey { .. })
285        ));
286    }
287
288    #[test]
289    fn parse_empty_toml() {
290        let dir = ContactDirectory::from_toml("[mobs]").unwrap();
291        assert!(dir.list().is_empty());
292    }
293
294    #[test]
295    fn parse_missing_mobs_section() {
296        let dir = ContactDirectory::from_toml("").unwrap();
297        assert!(dir.list().is_empty());
298    }
299
300    #[test]
301    fn parse_invalid_transport() {
302        let result = ContactDirectory::from_toml(
303            r#"
304            [mobs]
305            bad = "ftp://nope"
306            "#,
307        );
308        assert!(matches!(
309            result,
310            Err(ContactDirectoryError::InvalidTransport { .. })
311        ));
312    }
313
314    #[test]
315    fn cross_mob_address_parsing() {
316        let dir = ContactDirectory::from_toml(
317            r#"
318            [mobs]
319            google-workspace = "inproc"
320            "#,
321        )
322        .unwrap();
323
324        // Valid cross-mob address
325        assert_eq!(
326            parse_cross_mob_address("calendar::google-workspace", &dir),
327            Some(("calendar", "google-workspace"))
328        );
329
330        // Bare member name — no match
331        assert_eq!(parse_cross_mob_address("calendar", &dir), None);
332
333        // Unknown mob — no match
334        assert_eq!(parse_cross_mob_address("calendar::unknown-mob", &dir), None);
335
336        // Email-based member ID — no match (no :: separator)
337        assert_eq!(
338            parse_cross_mob_address("personal:luka@king.com", &dir),
339            None
340        );
341
342        // Empty parts
343        assert_eq!(parse_cross_mob_address("::google-workspace", &dir), None);
344        assert_eq!(parse_cross_mob_address("calendar::", &dir), None);
345    }
346}