Skip to main content

communitas_core/
doc_replicator.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2//
3// This file is part of the Saorsa P2P network.
4//
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7
8//! Document Replicator (Sprint 3.2)
9//!
10//! CRDT-based document synchronization with dual-storage architecture:
11//!
12//! ## Storage Modes
13//!
14//! - **Files**: SECRET storage, encrypted with ChaCha20Poly1305, group members only
15//! - **Web**: PUBLIC storage, unencrypted, anyone can read
16//! - **Both**: Document stored in both Files and Web (encrypted in Files, plaintext in Web)
17//!
18//! ## CRDT Synchronization
19//!
20//! Uses Yrs (Rust implementation of Yjs) for conflict-free collaborative editing.
21
22use anyhow::{Result, anyhow};
23use chacha20poly1305::{
24    ChaCha20Poly1305, Nonce,
25    aead::{Aead, KeyInit},
26};
27use serde::{Deserialize, Serialize};
28use std::collections::HashMap;
29use std::sync::{Arc, Mutex};
30use tokio::sync::RwLock;
31use tracing::{debug, info};
32use yrs::{
33    Doc, GetString, ReadTxn, StateVector, Text, Transact, Update,
34    updates::decoder::Decode,
35    updates::encoder::{Encoder, EncoderV1},
36};
37
38/// Document identifier (unique ID for each document)
39pub type DocumentId = String;
40
41/// Storage mode for documents
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
43pub enum StorageMode {
44    /// Files storage: encrypted, group members only (SECRET)
45    Files,
46    /// Web storage: plaintext, public access (PUBLIC)
47    Web,
48    /// Both storages: encrypted in Files, public in Web
49    Both,
50}
51
52/// DocReplicator configuration
53#[derive(Clone)]
54pub struct DocReplicatorConfig {
55    pub files_storage_enabled: bool,
56    pub web_storage_enabled: bool,
57}
58
59/// Document metadata
60#[derive(Debug, Clone, Serialize, Deserialize)]
61struct DocumentMetadata {
62    id: DocumentId,
63    name: String,
64    storage_mode: StorageMode,
65    created_at: chrono::DateTime<chrono::Utc>,
66    updated_at: chrono::DateTime<chrono::Utc>,
67}
68
69/// Encrypted document wrapper for Files storage
70#[derive(Debug, Clone, Serialize, Deserialize)]
71struct EncryptedDocument {
72    nonce: [u8; 12],
73    ciphertext: Vec<u8>,
74}
75
76/// Document Replicator - manages CRDT documents with dual storage
77pub struct DocReplicator {
78    // CRDT documents (Yrs) - using std::sync::Mutex for blocking Yrs operations
79    documents: Arc<RwLock<HashMap<DocumentId, Arc<Mutex<Doc>>>>>,
80
81    // Document metadata
82    metadata: Arc<RwLock<HashMap<DocumentId, DocumentMetadata>>>,
83
84    // Files storage: encrypted with ChaCha20Poly1305
85    files_storage: Arc<RwLock<HashMap<DocumentId, EncryptedDocument>>>,
86    encryption_keys: Arc<RwLock<HashMap<DocumentId, [u8; 32]>>>,
87
88    // Web storage: plaintext, public
89    web_storage: Arc<RwLock<HashMap<DocumentId, Vec<u8>>>>,
90
91    // Configuration
92    files_enabled: bool,
93    web_enabled: bool,
94}
95
96impl DocReplicator {
97    /// Create new DocReplicator
98    pub async fn new(config: DocReplicatorConfig) -> Result<Self> {
99        info!("Creating DocReplicator");
100
101        Ok(Self {
102            documents: Arc::new(RwLock::new(HashMap::new())),
103            metadata: Arc::new(RwLock::new(HashMap::new())),
104            files_storage: Arc::new(RwLock::new(HashMap::new())),
105            encryption_keys: Arc::new(RwLock::new(HashMap::new())),
106            web_storage: Arc::new(RwLock::new(HashMap::new())),
107            files_enabled: config.files_storage_enabled,
108            web_enabled: config.web_storage_enabled,
109        })
110    }
111
112    /// Create a new document
113    pub async fn create_document(
114        &self,
115        name: &str,
116        storage_mode: StorageMode,
117    ) -> Result<DocumentId> {
118        // Generate default encryption key for Files storage
119        let mut default_key = [0u8; 32];
120        getrandom::getrandom(&mut default_key)
121            .map_err(|e| anyhow!("Failed to generate key: {}", e))?;
122
123        self.create_document_with_key(name, storage_mode, &default_key)
124            .await
125    }
126
127    /// Create a new document with specific encryption key
128    ///
129    /// If `name` looks like a UUID, it will be used as the document ID directly.
130    /// Otherwise, a new UUID will be generated for the ID.
131    pub async fn create_document_with_key(
132        &self,
133        name: &str,
134        storage_mode: StorageMode,
135        encryption_key: &[u8; 32],
136    ) -> Result<DocumentId> {
137        // Use name as ID if it's a valid identifier, otherwise generate UUID
138        // For CRDT sync, we want to use the same ID across peers
139        let doc_id = if name.contains('-') || name.len() > 30 {
140            // Looks like a UUID or explicit ID - use it directly
141            name.to_string()
142        } else {
143            // Regular name - generate UUID
144            uuid::Uuid::new_v4().to_string()
145        };
146
147        let now = chrono::Utc::now();
148
149        debug!(
150            "Creating document '{}' with ID {} and mode {:?}",
151            name, doc_id, storage_mode
152        );
153
154        debug!("Step 1: Creating Yrs Doc");
155        // Create Yrs document
156        let doc = Doc::new();
157        let doc = Arc::new(Mutex::new(doc));
158        debug!("Step 1: Done creating Yrs Doc");
159
160        debug!("Step 2: Storing document in documents map");
161        // Store document
162        self.documents.write().await.insert(doc_id.clone(), doc);
163        debug!("Step 2: Done storing document");
164
165        debug!("Step 3: Storing metadata");
166        // Store metadata
167        let meta = DocumentMetadata {
168            id: doc_id.clone(),
169            name: name.to_string(),
170            storage_mode,
171            created_at: now,
172            updated_at: now,
173        };
174        self.metadata.write().await.insert(doc_id.clone(), meta);
175        debug!("Step 3: Done storing metadata");
176
177        debug!("Step 4: Storing encryption key");
178        // Store encryption key if Files storage
179        if storage_mode == StorageMode::Files || storage_mode == StorageMode::Both {
180            self.encryption_keys
181                .write()
182                .await
183                .insert(doc_id.clone(), *encryption_key);
184        }
185        debug!("Step 4: Done storing encryption key");
186
187        // Initialize storage based on mode - save empty document to all applicable storages
188        debug!("Saving document to storage");
189        self.update_storage(&doc_id).await?;
190        debug!("Done saving document");
191
192        info!("Document '{}' created with ID {}", name, doc_id);
193
194        Ok(doc_id)
195    }
196
197    /// Get document (returns None if not found)
198    pub async fn get_document(&self, doc_id: &str) -> Result<Option<Arc<Mutex<Doc>>>> {
199        Ok(self.documents.read().await.get(doc_id).cloned())
200    }
201
202    /// Check if document exists in Files storage
203    pub async fn document_exists_in_files(&self, doc_id: &str) -> Result<bool> {
204        Ok(self.files_storage.read().await.contains_key(doc_id))
205    }
206
207    /// Check if document exists in Web storage
208    pub async fn document_exists_in_web(&self, doc_id: &str) -> Result<bool> {
209        Ok(self.web_storage.read().await.contains_key(doc_id))
210    }
211
212    /// Insert text at position
213    pub async fn insert_text(&self, doc_id: &str, index: usize, text: &str) -> Result<()> {
214        let doc = self
215            .get_document(doc_id)
216            .await?
217            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
218
219        // Clone for move into spawn_blocking
220        let doc_clone = Arc::clone(&doc);
221        let text_owned = text.to_string();
222
223        // Wrap blocking Yrs operation in spawn_blocking
224        tokio::task::spawn_blocking(move || {
225            let doc = doc_clone
226                .lock()
227                .map_err(|e| anyhow!("Mutex lock failed: {}", e))?;
228
229            // Get or create text object from Doc
230            let ytext = doc.get_or_insert_text("content");
231            let mut txn = doc.transact_mut();
232
233            ytext.insert(&mut txn, index as u32, &text_owned);
234
235            drop(txn);
236            Ok::<(), anyhow::Error>(())
237        })
238        .await
239        .map_err(|e| anyhow!("Join error: {}", e))??;
240
241        // Update storage
242        self.update_storage(doc_id).await?;
243
244        debug!("Inserted '{}' at position {} in {}", text, index, doc_id);
245
246        Ok(())
247    }
248
249    /// Delete text in range
250    pub async fn delete_text(&self, doc_id: &str, index: usize, len: usize) -> Result<()> {
251        let doc = self
252            .get_document(doc_id)
253            .await?
254            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
255
256        // Clone for move into spawn_blocking
257        let doc_clone = Arc::clone(&doc);
258
259        // Wrap blocking Yrs operation in spawn_blocking
260        let actual_len = tokio::task::spawn_blocking(move || {
261            let doc = doc_clone
262                .lock()
263                .map_err(|e| anyhow!("Mutex lock failed: {}", e))?;
264
265            // Get or create text object from Doc
266            let ytext = doc.get_or_insert_text("content");
267            let mut txn = doc.transact_mut();
268
269            // Get current length
270            let current_len = ytext.len(&txn);
271
272            // Clamp deletion to valid range
273            let actual_len = if index + len > current_len as usize {
274                if index >= current_len as usize {
275                    return Ok(0); // Nothing to delete
276                }
277                current_len as usize - index
278            } else {
279                len
280            };
281
282            ytext.remove_range(&mut txn, index as u32, actual_len as u32);
283
284            drop(txn);
285            Ok::<usize, anyhow::Error>(actual_len)
286        })
287        .await
288        .map_err(|e| anyhow!("Join error: {}", e))??;
289
290        // Update storage
291        self.update_storage(doc_id).await?;
292
293        debug!(
294            "Deleted {} chars at position {} in {}",
295            actual_len, index, doc_id
296        );
297
298        Ok(())
299    }
300
301    /// Get text content of document
302    pub async fn get_text(&self, doc_id: &str) -> Result<String> {
303        let doc = self
304            .get_document(doc_id)
305            .await?
306            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
307
308        // Clone for move into spawn_blocking
309        let doc_clone = Arc::clone(&doc);
310
311        // Wrap blocking Yrs operation in spawn_blocking
312        let text = tokio::task::spawn_blocking(move || {
313            let doc = doc_clone
314                .lock()
315                .map_err(|e| anyhow!("Mutex lock failed: {}", e))?;
316
317            // Get or create text object from Doc
318            let ytext = doc.get_or_insert_text("content");
319            let txn = doc.transact();
320            let text = ytext.get_string(&txn);
321
322            Ok::<String, anyhow::Error>(text)
323        })
324        .await
325        .map_err(|e| anyhow!("Join error: {}", e))??;
326
327        Ok(text)
328    }
329
330    /// Get CRDT update (full state from beginning)
331    ///
332    /// This encodes ALL changes from the document's creation, suitable for
333    /// syncing to a new peer that doesn't have any prior state.
334    pub async fn get_crdt_update(&self, doc_id: &str) -> Result<Vec<u8>> {
335        let doc = self
336            .get_document(doc_id)
337            .await?
338            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
339
340        // Clone for move into spawn_blocking
341        let doc_clone = Arc::clone(&doc);
342
343        // Wrap blocking Yrs operation in spawn_blocking
344        let update = tokio::task::spawn_blocking(move || {
345            let doc = doc_clone
346                .lock()
347                .map_err(|e| anyhow!("Mutex lock failed: {}", e))?;
348            let txn = doc.transact();
349
350            // Use empty state vector to encode ALL changes from the beginning
351            // This is the correct approach for initial sync with new peers
352            let empty_state = StateVector::default();
353
354            // Encode state as update using EncoderV1
355            let mut encoder = EncoderV1::new();
356            txn.encode_state_as_update(&empty_state, &mut encoder);
357
358            Ok::<Vec<u8>, anyhow::Error>(encoder.to_vec())
359        })
360        .await
361        .map_err(|e| anyhow!("Join error: {}", e))??;
362
363        Ok(update)
364    }
365
366    /// Apply CRDT update from peer
367    pub async fn apply_crdt_update(&self, doc_id: &str, update: &[u8]) -> Result<()> {
368        // Create document if it doesn't exist (using the provided doc_id)
369        if self.get_document(doc_id).await?.is_none() {
370            self.create_document_with_id(doc_id, StorageMode::Files)
371                .await?;
372        }
373
374        let doc = self
375            .get_document(doc_id)
376            .await?
377            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
378
379        // Clone for move into spawn_blocking
380        let doc_clone = Arc::clone(&doc);
381        let update_owned = update.to_vec();
382
383        // Wrap blocking Yrs operation in spawn_blocking
384        tokio::task::spawn_blocking(move || {
385            let doc = doc_clone
386                .lock()
387                .map_err(|e| anyhow!("Mutex lock failed: {}", e))?;
388            let mut txn = doc.transact_mut();
389
390            let update_obj = Update::decode_v1(&update_owned)
391                .map_err(|e| anyhow!("Failed to decode update: {:?}", e))?;
392
393            txn.apply_update(update_obj);
394
395            drop(txn);
396            Ok::<(), anyhow::Error>(())
397        })
398        .await
399        .map_err(|e| anyhow!("Join error: {}", e))??;
400
401        // Update storage
402        self.update_storage(doc_id).await?;
403
404        debug!("Applied CRDT update to {}", doc_id);
405
406        Ok(())
407    }
408
409    /// Internal: Create document with specific ID (for CRDT sync)
410    async fn create_document_with_id(&self, doc_id: &str, storage_mode: StorageMode) -> Result<()> {
411        let now = chrono::Utc::now();
412
413        // Generate default encryption key if needed
414        let mut default_key = [0u8; 32];
415        getrandom::getrandom(&mut default_key)
416            .map_err(|e| anyhow!("Failed to generate key: {}", e))?;
417
418        debug!(
419            "Creating document with ID '{}' and mode {:?}",
420            doc_id, storage_mode
421        );
422
423        // Create Yrs document
424        let doc = Doc::new();
425        let doc = Arc::new(Mutex::new(doc));
426
427        // Store document
428        self.documents.write().await.insert(doc_id.to_string(), doc);
429
430        // Store metadata
431        let meta = DocumentMetadata {
432            id: doc_id.to_string(),
433            name: doc_id.to_string(),
434            storage_mode,
435            created_at: now,
436            updated_at: now,
437        };
438        self.metadata.write().await.insert(doc_id.to_string(), meta);
439
440        // Store encryption key if Files storage
441        if storage_mode == StorageMode::Files || storage_mode == StorageMode::Both {
442            self.encryption_keys
443                .write()
444                .await
445                .insert(doc_id.to_string(), default_key);
446        }
447
448        // Initialize storage based on mode
449        match storage_mode {
450            StorageMode::Files => {
451                self.save_to_files(doc_id).await?;
452            }
453            StorageMode::Web => {
454                self.save_to_web(doc_id).await?;
455            }
456            StorageMode::Both => {
457                self.save_to_files(doc_id).await?;
458                self.save_to_web(doc_id).await?;
459            }
460        }
461
462        info!("Document created with ID {}", doc_id);
463
464        Ok(())
465    }
466
467    /// Get encrypted blob from Files storage
468    pub async fn get_files_blob(&self, doc_id: &str) -> Result<Option<Vec<u8>>> {
469        let storage = self.files_storage.read().await;
470
471        let encrypted = match storage.get(doc_id) {
472            Some(enc) => enc,
473            None => return Ok(None),
474        };
475
476        let blob =
477            bincode::serialize(encrypted).map_err(|e| anyhow!("Serialization failed: {}", e))?;
478
479        Ok(Some(blob))
480    }
481
482    /// Get plaintext blob from Web storage
483    pub async fn get_web_blob(&self, doc_id: &str) -> Result<Option<Vec<u8>>> {
484        Ok(self.web_storage.read().await.get(doc_id).cloned())
485    }
486
487    /// Get encryption key for document (fails for Web-only docs)
488    pub async fn get_encryption_key(&self, doc_id: &str) -> Result<[u8; 32]> {
489        let meta = self
490            .metadata
491            .read()
492            .await
493            .get(doc_id)
494            .cloned()
495            .ok_or_else(|| anyhow!("Document not found: {}", doc_id))?;
496
497        if meta.storage_mode == StorageMode::Web {
498            return Err(anyhow!("Web documents do not have encryption keys"));
499        }
500
501        self.encryption_keys
502            .read()
503            .await
504            .get(doc_id)
505            .copied()
506            .ok_or_else(|| anyhow!("Encryption key not found"))
507    }
508
509    /// Decrypt document with specific key (for testing wrong key scenarios)
510    pub async fn decrypt_with_key(&self, doc_id: &str, key: &[u8; 32]) -> Result<Vec<u8>> {
511        let storage = self.files_storage.read().await;
512
513        let encrypted = storage
514            .get(doc_id)
515            .ok_or_else(|| anyhow!("Document not in Files storage"))?;
516
517        let cipher = ChaCha20Poly1305::new(key.into());
518        let nonce = Nonce::from(
519            *<&[u8; 12]>::try_from(encrypted.nonce.as_slice())
520                .map_err(|_| anyhow!("Invalid nonce length"))?,
521        );
522
523        let plaintext = cipher
524            .decrypt(&nonce, encrypted.ciphertext.as_ref())
525            .map_err(|e| anyhow!("Decryption failed: {}", e))?;
526
527        Ok(plaintext)
528    }
529
530    /// Save document to Files storage (encrypted)
531    async fn save_to_files(&self, doc_id: &str) -> Result<()> {
532        if !self.files_enabled {
533            return Ok(());
534        }
535
536        let update = self.get_crdt_update(doc_id).await?;
537
538        let key = self
539            .encryption_keys
540            .read()
541            .await
542            .get(doc_id)
543            .copied()
544            .ok_or_else(|| anyhow!("Encryption key not found"))?;
545
546        // Generate random nonce
547        let mut nonce_bytes = [0u8; 12];
548        getrandom::getrandom(&mut nonce_bytes)
549            .map_err(|e| anyhow!("Nonce generation failed: {}", e))?;
550
551        let cipher = ChaCha20Poly1305::new(&key.into());
552        let nonce = Nonce::from(nonce_bytes);
553
554        let ciphertext = cipher
555            .encrypt(&nonce, update.as_ref())
556            .map_err(|e| anyhow!("Encryption failed: {}", e))?;
557
558        let encrypted = EncryptedDocument {
559            nonce: nonce_bytes,
560            ciphertext,
561        };
562
563        self.files_storage
564            .write()
565            .await
566            .insert(doc_id.to_string(), encrypted);
567
568        debug!("Saved {} to Files storage (encrypted)", doc_id);
569
570        Ok(())
571    }
572
573    /// Save document to Web storage (plaintext)
574    async fn save_to_web(&self, doc_id: &str) -> Result<()> {
575        if !self.web_enabled {
576            return Ok(());
577        }
578
579        let update = self.get_crdt_update(doc_id).await?;
580
581        self.web_storage
582            .write()
583            .await
584            .insert(doc_id.to_string(), update);
585
586        debug!("Saved {} to Web storage (public)", doc_id);
587
588        Ok(())
589    }
590
591    /// Update storage after CRDT change
592    async fn update_storage(&self, doc_id: &str) -> Result<()> {
593        let meta = self
594            .metadata
595            .read()
596            .await
597            .get(doc_id)
598            .cloned()
599            .ok_or_else(|| anyhow!("Document metadata not found"))?;
600
601        match meta.storage_mode {
602            StorageMode::Files => self.save_to_files(doc_id).await?,
603            StorageMode::Web => self.save_to_web(doc_id).await?,
604            StorageMode::Both => {
605                self.save_to_files(doc_id).await?;
606                self.save_to_web(doc_id).await?;
607            }
608        }
609
610        // Update timestamp
611        self.metadata
612            .write()
613            .await
614            .entry(doc_id.to_string())
615            .and_modify(|m| {
616                m.updated_at = chrono::Utc::now();
617            });
618
619        Ok(())
620    }
621
622    /// List all document IDs
623    pub async fn list_documents(&self) -> Result<Vec<String>> {
624        debug!("list_documents: About to acquire read lock");
625        let documents = self.documents.read().await;
626        debug!(
627            "list_documents: Acquired read lock, found {} documents",
628            documents.len()
629        );
630        let keys: Vec<String> = documents.keys().cloned().collect();
631        debug!("list_documents: Collected keys");
632        Ok(keys)
633    }
634
635    /// Delete a document and all associated data
636    pub async fn delete_document(&self, doc_id: &str) -> Result<()> {
637        debug!("Deleting document: {}", doc_id);
638
639        // Remove from all storage locations
640        self.documents.write().await.remove(doc_id);
641        self.metadata.write().await.remove(doc_id);
642        self.encryption_keys.write().await.remove(doc_id);
643        self.files_storage.write().await.remove(doc_id);
644        self.web_storage.write().await.remove(doc_id);
645
646        info!("Document deleted: {}", doc_id);
647
648        Ok(())
649    }
650}
651
652#[cfg(test)]
653mod tests {
654    use super::*;
655
656    async fn create_test_config() -> DocReplicatorConfig {
657        DocReplicatorConfig {
658            files_storage_enabled: true,
659            web_storage_enabled: true,
660        }
661    }
662
663    #[tokio::test]
664    async fn test_doc_replicator_creation() {
665        let config = create_test_config().await;
666        let replicator = DocReplicator::new(config).await.expect("create replicator");
667
668        assert!(replicator.files_enabled);
669        assert!(replicator.web_enabled);
670    }
671
672    #[tokio::test]
673    async fn test_create_and_retrieve_document() {
674        let config = create_test_config().await;
675        let replicator = DocReplicator::new(config).await.expect("create replicator");
676
677        let doc_id = replicator
678            .create_document("test", StorageMode::Files)
679            .await
680            .expect("create doc");
681
682        let doc = replicator.get_document(&doc_id).await.expect("get doc");
683
684        assert!(doc.is_some());
685    }
686
687    #[tokio::test]
688    async fn test_insert_and_get_text() {
689        let config = create_test_config().await;
690        let replicator = DocReplicator::new(config).await.expect("create replicator");
691
692        let doc_id = replicator
693            .create_document("text-test", StorageMode::Files)
694            .await
695            .expect("create");
696
697        replicator
698            .insert_text(&doc_id, 0, "Hello!")
699            .await
700            .expect("insert");
701
702        let text = replicator.get_text(&doc_id).await.expect("get text");
703
704        assert_eq!(text, "Hello!");
705    }
706
707    #[tokio::test]
708    async fn test_dual_storage() {
709        let config = create_test_config().await;
710        let replicator = DocReplicator::new(config).await.expect("create replicator");
711
712        let doc_id = replicator
713            .create_document("dual-doc", StorageMode::Both)
714            .await
715            .expect("create");
716
717        let files_exists = replicator
718            .document_exists_in_files(&doc_id)
719            .await
720            .expect("check files");
721        let web_exists = replicator
722            .document_exists_in_web(&doc_id)
723            .await
724            .expect("check web");
725
726        assert!(files_exists);
727        assert!(web_exists);
728    }
729}