Skip to main content

bashkit/fs/
mountable.rs

1//! Mountable filesystem implementation.
2//!
3//! [`MountableFs`] allows mounting multiple filesystems at different paths,
4//! similar to Unix mount semantics.
5
6// RwLock.read()/write().unwrap() only panics on lock poisoning (prior panic
7// while holding lock). This is intentional - corrupted state should not propagate.
8#![allow(clippy::unwrap_used)]
9
10use async_trait::async_trait;
11use std::collections::BTreeMap;
12use std::io::Error as IoError;
13use std::path::{Path, PathBuf};
14use std::sync::{Arc, RwLock};
15
16use super::limits::{FsLimits, FsUsage};
17use super::traits::{DirEntry, FileSystem, FileType, Metadata};
18use crate::error::Result;
19use std::io::ErrorKind;
20
21/// Filesystem with Unix-style mount points.
22///
23/// `MountableFs` allows mounting different filesystem implementations at
24/// specific paths, similar to how Unix systems mount devices at directories.
25/// This enables complex multi-source filesystem setups.
26///
27/// # Features
28///
29/// - **Multiple mount points**: Mount different filesystems at different paths
30/// - **Nested mounts**: Mount filesystems within other mounts (longest-prefix matching)
31/// - **Dynamic mounting**: Add/remove mounts at runtime
32/// - **Cross-mount operations**: Copy/move files between different mounted filesystems
33///
34/// # Use Cases
35///
36/// - **Hybrid storage**: Combine in-memory temp storage with persistent data stores
37/// - **Multi-tenant isolation**: Mount separate filesystems for different tenants
38/// - **Plugin systems**: Each plugin gets its own mounted filesystem
39/// - **Testing**: Mount mock filesystems for specific paths
40///
41/// # Example: Basic Mounting
42///
43/// ```rust
44/// use bashkit::{Bash, FileSystem, InMemoryFs, MountableFs};
45/// use std::path::Path;
46/// use std::sync::Arc;
47///
48/// # #[tokio::main]
49/// # async fn main() -> bashkit::Result<()> {
50/// // Create root and separate data filesystem
51/// let root = Arc::new(InMemoryFs::new());
52/// let data_fs = Arc::new(InMemoryFs::new());
53///
54/// // Pre-populate data filesystem
55/// data_fs.write_file(Path::new("/users.json"), br#"["alice", "bob"]"#).await?;
56///
57/// // Create mountable filesystem
58/// let mountable = MountableFs::new(root.clone());
59///
60/// // Mount data_fs at /mnt/data
61/// mountable.mount("/mnt/data", data_fs.clone())?;
62///
63/// // Use with Bash
64/// let mut bash = Bash::builder().fs(Arc::new(mountable)).build();
65///
66/// // Access mounted filesystem
67/// let result = bash.exec("cat /mnt/data/users.json").await?;
68/// assert!(result.stdout.contains("alice"));
69///
70/// // Access root filesystem
71/// bash.exec("echo hello > /root.txt").await?;
72/// # Ok(())
73/// # }
74/// ```
75///
76/// # Example: Nested Mounts
77///
78/// ```rust
79/// use bashkit::{FileSystem, InMemoryFs, MountableFs};
80/// use std::path::Path;
81/// use std::sync::Arc;
82///
83/// # #[tokio::main]
84/// # async fn main() -> bashkit::Result<()> {
85/// let root = Arc::new(InMemoryFs::new());
86/// let outer = Arc::new(InMemoryFs::new());
87/// let inner = Arc::new(InMemoryFs::new());
88///
89/// outer.write_file(Path::new("/outer.txt"), b"outer").await?;
90/// inner.write_file(Path::new("/inner.txt"), b"inner").await?;
91///
92/// let mountable = MountableFs::new(root);
93/// mountable.mount("/mnt", outer)?;
94/// mountable.mount("/mnt/nested", inner)?;
95///
96/// // Access outer mount
97/// let content = mountable.read_file(Path::new("/mnt/outer.txt")).await?;
98/// assert_eq!(content, b"outer");
99///
100/// // Access nested mount (longest-prefix matching)
101/// let content = mountable.read_file(Path::new("/mnt/nested/inner.txt")).await?;
102/// assert_eq!(content, b"inner");
103/// # Ok(())
104/// # }
105/// ```
106///
107/// # Example: Dynamic Mount/Unmount
108///
109/// ```rust
110/// use bashkit::{FileSystem, InMemoryFs, MountableFs};
111/// use std::path::Path;
112/// use std::sync::Arc;
113///
114/// # #[tokio::main]
115/// # async fn main() -> bashkit::Result<()> {
116/// let root = Arc::new(InMemoryFs::new());
117/// let plugin_fs = Arc::new(InMemoryFs::new());
118/// plugin_fs.write_file(Path::new("/plugin.so"), b"binary").await?;
119///
120/// let mountable = MountableFs::new(root);
121///
122/// // Mount plugin filesystem
123/// mountable.mount("/plugins", plugin_fs)?;
124/// assert!(mountable.exists(Path::new("/plugins/plugin.so")).await?);
125///
126/// // Unmount when done
127/// mountable.unmount("/plugins")?;
128/// assert!(!mountable.exists(Path::new("/plugins/plugin.so")).await?);
129/// # Ok(())
130/// # }
131/// ```
132///
133/// # Path Resolution
134///
135/// When resolving a path, `MountableFs` uses longest-prefix matching to find
136/// the appropriate filesystem. For example, with mounts at `/mnt` and `/mnt/data`:
137///
138/// - `/mnt/file.txt` → resolves to `/mnt` mount
139/// - `/mnt/data/file.txt` → resolves to `/mnt/data` mount (longer prefix wins)
140/// - `/other/file.txt` → resolves to root filesystem
141pub struct MountableFs {
142    /// Root filesystem (for paths not covered by any mount)
143    root: Arc<dyn FileSystem>,
144    /// Mount points: path -> filesystem
145    /// BTreeMap ensures iteration in path order
146    mounts: RwLock<BTreeMap<PathBuf, Arc<dyn FileSystem>>>,
147}
148
149impl MountableFs {
150    /// Create a new `MountableFs` with the given root filesystem.
151    ///
152    /// The root filesystem is used for all paths that don't match any mount point.
153    ///
154    /// # Example
155    ///
156    /// ```rust
157    /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
158    /// use std::path::Path;
159    /// use std::sync::Arc;
160    ///
161    /// # #[tokio::main]
162    /// # async fn main() -> bashkit::Result<()> {
163    /// let root = Arc::new(InMemoryFs::new());
164    /// let mountable = MountableFs::new(root);
165    ///
166    /// // Paths not covered by mounts go to root
167    /// mountable.write_file(Path::new("/tmp/test.txt"), b"hello").await?;
168    /// # Ok(())
169    /// # }
170    /// ```
171    pub fn new(root: Arc<dyn FileSystem>) -> Self {
172        Self {
173            root,
174            mounts: RwLock::new(BTreeMap::new()),
175        }
176    }
177
178    /// Mount a filesystem at the given path.
179    ///
180    /// After mounting, all operations on paths under the mount point will be
181    /// directed to the mounted filesystem.
182    ///
183    /// # Arguments
184    ///
185    /// * `path` - The mount point (must be an absolute path)
186    /// * `fs` - The filesystem to mount
187    ///
188    /// # Errors
189    ///
190    /// Returns an error if the path is not absolute.
191    ///
192    /// # Example
193    ///
194    /// ```rust
195    /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
196    /// use std::path::Path;
197    /// use std::sync::Arc;
198    ///
199    /// # #[tokio::main]
200    /// # async fn main() -> bashkit::Result<()> {
201    /// let root = Arc::new(InMemoryFs::new());
202    /// let data_fs = Arc::new(InMemoryFs::new());
203    /// data_fs.write_file(Path::new("/data.txt"), b"data").await?;
204    ///
205    /// let mountable = MountableFs::new(root);
206    /// mountable.mount("/data", data_fs)?;
207    ///
208    /// // Access via mount point
209    /// let content = mountable.read_file(Path::new("/data/data.txt")).await?;
210    /// assert_eq!(content, b"data");
211    /// # Ok(())
212    /// # }
213    /// ```
214    pub fn mount(&self, path: impl AsRef<Path>, fs: Arc<dyn FileSystem>) -> Result<()> {
215        let path = Self::normalize_path(path.as_ref());
216
217        if !path.is_absolute() {
218            return Err(IoError::other("mount path must be absolute").into());
219        }
220
221        let mut mounts = self.mounts.write().unwrap();
222        mounts.insert(path, fs);
223        Ok(())
224    }
225
226    /// Unmount a filesystem at the given path.
227    ///
228    /// After unmounting, paths that previously resolved to the mounted filesystem
229    /// will fall back to the root filesystem or a shorter mount prefix.
230    ///
231    /// # Errors
232    ///
233    /// Returns an error if no filesystem is mounted at the given path.
234    ///
235    /// # Example
236    ///
237    /// ```rust
238    /// use bashkit::{FileSystem, InMemoryFs, MountableFs};
239    /// use std::path::Path;
240    /// use std::sync::Arc;
241    ///
242    /// # #[tokio::main]
243    /// # async fn main() -> bashkit::Result<()> {
244    /// let root = Arc::new(InMemoryFs::new());
245    /// let plugin = Arc::new(InMemoryFs::new());
246    /// plugin.write_file(Path::new("/lib.so"), b"binary").await?;
247    ///
248    /// let mountable = MountableFs::new(root);
249    /// mountable.mount("/plugin", plugin)?;
250    ///
251    /// // File is accessible
252    /// assert!(mountable.exists(Path::new("/plugin/lib.so")).await?);
253    ///
254    /// // Unmount
255    /// mountable.unmount("/plugin")?;
256    ///
257    /// // No longer accessible
258    /// assert!(!mountable.exists(Path::new("/plugin/lib.so")).await?);
259    /// # Ok(())
260    /// # }
261    /// ```
262    pub fn unmount(&self, path: impl AsRef<Path>) -> Result<()> {
263        let path = Self::normalize_path(path.as_ref());
264
265        let mut mounts = self.mounts.write().unwrap();
266        mounts
267            .remove(&path)
268            .ok_or_else(|| IoError::other("mount not found"))?;
269        Ok(())
270    }
271
272    /// Normalize a path for consistent lookups
273    fn normalize_path(path: &Path) -> PathBuf {
274        let mut result = PathBuf::new();
275
276        for component in path.components() {
277            match component {
278                std::path::Component::RootDir => {
279                    result.push("/");
280                }
281                std::path::Component::Normal(name) => {
282                    result.push(name);
283                }
284                std::path::Component::ParentDir => {
285                    result.pop();
286                }
287                std::path::Component::CurDir => {}
288                std::path::Component::Prefix(_) => {}
289            }
290        }
291
292        if result.as_os_str().is_empty() {
293            result.push("/");
294        }
295
296        result
297    }
298
299    /// THREAT[TM-DOS-046]: Validate path using root filesystem limits before delegation.
300    fn validate_path(&self, path: &Path) -> Result<()> {
301        self.root
302            .limits()
303            .validate_path(path)
304            .map_err(|e| IoError::new(ErrorKind::InvalidInput, e.to_string()))?;
305        Ok(())
306    }
307
308    /// Resolve a path to the appropriate filesystem and relative path.
309    ///
310    /// Returns (filesystem, path_within_mount).
311    fn resolve(&self, path: &Path) -> (Arc<dyn FileSystem>, PathBuf) {
312        let path = Self::normalize_path(path);
313        let mounts = self.mounts.read().unwrap();
314
315        // Find the longest matching mount point
316        // BTreeMap iteration is in key order, but we need longest match
317        // So we iterate and keep track of the best match
318        let mut best_mount: Option<(&PathBuf, &Arc<dyn FileSystem>)> = None;
319
320        for (mount_path, fs) in mounts.iter() {
321            if path.starts_with(mount_path) {
322                match best_mount {
323                    None => best_mount = Some((mount_path, fs)),
324                    Some((best_path, _)) => {
325                        if mount_path.components().count() > best_path.components().count() {
326                            best_mount = Some((mount_path, fs));
327                        }
328                    }
329                }
330            }
331        }
332
333        match best_mount {
334            Some((mount_path, fs)) => {
335                // Calculate relative path within mount
336                let relative = path
337                    .strip_prefix(mount_path)
338                    .unwrap_or(Path::new(""))
339                    .to_path_buf();
340
341                // Ensure we have an absolute path
342                let resolved = if relative.as_os_str().is_empty() {
343                    PathBuf::from("/")
344                } else {
345                    PathBuf::from("/").join(relative)
346                };
347
348                (Arc::clone(fs), resolved)
349            }
350            None => {
351                // Use root filesystem
352                (Arc::clone(&self.root), path)
353            }
354        }
355    }
356}
357
358#[async_trait]
359impl FileSystem for MountableFs {
360    async fn read_file(&self, path: &Path) -> Result<Vec<u8>> {
361        let (fs, resolved) = self.resolve(path);
362        fs.read_file(&resolved).await
363    }
364
365    async fn write_file(&self, path: &Path, content: &[u8]) -> Result<()> {
366        // THREAT[TM-DOS-046]: Validate path before delegation
367        self.validate_path(path)?;
368        let (fs, resolved) = self.resolve(path);
369        fs.write_file(&resolved, content).await
370    }
371
372    async fn append_file(&self, path: &Path, content: &[u8]) -> Result<()> {
373        self.validate_path(path)?;
374        let (fs, resolved) = self.resolve(path);
375        fs.append_file(&resolved, content).await
376    }
377
378    async fn mkdir(&self, path: &Path, recursive: bool) -> Result<()> {
379        self.validate_path(path)?;
380        let (fs, resolved) = self.resolve(path);
381        fs.mkdir(&resolved, recursive).await
382    }
383
384    async fn remove(&self, path: &Path, recursive: bool) -> Result<()> {
385        self.validate_path(path)?;
386        let (fs, resolved) = self.resolve(path);
387        fs.remove(&resolved, recursive).await
388    }
389
390    async fn stat(&self, path: &Path) -> Result<Metadata> {
391        let (fs, resolved) = self.resolve(path);
392        fs.stat(&resolved).await
393    }
394
395    async fn read_dir(&self, path: &Path) -> Result<Vec<DirEntry>> {
396        let path = Self::normalize_path(path);
397        let (fs, resolved) = self.resolve(&path);
398
399        let mut entries = fs.read_dir(&resolved).await?;
400
401        // Add mount points that are direct children of this directory
402        let mounts = self.mounts.read().unwrap();
403        for mount_path in mounts.keys() {
404            if mount_path.parent() == Some(&path) {
405                if let Some(name) = mount_path.file_name() {
406                    // Check if this entry already exists
407                    let name_str = name.to_string_lossy().to_string();
408                    if !entries.iter().any(|e| e.name == name_str) {
409                        entries.push(DirEntry {
410                            name: name_str,
411                            metadata: Metadata {
412                                file_type: FileType::Directory,
413                                size: 0,
414                                mode: 0o755,
415                                modified: std::time::SystemTime::now(),
416                                created: std::time::SystemTime::now(),
417                            },
418                        });
419                    }
420                }
421            }
422        }
423
424        Ok(entries)
425    }
426
427    async fn exists(&self, path: &Path) -> Result<bool> {
428        let path = Self::normalize_path(path);
429
430        // Check if this is a mount point
431        {
432            let mounts = self.mounts.read().unwrap();
433            if mounts.contains_key(&path) {
434                return Ok(true);
435            }
436        }
437
438        let (fs, resolved) = self.resolve(&path);
439        fs.exists(&resolved).await
440    }
441
442    async fn rename(&self, from: &Path, to: &Path) -> Result<()> {
443        self.validate_path(from)?;
444        self.validate_path(to)?;
445        let (from_fs, from_resolved) = self.resolve(from);
446        let (to_fs, to_resolved) = self.resolve(to);
447
448        // Check if both paths resolve to the same filesystem
449        // We can only do efficient rename within the same filesystem
450        // For cross-mount rename, we need to copy + delete
451        if Arc::ptr_eq(&from_fs, &to_fs) {
452            from_fs.rename(&from_resolved, &to_resolved).await
453        } else {
454            // Cross-mount rename: copy then delete
455            let content = from_fs.read_file(&from_resolved).await?;
456            to_fs.write_file(&to_resolved, &content).await?;
457            from_fs.remove(&from_resolved, false).await
458        }
459    }
460
461    async fn copy(&self, from: &Path, to: &Path) -> Result<()> {
462        self.validate_path(from)?;
463        self.validate_path(to)?;
464        let (from_fs, from_resolved) = self.resolve(from);
465        let (to_fs, to_resolved) = self.resolve(to);
466
467        if Arc::ptr_eq(&from_fs, &to_fs) {
468            from_fs.copy(&from_resolved, &to_resolved).await
469        } else {
470            // Cross-mount copy
471            let content = from_fs.read_file(&from_resolved).await?;
472            to_fs.write_file(&to_resolved, &content).await
473        }
474    }
475
476    async fn symlink(&self, target: &Path, link: &Path) -> Result<()> {
477        self.validate_path(link)?;
478        let (fs, resolved) = self.resolve(link);
479        fs.symlink(target, &resolved).await
480    }
481
482    async fn read_link(&self, path: &Path) -> Result<PathBuf> {
483        let (fs, resolved) = self.resolve(path);
484        fs.read_link(&resolved).await
485    }
486
487    async fn chmod(&self, path: &Path, mode: u32) -> Result<()> {
488        self.validate_path(path)?;
489        let (fs, resolved) = self.resolve(path);
490        fs.chmod(&resolved, mode).await
491    }
492
493    fn usage(&self) -> FsUsage {
494        // Aggregate usage from root and all mounts
495        let mut total = self.root.usage();
496
497        let mounts = self.mounts.read().unwrap();
498        for fs in mounts.values() {
499            let mount_usage = fs.usage();
500            total.total_bytes += mount_usage.total_bytes;
501            total.file_count += mount_usage.file_count;
502            total.dir_count += mount_usage.dir_count;
503        }
504
505        total
506    }
507
508    fn limits(&self) -> FsLimits {
509        // Return root filesystem limits as the overall limits
510        self.root.limits()
511    }
512}
513
514#[cfg(test)]
515#[allow(clippy::unwrap_used)]
516mod tests {
517    use super::*;
518    use crate::fs::InMemoryFs;
519
520    #[tokio::test]
521    async fn test_mount_and_access() {
522        let root = Arc::new(InMemoryFs::new());
523        let mounted = Arc::new(InMemoryFs::new());
524
525        // Write to mounted fs
526        mounted
527            .write_file(Path::new("/data.txt"), b"mounted data")
528            .await
529            .unwrap();
530
531        let mfs = MountableFs::new(root.clone());
532        mfs.mount("/mnt/data", mounted.clone()).unwrap();
533
534        // Access through mountable fs
535        let content = mfs
536            .read_file(Path::new("/mnt/data/data.txt"))
537            .await
538            .unwrap();
539        assert_eq!(content, b"mounted data");
540    }
541
542    #[tokio::test]
543    async fn test_write_to_mount() {
544        let root = Arc::new(InMemoryFs::new());
545        let mounted = Arc::new(InMemoryFs::new());
546
547        let mfs = MountableFs::new(root);
548        mfs.mount("/mnt", mounted.clone()).unwrap();
549
550        // Create directory and write file through mountable
551        mfs.mkdir(Path::new("/mnt/subdir"), false).await.unwrap();
552        mfs.write_file(Path::new("/mnt/subdir/test.txt"), b"hello")
553            .await
554            .unwrap();
555
556        // Verify it's in the mounted fs
557        let content = mounted
558            .read_file(Path::new("/subdir/test.txt"))
559            .await
560            .unwrap();
561        assert_eq!(content, b"hello");
562    }
563
564    #[tokio::test]
565    async fn test_nested_mounts() {
566        let root = Arc::new(InMemoryFs::new());
567        let outer = Arc::new(InMemoryFs::new());
568        let inner = Arc::new(InMemoryFs::new());
569
570        outer
571            .write_file(Path::new("/outer.txt"), b"outer")
572            .await
573            .unwrap();
574        inner
575            .write_file(Path::new("/inner.txt"), b"inner")
576            .await
577            .unwrap();
578
579        let mfs = MountableFs::new(root);
580        mfs.mount("/mnt", outer).unwrap();
581        mfs.mount("/mnt/nested", inner).unwrap();
582
583        // Access outer mount
584        let content = mfs.read_file(Path::new("/mnt/outer.txt")).await.unwrap();
585        assert_eq!(content, b"outer");
586
587        // Access nested mount
588        let content = mfs
589            .read_file(Path::new("/mnt/nested/inner.txt"))
590            .await
591            .unwrap();
592        assert_eq!(content, b"inner");
593    }
594
595    #[tokio::test]
596    async fn test_root_fallback() {
597        let root = Arc::new(InMemoryFs::new());
598        root.write_file(Path::new("/root.txt"), b"root data")
599            .await
600            .unwrap();
601
602        let mfs = MountableFs::new(root);
603
604        // Should access root fs
605        let content = mfs.read_file(Path::new("/root.txt")).await.unwrap();
606        assert_eq!(content, b"root data");
607    }
608
609    #[tokio::test]
610    async fn test_mount_point_in_readdir() {
611        let root = Arc::new(InMemoryFs::new());
612        let mounted = Arc::new(InMemoryFs::new());
613
614        let mfs = MountableFs::new(root);
615        mfs.mount("/mnt", mounted).unwrap();
616
617        // Read root directory should show mnt
618        let entries = mfs.read_dir(Path::new("/")).await.unwrap();
619        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
620        assert!(names.contains(&&"mnt".to_string()));
621    }
622
623    #[tokio::test]
624    async fn test_unmount() {
625        let root = Arc::new(InMemoryFs::new());
626        let mounted = Arc::new(InMemoryFs::new());
627        mounted
628            .write_file(Path::new("/data.txt"), b"data")
629            .await
630            .unwrap();
631
632        let mfs = MountableFs::new(root);
633        mfs.mount("/mnt", mounted).unwrap();
634
635        // Should exist
636        assert!(mfs.exists(Path::new("/mnt/data.txt")).await.unwrap());
637
638        // Unmount
639        mfs.unmount("/mnt").unwrap();
640
641        // Should no longer exist (falls back to root which doesn't have it)
642        assert!(!mfs.exists(Path::new("/mnt/data.txt")).await.unwrap());
643    }
644}