1use 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
12pub use kaish_types::backend::MountInfo;
16
17#[derive(Default)]
23pub struct VfsRouter {
24 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 pub fn new() -> Self {
39 Self {
40 mounts: BTreeMap::new(),
41 }
42 }
43
44 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 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 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 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 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 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 pub(crate) fn has_mount(&self, path: &Path) -> bool {
109 self.find_mount(path).is_ok()
110 }
111
112 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 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 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 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 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 let is_match = if mount_str == "/" {
183 true } 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 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 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 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 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 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 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 let path_str = path.to_string_lossy();
292 if path_str.is_empty() || path_str == "/" {
293 return Ok(DirEntry::directory("/"));
294 }
295
296 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 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 let path_str = path.to_string_lossy();
333 if path_str.is_empty() || path_str == "/" {
334 return Ok(DirEntry::directory("/"));
335 }
336
337 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 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 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 if self.mounts.is_empty() {
396 return false;
397 }
398 self.mounts.values().all(|fs| fs.read_only())
399 }
400}
401
402impl VfsRouter {
403 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 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 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 assert_eq!(
491 router.read(Path::new("/mnt/outer.txt")).await.unwrap(),
492 b"outer"
493 );
494
495 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 let data = router.read(Path::new("/scratch/new.txt")).await.unwrap();
606 assert_eq!(data, b"data");
607
608 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 #[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"]); }
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 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 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}