bp_core/network/
kad_store.rs

1//! Persistence for the Kademlia routing table.
2//!
3//! On every save tick (600 s) and on daemon shutdown, the set of peer
4//! multi-addresses discovered via mDNS or Identify is written to
5//! `~/.local/share/billpouch/kad_peers.json`.
6//!
7//! On the next daemon startup, [`KadPeers::load`] reads that file and the
8//! network loop dials each saved address.  This lets the node reconnect to
9//! previously-known peers instantly, without waiting for mDNS multicast.
10//!
11//! ## File format
12//!
13//! Plain JSON object mapping `peer_id_string → [multiaddr_string, ...]`.
14//!
15//! ```json
16//! {
17//!   "12D3KooW...": ["/ip4/192.168.1.10/tcp/41000", "/ip6/.../tcp/41001"],
18//!   ...
19//! }
20//! ```
21
22use serde::{Deserialize, Serialize};
23use std::{collections::HashMap, path::Path};
24
25// ── KadPeers ──────────────────────────────────────────────────────────────────
26
27/// Persisted map of `peer_id → [multiaddr_string, ...]`.
28///
29/// Wraps a plain [`HashMap`] so we can implement load/save once and reuse the
30/// type in the network loop.
31#[derive(Debug, Default, Serialize, Deserialize)]
32pub struct KadPeers(pub HashMap<String, Vec<String>>);
33
34impl KadPeers {
35    /// Load from `path`.  Returns an empty [`KadPeers`] if the file does not
36    /// exist or cannot be parsed.
37    pub fn load(path: &Path) -> Self {
38        std::fs::read_to_string(path)
39            .ok()
40            .and_then(|s| serde_json::from_str(&s).ok())
41            .unwrap_or_default()
42    }
43
44    /// Write to `path` as pretty-printed JSON.  Logs a warning on failure
45    /// (never panics).
46    pub fn save(&self, path: &Path) {
47        match serde_json::to_string_pretty(self) {
48            Ok(s) => {
49                if let Err(e) = std::fs::write(path, s) {
50                    tracing::warn!("kad_store: failed to save peers: {}", e);
51                }
52            }
53            Err(e) => {
54                tracing::warn!("kad_store: serialization error: {}", e);
55            }
56        }
57    }
58
59    /// Add `addr_str` to the entry for `peer_id_str`, deduplicating in place.
60    pub fn add(&mut self, peer_id_str: String, addr_str: String) {
61        let entry = self.0.entry(peer_id_str).or_default();
62        if !entry.contains(&addr_str) {
63            entry.push(addr_str);
64        }
65    }
66}
67
68// ── Tests ─────────────────────────────────────────────────────────────────────
69
70#[cfg(test)]
71mod tests {
72    use super::*;
73
74    fn tmp_path(name: &str) -> std::path::PathBuf {
75        std::env::temp_dir().join(format!("bp_kad_test_{name}.json"))
76    }
77
78    #[test]
79    fn round_trip_empty() {
80        let path = tmp_path("empty");
81        let peers = KadPeers::default();
82        peers.save(&path);
83        let loaded = KadPeers::load(&path);
84        let _ = std::fs::remove_file(&path);
85        assert!(loaded.0.is_empty());
86    }
87
88    #[test]
89    fn round_trip_with_entries() {
90        let path = tmp_path("entries");
91        let mut peers = KadPeers::default();
92        peers.add("peer-1".into(), "/ip4/1.2.3.4/tcp/4001".into());
93        peers.add("peer-1".into(), "/ip6/::1/tcp/4001".into());
94        peers.add("peer-2".into(), "/ip4/5.6.7.8/tcp/4002".into());
95        peers.save(&path);
96
97        let loaded = KadPeers::load(&path);
98        let _ = std::fs::remove_file(&path);
99        assert_eq!(loaded.0["peer-1"].len(), 2);
100        assert_eq!(loaded.0["peer-2"].len(), 1);
101    }
102
103    #[test]
104    fn add_deduplicates() {
105        let mut peers = KadPeers::default();
106        peers.add("p1".into(), "/ip4/1.2.3.4/tcp/4001".into());
107        peers.add("p1".into(), "/ip4/1.2.3.4/tcp/4001".into());
108        assert_eq!(peers.0["p1"].len(), 1);
109    }
110
111    #[test]
112    fn load_missing_file_returns_default() {
113        let peers = KadPeers::load(std::path::Path::new("/tmp/no_such_kad_file_xyz.json"));
114        assert!(peers.0.is_empty());
115    }
116
117    #[test]
118    fn load_corrupt_file_returns_default() {
119        let path = tmp_path("corrupt");
120        std::fs::write(&path, b"NOT VALID JSON {").unwrap();
121        let peers = KadPeers::load(&path);
122        let _ = std::fs::remove_file(&path);
123        assert!(peers.0.is_empty());
124    }
125}