use anyhow::{Context, Result};
use mcpmesh_net::{EndpointId, PeerIdentity, TrustGate};
use redb::{Database, ReadableTable, TableDefinition};
use std::path::Path;
use std::sync::Arc;
const PEERS: TableDefinition<&[u8], &[u8]> = TableDefinition::new("peers");
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct PeerEntry {
pub endpoint_id: [u8; 32],
pub nickname: String,
pub services: Vec<String>,
#[serde(default)]
pub paired_at: Option<String>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub last_addr: Option<String>,
}
pub struct PeerStore {
db: Database,
}
impl PeerStore {
pub fn open(path: &Path) -> Result<Self> {
let db = Database::create(path)
.with_context(|| format!("open peer store {}", path.display()))?;
let txn = db.begin_write()?;
txn.open_table(PEERS)?;
txn.commit()?;
Ok(Self { db })
}
pub fn add(&self, e: PeerEntry) -> Result<()> {
let bytes = serde_json::to_vec(&e)?;
let txn = self.db.begin_write()?;
{
let mut table = txn.open_table(PEERS)?;
table.insert(e.endpoint_id.as_slice(), bytes.as_slice())?;
}
txn.commit()?;
Ok(())
}
pub fn resolve(&self, endpoint_id: &[u8; 32]) -> Result<Option<PeerEntry>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(PEERS)?;
match table.get(endpoint_id.as_slice())? {
Some(v) => match serde_json::from_slice::<PeerEntry>(v.value()) {
Ok(entry) => Ok(Some(entry)),
Err(e) => {
tracing::warn!(
key_prefix = ?&endpoint_id[..8],
error = %e,
"corrupt peer entry for queried key; treating as unresolved (deny)"
);
Ok(None)
}
},
None => Ok(None),
}
}
pub fn entry_for(&self, nickname: &str) -> Result<Option<PeerEntry>> {
Ok(self.list()?.into_iter().find(|e| e.nickname == nickname))
}
pub fn entries_for_user(&self, user_id: &str) -> Result<Vec<PeerEntry>> {
Ok(self
.list()?
.into_iter()
.filter(|e| e.user_id.as_deref() == Some(user_id))
.collect())
}
pub fn list(&self) -> Result<Vec<PeerEntry>> {
let txn = self.db.begin_read()?;
let table = txn.open_table(PEERS)?;
let mut out = Vec::new();
for row in table.iter()? {
let (k, v) = row?;
match serde_json::from_slice::<PeerEntry>(v.value()) {
Ok(entry) => out.push(entry),
Err(e) => {
let kb = k.value();
tracing::warn!(
key_prefix = ?&kb[..kb.len().min(8)],
error = %e,
"skipping corrupt peer entry during list"
);
}
}
}
Ok(out)
}
pub fn remove(&self, nickname: &str) -> Result<bool> {
let txn = self.db.begin_write()?;
let removed = {
let mut table = txn.open_table(PEERS)?;
let victims: Vec<Vec<u8>> = {
let mut v = Vec::new();
for row in table.iter()? {
let (k, val) = row?;
match serde_json::from_slice::<PeerEntry>(val.value()) {
Ok(entry) if entry.nickname == nickname => v.push(k.value().to_vec()),
Ok(_) => {}
Err(e) => {
let kb = k.value();
tracing::warn!(
key_prefix = ?&kb[..kb.len().min(8)],
error = %e,
"skipping corrupt peer entry during remove"
);
}
}
}
v
};
for k in &victims {
table.remove(k.as_slice())?;
}
!victims.is_empty()
};
txn.commit()?;
Ok(removed)
}
}
pub struct AllowlistGate {
store: Arc<PeerStore>,
}
impl AllowlistGate {
pub fn new(store: Arc<PeerStore>) -> Self {
Self { store }
}
}
impl TrustGate for AllowlistGate {
fn resolve(&self, endpoint: &EndpointId) -> Option<PeerIdentity> {
match self.store.resolve(endpoint.as_bytes()) {
Ok(Some(e)) => Some(PeerIdentity {
endpoint: *endpoint,
user_id: e.user_id, name: e.nickname,
groups: vec![],
}),
Ok(None) => None,
Err(e) => {
tracing::warn!(%e, "peer store read failed; refusing (default-deny)");
None
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(eid: [u8; 32], nickname: &str, services: &[&str]) -> PeerEntry {
PeerEntry {
endpoint_id: eid,
nickname: nickname.into(),
services: services.iter().map(|s| s.to_string()).collect(),
paired_at: None,
user_id: None,
last_addr: None,
}
}
fn inject_raw(store: &PeerStore, eid: &[u8; 32], bytes: &[u8]) {
let txn = store.db.begin_write().unwrap();
{
let mut table = txn.open_table(PEERS).unwrap();
table.insert(eid.as_slice(), bytes).unwrap();
}
txn.commit().unwrap();
}
#[test]
fn gate_resolves_known_nickname_refuses_unknown() {
use mcpmesh_net::TrustGate;
use std::sync::Arc;
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let known_eid = [7u8; 32];
store.add(entry(known_eid, "bob", &["notes"])).unwrap();
let gate = AllowlistGate::new(Arc::new(store));
let id = gate.resolve(&known_eid.into()).unwrap();
assert_eq!(id.name, "bob");
assert_eq!(id.user_id, None);
assert!(id.groups.is_empty());
assert!(gate.resolve(&[9u8; 32].into()).is_none());
}
#[test]
fn add_then_resolve_and_list() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [7u8; 32];
store.add(entry(eid, "bob", &["notes"])).unwrap();
assert_eq!(store.resolve(&eid).unwrap().unwrap().nickname, "bob");
assert!(store.resolve(&[9u8; 32]).unwrap().is_none());
assert_eq!(store.list().unwrap().len(), 1);
}
#[test]
fn entry_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.redb");
let eid = [42u8; 32];
{
let store = PeerStore::open(&path).unwrap();
store.add(entry(eid, "alice", &["notes", "kb"])).unwrap();
} let store = PeerStore::open(&path).unwrap();
let got = store.resolve(&eid).unwrap().unwrap();
assert_eq!(got.nickname, "alice");
assert_eq!(got.services, vec!["notes".to_string(), "kb".to_string()]);
}
#[test]
fn add_upserts_same_endpoint_id() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [1u8; 32];
store.add(entry(eid, "bob", &["notes"])).unwrap();
store.add(entry(eid, "bob-renamed", &["kb"])).unwrap();
let all = store.list().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].nickname, "bob-renamed");
assert_eq!(all[0].services, vec!["kb".to_string()]);
}
#[test]
fn remove_deletes_match_and_is_a_noop_for_absent() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [3u8; 32];
store.add(entry(eid, "carol", &[])).unwrap();
assert!(
!store.remove("nobody").unwrap(),
"removing an absent nickname removes nothing"
);
assert!(store.resolve(&eid).unwrap().is_some());
assert!(
store.remove("carol").unwrap(),
"removing a present nickname reports the deletion"
);
assert!(store.resolve(&eid).unwrap().is_none());
}
#[test]
fn remove_persists_across_reopen() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("state.redb");
let eid = [5u8; 32];
{
let store = PeerStore::open(&path).unwrap();
store.add(entry(eid, "dave", &[])).unwrap();
store.remove("dave").unwrap();
}
let store = PeerStore::open(&path).unwrap();
assert!(store.resolve(&eid).unwrap().is_none());
}
#[test]
fn remove_deletes_all_entries_sharing_a_nickname() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
store.add(entry([10u8; 32], "dup", &[])).unwrap();
store.add(entry([11u8; 32], "dup", &[])).unwrap();
assert_eq!(store.list().unwrap().len(), 2);
store.remove("dup").unwrap();
assert_eq!(store.list().unwrap().len(), 0);
}
#[test]
fn old_row_without_paired_at_still_resolves_defaulting_to_none() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [7u8; 32];
let old_shape = serde_json::json!({
"endpoint_id": eid.to_vec(),
"nickname": "old",
"services": ["notes"],
});
inject_raw(&store, &eid, &serde_json::to_vec(&old_shape).unwrap());
let got = store.resolve(&eid).unwrap().unwrap();
assert_eq!(got.nickname, "old");
assert_eq!(got.services, vec!["notes".to_string()]);
assert_eq!(got.paired_at, None); }
#[test]
fn paired_at_round_trips_when_set() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [8u8; 32];
let mut e = entry(eid, "bob", &["notes"]);
e.paired_at = Some("1751760000".into());
store.add(e).unwrap();
let got = store.resolve(&eid).unwrap().unwrap();
assert_eq!(got.paired_at.as_deref(), Some("1751760000"));
}
#[test]
fn old_row_without_last_addr_still_resolves_defaulting_to_none() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [9u8; 32];
let old_shape = serde_json::json!({
"endpoint_id": eid.to_vec(),
"nickname": "old",
"services": ["notes"],
"paired_at": "1751760000",
"user_id": null,
});
inject_raw(&store, &eid, &serde_json::to_vec(&old_shape).unwrap());
let got = store.resolve(&eid).unwrap().unwrap();
assert_eq!(got.nickname, "old");
assert_eq!(got.last_addr, None); }
#[test]
fn last_addr_round_trips_when_set() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [10u8; 32];
let mut e = entry(eid, "bob", &["notes"]);
e.last_addr = Some(r#"{"id":"whatever","addrs":[]}"#.into());
store.add(e).unwrap();
let got = store.resolve(&eid).unwrap().unwrap();
assert_eq!(
got.last_addr.as_deref(),
Some(r#"{"id":"whatever","addrs":[]}"#)
);
}
#[test]
fn entry_for_returns_the_full_entry() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let eid = [11u8; 32];
let mut e = entry(eid, "alice", &["echo"]);
e.last_addr = Some("{}".into());
store.add(e).unwrap();
let got = store.entry_for("alice").unwrap().unwrap();
assert_eq!(got.endpoint_id, eid);
assert_eq!(got.last_addr.as_deref(), Some("{}"));
assert!(store.entry_for("nobody").unwrap().is_none());
}
#[test]
fn entries_for_user_groups_a_persons_devices() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let mut laptop = entry([1u8; 32], "alice", &["notes"]);
laptop.user_id = Some("b64u:ALICE".into());
let mut phone = entry([2u8; 32], "alice-phone", &["notes"]);
phone.user_id = Some("b64u:ALICE".into());
let mut bob = entry([3u8; 32], "bob", &["kb"]);
bob.user_id = Some("b64u:BOB".into());
let legacy = entry([4u8; 32], "carol", &["x"]); for e in [laptop, phone, bob, legacy] {
store.add(e).unwrap();
}
let alice = store.entries_for_user("b64u:ALICE").unwrap();
assert_eq!(alice.len(), 2, "both of alice's devices match her user_id");
let mut eids: Vec<_> = alice.iter().map(|e| e.endpoint_id).collect();
eids.sort();
assert_eq!(eids, vec![[1u8; 32], [2u8; 32]]);
assert_eq!(store.entries_for_user("b64u:BOB").unwrap().len(), 1);
assert!(store.entries_for_user("b64u:NOBODY").unwrap().is_empty());
}
#[test]
fn corrupt_row_is_skipped_on_list_and_denied_on_resolve() {
let dir = tempfile::tempdir().unwrap();
let store = PeerStore::open(&dir.path().join("state.redb")).unwrap();
let good = [1u8; 32];
let bad = [2u8; 32];
store.add(entry(good, "good", &["notes"])).unwrap();
inject_raw(&store, &bad, b"not json at all");
let all = store.list().unwrap();
assert_eq!(all.len(), 1);
assert_eq!(all[0].nickname, "good");
assert!(store.resolve(&bad).unwrap().is_none());
assert_eq!(store.resolve(&good).unwrap().unwrap().nickname, "good");
store.remove("good").unwrap();
assert!(store.resolve(&good).unwrap().is_none());
}
}