Skip to main content

kaish_kernel/vfs/
router.rs

1//! VFS router for mount point management.
2//!
3//! Routes filesystem operations to the appropriate backend based on path.
4
5use super::{DirEntry, Filesystem};
6use kaish_vfs::PathAccess;
7use async_trait::async_trait;
8use std::collections::BTreeMap;
9use std::io;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13// `MountInfo` now lives in kaish-types::backend (pure data, part of the
14// KernelBackend contract). Re-exported here so existing `vfs::MountInfo`
15// paths keep working.
16pub use kaish_types::backend::MountInfo;
17
18/// Mode reported for a directory the router synthesizes rather than reads
19/// from a mount: the root, and any ancestor of a mount that has no mount of
20/// its own. Readable and searchable, never writable — these directories are
21/// derived from the mount table and the router creates nothing in them.
22const SYNTHESIZED_DIRECTORY_MODE: u32 = 0o555;
23
24/// Routes filesystem operations to mounted backends.
25///
26/// Mount points are matched by longest prefix. For example, if `/mnt` and
27/// `/mnt/project` are both mounted, a path like `/mnt/project/src/main.rs`
28/// will be routed to the `/mnt/project` mount.
29#[derive(Default)]
30pub struct VfsRouter {
31    /// Mount points, keyed by path. Uses BTreeMap for ordered iteration.
32    mounts: BTreeMap<PathBuf, Arc<dyn Filesystem>>,
33}
34
35impl std::fmt::Debug for VfsRouter {
36    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37        f.debug_struct("VfsRouter")
38            .field("mounts", &self.mounts.keys().collect::<Vec<_>>())
39            .finish()
40    }
41}
42
43impl VfsRouter {
44    /// Create a new empty VFS router.
45    pub fn new() -> Self {
46        Self {
47            mounts: BTreeMap::new(),
48        }
49    }
50
51    /// Mount a filesystem at the given path.
52    ///
53    /// The path should be absolute (start with `/`). If a filesystem is
54    /// already mounted at this path, it will be replaced.
55    pub fn mount(&mut self, path: impl Into<PathBuf>, fs: impl Filesystem + 'static) {
56        let path = Self::normalize_mount_path(path.into());
57        self.mounts.insert(path, Arc::new(fs));
58    }
59
60    /// Mount a filesystem (already wrapped in Arc) at the given path.
61    pub fn mount_arc(&mut self, path: impl Into<PathBuf>, fs: Arc<dyn Filesystem>) {
62        let path = Self::normalize_mount_path(path.into());
63        self.mounts.insert(path, fs);
64    }
65
66    /// Unmount the filesystem at the given path.
67    ///
68    /// Returns `true` if a mount was removed, `false` if nothing was mounted there.
69    pub fn unmount(&mut self, path: impl AsRef<Path>) -> bool {
70        let path = Self::normalize_mount_path(path.as_ref().to_path_buf());
71        self.mounts.remove(&path).is_some()
72    }
73
74    /// List all current mounts.
75    pub fn list_mounts(&self) -> Vec<MountInfo> {
76        self.mounts
77            .iter()
78            .map(|(path, fs)| MountInfo {
79                path: path.clone(),
80                read_only: fs.read_only(),
81                resident_bytes: fs.resident_bytes(),
82            })
83            .collect()
84    }
85
86    /// Normalize a mount path: ensure it starts with `/` and has no trailing slash.
87    fn normalize_mount_path(path: PathBuf) -> PathBuf {
88        let s = path.to_string_lossy();
89        let s = s.trim_end_matches('/');
90        if s.is_empty() {
91            PathBuf::from("/")
92        } else if !s.starts_with('/') {
93            PathBuf::from(format!("/{}", s))
94        } else {
95            PathBuf::from(s)
96        }
97    }
98
99    /// Resolve a VFS path to a real filesystem path.
100    ///
101    /// Returns `Some(path)` if the VFS path maps to a real filesystem (like LocalFs),
102    /// or `None` if the path is in a virtual filesystem (like MemoryFs).
103    ///
104    /// This is needed for tools like `git` that must use real paths with external libraries.
105    pub fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
106        let (fs, relative) = self.find_mount(path).ok()?;
107        fs.real_path(&relative)
108    }
109
110    /// Returns true if some registered mount covers this path.
111    ///
112    /// Used by embedder overlay backends (`VirtualOverlayBackend`) to decide
113    /// whether a path belongs to this router's mounts or should be delegated
114    /// to the embedder's own backend — without hardcoding a mount prefix.
115    pub(crate) fn has_mount(&self, path: &Path) -> bool {
116        self.find_mount(path).is_ok()
117    }
118
119    /// Returns true if some mount lives strictly *below* `dir` — i.e. `dir` is a
120    /// proper ancestor of a mount point (`has_mount_under("/v")` is true when
121    /// `/v/jobs` is mounted, even though nothing is mounted at `/v` itself).
122    ///
123    /// Distinct from `has_mount`, which is true only when `dir` is *covered* by
124    /// a mount. Together they let an overlay treat an intermediate path like
125    /// `/v` as an existing directory (the union of its child mounts) while still
126    /// delegating unclaimed leaves to the embedder's backend.
127    pub(crate) fn has_mount_under(&self, dir: &Path) -> bool {
128        let dir = Self::normalize_mount_path(dir.to_path_buf());
129        let dir_str = dir.to_string_lossy();
130        self.mounts.keys().any(|mount_path| {
131            let mount_str = mount_path.to_string_lossy();
132            if dir_str == "/" {
133                mount_str != "/"
134            } else {
135                mount_str.starts_with(&format!("{}/", dir_str))
136            }
137        })
138    }
139
140    /// Synthesize the child directory entries of `dir` from the mount roster:
141    /// the first path component below `dir` of every mount that lives under it
142    /// (`/v` over mounts `/v/jobs`, `/v/blobs` → `blobs`, `jobs`). `dir` is
143    /// expected to be a non-root ancestor with no mount of its own; root is
144    /// handled by `list_root`, which also folds in a `/` mount's real contents.
145    fn list_mount_children(&self, dir: &Path) -> Vec<DirEntry> {
146        let dir = Self::normalize_mount_path(dir.to_path_buf());
147        let prefix = format!("{}/", dir.to_string_lossy());
148        let mut seen = std::collections::HashSet::new();
149        let mut entries = Vec::new();
150        for mount_path in self.mounts.keys() {
151            let mount_str = mount_path.to_string_lossy();
152            if let Some(rest) = mount_str.strip_prefix(&prefix) {
153                let first = rest.split('/').next().unwrap_or("");
154                if !first.is_empty() && seen.insert(first.to_string()) {
155                    entries.push(DirEntry::directory(first));
156                }
157            }
158        }
159        entries.sort_by(|a, b| a.name.cmp(&b.name));
160        entries
161    }
162
163    /// The final path component, for naming a synthesized directory entry
164    /// (`/v` → `v`). Falls back to `/` for a path with no component.
165    fn path_basename(path: &Path) -> String {
166        path.file_name()
167            .map(|n| n.to_string_lossy().into_owned())
168            .unwrap_or_else(|| "/".to_string())
169    }
170
171    /// Find the mount point for a given path.
172    ///
173    /// Returns the mount and the path relative to that mount.
174    fn find_mount(&self, path: &Path) -> io::Result<(Arc<dyn Filesystem>, PathBuf)> {
175        let (_, fs, relative) = self.mount_of(path)?;
176        Ok((fs, relative))
177    }
178
179    /// The mount that owns `path`: its mount point, its filesystem, and the
180    /// path relative to the mount point.
181    fn mount_of(&self, path: &Path) -> io::Result<(&Path, Arc<dyn Filesystem>, PathBuf)> {
182        let path_str = path.to_string_lossy();
183        let normalized = if path_str.starts_with('/') {
184            path.to_path_buf()
185        } else {
186            PathBuf::from(format!("/{}", path_str))
187        };
188
189        // Find longest matching mount point
190        let mut best_match: Option<(&PathBuf, &Arc<dyn Filesystem>)> = None;
191
192        for (mount_path, fs) in &self.mounts {
193            let mount_str = mount_path.to_string_lossy();
194
195            // Check if the path starts with this mount point
196            let is_match = if mount_str == "/" {
197                true // Root matches everything
198            } else {
199                let normalized_str = normalized.to_string_lossy();
200                normalized_str == mount_str.as_ref()
201                    || normalized_str.starts_with(&format!("{}/", mount_str))
202            };
203
204            if is_match {
205                // Keep the longest match
206                let dominated = best_match
207                    .as_ref()
208                    .is_none_or(|(bp, _)| mount_path.as_os_str().len() > bp.as_os_str().len());
209                if dominated {
210                    best_match = Some((mount_path, fs));
211                }
212            }
213        }
214
215        match best_match {
216            Some((mount_path, fs)) => {
217                // Calculate relative path
218                let mount_str = mount_path.to_string_lossy();
219                let normalized_str = normalized.to_string_lossy();
220
221                let relative = if mount_str == "/" {
222                    normalized_str.trim_start_matches('/').to_string()
223                } else {
224                    normalized_str
225                        .strip_prefix(mount_str.as_ref())
226                        .unwrap_or("")
227                        .trim_start_matches('/')
228                        .to_string()
229                };
230
231                Ok((mount_path.as_path(), Arc::clone(fs), PathBuf::from(relative)))
232            }
233            None => Err(io::Error::new(
234                io::ErrorKind::NotFound,
235                format!("no mount point for path: {}", path.display()),
236            )),
237        }
238    }
239}
240
241/// Resolve `.` and `..` lexically in an absolute VFS path; `..` at the root
242/// stays at the root.
243fn lexical_absolute(path: &Path) -> PathBuf {
244    let mut out = PathBuf::from("/");
245    for component in path.components() {
246        match component {
247            std::path::Component::Normal(name) => out.push(name),
248            std::path::Component::ParentDir => {
249                out.pop();
250            }
251            _ => {}
252        }
253    }
254    out
255}
256
257/// The relative path from directory `from` to `to`, both absolute and
258/// lexically normalized: `..` for each component of `from` past the common
259/// prefix, then the rest of `to`.
260fn relative_path_from(from: &Path, to: &Path) -> PathBuf {
261    let mut from_parts = from.components().skip(1).peekable();
262    let mut to_parts = to.components().skip(1).peekable();
263    while let (Some(a), Some(b)) = (from_parts.peek(), to_parts.peek()) {
264        if a != b {
265            break;
266        }
267        from_parts.next();
268        to_parts.next();
269    }
270    let mut relative = PathBuf::new();
271    for _ in from_parts {
272        relative.push("..");
273    }
274    for part in to_parts {
275        relative.push(part);
276    }
277    if relative.as_os_str().is_empty() {
278        relative.push(".");
279    }
280    relative
281}
282
283#[async_trait]
284impl Filesystem for VfsRouter {
285    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
286    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
287        let (fs, relative) = self.find_mount(path)?;
288        fs.read(&relative).await
289    }
290
291    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
292    async fn read_range(
293        &self,
294        path: &Path,
295        range: Option<kaish_vfs::ReadRange>,
296    ) -> io::Result<Vec<u8>> {
297        // Forward the range to the mount so range-aware backends (e.g. DevFs's
298        // /dev/zero) see the requested byte count. Falling through to the trait
299        // default would call our own `read` (whole file) and slice afterwards,
300        // which would hang or error on an infinite device.
301        let (fs, relative) = self.find_mount(path)?;
302        fs.read_range(&relative, range).await
303    }
304
305    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
306    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
307        let (fs, relative) = self.find_mount(path)?;
308        fs.write(&relative, data).await
309    }
310
311    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
312    async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
313        // Forward to the mount so a real append (LocalFs's O_APPEND, say)
314        // reaches it. Falling through to the trait default would call our
315        // own `read` and `write`, which route to the mount's read and write
316        // individually — never its `append` override, and losing the
317        // atomicity that override exists to provide.
318        let (fs, relative) = self.find_mount(path)?;
319        fs.append(&relative, data).await
320    }
321
322    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
323    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
324        // Special case: listing root might need to show mount points
325        let path_str = path.to_string_lossy();
326        if path_str.is_empty() || path_str == "/" {
327            return self.list_root().await;
328        }
329
330        match self.find_mount(path) {
331            Ok((fs, relative)) => fs.list(&relative).await,
332            // Not covered by a mount, but an ancestor of one (e.g. `/v` above
333            // `/v/jobs`): synthesize its child mount directories rather than 404.
334            Err(e) => {
335                if self.has_mount_under(path) {
336                    Ok(self.list_mount_children(path))
337                } else {
338                    Err(e)
339                }
340            }
341        }
342    }
343
344    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
345    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
346        // Special case: root always exists
347        let path_str = path.to_string_lossy();
348        if path_str.is_empty() || path_str == "/" {
349            return Ok(DirEntry::directory("/"));
350        }
351
352        // Check if path is a mount point itself
353        let normalized = Self::normalize_mount_path(path.to_path_buf());
354        if self.mounts.contains_key(&normalized) {
355            let name = path
356                .file_name()
357                .map(|n| n.to_string_lossy().into_owned())
358                .unwrap_or_else(|| "/".to_string());
359            return Ok(DirEntry::directory(name));
360        }
361
362        match self.find_mount(path) {
363            Ok((fs, relative)) => fs.stat(&relative).await,
364            // Intermediate ancestor of a mount (e.g. `/v` above `/v/jobs`)
365            // exists as a synthesized directory.
366            Err(e) => {
367                if self.has_mount_under(path) {
368                    Ok(DirEntry::directory(Self::path_basename(path)))
369                } else {
370                    Err(e)
371                }
372            }
373        }
374    }
375
376    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
377        let (fs, relative) = self.find_mount(path)?;
378        fs.read_link(&relative).await
379    }
380
381    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
382        let (link_mount, fs, relative_link) = self.mount_of(link)?;
383        // A backend refuses an absolute target: it has no namespace to read
384        // one in. The router has the namespace, so an absolute VFS target on
385        // the link's own mount is rewritten relative to the link's directory.
386        // The stored spelling is what readlink then shows.
387        let target = if target.is_absolute() {
388            let target = lexical_absolute(target);
389            let (target_mount, _, _) = self.mount_of(&target)?;
390            if target_mount != link_mount {
391                return Err(io::Error::new(
392                    io::ErrorKind::InvalidInput,
393                    format!(
394                        "symlink target {} is on mount {} and the link {} is on mount {}; a link cannot cross mounts",
395                        target.display(),
396                        target_mount.display(),
397                        link.display(),
398                        link_mount.display()
399                    ),
400                ));
401            }
402            let link_dir = lexical_absolute(link);
403            let link_dir = link_dir.parent().unwrap_or(Path::new("/"));
404            relative_path_from(link_dir, &target)
405        } else {
406            target.to_path_buf()
407        };
408        fs.symlink(&target, &relative_link).await
409    }
410
411    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
412        // Special case: root always exists
413        let path_str = path.to_string_lossy();
414        if path_str.is_empty() || path_str == "/" {
415            return Ok(DirEntry::directory("/"));
416        }
417
418        // Check if path is a mount point itself
419        let normalized = Self::normalize_mount_path(path.to_path_buf());
420        if self.mounts.contains_key(&normalized) {
421            let name = path
422                .file_name()
423                .map(|n| n.to_string_lossy().into_owned())
424                .unwrap_or_else(|| "/".to_string());
425            return Ok(DirEntry::directory(name));
426        }
427
428        match self.find_mount(path) {
429            Ok((fs, relative)) => fs.lstat(&relative).await,
430            // Intermediate ancestor of a mount (e.g. `/v` above `/v/jobs`)
431            // exists as a synthesized directory.
432            Err(e) => {
433                if self.has_mount_under(path) {
434                    Ok(DirEntry::directory(Self::path_basename(path)))
435                } else {
436                    Err(e)
437                }
438            }
439        }
440    }
441
442    async fn mkdir(&self, path: &Path) -> io::Result<()> {
443        let (fs, relative) = self.find_mount(path)?;
444        fs.mkdir(&relative).await
445    }
446
447    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> io::Result<()> {
448        let (fs, relative) = self.find_mount(path)?;
449        fs.set_mtime(&relative, mtime).await
450    }
451
452    async fn remove(&self, path: &Path) -> io::Result<()> {
453        let (fs, relative) = self.find_mount(path)?;
454        fs.remove(&relative).await
455    }
456
457    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
458        let (from_fs, from_relative) = self.find_mount(from)?;
459        let (to_fs, to_relative) = self.find_mount(to)?;
460
461        // Check if both paths are on the same mount by comparing Arc pointers
462        if !Arc::ptr_eq(&from_fs, &to_fs) {
463            return Err(io::Error::new(
464                io::ErrorKind::Unsupported,
465                "cannot rename across different mount points",
466            ));
467        }
468
469        from_fs.rename(&from_relative, &to_relative).await
470    }
471
472    /// Delegates to the mount that owns the path, so the answer is that
473    /// mount's — not the whole router's. `read_only()` below is the
474    /// whole-router question and cannot answer for one path: a router with a
475    /// writable `/` and a read-only `/v/bin` is read-only for neither.
476    ///
477    /// Falls back the way `stat` does. `stat` synthesizes a directory for the
478    /// root and for any ancestor of a mount (`/v` above `/v/jobs`), so those
479    /// paths exist, and an answer here that errored on them would contradict
480    /// it: `[[ -e /v ]]` true and `[[ -r /v ]]` false about the same path.
481    /// A synthesized directory is readable and searchable, and never
482    /// writable — the router creates nothing in one.
483    async fn path_access(&self, path: &Path) -> io::Result<PathAccess> {
484        match self.find_mount(path) {
485            Ok((fs, relative)) => fs.path_access(&relative).await,
486            Err(e) => {
487                let path_str = path.to_string_lossy();
488                let is_synthesized =
489                    path_str.is_empty() || path_str == "/" || self.has_mount_under(path);
490                if is_synthesized {
491                    Ok(PathAccess::resolve(Some(SYNTHESIZED_DIRECTORY_MODE), true))
492                } else {
493                    Err(e)
494                }
495            }
496        }
497    }
498
499    fn read_only(&self) -> bool {
500        // Router is read-only iff every mount is. Empty router returns
501        // false — a router with no mounts isn't meaningfully read-only,
502        // and false preserves the behaviour callers saw before this change.
503        if self.mounts.is_empty() {
504            return false;
505        }
506        self.mounts.values().all(|fs| fs.read_only())
507    }
508}
509
510impl VfsRouter {
511    /// List the root directory, synthesizing entries from mount points.
512    async fn list_root(&self) -> io::Result<Vec<DirEntry>> {
513        let mut entries = Vec::new();
514        let mut seen_names = std::collections::HashSet::new();
515
516        for mount_path in self.mounts.keys() {
517            let mount_str = mount_path.to_string_lossy();
518            if mount_str == "/" {
519                // Root mount: list its contents directly
520                if let Some(fs) = self.mounts.get(mount_path)
521                    && let Ok(root_entries) = fs.list(Path::new("")).await {
522                        for entry in root_entries {
523                            if seen_names.insert(entry.name.clone()) {
524                                entries.push(entry);
525                            }
526                        }
527                    }
528            } else {
529                // Non-root mount: extract first path component
530                let first_component = mount_str
531                    .trim_start_matches('/')
532                    .split('/')
533                    .next()
534                    .unwrap_or("");
535
536                if !first_component.is_empty() && seen_names.insert(first_component.to_string()) {
537                    entries.push(DirEntry::directory(first_component));
538                }
539            }
540        }
541
542        entries.sort_by(|a, b| a.name.cmp(&b.name));
543        Ok(entries)
544    }
545}
546
547#[cfg(test)]
548mod tests {
549    use super::*;
550    use crate::vfs::MemoryFs;
551
552    #[tokio::test]
553    async fn symlink_absolute_target_on_the_same_mount_is_stored_relative() {
554        let mut router = VfsRouter::new();
555        let data = MemoryFs::new();
556        data.write(Path::new("etc/hosts"), b"hosts").await.unwrap();
557        data.mkdir(Path::new("home")).await.unwrap();
558        router.mount("/data", data);
559
560        router
561            .symlink(Path::new("/data/etc/hosts"), Path::new("/data/home/link"))
562            .await
563            .unwrap();
564
565        assert_eq!(
566            router.read_link(Path::new("/data/home/link")).await.unwrap(),
567            PathBuf::from("../etc/hosts")
568        );
569        assert_eq!(router.read(Path::new("/data/home/link")).await.unwrap(), b"hosts");
570    }
571
572    #[tokio::test]
573    async fn symlink_absolute_target_beside_the_link_is_a_bare_name() {
574        let mut router = VfsRouter::new();
575        let root = MemoryFs::new();
576        root.write(Path::new("a/target"), b"t").await.unwrap();
577        router.mount("/", root);
578
579        router
580            .symlink(Path::new("/a/target"), Path::new("/a/link"))
581            .await
582            .unwrap();
583        assert_eq!(
584            router.read_link(Path::new("/a/link")).await.unwrap(),
585            PathBuf::from("target")
586        );
587    }
588
589    #[tokio::test]
590    async fn symlink_absolute_target_with_dotdot_is_normalized_first() {
591        let mut router = VfsRouter::new();
592        let root = MemoryFs::new();
593        root.write(Path::new("etc/hosts"), b"hosts").await.unwrap();
594        root.mkdir(Path::new("home")).await.unwrap();
595        router.mount("/", root);
596
597        router
598            .symlink(Path::new("/home/../etc/./hosts"), Path::new("/home/link"))
599            .await
600            .unwrap();
601        assert_eq!(
602            router.read_link(Path::new("/home/link")).await.unwrap(),
603            PathBuf::from("../etc/hosts")
604        );
605        assert_eq!(router.read(Path::new("/home/link")).await.unwrap(), b"hosts");
606    }
607
608    #[tokio::test]
609    async fn symlink_relative_target_is_stored_verbatim() {
610        let mut router = VfsRouter::new();
611        router.mount("/data", MemoryFs::new());
612
613        router
614            .symlink(Path::new("../x/../y"), Path::new("/data/d/link"))
615            .await
616            .unwrap();
617        assert_eq!(
618            router.read_link(Path::new("/data/d/link")).await.unwrap(),
619            PathBuf::from("../x/../y")
620        );
621    }
622
623    #[tokio::test]
624    async fn symlink_across_mounts_is_refused_and_creates_nothing() {
625        let mut router = VfsRouter::new();
626        router.mount("/data", MemoryFs::new());
627        let scratch = MemoryFs::new();
628        scratch.write(Path::new("x"), b"x").await.unwrap();
629        router.mount("/scratch", scratch);
630
631        let error = router
632            .symlink(Path::new("/scratch/x"), Path::new("/data/link"))
633            .await
634            .unwrap_err();
635        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
636        let message = error.to_string();
637        assert!(message.contains("/scratch") && message.contains("/data"), "{message}");
638        assert!(router.lstat(Path::new("/data/link")).await.is_err(), "nothing created");
639    }
640
641    #[test]
642    fn relative_path_from_walks_up_then_down() {
643        let rel = |from: &str, to: &str| relative_path_from(Path::new(from), Path::new(to));
644        assert_eq!(rel("/a/b", "/a/c/d"), PathBuf::from("../c/d"));
645        assert_eq!(rel("/a", "/a/x"), PathBuf::from("x"));
646        assert_eq!(rel("/", "/x/y"), PathBuf::from("x/y"));
647        assert_eq!(rel("/a/b/c", "/"), PathBuf::from("../../.."));
648        assert_eq!(rel("/a/b", "/a/b"), PathBuf::from("."));
649    }
650
651    #[tokio::test]
652    async fn test_basic_mount() {
653        let mut router = VfsRouter::new();
654        let scratch = MemoryFs::new();
655        scratch.write(Path::new("test.txt"), b"hello").await.unwrap();
656        router.mount("/scratch", scratch);
657
658        let data = router.read(Path::new("/scratch/test.txt")).await.unwrap();
659        assert_eq!(data, b"hello");
660    }
661
662    #[tokio::test]
663    async fn test_multiple_mounts() {
664        let mut router = VfsRouter::new();
665
666        let scratch = MemoryFs::new();
667        scratch.write(Path::new("a.txt"), b"scratch").await.unwrap();
668        router.mount("/scratch", scratch);
669
670        let data = MemoryFs::new();
671        data.write(Path::new("b.txt"), b"data").await.unwrap();
672        router.mount("/data", data);
673
674        assert_eq!(
675            router.read(Path::new("/scratch/a.txt")).await.unwrap(),
676            b"scratch"
677        );
678        assert_eq!(
679            router.read(Path::new("/data/b.txt")).await.unwrap(),
680            b"data"
681        );
682    }
683
684    #[tokio::test]
685    async fn test_nested_mount() {
686        let mut router = VfsRouter::new();
687
688        let outer = MemoryFs::new();
689        outer.write(Path::new("outer.txt"), b"outer").await.unwrap();
690        router.mount("/mnt", outer);
691
692        let inner = MemoryFs::new();
693        inner.write(Path::new("inner.txt"), b"inner").await.unwrap();
694        router.mount("/mnt/project", inner);
695
696        // /mnt/outer.txt should come from outer mount
697        assert_eq!(
698            router.read(Path::new("/mnt/outer.txt")).await.unwrap(),
699            b"outer"
700        );
701
702        // /mnt/project/inner.txt should come from inner mount
703        assert_eq!(
704            router.read(Path::new("/mnt/project/inner.txt")).await.unwrap(),
705            b"inner"
706        );
707    }
708
709    #[tokio::test]
710    async fn test_list_root() {
711        let mut router = VfsRouter::new();
712        router.mount("/scratch", MemoryFs::new());
713        router.mount("/mnt/a", MemoryFs::new());
714        router.mount("/mnt/b", MemoryFs::new());
715
716        let entries = router.list(Path::new("/")).await.unwrap();
717        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
718
719        assert!(names.contains(&&"scratch".to_string()));
720        assert!(names.contains(&&"mnt".to_string()));
721    }
722
723    #[tokio::test]
724    async fn test_unmount() {
725        let mut router = VfsRouter::new();
726
727        let fs = MemoryFs::new();
728        fs.write(Path::new("test.txt"), b"data").await.unwrap();
729        router.mount("/scratch", fs);
730
731        assert!(router.read(Path::new("/scratch/test.txt")).await.is_ok());
732
733        router.unmount("/scratch");
734
735        assert!(router.read(Path::new("/scratch/test.txt")).await.is_err());
736    }
737
738    #[tokio::test]
739    async fn test_list_mounts() {
740        let mut router = VfsRouter::new();
741        router.mount("/scratch", MemoryFs::new());
742        router.mount("/data", MemoryFs::new());
743
744        let mounts = router.list_mounts();
745        assert_eq!(mounts.len(), 2);
746
747        let paths: Vec<_> = mounts.iter().map(|m| &m.path).collect();
748        assert!(paths.contains(&&PathBuf::from("/scratch")));
749        assert!(paths.contains(&&PathBuf::from("/data")));
750    }
751
752    #[tokio::test]
753    async fn test_no_mount_error() {
754        let router = VfsRouter::new();
755        let result = router.read(Path::new("/nothing/here.txt")).await;
756        assert!(result.is_err());
757        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
758    }
759
760    #[tokio::test]
761    async fn test_root_mount() {
762        let mut router = VfsRouter::new();
763
764        let root = MemoryFs::new();
765        root.write(Path::new("at-root.txt"), b"root file").await.unwrap();
766        router.mount("/", root);
767
768        let data = router.read(Path::new("/at-root.txt")).await.unwrap();
769        assert_eq!(data, b"root file");
770    }
771
772    #[tokio::test]
773    async fn test_write_through_router() {
774        let mut router = VfsRouter::new();
775        router.mount("/scratch", MemoryFs::new());
776
777        router
778            .write(Path::new("/scratch/new.txt"), b"created")
779            .await
780            .unwrap();
781
782        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
783        assert_eq!(data, b"created");
784    }
785
786    #[tokio::test]
787    async fn test_stat_mount_point() {
788        let mut router = VfsRouter::new();
789        router.mount("/scratch", MemoryFs::new());
790
791        let entry = router.stat(Path::new("/scratch")).await.unwrap();
792        assert!(entry.is_dir());
793    }
794
795    #[tokio::test]
796    async fn test_stat_root() {
797        let router = VfsRouter::new();
798        let entry = router.stat(Path::new("/")).await.unwrap();
799        assert!(entry.is_dir());
800    }
801
802    #[tokio::test]
803    async fn test_rename_same_mount() {
804        let mut router = VfsRouter::new();
805        let mem = MemoryFs::new();
806        mem.write(Path::new("old.txt"), b"data").await.unwrap();
807        router.mount("/scratch", mem);
808
809        router.rename(Path::new("/scratch/old.txt"), Path::new("/scratch/new.txt")).await.unwrap();
810
811        // New path exists
812        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
813        assert_eq!(data, b"data");
814
815        // Old path doesn't exist
816        assert!(!router.exists(Path::new("/scratch/old.txt")).await);
817    }
818
819    #[tokio::test]
820    async fn test_rename_cross_mount_fails() {
821        let mut router = VfsRouter::new();
822        let mem1 = MemoryFs::new();
823        mem1.write(Path::new("file.txt"), b"data").await.unwrap();
824        router.mount("/mount1", mem1);
825        router.mount("/mount2", MemoryFs::new());
826
827        let result = router.rename(Path::new("/mount1/file.txt"), Path::new("/mount2/file.txt")).await;
828        assert!(result.is_err());
829        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported);
830    }
831
832    // `stat` synthesizes a directory for the root and for any ancestor of a
833    // mount, so those paths exist. `path_access` has to agree with `stat`
834    // about the same path — going straight to `find_mount` errors where
835    // `stat` succeeds, and `[[ -e /v ]]` would be true while `[[ -r /v ]]`
836    // was false about the identical path.
837    #[tokio::test]
838    async fn path_access_agrees_with_stat_on_synthesized_directories() {
839        let mut router = VfsRouter::new();
840        router.mount("/v/docs", MemoryFs::new());
841
842        for path in ["/", "/v"] {
843            let path = Path::new(path);
844            assert!(
845                router.stat(path).await.is_ok(),
846                "{} is synthesized by stat",
847                path.display()
848            );
849            let access = router
850                .path_access(path)
851                .await
852                .unwrap_or_else(|e| panic!("path_access must not error where stat succeeds: {e}"));
853            assert!(access.readable, "{} must be readable", path.display());
854            assert!(access.executable, "{} must be searchable", path.display());
855            assert!(
856                !access.writable,
857                "the router creates nothing in {}",
858                path.display()
859            );
860        }
861    }
862
863    // The synthesis must not swallow a genuinely absent path.
864    #[tokio::test]
865    async fn path_access_errors_on_a_path_with_no_mount() {
866        let mut router = VfsRouter::new();
867        router.mount("/v/docs", MemoryFs::new());
868        assert!(router.path_access(Path::new("/nope")).await.is_err());
869        assert!(router.path_access(Path::new("/v/docs/absent")).await.is_err());
870    }
871
872    // A real mount answers for itself, not with the synthesized defaults.
873    #[tokio::test]
874    async fn path_access_at_a_mount_point_asks_the_mount() {
875        let mut router = VfsRouter::new();
876        router.mount("/rw", MemoryFs::new());
877        router.mount("/ro", BuiltinFsStub);
878
879        assert!(router.path_access(Path::new("/rw")).await.unwrap().writable);
880        assert!(!router.path_access(Path::new("/ro")).await.unwrap().writable);
881        assert!(router.path_access(Path::new("/ro")).await.unwrap().readable);
882    }
883
884    /// A minimal read-only mount that reports no mode, standing in for
885    /// `BuiltinFs`/`JobFs` without dragging a ToolRegistry into this module.
886    struct BuiltinFsStub;
887
888    #[async_trait]
889    impl Filesystem for BuiltinFsStub {
890        async fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
891            Ok(Vec::new())
892        }
893        async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
894            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
895        }
896        async fn list(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
897            Ok(Vec::new())
898        }
899        async fn stat(&self, _path: &Path) -> io::Result<DirEntry> {
900            Ok(DirEntry::directory("."))
901        }
902        async fn mkdir(&self, _path: &Path) -> io::Result<()> {
903            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
904        }
905        async fn remove(&self, _path: &Path) -> io::Result<()> {
906            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
907        }
908        fn read_only(&self) -> bool {
909            true
910        }
911    }
912
913    #[tokio::test]
914    async fn read_only_empty_router_returns_false() {
915        let router = VfsRouter::new();
916        assert!(!router.read_only());
917    }
918
919    #[cfg(feature = "localfs")]
920    #[tokio::test]
921    async fn read_only_all_read_only_mounts_returns_true() {
922        use crate::vfs::LocalFs;
923
924        let t1 = tempfile::tempdir().unwrap();
925        let t2 = tempfile::tempdir().unwrap();
926
927        let mut router = VfsRouter::new();
928        router.mount("/a", LocalFs::read_only(t1.path().to_path_buf()));
929        router.mount("/b", LocalFs::read_only(t2.path().to_path_buf()));
930
931        assert!(router.read_only());
932    }
933
934    #[cfg(feature = "localfs")]
935    #[tokio::test]
936    async fn read_only_mixed_mounts_returns_false() {
937        use crate::vfs::LocalFs;
938
939        let t1 = tempfile::tempdir().unwrap();
940
941        let mut router = VfsRouter::new();
942        router.mount("/ro", LocalFs::read_only(t1.path().to_path_buf()));
943        router.mount("/rw", MemoryFs::new());
944
945        assert!(!router.read_only());
946    }
947
948    // An intermediate directory that has no mount of its own but sits *above*
949    // one or more mounts (e.g. `/v` over `/v/jobs`, `/v/blobs`) must present as
950    // a real, listable directory synthesized from the mount roster — not the
951    // `NotFound` the bare `find_mount` returns. This is what lets a kaish shell
952    // (and SFTP over the bare router) navigate `/v` when the mounts sit at
953    // `/v/*`, and it's the router half of the `/v` overlay-tuning fix.
954    #[tokio::test]
955    async fn test_list_synthesizes_intermediate_dir() {
956        let mut router = VfsRouter::new();
957        router.mount("/v/jobs", MemoryFs::new());
958        router.mount("/v/blobs", MemoryFs::new());
959
960        let entries = router.list(Path::new("/v")).await.unwrap();
961        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
962        assert_eq!(names, vec!["blobs", "jobs"]); // sorted, synthesized from mounts
963    }
964
965    #[tokio::test]
966    async fn test_stat_intermediate_dir_is_directory() {
967        let mut router = VfsRouter::new();
968        router.mount("/v/jobs", MemoryFs::new());
969
970        assert!(router.stat(Path::new("/v")).await.unwrap().is_dir());
971        assert!(router.lstat(Path::new("/v")).await.unwrap().is_dir());
972    }
973
974    #[tokio::test]
975    async fn test_deep_intermediate_dir() {
976        let mut router = VfsRouter::new();
977        router.mount("/v/etc/rc", MemoryFs::new());
978
979        let v: Vec<_> = router.list(Path::new("/v")).await.unwrap();
980        assert_eq!(v.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["etc"]);
981        let etc: Vec<_> = router.list(Path::new("/v/etc")).await.unwrap();
982        assert_eq!(etc.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["rc"]);
983        assert!(router.stat(Path::new("/v/etc")).await.unwrap().is_dir());
984    }
985
986    #[tokio::test]
987    async fn test_has_mount_under() {
988        let mut router = VfsRouter::new();
989        router.mount("/v/jobs", MemoryFs::new());
990
991        assert!(router.has_mount_under(Path::new("/v")));
992        assert!(router.has_mount_under(Path::new("/")));
993        // The mount point itself has nothing *below* it.
994        assert!(!router.has_mount_under(Path::new("/v/jobs")));
995        assert!(!router.has_mount_under(Path::new("/other")));
996    }
997
998    #[tokio::test]
999    async fn test_nonexistent_ancestor_still_notfound() {
1000        let mut router = VfsRouter::new();
1001        router.mount("/v/jobs", MemoryFs::new());
1002
1003        // A path with no mount at or below it stays NotFound — synthesis is
1004        // only for genuine ancestors of a mount.
1005        assert_eq!(
1006            router.list(Path::new("/nope")).await.unwrap_err().kind(),
1007            io::ErrorKind::NotFound
1008        );
1009        assert_eq!(
1010            router.stat(Path::new("/nope")).await.unwrap_err().kind(),
1011            io::ErrorKind::NotFound
1012        );
1013    }
1014}