Skip to main content

qualia_client_core/
social_peers.rs

1//! SocialWebNet peer store.
2//!
3//! Where an accepted connection's peering material lands. Once a connection
4//! offer has been mutually authenticated (see `handshake.rs`) and the peering
5//! payload exchanged (see `connection_identifier.rs`), the resulting
6//! [`SocialPeer`] — the counterpart's DID plus the WireGuard public key and
7//! overlay address needed to reach them on the SocialWebNet mesh — is recorded
8//! here.
9//!
10//! The store is deliberately thin and additive: pure list-manipulation helpers
11//! ([`upsert`], [`find`]) carry the logic and are unit-tested in isolation,
12//! while the `*_peer` functions layer a small pretty-JSON persistence step on
13//! top (a `Vec<SocialPeer>` at `app_meta_dir()/social_peers.json`).
14
15use std::fs;
16use std::path::PathBuf;
17
18use crate::state::app_meta_dir;
19
20/// An accepted peer on the SocialWebNet mesh.
21///
22/// This is the material needed to reach and recognise a connection after the
23/// handshake has completed — the counterpart's stable identifier, a friendly
24/// label, and the WireGuard/overlay coordinates that route packets to them.
25#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
26pub struct SocialPeer {
27    /// The peer's DID — the stable identifier that keys this record.
28    pub did: String,
29    /// Human-friendly label for the peer (from their profile / the invite).
30    pub display_name: String,
31    /// The peer's WireGuard public key, hex-encoded.
32    pub wireguard_pubkey_hex: String,
33    /// The peer's address on the SocialWebNet overlay.
34    pub overlay_addr: String,
35    /// Optional last-known transport endpoint (`host:port`) for the tunnel.
36    pub endpoint: Option<String>,
37    /// The relationship this peering was established under (free-form; aligns
38    /// with `spc:relationType` in the directory ontology).
39    pub relation_type: String,
40    /// Unix seconds at which this peer was added.
41    pub added_at: u64,
42    /// Whether the peering is currently active (a soft on/off that leaves the
43    /// record in place).
44    pub active: bool,
45    /// The peer's **envelope (X25519) public key**, hex — the key to *seal payloads to* this peer (distinct
46    /// from the WireGuard key, which routes packets). Lets the accountability fabric resolve a
47    /// worker/trustee's key from their peer record instead of pasting it. `None` until the peer publishes it.
48    #[serde(default)]
49    pub envelope_pubkey_hex: Option<String>,
50}
51
52// ---------------------------------------------------------------------------
53// Pure helpers (unit-tested; no filesystem)
54// ---------------------------------------------------------------------------
55
56/// Insert or update `peer` in `peers`, keyed by [`SocialPeer::did`].
57///
58/// If a peer with the same `did` already exists it is replaced in place
59/// (preserving its position); otherwise `peer` is appended.
60pub fn upsert(peers: &mut Vec<SocialPeer>, peer: SocialPeer) {
61    if let Some(slot) = peers.iter_mut().find(|p| p.did == peer.did) {
62        *slot = peer;
63    } else {
64        peers.push(peer);
65    }
66}
67
68/// Find the peer with the given `did`, if present.
69pub fn find<'a>(peers: &'a [SocialPeer], did: &str) -> Option<&'a SocialPeer> {
70    peers.iter().find(|p| p.did == did)
71}
72
73// ---------------------------------------------------------------------------
74// Persistence (filesystem)
75// ---------------------------------------------------------------------------
76
77fn peers_path() -> PathBuf {
78    app_meta_dir().join("social_peers.json")
79}
80
81fn save_peers(peers: &[SocialPeer]) -> Result<(), String> {
82    let path = peers_path();
83    if let Some(parent) = path.parent() {
84        fs::create_dir_all(parent).map_err(|e| e.to_string())?;
85    }
86    let text = serde_json::to_string_pretty(peers).map_err(|e| e.to_string())?;
87    fs::write(path, text).map_err(|e| e.to_string())
88}
89
90/// Load every stored peer. Returns `vec![]` if the store file is absent or
91/// unreadable.
92pub fn list_peers() -> Vec<SocialPeer> {
93    fs::read_to_string(peers_path())
94        .ok()
95        .and_then(|t| serde_json::from_str(&t).ok())
96        .unwrap_or_default()
97}
98
99/// Register (insert-or-update) a peer, then persist the store.
100pub fn register_peer(peer: SocialPeer) -> Result<(), String> {
101    let mut peers = list_peers();
102    upsert(&mut peers, peer);
103    save_peers(&peers)
104}
105
106/// Set the `active` flag on the peer with the given `did`, then persist.
107///
108/// Returns an error if no peer with that `did` is stored.
109pub fn set_peer_active(did: &str, active: bool) -> Result<(), String> {
110    let mut peers = list_peers();
111    match peers.iter_mut().find(|p| p.did == did) {
112        Some(p) => p.active = active,
113        None => return Err(format!("no peer with did {did}")),
114    }
115    save_peers(&peers)
116}
117
118/// Remove the peer with the given `did` from the store, then persist.
119///
120/// Removing an absent `did` is a no-op success (the store already lacks it).
121pub fn remove_peer(did: &str) -> Result<(), String> {
122    let mut peers = list_peers();
123    peers.retain(|p| p.did != did);
124    save_peers(&peers)
125}
126
127/// Set the peer's **envelope (X25519) public key** (hex), then persist. Errors if no such peer.
128pub fn set_peer_envelope_key(did: &str, pubkey_hex: &str) -> Result<(), String> {
129    let mut peers = list_peers();
130    match peers.iter_mut().find(|p| p.did == did) {
131        Some(p) => p.envelope_pubkey_hex = Some(pubkey_hex.to_string()),
132        None => return Err(format!("no peer with did {did}")),
133    }
134    save_peers(&peers)
135}
136
137/// Set (or clear) the peer's last-known transport endpoint (`host:port`), then persist.
138/// Empty `endpoint` clears it. Errors if no such peer.
139pub fn set_peer_endpoint(did: &str, endpoint: Option<&str>) -> Result<(), String> {
140    let mut peers = list_peers();
141    match peers.iter_mut().find(|p| p.did == did) {
142        Some(p) => {
143            p.endpoint = endpoint
144                .map(|e| e.trim().to_string())
145                .filter(|e| !e.is_empty());
146        }
147        None => return Err(format!("no peer with did {did}")),
148    }
149    save_peers(&peers)
150}
151
152/// Resolve `dids` to `(did, envelope_pubkey_hex)` pairs from `peers` — the parties whose envelope key is
153/// known. Parties without a published key (or not peered) are simply omitted (the caller learns which keys
154/// are still missing by comparing lengths). Pure; unit-tested.
155pub fn resolve_envelope_keys(peers: &[SocialPeer], dids: &[String]) -> Vec<(String, String)> {
156    dids.iter()
157        .filter_map(|did| {
158            find(peers, did)
159                .and_then(|p| p.envelope_pubkey_hex.clone())
160                .map(|pk| (did.clone(), pk))
161        })
162        .collect()
163}
164
165// ---------------------------------------------------------------------------
166// Tests — PURE ONLY. These operate on a local `Vec<SocialPeer>` via the
167// pure helpers and never touch the real filesystem / app dir.
168// ---------------------------------------------------------------------------
169
170#[cfg(test)]
171mod tests {
172    use super::*;
173
174    fn peer(did: &str, name: &str) -> SocialPeer {
175        SocialPeer {
176            did: did.to_string(),
177            display_name: name.to_string(),
178            wireguard_pubkey_hex: "aa".repeat(32),
179            overlay_addr: "10.44.0.2".to_string(),
180            endpoint: Some("203.0.113.5:51820".to_string()),
181            relation_type: "collaboration".to_string(),
182            added_at: 1_700_000_000,
183            active: true,
184            envelope_pubkey_hex: None,
185        }
186    }
187
188    #[test]
189    fn upsert_new_did_appends() {
190        let mut peers = vec![peer("did:key:alice", "Alice")];
191        upsert(&mut peers, peer("did:key:bob", "Bob"));
192
193        assert_eq!(peers.len(), 2);
194        assert_eq!(peers[0].did, "did:key:alice");
195        assert_eq!(peers[1].did, "did:key:bob");
196    }
197
198    #[test]
199    fn resolve_envelope_keys_returns_only_peers_with_a_published_key() {
200        let mut alice = peer("did:key:alice", "Alice");
201        alice.envelope_pubkey_hex = Some("ab".repeat(32));
202        let bob = peer("did:key:bob", "Bob"); // no envelope key yet
203        let peers = vec![alice, bob];
204        let resolved = resolve_envelope_keys(
205            &peers,
206            &[
207                "did:key:alice".to_string(),
208                "did:key:bob".to_string(),
209                "did:key:carol".to_string(),
210            ],
211        );
212        // Only Alice has a key; Bob (no key) and Carol (not a peer) are omitted.
213        assert_eq!(
214            resolved,
215            vec![("did:key:alice".to_string(), "ab".repeat(32))]
216        );
217    }
218
219    #[test]
220    fn upsert_existing_did_replaces_in_place() {
221        let mut peers = vec![peer("did:key:alice", "Alice"), peer("did:key:bob", "Bob")];
222
223        let mut updated = peer("did:key:alice", "Alice (renamed)");
224        updated.overlay_addr = "10.44.0.9".to_string();
225        updated.active = false;
226        upsert(&mut peers, updated);
227
228        // Length unchanged: replacement, not append.
229        assert_eq!(peers.len(), 2);
230        // Position preserved.
231        assert_eq!(peers[0].did, "did:key:alice");
232        assert_eq!(peers[1].did, "did:key:bob");
233        // Fields updated.
234        assert_eq!(peers[0].display_name, "Alice (renamed)");
235        assert_eq!(peers[0].overlay_addr, "10.44.0.9");
236        assert!(!peers[0].active);
237    }
238
239    #[test]
240    fn find_returns_peer_or_none() {
241        let peers = vec![peer("did:key:alice", "Alice"), peer("did:key:bob", "Bob")];
242
243        let found = find(&peers, "did:key:bob");
244        assert!(found.is_some());
245        assert_eq!(found.unwrap().display_name, "Bob");
246
247        assert!(find(&peers, "did:key:carol").is_none());
248    }
249}