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    /// Find the mount point for a given path.
113    ///
114    /// Returns the mount and the path relative to that mount.
115    fn find_mount(&self, path: &Path) -> io::Result<(Arc<dyn Filesystem>, PathBuf)> {
116        let path_str = path.to_string_lossy();
117        let normalized = if path_str.starts_with('/') {
118            path.to_path_buf()
119        } else {
120            PathBuf::from(format!("/{}", path_str))
121        };
122
123        // Find longest matching mount point
124        let mut best_match: Option<(&PathBuf, &Arc<dyn Filesystem>)> = None;
125
126        for (mount_path, fs) in &self.mounts {
127            let mount_str = mount_path.to_string_lossy();
128
129            // Check if the path starts with this mount point
130            let is_match = if mount_str == "/" {
131                true // Root matches everything
132            } else {
133                let normalized_str = normalized.to_string_lossy();
134                normalized_str == mount_str.as_ref()
135                    || normalized_str.starts_with(&format!("{}/", mount_str))
136            };
137
138            if is_match {
139                // Keep the longest match
140                let dominated = best_match
141                    .as_ref()
142                    .is_none_or(|(bp, _)| mount_path.as_os_str().len() > bp.as_os_str().len());
143                if dominated {
144                    best_match = Some((mount_path, fs));
145                }
146            }
147        }
148
149        match best_match {
150            Some((mount_path, fs)) => {
151                // Calculate relative path
152                let mount_str = mount_path.to_string_lossy();
153                let normalized_str = normalized.to_string_lossy();
154
155                let relative = if mount_str == "/" {
156                    normalized_str.trim_start_matches('/').to_string()
157                } else {
158                    normalized_str
159                        .strip_prefix(mount_str.as_ref())
160                        .unwrap_or("")
161                        .trim_start_matches('/')
162                        .to_string()
163                };
164
165                Ok((Arc::clone(fs), PathBuf::from(relative)))
166            }
167            None => Err(io::Error::new(
168                io::ErrorKind::NotFound,
169                format!("no mount point for path: {}", path.display()),
170            )),
171        }
172    }
173}
174
175#[async_trait]
176impl Filesystem for VfsRouter {
177    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
178    async fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
179        let (fs, relative) = self.find_mount(path)?;
180        fs.read(&relative).await
181    }
182
183    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
184    async fn read_range(
185        &self,
186        path: &Path,
187        range: Option<kaish_vfs::ReadRange>,
188    ) -> io::Result<Vec<u8>> {
189        // Forward the range to the mount so range-aware backends (e.g. DevFs's
190        // /dev/zero) see the requested byte count. Falling through to the trait
191        // default would call our own `read` (whole file) and slice afterwards,
192        // which would hang or error on an infinite device.
193        let (fs, relative) = self.find_mount(path)?;
194        fs.read_range(&relative, range).await
195    }
196
197    #[tracing::instrument(level = "trace", skip(self, data), fields(path = %path.display(), size = data.len()))]
198    async fn write(&self, path: &Path, data: &[u8]) -> io::Result<()> {
199        let (fs, relative) = self.find_mount(path)?;
200        fs.write(&relative, data).await
201    }
202
203    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
204    async fn list(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
205        // Special case: listing root might need to show mount points
206        let path_str = path.to_string_lossy();
207        if path_str.is_empty() || path_str == "/" {
208            return self.list_root().await;
209        }
210
211        let (fs, relative) = self.find_mount(path)?;
212        fs.list(&relative).await
213    }
214
215    #[tracing::instrument(level = "trace", skip(self), fields(path = %path.display()))]
216    async fn stat(&self, path: &Path) -> io::Result<DirEntry> {
217        // Special case: root always exists
218        let path_str = path.to_string_lossy();
219        if path_str.is_empty() || path_str == "/" {
220            return Ok(DirEntry::directory("/"));
221        }
222
223        // Check if path is a mount point itself
224        let normalized = Self::normalize_mount_path(path.to_path_buf());
225        if self.mounts.contains_key(&normalized) {
226            let name = path
227                .file_name()
228                .map(|n| n.to_string_lossy().into_owned())
229                .unwrap_or_else(|| "/".to_string());
230            return Ok(DirEntry::directory(name));
231        }
232
233        let (fs, relative) = self.find_mount(path)?;
234        fs.stat(&relative).await
235    }
236
237    async fn read_link(&self, path: &Path) -> io::Result<PathBuf> {
238        let (fs, relative) = self.find_mount(path)?;
239        fs.read_link(&relative).await
240    }
241
242    async fn symlink(&self, target: &Path, link: &Path) -> io::Result<()> {
243        let (fs, relative) = self.find_mount(link)?;
244        fs.symlink(target, &relative).await
245    }
246
247    async fn lstat(&self, path: &Path) -> io::Result<DirEntry> {
248        // Special case: root always exists
249        let path_str = path.to_string_lossy();
250        if path_str.is_empty() || path_str == "/" {
251            return Ok(DirEntry::directory("/"));
252        }
253
254        // Check if path is a mount point itself
255        let normalized = Self::normalize_mount_path(path.to_path_buf());
256        if self.mounts.contains_key(&normalized) {
257            let name = path
258                .file_name()
259                .map(|n| n.to_string_lossy().into_owned())
260                .unwrap_or_else(|| "/".to_string());
261            return Ok(DirEntry::directory(name));
262        }
263
264        let (fs, relative) = self.find_mount(path)?;
265        fs.lstat(&relative).await
266    }
267
268    async fn mkdir(&self, path: &Path) -> io::Result<()> {
269        let (fs, relative) = self.find_mount(path)?;
270        fs.mkdir(&relative).await
271    }
272
273    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> io::Result<()> {
274        let (fs, relative) = self.find_mount(path)?;
275        fs.set_mtime(&relative, mtime).await
276    }
277
278    async fn remove(&self, path: &Path) -> io::Result<()> {
279        let (fs, relative) = self.find_mount(path)?;
280        fs.remove(&relative).await
281    }
282
283    async fn rename(&self, from: &Path, to: &Path) -> io::Result<()> {
284        let (from_fs, from_relative) = self.find_mount(from)?;
285        let (to_fs, to_relative) = self.find_mount(to)?;
286
287        // Check if both paths are on the same mount by comparing Arc pointers
288        if !Arc::ptr_eq(&from_fs, &to_fs) {
289            return Err(io::Error::new(
290                io::ErrorKind::Unsupported,
291                "cannot rename across different mount points",
292            ));
293        }
294
295        from_fs.rename(&from_relative, &to_relative).await
296    }
297
298    fn read_only(&self) -> bool {
299        // Router is read-only iff every mount is. Empty router returns
300        // false — a router with no mounts isn't meaningfully read-only,
301        // and false preserves the behaviour callers saw before this change.
302        if self.mounts.is_empty() {
303            return false;
304        }
305        self.mounts.values().all(|fs| fs.read_only())
306    }
307}
308
309impl VfsRouter {
310    /// List the root directory, synthesizing entries from mount points.
311    async fn list_root(&self) -> io::Result<Vec<DirEntry>> {
312        let mut entries = Vec::new();
313        let mut seen_names = std::collections::HashSet::new();
314
315        for mount_path in self.mounts.keys() {
316            let mount_str = mount_path.to_string_lossy();
317            if mount_str == "/" {
318                // Root mount: list its contents directly
319                if let Some(fs) = self.mounts.get(mount_path)
320                    && let Ok(root_entries) = fs.list(Path::new("")).await {
321                        for entry in root_entries {
322                            if seen_names.insert(entry.name.clone()) {
323                                entries.push(entry);
324                            }
325                        }
326                    }
327            } else {
328                // Non-root mount: extract first path component
329                let first_component = mount_str
330                    .trim_start_matches('/')
331                    .split('/')
332                    .next()
333                    .unwrap_or("");
334
335                if !first_component.is_empty() && seen_names.insert(first_component.to_string()) {
336                    entries.push(DirEntry::directory(first_component));
337                }
338            }
339        }
340
341        entries.sort_by(|a, b| a.name.cmp(&b.name));
342        Ok(entries)
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::vfs::MemoryFs;
350
351    #[tokio::test]
352    async fn test_basic_mount() {
353        let mut router = VfsRouter::new();
354        let scratch = MemoryFs::new();
355        scratch.write(Path::new("test.txt"), b"hello").await.unwrap();
356        router.mount("/scratch", scratch);
357
358        let data = router.read(Path::new("/scratch/test.txt")).await.unwrap();
359        assert_eq!(data, b"hello");
360    }
361
362    #[tokio::test]
363    async fn test_multiple_mounts() {
364        let mut router = VfsRouter::new();
365
366        let scratch = MemoryFs::new();
367        scratch.write(Path::new("a.txt"), b"scratch").await.unwrap();
368        router.mount("/scratch", scratch);
369
370        let data = MemoryFs::new();
371        data.write(Path::new("b.txt"), b"data").await.unwrap();
372        router.mount("/data", data);
373
374        assert_eq!(
375            router.read(Path::new("/scratch/a.txt")).await.unwrap(),
376            b"scratch"
377        );
378        assert_eq!(
379            router.read(Path::new("/data/b.txt")).await.unwrap(),
380            b"data"
381        );
382    }
383
384    #[tokio::test]
385    async fn test_nested_mount() {
386        let mut router = VfsRouter::new();
387
388        let outer = MemoryFs::new();
389        outer.write(Path::new("outer.txt"), b"outer").await.unwrap();
390        router.mount("/mnt", outer);
391
392        let inner = MemoryFs::new();
393        inner.write(Path::new("inner.txt"), b"inner").await.unwrap();
394        router.mount("/mnt/project", inner);
395
396        // /mnt/outer.txt should come from outer mount
397        assert_eq!(
398            router.read(Path::new("/mnt/outer.txt")).await.unwrap(),
399            b"outer"
400        );
401
402        // /mnt/project/inner.txt should come from inner mount
403        assert_eq!(
404            router.read(Path::new("/mnt/project/inner.txt")).await.unwrap(),
405            b"inner"
406        );
407    }
408
409    #[tokio::test]
410    async fn test_list_root() {
411        let mut router = VfsRouter::new();
412        router.mount("/scratch", MemoryFs::new());
413        router.mount("/mnt/a", MemoryFs::new());
414        router.mount("/mnt/b", MemoryFs::new());
415
416        let entries = router.list(Path::new("/")).await.unwrap();
417        let names: Vec<_> = entries.iter().map(|e| &e.name).collect();
418
419        assert!(names.contains(&&"scratch".to_string()));
420        assert!(names.contains(&&"mnt".to_string()));
421    }
422
423    #[tokio::test]
424    async fn test_unmount() {
425        let mut router = VfsRouter::new();
426
427        let fs = MemoryFs::new();
428        fs.write(Path::new("test.txt"), b"data").await.unwrap();
429        router.mount("/scratch", fs);
430
431        assert!(router.read(Path::new("/scratch/test.txt")).await.is_ok());
432
433        router.unmount("/scratch");
434
435        assert!(router.read(Path::new("/scratch/test.txt")).await.is_err());
436    }
437
438    #[tokio::test]
439    async fn test_list_mounts() {
440        let mut router = VfsRouter::new();
441        router.mount("/scratch", MemoryFs::new());
442        router.mount("/data", MemoryFs::new());
443
444        let mounts = router.list_mounts();
445        assert_eq!(mounts.len(), 2);
446
447        let paths: Vec<_> = mounts.iter().map(|m| &m.path).collect();
448        assert!(paths.contains(&&PathBuf::from("/scratch")));
449        assert!(paths.contains(&&PathBuf::from("/data")));
450    }
451
452    #[tokio::test]
453    async fn test_no_mount_error() {
454        let router = VfsRouter::new();
455        let result = router.read(Path::new("/nothing/here.txt")).await;
456        assert!(result.is_err());
457        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::NotFound);
458    }
459
460    #[tokio::test]
461    async fn test_root_mount() {
462        let mut router = VfsRouter::new();
463
464        let root = MemoryFs::new();
465        root.write(Path::new("at-root.txt"), b"root file").await.unwrap();
466        router.mount("/", root);
467
468        let data = router.read(Path::new("/at-root.txt")).await.unwrap();
469        assert_eq!(data, b"root file");
470    }
471
472    #[tokio::test]
473    async fn test_write_through_router() {
474        let mut router = VfsRouter::new();
475        router.mount("/scratch", MemoryFs::new());
476
477        router
478            .write(Path::new("/scratch/new.txt"), b"created")
479            .await
480            .unwrap();
481
482        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
483        assert_eq!(data, b"created");
484    }
485
486    #[tokio::test]
487    async fn test_stat_mount_point() {
488        let mut router = VfsRouter::new();
489        router.mount("/scratch", MemoryFs::new());
490
491        let entry = router.stat(Path::new("/scratch")).await.unwrap();
492        assert!(entry.is_dir());
493    }
494
495    #[tokio::test]
496    async fn test_stat_root() {
497        let router = VfsRouter::new();
498        let entry = router.stat(Path::new("/")).await.unwrap();
499        assert!(entry.is_dir());
500    }
501
502    #[tokio::test]
503    async fn test_rename_same_mount() {
504        let mut router = VfsRouter::new();
505        let mem = MemoryFs::new();
506        mem.write(Path::new("old.txt"), b"data").await.unwrap();
507        router.mount("/scratch", mem);
508
509        router.rename(Path::new("/scratch/old.txt"), Path::new("/scratch/new.txt")).await.unwrap();
510
511        // New path exists
512        let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
513        assert_eq!(data, b"data");
514
515        // Old path doesn't exist
516        assert!(!router.exists(Path::new("/scratch/old.txt")).await);
517    }
518
519    #[tokio::test]
520    async fn test_rename_cross_mount_fails() {
521        let mut router = VfsRouter::new();
522        let mem1 = MemoryFs::new();
523        mem1.write(Path::new("file.txt"), b"data").await.unwrap();
524        router.mount("/mount1", mem1);
525        router.mount("/mount2", MemoryFs::new());
526
527        let result = router.rename(Path::new("/mount1/file.txt"), Path::new("/mount2/file.txt")).await;
528        assert!(result.is_err());
529        assert_eq!(result.unwrap_err().kind(), io::ErrorKind::Unsupported);
530    }
531
532    #[tokio::test]
533    async fn read_only_empty_router_returns_false() {
534        let router = VfsRouter::new();
535        assert!(!router.read_only());
536    }
537
538    #[cfg(feature = "localfs")]
539    #[tokio::test]
540    async fn read_only_all_read_only_mounts_returns_true() {
541        use crate::vfs::LocalFs;
542
543        let t1 = tempfile::tempdir().unwrap();
544        let t2 = tempfile::tempdir().unwrap();
545
546        let mut router = VfsRouter::new();
547        router.mount("/a", LocalFs::read_only(t1.path().to_path_buf()));
548        router.mount("/b", LocalFs::read_only(t2.path().to_path_buf()));
549
550        assert!(router.read_only());
551    }
552
553    #[cfg(feature = "localfs")]
554    #[tokio::test]
555    async fn read_only_mixed_mounts_returns_false() {
556        use crate::vfs::LocalFs;
557
558        let t1 = tempfile::tempdir().unwrap();
559
560        let mut router = VfsRouter::new();
561        router.mount("/ro", LocalFs::read_only(t1.path().to_path_buf()));
562        router.mount("/rw", MemoryFs::new());
563
564        assert!(!router.read_only());
565    }
566}