Skip to main content

communitas_core/
disk_service.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//! Entity Disk Service - Per-entity virtual disk management
9//!
10//! This module provides virtual disk functionality for entities (users, organizations,
11//! groups, channels, projects). Each entity has three disk types:
12//!
13//! - **Private**: Encrypted, local-only storage (owner access only)
14//! - **Public**: Content-addressed, distributed storage (world-readable)
15//! - **Shared**: Group-accessible with shared encryption (members only)
16
17use anyhow::{Context, Result, bail};
18use serde::{Deserialize, Serialize};
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21use tokio::sync::RwLock;
22use tracing::{debug, info, warn};
23
24/// Type of virtual disk
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
26pub enum DiskType {
27    /// Private: Encrypted, local-only storage (owner access only)
28    Private,
29    /// Public: Content-addressed, distributed storage (world-readable)
30    Public,
31    /// Shared: Group-accessible with shared encryption (members only)
32    Shared,
33}
34
35impl DiskType {
36    /// Get the directory name for this disk type
37    pub fn as_dir_name(&self) -> &'static str {
38        match self {
39            DiskType::Private => "private",
40            DiskType::Public => "public",
41            DiskType::Shared => "shared",
42        }
43    }
44}
45
46impl std::fmt::Display for DiskType {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            DiskType::Private => write!(f, "private"),
50            DiskType::Public => write!(f, "public"),
51            DiskType::Shared => write!(f, "shared"),
52        }
53    }
54}
55
56/// Information about a file or directory in a virtual disk
57#[derive(Debug, Clone, Serialize, Deserialize)]
58pub struct FileInfo {
59    /// Full path within the disk (e.g., "/docs/readme.md")
60    pub path: String,
61    /// File or directory name
62    pub name: String,
63    /// True if this is a directory
64    pub is_directory: bool,
65    /// Size in bytes (0 for directories)
66    pub size_bytes: u64,
67    /// Last modified timestamp (Unix epoch seconds)
68    pub modified_at: i64,
69    /// BLAKE3 hash of contents (empty for directories)
70    pub content_hash: String,
71}
72
73/// Storage statistics for a virtual disk
74#[derive(Debug, Clone, Serialize, Deserialize)]
75pub struct DiskStats {
76    /// Entity ID this disk belongs to
77    pub entity_id: String,
78    /// Type of disk
79    pub disk_type: DiskType,
80    /// Total bytes used
81    pub used_bytes: u64,
82    /// Total number of files
83    pub file_count: u32,
84    /// Total number of directories
85    pub dir_count: u32,
86    /// Last modification timestamp
87    pub last_modified: i64,
88}
89
90/// Internal metadata for tracking files
91#[derive(Debug, Clone, Serialize, Deserialize)]
92struct DiskFileMetadata {
93    pub entity_id: String,
94    pub disk_type: DiskType,
95    pub path: String,
96    pub name: String,
97    pub is_directory: bool,
98    pub size_bytes: u64,
99    pub modified_at: i64,
100    pub content_hash: String,
101    pub file_path: PathBuf, // Actual filesystem path
102}
103
104/// Entity Disk Service - manages per-entity virtual disks
105#[derive(Debug)]
106pub struct EntityDiskService {
107    /// Root directory for all disk storage
108    root: PathBuf,
109    /// Metadata index: (entity_id, disk_type, path) -> metadata
110    index: RwLock<HashMap<String, DiskFileMetadata>>,
111}
112
113impl EntityDiskService {
114    /// Create a new EntityDiskService with the given root directory
115    pub async fn new<P: AsRef<Path>>(root: P) -> Result<Self> {
116        let root = root.as_ref().to_path_buf();
117
118        // Create root directory if it doesn't exist
119        tokio::fs::create_dir_all(&root)
120            .await
121            .with_context(|| format!("Failed to create disk root: {}", root.display()))?;
122
123        let service = Self {
124            root,
125            index: RwLock::new(HashMap::new()),
126        };
127
128        // Load existing metadata
129        service.load_index().await?;
130
131        info!(
132            "EntityDiskService initialized at {}",
133            service.root.display()
134        );
135        Ok(service)
136    }
137
138    /// Get the filesystem path for an entity's disk directory
139    fn get_entity_disk_path(&self, entity_id: &str, disk_type: DiskType) -> PathBuf {
140        self.root
141            .join("entities")
142            .join(entity_id)
143            .join(disk_type.as_dir_name())
144    }
145
146    /// Get the filesystem path for a file within an entity's disk
147    fn get_file_path(&self, entity_id: &str, disk_type: DiskType, path: &str) -> PathBuf {
148        let disk_path = self.get_entity_disk_path(entity_id, disk_type);
149        // Normalize path: remove leading slash and any .. components
150        let clean_path = path.trim_start_matches('/').replace("..", "");
151        disk_path.join(clean_path)
152    }
153
154    /// Generate the index key for a file
155    fn index_key(entity_id: &str, disk_type: DiskType, path: &str) -> String {
156        format!("{}:{}:{}", entity_id, disk_type, path)
157    }
158
159    /// Write a file to an entity's virtual disk
160    pub async fn write_file(
161        &self,
162        entity_id: &str,
163        disk_type: DiskType,
164        path: &str,
165        data: &[u8],
166    ) -> Result<FileInfo> {
167        // Validate path
168        if path.is_empty() || path == "/" {
169            bail!("Invalid file path: cannot write to root");
170        }
171
172        let file_path = self.get_file_path(entity_id, disk_type, path);
173
174        // Create parent directories
175        if let Some(parent) = file_path.parent() {
176            tokio::fs::create_dir_all(parent).await.with_context(|| {
177                format!(
178                    "Failed to create parent directories for {}",
179                    file_path.display()
180                )
181            })?;
182        }
183
184        // Write the file
185        tokio::fs::write(&file_path, data)
186            .await
187            .with_context(|| format!("Failed to write file: {}", file_path.display()))?;
188
189        // Calculate hash
190        let content_hash = blake3::hash(data).to_string();
191
192        // Get file name
193        let name = Path::new(path)
194            .file_name()
195            .map(|s| s.to_string_lossy().to_string())
196            .unwrap_or_else(|| path.to_string());
197
198        let now = chrono::Utc::now().timestamp();
199
200        // Create metadata
201        let metadata = DiskFileMetadata {
202            entity_id: entity_id.to_string(),
203            disk_type,
204            path: path.to_string(),
205            name: name.clone(),
206            is_directory: false,
207            size_bytes: data.len() as u64,
208            modified_at: now,
209            content_hash: content_hash.clone(),
210            file_path: file_path.clone(),
211        };
212
213        // Update index
214        {
215            let key = Self::index_key(entity_id, disk_type, path);
216            let mut index = self.index.write().await;
217            index.insert(key, metadata);
218        }
219
220        // Persist index
221        self.save_index().await?;
222
223        debug!(
224            "Wrote file {}:{}{} ({} bytes)",
225            entity_id,
226            disk_type,
227            path,
228            data.len()
229        );
230
231        Ok(FileInfo {
232            path: path.to_string(),
233            name,
234            is_directory: false,
235            size_bytes: data.len() as u64,
236            modified_at: now,
237            content_hash,
238        })
239    }
240
241    /// Read a file from an entity's virtual disk
242    pub async fn read_file(
243        &self,
244        entity_id: &str,
245        disk_type: DiskType,
246        path: &str,
247    ) -> Result<Vec<u8>> {
248        let key = Self::index_key(entity_id, disk_type, path);
249
250        // Get metadata
251        let metadata = {
252            let index = self.index.read().await;
253            index.get(&key).cloned()
254        };
255
256        let file_path = match metadata {
257            Some(meta) => {
258                if meta.is_directory {
259                    bail!("Cannot read directory as file: {}", path);
260                }
261                meta.file_path
262            }
263            None => {
264                // Try direct path lookup
265                let fp = self.get_file_path(entity_id, disk_type, path);
266                if !fp.exists() {
267                    bail!("File not found: {}:{}{}", entity_id, disk_type, path);
268                }
269                fp
270            }
271        };
272
273        // Read the file
274        let data = tokio::fs::read(&file_path)
275            .await
276            .with_context(|| format!("Failed to read file: {}", file_path.display()))?;
277
278        debug!(
279            "Read file {}:{}{} ({} bytes)",
280            entity_id,
281            disk_type,
282            path,
283            data.len()
284        );
285
286        Ok(data)
287    }
288
289    /// List files in a directory within an entity's virtual disk
290    pub async fn list_files(
291        &self,
292        entity_id: &str,
293        disk_type: DiskType,
294        path: &str,
295    ) -> Result<Vec<FileInfo>> {
296        let dir_path = if path.is_empty() || path == "/" {
297            self.get_entity_disk_path(entity_id, disk_type)
298        } else {
299            self.get_file_path(entity_id, disk_type, path)
300        };
301
302        // Create directory if it doesn't exist
303        if !dir_path.exists() {
304            tokio::fs::create_dir_all(&dir_path)
305                .await
306                .with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
307        }
308
309        if !dir_path.is_dir() {
310            bail!("Path is not a directory: {}", path);
311        }
312
313        let mut entries = Vec::new();
314        let mut read_dir = tokio::fs::read_dir(&dir_path)
315            .await
316            .with_context(|| format!("Failed to read directory: {}", dir_path.display()))?;
317
318        while let Some(entry) = read_dir.next_entry().await? {
319            let entry_path = entry.path();
320            let metadata = entry.metadata().await?;
321
322            let name = entry.file_name().to_string_lossy().to_string();
323
324            // Skip hidden files and index files
325            if name.starts_with('.') || name == "disk_index.json" {
326                continue;
327            }
328
329            let entry_relative_path = if path.is_empty() || path == "/" {
330                format!("/{}", name)
331            } else {
332                format!("{}/{}", path.trim_end_matches('/'), name)
333            };
334
335            let (content_hash, size_bytes) = if metadata.is_file() {
336                let data = tokio::fs::read(&entry_path).await?;
337                (blake3::hash(&data).to_string(), data.len() as u64)
338            } else {
339                (String::new(), 0)
340            };
341
342            let modified_at = metadata
343                .modified()
344                .ok()
345                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
346                .map(|d| d.as_secs() as i64)
347                .unwrap_or(0);
348
349            entries.push(FileInfo {
350                path: entry_relative_path,
351                name,
352                is_directory: metadata.is_dir(),
353                size_bytes,
354                modified_at,
355                content_hash,
356            });
357        }
358
359        // Sort by name
360        entries.sort_by(|a, b| a.name.cmp(&b.name));
361
362        debug!(
363            "Listed {}:{}{} - {} entries",
364            entity_id,
365            disk_type,
366            path,
367            entries.len()
368        );
369
370        Ok(entries)
371    }
372
373    /// Delete a file from an entity's virtual disk
374    pub async fn delete_file(
375        &self,
376        entity_id: &str,
377        disk_type: DiskType,
378        path: &str,
379    ) -> Result<()> {
380        if path.is_empty() || path == "/" {
381            bail!("Cannot delete root directory");
382        }
383
384        let file_path = self.get_file_path(entity_id, disk_type, path);
385
386        if !file_path.exists() {
387            bail!("File not found: {}:{}{}", entity_id, disk_type, path);
388        }
389
390        if file_path.is_dir() {
391            tokio::fs::remove_dir_all(&file_path)
392                .await
393                .with_context(|| format!("Failed to delete directory: {}", file_path.display()))?;
394        } else {
395            tokio::fs::remove_file(&file_path)
396                .await
397                .with_context(|| format!("Failed to delete file: {}", file_path.display()))?;
398        }
399
400        // Remove from index
401        {
402            let key = Self::index_key(entity_id, disk_type, path);
403            let mut index = self.index.write().await;
404            index.remove(&key);
405        }
406
407        // Persist index
408        self.save_index().await?;
409
410        debug!("Deleted {}:{}{}", entity_id, disk_type, path);
411
412        Ok(())
413    }
414
415    /// Get storage statistics for an entity's disk
416    pub async fn get_stats(&self, entity_id: &str, disk_type: DiskType) -> Result<DiskStats> {
417        let disk_path = self.get_entity_disk_path(entity_id, disk_type);
418
419        let mut used_bytes: u64 = 0;
420        let mut file_count: u32 = 0;
421        let mut dir_count: u32 = 0;
422        let mut last_modified: i64 = 0;
423
424        if disk_path.exists() {
425            self.calculate_stats_recursive(
426                &disk_path,
427                &mut used_bytes,
428                &mut file_count,
429                &mut dir_count,
430                &mut last_modified,
431            )
432            .await?;
433        }
434
435        Ok(DiskStats {
436            entity_id: entity_id.to_string(),
437            disk_type,
438            used_bytes,
439            file_count,
440            dir_count,
441            last_modified,
442        })
443    }
444
445    /// Recursively calculate storage statistics
446    async fn calculate_stats_recursive(
447        &self,
448        path: &Path,
449        used_bytes: &mut u64,
450        file_count: &mut u32,
451        dir_count: &mut u32,
452        last_modified: &mut i64,
453    ) -> Result<()> {
454        let mut read_dir = tokio::fs::read_dir(path).await?;
455
456        while let Some(entry) = read_dir.next_entry().await? {
457            let entry_path = entry.path();
458            let metadata = entry.metadata().await?;
459
460            // Skip hidden files
461            let name = entry.file_name().to_string_lossy().to_string();
462            if name.starts_with('.') || name == "disk_index.json" {
463                continue;
464            }
465
466            let modified = metadata
467                .modified()
468                .ok()
469                .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
470                .map(|d| d.as_secs() as i64)
471                .unwrap_or(0);
472
473            if modified > *last_modified {
474                *last_modified = modified;
475            }
476
477            if metadata.is_dir() {
478                *dir_count += 1;
479                Box::pin(self.calculate_stats_recursive(
480                    &entry_path,
481                    used_bytes,
482                    file_count,
483                    dir_count,
484                    last_modified,
485                ))
486                .await?;
487            } else {
488                *file_count += 1;
489                *used_bytes += metadata.len();
490            }
491        }
492
493        Ok(())
494    }
495
496    /// Create a directory in an entity's virtual disk
497    pub async fn create_directory(
498        &self,
499        entity_id: &str,
500        disk_type: DiskType,
501        path: &str,
502    ) -> Result<FileInfo> {
503        if path.is_empty() || path == "/" {
504            bail!("Cannot create root directory");
505        }
506
507        let dir_path = self.get_file_path(entity_id, disk_type, path);
508
509        tokio::fs::create_dir_all(&dir_path)
510            .await
511            .with_context(|| format!("Failed to create directory: {}", dir_path.display()))?;
512
513        let name = Path::new(path)
514            .file_name()
515            .map(|s| s.to_string_lossy().to_string())
516            .unwrap_or_else(|| path.to_string());
517
518        let now = chrono::Utc::now().timestamp();
519
520        // Create metadata
521        let metadata = DiskFileMetadata {
522            entity_id: entity_id.to_string(),
523            disk_type,
524            path: path.to_string(),
525            name: name.clone(),
526            is_directory: true,
527            size_bytes: 0,
528            modified_at: now,
529            content_hash: String::new(),
530            file_path: dir_path,
531        };
532
533        // Update index
534        {
535            let key = Self::index_key(entity_id, disk_type, path);
536            let mut index = self.index.write().await;
537            index.insert(key, metadata);
538        }
539
540        // Persist index
541        self.save_index().await?;
542
543        debug!("Created directory {}:{}{}", entity_id, disk_type, path);
544
545        Ok(FileInfo {
546            path: path.to_string(),
547            name,
548            is_directory: true,
549            size_bytes: 0,
550            modified_at: now,
551            content_hash: String::new(),
552        })
553    }
554
555    /// Check if a file exists
556    pub async fn file_exists(&self, entity_id: &str, disk_type: DiskType, path: &str) -> bool {
557        let file_path = self.get_file_path(entity_id, disk_type, path);
558        file_path.exists()
559    }
560
561    /// Get file info without reading the contents
562    pub async fn get_file_info(
563        &self,
564        entity_id: &str,
565        disk_type: DiskType,
566        path: &str,
567    ) -> Result<FileInfo> {
568        let file_path = self.get_file_path(entity_id, disk_type, path);
569
570        if !file_path.exists() {
571            bail!("File not found: {}:{}{}", entity_id, disk_type, path);
572        }
573
574        let metadata = tokio::fs::metadata(&file_path).await?;
575        let is_directory = metadata.is_dir();
576
577        let name = Path::new(path)
578            .file_name()
579            .map(|s| s.to_string_lossy().to_string())
580            .unwrap_or_else(|| path.to_string());
581
582        let modified_at = metadata
583            .modified()
584            .ok()
585            .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
586            .map(|d| d.as_secs() as i64)
587            .unwrap_or(0);
588
589        let (size_bytes, content_hash) = if is_directory {
590            (0, String::new())
591        } else {
592            let data = tokio::fs::read(&file_path).await?;
593            (data.len() as u64, blake3::hash(&data).to_string())
594        };
595
596        Ok(FileInfo {
597            path: path.to_string(),
598            name,
599            is_directory,
600            size_bytes,
601            modified_at,
602            content_hash,
603        })
604    }
605
606    /// Load the metadata index from disk
607    async fn load_index(&self) -> Result<()> {
608        let index_path = self.root.join("disk_index.json");
609
610        if !index_path.exists() {
611            debug!("No existing disk index found, starting fresh");
612            return Ok(());
613        }
614
615        match tokio::fs::read_to_string(&index_path).await {
616            Ok(data) => match serde_json::from_str::<HashMap<String, DiskFileMetadata>>(&data) {
617                Ok(stored_index) => {
618                    let count = stored_index.len();
619                    let mut index = self.index.write().await;
620                    *index = stored_index;
621                    info!("Loaded {} entries from disk index", count);
622                }
623                Err(e) => {
624                    warn!("Failed to parse disk index, starting fresh: {}", e);
625                }
626            },
627            Err(e) => {
628                warn!("Failed to read disk index, starting fresh: {}", e);
629            }
630        }
631
632        Ok(())
633    }
634
635    /// Save the metadata index to disk
636    async fn save_index(&self) -> Result<()> {
637        let index_path = self.root.join("disk_index.json");
638        let temp_path = self.root.join(".disk_index.tmp");
639
640        let data = {
641            let index = self.index.read().await;
642            serde_json::to_string_pretty(&*index).context("Failed to serialize disk index")?
643        };
644
645        // Write to temp file first
646        tokio::fs::write(&temp_path, &data)
647            .await
648            .context("Failed to write temp index file")?;
649
650        // Atomically move to final location
651        tokio::fs::rename(&temp_path, &index_path)
652            .await
653            .context("Failed to move index file")?;
654
655        Ok(())
656    }
657}
658
659#[cfg(test)]
660mod tests {
661    use super::*;
662    use tempfile::tempdir;
663
664    #[tokio::test]
665    async fn test_write_and_read_file() {
666        let temp = tempdir().unwrap();
667        let service = EntityDiskService::new(temp.path()).await.unwrap();
668
669        let entity_id = "test-entity-one-two";
670        let data = b"Hello, World!";
671
672        // Write file
673        let info = service
674            .write_file(entity_id, DiskType::Private, "/docs/test.txt", data)
675            .await
676            .unwrap();
677
678        assert_eq!(info.name, "test.txt");
679        assert_eq!(info.size_bytes, 13);
680        assert!(!info.is_directory);
681
682        // Read file
683        let read_data = service
684            .read_file(entity_id, DiskType::Private, "/docs/test.txt")
685            .await
686            .unwrap();
687
688        assert_eq!(read_data, data);
689    }
690
691    #[tokio::test]
692    async fn test_list_files() {
693        let temp = tempdir().unwrap();
694        let service = EntityDiskService::new(temp.path()).await.unwrap();
695
696        let entity_id = "test-entity-one-two";
697
698        // Write some files
699        service
700            .write_file(entity_id, DiskType::Public, "/file1.txt", b"one")
701            .await
702            .unwrap();
703        service
704            .write_file(entity_id, DiskType::Public, "/file2.txt", b"two")
705            .await
706            .unwrap();
707
708        // List files
709        let files = service
710            .list_files(entity_id, DiskType::Public, "/")
711            .await
712            .unwrap();
713
714        assert_eq!(files.len(), 2);
715    }
716
717    #[tokio::test]
718    async fn test_delete_file() {
719        let temp = tempdir().unwrap();
720        let service = EntityDiskService::new(temp.path()).await.unwrap();
721
722        let entity_id = "test-entity-one-two";
723
724        // Write file
725        service
726            .write_file(entity_id, DiskType::Shared, "/to_delete.txt", b"delete me")
727            .await
728            .unwrap();
729
730        // Verify it exists
731        assert!(
732            service
733                .file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
734                .await
735        );
736
737        // Delete it
738        service
739            .delete_file(entity_id, DiskType::Shared, "/to_delete.txt")
740            .await
741            .unwrap();
742
743        // Verify it's gone
744        assert!(
745            !service
746                .file_exists(entity_id, DiskType::Shared, "/to_delete.txt")
747                .await
748        );
749    }
750
751    #[tokio::test]
752    async fn test_get_stats() {
753        let temp = tempdir().unwrap();
754        let service = EntityDiskService::new(temp.path()).await.unwrap();
755
756        let entity_id = "test-entity-one-two";
757
758        // Write some files
759        service
760            .write_file(entity_id, DiskType::Private, "/file1.txt", b"hello")
761            .await
762            .unwrap();
763        service
764            .write_file(entity_id, DiskType::Private, "/dir/file2.txt", b"world")
765            .await
766            .unwrap();
767
768        // Get stats
769        let stats = service
770            .get_stats(entity_id, DiskType::Private)
771            .await
772            .unwrap();
773
774        assert_eq!(stats.file_count, 2);
775        assert_eq!(stats.used_bytes, 10); // "hello" + "world"
776        assert_eq!(stats.dir_count, 1); // "dir"
777    }
778
779    #[tokio::test]
780    async fn test_create_directory() {
781        let temp = tempdir().unwrap();
782        let service = EntityDiskService::new(temp.path()).await.unwrap();
783
784        let entity_id = "test-entity-one-two";
785
786        // Create directory
787        let info = service
788            .create_directory(entity_id, DiskType::Public, "/my-folder")
789            .await
790            .unwrap();
791
792        assert_eq!(info.name, "my-folder");
793        assert!(info.is_directory);
794
795        // Verify it exists
796        assert!(
797            service
798                .file_exists(entity_id, DiskType::Public, "/my-folder")
799                .await
800        );
801    }
802
803    #[tokio::test]
804    async fn test_disk_types() {
805        let temp = tempdir().unwrap();
806        let service = EntityDiskService::new(temp.path()).await.unwrap();
807
808        let entity_id = "test-entity-one-two";
809
810        // Write to each disk type
811        service
812            .write_file(entity_id, DiskType::Private, "/private.txt", b"private")
813            .await
814            .unwrap();
815        service
816            .write_file(entity_id, DiskType::Public, "/public.txt", b"public")
817            .await
818            .unwrap();
819        service
820            .write_file(entity_id, DiskType::Shared, "/shared.txt", b"shared")
821            .await
822            .unwrap();
823
824        // Verify isolation
825        assert!(
826            service
827                .file_exists(entity_id, DiskType::Private, "/private.txt")
828                .await
829        );
830        assert!(
831            !service
832                .file_exists(entity_id, DiskType::Public, "/private.txt")
833                .await
834        );
835        assert!(
836            !service
837                .file_exists(entity_id, DiskType::Shared, "/private.txt")
838                .await
839        );
840    }
841}