Skip to main content

logicaffeine_system/fs/
mod.rs

1//! Virtual File System Abstraction
2//!
3//! Provides platform-agnostic async file operations through the [`Vfs`] trait.
4//! This enables the same code to work on native platforms and in the browser.
5//!
6//! # Platform Implementations
7//!
8//! - **Native** ([`NativeVfs`]): Uses `tokio::fs` with atomic write operations
9//! - **WASM** (`OpfsVfs`): Uses the browser's Origin Private File System API
10//!
11//! # Features
12//!
13//! Requires the `persistence` feature.
14//!
15//! # Example
16//!
17//! ```no_run
18//! use logicaffeine_system::fs::{Vfs, NativeVfs};
19//! use std::sync::Arc;
20//!
21//! # fn main() {}
22//! # async fn example() -> Result<(), Box<dyn std::error::Error>> {
23//! let vfs: Arc<dyn Vfs + Send + Sync> = Arc::new(NativeVfs::new("/data"));
24//!
25//! // Write and read files
26//! vfs.write("config.json", b"{}").await?;
27//! let data = vfs.read("config.json").await?;
28//!
29//! // Atomic append for journaling
30//! vfs.append("log.txt", b"entry\n").await?;
31//! # Ok(())
32//! # }
33//! ```
34
35#[cfg(all(target_os = "linux", feature = "io-uring"))]
36mod uring;
37#[cfg(all(target_os = "linux", feature = "io-uring"))]
38pub(crate) mod uring_worker;
39
40#[cfg(all(target_os = "linux", feature = "io-uring"))]
41pub use uring::UringVfs;
42
43#[cfg(target_arch = "wasm32")]
44mod opfs;
45
46#[cfg(target_arch = "wasm32")]
47mod worker_opfs;
48
49#[cfg(target_arch = "wasm32")]
50mod indexeddb_vfs;
51
52#[cfg(target_arch = "wasm32")]
53pub use opfs::OpfsVfs;
54
55#[cfg(target_arch = "wasm32")]
56pub use worker_opfs::WorkerOpfsVfs;
57
58#[cfg(target_arch = "wasm32")]
59pub use indexeddb_vfs::IndexedDbVfs;
60
61use async_trait::async_trait;
62use std::io;
63
64#[cfg(not(target_arch = "wasm32"))]
65use std::path::PathBuf;
66
67/// Error type for VFS operations
68#[derive(Debug)]
69pub enum VfsError {
70    NotFound(String),
71    PermissionDenied(String),
72    AlreadyExists(String),
73    IoError(io::Error),
74    SerializationError(String),
75    JournalCorrupted(String),
76}
77
78impl std::fmt::Display for VfsError {
79    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
80        match self {
81            VfsError::NotFound(s) => write!(f, "Not found: {}", s),
82            VfsError::PermissionDenied(s) => write!(f, "Permission denied: {}", s),
83            VfsError::AlreadyExists(s) => write!(f, "Already exists: {}", s),
84            VfsError::IoError(e) => write!(f, "IO error: {}", e),
85            VfsError::SerializationError(s) => write!(f, "Serialization error: {}", s),
86            VfsError::JournalCorrupted(s) => write!(f, "Journal corrupted: {}", s),
87        }
88    }
89}
90
91impl std::error::Error for VfsError {}
92
93impl From<io::Error> for VfsError {
94    fn from(e: io::Error) -> Self {
95        match e.kind() {
96            io::ErrorKind::NotFound => VfsError::NotFound(e.to_string()),
97            io::ErrorKind::PermissionDenied => VfsError::PermissionDenied(e.to_string()),
98            io::ErrorKind::AlreadyExists => VfsError::AlreadyExists(e.to_string()),
99            _ => VfsError::IoError(e),
100        }
101    }
102}
103
104pub type VfsResult<T> = Result<T, VfsError>;
105
106/// A directory entry returned by `list_dir`.
107#[derive(Debug, Clone, PartialEq, Eq)]
108pub struct DirEntry {
109    /// Name of the file or directory (not full path).
110    pub name: String,
111    /// True if this entry is a directory.
112    pub is_directory: bool,
113}
114
115/// Virtual File System trait for platform-agnostic file operations.
116///
117/// On native platforms, requires Send+Sync for thread-safe access.
118/// On WASM, these bounds are relaxed since JS is single-threaded.
119#[cfg(not(target_arch = "wasm32"))]
120#[async_trait]
121pub trait Vfs: Send + Sync {
122    /// Read entire file contents as bytes.
123    async fn read(&self, path: &str) -> VfsResult<Vec<u8>>;
124
125    /// Read file contents as UTF-8 string.
126    async fn read_to_string(&self, path: &str) -> VfsResult<String>;
127
128    /// Write bytes to file (atomic on native, best-effort on WASM).
129    async fn write(&self, path: &str, contents: &[u8]) -> VfsResult<()>;
130
131    /// Append bytes to file (atomic append semantics).
132    async fn append(&self, path: &str, contents: &[u8]) -> VfsResult<()>;
133
134    /// Check if file exists.
135    async fn exists(&self, path: &str) -> VfsResult<bool>;
136
137    /// Delete a file.
138    async fn remove(&self, path: &str) -> VfsResult<()>;
139
140    /// Create directory and all parent directories.
141    async fn create_dir_all(&self, path: &str) -> VfsResult<()>;
142
143    /// Atomically rename a file (for journal compaction).
144    async fn rename(&self, from: &str, to: &str) -> VfsResult<()>;
145
146    /// List entries in a directory.
147    async fn list_dir(&self, path: &str) -> VfsResult<Vec<DirEntry>>;
148}
149
150/// WASM version of VFS trait without Send+Sync (JS is single-threaded).
151#[cfg(target_arch = "wasm32")]
152#[async_trait(?Send)]
153pub trait Vfs {
154    /// Read entire file contents as bytes.
155    async fn read(&self, path: &str) -> VfsResult<Vec<u8>>;
156
157    /// Read file contents as UTF-8 string.
158    async fn read_to_string(&self, path: &str) -> VfsResult<String>;
159
160    /// Write bytes to file (atomic on native, best-effort on WASM).
161    async fn write(&self, path: &str, contents: &[u8]) -> VfsResult<()>;
162
163    /// Append bytes to file (atomic append semantics).
164    async fn append(&self, path: &str, contents: &[u8]) -> VfsResult<()>;
165
166    /// Check if file exists.
167    async fn exists(&self, path: &str) -> VfsResult<bool>;
168
169    /// Delete a file.
170    async fn remove(&self, path: &str) -> VfsResult<()>;
171
172    /// Create directory and all parent directories.
173    async fn create_dir_all(&self, path: &str) -> VfsResult<()>;
174
175    /// Atomically rename a file (for journal compaction).
176    async fn rename(&self, from: &str, to: &str) -> VfsResult<()>;
177
178    /// List entries in a directory.
179    async fn list_dir(&self, path: &str) -> VfsResult<Vec<DirEntry>>;
180}
181
182/// Native filesystem VFS using tokio::fs.
183#[cfg(not(target_arch = "wasm32"))]
184pub struct NativeVfs {
185    /// Base directory for all operations (sandbox root).
186    base_dir: PathBuf,
187}
188
189#[cfg(not(target_arch = "wasm32"))]
190impl NativeVfs {
191    /// Create a new NativeVfs rooted at the given directory.
192    pub fn new<P: Into<PathBuf>>(base_dir: P) -> Self {
193        Self {
194            base_dir: base_dir.into(),
195        }
196    }
197
198    /// Resolve a virtual path to an absolute filesystem path.
199    fn resolve(&self, path: &str) -> PathBuf {
200        // Security: Prevent path traversal attacks
201        let clean = path.trim_start_matches('/').trim_start_matches("../");
202        self.base_dir.join(clean)
203    }
204}
205
206#[cfg(not(target_arch = "wasm32"))]
207#[async_trait]
208impl Vfs for NativeVfs {
209    async fn read(&self, path: &str) -> VfsResult<Vec<u8>> {
210        let full_path = self.resolve(path);
211        tokio::fs::read(&full_path).await.map_err(VfsError::from)
212    }
213
214    async fn read_to_string(&self, path: &str) -> VfsResult<String> {
215        let full_path = self.resolve(path);
216        tokio::fs::read_to_string(&full_path).await.map_err(VfsError::from)
217    }
218
219    async fn write(&self, path: &str, contents: &[u8]) -> VfsResult<()> {
220        let full_path = self.resolve(path);
221
222        // Ensure parent directory exists
223        if let Some(parent) = full_path.parent() {
224            tokio::fs::create_dir_all(parent).await?;
225        }
226
227        // Atomic write: write to temp file, then rename
228        let temp_path = full_path.with_extension("tmp");
229        tokio::fs::write(&temp_path, contents).await?;
230        tokio::fs::rename(&temp_path, &full_path).await?;
231
232        Ok(())
233    }
234
235    async fn append(&self, path: &str, contents: &[u8]) -> VfsResult<()> {
236        use tokio::io::AsyncWriteExt;
237
238        let full_path = self.resolve(path);
239
240        // Ensure parent directory exists
241        if let Some(parent) = full_path.parent() {
242            tokio::fs::create_dir_all(parent).await?;
243        }
244
245        let mut file = tokio::fs::OpenOptions::new()
246            .create(true)
247            .append(true)
248            .open(&full_path)
249            .await?;
250
251        file.write_all(contents).await?;
252        file.sync_all().await?;
253
254        Ok(())
255    }
256
257    async fn exists(&self, path: &str) -> VfsResult<bool> {
258        let full_path = self.resolve(path);
259        Ok(full_path.exists())
260    }
261
262    async fn remove(&self, path: &str) -> VfsResult<()> {
263        let full_path = self.resolve(path);
264        tokio::fs::remove_file(&full_path).await.map_err(VfsError::from)
265    }
266
267    async fn create_dir_all(&self, path: &str) -> VfsResult<()> {
268        let full_path = self.resolve(path);
269        tokio::fs::create_dir_all(&full_path).await.map_err(VfsError::from)
270    }
271
272    async fn rename(&self, from: &str, to: &str) -> VfsResult<()> {
273        let from_path = self.resolve(from);
274        let to_path = self.resolve(to);
275        tokio::fs::rename(&from_path, &to_path).await.map_err(VfsError::from)
276    }
277
278    async fn list_dir(&self, path: &str) -> VfsResult<Vec<DirEntry>> {
279        let full_path = self.resolve(path);
280        let mut entries = Vec::new();
281        let mut read_dir = tokio::fs::read_dir(&full_path).await.map_err(VfsError::from)?;
282
283        while let Some(entry) = read_dir.next_entry().await.map_err(VfsError::from)? {
284            let metadata = entry.metadata().await.map_err(VfsError::from)?;
285            entries.push(DirEntry {
286                name: entry.file_name().to_string_lossy().into_owned(),
287                is_directory: metadata.is_dir(),
288            });
289        }
290
291        // Sort entries: directories first, then alphabetically
292        entries.sort_by(|a, b| {
293            match (a.is_directory, b.is_directory) {
294                (true, false) => std::cmp::Ordering::Less,
295                (false, true) => std::cmp::Ordering::Greater,
296                _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
297            }
298        });
299
300        Ok(entries)
301    }
302}
303
304/// Type alias for platform-specific VFS.
305#[cfg(not(target_arch = "wasm32"))]
306pub type PlatformVfs = NativeVfs;
307
308#[cfg(target_arch = "wasm32")]
309pub type PlatformVfs = WorkerOpfsVfs;
310
311/// Get the platform-default VFS instance.
312///
313/// - Linux with `io-uring` feature: Tries `UringVfs` first for kernel-async I/O,
314///   falls back to `NativeVfs` if io_uring is unavailable (old kernel, container).
315/// - Other native: Returns `NativeVfs` (tokio::fs)
316/// - WASM: Returns `WorkerOpfsVfs` backed by a Web Worker
317#[cfg(not(target_arch = "wasm32"))]
318pub fn get_platform_vfs() -> Box<dyn Vfs + Send + Sync> {
319    #[cfg(all(target_os = "linux", feature = "io-uring"))]
320    {
321        match UringVfs::new(".") {
322            Ok(vfs) => return Box::new(vfs),
323            Err(_) => {} // Fall through to NativeVfs
324        }
325    }
326    Box::new(NativeVfs::new("."))
327}
328
329#[cfg(target_arch = "wasm32")]
330pub fn get_platform_vfs() -> VfsResult<WorkerOpfsVfs> {
331    WorkerOpfsVfs::new()
332}
333
334/// Enum wrapping both OPFS and IndexedDB VFS implementations.
335///
336/// Used for transparent fallback when OPFS is unavailable (e.g., Private Browsing).
337#[cfg(target_arch = "wasm32")]
338#[derive(Clone)]
339pub enum WebVfs {
340    /// Primary: OPFS via Web Worker (best performance, largest quota)
341    Opfs(WorkerOpfsVfs),
342    /// Fallback: IndexedDB (works in Private Browsing, session-scoped)
343    IndexedDb(IndexedDbVfs),
344}
345
346#[cfg(target_arch = "wasm32")]
347impl WebVfs {
348    /// Returns true if using the IndexedDB fallback.
349    pub fn is_fallback(&self) -> bool {
350        matches!(self, WebVfs::IndexedDb(_))
351    }
352
353    /// Returns a human-readable name for the current storage backend.
354    pub fn backend_name(&self) -> &'static str {
355        match self {
356            WebVfs::Opfs(_) => "OPFS",
357            WebVfs::IndexedDb(_) => "IndexedDB",
358        }
359    }
360}
361
362#[cfg(target_arch = "wasm32")]
363#[async_trait(?Send)]
364impl Vfs for WebVfs {
365    async fn read(&self, path: &str) -> VfsResult<Vec<u8>> {
366        match self {
367            WebVfs::Opfs(vfs) => vfs.read(path).await,
368            WebVfs::IndexedDb(vfs) => vfs.read(path).await,
369        }
370    }
371
372    async fn read_to_string(&self, path: &str) -> VfsResult<String> {
373        match self {
374            WebVfs::Opfs(vfs) => vfs.read_to_string(path).await,
375            WebVfs::IndexedDb(vfs) => vfs.read_to_string(path).await,
376        }
377    }
378
379    async fn write(&self, path: &str, contents: &[u8]) -> VfsResult<()> {
380        match self {
381            WebVfs::Opfs(vfs) => vfs.write(path, contents).await,
382            WebVfs::IndexedDb(vfs) => vfs.write(path, contents).await,
383        }
384    }
385
386    async fn append(&self, path: &str, contents: &[u8]) -> VfsResult<()> {
387        match self {
388            WebVfs::Opfs(vfs) => vfs.append(path, contents).await,
389            WebVfs::IndexedDb(vfs) => vfs.append(path, contents).await,
390        }
391    }
392
393    async fn exists(&self, path: &str) -> VfsResult<bool> {
394        match self {
395            WebVfs::Opfs(vfs) => vfs.exists(path).await,
396            WebVfs::IndexedDb(vfs) => vfs.exists(path).await,
397        }
398    }
399
400    async fn remove(&self, path: &str) -> VfsResult<()> {
401        match self {
402            WebVfs::Opfs(vfs) => vfs.remove(path).await,
403            WebVfs::IndexedDb(vfs) => vfs.remove(path).await,
404        }
405    }
406
407    async fn create_dir_all(&self, path: &str) -> VfsResult<()> {
408        match self {
409            WebVfs::Opfs(vfs) => vfs.create_dir_all(path).await,
410            WebVfs::IndexedDb(vfs) => vfs.create_dir_all(path).await,
411        }
412    }
413
414    async fn rename(&self, from: &str, to: &str) -> VfsResult<()> {
415        match self {
416            WebVfs::Opfs(vfs) => vfs.rename(from, to).await,
417            WebVfs::IndexedDb(vfs) => vfs.rename(from, to).await,
418        }
419    }
420
421    async fn list_dir(&self, path: &str) -> VfsResult<Vec<DirEntry>> {
422        match self {
423            WebVfs::Opfs(vfs) => vfs.list_dir(path).await,
424            WebVfs::IndexedDb(vfs) => vfs.list_dir(path).await,
425        }
426    }
427}
428
429/// Get platform VFS with automatic fallback.
430///
431/// Tries OPFS first (best performance), falls back to IndexedDB if OPFS is
432/// unavailable (e.g., Private Browsing mode).
433///
434/// Returns `(WebVfs, is_fallback)` where `is_fallback` is true if using IndexedDB.
435#[cfg(target_arch = "wasm32")]
436pub async fn get_platform_vfs_with_fallback() -> VfsResult<WebVfs> {
437    use wasm_bindgen::prelude::*;
438
439    #[wasm_bindgen]
440    extern "C" {
441        #[wasm_bindgen(js_namespace = console)]
442        fn log(s: &str);
443    }
444
445    log("[VFS] Attempting OPFS initialization...");
446
447    // Try OPFS first
448    match WorkerOpfsVfs::new() {
449        Ok(opfs) => {
450            // Test if OPFS actually works by trying to create a directory
451            // This catches Private Browsing mode where OPFS creation succeeds
452            // but operations fail
453            match opfs.create_dir_all("/").await {
454                Ok(_) => {
455                    log("[VFS] OPFS initialized successfully");
456                    return Ok(WebVfs::Opfs(opfs));
457                }
458                Err(e) => {
459                    log(&format!("[VFS] OPFS test failed: {:?}, trying IndexedDB...", e));
460                }
461            }
462        }
463        Err(e) => {
464            log(&format!("[VFS] OPFS creation failed: {:?}, trying IndexedDB...", e));
465        }
466    }
467
468    // Fall back to IndexedDB
469    log("[VFS] Falling back to IndexedDB...");
470    match IndexedDbVfs::new().await {
471        Ok(idb) => {
472            log("[VFS] IndexedDB initialized successfully");
473            Ok(WebVfs::IndexedDb(idb))
474        }
475        Err(e) => {
476            log(&format!("[VFS] IndexedDB initialization failed: {:?}", e));
477            Err(e)
478        }
479    }
480}
481
482#[cfg(test)]
483mod tests {
484    use super::*;
485    use tempfile::TempDir;
486
487    #[tokio::test]
488    async fn test_native_vfs_read_write() {
489        let temp = TempDir::new().unwrap();
490        let vfs = NativeVfs::new(temp.path());
491
492        vfs.write("test.txt", b"hello world").await.unwrap();
493        let content = vfs.read_to_string("test.txt").await.unwrap();
494
495        assert_eq!(content, "hello world");
496    }
497
498    #[tokio::test]
499    async fn test_native_vfs_append() {
500        let temp = TempDir::new().unwrap();
501        let vfs = NativeVfs::new(temp.path());
502
503        vfs.append("log.txt", b"line1\n").await.unwrap();
504        vfs.append("log.txt", b"line2\n").await.unwrap();
505
506        let content = vfs.read_to_string("log.txt").await.unwrap();
507        assert_eq!(content, "line1\nline2\n");
508    }
509
510    #[tokio::test]
511    async fn test_native_vfs_nested_dirs() {
512        let temp = TempDir::new().unwrap();
513        let vfs = NativeVfs::new(temp.path());
514
515        vfs.write("a/b/c/file.txt", b"deep").await.unwrap();
516        let content = vfs.read_to_string("a/b/c/file.txt").await.unwrap();
517
518        assert_eq!(content, "deep");
519    }
520
521    #[tokio::test]
522    async fn test_native_vfs_list_dir() {
523        let temp = TempDir::new().unwrap();
524        let vfs = NativeVfs::new(temp.path());
525
526        // Create files and directories
527        vfs.write("file1.txt", b"content1").await.unwrap();
528        vfs.write("file2.txt", b"content2").await.unwrap();
529        vfs.write("subdir/nested.txt", b"nested").await.unwrap();
530
531        // List root directory
532        let entries = vfs.list_dir("").await.unwrap();
533
534        // Should have 3 entries: subdir (dir), file1.txt, file2.txt
535        assert_eq!(entries.len(), 3);
536
537        // Directory should come first
538        assert_eq!(entries[0].name, "subdir");
539        assert!(entries[0].is_directory);
540
541        // Files should be alphabetically sorted
542        assert_eq!(entries[1].name, "file1.txt");
543        assert!(!entries[1].is_directory);
544        assert_eq!(entries[2].name, "file2.txt");
545        assert!(!entries[2].is_directory);
546    }
547
548    // ─── NativeVfs: exists, remove, rename ───────────────────────────────
549
550    #[tokio::test]
551    async fn test_native_vfs_exists_returns_false_for_missing_file() {
552        let temp = TempDir::new().unwrap();
553        let vfs = NativeVfs::new(temp.path());
554
555        assert!(!vfs.exists("nope.txt").await.unwrap());
556    }
557
558    #[tokio::test]
559    async fn test_native_vfs_exists_returns_true_after_write() {
560        let temp = TempDir::new().unwrap();
561        let vfs = NativeVfs::new(temp.path());
562
563        vfs.write("yes.txt", b"data").await.unwrap();
564        assert!(vfs.exists("yes.txt").await.unwrap());
565    }
566
567    #[tokio::test]
568    async fn test_native_vfs_remove_deletes_file() {
569        let temp = TempDir::new().unwrap();
570        let vfs = NativeVfs::new(temp.path());
571
572        vfs.write("doomed.txt", b"bye").await.unwrap();
573        assert!(vfs.exists("doomed.txt").await.unwrap());
574
575        vfs.remove("doomed.txt").await.unwrap();
576        assert!(!vfs.exists("doomed.txt").await.unwrap());
577    }
578
579    #[tokio::test]
580    async fn test_native_vfs_remove_nonexistent_returns_error() {
581        let temp = TempDir::new().unwrap();
582        let vfs = NativeVfs::new(temp.path());
583
584        let result = vfs.remove("ghost.txt").await;
585        assert!(result.is_err());
586    }
587
588    #[tokio::test]
589    async fn test_native_vfs_rename_moves_file() {
590        let temp = TempDir::new().unwrap();
591        let vfs = NativeVfs::new(temp.path());
592
593        vfs.write("old.txt", b"content").await.unwrap();
594        vfs.rename("old.txt", "new.txt").await.unwrap();
595
596        assert!(!vfs.exists("old.txt").await.unwrap());
597        let content = vfs.read_to_string("new.txt").await.unwrap();
598        assert_eq!(content, "content");
599    }
600
601    #[tokio::test]
602    async fn test_native_vfs_create_dir_all() {
603        let temp = TempDir::new().unwrap();
604        let vfs = NativeVfs::new(temp.path());
605
606        vfs.create_dir_all("x/y/z").await.unwrap();
607        vfs.write("x/y/z/deep.txt", b"found").await.unwrap();
608        let content = vfs.read_to_string("x/y/z/deep.txt").await.unwrap();
609        assert_eq!(content, "found");
610    }
611
612    // ─── NativeVfs: atomic write semantics ───────────────────────────────
613
614    #[tokio::test]
615    async fn test_native_vfs_write_overwrites_existing() {
616        let temp = TempDir::new().unwrap();
617        let vfs = NativeVfs::new(temp.path());
618
619        vfs.write("over.txt", b"first").await.unwrap();
620        vfs.write("over.txt", b"second").await.unwrap();
621        let content = vfs.read_to_string("over.txt").await.unwrap();
622        assert_eq!(content, "second");
623    }
624
625    #[tokio::test]
626    async fn test_native_vfs_write_empty_file() {
627        let temp = TempDir::new().unwrap();
628        let vfs = NativeVfs::new(temp.path());
629
630        vfs.write("empty.txt", b"").await.unwrap();
631        let content = vfs.read("empty.txt").await.unwrap();
632        assert!(content.is_empty());
633    }
634
635    #[tokio::test]
636    async fn test_native_vfs_write_no_leftover_tmp() {
637        let temp = TempDir::new().unwrap();
638        let vfs = NativeVfs::new(temp.path());
639
640        vfs.write("clean.txt", b"data").await.unwrap();
641
642        // No .tmp file should remain after atomic write
643        assert!(!vfs.exists("clean.tmp").await.unwrap());
644    }
645
646    // ─── NativeVfs: read errors ──────────────────────────────────────────
647
648    #[tokio::test]
649    async fn test_native_vfs_read_nonexistent_returns_not_found() {
650        let temp = TempDir::new().unwrap();
651        let vfs = NativeVfs::new(temp.path());
652
653        let result = vfs.read("missing.txt").await;
654        assert!(matches!(result, Err(VfsError::NotFound(_))));
655    }
656
657    #[tokio::test]
658    async fn test_native_vfs_read_to_string_nonexistent_returns_not_found() {
659        let temp = TempDir::new().unwrap();
660        let vfs = NativeVfs::new(temp.path());
661
662        let result = vfs.read_to_string("missing.txt").await;
663        assert!(matches!(result, Err(VfsError::NotFound(_))));
664    }
665
666    // ─── NativeVfs: append edge cases ────────────────────────────────────
667
668    #[tokio::test]
669    async fn test_native_vfs_append_creates_file_if_missing() {
670        let temp = TempDir::new().unwrap();
671        let vfs = NativeVfs::new(temp.path());
672
673        vfs.append("new.txt", b"first").await.unwrap();
674        let content = vfs.read_to_string("new.txt").await.unwrap();
675        assert_eq!(content, "first");
676    }
677
678    #[tokio::test]
679    async fn test_native_vfs_append_multiple_sequential() {
680        let temp = TempDir::new().unwrap();
681        let vfs = NativeVfs::new(temp.path());
682
683        for i in 0..10 {
684            vfs.append("journal.log", format!("entry-{}\n", i).as_bytes())
685                .await
686                .unwrap();
687        }
688
689        let content = vfs.read_to_string("journal.log").await.unwrap();
690        let lines: Vec<&str> = content.lines().collect();
691        assert_eq!(lines.len(), 10);
692        assert_eq!(lines[0], "entry-0");
693        assert_eq!(lines[9], "entry-9");
694    }
695
696    // ─── NativeVfs: binary data ──────────────────────────────────────────
697
698    #[tokio::test]
699    async fn test_native_vfs_read_write_binary() {
700        let temp = TempDir::new().unwrap();
701        let vfs = NativeVfs::new(temp.path());
702
703        let binary: Vec<u8> = (0u8..=255).collect();
704        vfs.write("binary.bin", &binary).await.unwrap();
705        let read_back = vfs.read("binary.bin").await.unwrap();
706        assert_eq!(read_back, binary);
707    }
708
709    // ─── NativeVfs: path traversal prevention ────────────────────────────
710
711    #[tokio::test]
712    async fn test_native_vfs_resolve_strips_path_traversal() {
713        let temp = TempDir::new().unwrap();
714        let vfs = NativeVfs::new(temp.path());
715
716        // Path traversal attempts should be stripped
717        vfs.write("../../../etc/passwd", b"nope").await.unwrap();
718        // File should be created inside the base dir, not at /etc/passwd
719        assert!(vfs.exists("etc/passwd").await.unwrap());
720    }
721
722    #[tokio::test]
723    async fn test_native_vfs_resolve_strips_leading_slash() {
724        let temp = TempDir::new().unwrap();
725        let vfs = NativeVfs::new(temp.path());
726
727        vfs.write("/absolute.txt", b"data").await.unwrap();
728        assert!(vfs.exists("absolute.txt").await.unwrap());
729    }
730
731    // ─── NativeVfs: list_dir edge cases ──────────────────────────────────
732
733    #[tokio::test]
734    async fn test_native_vfs_list_dir_empty() {
735        let temp = TempDir::new().unwrap();
736        let vfs = NativeVfs::new(temp.path());
737
738        let entries = vfs.list_dir("").await.unwrap();
739        assert!(entries.is_empty());
740    }
741
742    #[tokio::test]
743    async fn test_native_vfs_list_dir_nonexistent_returns_error() {
744        let temp = TempDir::new().unwrap();
745        let vfs = NativeVfs::new(temp.path());
746
747        let result = vfs.list_dir("nonexistent").await;
748        assert!(result.is_err());
749    }
750
751    #[tokio::test]
752    async fn test_native_vfs_list_dir_case_insensitive_sort() {
753        let temp = TempDir::new().unwrap();
754        let vfs = NativeVfs::new(temp.path());
755
756        vfs.write("Zebra.txt", b"z").await.unwrap();
757        vfs.write("apple.txt", b"a").await.unwrap();
758        vfs.write("Banana.txt", b"b").await.unwrap();
759
760        let entries = vfs.list_dir("").await.unwrap();
761        assert_eq!(entries.len(), 3);
762        assert_eq!(entries[0].name, "apple.txt");
763        assert_eq!(entries[1].name, "Banana.txt");
764        assert_eq!(entries[2].name, "Zebra.txt");
765    }
766
767    // ─── get_platform_vfs() ──────────────────────────────────────────────
768
769    #[tokio::test]
770    async fn test_get_platform_vfs_returns_boxed_vfs() {
771        // get_platform_vfs() returns Box<dyn Vfs + Send + Sync> on all platforms.
772        // We can't control the root dir, but we can verify it returns a valid object.
773        let vfs = get_platform_vfs();
774        // Smoke test: exists should work even if path doesn't exist
775        let result = vfs.exists("__nonexistent_test_file__").await;
776        assert!(result.is_ok());
777    }
778
779    #[tokio::test]
780    async fn test_dyn_vfs_supports_all_operations() {
781        let temp = TempDir::new().unwrap();
782        let vfs: Box<dyn Vfs + Send + Sync> = Box::new(NativeVfs::new(temp.path()));
783
784        // write + read
785        vfs.write("test.txt", b"hello").await.unwrap();
786        assert_eq!(vfs.read("test.txt").await.unwrap(), b"hello");
787
788        // read_to_string
789        assert_eq!(vfs.read_to_string("test.txt").await.unwrap(), "hello");
790
791        // exists
792        assert!(vfs.exists("test.txt").await.unwrap());
793        assert!(!vfs.exists("nope.txt").await.unwrap());
794
795        // append
796        vfs.append("test.txt", b" world").await.unwrap();
797        assert_eq!(vfs.read_to_string("test.txt").await.unwrap(), "hello world");
798
799        // create_dir_all + nested write
800        vfs.create_dir_all("a/b").await.unwrap();
801        vfs.write("a/b/nested.txt", b"deep").await.unwrap();
802        assert_eq!(vfs.read_to_string("a/b/nested.txt").await.unwrap(), "deep");
803
804        // rename
805        vfs.rename("test.txt", "moved.txt").await.unwrap();
806        assert!(!vfs.exists("test.txt").await.unwrap());
807        assert_eq!(
808            vfs.read_to_string("moved.txt").await.unwrap(),
809            "hello world"
810        );
811
812        // list_dir
813        let entries = vfs.list_dir("").await.unwrap();
814        assert!(entries.len() >= 2);
815
816        // remove
817        vfs.remove("moved.txt").await.unwrap();
818        assert!(!vfs.exists("moved.txt").await.unwrap());
819    }
820
821    #[tokio::test]
822    async fn test_dyn_vfs_can_be_wrapped_in_arc() {
823        let temp = TempDir::new().unwrap();
824        let vfs: std::sync::Arc<dyn Vfs + Send + Sync> =
825            std::sync::Arc::from(Box::new(NativeVfs::new(temp.path())) as Box<dyn Vfs + Send + Sync>);
826
827        // Verify Arc<dyn Vfs> works for Distributed/Persistent mount patterns
828        let vfs_clone = vfs.clone();
829        vfs.write("arc_test.txt", b"shared").await.unwrap();
830        let content = vfs_clone.read_to_string("arc_test.txt").await.unwrap();
831        assert_eq!(content, "shared");
832    }
833
834    // ─── UringVfs tests (Linux only, gated behind feature) ──────────────
835
836    #[cfg(all(target_os = "linux", feature = "io-uring"))]
837    mod uring_tests {
838        use super::*;
839
840        #[tokio::test]
841        async fn test_uring_vfs_creation() {
842            let temp = TempDir::new().unwrap();
843            let result = super::super::UringVfs::new(temp.path());
844            assert!(result.is_ok(), "UringVfs::new should succeed on supported Linux kernels");
845        }
846
847        #[tokio::test]
848        async fn test_uring_vfs_read_write() {
849            let temp = TempDir::new().unwrap();
850            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
851
852            vfs.write("test.txt", b"hello world").await.unwrap();
853            let content = vfs.read_to_string("test.txt").await.unwrap();
854            assert_eq!(content, "hello world");
855        }
856
857        #[tokio::test]
858        async fn test_uring_vfs_read_bytes() {
859            let temp = TempDir::new().unwrap();
860            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
861
862            let data = b"binary data \x00\x01\x02\xff";
863            vfs.write("bin.dat", data).await.unwrap();
864            let read_back = vfs.read("bin.dat").await.unwrap();
865            assert_eq!(read_back, data);
866        }
867
868        #[tokio::test]
869        async fn test_uring_vfs_append() {
870            let temp = TempDir::new().unwrap();
871            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
872
873            vfs.append("log.txt", b"line1\n").await.unwrap();
874            vfs.append("log.txt", b"line2\n").await.unwrap();
875
876            let content = vfs.read_to_string("log.txt").await.unwrap();
877            assert_eq!(content, "line1\nline2\n");
878        }
879
880        #[tokio::test]
881        async fn test_uring_vfs_append_creates_file() {
882            let temp = TempDir::new().unwrap();
883            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
884
885            vfs.append("new.log", b"first entry\n").await.unwrap();
886            let content = vfs.read_to_string("new.log").await.unwrap();
887            assert_eq!(content, "first entry\n");
888        }
889
890        #[tokio::test]
891        async fn test_uring_vfs_append_many_sequential() {
892            let temp = TempDir::new().unwrap();
893            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
894
895            for i in 0..100 {
896                vfs.append("journal.log", format!("entry-{}\n", i).as_bytes())
897                    .await
898                    .unwrap();
899            }
900
901            let content = vfs.read_to_string("journal.log").await.unwrap();
902            let lines: Vec<&str> = content.lines().collect();
903            assert_eq!(lines.len(), 100);
904            assert_eq!(lines[0], "entry-0");
905            assert_eq!(lines[99], "entry-99");
906        }
907
908        #[tokio::test]
909        async fn test_uring_vfs_nested_dirs() {
910            let temp = TempDir::new().unwrap();
911            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
912
913            vfs.write("a/b/c/file.txt", b"deep").await.unwrap();
914            let content = vfs.read_to_string("a/b/c/file.txt").await.unwrap();
915            assert_eq!(content, "deep");
916        }
917
918        #[tokio::test]
919        async fn test_uring_vfs_exists() {
920            let temp = TempDir::new().unwrap();
921            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
922
923            assert!(!vfs.exists("missing.txt").await.unwrap());
924            vfs.write("present.txt", b"here").await.unwrap();
925            assert!(vfs.exists("present.txt").await.unwrap());
926        }
927
928        #[tokio::test]
929        async fn test_uring_vfs_remove() {
930            let temp = TempDir::new().unwrap();
931            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
932
933            vfs.write("doomed.txt", b"bye").await.unwrap();
934            assert!(vfs.exists("doomed.txt").await.unwrap());
935
936            vfs.remove("doomed.txt").await.unwrap();
937            assert!(!vfs.exists("doomed.txt").await.unwrap());
938        }
939
940        #[tokio::test]
941        async fn test_uring_vfs_remove_nonexistent_returns_error() {
942            let temp = TempDir::new().unwrap();
943            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
944
945            let result = vfs.remove("ghost.txt").await;
946            assert!(result.is_err());
947        }
948
949        #[tokio::test]
950        async fn test_uring_vfs_rename() {
951            let temp = TempDir::new().unwrap();
952            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
953
954            vfs.write("old.txt", b"content").await.unwrap();
955            vfs.rename("old.txt", "new.txt").await.unwrap();
956
957            assert!(!vfs.exists("old.txt").await.unwrap());
958            let content = vfs.read_to_string("new.txt").await.unwrap();
959            assert_eq!(content, "content");
960        }
961
962        #[tokio::test]
963        async fn test_uring_vfs_create_dir_all() {
964            let temp = TempDir::new().unwrap();
965            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
966
967            vfs.create_dir_all("x/y/z").await.unwrap();
968            vfs.write("x/y/z/deep.txt", b"found").await.unwrap();
969            let content = vfs.read_to_string("x/y/z/deep.txt").await.unwrap();
970            assert_eq!(content, "found");
971        }
972
973        #[tokio::test]
974        async fn test_uring_vfs_list_dir() {
975            let temp = TempDir::new().unwrap();
976            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
977
978            vfs.write("file1.txt", b"1").await.unwrap();
979            vfs.write("file2.txt", b"2").await.unwrap();
980            vfs.write("subdir/nested.txt", b"n").await.unwrap();
981
982            let entries = vfs.list_dir("").await.unwrap();
983            assert_eq!(entries.len(), 3);
984            assert_eq!(entries[0].name, "subdir");
985            assert!(entries[0].is_directory);
986            assert_eq!(entries[1].name, "file1.txt");
987            assert_eq!(entries[2].name, "file2.txt");
988        }
989
990        #[tokio::test]
991        async fn test_uring_vfs_list_dir_empty() {
992            let temp = TempDir::new().unwrap();
993            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
994
995            let entries = vfs.list_dir("").await.unwrap();
996            assert!(entries.is_empty());
997        }
998
999        #[tokio::test]
1000        async fn test_uring_vfs_atomic_write_overwrites() {
1001            let temp = TempDir::new().unwrap();
1002            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1003
1004            vfs.write("over.txt", b"first").await.unwrap();
1005            vfs.write("over.txt", b"second").await.unwrap();
1006            let content = vfs.read_to_string("over.txt").await.unwrap();
1007            assert_eq!(content, "second");
1008        }
1009
1010        #[tokio::test]
1011        async fn test_uring_vfs_atomic_write_no_leftover_tmp() {
1012            let temp = TempDir::new().unwrap();
1013            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1014
1015            vfs.write("clean.txt", b"data").await.unwrap();
1016            assert!(!vfs.exists("clean.tmp").await.unwrap());
1017        }
1018
1019        #[tokio::test]
1020        async fn test_uring_vfs_write_empty_file() {
1021            let temp = TempDir::new().unwrap();
1022            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1023
1024            vfs.write("empty.txt", b"").await.unwrap();
1025            let content = vfs.read("empty.txt").await.unwrap();
1026            assert!(content.is_empty());
1027        }
1028
1029        #[tokio::test]
1030        async fn test_uring_vfs_read_nonexistent_returns_error() {
1031            let temp = TempDir::new().unwrap();
1032            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1033
1034            let result = vfs.read("missing.txt").await;
1035            assert!(result.is_err());
1036        }
1037
1038        #[tokio::test]
1039        async fn test_uring_vfs_binary_roundtrip() {
1040            let temp = TempDir::new().unwrap();
1041            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1042
1043            let binary: Vec<u8> = (0u8..=255).collect();
1044            vfs.write("binary.bin", &binary).await.unwrap();
1045            let read_back = vfs.read("binary.bin").await.unwrap();
1046            assert_eq!(read_back, binary);
1047        }
1048
1049        #[tokio::test]
1050        async fn test_uring_vfs_large_file_roundtrip() {
1051            let temp = TempDir::new().unwrap();
1052            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1053
1054            // 1 MiB file
1055            let large: Vec<u8> = (0..1_048_576).map(|i| (i % 256) as u8).collect();
1056            vfs.write("large.bin", &large).await.unwrap();
1057            let read_back = vfs.read("large.bin").await.unwrap();
1058            assert_eq!(read_back.len(), large.len());
1059            assert_eq!(read_back, large);
1060        }
1061
1062        #[tokio::test]
1063        async fn test_uring_vfs_path_traversal_prevention() {
1064            let temp = TempDir::new().unwrap();
1065            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1066
1067            vfs.write("../../../etc/passwd", b"nope").await.unwrap();
1068            assert!(vfs.exists("etc/passwd").await.unwrap());
1069        }
1070
1071        #[tokio::test]
1072        async fn test_uring_vfs_concurrent_reads() {
1073            let temp = TempDir::new().unwrap();
1074            let vfs = std::sync::Arc::new(super::super::UringVfs::new(temp.path()).unwrap());
1075
1076            // Write several files
1077            for i in 0..10 {
1078                vfs.write(&format!("file_{}.txt", i), format!("content_{}", i).as_bytes())
1079                    .await
1080                    .unwrap();
1081            }
1082
1083            // Read them all concurrently
1084            let mut handles = Vec::new();
1085            for i in 0..10 {
1086                let vfs = vfs.clone();
1087                handles.push(tokio::spawn(async move {
1088                    let content = vfs.read_to_string(&format!("file_{}.txt", i)).await.unwrap();
1089                    assert_eq!(content, format!("content_{}", i));
1090                }));
1091            }
1092
1093            for handle in handles {
1094                handle.await.unwrap();
1095            }
1096        }
1097
1098        #[tokio::test]
1099        async fn test_uring_vfs_concurrent_appends() {
1100            let temp = TempDir::new().unwrap();
1101            let vfs = std::sync::Arc::new(super::super::UringVfs::new(temp.path()).unwrap());
1102
1103            // 50 concurrent appends
1104            let mut handles = Vec::new();
1105            for i in 0..50 {
1106                let vfs = vfs.clone();
1107                handles.push(tokio::spawn(async move {
1108                    vfs.append("concurrent.log", format!("entry-{}\n", i).as_bytes())
1109                        .await
1110                        .unwrap();
1111                }));
1112            }
1113
1114            for handle in handles {
1115                handle.await.unwrap();
1116            }
1117
1118            let content = vfs.read_to_string("concurrent.log").await.unwrap();
1119            let lines: Vec<&str> = content.lines().collect();
1120            assert_eq!(lines.len(), 50);
1121        }
1122
1123        #[tokio::test]
1124        async fn test_uring_vfs_worker_shutdown_clean() {
1125            let temp = TempDir::new().unwrap();
1126
1127            // Create and immediately drop — should shut down cleanly
1128            {
1129                let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1130                vfs.write("shutdown_test.txt", b"data").await.unwrap();
1131            }
1132            // If we reach here without hanging, the worker shut down cleanly.
1133
1134            // Verify the data persisted
1135            let vfs = NativeVfs::new(temp.path());
1136            let content = vfs.read_to_string("shutdown_test.txt").await.unwrap();
1137            assert_eq!(content, "data");
1138        }
1139
1140        #[tokio::test]
1141        async fn test_uring_vfs_operations_after_many_writes() {
1142            let temp = TempDir::new().unwrap();
1143            let vfs = super::super::UringVfs::new(temp.path()).unwrap();
1144
1145            // Stress test: many sequential writes then reads
1146            for i in 0..200 {
1147                let name = format!("file_{}.txt", i);
1148                let content = format!("content_{}", i);
1149                vfs.write(&name, content.as_bytes()).await.unwrap();
1150            }
1151
1152            for i in 0..200 {
1153                let name = format!("file_{}.txt", i);
1154                let expected = format!("content_{}", i);
1155                let actual = vfs.read_to_string(&name).await.unwrap();
1156                assert_eq!(actual, expected, "file {} content mismatch", i);
1157            }
1158        }
1159
1160        #[tokio::test]
1161        async fn test_uring_vfs_matches_native_vfs_behavior() {
1162            let temp1 = TempDir::new().unwrap();
1163            let temp2 = TempDir::new().unwrap();
1164            let uring = super::super::UringVfs::new(temp1.path()).unwrap();
1165            let native = NativeVfs::new(temp2.path());
1166
1167            // Both should produce identical results for the same operations
1168            let ops = vec![
1169                ("write", "test.txt", b"hello" as &[u8]),
1170                ("write", "dir/nested.txt", b"nested"),
1171                ("append", "log.txt", b"line1\n"),
1172                ("append", "log.txt", b"line2\n"),
1173            ];
1174
1175            for (op, path, data) in &ops {
1176                match *op {
1177                    "write" => {
1178                        uring.write(path, data).await.unwrap();
1179                        native.write(path, data).await.unwrap();
1180                    }
1181                    "append" => {
1182                        uring.append(path, data).await.unwrap();
1183                        native.append(path, data).await.unwrap();
1184                    }
1185                    _ => unreachable!(),
1186                }
1187            }
1188
1189            // Compare results
1190            for path in &["test.txt", "dir/nested.txt", "log.txt"] {
1191                let uring_content = uring.read(path).await.unwrap();
1192                let native_content = native.read(path).await.unwrap();
1193                assert_eq!(uring_content, native_content, "mismatch for {}", path);
1194            }
1195
1196            // Compare list_dir
1197            let uring_entries = uring.list_dir("").await.unwrap();
1198            let native_entries = native.list_dir("").await.unwrap();
1199            assert_eq!(uring_entries.len(), native_entries.len());
1200            for (u, n) in uring_entries.iter().zip(native_entries.iter()) {
1201                assert_eq!(u.name, n.name);
1202                assert_eq!(u.is_directory, n.is_directory);
1203            }
1204        }
1205    }
1206
1207    // ─── UringVfs fallback test (Linux without io_uring support) ─────────
1208
1209    #[cfg(all(target_os = "linux", feature = "io-uring"))]
1210    #[tokio::test]
1211    async fn test_uring_vfs_fallback_on_unsupported() {
1212        // get_platform_vfs should always return a working VFS,
1213        // regardless of whether io_uring is actually available.
1214        let temp = TempDir::new().unwrap();
1215        std::env::set_current_dir(temp.path()).unwrap();
1216
1217        let vfs = get_platform_vfs();
1218        vfs.write("fallback.txt", b"works").await.unwrap();
1219        let content = vfs.read_to_string("fallback.txt").await.unwrap();
1220        assert_eq!(content, "works");
1221    }
1222}