Skip to main content

lit/federation/
peers.rs

1//! Peer discovery and federation management
2//!
3//! Manages known peers, their DIDs, endpoints, and synchronization state.
4
5use crate::errors::LitError;
6use serde::{Deserialize, Serialize};
7use sha3::{Digest, Sha3_256};
8use std::fs;
9use std::path::Path;
10
11/// Information about a federated peer
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct PeerInfo {
14    /// Peer's DID
15    pub did: String,
16    /// Human-readable alias
17    #[serde(skip_serializing_if = "Option::is_none")]
18    pub alias: Option<String>,
19    /// Network endpoint (e.g., "https://peer.example.com:8443")
20    pub endpoint: String,
21    /// Peer's public key hex for verification
22    pub public_key_hex: String,
23    /// Content ID of the peer's latest known head
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub last_known_head: Option<String>,
26    /// Last successful sync timestamp
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub last_sync: Option<String>,
29    /// Whether the peer is currently reachable
30    pub reachable: bool,
31    /// When this peer was first added
32    pub added: String,
33}
34
35/// Content identifier for a lit object
36#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
37pub struct ContentId {
38    /// Hash algorithm used
39    pub algorithm: String,
40    /// Hex-encoded hash
41    pub hash: String,
42}
43
44impl ContentId {
45    /// Create a CID from raw bytes
46    pub fn from_bytes(data: &[u8]) -> Self {
47        let hash = Sha3_256::digest(data);
48        ContentId {
49            algorithm: "sha3-256".to_string(),
50            hash: hex::encode(hash),
51        }
52    }
53
54    /// Short display form
55    pub fn short(&self) -> String {
56        if self.hash.len() > 12 {
57            format!("{}..{}", &self.hash[..6], &self.hash[self.hash.len() - 6..])
58        } else {
59            self.hash.clone()
60        }
61    }
62}
63
64impl std::fmt::Display for ContentId {
65    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66        write!(f, "{}:{}", self.algorithm, self.hash)
67    }
68}
69
70fn peers_dir(repo_root: &Path) -> std::path::PathBuf {
71    repo_root.join(".lit").join("federation").join("peers")
72}
73
74/// Add a new peer
75pub fn add_peer(repo_root: &Path, peer: &PeerInfo) -> Result<(), LitError> {
76    let dir = peers_dir(repo_root);
77    fs::create_dir_all(&dir)
78        .map_err(|e| LitError::io(format!("Failed to create peers dir: {}", e)))?;
79
80    let safe_name: String = peer
81        .did
82        .chars()
83        .map(|c| if c.is_alphanumeric() { c } else { '_' })
84        .collect();
85    let path = dir.join(format!("{}.json", safe_name));
86
87    let json = serde_json::to_string_pretty(peer)
88        .map_err(|e| LitError::general(format!("Serialize error: {}", e)))?;
89    fs::write(&path, json).map_err(|e| LitError::io(format!("Write error: {}", e)))?;
90    Ok(())
91}
92
93/// Remove a peer
94pub fn remove_peer(repo_root: &Path, did: &str) -> Result<(), LitError> {
95    let safe_name: String = did
96        .chars()
97        .map(|c| if c.is_alphanumeric() { c } else { '_' })
98        .collect();
99    let path = peers_dir(repo_root).join(format!("{}.json", safe_name));
100    if path.exists() {
101        fs::remove_file(&path).map_err(|e| LitError::io(format!("Remove error: {}", e)))?;
102        Ok(())
103    } else {
104        Err(LitError::general(format!("Peer not found: {}", did)))
105    }
106}
107
108/// List all known peers
109pub fn list_peers(repo_root: &Path) -> Result<Vec<PeerInfo>, LitError> {
110    let dir = peers_dir(repo_root);
111    if !dir.exists() {
112        return Ok(Vec::new());
113    }
114
115    let mut peers = Vec::new();
116    for entry in fs::read_dir(&dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
117        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
118        if entry.path().extension().is_some_and(|e| e == "json") {
119            if let Ok(json) = fs::read_to_string(entry.path()) {
120                if let Ok(peer) = serde_json::from_str::<PeerInfo>(&json) {
121                    peers.push(peer);
122                }
123            }
124        }
125    }
126    Ok(peers)
127}
128
129/// Get a specific peer by DID
130pub fn get_peer(repo_root: &Path, did: &str) -> Result<PeerInfo, LitError> {
131    let safe_name: String = did
132        .chars()
133        .map(|c| if c.is_alphanumeric() { c } else { '_' })
134        .collect();
135    let path = peers_dir(repo_root).join(format!("{}.json", safe_name));
136    if !path.exists() {
137        return Err(LitError::general(format!("Peer not found: {}", did)));
138    }
139    let json = fs::read_to_string(&path).map_err(|e| LitError::io(format!("IO: {}", e)))?;
140    serde_json::from_str(&json).map_err(|e| LitError::general(format!("Parse error: {}", e)))
141}
142
143/// Update a peer's last sync info
144pub fn update_peer_sync(repo_root: &Path, did: &str, head: &str) -> Result<(), LitError> {
145    let mut peer = get_peer(repo_root, did)?;
146    peer.last_known_head = Some(head.to_string());
147    peer.last_sync = Some(chrono::Utc::now().to_rfc3339());
148    peer.reachable = true;
149    add_peer(repo_root, &peer)
150}
151
152/// Generate a want list — CIDs this repo wants from peers
153pub fn generate_want_list(repo_root: &Path) -> Result<Vec<String>, LitError> {
154    // Check for any refs that reference objects we don't have locally
155    let refs_dir = repo_root.join(".lit").join("refs").join("remotes");
156    if !refs_dir.exists() {
157        return Ok(Vec::new());
158    }
159
160    let mut wants = Vec::new();
161    for entry in fs::read_dir(&refs_dir).map_err(|e| LitError::io(format!("IO: {}", e)))? {
162        let entry = entry.map_err(|e| LitError::io(format!("IO: {}", e)))?;
163        if let Ok(hash) = fs::read_to_string(entry.path()) {
164            let hash = hash.trim().to_string();
165            // Check if we have this object
166            let obj_path = repo_root
167                .join(".lit")
168                .join("objects")
169                .join(&hash[..2])
170                .join(&hash[2..]);
171            if !obj_path.exists() && !hash.is_empty() {
172                wants.push(hash);
173            }
174        }
175    }
176    Ok(wants)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use std::path::PathBuf;
183    use std::sync::atomic::{AtomicU32, Ordering};
184
185    static COUNTER: AtomicU32 = AtomicU32::new(0);
186
187    /// Per-test scratch directory.
188    ///
189    /// The peer list is a file under one repo root that each test removes when
190    /// it finishes. Only one test currently writes there, but sharing a root
191    /// would make the next one that does collide with it.
192    fn tmp_dir() -> PathBuf {
193        let n = COUNTER.fetch_add(1, Ordering::SeqCst);
194        let dir = std::env::temp_dir().join(format!("lit_fed_test_{}_{}", std::process::id(), n));
195        let _ = fs::remove_dir_all(&dir);
196        fs::create_dir_all(&dir).unwrap();
197        dir
198    }
199
200    #[test]
201    fn test_add_and_list_peer() {
202        let dir = tmp_dir();
203        let peer = PeerInfo {
204            did: "did:lit:peer1".to_string(),
205            alias: Some("Alice".to_string()),
206            endpoint: "https://alice.example.com:8443".to_string(),
207            public_key_hex: "abcdef1234567890".to_string(),
208            last_known_head: None,
209            last_sync: None,
210            reachable: false,
211            added: chrono::Utc::now().to_rfc3339(),
212        };
213        add_peer(&dir, &peer).unwrap();
214
215        let peers = list_peers(&dir).unwrap();
216        assert_eq!(peers.len(), 1);
217        assert_eq!(peers[0].did, "did:lit:peer1");
218
219        let _ = fs::remove_dir_all(&dir);
220    }
221
222    #[test]
223    fn test_content_id() {
224        let cid = ContentId::from_bytes(b"hello world");
225        assert_eq!(cid.algorithm, "sha3-256");
226        assert!(!cid.hash.is_empty());
227        assert!(cid.short().contains(".."));
228    }
229}