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