Skip to main content

cp_desktop/
sync.rs

1use cp_core::{CPError, CognitiveDiff, Hlc, Result};
2use cp_sync::{CryptoEngine, DeviceIdentity, EncryptedPayload, PairedDevice};
3use std::sync::Mutex;
4use tracing::{info, warn};
5use uuid::Uuid;
6
7const KEYRING_SERVICE: &str = "canon-protocol";
8const KEYRING_USER: &str = "node-identity-seed";
9
10/// Manages node identity and tracks local changes for network sync.
11///
12/// Records document/chunk/embedding mutations as `CognitiveDiffs`.
13/// The identity seed is stored in the platform keychain (macOS Keychain,
14/// Linux secret-service) with a plaintext file fallback for environments
15/// where no keychain is available.
16pub struct SyncManager {
17    identity: DeviceIdentity,
18    pending_diff: Mutex<CognitiveDiff>,
19    paired_devices: Mutex<Vec<PairedDevice>>,
20    /// Last known state root, used as `prev_root` for the next diff.
21    last_state_root: Mutex<[u8; 32]>,
22}
23
24impl SyncManager {
25    /// Create a new sync manager with a persistent node identity.
26    ///
27    /// Tries to load the identity seed from the file path first, then falls
28    /// back to the platform keychain. On first run, generates a new identity
29    /// and stores it in both locations. The file is always written as a backup
30    /// even when keychain succeeds, ensuring reliable persistence.
31    pub fn new(key_path: &std::path::Path) -> Result<Self> {
32        let identity = if key_path.exists() {
33            let seed = load_seed_from_file(key_path)?;
34            if let Err(e) = store_seed_in_keychain(&seed) {
35                warn!("Could not sync seed to keychain: {}", e);
36            }
37            info!("Loaded node identity from {}", key_path.display());
38            DeviceIdentity::from_seed(seed)
39        } else if let Ok(Some(seed)) = load_seed_from_keychain() {
40            info!("Loaded node identity from platform keychain");
41            store_seed_in_file(key_path, &seed)?;
42            DeviceIdentity::from_seed(seed)
43        } else {
44            let identity = DeviceIdentity::generate();
45            let seed = identity.export_seed();
46            store_seed_in_file(key_path, &seed)?;
47            if let Err(e) = store_seed_in_keychain(&seed) {
48                warn!("Could not store seed in keychain: {}", e);
49            } else {
50                info!("Stored node identity in platform keychain");
51            }
52            identity
53        };
54
55        Ok(Self::from_identity(identity))
56    }
57
58    /// Create a sync manager using file-based storage only.
59    ///
60    /// Skips all keychain access. Useful for headless environments (CI,
61    /// containers, tests) where no platform keychain is available.
62    pub fn new_file_only(key_path: &std::path::Path) -> Result<Self> {
63        let identity = if key_path.exists() {
64            let seed = load_seed_from_file(key_path)?;
65            info!("Loaded node identity from {}", key_path.display());
66            DeviceIdentity::from_seed(seed)
67        } else {
68            let identity = DeviceIdentity::generate();
69            let seed = identity.export_seed();
70            store_seed_in_file(key_path, &seed)?;
71            identity
72        };
73
74        Ok(Self::from_identity(identity))
75    }
76
77    fn from_identity(identity: DeviceIdentity) -> Self {
78        let device_id = Uuid::from_bytes(identity.device_id);
79        let node_id = *device_id.as_bytes();
80        let hlc = Hlc::new(
81            std::time::SystemTime::now()
82                .duration_since(std::time::UNIX_EPOCH)
83                .unwrap()
84                .as_millis() as u64,
85            node_id,
86        );
87        let pending = CognitiveDiff::empty([0u8; 32], device_id, 0, hlc);
88
89        Self {
90            identity,
91            pending_diff: Mutex::new(pending),
92            paired_devices: Mutex::new(Vec::new()),
93            last_state_root: Mutex::new([0u8; 32]),
94        }
95    }
96
97    /// Record a document addition (deduplicates by ID)
98    pub fn record_add_doc(&self, doc: cp_core::Document) {
99        let mut diff = self
100            .pending_diff
101            .lock()
102            .expect("pending_diff lock poisoned");
103        diff.added_docs.retain(|d| d.id != doc.id);
104        diff.added_docs.push(doc);
105    }
106
107    /// Record a chunk addition (deduplicates by ID)
108    pub fn record_add_chunk(&self, chunk: cp_core::Chunk) {
109        let mut diff = self
110            .pending_diff
111            .lock()
112            .expect("pending_diff lock poisoned");
113        diff.added_chunks.retain(|c| c.id != chunk.id);
114        diff.added_chunks.push(chunk);
115    }
116
117    /// Record an embedding addition (deduplicates by ID)
118    pub fn record_add_embedding(&self, emb: cp_core::Embedding) {
119        let mut diff = self
120            .pending_diff
121            .lock()
122            .expect("pending_diff lock poisoned");
123        diff.added_embeddings.retain(|e| e.id != emb.id);
124        diff.added_embeddings.push(emb);
125    }
126
127    /// Record a document removal (deduplicates by ID)
128    pub fn record_remove_doc(&self, doc_id: Uuid, chunk_ids: Vec<Uuid>, embedding_ids: Vec<Uuid>) {
129        let mut diff = self
130            .pending_diff
131            .lock()
132            .expect("pending_diff lock poisoned");
133        if !diff.removed_doc_ids.contains(&doc_id) {
134            diff.removed_doc_ids.push(doc_id);
135        }
136        for cid in chunk_ids {
137            if !diff.removed_chunk_ids.contains(&cid) {
138                diff.removed_chunk_ids.push(cid);
139            }
140        }
141        for eid in embedding_ids {
142            if !diff.removed_embedding_ids.contains(&eid) {
143                diff.removed_embedding_ids.push(eid);
144            }
145        }
146    }
147
148    /// Take the pending diff and reset it. Used by the network layer to
149    /// retrieve accumulated changes for gossip.
150    pub fn take_pending_diff(&self) -> CognitiveDiff {
151        let mut pending = self
152            .pending_diff
153            .lock()
154            .expect("pending_diff lock poisoned");
155        let old = pending.clone();
156        let device_id = old.metadata.device_id;
157        let node_id = *device_id.as_bytes();
158        let hlc = Hlc::new(
159            std::time::SystemTime::now()
160                .duration_since(std::time::UNIX_EPOCH)
161                .unwrap()
162                .as_millis() as u64,
163            node_id,
164        );
165        let prev_root = *self
166            .last_state_root
167            .lock()
168            .expect("state_root lock poisoned");
169        *pending = CognitiveDiff::empty(prev_root, device_id, old.metadata.seq + 1, hlc);
170        old
171    }
172
173    /// Update the last known state root. Call this after computing the
174    /// Merkle root from the graph store so that the next diff's `prev_root`
175    /// reflects the current state.
176    pub fn update_state_root(&self, root: [u8; 32]) {
177        *self
178            .last_state_root
179            .lock()
180            .expect("state_root lock poisoned") = root;
181    }
182
183    /// Check whether there are pending changes to sync.
184    pub fn has_pending_changes(&self) -> bool {
185        let diff = self
186            .pending_diff
187            .lock()
188            .expect("pending_diff lock poisoned");
189        !diff.is_empty()
190    }
191
192    /// Get device identity for network operations
193    pub fn get_identity(&self) -> &DeviceIdentity {
194        &self.identity
195    }
196
197    /// Get device ID as hex string
198    pub fn get_device_id_hex(&self) -> String {
199        hex::encode(self.identity.device_id)
200    }
201
202    /// Get the X25519 public key for key agreement with remote nodes.
203    pub fn x25519_public_key(&self) -> [u8; 32] {
204        self.identity.x25519_public_key()
205    }
206
207    /// Pair with a remote device using X25519 key agreement.
208    ///
209    /// Performs Diffie-Hellman key exchange to derive a shared encryption
210    /// key, then stores the paired device for future encrypted diff exchange.
211    pub fn pair_with_remote(
212        &self,
213        remote_public_key: &[u8; 32],
214        remote_x25519_public: &[u8; 32],
215    ) -> Result<()> {
216        let paired = self
217            .identity
218            .pair_with(remote_public_key, remote_x25519_public)?;
219        info!("Paired with remote device {}", paired.device_id_hex());
220
221        let mut devices = self
222            .paired_devices
223            .lock()
224            .expect("paired_devices lock poisoned");
225        // Replace existing pairing for same device
226        devices.retain(|d| d.device_id != paired.device_id);
227        devices.push(paired);
228        Ok(())
229    }
230
231    /// Encrypt a diff for a specific paired device using their shared key.
232    pub fn encrypt_diff_for_peer(
233        &self,
234        diff: &CognitiveDiff,
235        peer_device_id: &[u8; 16],
236    ) -> Result<Option<EncryptedPayload>> {
237        let devices = self
238            .paired_devices
239            .lock()
240            .expect("paired_devices lock poisoned");
241        let Some(paired) = devices.iter().find(|d| &d.device_id == peer_device_id) else {
242            return Ok(None);
243        };
244
245        let engine = CryptoEngine::from_keys(paired.encryption_key, self.identity.export_seed());
246        let encrypted = engine.encrypt_diff(diff)?;
247        Ok(Some(encrypted))
248    }
249
250    /// Get the list of paired device IDs.
251    pub fn paired_device_ids(&self) -> Vec<[u8; 16]> {
252        self.paired_devices
253            .lock()
254            .expect("paired_devices lock poisoned")
255            .iter()
256            .map(|d| d.device_id)
257            .collect()
258    }
259}
260
261// ============================================================================
262// Platform keychain storage
263// ============================================================================
264
265#[cfg(any(target_os = "macos", target_os = "linux"))]
266fn load_seed_from_keychain() -> std::result::Result<Option<[u8; 32]>, String> {
267    let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
268        .map_err(|e| format!("Failed to create keyring entry: {e}"))?;
269    match entry.get_secret() {
270        Ok(secret) => {
271            if secret.len() != 32 {
272                return Err(format!("Invalid seed length in keychain: {}", secret.len()));
273            }
274            let mut seed = [0u8; 32];
275            seed.copy_from_slice(&secret);
276            Ok(Some(seed))
277        }
278        Err(keyring::Error::NoEntry) => Ok(None),
279        Err(e) => Err(format!("Keychain read error: {e}")),
280    }
281}
282
283#[cfg(any(target_os = "macos", target_os = "linux"))]
284fn store_seed_in_keychain(seed: &[u8; 32]) -> std::result::Result<(), String> {
285    let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
286        .map_err(|e| format!("Failed to create keyring entry: {e}"))?;
287    entry
288        .set_secret(seed)
289        .map_err(|e| format!("Keychain write error: {e}"))
290}
291
292#[cfg(not(any(target_os = "macos", target_os = "linux")))]
293fn load_seed_from_keychain() -> std::result::Result<Option<[u8; 32]>, String> {
294    Err("Platform keychain not available".to_string())
295}
296
297#[cfg(not(any(target_os = "macos", target_os = "linux")))]
298fn store_seed_in_keychain(_seed: &[u8; 32]) -> std::result::Result<(), String> {
299    Err("Platform keychain not available".to_string())
300}
301
302// ============================================================================
303// File-based fallback storage
304// ============================================================================
305
306fn load_seed_from_file(path: &std::path::Path) -> Result<[u8; 32]> {
307    let data = std::fs::read(path).map_err(CPError::Io)?;
308    if data.len() != 32 {
309        return Err(CPError::Crypto("Invalid key file length".into()));
310    }
311    let mut seed = [0u8; 32];
312    seed.copy_from_slice(&data);
313    Ok(seed)
314}
315
316fn store_seed_in_file(path: &std::path::Path, seed: &[u8; 32]) -> Result<()> {
317    std::fs::write(path, seed).map_err(CPError::Io)?;
318    #[cfg(unix)]
319    {
320        use std::os::unix::fs::PermissionsExt;
321        let perms = std::fs::Permissions::from_mode(0o600);
322        std::fs::set_permissions(path, perms).map_err(CPError::Io)?;
323    }
324    Ok(())
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use tempfile::TempDir;
331
332    #[test]
333    fn test_sync_manager_new_generates_identity() {
334        let tmp = TempDir::new().unwrap();
335        let key_path = tmp.path().join("test_key");
336        let mgr = SyncManager::new_file_only(&key_path).unwrap();
337        assert!(!mgr.get_device_id_hex().is_empty());
338    }
339
340    #[test]
341    fn test_sync_manager_persists_identity() {
342        let tmp = TempDir::new().unwrap();
343        let key_path = tmp.path().join("test_key");
344
345        let id1 = {
346            let mgr = SyncManager::new_file_only(&key_path).unwrap();
347            mgr.get_device_id_hex()
348        };
349
350        let id2 = {
351            let mgr = SyncManager::new_file_only(&key_path).unwrap();
352            mgr.get_device_id_hex()
353        };
354
355        assert_eq!(id1, id2, "Identity should persist across restarts");
356    }
357
358    #[test]
359    fn test_record_and_take_diff() {
360        let tmp = TempDir::new().unwrap();
361        let key_path = tmp.path().join("test_key");
362        let mgr = SyncManager::new_file_only(&key_path).unwrap();
363
364        assert!(!mgr.has_pending_changes());
365
366        let doc = cp_core::Document::new(std::path::PathBuf::from("test.md"), b"content", 12345);
367        mgr.record_add_doc(doc);
368        assert!(mgr.has_pending_changes());
369
370        let diff = mgr.take_pending_diff();
371        assert_eq!(diff.added_docs.len(), 1);
372        assert!(!mgr.has_pending_changes());
373    }
374
375    #[test]
376    fn test_file_fallback_permissions() {
377        let tmp = TempDir::new().unwrap();
378        let key_path = tmp.path().join("test_key");
379        let seed = [42u8; 32];
380        store_seed_in_file(&key_path, &seed).unwrap();
381
382        #[cfg(unix)]
383        {
384            use std::os::unix::fs::PermissionsExt;
385            let meta = std::fs::metadata(&key_path).unwrap();
386            let mode = meta.permissions().mode() & 0o777;
387            assert_eq!(mode, 0o600, "Key file should have 0600 permissions");
388        }
389
390        let loaded = load_seed_from_file(&key_path).unwrap();
391        assert_eq!(loaded, seed);
392    }
393}