Skip to main content

bashkit/fs/
memory.rs

1//! In-memory filesystem implementation.
2//!
3//! [`InMemoryFs`] provides a simple, fast, thread-safe filesystem that stores
4//! all data in memory using a `HashMap`.
5//!
6//! # Security Mitigations
7//!
8//! This module mitigates the following threats (see `specs/006-threat-model.md`):
9//!
10//! - **TM-ESC-001**: Path traversal → `normalize_path()` collapses `..` safely
11//! - **TM-ESC-002**: Symlink escape → symlinks stored but not followed
12//! - **TM-ESC-003**: Real FS access → in-memory by default, no real syscalls
13//! - **TM-DOS-011**: Symlink loops → no symlink resolution during path lookup
14//! - **TM-INJ-005**: Path injection → path normalization at all entry points
15//!
16//! # Resource Limits
17//!
18//! `InMemoryFs` enforces configurable limits to prevent memory exhaustion:
19//!
20//! - `max_total_bytes`: Maximum total size of all files (default: 100MB)
21//! - `max_file_size`: Maximum size of a single file (default: 10MB)
22//! - `max_file_count`: Maximum number of files (default: 10,000)
23//!
24//! See [`FsLimits`](crate::FsLimits) for configuration.
25//!
26//! # Fail Points (enabled with `failpoints` feature)
27//!
28//! For testing error handling, the following fail points are available:
29//!
30//! - `fs::read_file` - Inject failures in file reads
31//! - `fs::write_file` - Inject failures in file writes
32//! - `fs::mkdir` - Inject failures in directory creation
33//! - `fs::remove` - Inject failures in file/directory removal
34//! - `fs::lock_read` - Inject failures in read lock acquisition
35//! - `fs::lock_write` - Inject failures in write lock acquisition
36
37// RwLock.read()/write().unwrap() only panics on lock poisoning (prior panic
38// while holding lock). This is intentional - corrupted state should not propagate.
39#![allow(clippy::unwrap_used)]
40
41use async_trait::async_trait;
42use std::collections::HashMap;
43use std::io::{Error as IoError, ErrorKind};
44use std::path::{Path, PathBuf};
45use std::sync::RwLock;
46use std::time::SystemTime;
47
48use super::limits::{FsLimits, FsUsage};
49use super::traits::{DirEntry, FileSystem, FileType, Metadata};
50use crate::error::Result;
51
52#[cfg(feature = "failpoints")]
53use fail::fail_point;
54
55/// In-memory filesystem implementation.
56///
57/// `InMemoryFs` is the default filesystem used by [`Bash::new()`](crate::Bash::new).
58/// It stores all files and directories in memory using a `HashMap`, making it
59/// ideal for sandboxed execution where no real filesystem access is needed.
60///
61/// # Features
62///
63/// - **Thread-safe**: Uses `RwLock` for concurrent read/write access
64/// - **Binary-safe**: Fully supports binary data including null bytes
65/// - **Default directories**: Creates `/`, `/tmp`, `/home`, `/home/user`, `/dev`
66/// - **Special devices**: `/dev/null` discards writes and returns empty on read
67///
68/// # Example
69///
70/// ```rust
71/// use bashkit::{Bash, FileSystem, InMemoryFs};
72/// use std::path::Path;
73/// use std::sync::Arc;
74///
75/// # #[tokio::main]
76/// # async fn main() -> bashkit::Result<()> {
77/// // InMemoryFs is the default when using Bash::new()
78/// let mut bash = Bash::new();
79///
80/// // Or create explicitly for direct filesystem access
81/// let fs = Arc::new(InMemoryFs::new());
82///
83/// // Write files
84/// fs.write_file(Path::new("/tmp/test.txt"), b"hello").await?;
85///
86/// // Read files
87/// let content = fs.read_file(Path::new("/tmp/test.txt")).await?;
88/// assert_eq!(content, b"hello");
89///
90/// // Create directories
91/// fs.mkdir(Path::new("/data/nested/dir"), true).await?;
92///
93/// // Check existence
94/// assert!(fs.exists(Path::new("/data/nested/dir")).await?);
95///
96/// // Use with Bash
97/// let mut bash = Bash::builder().fs(fs.clone()).build();
98/// bash.exec("echo 'from bash' >> /tmp/test.txt").await?;
99///
100/// let content = fs.read_file(Path::new("/tmp/test.txt")).await?;
101/// assert_eq!(content, b"hellofrom bash\n");
102/// # Ok(())
103/// # }
104/// ```
105///
106/// # Default Directory Structure
107///
108/// `InMemoryFs::new()` creates these directories:
109///
110/// ```text
111/// /
112/// ├── tmp/
113/// ├── home/
114/// │   └── user/
115/// └── dev/
116///     └── null  (special device)
117/// ```
118///
119/// # Binary Data
120///
121/// The filesystem fully supports binary data:
122///
123/// ```rust
124/// use bashkit::{FileSystem, InMemoryFs};
125/// use std::path::Path;
126///
127/// # #[tokio::main]
128/// # async fn main() -> bashkit::Result<()> {
129/// let fs = InMemoryFs::new();
130///
131/// // Write binary with null bytes
132/// let data = vec![0x89, 0x50, 0x4E, 0x47, 0x00, 0xFF];
133/// fs.write_file(Path::new("/tmp/binary.bin"), &data).await?;
134///
135/// // Read it back unchanged
136/// let read = fs.read_file(Path::new("/tmp/binary.bin")).await?;
137/// assert_eq!(read, data);
138/// # Ok(())
139/// # }
140/// ```
141///
142/// # Resource Limits
143///
144/// Configure limits to prevent memory exhaustion:
145///
146/// ```rust
147/// use bashkit::{FileSystem, InMemoryFs, FsLimits};
148/// use std::path::Path;
149///
150/// # #[tokio::main]
151/// # async fn main() -> bashkit::Result<()> {
152/// let limits = FsLimits::new()
153///     .max_total_bytes(1_000_000)   // 1MB total
154///     .max_file_size(100_000)       // 100KB per file
155///     .max_file_count(100);         // 100 files max
156///
157/// let fs = InMemoryFs::with_limits(limits);
158///
159/// // This works
160/// fs.write_file(Path::new("/tmp/small.txt"), b"hello").await?;
161///
162/// // This would fail with "file too large" error:
163/// // let big_data = vec![0u8; 200_000];
164/// // fs.write_file(Path::new("/tmp/big.bin"), &big_data).await?;
165/// # Ok(())
166/// # }
167/// ```
168pub struct InMemoryFs {
169    entries: RwLock<HashMap<PathBuf, FsEntry>>,
170    limits: FsLimits,
171}
172
173#[derive(Debug, Clone)]
174enum FsEntry {
175    File {
176        content: Vec<u8>,
177        metadata: Metadata,
178    },
179    Directory {
180        metadata: Metadata,
181    },
182    Symlink {
183        target: PathBuf,
184        metadata: Metadata,
185    },
186}
187
188impl Default for InMemoryFs {
189    fn default() -> Self {
190        Self::new()
191    }
192}
193
194impl InMemoryFs {
195    /// Create a new in-memory filesystem with default directories and default limits.
196    ///
197    /// Creates the following directory structure:
198    /// - `/` - Root directory
199    /// - `/tmp` - Temporary files
200    /// - `/home` - Home directories
201    /// - `/home/user` - Default user home
202    /// - `/dev` - Device files
203    /// - `/dev/null` - Null device (discards writes, returns empty)
204    ///
205    /// # Default Limits
206    ///
207    /// - Total filesystem: 100MB
208    /// - Single file: 10MB
209    /// - File count: 10,000
210    ///
211    /// Use [`InMemoryFs::with_limits`] for custom limits.
212    ///
213    /// # Example
214    ///
215    /// ```rust
216    /// use bashkit::{FileSystem, InMemoryFs};
217    /// use std::path::Path;
218    ///
219    /// # #[tokio::main]
220    /// # async fn main() -> bashkit::Result<()> {
221    /// let fs = InMemoryFs::new();
222    ///
223    /// // Default directories exist
224    /// assert!(fs.exists(Path::new("/tmp")).await?);
225    /// assert!(fs.exists(Path::new("/home/user")).await?);
226    /// assert!(fs.exists(Path::new("/dev/null")).await?);
227    /// # Ok(())
228    /// # }
229    /// ```
230    pub fn new() -> Self {
231        Self::with_limits(FsLimits::default())
232    }
233
234    /// Create a new in-memory filesystem with custom limits.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use bashkit::{FileSystem, InMemoryFs, FsLimits};
240    /// use std::path::Path;
241    ///
242    /// # #[tokio::main]
243    /// # async fn main() -> bashkit::Result<()> {
244    /// let limits = FsLimits::new()
245    ///     .max_total_bytes(50_000_000)  // 50MB
246    ///     .max_file_size(5_000_000);    // 5MB per file
247    ///
248    /// let fs = InMemoryFs::with_limits(limits);
249    /// # Ok(())
250    /// # }
251    /// ```
252    pub fn with_limits(limits: FsLimits) -> Self {
253        let mut entries = HashMap::new();
254
255        // Create root directory
256        entries.insert(
257            PathBuf::from("/"),
258            FsEntry::Directory {
259                metadata: Metadata {
260                    file_type: FileType::Directory,
261                    size: 0,
262                    mode: 0o755,
263                    modified: SystemTime::now(),
264                    created: SystemTime::now(),
265                },
266            },
267        );
268
269        // Create common directories
270        for dir in &["/tmp", "/home", "/home/user", "/dev"] {
271            entries.insert(
272                PathBuf::from(dir),
273                FsEntry::Directory {
274                    metadata: Metadata {
275                        file_type: FileType::Directory,
276                        size: 0,
277                        mode: 0o755,
278                        modified: SystemTime::now(),
279                        created: SystemTime::now(),
280                    },
281                },
282            );
283        }
284
285        // Create special device files
286        // /dev/null - discards all writes, returns empty on read
287        entries.insert(
288            PathBuf::from("/dev/null"),
289            FsEntry::File {
290                content: Vec::new(),
291                metadata: Metadata {
292                    file_type: FileType::File,
293                    size: 0,
294                    mode: 0o666,
295                    modified: SystemTime::now(),
296                    created: SystemTime::now(),
297                },
298            },
299        );
300
301        // /dev/fd - directory for process substitution file descriptors
302        entries.insert(
303            PathBuf::from("/dev/fd"),
304            FsEntry::Directory {
305                metadata: Metadata {
306                    file_type: FileType::Directory,
307                    size: 0,
308                    mode: 0o755,
309                    modified: SystemTime::now(),
310                    created: SystemTime::now(),
311                },
312            },
313        );
314
315        Self {
316            entries: RwLock::new(entries),
317            limits,
318        }
319    }
320
321    /// Compute current usage statistics.
322    fn compute_usage(&self) -> FsUsage {
323        let entries = self.entries.read().unwrap();
324        let mut total_bytes = 0u64;
325        let mut file_count = 0u64;
326        let mut dir_count = 0u64;
327
328        for entry in entries.values() {
329            match entry {
330                FsEntry::File { content, .. } => {
331                    total_bytes += content.len() as u64;
332                    file_count += 1;
333                }
334                FsEntry::Directory { .. } => {
335                    dir_count += 1;
336                }
337                FsEntry::Symlink { .. } => {
338                    // Symlinks don't count toward file count or size
339                }
340            }
341        }
342
343        FsUsage::new(total_bytes, file_count, dir_count)
344    }
345
346    /// Check limits before writing. Returns error if limits exceeded.
347    fn check_write_limits(
348        &self,
349        entries: &HashMap<PathBuf, FsEntry>,
350        path: &Path,
351        new_size: usize,
352    ) -> Result<()> {
353        // Check single file size limit
354        self.limits
355            .check_file_size(new_size as u64)
356            .map_err(|e| IoError::other(e.to_string()))?;
357
358        // Calculate current total and what the new total would be
359        let mut current_total = 0u64;
360        let mut current_file_count = 0u64;
361        let mut old_file_size = 0u64;
362        let mut is_new_file = true;
363
364        for (entry_path, entry) in entries.iter() {
365            if let FsEntry::File { content, .. } = entry {
366                current_total += content.len() as u64;
367                current_file_count += 1;
368                if entry_path == path {
369                    old_file_size = content.len() as u64;
370                    is_new_file = false;
371                }
372            }
373        }
374
375        // Check file count limit (only if this is a new file)
376        if is_new_file {
377            self.limits
378                .check_file_count(current_file_count)
379                .map_err(|e| IoError::other(e.to_string()))?;
380        }
381
382        // Check total bytes limit
383        // New total = current - old_file_size + new_size
384        let new_total = current_total - old_file_size + new_size as u64;
385        if new_total > self.limits.max_total_bytes {
386            return Err(IoError::other(format!(
387                "filesystem full: {} bytes would exceed {} byte limit",
388                new_total, self.limits.max_total_bytes
389            ))
390            .into());
391        }
392
393        Ok(())
394    }
395
396    fn normalize_path(path: &Path) -> PathBuf {
397        let mut result = PathBuf::new();
398
399        for component in path.components() {
400            match component {
401                std::path::Component::RootDir => {
402                    result.push("/");
403                }
404                std::path::Component::Normal(name) => {
405                    result.push(name);
406                }
407                std::path::Component::ParentDir => {
408                    result.pop();
409                }
410                std::path::Component::CurDir => {}
411                std::path::Component::Prefix(_) => {}
412            }
413        }
414
415        if result.as_os_str().is_empty() {
416            result.push("/");
417        }
418
419        result
420    }
421
422    /// Add a file with specific mode (synchronous, for initial setup).
423    ///
424    /// This method is primarily used by [`BashBuilder`](crate::BashBuilder) to
425    /// pre-populate the filesystem during construction. For runtime file operations,
426    /// use the async [`FileSystem::write_file`] method instead.
427    ///
428    /// Parent directories are created automatically.
429    ///
430    /// # Arguments
431    ///
432    /// * `path` - Absolute path where the file will be created
433    /// * `content` - File content (will be converted to bytes)
434    /// * `mode` - Unix permission mode (e.g., `0o644` for writable, `0o444` for readonly)
435    ///
436    /// # Example
437    ///
438    /// ```rust
439    /// use bashkit::InMemoryFs;
440    ///
441    /// let fs = InMemoryFs::new();
442    ///
443    /// // Add a writable config file
444    /// fs.add_file("/config/app.conf", "debug=true\n", 0o644);
445    ///
446    /// // Add a readonly file
447    /// fs.add_file("/etc/version", "1.0.0", 0o444);
448    /// ```
449    pub fn add_file(&self, path: impl AsRef<Path>, content: impl AsRef<[u8]>, mode: u32) {
450        let path = Self::normalize_path(path.as_ref());
451        let content = content.as_ref();
452        let mut entries = self.entries.write().unwrap();
453
454        // Ensure parent directories exist
455        if let Some(parent) = path.parent() {
456            let mut current = PathBuf::from("/");
457            for component in parent.components().skip(1) {
458                current.push(component);
459                if !entries.contains_key(&current) {
460                    entries.insert(
461                        current.clone(),
462                        FsEntry::Directory {
463                            metadata: Metadata {
464                                file_type: FileType::Directory,
465                                size: 0,
466                                mode: 0o755,
467                                modified: SystemTime::now(),
468                                created: SystemTime::now(),
469                            },
470                        },
471                    );
472                }
473            }
474        }
475
476        entries.insert(
477            path,
478            FsEntry::File {
479                content: content.to_vec(),
480                metadata: Metadata {
481                    file_type: FileType::File,
482                    size: content.len() as u64,
483                    mode,
484                    modified: SystemTime::now(),
485                    created: SystemTime::now(),
486                },
487            },
488        );
489    }
490}
491
492#[async_trait]
493impl FileSystem for InMemoryFs {
494    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
495        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
496        self.limits
497            .validate_path(path)
498            .map_err(|e| IoError::other(e.to_string()))?;
499
500        // Fail point: simulate read failures
501        #[cfg(feature = "failpoints")]
502        fail_point!("fs::read_file", |action| {
503            match action.as_deref() {
504                Some("io_error") => {
505                    return Err(IoError::other("injected I/O error").into());
506                }
507                Some("permission_denied") => {
508                    return Err(
509                        IoError::new(ErrorKind::PermissionDenied, "permission denied").into(),
510                    );
511                }
512                Some("corrupt_data") => {
513                    // Return garbage data instead of actual content
514                    return Ok(vec![0xFF, 0xFE, 0x00, 0x01]);
515                }
516                _ => {}
517            }
518            Err(IoError::other("fail point triggered").into())
519        });
520
521        let path = Self::normalize_path(path);
522        let entries = self.entries.read().unwrap();
523
524        match entries.get(&path) {
525            Some(FsEntry::File { content, .. }) => Ok(content.clone()),
526            Some(FsEntry::Directory { .. }) => Err(IoError::other("is a directory").into()),
527            Some(FsEntry::Symlink { .. }) => {
528                // Symlinks are intentionally not followed for security (TM-ESC-002, TM-DOS-011)
529                Err(IoError::new(ErrorKind::NotFound, "file not found").into())
530            }
531            None => Err(IoError::new(ErrorKind::NotFound, "file not found").into()),
532        }
533    }
534
535    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
536        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
537        self.limits
538            .validate_path(path)
539            .map_err(|e| IoError::other(e.to_string()))?;
540
541        // Fail point: simulate write failures
542        #[cfg(feature = "failpoints")]
543        fail_point!("fs::write_file", |action| {
544            match action.as_deref() {
545                Some("io_error") => {
546                    return Err(IoError::other("injected I/O error").into());
547                }
548                Some("disk_full") => {
549                    return Err(IoError::other("no space left on device").into());
550                }
551                Some("permission_denied") => {
552                    return Err(
553                        IoError::new(ErrorKind::PermissionDenied, "permission denied").into(),
554                    );
555                }
556                Some("partial_write") => {
557                    // Simulate partial write - this tests data integrity handling
558                    // In a real scenario, this could corrupt data
559                    return Err(IoError::new(ErrorKind::Interrupted, "partial write").into());
560                }
561                _ => {}
562            }
563            Err(IoError::other("fail point triggered").into())
564        });
565
566        let path = Self::normalize_path(path);
567
568        // Special handling for /dev/null - discard all writes
569        if path == Path::new("/dev/null") {
570            return Ok(());
571        }
572
573        let mut entries = self.entries.write().unwrap();
574
575        // Ensure parent directory exists
576        if let Some(parent) = path.parent() {
577            if !entries.contains_key(parent) && parent != Path::new("/") {
578                return Err(IoError::new(ErrorKind::NotFound, "parent directory not found").into());
579            }
580        }
581
582        // Cannot write to a directory
583        if let Some(FsEntry::Directory { .. }) = entries.get(&path) {
584            return Err(IoError::other("is a directory").into());
585        }
586
587        // Check limits before writing
588        self.check_write_limits(&entries, &path, content.len())?;
589
590        entries.insert(
591            path,
592            FsEntry::File {
593                content: content.to_vec(),
594                metadata: Metadata {
595                    file_type: FileType::File,
596                    size: content.len() as u64,
597                    mode: 0o644,
598                    modified: SystemTime::now(),
599                    created: SystemTime::now(),
600                },
601            },
602        );
603
604        Ok(())
605    }
606
607    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
608        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
609        self.limits
610            .validate_path(path)
611            .map_err(|e| IoError::other(e.to_string()))?;
612
613        let path = Self::normalize_path(path);
614
615        // Special handling for /dev/null - discard all writes
616        if path == Path::new("/dev/null") {
617            return Ok(());
618        }
619
620        // Check if file exists and get the info we need
621        let (should_create, current_size) = {
622            let entries = self.entries.read().unwrap();
623            match entries.get(&path) {
624                Some(FsEntry::File {
625                    content: existing, ..
626                }) => (false, Some(existing.len())),
627                Some(FsEntry::Directory { .. }) => {
628                    return Err(IoError::other("is a directory").into());
629                }
630                Some(FsEntry::Symlink { .. }) => {
631                    return Err(IoError::new(ErrorKind::NotFound, "file not found").into());
632                }
633                None => (true, None),
634            }
635        };
636
637        if should_create {
638            return self.write_file(&path, content).await;
639        }
640
641        // File exists, need to append
642        let current_file_size = current_size.unwrap();
643        let new_size = current_file_size + content.len();
644
645        // Check file size limit
646        self.limits
647            .check_file_size(new_size as u64)
648            .map_err(|e| IoError::other(e.to_string()))?;
649
650        // Now do the actual append with write lock
651        let mut entries = self.entries.write().unwrap();
652
653        // Calculate current total for limit check
654        let mut current_total = 0u64;
655        for entry in entries.values() {
656            if let FsEntry::File {
657                content: file_content,
658                ..
659            } = entry
660            {
661                current_total += file_content.len() as u64;
662            }
663        }
664
665        // Check total bytes limit
666        let new_total = current_total + content.len() as u64;
667        if new_total > self.limits.max_total_bytes {
668            return Err(IoError::other(format!(
669                "filesystem full: {} bytes would exceed {} byte limit",
670                new_total, self.limits.max_total_bytes
671            ))
672            .into());
673        }
674
675        // Actually append
676        if let Some(FsEntry::File {
677            content: existing,
678            metadata,
679        }) = entries.get_mut(&path)
680        {
681            existing.extend_from_slice(content);
682            metadata.size = existing.len() as u64;
683            metadata.modified = SystemTime::now();
684        }
685
686        Ok(())
687    }
688
689    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
690        // THREAT[TM-DOS-012, TM-DOS-013, TM-DOS-015]: Validate path before use
691        self.limits
692            .validate_path(path)
693            .map_err(|e| IoError::other(e.to_string()))?;
694
695        let path = Self::normalize_path(path);
696        let mut entries = self.entries.write().unwrap();
697
698        if recursive {
699            let mut current = PathBuf::from("/");
700            for component in path.components().skip(1) {
701                current.push(component);
702                match entries.get(&current) {
703                    Some(FsEntry::Directory { .. }) => {
704                        // Directory exists, continue to next component
705                    }
706                    Some(FsEntry::File { .. } | FsEntry::Symlink { .. }) => {
707                        // File or symlink exists at path - cannot create directory
708                        return Err(IoError::new(ErrorKind::AlreadyExists, "file exists").into());
709                    }
710                    None => {
711                        // Create the directory
712                        entries.insert(
713                            current.clone(),
714                            FsEntry::Directory {
715                                metadata: Metadata {
716                                    file_type: FileType::Directory,
717                                    size: 0,
718                                    mode: 0o755,
719                                    modified: SystemTime::now(),
720                                    created: SystemTime::now(),
721                                },
722                            },
723                        );
724                    }
725                }
726            }
727        } else {
728            // Check parent exists
729            if let Some(parent) = path.parent() {
730                if !entries.contains_key(parent) && parent != Path::new("/") {
731                    return Err(
732                        IoError::new(ErrorKind::NotFound, "parent directory not found").into(),
733                    );
734                }
735            }
736
737            if entries.contains_key(&path) {
738                return Err(IoError::new(ErrorKind::AlreadyExists, "directory exists").into());
739            }
740
741            entries.insert(
742                path,
743                FsEntry::Directory {
744                    metadata: Metadata {
745                        file_type: FileType::Directory,
746                        size: 0,
747                        mode: 0o755,
748                        modified: SystemTime::now(),
749                        created: SystemTime::now(),
750                    },
751                },
752            );
753        }
754
755        Ok(())
756    }
757
758    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
759        let path = Self::normalize_path(path);
760        let mut entries = self.entries.write().unwrap();
761
762        match entries.get(&path) {
763            Some(FsEntry::Directory { .. }) => {
764                if recursive {
765                    // Remove all entries under this path
766                    let to_remove: Vec<PathBuf> = entries
767                        .keys()
768                        .filter(|p| p.starts_with(&path))
769                        .cloned()
770                        .collect();
771
772                    for p in to_remove {
773                        entries.remove(&p);
774                    }
775                } else {
776                    // Check if directory is empty
777                    let has_children = entries
778                        .keys()
779                        .any(|p| p != &path && p.parent() == Some(&path));
780
781                    if has_children {
782                        return Err(IoError::other("directory not empty").into());
783                    }
784
785                    entries.remove(&path);
786                }
787            }
788            Some(FsEntry::File { .. }) | Some(FsEntry::Symlink { .. }) => {
789                entries.remove(&path);
790            }
791            None => {
792                return Err(IoError::new(ErrorKind::NotFound, "not found").into());
793            }
794        }
795
796        Ok(())
797    }
798
799    async fn stat(&self, path: &Path) -> Result<Metadata> {
800        let path = Self::normalize_path(path);
801        let entries = self.entries.read().unwrap();
802
803        match entries.get(&path) {
804            Some(FsEntry::File { metadata, .. })
805            | Some(FsEntry::Directory { metadata })
806            | Some(FsEntry::Symlink { metadata, .. }) => Ok(metadata.clone()),
807            None => Err(IoError::new(ErrorKind::NotFound, "not found").into()),
808        }
809    }
810
811    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
812        let path = Self::normalize_path(path);
813        let entries = self.entries.read().unwrap();
814
815        match entries.get(&path) {
816            Some(FsEntry::Directory { .. }) => {
817                let mut result = Vec::new();
818
819                for (entry_path, entry) in entries.iter() {
820                    if entry_path.parent() == Some(&path) && entry_path != &path {
821                        let name = entry_path
822                            .file_name()
823                            .map(|n| n.to_string_lossy().to_string())
824                            .unwrap_or_default();
825
826                        let metadata = match entry {
827                            FsEntry::File { metadata, .. }
828                            | FsEntry::Directory { metadata }
829                            | FsEntry::Symlink { metadata, .. } => metadata.clone(),
830                        };
831
832                        result.push(DirEntry { name, metadata });
833                    }
834                }
835
836                Ok(result)
837            }
838            Some(_) => Err(IoError::other("not a directory").into()),
839            None => Err(IoError::new(ErrorKind::NotFound, "not found").into()),
840        }
841    }
842
843    async fn exists(&self, path: &Path) -> Result<bool> {
844        let path = Self::normalize_path(path);
845        let entries = self.entries.read().unwrap();
846        Ok(entries.contains_key(&path))
847    }
848
849    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
850        let from = Self::normalize_path(from);
851        let to = Self::normalize_path(to);
852        let mut entries = self.entries.write().unwrap();
853
854        let entry = entries
855            .remove(&from)
856            .ok_or_else(|| IoError::new(ErrorKind::NotFound, "not found"))?;
857
858        entries.insert(to, entry);
859        Ok(())
860    }
861
862    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
863        let from = Self::normalize_path(from);
864        let to = Self::normalize_path(to);
865        let mut entries = self.entries.write().unwrap();
866
867        let entry = entries
868            .get(&from)
869            .cloned()
870            .ok_or_else(|| IoError::new(ErrorKind::NotFound, "not found"))?;
871
872        entries.insert(to, entry);
873        Ok(())
874    }
875
876    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
877        let link = Self::normalize_path(link);
878        let mut entries = self.entries.write().unwrap();
879
880        entries.insert(
881            link,
882            FsEntry::Symlink {
883                target: target.to_path_buf(),
884                metadata: Metadata {
885                    file_type: FileType::Symlink,
886                    size: 0,
887                    mode: 0o777,
888                    modified: SystemTime::now(),
889                    created: SystemTime::now(),
890                },
891            },
892        );
893
894        Ok(())
895    }
896
897    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
898        let path = Self::normalize_path(path);
899        let entries = self.entries.read().unwrap();
900
901        match entries.get(&path) {
902            Some(FsEntry::Symlink { target, .. }) => Ok(target.clone()),
903            Some(_) => Err(IoError::other("not a symlink").into()),
904            None => Err(IoError::new(ErrorKind::NotFound, "not found").into()),
905        }
906    }
907
908    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
909        let path = Self::normalize_path(path);
910        let mut entries = self.entries.write().unwrap();
911
912        match entries.get_mut(&path) {
913            Some(FsEntry::File { metadata, .. })
914            | Some(FsEntry::Directory { metadata })
915            | Some(FsEntry::Symlink { metadata, .. }) => {
916                metadata.mode = mode;
917                Ok(())
918            }
919            None => Err(IoError::new(ErrorKind::NotFound, "not found").into()),
920        }
921    }
922
923    fn usage(&self) -> FsUsage {
924        self.compute_usage()
925    }
926
927    fn limits(&self) -> FsLimits {
928        self.limits.clone()
929    }
930}
931
932#[cfg(test)]
933#[allow(clippy::unwrap_used)]
934mod tests {
935    use super::*;
936
937    #[tokio::test]
938    async fn test_write_and_read_file() {
939        let fs = InMemoryFs::new();
940
941        fs.write_file(Path::new("/tmp/test.txt"), b"hello world")
942            .await
943            .unwrap();
944
945        let content = fs.read_file(Path::new("/tmp/test.txt")).await.unwrap();
946        assert_eq!(content, b"hello world");
947    }
948
949    #[tokio::test]
950    async fn test_mkdir_and_read_dir() {
951        let fs = InMemoryFs::new();
952
953        fs.mkdir(Path::new("/tmp/mydir"), false).await.unwrap();
954        fs.write_file(Path::new("/tmp/mydir/file.txt"), b"test")
955            .await
956            .unwrap();
957
958        let entries = fs.read_dir(Path::new("/tmp/mydir")).await.unwrap();
959        assert_eq!(entries.len(), 1);
960        assert_eq!(entries[0].name, "file.txt");
961    }
962
963    #[tokio::test]
964    async fn test_exists() {
965        let fs = InMemoryFs::new();
966
967        assert!(fs.exists(Path::new("/tmp")).await.unwrap());
968        assert!(!fs.exists(Path::new("/tmp/nonexistent")).await.unwrap());
969    }
970
971    #[tokio::test]
972    async fn test_add_file_basic() {
973        let fs = InMemoryFs::new();
974        fs.add_file("/tmp/added.txt", "hello from add_file", 0o644);
975
976        let content = fs.read_file(Path::new("/tmp/added.txt")).await.unwrap();
977        assert_eq!(content, b"hello from add_file");
978    }
979
980    #[tokio::test]
981    async fn test_add_file_with_mode() {
982        let fs = InMemoryFs::new();
983        fs.add_file("/etc/readonly.conf", "secret", 0o444);
984
985        let stat = fs.stat(Path::new("/etc/readonly.conf")).await.unwrap();
986        assert_eq!(stat.mode, 0o444);
987    }
988
989    #[tokio::test]
990    async fn test_add_file_creates_parent_directories() {
991        let fs = InMemoryFs::new();
992        fs.add_file("/a/b/c/d/nested.txt", "deep content", 0o644);
993
994        // File should exist
995        assert!(fs.exists(Path::new("/a/b/c/d/nested.txt")).await.unwrap());
996
997        // Parent directories should exist
998        assert!(fs.exists(Path::new("/a")).await.unwrap());
999        assert!(fs.exists(Path::new("/a/b")).await.unwrap());
1000        assert!(fs.exists(Path::new("/a/b/c")).await.unwrap());
1001        assert!(fs.exists(Path::new("/a/b/c/d")).await.unwrap());
1002
1003        // Verify content
1004        let content = fs
1005            .read_file(Path::new("/a/b/c/d/nested.txt"))
1006            .await
1007            .unwrap();
1008        assert_eq!(content, b"deep content");
1009    }
1010
1011    #[tokio::test]
1012    async fn test_add_file_binary() {
1013        let fs = InMemoryFs::new();
1014        let binary_data = vec![0x00, 0xFF, 0x89, 0x50, 0x4E, 0x47];
1015        fs.add_file("/data/binary.bin", &binary_data, 0o644);
1016
1017        let content = fs.read_file(Path::new("/data/binary.bin")).await.unwrap();
1018        assert_eq!(content, binary_data);
1019    }
1020    // ==================== Limit tests ====================
1021
1022    #[tokio::test]
1023    async fn test_file_size_limit() {
1024        let limits = FsLimits::new().max_file_size(100);
1025        let fs = InMemoryFs::with_limits(limits);
1026
1027        // Should succeed - under limit
1028        fs.write_file(Path::new("/tmp/small.txt"), &[0u8; 50])
1029            .await
1030            .unwrap();
1031
1032        // Should succeed - at limit
1033        fs.write_file(Path::new("/tmp/exact.txt"), &[0u8; 100])
1034            .await
1035            .unwrap();
1036
1037        // Should fail - over limit
1038        let result = fs
1039            .write_file(Path::new("/tmp/large.txt"), &[0u8; 101])
1040            .await;
1041        assert!(result.is_err());
1042        let err = result.unwrap_err().to_string();
1043        assert!(err.contains("file too large") || err.contains("exceeds"));
1044    }
1045
1046    #[tokio::test]
1047    async fn test_total_bytes_limit() {
1048        let limits = FsLimits::new().max_total_bytes(200);
1049        let fs = InMemoryFs::with_limits(limits);
1050
1051        // Should succeed
1052        fs.write_file(Path::new("/tmp/file1.txt"), &[0u8; 100])
1053            .await
1054            .unwrap();
1055
1056        // Should succeed - still under total limit
1057        fs.write_file(Path::new("/tmp/file2.txt"), &[0u8; 50])
1058            .await
1059            .unwrap();
1060
1061        // Should fail - would exceed total limit
1062        let result = fs
1063            .write_file(Path::new("/tmp/file3.txt"), &[0u8; 100])
1064            .await;
1065        assert!(result.is_err());
1066        let err = result.unwrap_err().to_string();
1067        assert!(err.contains("filesystem full") || err.contains("exceeds"));
1068    }
1069
1070    #[tokio::test]
1071    async fn test_file_count_limit() {
1072        // Note: InMemoryFs starts with /dev/null as 1 file
1073        let limits = FsLimits::new().max_file_count(4); // 1 existing + 3 new
1074        let fs = InMemoryFs::with_limits(limits);
1075
1076        // Should succeed - under limit
1077        fs.write_file(Path::new("/tmp/file1.txt"), b"1")
1078            .await
1079            .unwrap();
1080        fs.write_file(Path::new("/tmp/file2.txt"), b"2")
1081            .await
1082            .unwrap();
1083        fs.write_file(Path::new("/tmp/file3.txt"), b"3")
1084            .await
1085            .unwrap();
1086
1087        // Should fail - at limit (4 files: /dev/null + 3 new)
1088        let result = fs.write_file(Path::new("/tmp/file4.txt"), b"4").await;
1089        assert!(result.is_err());
1090        let err = result.unwrap_err().to_string();
1091        assert!(err.contains("too many files") || err.contains("limit"));
1092    }
1093
1094    #[tokio::test]
1095    async fn test_overwrite_does_not_increase_count() {
1096        // Note: InMemoryFs starts with /dev/null as 1 file
1097        let limits = FsLimits::new().max_file_count(3); // 1 existing + 2 new
1098        let fs = InMemoryFs::with_limits(limits);
1099
1100        // Create two files
1101        fs.write_file(Path::new("/tmp/file1.txt"), b"original")
1102            .await
1103            .unwrap();
1104        fs.write_file(Path::new("/tmp/file2.txt"), b"original")
1105            .await
1106            .unwrap();
1107
1108        // Overwrite existing file - should succeed
1109        fs.write_file(Path::new("/tmp/file1.txt"), b"updated")
1110            .await
1111            .unwrap();
1112
1113        // New file should fail (we're at 3: /dev/null + 2 files)
1114        let result = fs.write_file(Path::new("/tmp/file3.txt"), b"new").await;
1115        assert!(result.is_err());
1116    }
1117
1118    #[tokio::test]
1119    async fn test_append_respects_limits() {
1120        let limits = FsLimits::new().max_file_size(100);
1121        let fs = InMemoryFs::with_limits(limits);
1122
1123        // Create file
1124        fs.write_file(Path::new("/tmp/append.txt"), &[0u8; 50])
1125            .await
1126            .unwrap();
1127
1128        // Append under limit - should succeed
1129        fs.append_file(Path::new("/tmp/append.txt"), &[0u8; 30])
1130            .await
1131            .unwrap();
1132
1133        // Append over limit - should fail
1134        let result = fs
1135            .append_file(Path::new("/tmp/append.txt"), &[0u8; 50])
1136            .await;
1137        assert!(result.is_err());
1138    }
1139
1140    #[tokio::test]
1141    async fn test_usage_tracking() {
1142        let fs = InMemoryFs::new();
1143
1144        // Initial usage (only default directories)
1145        let usage = fs.usage();
1146        assert_eq!(usage.total_bytes, 0); // No file content yet
1147        assert_eq!(usage.file_count, 1); // /dev/null
1148
1149        // Add a file
1150        fs.write_file(Path::new("/tmp/test.txt"), b"hello")
1151            .await
1152            .unwrap();
1153
1154        let usage = fs.usage();
1155        assert_eq!(usage.total_bytes, 5);
1156        assert_eq!(usage.file_count, 2); // /dev/null + test.txt
1157    }
1158
1159    #[tokio::test]
1160    async fn test_limits_method() {
1161        let limits = FsLimits::new()
1162            .max_total_bytes(1000)
1163            .max_file_size(500)
1164            .max_file_count(10);
1165        let fs = InMemoryFs::with_limits(limits.clone());
1166
1167        let returned = fs.limits();
1168        assert_eq!(returned.max_total_bytes, 1000);
1169        assert_eq!(returned.max_file_size, 500);
1170        assert_eq!(returned.max_file_count, 10);
1171    }
1172
1173    #[tokio::test]
1174    async fn test_unlimited_fs() {
1175        let fs = InMemoryFs::with_limits(FsLimits::unlimited());
1176
1177        // Should allow very large files
1178        fs.write_file(Path::new("/tmp/large.txt"), &[0u8; 10_000_000])
1179            .await
1180            .unwrap();
1181
1182        let limits = fs.limits();
1183        assert_eq!(limits.max_total_bytes, u64::MAX);
1184    }
1185
1186    #[tokio::test]
1187    async fn test_delete_frees_space() {
1188        let limits = FsLimits::new().max_total_bytes(100);
1189        let fs = InMemoryFs::with_limits(limits);
1190
1191        // Fill up space
1192        fs.write_file(Path::new("/tmp/file.txt"), &[0u8; 80])
1193            .await
1194            .unwrap();
1195
1196        // Can't add more
1197        let result = fs.write_file(Path::new("/tmp/more.txt"), &[0u8; 80]).await;
1198        assert!(result.is_err());
1199
1200        // Delete file
1201        fs.remove(Path::new("/tmp/file.txt"), false).await.unwrap();
1202
1203        // Now we can add
1204        fs.write_file(Path::new("/tmp/more.txt"), &[0u8; 80])
1205            .await
1206            .unwrap();
1207    }
1208
1209    // ==================== Type conflict tests ====================
1210
1211    #[tokio::test]
1212    async fn test_write_file_to_directory_fails() {
1213        let fs = InMemoryFs::new();
1214
1215        // Create a directory
1216        fs.mkdir(Path::new("/tmp/mydir"), false).await.unwrap();
1217
1218        // Attempt to write file at same path should fail
1219        let result = fs.write_file(Path::new("/tmp/mydir"), b"content").await;
1220        assert!(result.is_err());
1221        let err = result.unwrap_err();
1222        assert!(
1223            err.to_string().contains("directory"),
1224            "Error should mention directory: {}",
1225            err
1226        );
1227    }
1228
1229    #[tokio::test]
1230    async fn test_append_file_to_directory_fails() {
1231        let fs = InMemoryFs::new();
1232
1233        // Create a directory
1234        fs.mkdir(Path::new("/tmp/appenddir"), false).await.unwrap();
1235
1236        // Attempt to append to directory should fail
1237        let result = fs
1238            .append_file(Path::new("/tmp/appenddir"), b"content")
1239            .await;
1240        assert!(result.is_err());
1241        let err = result.unwrap_err();
1242        assert!(
1243            err.to_string().contains("directory"),
1244            "Error should mention directory: {}",
1245            err
1246        );
1247    }
1248
1249    #[tokio::test]
1250    async fn test_mkdir_on_existing_file_fails() {
1251        let fs = InMemoryFs::new();
1252
1253        // Create a file
1254        fs.write_file(Path::new("/tmp/myfile"), b"content")
1255            .await
1256            .unwrap();
1257
1258        // Attempt to mkdir at same path should fail
1259        let result = fs.mkdir(Path::new("/tmp/myfile"), false).await;
1260        assert!(result.is_err());
1261    }
1262
1263    #[tokio::test]
1264    async fn test_mkdir_recursive_on_existing_file_fails() {
1265        let fs = InMemoryFs::new();
1266
1267        // Create a file
1268        fs.write_file(Path::new("/tmp/myfile"), b"content")
1269            .await
1270            .unwrap();
1271
1272        // Attempt to mkdir -p at same path should also fail
1273        let result = fs.mkdir(Path::new("/tmp/myfile"), true).await;
1274        assert!(result.is_err());
1275    }
1276
1277    #[tokio::test]
1278    async fn test_mkdir_on_existing_directory_fails() {
1279        let fs = InMemoryFs::new();
1280
1281        // /tmp already exists as directory
1282        let result = fs.mkdir(Path::new("/tmp"), false).await;
1283        assert!(result.is_err());
1284    }
1285
1286    #[tokio::test]
1287    async fn test_mkdir_recursive_on_existing_directory_succeeds() {
1288        let fs = InMemoryFs::new();
1289
1290        // mkdir -p on existing directory should succeed
1291        let result = fs.mkdir(Path::new("/tmp"), true).await;
1292        assert!(result.is_ok());
1293    }
1294
1295    #[tokio::test]
1296    async fn test_write_file_overwrites_existing_file() {
1297        let fs = InMemoryFs::new();
1298
1299        // Create a file
1300        fs.write_file(Path::new("/tmp/file.txt"), b"original")
1301            .await
1302            .unwrap();
1303
1304        // Overwrite should succeed
1305        fs.write_file(Path::new("/tmp/file.txt"), b"updated")
1306            .await
1307            .unwrap();
1308
1309        let content = fs.read_file(Path::new("/tmp/file.txt")).await.unwrap();
1310        assert_eq!(content, b"updated");
1311    }
1312}