Skip to main content

rust_bash/vfs/
mountable.rs

1//! MountableFs — composite filesystem that delegates to different backends
2//! based on longest-prefix mount point matching.
3//!
4//! Each mount point maps an absolute path to a `VirtualFs` backend. When an
5//! operation arrives, MountableFs finds the longest mount prefix that matches
6//! the path, strips the prefix, re-roots the remainder as absolute, and
7//! delegates to that backend.
8//!
9//! Mounting at `"/"` provides a default fallback for all paths.
10
11use std::collections::BTreeMap;
12use std::path::{Path, PathBuf};
13use std::sync::Arc;
14
15use crate::platform::SystemTime;
16
17use parking_lot::RwLock;
18
19use super::{DirEntry, Metadata, NodeType, VirtualFs};
20use crate::error::VfsError;
21use crate::interpreter::pattern::glob_match;
22
23/// Result of resolving two paths to their respective mounts.
24struct MountPair {
25    src_fs: Arc<dyn VirtualFs>,
26    src_rel: PathBuf,
27    dst_fs: Arc<dyn VirtualFs>,
28    dst_rel: PathBuf,
29    same: bool,
30}
31
32/// A composite filesystem that delegates to mounted backends via longest-prefix
33/// matching.
34///
35/// # Example
36///
37/// ```ignore
38/// use rust_bash::{RustBashBuilder, InMemoryFs, MountableFs, OverlayFs};
39/// use std::sync::Arc;
40///
41/// let mountable = MountableFs::new()
42///     .mount("/", Arc::new(InMemoryFs::new()))
43///     .mount("/project", Arc::new(OverlayFs::new("./myproject").unwrap()));
44///
45/// let mut shell = RustBashBuilder::new()
46///     .fs(Arc::new(mountable))
47///     .cwd("/")
48///     .build()
49///     .unwrap();
50/// ```
51pub struct MountableFs {
52    mounts: Arc<RwLock<BTreeMap<PathBuf, Arc<dyn VirtualFs>>>>,
53}
54
55impl MountableFs {
56    /// Create an empty MountableFs with no mount points.
57    pub fn new() -> Self {
58        Self {
59            mounts: Arc::new(RwLock::new(BTreeMap::new())),
60        }
61    }
62
63    /// Mount a filesystem backend at the given absolute path.
64    ///
65    /// Paths must be absolute. Mounting at `"/"` provides the default fallback.
66    /// Later mounts at the same path replace earlier ones.
67    pub fn mount(self, path: impl Into<PathBuf>, fs: Arc<dyn VirtualFs>) -> Self {
68        let path = path.into();
69        assert!(
70            super::vfs_path_is_absolute(&path),
71            "mount path must be absolute: {path:?}"
72        );
73        self.mounts.write().insert(path, fs);
74        self
75    }
76
77    /// Find the mount that owns the given path.
78    ///
79    /// Returns the mount's filesystem and the path relative to the mount point,
80    /// re-rooted as absolute (prepended with `/`).
81    ///
82    /// BTreeMap sorts lexicographically, so `/project/src` > `/project`.
83    /// Iterating in reverse gives longest-prefix first.
84    fn resolve_mount(&self, path: &Path) -> Result<(Arc<dyn VirtualFs>, PathBuf), VfsError> {
85        let mounts = self.mounts.read();
86        for (mount_point, fs) in mounts.iter().rev() {
87            if path.starts_with(mount_point) {
88                let relative = path.strip_prefix(mount_point).unwrap_or(Path::new(""));
89                let resolved = if relative.as_os_str().is_empty() {
90                    PathBuf::from("/")
91                } else {
92                    PathBuf::from("/").join(relative)
93                };
94                return Ok((Arc::clone(fs), resolved));
95            }
96        }
97        Err(VfsError::NotFound(path.to_path_buf()))
98    }
99
100    /// Resolve mount for two paths (used by copy/rename/hardlink).
101    fn resolve_two(&self, src: &Path, dst: &Path) -> Result<MountPair, VfsError> {
102        let mounts = self.mounts.read();
103        let resolve_one =
104            |path: &Path| -> Result<(Arc<dyn VirtualFs>, PathBuf, PathBuf), VfsError> {
105                for (mount_point, fs) in mounts.iter().rev() {
106                    if path.starts_with(mount_point) {
107                        let relative = path.strip_prefix(mount_point).unwrap_or(Path::new(""));
108                        let resolved = if relative.as_os_str().is_empty() {
109                            PathBuf::from("/")
110                        } else {
111                            PathBuf::from("/").join(relative)
112                        };
113                        return Ok((Arc::clone(fs), resolved, mount_point.clone()));
114                    }
115                }
116                Err(VfsError::NotFound(path.to_path_buf()))
117            };
118
119        let (src_fs, src_rel, src_mount) = resolve_one(src)?;
120        let (dst_fs, dst_rel, dst_mount) = resolve_one(dst)?;
121        let same = src_mount == dst_mount;
122        Ok(MountPair {
123            src_fs,
124            src_rel,
125            dst_fs,
126            dst_rel,
127            same,
128        })
129    }
130
131    /// Collect synthetic directory entries from mount points that are direct
132    /// children of `dir_path`. For example, if mounts exist at `/project` and
133    /// `/project/src`, listing `/` should include `project` and listing
134    /// `/project` should include `src`.
135    fn synthetic_mount_entries(&self, dir_path: &Path) -> Vec<DirEntry> {
136        let mounts = self.mounts.read();
137        let mut entries = Vec::new();
138        let dir_str = dir_path.to_string_lossy();
139        let prefix = if dir_str == "/" {
140            "/".to_string()
141        } else {
142            format!("{}/", dir_str.trim_end_matches('/'))
143        };
144
145        for mount_point in mounts.keys() {
146            // Skip the mount if it IS the directory itself.
147            if mount_point == dir_path {
148                continue;
149            }
150            let mp_str = mount_point.to_string_lossy();
151            if let Some(rest) = mp_str.strip_prefix(&prefix)
152                && !rest.is_empty()
153            {
154                // Take only the first path component (handles deep mounts
155                // like /a/b/c when listing /a).
156                let first_component = rest.split('/').next().unwrap();
157                if !entries.iter().any(|e: &DirEntry| e.name == first_component) {
158                    entries.push(DirEntry {
159                        name: first_component.to_string(),
160                        node_type: NodeType::Directory,
161                    });
162                }
163            }
164        }
165        entries
166    }
167
168    /// Recursive glob walker that spans mount boundaries.
169    fn glob_walk(
170        &self,
171        dir: &Path,
172        components: &[&str],
173        current_path: PathBuf,
174        results: &mut Vec<PathBuf>,
175        max: usize,
176    ) {
177        if results.len() >= max || components.is_empty() {
178            if components.is_empty() {
179                results.push(current_path);
180            }
181            return;
182        }
183
184        let pattern = components[0];
185        let rest = &components[1..];
186
187        // Get entries from the mounted fs (if any) merged with synthetic mount entries.
188        let entries = self.merged_readdir_for_glob(dir);
189
190        if pattern == "**" {
191            // Zero directories — advance past **
192            self.glob_walk(dir, rest, current_path.clone(), results, max);
193
194            for entry in &entries {
195                if results.len() >= max {
196                    return;
197                }
198                if entry.name.starts_with('.') {
199                    continue;
200                }
201                let child_path = current_path.join(&entry.name);
202                let child_dir = dir.join(&entry.name);
203                if entry.node_type == NodeType::Directory || entry.node_type == NodeType::Symlink {
204                    self.glob_walk(&child_dir, components, child_path, results, max);
205                }
206            }
207        } else {
208            for entry in &entries {
209                if results.len() >= max {
210                    return;
211                }
212                if entry.name.starts_with('.') && !pattern.starts_with('.') {
213                    continue;
214                }
215                if glob_match(pattern, &entry.name) {
216                    let child_path = current_path.join(&entry.name);
217                    let child_dir = dir.join(&entry.name);
218                    if rest.is_empty() {
219                        results.push(child_path);
220                    } else if entry.node_type == NodeType::Directory
221                        || entry.node_type == NodeType::Symlink
222                    {
223                        self.glob_walk(&child_dir, rest, child_path, results, max);
224                    }
225                }
226            }
227        }
228    }
229
230    /// Get directory entries for glob walking: real entries from the mount
231    /// merged with synthetic mount-point entries.
232    fn merged_readdir_for_glob(&self, dir: &Path) -> Vec<DirEntry> {
233        let mut entries = match self.resolve_mount(dir) {
234            Ok((fs, rel)) => fs.readdir(&rel).unwrap_or_default(),
235            Err(_) => Vec::new(),
236        };
237
238        // Add synthetic entries for child mount points.
239        let synthetics = self.synthetic_mount_entries(dir);
240        let existing_names: std::collections::HashSet<String> =
241            entries.iter().map(|e| e.name.clone()).collect();
242        for s in synthetics {
243            if !existing_names.contains(&s.name) {
244                entries.push(s);
245            }
246        }
247        entries
248    }
249}
250
251impl Default for MountableFs {
252    fn default() -> Self {
253        Self::new()
254    }
255}
256
257// ---------------------------------------------------------------------------
258// VirtualFs implementation
259// ---------------------------------------------------------------------------
260
261impl VirtualFs for MountableFs {
262    fn read_file(&self, path: &Path) -> Result<Vec<u8>, VfsError> {
263        let (fs, rel) = self.resolve_mount(path)?;
264        fs.read_file(&rel)
265    }
266
267    fn write_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {
268        let (fs, rel) = self.resolve_mount(path)?;
269        fs.write_file(&rel, content)
270    }
271
272    fn append_file(&self, path: &Path, content: &[u8]) -> Result<(), VfsError> {
273        let (fs, rel) = self.resolve_mount(path)?;
274        fs.append_file(&rel, content)
275    }
276
277    fn remove_file(&self, path: &Path) -> Result<(), VfsError> {
278        let (fs, rel) = self.resolve_mount(path)?;
279        fs.remove_file(&rel)
280    }
281
282    fn mkdir(&self, path: &Path) -> Result<(), VfsError> {
283        let (fs, rel) = self.resolve_mount(path)?;
284        fs.mkdir(&rel)
285    }
286
287    fn mkdir_p(&self, path: &Path) -> Result<(), VfsError> {
288        let (fs, rel) = self.resolve_mount(path)?;
289        fs.mkdir_p(&rel)
290    }
291
292    fn readdir(&self, path: &Path) -> Result<Vec<DirEntry>, VfsError> {
293        // Track whether the underlying mount confirmed this directory exists.
294        let (mut entries, mount_ok) = match self.resolve_mount(path) {
295            Ok((fs, rel)) => match fs.readdir(&rel) {
296                Ok(e) => (e, true),
297                Err(_) => (Vec::new(), false),
298            },
299            Err(_) => (Vec::new(), false),
300        };
301
302        // Merge in synthetic entries from child mount points.
303        let synthetics = self.synthetic_mount_entries(path);
304        let existing_names: std::collections::HashSet<String> =
305            entries.iter().map(|e| e.name.clone()).collect();
306        for s in synthetics {
307            if !existing_names.contains(&s.name) {
308                entries.push(s);
309            }
310        }
311
312        // Only return NotFound when the mount itself errored AND there are no
313        // synthetic entries from child mounts. An empty directory that the
314        // mount confirmed is legitimate.
315        if !mount_ok && entries.is_empty() {
316            return Err(VfsError::NotFound(path.to_path_buf()));
317        }
318        Ok(entries)
319    }
320
321    fn remove_dir(&self, path: &Path) -> Result<(), VfsError> {
322        let (fs, rel) = self.resolve_mount(path)?;
323        fs.remove_dir(&rel)
324    }
325
326    fn remove_dir_all(&self, path: &Path) -> Result<(), VfsError> {
327        let (fs, rel) = self.resolve_mount(path)?;
328        fs.remove_dir_all(&rel)
329    }
330
331    fn exists(&self, path: &Path) -> bool {
332        // A path exists if its owning mount says so, OR if it is itself a
333        // mount point (mount points are treated as existing directories).
334        if let Ok((fs, rel)) = self.resolve_mount(path)
335            && fs.exists(&rel)
336        {
337            return true;
338        }
339        // Check if this exact path is a mount point.
340        let mounts = self.mounts.read();
341        if mounts.contains_key(path) {
342            return true;
343        }
344        // Check if any mount is a descendant (making this a synthetic parent).
345        let prefix = if path == Path::new("/") {
346            "/".to_string()
347        } else {
348            format!("{}/", path.to_string_lossy().trim_end_matches('/'))
349        };
350        mounts
351            .keys()
352            .any(|mp| mp.to_string_lossy().starts_with(&prefix))
353    }
354
355    fn stat(&self, path: &Path) -> Result<Metadata, VfsError> {
356        // Try the owning mount first.
357        if let Ok((fs, rel)) = self.resolve_mount(path)
358            && let Ok(m) = fs.stat(&rel)
359        {
360            return Ok(m);
361        }
362        // If this path is a mount point or has child mounts, return synthetic
363        // directory metadata.
364        if self.is_mount_point_or_ancestor(path) {
365            return Ok(Metadata {
366                node_type: NodeType::Directory,
367                size: 0,
368                mode: 0o755,
369                mtime: SystemTime::UNIX_EPOCH,
370            });
371        }
372        Err(VfsError::NotFound(path.to_path_buf()))
373    }
374
375    fn lstat(&self, path: &Path) -> Result<Metadata, VfsError> {
376        if let Ok((fs, rel)) = self.resolve_mount(path)
377            && let Ok(m) = fs.lstat(&rel)
378        {
379            return Ok(m);
380        }
381        if self.is_mount_point_or_ancestor(path) {
382            return Ok(Metadata {
383                node_type: NodeType::Directory,
384                size: 0,
385                mode: 0o755,
386                mtime: SystemTime::UNIX_EPOCH,
387            });
388        }
389        Err(VfsError::NotFound(path.to_path_buf()))
390    }
391
392    fn chmod(&self, path: &Path, mode: u32) -> Result<(), VfsError> {
393        let (fs, rel) = self.resolve_mount(path)?;
394        fs.chmod(&rel, mode)
395    }
396
397    fn utimes(&self, path: &Path, mtime: SystemTime) -> Result<(), VfsError> {
398        let (fs, rel) = self.resolve_mount(path)?;
399        fs.utimes(&rel, mtime)
400    }
401
402    fn symlink(&self, target: &Path, link: &Path) -> Result<(), VfsError> {
403        let (link_fs, link_rel) = self.resolve_mount(link)?;
404        // If the target is absolute and resolves to the same mount as the link,
405        // remap it into the mount's namespace so the underlying FS can follow it.
406        let remapped_target = if target.is_absolute() {
407            if let Ok((_, target_rel)) = self.resolve_mount(target) {
408                // Find mount point for the link to compare
409                let link_mount = self.mount_point_for(link);
410                let target_mount = self.mount_point_for(target);
411                if link_mount == target_mount {
412                    target_rel
413                } else {
414                    target.to_path_buf()
415                }
416            } else {
417                target.to_path_buf()
418            }
419        } else {
420            target.to_path_buf()
421        };
422        link_fs.symlink(&remapped_target, &link_rel)
423    }
424
425    fn hardlink(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
426        let pair = self.resolve_two(src, dst)?;
427        if !pair.same {
428            return Err(VfsError::IoError(
429                "hard links across mount boundaries are not supported".to_string(),
430            ));
431        }
432        pair.src_fs.hardlink(&pair.src_rel, &pair.dst_rel)
433    }
434
435    fn readlink(&self, path: &Path) -> Result<PathBuf, VfsError> {
436        let (fs, rel) = self.resolve_mount(path)?;
437        let target = fs.readlink(&rel)?;
438        // If the target is absolute and the link lives at a non-root mount,
439        // remap the target back to the global namespace.
440        if target.is_absolute() {
441            let mount_point = self.mount_point_for(path);
442            if mount_point != Path::new("/") {
443                let inner_rel = target.strip_prefix("/").unwrap_or(&target);
444                if inner_rel.as_os_str().is_empty() {
445                    return Ok(mount_point);
446                }
447                return Ok(mount_point.join(inner_rel));
448            }
449        }
450        Ok(target)
451    }
452
453    fn canonicalize(&self, path: &Path) -> Result<PathBuf, VfsError> {
454        let (fs, rel) = self.resolve_mount(path)?;
455        let canonical_in_mount = fs.canonicalize(&rel)?;
456        // Re-root back to global namespace: find what mount we used, prepend
457        // the mount point.
458        let mounts = self.mounts.read();
459        for (mount_point, _) in mounts.iter().rev() {
460            if path.starts_with(mount_point) {
461                if mount_point == Path::new("/") {
462                    return Ok(canonical_in_mount);
463                }
464                let inner_rel = canonical_in_mount
465                    .strip_prefix("/")
466                    .unwrap_or(&canonical_in_mount);
467                if inner_rel.as_os_str().is_empty() {
468                    return Ok(mount_point.clone());
469                }
470                return Ok(mount_point.join(inner_rel));
471            }
472        }
473        Ok(canonical_in_mount)
474    }
475
476    fn copy(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
477        let pair = self.resolve_two(src, dst)?;
478        if pair.same {
479            pair.src_fs.copy(&pair.src_rel, &pair.dst_rel)
480        } else {
481            let content = pair.src_fs.read_file(&pair.src_rel)?;
482            pair.dst_fs.write_file(&pair.dst_rel, &content)
483        }
484    }
485
486    fn rename(&self, src: &Path, dst: &Path) -> Result<(), VfsError> {
487        let pair = self.resolve_two(src, dst)?;
488        if pair.same {
489            pair.src_fs.rename(&pair.src_rel, &pair.dst_rel)
490        } else {
491            // Check if source is a directory — cross-mount directory rename
492            // is not supported (would need recursive copy).
493            if let Ok(m) = pair.src_fs.stat(&pair.src_rel)
494                && m.node_type == NodeType::Directory
495            {
496                return Err(VfsError::IoError(
497                    "rename of directories across mount boundaries is not supported".to_string(),
498                ));
499            }
500            let content = pair.src_fs.read_file(&pair.src_rel)?;
501            pair.dst_fs.write_file(&pair.dst_rel, &content)?;
502            pair.src_fs.remove_file(&pair.src_rel)
503        }
504    }
505
506    // TODO: MountableFs::glob does not yet honor GlobOptions (dotglob, nocaseglob, globstar).
507    // Its glob_walk traversal needs refactoring to accept options.
508    fn glob(&self, pattern: &str, cwd: &Path) -> Result<Vec<PathBuf>, VfsError> {
509        let is_absolute = pattern.starts_with('/');
510        let abs_pattern = if is_absolute {
511            pattern.to_string()
512        } else {
513            let cwd_str = cwd.to_str().unwrap_or("/").trim_end_matches('/');
514            format!("{cwd_str}/{pattern}")
515        };
516
517        let components: Vec<&str> = abs_pattern.split('/').filter(|s| !s.is_empty()).collect();
518        let mut results = Vec::new();
519        let max = 100_000;
520        self.glob_walk(
521            Path::new("/"),
522            &components,
523            PathBuf::from("/"),
524            &mut results,
525            max,
526        );
527
528        results.sort();
529        results.dedup();
530
531        if !is_absolute {
532            results = results
533                .into_iter()
534                .filter_map(|p| p.strip_prefix(cwd).ok().map(|r| r.to_path_buf()))
535                .collect();
536        }
537
538        Ok(results)
539    }
540
541    fn deep_clone(&self) -> Arc<dyn VirtualFs> {
542        let mounts = self.mounts.read();
543        let cloned_mounts: BTreeMap<PathBuf, Arc<dyn VirtualFs>> = mounts
544            .iter()
545            .map(|(path, fs)| (path.clone(), fs.deep_clone()))
546            .collect();
547        Arc::new(MountableFs {
548            mounts: Arc::new(RwLock::new(cloned_mounts)),
549        })
550    }
551}
552
553// ---------------------------------------------------------------------------
554// Private helpers
555// ---------------------------------------------------------------------------
556
557impl MountableFs {
558    /// Returns true if `path` is a mount point or an ancestor of one.
559    fn is_mount_point_or_ancestor(&self, path: &Path) -> bool {
560        let mounts = self.mounts.read();
561        if mounts.contains_key(path) {
562            return true;
563        }
564        let prefix = if path == Path::new("/") {
565            "/".to_string()
566        } else {
567            format!("{}/", path.to_string_lossy().trim_end_matches('/'))
568        };
569        mounts
570            .keys()
571            .any(|mp| mp.to_string_lossy().starts_with(&prefix))
572    }
573
574    /// Return the mount point that owns `path` (longest-prefix match).
575    fn mount_point_for(&self, path: &Path) -> PathBuf {
576        let mounts = self.mounts.read();
577        for mount_point in mounts.keys().rev() {
578            if path.starts_with(mount_point) {
579                return mount_point.clone();
580            }
581        }
582        PathBuf::from("/")
583    }
584}