saorsa_core/
virtual_disk.rs

1// Copyright 2024 Saorsa Labs Limited
2//
3// This software is dual-licensed under:
4// - GNU Affero General Public License v3.0 or later (AGPL-3.0-or-later)
5// - Commercial License
6//
7// For AGPL-3.0 license, see LICENSE-AGPL-3.0
8// For commercial licensing, contact: saorsalabs@gmail.com
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under these licenses is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
14//! Virtual Disk API implementation for encrypted file storage
15
16// TODO: Update to use new clean API
17// use crate::api::{ContainerManifestV1, FecParams, container_manifest_put};
18
19// Temporary stubs
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct ContainerManifestV1 {
22    pub v: u8,
23    pub object: Key,
24    pub fec: Option<FecParams>,
25    pub assets: Vec<Key>,
26    pub sealed_meta: Option<Key>,
27}
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct FecParams {
30    pub k: usize,
31    pub m: usize,
32    pub shard_size: usize,
33}
34#[allow(dead_code)]
35async fn container_manifest_put(_: &ContainerManifestV1, _: &FecParams, _: &PutPolicy) -> Result<Key> {
36    unimplemented!("Virtual disk not yet migrated")
37}
38#[allow(dead_code)]
39async fn container_manifest_fetch(_: &[u8]) -> Result<ContainerManifestV1> {
40    unimplemented!("Virtual disk not yet migrated")
41}
42
43use crate::dht::PutPolicy;
44use crate::fwid::Key;
45use anyhow::Result;
46use chrono::{DateTime, Utc};
47use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49use std::path::PathBuf;
50use std::sync::Arc;
51use tokio::sync::RwLock;
52
53/// Disk type for virtual disk instances
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
55pub enum DiskType {
56    /// Private encrypted disk for user data
57    Private,
58    /// Public disk for website/content hosting
59    Public,
60    /// Shared disk with access control
61    Shared,
62}
63
64/// Configuration for virtual disk
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct DiskConfig {
67    /// Maximum disk size in bytes
68    pub max_size: u64,
69    /// Encryption enabled
70    pub encrypted: bool,
71    /// FEC parameters for redundancy
72    pub fec: FecParams,
73    /// Auto-sync interval in seconds
74    pub auto_sync_interval: Option<u64>,
75}
76
77impl Default for DiskConfig {
78    fn default() -> Self {
79        Self {
80            max_size: 1_073_741_824, // 1GB default
81            encrypted: true,
82            fec: FecParams {
83                k: 4,              // 4 data shards
84                m: 2,              // 2 parity shards
85                shard_size: 65536, // 64KB shards
86            },
87            auto_sync_interval: Some(300), // 5 minutes
88        }
89    }
90}
91
92/// Handle to an active virtual disk
93#[derive(Debug, Clone)]
94pub struct DiskHandle {
95    /// Entity ID owning this disk
96    pub entity_id: Key,
97    /// Disk type
98    pub disk_type: DiskType,
99    /// Disk configuration
100    pub config: DiskConfig,
101    /// Root manifest key
102    pub root_manifest: Key,
103    /// Internal state
104    state: Arc<RwLock<DiskState>>,
105}
106
107/// Internal disk state
108#[derive(Debug)]
109struct DiskState {
110    /// File system tree
111    files: HashMap<PathBuf, FileEntry>,
112    /// Total used space
113    used_space: u64,
114    /// Last sync time
115    last_sync: DateTime<Utc>,
116    /// Dirty flag for pending changes
117    dirty: bool,
118}
119
120/// File entry in virtual disk
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct FileEntry {
123    /// File path
124    pub path: PathBuf,
125    /// File size in bytes
126    pub size: u64,
127    /// Content hash
128    pub content_hash: Key,
129    /// File metadata
130    pub metadata: FileMetadata,
131    /// Creation time
132    pub created_at: DateTime<Utc>,
133    /// Modification time
134    pub modified_at: DateTime<Utc>,
135    /// Is directory
136    pub is_directory: bool,
137}
138
139/// File metadata
140#[derive(Debug, Clone, Serialize, Deserialize)]
141pub struct FileMetadata {
142    /// MIME type
143    pub mime_type: Option<String>,
144    /// Custom attributes
145    pub attributes: HashMap<String, String>,
146    /// Permissions (Unix-style)
147    pub permissions: u32,
148}
149
150impl Default for FileMetadata {
151    fn default() -> Self {
152        Self {
153            mime_type: None,
154            attributes: HashMap::new(),
155            permissions: 0o644,
156        }
157    }
158}
159
160/// Write receipt for disk operations
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct WriteReceipt {
163    /// Written file path
164    pub path: PathBuf,
165    /// Content hash
166    pub content_hash: Key,
167    /// Bytes written
168    pub bytes_written: u64,
169    /// Timestamp
170    pub timestamp: DateTime<Utc>,
171}
172
173/// Sync status for disk synchronization
174#[derive(Debug, Clone, Serialize, Deserialize)]
175pub struct SyncStatus {
176    /// Files synced
177    pub files_synced: usize,
178    /// Bytes synced
179    pub bytes_synced: u64,
180    /// Sync timestamp
181    pub timestamp: DateTime<Utc>,
182    /// Any errors encountered
183    pub errors: Vec<String>,
184}
185
186/// Asset for website publishing
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct Asset {
189    /// Asset path (relative)
190    pub path: String,
191    /// Asset content
192    pub content: Vec<u8>,
193    /// MIME type
194    pub mime_type: String,
195}
196
197/// Publish receipt for website publishing
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct PublishReceipt {
200    /// Entity ID
201    pub entity_id: Key,
202    /// Website root key
203    pub website_root: Key,
204    /// Manifest key
205    pub manifest_key: Key,
206    /// Timestamp
207    pub timestamp: DateTime<Utc>,
208}
209
210/// Create a new virtual disk
211pub async fn disk_create(
212    entity_id: Key,
213    disk_type: DiskType,
214    config: DiskConfig,
215) -> Result<DiskHandle> {
216    // Generate root manifest key
217    let mut hash_input = Vec::new();
218    hash_input.extend_from_slice(entity_id.as_bytes());
219    hash_input.push(disk_type as u8);
220    let root_manifest = Key::from(*blake3::hash(&hash_input).as_bytes());
221
222    // Initialize disk state
223    let state = Arc::new(RwLock::new(DiskState {
224        files: HashMap::new(),
225        used_space: 0,
226        last_sync: Utc::now(),
227        dirty: false,
228    }));
229
230    // Create initial manifest
231    let manifest = ContainerManifestV1 {
232        v: 1,
233        object: root_manifest.clone(),
234        fec: Some(config.fec.clone()),
235        assets: Vec::new(),
236        sealed_meta: if config.encrypted {
237            Some(Key::from([0u8; 32])) // Placeholder for sealed metadata
238        } else {
239            None
240        },
241    };
242
243    // Store manifest in DHT
244    #[cfg(any(test, feature = "test-utils"))]
245    {
246        use crate::mock_dht::mock_ops;
247        mock_ops::container_manifest_put(
248            &manifest,
249            &PutPolicy {
250                quorum: 3,
251                ttl: None,
252                // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
253            },
254        )
255        .await?;
256    }
257
258    #[cfg(not(any(test, feature = "test-utils")))]
259    {
260        let fec_params = manifest.fec.as_ref().unwrap_or(&config.fec);
261        container_manifest_put(
262            &manifest,
263            fec_params,
264            &PutPolicy {
265                quorum: 3,
266                ttl: None,
267                // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
268            },
269        )
270        .await?;
271    }
272
273    Ok(DiskHandle {
274        entity_id,
275        disk_type,
276        config,
277        root_manifest,
278        state,
279    })
280}
281
282/// Mount an existing virtual disk
283pub async fn disk_mount(entity_id: Key, disk_type: DiskType) -> Result<DiskHandle> {
284    // Derive root manifest key
285    let mut hash_input = Vec::new();
286    hash_input.extend_from_slice(entity_id.as_bytes());
287    hash_input.push(disk_type as u8);
288    let root_manifest = Key::from(*blake3::hash(&hash_input).as_bytes());
289
290    // Fetch manifest from DHT
291    #[cfg(any(test, feature = "test-utils"))]
292    let _manifest =
293        crate::mock_dht::mock_ops::container_manifest_fetch(root_manifest.as_bytes()).await?;
294
295    #[cfg(not(any(test, feature = "test-utils")))]
296    let _manifest = container_manifest_fetch(root_manifest.as_bytes()).await?;
297
298    // Load disk state from manifest
299    // TODO: Deserialize file tree from manifest assets
300    let state = Arc::new(RwLock::new(DiskState {
301        files: HashMap::new(),
302        used_space: 0,
303        last_sync: Utc::now(),
304        dirty: false,
305    }));
306
307    Ok(DiskHandle {
308        entity_id,
309        disk_type,
310        config: DiskConfig::default(),
311        root_manifest,
312        state,
313    })
314}
315
316/// Write a file to the virtual disk
317pub async fn disk_write(
318    handle: &DiskHandle,
319    path: &str,
320    content: &[u8],
321    metadata: FileMetadata,
322) -> Result<WriteReceipt> {
323    let path_buf = PathBuf::from(path);
324
325    // Check disk space
326    let mut state = handle.state.write().await;
327    if state.used_space + content.len() as u64 > handle.config.max_size {
328        anyhow::bail!("Disk space exceeded");
329    }
330
331    // Hash content
332    let content_hash = Key::from(*blake3::hash(content).as_bytes());
333
334    // Apply FEC encoding / encryption (handled in build-specific branches below)
335    // TODO: Use saorsa-fec for forward error correction
336
337    // Store in DHT
338    #[cfg(any(test, feature = "test-utils"))]
339    {
340        // Encrypt if needed
341        let stored_content = if handle.config.encrypted {
342            // TODO: Use saorsa-seal for encryption
343            content.to_vec()
344        } else {
345            content.to_vec()
346        };
347        let pol = PutPolicy {
348            quorum: 3,
349            ttl: None,
350            // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
351        };
352        crate::mock_dht::mock_ops::dht_put(
353            content_hash.clone(),
354            bytes::Bytes::from(stored_content),
355            &pol,
356        )
357        .await?;
358    }
359
360    #[cfg(not(any(test, feature = "test-utils")))]
361    {
362        let _pol = PutPolicy {
363            quorum: 3,
364            ttl: None,
365            // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
366        };
367        // TODO: Update to use new clean API
368        // crate::api::dht_put(
369        //     content_hash.clone(),
370        //     bytes::Bytes::from(stored_content),
371        //     &pol,
372        // )
373        // .await?;
374    }
375
376    // Update file tree
377    let now = Utc::now();
378    let entry = FileEntry {
379        path: path_buf.clone(),
380        size: content.len() as u64,
381        content_hash: content_hash.clone(),
382        metadata,
383        created_at: state
384            .files
385            .get(&path_buf)
386            .map(|e| e.created_at)
387            .unwrap_or(now),
388        modified_at: now,
389        is_directory: false,
390    };
391
392    // Update state
393    if let Some(old_entry) = state.files.insert(path_buf.clone(), entry) {
394        state.used_space -= old_entry.size;
395    }
396    state.used_space += content.len() as u64;
397    state.dirty = true;
398
399    Ok(WriteReceipt {
400        path: path_buf,
401        content_hash,
402        bytes_written: content.len() as u64,
403        timestamp: now,
404    })
405}
406
407/// Read a file from the virtual disk
408pub async fn disk_read(handle: &DiskHandle, path: &str) -> Result<Vec<u8>> {
409    let path_buf = PathBuf::from(path);
410
411    // Find file in tree
412    let state = handle.state.read().await;
413    let entry = state
414        .files
415        .get(&path_buf)
416        .ok_or_else(|| anyhow::anyhow!("File not found: {}", path))?;
417
418    if entry.is_directory {
419        anyhow::bail!("Path is a directory: {}", path);
420    }
421
422    // Fetch from DHT
423    #[cfg(any(test, feature = "test-utils"))]
424    let content = crate::mock_dht::mock_ops::dht_get(entry.content_hash.clone(), 1).await?;
425
426    #[cfg(not(any(test, feature = "test-utils")))]
427    // TODO: Update to use new clean API
428    // let content = crate::api::dht_get(entry.content_hash.clone(), 1).await?;
429    let content = bytes::Bytes::from(vec![]);
430
431    // Decrypt if needed
432    let decrypted = if handle.config.encrypted {
433        // TODO: Use saorsa-seal for decryption
434        content.to_vec()
435    } else {
436        content.to_vec()
437    };
438
439    Ok(decrypted)
440}
441
442/// List files in a directory
443pub async fn disk_list(handle: &DiskHandle, path: &str, recursive: bool) -> Result<Vec<FileEntry>> {
444    let search_path = if path == "." {
445        PathBuf::new() // Use empty path for current directory
446    } else {
447        PathBuf::from(path)
448    };
449    let state = handle.state.read().await;
450
451    let mut results = Vec::new();
452
453    for (file_path, entry) in &state.files {
454        // Check if file is in the requested directory
455        if recursive {
456            // Include if path is ancestor or if we're listing from root
457            if search_path.as_os_str().is_empty() || file_path.starts_with(&search_path) {
458                results.push(entry.clone());
459            }
460        } else {
461            // Include only direct children
462            if let Some(parent) = file_path.parent() {
463                if parent == search_path {
464                    results.push(entry.clone());
465                }
466            } else if search_path.as_os_str().is_empty() {
467                // File has no parent (is in root), and we're searching root
468                results.push(entry.clone());
469            }
470        }
471    }
472
473    // Sort by path
474    results.sort_by(|a, b| a.path.cmp(&b.path));
475
476    Ok(results)
477}
478
479/// Delete a file from the virtual disk
480pub async fn disk_delete(handle: &DiskHandle, path: &str) -> Result<()> {
481    let path_buf = PathBuf::from(path);
482
483    let mut state = handle.state.write().await;
484
485    // Remove file from tree
486    if let Some(entry) = state.files.remove(&path_buf) {
487        state.used_space -= entry.size;
488        state.dirty = true;
489        Ok(())
490    } else {
491        Err(anyhow::anyhow!("File not found: {}", path))
492    }
493}
494
495/// Synchronize disk state to DHT
496pub async fn disk_sync(handle: &DiskHandle) -> Result<SyncStatus> {
497    let mut state = handle.state.write().await;
498
499    if !state.dirty {
500        return Ok(SyncStatus {
501            files_synced: 0,
502            bytes_synced: 0,
503            timestamp: Utc::now(),
504            errors: Vec::new(),
505        });
506    }
507
508    // Serialize file tree
509    let file_list: Vec<FileEntry> = state.files.values().cloned().collect();
510    let tree_bytes = serde_cbor::to_vec(&file_list)?;
511    let tree_hash = Key::from(*blake3::hash(&tree_bytes).as_bytes());
512
513    // Store tree in DHT
514    #[cfg(any(test, feature = "test-utils"))]
515    {
516        let pol = PutPolicy {
517            quorum: 3,
518            ttl: None,
519            // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
520        };
521        crate::mock_dht::mock_ops::dht_put(tree_hash.clone(), bytes::Bytes::from(tree_bytes), &pol)
522            .await?;
523
524        // Update manifest
525        let manifest = ContainerManifestV1 {
526            v: 1,
527            object: handle.root_manifest.clone(),
528            fec: Some(handle.config.fec.clone()),
529            assets: vec![tree_hash],
530            sealed_meta: if handle.config.encrypted {
531                Some(Key::from([0u8; 32])) // Placeholder
532            } else {
533                None
534            },
535        };
536
537        crate::mock_dht::mock_ops::container_manifest_put(&manifest, &pol).await?;
538    }
539
540    #[cfg(not(any(test, feature = "test-utils")))]
541    {
542        let pol = PutPolicy {
543            quorum: 3,
544            ttl: None,
545            // auth: Box::new(crate::auth::DelegatedWriteAuth::new(vec![])),
546        };
547        // TODO: Update to use new clean API
548        // crate::api::dht_put(tree_hash.clone(), bytes::Bytes::from(tree_bytes), &pol).await?;
549
550        // Update manifest
551        let manifest = ContainerManifestV1 {
552            v: 1,
553            object: handle.root_manifest.clone(),
554            fec: Some(handle.config.fec.clone()),
555            assets: vec![tree_hash],
556            sealed_meta: if handle.config.encrypted {
557                Some(Key::from([0u8; 32])) // Placeholder
558            } else {
559                None
560            },
561        };
562
563        let fec_params = manifest.fec.as_ref().unwrap_or(&handle.config.fec);
564        container_manifest_put(&manifest, fec_params, &pol).await?;
565    }
566
567    // Update state
568    state.dirty = false;
569    state.last_sync = Utc::now();
570
571    Ok(SyncStatus {
572        files_synced: state.files.len(),
573        bytes_synced: state.used_space,
574        timestamp: state.last_sync,
575        errors: Vec::new(),
576    })
577}
578
579/// Set the home page for a website
580pub async fn website_set_home(
581    handle: &DiskHandle,
582    markdown_content: &str,
583    assets: Vec<Asset>,
584) -> Result<()> {
585    // Write home.md
586    disk_write(
587        handle,
588        "home.md",
589        markdown_content.as_bytes(),
590        FileMetadata {
591            mime_type: Some("text/markdown".to_string()),
592            ..Default::default()
593        },
594    )
595    .await?;
596
597    // Write assets
598    for asset in assets {
599        disk_write(
600            handle,
601            &asset.path,
602            &asset.content,
603            FileMetadata {
604                mime_type: Some(asset.mime_type),
605                ..Default::default()
606            },
607        )
608        .await?;
609    }
610
611    // Sync to ensure persistence
612    disk_sync(handle).await?;
613
614    Ok(())
615}
616
617/// Publish a website from a disk
618pub async fn website_publish(entity_id: Key, website_root: Key) -> Result<PublishReceipt> {
619    // Create public disk if not exists
620    let handle = match disk_mount(entity_id.clone(), DiskType::Public).await {
621        Ok(h) => h,
622        Err(_) => disk_create(entity_id.clone(), DiskType::Public, DiskConfig::default()).await?,
623    };
624
625    // Sync disk to ensure latest state
626    disk_sync(&handle).await?;
627
628    // Optionally update identity with website root
629    // Note: This requires the caller to have signing material
630    // identity_set_website_root(entity_id.clone(), website_root.clone(), sig).await?;
631
632    Ok(PublishReceipt {
633        entity_id,
634        website_root,
635        manifest_key: handle.root_manifest,
636        timestamp: Utc::now(),
637    })
638}