use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::auth::peer_keys::decode_pubkey_b64;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum MobTransport {
Inproc,
Tcp(String),
Uds(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContactEntry {
pub mob_id: String,
pub transport: MobTransport,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pubkey: Option<[u8; 32]>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContactDirectoryError {
Parse(String),
InvalidTransport { mob_id: String, value: String },
InvalidPubkey { mob_id: String, reason: String },
}
impl std::fmt::Display for ContactDirectoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Parse(reason) => write!(f, "contact directory parse error: {reason}"),
Self::InvalidTransport { mob_id, value } => {
write!(f, "invalid transport for mob '{mob_id}': {value}")
}
Self::InvalidPubkey { mob_id, reason } => {
write!(f, "invalid pubkey for mob '{mob_id}': {reason}")
}
}
}
}
impl std::error::Error for ContactDirectoryError {}
#[derive(Debug, Clone, Default)]
pub struct ContactDirectory {
entries: BTreeMap<String, ContactEntry>,
}
impl ContactDirectory {
pub fn from_toml(text: &str) -> Result<Self, ContactDirectoryError> {
let table: toml::Value =
toml::from_str(text).map_err(|e| ContactDirectoryError::Parse(e.to_string()))?;
let mobs = table
.get("mobs")
.and_then(|v| v.as_table())
.cloned()
.unwrap_or_default();
let mut entries = BTreeMap::new();
for (mob_id, value) in mobs {
let entry = parse_entry(&mob_id, &value)?;
entries.insert(mob_id, entry);
}
Ok(Self { entries })
}
pub fn get(&self, mob_id: &str) -> Option<&ContactEntry> {
self.entries.get(mob_id)
}
pub fn contains(&self, mob_id: &str) -> bool {
self.entries.contains_key(mob_id)
}
pub fn list(&self) -> Vec<&ContactEntry> {
self.entries.values().collect()
}
}
fn parse_entry(mob_id: &str, value: &toml::Value) -> Result<ContactEntry, ContactDirectoryError> {
if let Some(s) = value.as_str() {
let transport =
parse_transport(s).ok_or_else(|| ContactDirectoryError::InvalidTransport {
mob_id: mob_id.to_string(),
value: s.to_string(),
})?;
return Ok(ContactEntry {
mob_id: mob_id.to_string(),
transport,
pubkey: None,
});
}
if let Some(tbl) = value.as_table() {
let transport_str = tbl
.get("transport")
.and_then(|v| v.as_str())
.ok_or_else(|| ContactDirectoryError::InvalidTransport {
mob_id: mob_id.to_string(),
value: format!("{value}"),
})?;
let transport = parse_transport(transport_str).ok_or_else(|| {
ContactDirectoryError::InvalidTransport {
mob_id: mob_id.to_string(),
value: transport_str.to_string(),
}
})?;
let pubkey =
match tbl.get("pubkey").and_then(|v| v.as_str()) {
Some(s) => Some(decode_pubkey_b64(s).map_err(|err| {
ContactDirectoryError::InvalidPubkey {
mob_id: mob_id.to_string(),
reason: err.to_string(),
}
})?),
None => None,
};
return Ok(ContactEntry {
mob_id: mob_id.to_string(),
transport,
pubkey,
});
}
Err(ContactDirectoryError::InvalidTransport {
mob_id: mob_id.to_string(),
value: format!("{value}"),
})
}
fn parse_transport(s: &str) -> Option<MobTransport> {
if s == "inproc" {
return Some(MobTransport::Inproc);
}
if let Some(addr) = s.strip_prefix("tcp://") {
return Some(MobTransport::Tcp(addr.to_string()));
}
if let Some(path) = s.strip_prefix("uds://") {
return Some(MobTransport::Uds(path.to_string()));
}
None
}
pub fn parse_cross_mob_address<'a>(
address: &'a str,
directory: &ContactDirectory,
) -> Option<(&'a str, &'a str)> {
let sep = address.rfind("::")?;
let member = &address[..sep];
let mob_id = &address[sep + 2..];
if member.is_empty() || mob_id.is_empty() {
return None;
}
if !directory.contains(mob_id) {
return None;
}
Some((member, mob_id))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)]
mod tests {
use super::*;
#[test]
fn parse_valid_toml() {
let dir = ContactDirectory::from_toml(
r#"
[mobs]
google-workspace = "inproc"
home-assistant = "tcp://192.168.1.50:9002"
smart-home = "uds:///var/run/meerkat/smart-home.sock"
"#,
)
.unwrap();
assert_eq!(dir.list().len(), 3);
assert_eq!(
dir.get("google-workspace").unwrap().transport,
MobTransport::Inproc
);
assert_eq!(
dir.get("home-assistant").unwrap().transport,
MobTransport::Tcp("192.168.1.50:9002".to_string())
);
assert_eq!(
dir.get("smart-home").unwrap().transport,
MobTransport::Uds("/var/run/meerkat/smart-home.sock".to_string())
);
for entry in dir.list() {
assert!(entry.pubkey.is_none(), "{} pubkey", entry.mob_id);
}
}
#[test]
fn parse_table_form_carries_pubkey() {
let pubkey_b64 = "KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=";
let dir = ContactDirectory::from_toml(&format!(
r#"
[mobs]
home-assistant = {{ transport = "tcp://192.168.1.50:9002", pubkey = "{pubkey_b64}" }}
"#,
))
.unwrap();
let entry = dir.get("home-assistant").unwrap();
assert!(matches!(entry.transport, MobTransport::Tcp(_)));
assert_eq!(entry.pubkey, Some([42u8; 32]));
}
#[test]
fn parse_table_form_accepts_ed25519_prefix() {
let dir = ContactDirectory::from_toml(
r#"
[mobs]
home-assistant = { transport = "tcp://1.2.3.4:9000", pubkey = "ed25519:KioqKioqKioqKioqKioqKioqKioqKioqKioqKioqKio=" }
"#,
)
.unwrap();
assert_eq!(dir.get("home-assistant").unwrap().pubkey, Some([42u8; 32]));
}
#[test]
fn parse_table_form_rejects_bad_pubkey() {
let result = ContactDirectory::from_toml(
r#"
[mobs]
home-assistant = { transport = "tcp://1.2.3.4:9000", pubkey = "not-base64!!" }
"#,
);
assert!(matches!(
result,
Err(ContactDirectoryError::InvalidPubkey { .. })
));
}
#[test]
fn parse_empty_toml() {
let dir = ContactDirectory::from_toml("[mobs]").unwrap();
assert!(dir.list().is_empty());
}
#[test]
fn parse_missing_mobs_section() {
let dir = ContactDirectory::from_toml("").unwrap();
assert!(dir.list().is_empty());
}
#[test]
fn parse_invalid_transport() {
let result = ContactDirectory::from_toml(
r#"
[mobs]
bad = "ftp://nope"
"#,
);
assert!(matches!(
result,
Err(ContactDirectoryError::InvalidTransport { .. })
));
}
#[test]
fn cross_mob_address_parsing() {
let dir = ContactDirectory::from_toml(
r#"
[mobs]
google-workspace = "inproc"
"#,
)
.unwrap();
assert_eq!(
parse_cross_mob_address("calendar::google-workspace", &dir),
Some(("calendar", "google-workspace"))
);
assert_eq!(parse_cross_mob_address("calendar", &dir), None);
assert_eq!(parse_cross_mob_address("calendar::unknown-mob", &dir), None);
assert_eq!(
parse_cross_mob_address("personal:luka@king.com", &dir),
None
);
assert_eq!(parse_cross_mob_address("::google-workspace", &dir), None);
assert_eq!(parse_cross_mob_address("calendar::", &dir), None);
}
}