1use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7use crate::auth::peer_keys::decode_pubkey_b64;
8
9#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
11pub enum MobTransport {
12 Inproc,
14 Tcp(String),
16 Uds(String),
18}
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
29pub struct ContactEntry {
30 pub mob_id: String,
31 pub transport: MobTransport,
32 #[serde(default, skip_serializing_if = "Option::is_none")]
36 pub pubkey: Option<[u8; 32]>,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ContactDirectoryError {
42 Parse(String),
44 InvalidTransport { mob_id: String, value: String },
46 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#[derive(Debug, Clone, Default)]
70pub struct ContactDirectory {
71 entries: BTreeMap<String, ContactEntry>,
72}
73
74impl ContactDirectory {
75 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 pub fn get(&self, mob_id: &str) -> Option<&ContactEntry> {
111 self.entries.get(mob_id)
112 }
113
114 pub fn contains(&self, mob_id: &str) -> bool {
116 self.entries.contains_key(mob_id)
117 }
118
119 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
189pub 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 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 assert_eq!(
326 parse_cross_mob_address("calendar::google-workspace", &dir),
327 Some(("calendar", "google-workspace"))
328 );
329
330 assert_eq!(parse_cross_mob_address("calendar", &dir), None);
332
333 assert_eq!(parse_cross_mob_address("calendar::unknown-mob", &dir), None);
335
336 assert_eq!(
338 parse_cross_mob_address("personal:luka@king.com", &dir),
339 None
340 );
341
342 assert_eq!(parse_cross_mob_address("::google-workspace", &dir), None);
344 assert_eq!(parse_cross_mob_address("calendar::", &dir), None);
345 }
346}