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 async_trait::async_trait;
7use std::collections::BTreeMap;
8use std::io;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11
12// `MountInfo` now lives in kaish-types::backend (pure data, part of the
13// KernelBackend contract). Re-exported here so existing `vfs::MountInfo`
14// paths keep working.
15pub use kaish_types::backend::MountInfo;
16
17/// Routes filesystem operations to mounted backends.
18///
19/// Mount points are matched by longest prefix. For example, if `/mnt` and
20/// `/mnt/project` are both mounted, a path like `/mnt/project/src/main.rs`
21/// will be routed to the `/mnt/project` mount.
22#[derive(Default)]
23pub struct VfsRouter {
24    /// Mount points, keyed by path. Uses BTreeMap for ordered iteration.
25    mounts: BTreeMap<PathBuf, Arc<dyn Filesystem>>,
26}
27
28impl std::fmt::Debug for VfsRouter {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        f.debug_struct("VfsRouter")
31            .field("mounts", &self.mounts.keys().collect::<Vec<_>>())
32            .finish()
33    }
34}
35
36impl VfsRouter {
37    /// Create a new empty VFS router.
38    pub fn new() -> Self {
39        Self {
40            mounts: BTreeMap::new(),
41        }
42    }
43
44    /// Mount a filesystem at the given path.
45    ///
46    /// The path should be absolute (start with `/`). If a filesystem is
47    /// already mounted at this path, it will be replaced.
48    pub fn mount(&mut self, path: impl Into<PathBuf>, fs: impl Filesystem + 'static) {
49        let path = Self::normalize_mount_path(path.into());
50        self.mounts.insert(path, Arc::new(fs));
51    }
52
53    /// Mount a filesystem (already wrapped in Arc) at the given path.
54    pub fn mount_arc(&mut self, path: impl Into<PathBuf>, fs: Arc<dyn Filesystem>) {
55        let path = Self::normalize_mount_path(path.into());
56        self.mounts.insert(path, fs);
57    }
58
59    /// Unmount the filesystem at the given path.
60    ///
61    /// Returns `true` if a mount was removed, `false` if nothing was mounted there.
62    pub fn unmount(&mut self, path: impl AsRef<Path>) -> bool {
63        let path = Self::normalize_mount_path(path.as_ref().to_path_buf());
64        self.mounts.remove(&path).is_some()
65    }
66
67    /// List all current mounts.
68    pub fn list_mounts(&self) -> Vec<MountInfo> {
69        self.mounts
70            .iter()
71            .map(|(path, fs)| MountInfo {
72                path: path.clone(),
73                read_only: fs.read_only(),
74                resident_bytes: fs.resident_bytes(),
75            })
76            .collect()
77    }
78
79    /// Normalize a mount path: ensure it starts with `/` and has no trailing slash.
80    fn normalize_mount_path(path: PathBuf) -> PathBuf {
81        let s = path.to_string_lossy();
82        let s = s.trim_end_matches('/');
83        if s.is_empty() {
84            PathBuf::from("/")
85        } else if !s.starts_with('/') {
86            PathBuf::from(format!("/{}", s))
87        } else {
88            PathBuf::from(s)
89        }
90    }
91
92    /// Resolve a VFS path to a real filesystem path.
93    ///
94    /// Returns `Some(path)` if the VFS path maps to a real filesystem (like LocalFs),
95    /// or `None` if the path is in a virtual filesystem (like MemoryFs).
96    ///
97    /// This is needed for tools like `git` that must use real paths with external libraries.
98    pub fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
99        let (fs, relative) = self.find_mount(path).ok()?;
100        fs.real_path(&relative)
101    }
102
103    /// Returns true if some registered mount covers this path.
104    ///
105    /// Used by embedder overlay backends (`VirtualOverlayBackend`) to decide
106    /// whether a path belongs to this router's mounts or should be delegated
107    /// to the embedder's own backend — without hardcoding a mount prefix.
108    pub(crate) fn has_mount(&self, path: &Path) -> bool {
109        self.find_mount(path).is_ok()
110    }
111
112    /// Returns true if some mount lives strictly *below* `dir` — i.e. `dir` is a
113    /// proper ancestor of a mount point (`has_mount_under("/v")` is true when
114    /// `/v/jobs` is mounted, even though nothing is mounted at `/v` itself).
115    ///
116    /// Distinct from `has_mount`, which is true only when `dir` is *covered* by
117    /// a mount. Together they let an overlay treat an intermediate path like
118    /// `/v` as an existing directory (the union of its child mounts) while still
119    /// delegating unclaimed leaves to the embedder's backend.
120    pub(crate) fn has_mount_under(&self, dir: &Path) -> bool {
121        let dir = Self::normalize_mount_path(dir.to_path_buf());
122        let dir_str = dir.to_string_lossy();
123        self.mounts.keys().any(|mount_path| {
124            let mount_str = mount_path.to_string_lossy();
125            if dir_str == "/" {
126                mount_str != "/"
127            } else {
128                mount_str.starts_with(&format!("{}/", dir_str))
129            }
130        })
131    }
132
133    /// Synthesize the child directory entries of `dir` from the mount roster:
134    /// the first path component below `dir` of every mount that lives under it
135    /// (`/v` over mounts `/v/jobs`, `/v/blobs` → `blobs`, `jobs`). `dir` is
136    /// expected to be a non-root ancestor with no mount of its own; root is
137    /// handled by `list_root`, which also folds in a `/` mount's real contents.
138    fn list_mount_children(&self, dir: &Path) -> Vec<DirEntry> {
139        let dir = Self::normalize_mount_path(dir.to_path_buf());
140        let prefix = format!("{}/", dir.to_string_lossy());
141        let mut seen = std::collections::HashSet::new();
142        let mut entries = Vec::new();
143        for mount_path in self.mounts.keys() {
144            let mount_str = mount_path.to_string_lossy();
145            if let Some(rest) = mount_str.strip_prefix(&prefix) {
146                let first = rest.split('/').next().unwrap_or("");
147                if !first.is_empty() && seen.insert(first.to_string()) {
148                    entries.push(DirEntry::directory(first));
149                }
150            }
151        }
152        entries.sort_by(|a, b| a.name.cmp(&b.name));
153        entries
154    }
155
156    /// The final path component, for naming a synthesized directory entry
157    /// (`/v` → `v`). Falls back to `/` for a path with no component.
158    fn path_basename(path: &Path) -> String {
159        path.file_name()
160            .map(|n| n.to_string_lossy().into_owned())
161            .unwrap_or_else(|| "/".to_string())
162    }
163
164    /// Find the mount point for a given path.
165    ///
166    /// Returns the mount and the path relative to that mount.
167    fn find_mount(&self, path: &Path) -> io::Result<(Arc<dyn Filesystem>, PathBuf)> {
168        let path_str = path.to_string_lossy();
169        let normalized = if path_str.starts_with('/') {
170            path.to_path_buf()
171        } else {
172            PathBuf::from(format!("/{}", path_str))
173        };
174
175        // Find longest matching mount point
176        let mut best_match: Option<(&PathBuf, &Arc<dyn Filesystem>)> = None;
177
178        for (mount_path, fs) in &self.mounts {
179            let mount_str = mount_path.to_string_lossy();
180
181            // Check if the path starts with this mount point
182            let is_match = if mount_str == "/" {
183                true // Root matches everything
184            } else {
185                let normalized_str = normalized.to_string_lossy();
186                normalized_str == mount_str.as_ref()
187                    || normalized_str.starts_with(&format!("{}/", mount_str))
188            };
189
190            if is_match {
191                // Keep the longest match
192                let dominated = best_match
193                    .as_ref()
194                    .is_none_or(|(bp, _)| mount_path.as_os_str().len() > bp.as_os_str().len());
195                if dominated {
196                    best_match = Some((mount_path, fs));
197                }
198            }
199        }
200
201        match best_match {
202            Some((mount_path, fs)) => {
203                // Calculate relative path
204                let mount_str = mount_path.to_string_lossy();
205                let normalized_str = normalized.to_string_lossy();
206
207                let relative = if mount_str == "/" {
208                    normalized_str.trim_start_matches('/').to_string()
209                } else {
210                    normalized_str
211                        .strip_prefix(mount_str.as_ref())
212                        .unwrap_or("")
213                        .trim_start_matches('/')
214                        .to_string()
215                };
216
217                Ok((Arc::clone(fs), PathBuf::from(relative)))
218            }
219            None => Err(io::Error::new(
220                io::ErrorKind::NotFound,
221                format!("no mount point for path: {}", path.display()),
222            )),
223        }
224    }
225}
226
227#[async_trait]
228impl Filesystem for VfsRouter {
229    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
230    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
231        let (fs, relative) = self.find_mount(path)?;
232        fs.read(&relative).await
233    }
234
235    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
236    async fn read_range(
237        &self,
238        path: &Path,
239        range: Option<kaish_vfs::ReadRange>,
240    ) -> io::Result<Vec<u8>> {
241        // Forward the range to the mount so range-aware backends (e.g. DevFs's
242        // /dev/zero) see the requested byte count. Falling through to the trait
243        // default would call our own `read` (whole file) and slice afterwards,
244        // which would hang or error on an infinite device.
245        let (fs, relative) = self.find_mount(path)?;
246        fs.read_range(&relative, range).await
247    }
248
249    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
250    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
251        let (fs, relative) = self.find_mount(path)?;
252        fs.write(&relative, data).await
253    }
254
255    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
256    async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
257        // Forward to the mount so a real append (LocalFs's O_APPEND, say)
258        // reaches it. Falling through to the trait default would call our
259        // own `read` and `write`, which route to the mount's read and write
260        // individually — never its `append` override, and losing the
261        // atomicity that override exists to provide.
262        let (fs, relative) = self.find_mount(path)?;
263        fs.append(&relative, data).await
264    }
265
266    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
267    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
268        // Special case: listing root might need to show mount points
269        let path_str = path.to_string_lossy();
270        if path_str.is_empty() || path_str == "/" {
271            return self.list_root().await;
272        }
273
274        match self.find_mount(path) {
275            Ok((fs, relative)) => fs.list(&relative).await,
276            // Not covered by a mount, but an ancestor of one (e.g. `/v` above
277            // `/v/jobs`): synthesize its child mount directories rather than 404.
278            Err(e) => {
279                if self.has_mount_under(path) {
280                    Ok(self.list_mount_children(path))
281                } else {
282                    Err(e)
283                }
284            }
285        }
286    }
287
288    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
289    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
290        // Special case: root always exists
291        let path_str = path.to_string_lossy();
292        if path_str.is_empty() || path_str == "/" {
293            return Ok(DirEntry::directory("/"));
294        }
295
296        // Check if path is a mount point itself
297        let normalized = Self::normalize_mount_path(path.to_path_buf());
298        if self.mounts.contains_key(&normalized) {
299            let name = path
300                .file_name()
301                .map(|n| n.to_string_lossy().into_owned())
302                .unwrap_or_else(|| "/".to_string());
303            return Ok(DirEntry::directory(name));
304        }
305
306        match self.find_mount(path) {
307            Ok((fs, relative)) => fs.stat(&relative).await,
308            // Intermediate ancestor of a mount (e.g. `/v` above `/v/jobs`)
309            // exists as a synthesized directory.
310            Err(e) => {
311                if self.has_mount_under(path) {
312                    Ok(DirEntry::directory(Self::path_basename(path)))
313                } else {
314                    Err(e)
315                }
316            }
317        }
318    }
319
320    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
321        let (fs, relative) = self.find_mount(path)?;
322        fs.read_link(&relative).await
323    }
324
325    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
326        let (fs, relative) = self.find_mount(link)?;
327        fs.symlink(target, &relative).await
328    }
329
330    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
331        // Special case: root always exists
332        let path_str = path.to_string_lossy();
333        if path_str.is_empty() || path_str == "/" {
334            return Ok(DirEntry::directory("/"));
335        }
336
337        // Check if path is a mount point itself
338        let normalized = Self::normalize_mount_path(path.to_path_buf());
339        if self.mounts.contains_key(&normalized) {
340            let name = path
341                .file_name()
342                .map(|n| n.to_string_lossy().into_owned())
343                .unwrap_or_else(|| "/".to_string());
344            return Ok(DirEntry::directory(name));
345        }
346
347        match self.find_mount(path) {
348            Ok((fs, relative)) => fs.lstat(&relative).await,
349            // Intermediate ancestor of a mount (e.g. `/v` above `/v/jobs`)
350            // exists as a synthesized directory.
351            Err(e) => {
352                if self.has_mount_under(path) {
353                    Ok(DirEntry::directory(Self::path_basename(path)))
354                } else {
355                    Err(e)
356                }
357            }
358        }
359    }
360
361    async fn mkdir(&self, path: &Path) -> io::Result<()> {
362        let (fs, relative) = self.find_mount(path)?;
363        fs.mkdir(&relative).await
364    }
365
366    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> io::Result<()> {
367        let (fs, relative) = self.find_mount(path)?;
368        fs.set_mtime(&relative, mtime).await
369    }
370
371    async fn remove(&self, path: &Path) -> io::Result<()> {
372        let (fs, relative) = self.find_mount(path)?;
373        fs.remove(&relative).await
374    }
375
376    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
377        let (from_fs, from_relative) = self.find_mount(from)?;
378        let (to_fs, to_relative) = self.find_mount(to)?;
379
380        // Check if both paths are on the same mount by comparing Arc pointers
381        if !Arc::ptr_eq(&from_fs, &to_fs) {
382            return Err(io::Error::new(
383                io::ErrorKind::Unsupported,
384                "cannot rename across different mount points",
385            ));
386        }
387
388        from_fs.rename(&from_relative, &to_relative).await
389    }
390
391    fn read_only(&self) -> bool {
392        // Router is read-only iff every mount is. Empty router returns
393        // false — a router with no mounts isn't meaningfully read-only,
394        // and false preserves the behaviour callers saw before this change.
395        if self.mounts.is_empty() {
396            return false;
397        }
398        self.mounts.values().all(|fs| fs.read_only())
399    }
400}
401
402impl VfsRouter {
403    /// List the root directory, synthesizing entries from mount points.
404    async fn list_root(&self) -> io::Result<Vec<DirEntry>> {
405        let mut entries = Vec::new();
406        let mut seen_names = std::collections::HashSet::new();
407
408        for mount_path in self.mounts.keys() {
409            let mount_str = mount_path.to_string_lossy();
410            if mount_str == "/" {
411                // Root mount: list its contents directly
412                if let Some(fs) = self.mounts.get(mount_path)
413                    && let Ok(root_entries) = fs.list(Path::new("")).await {
414                        for entry in root_entries {
415                            if seen_names.insert(entry.name.clone()) {
416                                entries.push(entry);
417                            }
418                        }
419                    }
420            } else {
421                // Non-root mount: extract first path component
422                let first_component = mount_str
423                    .trim_start_matches('/')
424                    .split('/')
425                    .next()
426                    .unwrap_or("");
427
428                if !first_component.is_empty() && seen_names.insert(first_component.to_string()) {
429                    entries.push(DirEntry::directory(first_component));
430                }
431            }
432        }
433
434        entries.sort_by(|a, b| a.name.cmp(&b.name));
435        Ok(entries)
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crate::vfs::MemoryFs;
443
444    #[tokio::test]
445    async fn test_basic_mount() {
446        let mut router = VfsRouter::new();
447        let scratch = MemoryFs::new();
448        scratch.write(Path::new("test.txt"), b"hello").await.unwrap();
449        router.mount("/scratch", scratch);
450
451        let data = router.read(Path::new("/scratch/test.txt")).await.unwrap();
452        assert_eq!(data, b"hello");
453    }
454
455    #[tokio::test]
456    async fn test_multiple_mounts() {
457        let mut router = VfsRouter::new();
458
459        let scratch = MemoryFs::new();
460        scratch.write(Path::new("a.txt"), b"scratch").await.unwrap();
461        router.mount("/scratch", scratch);
462
463        let data = MemoryFs::new();
464        data.write(Path::new("b.txt"), b"data").await.unwrap();
465        router.mount("/data", data);
466
467        assert_eq!(
468            router.read(Path::new("/scratch/a.txt")).await.unwrap(),
469            b"scratch"
470        );
471        assert_eq!(
472            router.read(Path::new("/data/b.txt")).await.unwrap(),
473            b"data"
474        );
475    }
476
477    #[tokio::test]
478    async fn test_nested_mount() {
479        let mut router = VfsRouter::new();
480
481        let outer = MemoryFs::new();
482        outer.write(Path::new("outer.txt"), b"outer").await.unwrap();
483        router.mount("/mnt", outer);
484
485        let inner = MemoryFs::new();
486        inner.write(Path::new("inner.txt"), b"inner").await.unwrap();
487        router.mount("/mnt/project", inner);
488
489        // /mnt/outer.txt should come from outer mount
490        assert_eq!(
491            router.read(Path::new("/mnt/outer.txt")).await.unwrap(),
492            b"outer"
493        );
494
495        // /mnt/project/inner.txt should come from inner mount
496        assert_eq!(
497            router.read(Path::new("/mnt/project/inner.txt")).await.unwrap(),
498            b"inner"
499        );
500    }
501
502    #[tokio::test]
503    async fn test_list_root() {
504        let mut router = VfsRouter::new();
505        router.mount("/scratch", MemoryFs::new());
506        router.mount("/mnt/a", MemoryFs::new());
507        router.mount("/mnt/b", MemoryFs::new());
508
509        let entries = router.list(Path::new("/")).await.unwrap();
510        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
511
512        assert!(names.contains(&&"scratch".to_string()));
513        assert!(names.contains(&&"mnt".to_string()));
514    }
515
516    #[tokio::test]
517    async fn test_unmount() {
518        let mut router = VfsRouter::new();
519
520        let fs = MemoryFs::new();
521        fs.write(Path::new("test.txt"), b"data").await.unwrap();
522        router.mount("/scratch", fs);
523
524        assert!(router.read(Path::new("/scratch/test.txt")).await.is_ok());
525
526        router.unmount("/scratch");
527
528        assert!(router.read(Path::new("/scratch/test.txt")).await.is_err());
529    }
530
531    #[tokio::test]
532    async fn test_list_mounts() {
533        let mut router = VfsRouter::new();
534        router.mount("/scratch", MemoryFs::new());
535        router.mount("/data", MemoryFs::new());
536
537        let mounts = router.list_mounts();
538        assert_eq!(mounts.len(), 2);
539
540        let paths: Vec<_> = mounts.iter().map(|m| &m.path).collect();
541        assert!(paths.contains(&&PathBuf::from("/scratch")));
542        assert!(paths.contains(&&PathBuf::from("/data")));
543    }
544
545    #[tokio::test]
546    async fn test_no_mount_error() {
547        let router = VfsRouter::new();
548        let result = router.read(Path::new("/nothing/here.txt")).await;
549        assert!(result.is_err());
550        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
551    }
552
553    #[tokio::test]
554    async fn test_root_mount() {
555        let mut router = VfsRouter::new();
556
557        let root = MemoryFs::new();
558        root.write(Path::new("at-root.txt"), b"root file").await.unwrap();
559        router.mount("/", root);
560
561        let data = router.read(Path::new("/at-root.txt")).await.unwrap();
562        assert_eq!(data, b"root file");
563    }
564
565    #[tokio::test]
566    async fn test_write_through_router() {
567        let mut router = VfsRouter::new();
568        router.mount("/scratch", MemoryFs::new());
569
570        router
571            .write(Path::new("/scratch/new.txt"), b"created")
572            .await
573            .unwrap();
574
575        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
576        assert_eq!(data, b"created");
577    }
578
579    #[tokio::test]
580    async fn test_stat_mount_point() {
581        let mut router = VfsRouter::new();
582        router.mount("/scratch", MemoryFs::new());
583
584        let entry = router.stat(Path::new("/scratch")).await.unwrap();
585        assert!(entry.is_dir());
586    }
587
588    #[tokio::test]
589    async fn test_stat_root() {
590        let router = VfsRouter::new();
591        let entry = router.stat(Path::new("/")).await.unwrap();
592        assert!(entry.is_dir());
593    }
594
595    #[tokio::test]
596    async fn test_rename_same_mount() {
597        let mut router = VfsRouter::new();
598        let mem = MemoryFs::new();
599        mem.write(Path::new("old.txt"), b"data").await.unwrap();
600        router.mount("/scratch", mem);
601
602        router.rename(Path::new("/scratch/old.txt"), Path::new("/scratch/new.txt")).await.unwrap();
603
604        // New path exists
605        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
606        assert_eq!(data, b"data");
607
608        // Old path doesn't exist
609        assert!(!router.exists(Path::new("/scratch/old.txt")).await);
610    }
611
612    #[tokio::test]
613    async fn test_rename_cross_mount_fails() {
614        let mut router = VfsRouter::new();
615        let mem1 = MemoryFs::new();
616        mem1.write(Path::new("file.txt"), b"data").await.unwrap();
617        router.mount("/mount1", mem1);
618        router.mount("/mount2", MemoryFs::new());
619
620        let result = router.rename(Path::new("/mount1/file.txt"), Path::new("/mount2/file.txt")).await;
621        assert!(result.is_err());
622        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported);
623    }
624
625    #[tokio::test]
626    async fn read_only_empty_router_returns_false() {
627        let router = VfsRouter::new();
628        assert!(!router.read_only());
629    }
630
631    #[cfg(feature = "localfs")]
632    #[tokio::test]
633    async fn read_only_all_read_only_mounts_returns_true() {
634        use crate::vfs::LocalFs;
635
636        let t1 = tempfile::tempdir().unwrap();
637        let t2 = tempfile::tempdir().unwrap();
638
639        let mut router = VfsRouter::new();
640        router.mount("/a", LocalFs::read_only(t1.path().to_path_buf()));
641        router.mount("/b", LocalFs::read_only(t2.path().to_path_buf()));
642
643        assert!(router.read_only());
644    }
645
646    #[cfg(feature = "localfs")]
647    #[tokio::test]
648    async fn read_only_mixed_mounts_returns_false() {
649        use crate::vfs::LocalFs;
650
651        let t1 = tempfile::tempdir().unwrap();
652
653        let mut router = VfsRouter::new();
654        router.mount("/ro", LocalFs::read_only(t1.path().to_path_buf()));
655        router.mount("/rw", MemoryFs::new());
656
657        assert!(!router.read_only());
658    }
659
660    // An intermediate directory that has no mount of its own but sits *above*
661    // one or more mounts (e.g. `/v` over `/v/jobs`, `/v/blobs`) must present as
662    // a real, listable directory synthesized from the mount roster — not the
663    // `NotFound` the bare `find_mount` returns. This is what lets a kaish shell
664    // (and SFTP over the bare router) navigate `/v` when the mounts sit at
665    // `/v/*`, and it's the router half of the `/v` overlay-tuning fix.
666    #[tokio::test]
667    async fn test_list_synthesizes_intermediate_dir() {
668        let mut router = VfsRouter::new();
669        router.mount("/v/jobs", MemoryFs::new());
670        router.mount("/v/blobs", MemoryFs::new());
671
672        let entries = router.list(Path::new("/v")).await.unwrap();
673        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
674        assert_eq!(names, vec!["blobs", "jobs"]); // sorted, synthesized from mounts
675    }
676
677    #[tokio::test]
678    async fn test_stat_intermediate_dir_is_directory() {
679        let mut router = VfsRouter::new();
680        router.mount("/v/jobs", MemoryFs::new());
681
682        assert!(router.stat(Path::new("/v")).await.unwrap().is_dir());
683        assert!(router.lstat(Path::new("/v")).await.unwrap().is_dir());
684    }
685
686    #[tokio::test]
687    async fn test_deep_intermediate_dir() {
688        let mut router = VfsRouter::new();
689        router.mount("/v/etc/rc", MemoryFs::new());
690
691        let v: Vec<_> = router.list(Path::new("/v")).await.unwrap();
692        assert_eq!(v.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["etc"]);
693        let etc: Vec<_> = router.list(Path::new("/v/etc")).await.unwrap();
694        assert_eq!(etc.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["rc"]);
695        assert!(router.stat(Path::new("/v/etc")).await.unwrap().is_dir());
696    }
697
698    #[tokio::test]
699    async fn test_has_mount_under() {
700        let mut router = VfsRouter::new();
701        router.mount("/v/jobs", MemoryFs::new());
702
703        assert!(router.has_mount_under(Path::new("/v")));
704        assert!(router.has_mount_under(Path::new("/")));
705        // The mount point itself has nothing *below* it.
706        assert!(!router.has_mount_under(Path::new("/v/jobs")));
707        assert!(!router.has_mount_under(Path::new("/other")));
708    }
709
710    #[tokio::test]
711    async fn test_nonexistent_ancestor_still_notfound() {
712        let mut router = VfsRouter::new();
713        router.mount("/v/jobs", MemoryFs::new());
714
715        // A path with no mount at or below it stays NotFound — synthesis is
716        // only for genuine ancestors of a mount.
717        assert_eq!(
718            router.list(Path::new("/nope")).await.unwrap_err().kind(),
719            io::ErrorKind::NotFound
720        );
721        assert_eq!(
722            router.stat(Path::new("/nope")).await.unwrap_err().kind(),
723            io::ErrorKind::NotFound
724        );
725    }
726}