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    /// Recover a mount point's ancestor from a `NotFound`.
146    ///
147    /// A mount at `/a/b/c` implies `/a` and `/a/b` are directories, the way a
148    /// real mount implies its mount point's parents. The router synthesizes
149    /// them because no backend owns them.
150    ///
151    /// Mounting `/` makes `mount_of` match every path, so the backend covering
152    /// `/` is asked for `/a`, answers `NotFound`, and that reaches the caller
153    /// before the ancestor check below the `Err` arm can run. That check is
154    /// therefore unreachable whenever a root mount exists, which is the
155    /// ordinary embedder shape. This runs on the answer instead of on the
156    /// routing.
157    ///
158    /// Only `NotFound` is recovered. Any other error is the backend's answer
159    /// about a path it owns and must reach the caller unchanged.
160    fn or_synthesized_ancestor<T>(
161        &self,
162        path: &Path,
163        error: io::Error,
164        synthesize: impl FnOnce() -> T,
165    ) -> io::Result<T> {
166        if error.kind() == io::ErrorKind::NotFound && self.has_mount_under(path) {
167            Ok(synthesize())
168        } else {
169            Err(error)
170        }
171    }
172
173    fn list_mount_children(&self, dir: &Path) -> Vec<DirEntry> {
174        let dir = Self::normalize_mount_path(dir.to_path_buf());
175        let prefix = format!("{}/", dir.to_string_lossy());
176        let mut seen = std::collections::HashSet::new();
177        let mut entries = Vec::new();
178        for mount_path in self.mounts.keys() {
179            let mount_str = mount_path.to_string_lossy();
180            if let Some(rest) = mount_str.strip_prefix(&prefix) {
181                let first = rest.split('/').next().unwrap_or("");
182                if !first.is_empty() && seen.insert(first.to_string()) {
183                    entries.push(DirEntry::directory(first));
184                }
185            }
186        }
187        entries.sort_by(|a, b| a.name.cmp(&b.name));
188        entries
189    }
190
191    /// The final path component, for naming a synthesized directory entry
192    /// (`/v` → `v`). Falls back to `/` for a path with no component.
193    fn path_basename(path: &Path) -> String {
194        path.file_name()
195            .map(|n| n.to_string_lossy().into_owned())
196            .unwrap_or_else(|| "/".to_string())
197    }
198
199    /// Find the mount point for a given path.
200    ///
201    /// Returns the mount and the path relative to that mount.
202    fn find_mount(&self, path: &Path) -> io::Result<(Arc<dyn Filesystem>, PathBuf)> {
203        let (_, fs, relative) = self.mount_of(path)?;
204        Ok((fs, relative))
205    }
206
207    /// The mount that owns `path`: its mount point, its filesystem, and the
208    /// path relative to the mount point.
209    fn mount_of(&self, path: &Path) -> io::Result<(&Path, Arc<dyn Filesystem>, PathBuf)> {
210        let path_str = path.to_string_lossy();
211        let normalized = if path_str.starts_with('/') {
212            path.to_path_buf()
213        } else {
214            PathBuf::from(format!("/{}", path_str))
215        };
216
217        // Find longest matching mount point
218        let mut best_match: Option<(&PathBuf, &Arc<dyn Filesystem>)> = None;
219
220        for (mount_path, fs) in &self.mounts {
221            let mount_str = mount_path.to_string_lossy();
222
223            // Check if the path starts with this mount point
224            let is_match = if mount_str == "/" {
225                true // Root matches everything
226            } else {
227                let normalized_str = normalized.to_string_lossy();
228                normalized_str == mount_str.as_ref()
229                    || normalized_str.starts_with(&format!("{}/", mount_str))
230            };
231
232            if is_match {
233                // Keep the longest match
234                let dominated = best_match
235                    .as_ref()
236                    .is_none_or(|(bp, _)| mount_path.as_os_str().len() > bp.as_os_str().len());
237                if dominated {
238                    best_match = Some((mount_path, fs));
239                }
240            }
241        }
242
243        match best_match {
244            Some((mount_path, fs)) => {
245                // Calculate relative path
246                let mount_str = mount_path.to_string_lossy();
247                let normalized_str = normalized.to_string_lossy();
248
249                let relative = if mount_str == "/" {
250                    normalized_str.trim_start_matches('/').to_string()
251                } else {
252                    normalized_str
253                        .strip_prefix(mount_str.as_ref())
254                        .unwrap_or("")
255                        .trim_start_matches('/')
256                        .to_string()
257                };
258
259                Ok((mount_path.as_path(), Arc::clone(fs), PathBuf::from(relative)))
260            }
261            None => Err(io::Error::new(
262                io::ErrorKind::NotFound,
263                format!("no mount point for path: {}", path.display()),
264            )),
265        }
266    }
267}
268
269/// Resolve `.` and `..` lexically in an absolute VFS path; `..` at the root
270/// stays at the root.
271fn lexical_absolute(path: &Path) -> PathBuf {
272    let mut out = PathBuf::from("/");
273    for component in path.components() {
274        match component {
275            std::path::Component::Normal(name) => out.push(name),
276            std::path::Component::ParentDir => {
277                out.pop();
278            }
279            _ => {}
280        }
281    }
282    out
283}
284
285/// The relative path from directory `from` to `to`, both absolute and
286/// lexically normalized: `..` for each component of `from` past the common
287/// prefix, then the rest of `to`.
288fn relative_path_from(from: &Path, to: &Path) -> PathBuf {
289    let mut from_parts = from.components().skip(1).peekable();
290    let mut to_parts = to.components().skip(1).peekable();
291    while let (Some(a), Some(b)) = (from_parts.peek(), to_parts.peek()) {
292        if a != b {
293            break;
294        }
295        from_parts.next();
296        to_parts.next();
297    }
298    let mut relative = PathBuf::new();
299    for _ in from_parts {
300        relative.push("..");
301    }
302    for part in to_parts {
303        relative.push(part);
304    }
305    if relative.as_os_str().is_empty() {
306        relative.push(".");
307    }
308    relative
309}
310
311#[async_trait]
312impl Filesystem for VfsRouter {
313    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
314    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
315        let (fs, relative) = self.find_mount(path)?;
316        fs.read(&relative).await
317    }
318
319    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
320    async fn read_range(
321        &self,
322        path: &Path,
323        range: Option<kaish_vfs::ReadRange>,
324    ) -> io::Result<Vec<u8>> {
325        // Forward the range to the mount so range-aware backends (e.g. DevFs's
326        // /dev/zero) see the requested byte count. Falling through to the trait
327        // default would call our own `read` (whole file) and slice afterwards,
328        // which would hang or error on an infinite device.
329        let (fs, relative) = self.find_mount(path)?;
330        fs.read_range(&relative, range).await
331    }
332
333    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
334    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
335        let (fs, relative) = self.find_mount(path)?;
336        fs.write(&relative, data).await
337    }
338
339    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
340    async fn append(&self, path: &Path, data: &[u8]) -> io::Result<()> {
341        // Forward to the mount so a real append (LocalFs's O_APPEND, say)
342        // reaches it. Falling through to the trait default would call our
343        // own `read` and `write`, which route to the mount's read and write
344        // individually — never its `append` override, and losing the
345        // atomicity that override exists to provide.
346        let (fs, relative) = self.find_mount(path)?;
347        fs.append(&relative, data).await
348    }
349
350    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
351    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
352        // Special case: listing root might need to show mount points
353        let path_str = path.to_string_lossy();
354        if path_str.is_empty() || path_str == "/" {
355            return self.list_root().await;
356        }
357
358        let answer = match self.find_mount(path) {
359            Ok((fs, relative)) => fs.list(&relative).await,
360            Err(e) => Err(e),
361        };
362        match answer {
363            Ok(entries) => Ok(entries),
364            // An ancestor of a mount lists the mounts beneath it rather
365            // than 404ing.
366            Err(e) => self.or_synthesized_ancestor(path, e, || self.list_mount_children(path)),
367        }
368    }
369
370    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
371    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
372        // Special case: root always exists
373        let path_str = path.to_string_lossy();
374        if path_str.is_empty() || path_str == "/" {
375            return Ok(DirEntry::directory("/"));
376        }
377
378        // Check if path is a mount point itself
379        let normalized = Self::normalize_mount_path(path.to_path_buf());
380        if self.mounts.contains_key(&normalized) {
381            let name = path
382                .file_name()
383                .map(|n| n.to_string_lossy().into_owned())
384                .unwrap_or_else(|| "/".to_string());
385            return Ok(DirEntry::directory(name));
386        }
387
388        let answer = match self.find_mount(path) {
389            Ok((fs, relative)) => fs.stat(&relative).await,
390            Err(e) => Err(e),
391        };
392        match answer {
393            Ok(entry) => Ok(entry),
394            // An ancestor of a mount (`/v` above `/v/jobs`) is a synthesized
395            // directory, whether the routing missed or the backend did.
396            Err(e) => {
397                self.or_synthesized_ancestor(path, e, || {
398                    DirEntry::directory(Self::path_basename(path))
399                })
400            }
401        }
402    }
403
404    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
405        let (fs, relative) = self.find_mount(path)?;
406        fs.read_link(&relative).await
407    }
408
409    /// Delegates to the mount that owns `path`, translating VFS-absolute to
410    /// mount-relative going in and back going out — the mount answers in
411    /// its own namespace, same as every other `Filesystem` method here.
412    ///
413    /// `.` and `..` are folded lexically before routing, so a `..` that
414    /// walks from one mount into another (or into a synthesized ancestor)
415    /// resolves against the right one, the way `symlink`'s absolute-target
416    /// rewrite already folds before it picks a mount.
417    ///
418    /// Falls back the way `stat` does: a synthesized ancestor of a mount
419    /// (`/v` above `/v/jobs`) is a directory the router creates, never a
420    /// symlink, so it canonicalizes to itself.
421    async fn canonicalize(&self, path: &Path, allow_missing_final: bool) -> io::Result<PathBuf> {
422        let normalized = lexical_absolute(path);
423        if normalized == Path::new("/") {
424            return Ok(PathBuf::from("/"));
425        }
426
427        let answer = match self.mount_of(&normalized) {
428            Ok((mount_path, fs, relative)) => {
429                let mount_path = mount_path.to_path_buf();
430                fs.canonicalize(&relative, allow_missing_final)
431                    .await
432                    .map(|resolved| mount_path.join(resolved))
433            }
434            Err(e) => Err(e),
435        };
436        match answer {
437            Ok(resolved) => Ok(resolved),
438            Err(e) => self.or_synthesized_ancestor(&normalized, e, || normalized.clone()),
439        }
440    }
441
442    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
443        let (link_mount, fs, relative_link) = self.mount_of(link)?;
444        // A backend refuses an absolute target: it has no namespace to read
445        // one in. The router has the namespace, so an absolute VFS target on
446        // the link's own mount is rewritten relative to the link's directory.
447        // The stored spelling is what readlink then shows.
448        let target = if target.is_absolute() {
449            let target = lexical_absolute(target);
450            let (target_mount, _, _) = self.mount_of(&target)?;
451            if target_mount != link_mount {
452                return Err(io::Error::new(
453                    io::ErrorKind::InvalidInput,
454                    format!(
455                        "symlink target {} is on mount {} and the link {} is on mount {}; a link cannot cross mounts",
456                        target.display(),
457                        target_mount.display(),
458                        link.display(),
459                        link_mount.display()
460                    ),
461                ));
462            }
463            let link_dir = lexical_absolute(link);
464            let link_dir = link_dir.parent().unwrap_or(Path::new("/"));
465            relative_path_from(link_dir, &target)
466        } else {
467            target.to_path_buf()
468        };
469        fs.symlink(&target, &relative_link).await
470    }
471
472    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
473        // Special case: root always exists
474        let path_str = path.to_string_lossy();
475        if path_str.is_empty() || path_str == "/" {
476            return Ok(DirEntry::directory("/"));
477        }
478
479        // Check if path is a mount point itself
480        let normalized = Self::normalize_mount_path(path.to_path_buf());
481        if self.mounts.contains_key(&normalized) {
482            let name = path
483                .file_name()
484                .map(|n| n.to_string_lossy().into_owned())
485                .unwrap_or_else(|| "/".to_string());
486            return Ok(DirEntry::directory(name));
487        }
488
489        let answer = match self.find_mount(path) {
490            Ok((fs, relative)) => fs.lstat(&relative).await,
491            Err(e) => Err(e),
492        };
493        match answer {
494            Ok(entry) => Ok(entry),
495            // A synthesized ancestor is a directory, never a symlink, so
496            // lstat and stat agree about it.
497            Err(e) => {
498                self.or_synthesized_ancestor(path, e, || {
499                    DirEntry::directory(Self::path_basename(path))
500                })
501            }
502        }
503    }
504
505    async fn mkdir(&self, path: &Path) -> io::Result<()> {
506        let (fs, relative) = self.find_mount(path)?;
507        fs.mkdir(&relative).await
508    }
509
510    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> io::Result<()> {
511        let (fs, relative) = self.find_mount(path)?;
512        fs.set_mtime(&relative, mtime).await
513    }
514
515    async fn remove(&self, path: &Path) -> io::Result<()> {
516        let (fs, relative) = self.find_mount(path)?;
517        fs.remove(&relative).await
518    }
519
520    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
521        let (from_fs, from_relative) = self.find_mount(from)?;
522        let (to_fs, to_relative) = self.find_mount(to)?;
523
524        // Check if both paths are on the same mount by comparing Arc pointers
525        if !Arc::ptr_eq(&from_fs, &to_fs) {
526            return Err(io::Error::new(
527                io::ErrorKind::Unsupported,
528                "cannot rename across different mount points",
529            ));
530        }
531
532        from_fs.rename(&from_relative, &to_relative).await
533    }
534
535    /// Delegates to the mount that owns the path, so the answer is that
536    /// mount's — not the whole router's. `read_only()` below is the
537    /// whole-router question and cannot answer for one path: a router with a
538    /// writable `/` and a read-only `/v/bin` is read-only for neither.
539    ///
540    /// Falls back the way `stat` does. `stat` synthesizes a directory for the
541    /// root and for any ancestor of a mount (`/v` above `/v/jobs`), so those
542    /// paths exist, and an answer here that errored on them would contradict
543    /// it: `[[ -e /v ]]` true and `[[ -r /v ]]` false about the same path.
544    /// A synthesized directory is readable and searchable, and never
545    /// writable — the router creates nothing in one.
546    async fn path_access(&self, path: &Path) -> io::Result<PathAccess> {
547        let path_str = path.to_string_lossy();
548        if path_str.is_empty() || path_str == "/" {
549            return Ok(PathAccess::resolve(Some(SYNTHESIZED_DIRECTORY_MODE), true));
550        }
551        let answer = match self.find_mount(path) {
552            Ok((fs, relative)) => fs.path_access(&relative).await,
553            Err(e) => Err(e),
554        };
555        match answer {
556            Ok(access) => Ok(access),
557            Err(e) => self.or_synthesized_ancestor(path, e, || {
558                PathAccess::resolve(Some(SYNTHESIZED_DIRECTORY_MODE), true)
559            }),
560        }
561    }
562
563    fn read_only(&self) -> bool {
564        // Router is read-only iff every mount is. Empty router returns
565        // false — a router with no mounts isn't meaningfully read-only,
566        // and false preserves the behaviour callers saw before this change.
567        if self.mounts.is_empty() {
568            return false;
569        }
570        self.mounts.values().all(|fs| fs.read_only())
571    }
572}
573
574impl VfsRouter {
575    /// List the root directory, synthesizing entries from mount points.
576    async fn list_root(&self) -> io::Result<Vec<DirEntry>> {
577        let mut entries = Vec::new();
578        let mut seen_names = std::collections::HashSet::new();
579
580        for mount_path in self.mounts.keys() {
581            let mount_str = mount_path.to_string_lossy();
582            if mount_str == "/" {
583                // Root mount: list its contents directly
584                if let Some(fs) = self.mounts.get(mount_path)
585                    && let Ok(root_entries) = fs.list(Path::new("")).await {
586                        for entry in root_entries {
587                            if seen_names.insert(entry.name.clone()) {
588                                entries.push(entry);
589                            }
590                        }
591                    }
592            } else {
593                // Non-root mount: extract first path component
594                let first_component = mount_str
595                    .trim_start_matches('/')
596                    .split('/')
597                    .next()
598                    .unwrap_or("");
599
600                if !first_component.is_empty() && seen_names.insert(first_component.to_string()) {
601                    entries.push(DirEntry::directory(first_component));
602                }
603            }
604        }
605
606        entries.sort_by(|a, b| a.name.cmp(&b.name));
607        Ok(entries)
608    }
609}
610
611#[cfg(test)]
612mod tests {
613    use super::*;
614    use crate::vfs::MemoryFs;
615
616    #[tokio::test]
617    async fn symlink_absolute_target_on_the_same_mount_is_stored_relative() {
618        let mut router = VfsRouter::new();
619        let data = MemoryFs::new();
620        data.write(Path::new("etc/hosts"), b"hosts").await.unwrap();
621        data.mkdir(Path::new("home")).await.unwrap();
622        router.mount("/data", data);
623
624        router
625            .symlink(Path::new("/data/etc/hosts"), Path::new("/data/home/link"))
626            .await
627            .unwrap();
628
629        assert_eq!(
630            router.read_link(Path::new("/data/home/link")).await.unwrap(),
631            PathBuf::from("../etc/hosts")
632        );
633        assert_eq!(router.read(Path::new("/data/home/link")).await.unwrap(), b"hosts");
634    }
635
636    #[tokio::test]
637    async fn symlink_absolute_target_beside_the_link_is_a_bare_name() {
638        let mut router = VfsRouter::new();
639        let root = MemoryFs::new();
640        root.write(Path::new("a/target"), b"t").await.unwrap();
641        router.mount("/", root);
642
643        router
644            .symlink(Path::new("/a/target"), Path::new("/a/link"))
645            .await
646            .unwrap();
647        assert_eq!(
648            router.read_link(Path::new("/a/link")).await.unwrap(),
649            PathBuf::from("target")
650        );
651    }
652
653    #[tokio::test]
654    async fn symlink_absolute_target_with_dotdot_is_normalized_first() {
655        let mut router = VfsRouter::new();
656        let root = MemoryFs::new();
657        root.write(Path::new("etc/hosts"), b"hosts").await.unwrap();
658        root.mkdir(Path::new("home")).await.unwrap();
659        router.mount("/", root);
660
661        router
662            .symlink(Path::new("/home/../etc/./hosts"), Path::new("/home/link"))
663            .await
664            .unwrap();
665        assert_eq!(
666            router.read_link(Path::new("/home/link")).await.unwrap(),
667            PathBuf::from("../etc/hosts")
668        );
669        assert_eq!(router.read(Path::new("/home/link")).await.unwrap(), b"hosts");
670    }
671
672    #[tokio::test]
673    async fn symlink_relative_target_is_stored_verbatim() {
674        let mut router = VfsRouter::new();
675        router.mount("/data", MemoryFs::new());
676
677        router
678            .symlink(Path::new("../x/../y"), Path::new("/data/d/link"))
679            .await
680            .unwrap();
681        assert_eq!(
682            router.read_link(Path::new("/data/d/link")).await.unwrap(),
683            PathBuf::from("../x/../y")
684        );
685    }
686
687    #[tokio::test]
688    async fn symlink_across_mounts_is_refused_and_creates_nothing() {
689        let mut router = VfsRouter::new();
690        router.mount("/data", MemoryFs::new());
691        let scratch = MemoryFs::new();
692        scratch.write(Path::new("x"), b"x").await.unwrap();
693        router.mount("/scratch", scratch);
694
695        let error = router
696            .symlink(Path::new("/scratch/x"), Path::new("/data/link"))
697            .await
698            .unwrap_err();
699        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
700        let message = error.to_string();
701        assert!(message.contains("/scratch") && message.contains("/data"), "{message}");
702        assert!(router.lstat(Path::new("/data/link")).await.is_err(), "nothing created");
703    }
704
705    #[test]
706    fn relative_path_from_walks_up_then_down() {
707        let rel = |from: &str, to: &str| relative_path_from(Path::new(from), Path::new(to));
708        assert_eq!(rel("/a/b", "/a/c/d"), PathBuf::from("../c/d"));
709        assert_eq!(rel("/a", "/a/x"), PathBuf::from("x"));
710        assert_eq!(rel("/", "/x/y"), PathBuf::from("x/y"));
711        assert_eq!(rel("/a/b/c", "/"), PathBuf::from("../../.."));
712        assert_eq!(rel("/a/b", "/a/b"), PathBuf::from("."));
713    }
714
715    #[tokio::test]
716    async fn test_basic_mount() {
717        let mut router = VfsRouter::new();
718        let scratch = MemoryFs::new();
719        scratch.write(Path::new("test.txt"), b"hello").await.unwrap();
720        router.mount("/scratch", scratch);
721
722        let data = router.read(Path::new("/scratch/test.txt")).await.unwrap();
723        assert_eq!(data, b"hello");
724    }
725
726    #[tokio::test]
727    async fn test_multiple_mounts() {
728        let mut router = VfsRouter::new();
729
730        let scratch = MemoryFs::new();
731        scratch.write(Path::new("a.txt"), b"scratch").await.unwrap();
732        router.mount("/scratch", scratch);
733
734        let data = MemoryFs::new();
735        data.write(Path::new("b.txt"), b"data").await.unwrap();
736        router.mount("/data", data);
737
738        assert_eq!(
739            router.read(Path::new("/scratch/a.txt")).await.unwrap(),
740            b"scratch"
741        );
742        assert_eq!(
743            router.read(Path::new("/data/b.txt")).await.unwrap(),
744            b"data"
745        );
746    }
747
748    #[tokio::test]
749    async fn test_nested_mount() {
750        let mut router = VfsRouter::new();
751
752        let outer = MemoryFs::new();
753        outer.write(Path::new("outer.txt"), b"outer").await.unwrap();
754        router.mount("/mnt", outer);
755
756        let inner = MemoryFs::new();
757        inner.write(Path::new("inner.txt"), b"inner").await.unwrap();
758        router.mount("/mnt/project", inner);
759
760        // /mnt/outer.txt should come from outer mount
761        assert_eq!(
762            router.read(Path::new("/mnt/outer.txt")).await.unwrap(),
763            b"outer"
764        );
765
766        // /mnt/project/inner.txt should come from inner mount
767        assert_eq!(
768            router.read(Path::new("/mnt/project/inner.txt")).await.unwrap(),
769            b"inner"
770        );
771    }
772
773    #[tokio::test]
774    async fn test_list_root() {
775        let mut router = VfsRouter::new();
776        router.mount("/scratch", MemoryFs::new());
777        router.mount("/mnt/a", MemoryFs::new());
778        router.mount("/mnt/b", MemoryFs::new());
779
780        let entries = router.list(Path::new("/")).await.unwrap();
781        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
782
783        assert!(names.contains(&&"scratch".to_string()));
784        assert!(names.contains(&&"mnt".to_string()));
785    }
786
787    #[tokio::test]
788    async fn test_unmount() {
789        let mut router = VfsRouter::new();
790
791        let fs = MemoryFs::new();
792        fs.write(Path::new("test.txt"), b"data").await.unwrap();
793        router.mount("/scratch", fs);
794
795        assert!(router.read(Path::new("/scratch/test.txt")).await.is_ok());
796
797        router.unmount("/scratch");
798
799        assert!(router.read(Path::new("/scratch/test.txt")).await.is_err());
800    }
801
802    #[tokio::test]
803    async fn test_list_mounts() {
804        let mut router = VfsRouter::new();
805        router.mount("/scratch", MemoryFs::new());
806        router.mount("/data", MemoryFs::new());
807
808        let mounts = router.list_mounts();
809        assert_eq!(mounts.len(), 2);
810
811        let paths: Vec<_> = mounts.iter().map(|m| &m.path).collect();
812        assert!(paths.contains(&&PathBuf::from("/scratch")));
813        assert!(paths.contains(&&PathBuf::from("/data")));
814    }
815
816    #[tokio::test]
817    async fn test_no_mount_error() {
818        let router = VfsRouter::new();
819        let result = router.read(Path::new("/nothing/here.txt")).await;
820        assert!(result.is_err());
821        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
822    }
823
824    #[tokio::test]
825    async fn test_root_mount() {
826        let mut router = VfsRouter::new();
827
828        let root = MemoryFs::new();
829        root.write(Path::new("at-root.txt"), b"root file").await.unwrap();
830        router.mount("/", root);
831
832        let data = router.read(Path::new("/at-root.txt")).await.unwrap();
833        assert_eq!(data, b"root file");
834    }
835
836    #[tokio::test]
837    async fn test_write_through_router() {
838        let mut router = VfsRouter::new();
839        router.mount("/scratch", MemoryFs::new());
840
841        router
842            .write(Path::new("/scratch/new.txt"), b"created")
843            .await
844            .unwrap();
845
846        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
847        assert_eq!(data, b"created");
848    }
849
850    #[tokio::test]
851    async fn test_stat_mount_point() {
852        let mut router = VfsRouter::new();
853        router.mount("/scratch", MemoryFs::new());
854
855        let entry = router.stat(Path::new("/scratch")).await.unwrap();
856        assert!(entry.is_dir());
857    }
858
859    #[tokio::test]
860    async fn test_stat_root() {
861        let router = VfsRouter::new();
862        let entry = router.stat(Path::new("/")).await.unwrap();
863        assert!(entry.is_dir());
864    }
865
866    #[tokio::test]
867    async fn test_rename_same_mount() {
868        let mut router = VfsRouter::new();
869        let mem = MemoryFs::new();
870        mem.write(Path::new("old.txt"), b"data").await.unwrap();
871        router.mount("/scratch", mem);
872
873        router.rename(Path::new("/scratch/old.txt"), Path::new("/scratch/new.txt")).await.unwrap();
874
875        // New path exists
876        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
877        assert_eq!(data, b"data");
878
879        // Old path doesn't exist
880        assert!(!router.exists(Path::new("/scratch/old.txt")).await);
881    }
882
883    #[tokio::test]
884    async fn test_rename_cross_mount_fails() {
885        let mut router = VfsRouter::new();
886        let mem1 = MemoryFs::new();
887        mem1.write(Path::new("file.txt"), b"data").await.unwrap();
888        router.mount("/mount1", mem1);
889        router.mount("/mount2", MemoryFs::new());
890
891        let result = router.rename(Path::new("/mount1/file.txt"), Path::new("/mount2/file.txt")).await;
892        assert!(result.is_err());
893        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported);
894    }
895
896    // `stat` synthesizes a directory for the root and for any ancestor of a
897    // mount, so those paths exist. `path_access` has to agree with `stat`
898    // about the same path — going straight to `find_mount` errors where
899    // `stat` succeeds, and `[[ -e /v ]]` would be true while `[[ -r /v ]]`
900    // was false about the identical path.
901    #[tokio::test]
902    async fn path_access_agrees_with_stat_on_synthesized_directories() {
903        let mut router = VfsRouter::new();
904        router.mount("/v/docs", MemoryFs::new());
905
906        for path in ["/", "/v"] {
907            let path = Path::new(path);
908            assert!(
909                router.stat(path).await.is_ok(),
910                "{} is synthesized by stat",
911                path.display()
912            );
913            let access = router
914                .path_access(path)
915                .await
916                .unwrap_or_else(|e| panic!("path_access must not error where stat succeeds: {e}"));
917            assert!(access.readable, "{} must be readable", path.display());
918            assert!(access.executable, "{} must be searchable", path.display());
919            assert!(
920                !access.writable,
921                "the router creates nothing in {}",
922                path.display()
923            );
924        }
925    }
926
927    // The synthesis must not swallow a genuinely absent path.
928    #[tokio::test]
929    async fn path_access_errors_on_a_path_with_no_mount() {
930        let mut router = VfsRouter::new();
931        router.mount("/v/docs", MemoryFs::new());
932        assert!(router.path_access(Path::new("/nope")).await.is_err());
933        assert!(router.path_access(Path::new("/v/docs/absent")).await.is_err());
934    }
935
936    // A real mount answers for itself, not with the synthesized defaults.
937    #[tokio::test]
938    async fn path_access_at_a_mount_point_asks_the_mount() {
939        let mut router = VfsRouter::new();
940        router.mount("/rw", MemoryFs::new());
941        router.mount("/ro", BuiltinFsStub);
942
943        assert!(router.path_access(Path::new("/rw")).await.unwrap().writable);
944        assert!(!router.path_access(Path::new("/ro")).await.unwrap().writable);
945        assert!(router.path_access(Path::new("/ro")).await.unwrap().readable);
946    }
947
948    /// A minimal read-only mount that reports no mode, standing in for
949    /// `BuiltinFs`/`JobFs` without dragging a ToolRegistry into this module.
950    struct BuiltinFsStub;
951
952    #[async_trait]
953    impl Filesystem for BuiltinFsStub {
954        async fn read(&self, _path: &Path) -> io::Result<Vec<u8>> {
955            Ok(Vec::new())
956        }
957        async fn write(&self, _path: &Path, _data: &[u8]) -> io::Result<()> {
958            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
959        }
960        async fn list(&self, _path: &Path) -> io::Result<Vec<DirEntry>> {
961            Ok(Vec::new())
962        }
963        async fn stat(&self, _path: &Path) -> io::Result<DirEntry> {
964            Ok(DirEntry::directory("."))
965        }
966        async fn mkdir(&self, _path: &Path) -> io::Result<()> {
967            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
968        }
969        async fn remove(&self, _path: &Path) -> io::Result<()> {
970            Err(io::Error::new(io::ErrorKind::PermissionDenied, "read-only"))
971        }
972        fn read_only(&self) -> bool {
973            true
974        }
975    }
976
977    #[tokio::test]
978    async fn read_only_empty_router_returns_false() {
979        let router = VfsRouter::new();
980        assert!(!router.read_only());
981    }
982
983    #[cfg(feature = "localfs")]
984    #[tokio::test]
985    async fn read_only_all_read_only_mounts_returns_true() {
986        use crate::vfs::LocalFs;
987
988        let t1 = tempfile::tempdir().unwrap();
989        let t2 = tempfile::tempdir().unwrap();
990
991        let mut router = VfsRouter::new();
992        router.mount("/a", LocalFs::read_only(t1.path().to_path_buf()));
993        router.mount("/b", LocalFs::read_only(t2.path().to_path_buf()));
994
995        assert!(router.read_only());
996    }
997
998    #[cfg(feature = "localfs")]
999    #[tokio::test]
1000    async fn read_only_mixed_mounts_returns_false() {
1001        use crate::vfs::LocalFs;
1002
1003        let t1 = tempfile::tempdir().unwrap();
1004
1005        let mut router = VfsRouter::new();
1006        router.mount("/ro", LocalFs::read_only(t1.path().to_path_buf()));
1007        router.mount("/rw", MemoryFs::new());
1008
1009        assert!(!router.read_only());
1010    }
1011
1012    // An intermediate directory that has no mount of its own but sits *above*
1013    // one or more mounts (e.g. `/v` over `/v/jobs`, `/v/blobs`) must present as
1014    // a real, listable directory synthesized from the mount roster — not the
1015    // `NotFound` the bare `find_mount` returns. This is what lets a kaish shell
1016    // (and SFTP over the bare router) navigate `/v` when the mounts sit at
1017    // `/v/*`, and it's the router half of the `/v` overlay-tuning fix.
1018    #[tokio::test]
1019    async fn test_list_synthesizes_intermediate_dir() {
1020        let mut router = VfsRouter::new();
1021        router.mount("/v/jobs", MemoryFs::new());
1022        router.mount("/v/blobs", MemoryFs::new());
1023
1024        let entries = router.list(Path::new("/v")).await.unwrap();
1025        let names: Vec<_> = entries.iter().map(|e| e.name.as_str()).collect();
1026        assert_eq!(names, vec!["blobs", "jobs"]); // sorted, synthesized from mounts
1027    }
1028
1029    #[tokio::test]
1030    async fn test_stat_intermediate_dir_is_directory() {
1031        let mut router = VfsRouter::new();
1032        router.mount("/v/jobs", MemoryFs::new());
1033
1034        assert!(router.stat(Path::new("/v")).await.unwrap().is_dir());
1035        assert!(router.lstat(Path::new("/v")).await.unwrap().is_dir());
1036    }
1037
1038    #[tokio::test]
1039    async fn test_deep_intermediate_dir() {
1040        let mut router = VfsRouter::new();
1041        router.mount("/v/etc/rc", MemoryFs::new());
1042
1043        let v: Vec<_> = router.list(Path::new("/v")).await.unwrap();
1044        assert_eq!(v.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["etc"]);
1045        let etc: Vec<_> = router.list(Path::new("/v/etc")).await.unwrap();
1046        assert_eq!(etc.iter().map(|e| e.name.as_str()).collect::<Vec<_>>(), vec!["rc"]);
1047        assert!(router.stat(Path::new("/v/etc")).await.unwrap().is_dir());
1048    }
1049
1050    #[tokio::test]
1051    async fn test_has_mount_under() {
1052        let mut router = VfsRouter::new();
1053        router.mount("/v/jobs", MemoryFs::new());
1054
1055        assert!(router.has_mount_under(Path::new("/v")));
1056        assert!(router.has_mount_under(Path::new("/")));
1057        // The mount point itself has nothing *below* it.
1058        assert!(!router.has_mount_under(Path::new("/v/jobs")));
1059        assert!(!router.has_mount_under(Path::new("/other")));
1060    }
1061
1062    #[tokio::test]
1063    async fn test_nonexistent_ancestor_still_notfound() {
1064        let mut router = VfsRouter::new();
1065        router.mount("/v/jobs", MemoryFs::new());
1066
1067        // A path with no mount at or below it stays NotFound — synthesis is
1068        // only for genuine ancestors of a mount.
1069        assert_eq!(
1070            router.list(Path::new("/nope")).await.unwrap_err().kind(),
1071            io::ErrorKind::NotFound
1072        );
1073        assert_eq!(
1074            router.stat(Path::new("/nope")).await.unwrap_err().kind(),
1075            io::ErrorKind::NotFound
1076        );
1077    }
1078}