pub mod rendezvous;
pub mod sas;
use anyhow::Context;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::{Mutex, MutexGuard};
const INVITE_SCHEME: &str = "mcpmesh-invite:";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Invite {
pub secret: [u8; 32],
pub inviter_id: [u8; 32],
pub inviter_addr_json: String,
pub nickname: String,
pub services: Vec<String>,
pub expires_at_epoch: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub app_label: Option<String>,
}
pub const MAX_APP_LABEL_LEN: usize = 256;
impl Invite {
pub fn encode(&self) -> String {
let json = serde_json::to_vec(self).expect("invite serializes");
format!(
"{INVITE_SCHEME}{}",
data_encoding::BASE32_NOPAD.encode(&json)
)
}
pub fn decode(line: &str) -> anyhow::Result<Self> {
let payload = line.strip_prefix(INVITE_SCHEME).ok_or_else(|| {
anyhow::anyhow!("not an mcpmesh invite (missing {INVITE_SCHEME} scheme)")
})?;
let json = data_encoding::BASE32_NOPAD
.decode(payload.as_bytes())
.context("invite payload is not valid base32")?;
serde_json::from_slice(&json).context("invite payload is not a valid invite")
}
}
#[derive(Debug)]
pub enum Redeem {
Ok(Invite),
Expired,
Unknown,
}
#[derive(Default)]
pub struct LiveInvites {
inner: Mutex<HashMap<[u8; 32], Invite>>,
}
impl LiveInvites {
pub fn new() -> Self {
Self::default()
}
fn guard(&self) -> MutexGuard<'_, HashMap<[u8; 32], Invite>> {
self.inner.lock().expect("LiveInvites mutex poisoned")
}
pub fn mint(&self, invite: Invite) {
self.guard().insert(invite.secret, invite);
}
pub fn try_redeem(&self, secret: &[u8; 32], now_epoch: u64) -> Redeem {
let mut map = self.guard();
match map.get(secret) {
None => Redeem::Unknown,
Some(inv) if inv.expires_at_epoch < now_epoch => {
map.remove(secret);
Redeem::Expired
}
Some(_) => {
let inv = map.remove(secret).expect("present under lock");
Redeem::Ok(inv)
}
}
}
pub fn count(&self) -> usize {
self.guard().len()
}
pub fn remove_expired(&self, now_epoch: u64) {
self.guard()
.retain(|_, inv| inv.expires_at_epoch >= now_epoch);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_invite(secret: u8, expires_at_epoch: u64) -> Invite {
Invite {
secret: [secret; 32],
inviter_id: [3u8; 32],
inviter_addr_json: "{\"id\":\"abc\",\"addrs\":[]}".into(),
nickname: "alice".into(),
services: vec!["notes".into()],
expires_at_epoch,
app_label: None,
}
}
#[test]
fn invite_roundtrips_through_the_line_encoding() {
let inv = sample_invite(7, 1_800_000_000);
let line = inv.encode(); assert!(line.starts_with("mcpmesh-invite:"));
let back = Invite::decode(&line).unwrap();
assert_eq!(back, inv);
assert!(Invite::decode("mcpmesh-invite:!!!not-valid").is_err());
assert!(Invite::decode("notaninvite").is_err());
}
#[test]
fn invite_carries_an_opaque_app_label_additively() {
let mut inv = sample_invite(9, 1_800_000_000);
inv.app_label = Some("urn:kb-mesh:node:abc123".into());
let back = Invite::decode(&inv.encode()).unwrap();
assert_eq!(back.app_label.as_deref(), Some("urn:kb-mesh:node:abc123"));
assert_eq!(back, inv);
let no_label = sample_invite(9, 1_800_000_000);
assert!(no_label.app_label.is_none());
let json = serde_json::to_vec(&no_label).unwrap();
assert!(!String::from_utf8_lossy(&json).contains("app_label"));
let line = format!(
"mcpmesh-invite:{}",
data_encoding::BASE32_NOPAD.encode(&json)
);
assert_eq!(Invite::decode(&line).unwrap().app_label, None);
}
#[test]
fn decode_rejects_hostile_payloads_without_panicking() {
assert!(Invite::decode("mcpmesh-invite:").is_err());
let not_invite = data_encoding::BASE32_NOPAD.encode(b"{\"nope\":1}");
assert!(Invite::decode(&format!("mcpmesh-invite:{not_invite}")).is_err());
}
#[test]
fn mint_then_redeem_valid_burns_the_invite() {
let live = LiveInvites::default();
let inv = sample_invite(7, 1_800_000_000);
let secret = inv.secret;
live.mint(inv.clone());
assert_eq!(live.count(), 1);
match live.try_redeem(&secret, 1_000_000_000) {
Redeem::Ok(got) => assert_eq!(got, inv),
other => panic!("expected Ok, got {other:?}"),
}
assert!(matches!(
live.try_redeem(&secret, 1_000_000_000),
Redeem::Unknown
));
assert_eq!(live.count(), 0);
}
#[test]
fn redeem_unknown_secret_is_unknown_and_leaves_other_invites_untouched() {
let live = LiveInvites::default();
let inv = sample_invite(7, 1_800_000_000);
live.mint(inv);
assert!(matches!(
live.try_redeem(&[9u8; 32], 1_000_000_000),
Redeem::Unknown
));
assert_eq!(
live.count(),
1,
"unknown secret must not burn a live invite"
);
}
#[test]
fn redeem_expired_secret_is_expired_and_removed() {
let live = LiveInvites::default();
let inv = sample_invite(7, 1_000);
let secret = inv.secret;
live.mint(inv);
assert!(matches!(live.try_redeem(&secret, 2_000), Redeem::Expired));
assert_eq!(live.count(), 0);
}
#[test]
fn remove_expired_drops_only_the_stale_invites() {
let live = LiveInvites::default();
live.mint(sample_invite(1, 1_000)); live.mint(sample_invite(2, 9_000)); assert_eq!(live.count(), 2);
live.remove_expired(2_000);
assert_eq!(live.count(), 1);
assert!(matches!(live.try_redeem(&[2u8; 32], 2_000), Redeem::Ok(_)));
}
}