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
10pub struct SyncManager {
17 identity: DeviceIdentity,
18 pending_diff: Mutex<CognitiveDiff>,
19 paired_devices: Mutex<Vec<PairedDevice>>,
20 last_state_root: Mutex<[u8; 32]>,
22}
23
24impl SyncManager {
25 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 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 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 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 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 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 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 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 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 pub fn get_identity(&self) -> &DeviceIdentity {
194 &self.identity
195 }
196
197 pub fn get_device_id_hex(&self) -> String {
199 hex::encode(self.identity.device_id)
200 }
201
202 pub fn x25519_public_key(&self) -> [u8; 32] {
204 self.identity.x25519_public_key()
205 }
206
207 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 devices.retain(|d| d.device_id != paired.device_id);
227 devices.push(paired);
228 Ok(())
229 }
230
231 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 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#[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
302fn 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}