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