Skip to main content

kaish_kernel/backend/
overlay.rs

1//! VirtualOverlayBackend: Routes /v/* paths to internal VFS while delegating everything else.
2//!
3//! This backend is designed for embedders who provide their own `KernelBackend` but want
4//! kaish's virtual filesystems (like `/v/jobs` for job observability) to work automatically.
5//!
6//! # Usage
7//!
8//! Prefer using `Kernel::with_backend()` which handles overlay setup automatically:
9//!
10//! ```ignore
11//! let kernel = Kernel::with_backend(my_backend, config, |vfs| {
12//!     vfs.mount_arc("/v/docs", docs_fs);
13//! }, |_| {})?;
14//! ```
15//!
16//! # Path Routing
17//!
18//! Routing is by *mount coverage* (longest prefix), not by a lexical `/v`
19//! reservation:
20//!
21//! - Any path covered by a mount on the internal VFS (`/v/jobs`, `/v/blobs`,
22//!   `/dev`, a `configure_vfs` mount) → routed to the VFS.
23//! - An *unclaimed* path under `/v` (e.g. an embedder's own `/v/cas`) → falls
24//!   through to the custom backend, so an embedder can mount its own storage
25//!   under `/v` without kaish shadowing it. (Previously the whole `/v`
26//!   namespace was reserved and a miss returned `NotFound`.)
27//! - A shared *ancestor* directory like `/v` — no mount of its own, but sitting
28//!   above `/v/jobs` — is presented as the *union* of both layers: `list`
29//!   merges the embedder's entries with kaish's child mounts, and `stat`/
30//!   `exists` synthesize it as a directory when the embedder lacks it.
31//! - Everything else → custom backend
32
33use async_trait::async_trait;
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use super::{
38    BackendError, BackendResult, KernelBackend, LocalBackend, PatchOp, ReadRange,
39    ToolInfo, ToolResult, WriteMode,
40};
41use crate::tools::{ToolArgs, ToolCtx};
42use crate::vfs::{DirEntry, Filesystem, MountInfo, VfsRouter};
43
44/// The final path component, used to name a synthesized directory entry for a
45/// shared ancestor (`/v` → `v`). Falls back to `/` for a component-less path.
46fn dir_basename(path: &Path) -> String {
47    path.file_name()
48        .map(|n| n.to_string_lossy().into_owned())
49        .unwrap_or_else(|| "/".to_string())
50}
51
52/// Explanatory clause for errors on a shared-ancestor path (see
53/// `VirtualOverlayBackend::is_shared_ancestor`), so a rejected mutation reads
54/// clearly instead of the misleading `NotFound` a bare inner delegation gives.
55fn synth_dir_note(path: &Path) -> String {
56    format!("{} is a synthesized directory that only holds kaish mounts", path.display())
57}
58
59/// Backend that overlays virtual paths (`/v/*`) on top of a custom backend.
60///
61/// This enables embedders to provide their own storage backend while still
62/// getting kaish's virtual filesystem features like `/v/jobs` for job observability.
63pub struct VirtualOverlayBackend {
64    /// Custom backend for most paths (embedder-provided).
65    inner: Arc<dyn KernelBackend>,
66    /// VFS for /v/* paths (internal virtual filesystems).
67    vfs: Arc<VfsRouter>,
68}
69
70impl VirtualOverlayBackend {
71    /// Create a new virtual overlay backend.
72    ///
73    /// # Arguments
74    ///
75    /// * `inner` - The custom backend to delegate non-virtual paths to
76    /// * `vfs` - VFS router containing virtual filesystem mounts (typically at /v/*)
77    ///
78    /// # Example
79    ///
80    /// ```ignore
81    /// let overlay = VirtualOverlayBackend::new(my_backend, vfs);
82    /// ```
83    pub fn new(inner: Arc<dyn KernelBackend>, vfs: Arc<VfsRouter>) -> Self {
84        Self { inner, vfs }
85    }
86
87    /// Check if a path is *covered by a kaish VFS mount* and should therefore be
88    /// handled by the VFS rather than the inner backend.
89    ///
90    /// Routing is purely by mount coverage (longest prefix) — there is no
91    /// lexical `/v` reservation. `/v/jobs`, `/dev`, and any `configure_vfs`
92    /// mount route to the VFS; an *unclaimed* path under `/v` (e.g. an embedder
93    /// CAS at `/v/cas`) falls through to the inner backend instead of returning
94    /// `NotFound`. Shared *ancestor* directories like `/v` (which have no mount
95    /// of their own but sit above `/v/jobs`) are not "virtual" by this test —
96    /// they're handled by the union/synthesis paths in `list`/`stat`/`exists`.
97    fn is_virtual_path(&self, path: &Path) -> bool {
98        self.vfs.has_mount(path)
99    }
100
101    /// A *shared ancestor*: a path that is not itself covered by a kaish mount
102    /// but sits above one (`/`, `/v`, `/v/etc`, …). It is presented as a
103    /// **read-only synthesized directory** — a union of the embedder's view and
104    /// kaish's child mounts. Reads and listing treat it as a directory; every
105    /// direct mutation is rejected, because the node exists only insofar as
106    /// kaish mounts live beneath it, so there is nothing coherent for the
107    /// embedder alone to create, remove, or re-time.
108    fn is_shared_ancestor(&self, path: &Path) -> bool {
109        !self.is_virtual_path(path) && self.vfs.has_mount_under(path)
110    }
111
112    /// Get the inner backend.
113    pub fn inner(&self) -> &Arc<dyn KernelBackend> {
114        &self.inner
115    }
116
117    /// Get the VFS router.
118    pub fn vfs(&self) -> &Arc<VfsRouter> {
119        &self.vfs
120    }
121}
122
123impl std::fmt::Debug for VirtualOverlayBackend {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("VirtualOverlayBackend")
126            .field("inner_type", &self.inner.backend_type())
127            .field("vfs", &self.vfs)
128            .finish()
129    }
130}
131
132#[async_trait]
133impl KernelBackend for VirtualOverlayBackend {
134    // ═══════════════════════════════════════════════════════════════════════════
135    // File Operations
136    // ═══════════════════════════════════════════════════════════════════════════
137
138    async fn read(&self, path: &Path, range: Option<ReadRange>) -> BackendResult<Vec<u8>> {
139        if self.is_virtual_path(path) {
140            Ok(self.vfs.read_range(path, range).await?)
141        } else if self.is_shared_ancestor(path) {
142            Err(BackendError::IsDirectory(synth_dir_note(path)))
143        } else {
144            self.inner.read(path, range).await
145        }
146    }
147
148    async fn write(&self, path: &Path, content: &[u8], mode: WriteMode) -> BackendResult<()> {
149        if self.is_virtual_path(path) {
150            match mode {
151                WriteMode::CreateNew => {
152                    if self.vfs.exists(path).await {
153                        return Err(BackendError::AlreadyExists(path.display().to_string()));
154                    }
155                    self.vfs.write(path, content).await?;
156                }
157                WriteMode::Overwrite | WriteMode::Truncate => {
158                    self.vfs.write(path, content).await?;
159                }
160                WriteMode::UpdateOnly => {
161                    if !self.vfs.exists(path).await {
162                        return Err(BackendError::NotFound(path.display().to_string()));
163                    }
164                    self.vfs.write(path, content).await?;
165                }
166                // WriteMode is #[non_exhaustive] — treat unknown modes as Overwrite
167                _ => {
168                    self.vfs.write(path, content).await?;
169                }
170            }
171            Ok(())
172        } else if self.is_shared_ancestor(path) {
173            Err(BackendError::IsDirectory(synth_dir_note(path)))
174        } else {
175            self.inner.write(path, content, mode).await
176        }
177    }
178
179    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> BackendResult<()> {
180        if self.is_virtual_path(path) {
181            self.vfs.set_mtime(path, mtime).await?;
182            Ok(())
183        } else if self.is_shared_ancestor(path) {
184            Err(BackendError::InvalidOperation(format!("cannot set mtime: {}", synth_dir_note(path))))
185        } else {
186            self.inner.set_mtime(path, mtime).await
187        }
188    }
189
190    async fn append(&self, path: &Path, content: &[u8]) -> BackendResult<()> {
191        if self.is_virtual_path(path) {
192            let mut existing = match self.vfs.read(path).await {
193                Ok(data) => data,
194                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
195                Err(e) => return Err(e.into()),
196            };
197            existing.extend_from_slice(content);
198            self.vfs.write(path, &existing).await?;
199            Ok(())
200        } else if self.is_shared_ancestor(path) {
201            Err(BackendError::IsDirectory(synth_dir_note(path)))
202        } else {
203            self.inner.append(path, content).await
204        }
205    }
206
207    async fn patch(&self, path: &Path, ops: &[PatchOp]) -> BackendResult<()> {
208        if self.is_virtual_path(path) {
209            // Read existing content
210            let data = self.vfs.read(path).await?;
211            let mut content = String::from_utf8(data)
212                .map_err(|e| BackendError::InvalidOperation(format!("file is not valid UTF-8: {}", e)))?;
213
214            // Apply each patch operation
215            for op in ops {
216                LocalBackend::apply_patch_op(&mut content, op)?;
217            }
218
219            // Write back
220            self.vfs.write(path, content.as_bytes()).await?;
221            Ok(())
222        } else if self.is_shared_ancestor(path) {
223            Err(BackendError::IsDirectory(synth_dir_note(path)))
224        } else {
225            self.inner.patch(path, ops).await
226        }
227    }
228
229    // ═══════════════════════════════════════════════════════════════════════════
230    // Directory Operations
231    // ═══════════════════════════════════════════════════════════════════════════
232
233    async fn list(&self, path: &Path) -> BackendResult<Vec<DirEntry>> {
234        if self.is_virtual_path(path) {
235            Ok(self.vfs.list(path).await?)
236        } else if self.is_shared_ancestor(path) {
237            // A shared parent of kaish mounts (`/`, `/v`, `/v/etc`, …): return
238            // the union of the embedder's view and kaish's child mounts. The
239            // embedder's entries come first so they carry real metadata; then
240            // for each kaish child, an explicit kaish *mount* (`/dev`, `/v/jobs`)
241            // shadows the embedder's same-named entry (longest-prefix routing
242            // owns that subtree), while a synthesized *intermediate* (`/v` under
243            // `/`, when nothing is mounted at `/v`) only fills a gap — keeping
244            // the embedder's real entry if it has one. Any inner error (the
245            // embedder has no listable directory here: `NotFound`,
246            // `NotADirectory` over an inner file, a permission error) means
247            // "embedder contributes nothing"; kaish's mounts still list.
248            let mut by_name: std::collections::HashMap<String, DirEntry> =
249                std::collections::HashMap::new();
250            if let Ok(inner_entries) = self.inner.list(path).await {
251                for entry in inner_entries {
252                    by_name.insert(entry.name.clone(), entry);
253                }
254            }
255            for entry in self.vfs.list(path).await? {
256                if self.vfs.has_mount(&path.join(&entry.name)) {
257                    by_name.insert(entry.name.clone(), entry);
258                } else {
259                    by_name.entry(entry.name.clone()).or_insert(entry);
260                }
261            }
262            let mut entries: Vec<DirEntry> = by_name.into_values().collect();
263            entries.sort_by(|a, b| a.name.cmp(&b.name));
264            Ok(entries)
265        } else {
266            self.inner.list(path).await
267        }
268    }
269
270    async fn stat(&self, path: &Path) -> BackendResult<DirEntry> {
271        if self.is_virtual_path(path) {
272            Ok(self.vfs.stat(path).await?)
273        } else if self.is_shared_ancestor(path) {
274            // A shared ancestor is a directory (kaish mounts live under it).
275            // Prefer the embedder's real directory metadata; otherwise (embedder
276            // lacks it, has a file there, or errors) synthesize a plain dir.
277            match self.inner.stat(path).await {
278                Ok(entry) if entry.is_dir() => Ok(entry),
279                _ => Ok(DirEntry::directory(dir_basename(path))),
280            }
281        } else {
282            self.inner.stat(path).await
283        }
284    }
285
286    async fn mkdir(&self, path: &Path) -> BackendResult<()> {
287        if self.is_virtual_path(path) {
288            self.vfs.mkdir(path).await?;
289            Ok(())
290        } else if self.is_shared_ancestor(path) {
291            Err(BackendError::AlreadyExists(synth_dir_note(path)))
292        } else {
293            self.inner.mkdir(path).await
294        }
295    }
296
297    async fn remove(&self, path: &Path, recursive: bool) -> BackendResult<()> {
298        if self.is_virtual_path(path) {
299            if recursive
300                && let Ok(entry) = self.vfs.lstat(path).await
301                && entry.is_dir()
302                && let Ok(entries) = self.vfs.list(path).await
303            {
304                for entry in entries {
305                    let child_path = path.join(&entry.name);
306                    Box::pin(self.remove(&child_path, true)).await?;
307                }
308            }
309            self.vfs.remove(path).await?;
310            Ok(())
311        } else if self.is_shared_ancestor(path) {
312            // Refuse even `rm -rf /v`: the node holds kaish-managed mounts that
313            // don't live on the embedder's backend, so it can't be removed.
314            Err(BackendError::InvalidOperation(format!("cannot remove: {}", synth_dir_note(path))))
315        } else {
316            self.inner.remove(path, recursive).await
317        }
318    }
319
320    async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()> {
321        let from_virtual = self.is_virtual_path(from);
322        let to_virtual = self.is_virtual_path(to);
323
324        if from_virtual != to_virtual {
325            return Err(BackendError::InvalidOperation(
326                "cannot rename between virtual and non-virtual paths".into(),
327            ));
328        }
329
330        if self.is_shared_ancestor(from) || self.is_shared_ancestor(to) {
331            return Err(BackendError::InvalidOperation(format!(
332                "cannot rename: {} is a synthesized directory",
333                if self.is_shared_ancestor(from) { from.display() } else { to.display() }
334            )));
335        }
336
337        if from_virtual {
338            self.vfs.rename(from, to).await?;
339            Ok(())
340        } else {
341            self.inner.rename(from, to).await
342        }
343    }
344
345    async fn exists(&self, path: &Path) -> bool {
346        if self.is_virtual_path(path) {
347            self.vfs.exists(path).await
348        } else {
349            // A shared ancestor (e.g. `/v`) exists as a directory regardless of
350            // the embedder — consistent with `stat`/`list`.
351            self.is_shared_ancestor(path) || self.inner.exists(path).await
352        }
353    }
354
355    // ═══════════════════════════════════════════════════════════════════════════
356    // Symlink Operations
357    // ═══════════════════════════════════════════════════════════════════════════
358
359    async fn lstat(&self, path: &Path) -> BackendResult<DirEntry> {
360        if self.is_virtual_path(path) {
361            Ok(self.vfs.lstat(path).await?)
362        } else if self.is_shared_ancestor(path) {
363            match self.inner.lstat(path).await {
364                Ok(entry) if entry.is_dir() => Ok(entry),
365                _ => Ok(DirEntry::directory(dir_basename(path))),
366            }
367        } else {
368            self.inner.lstat(path).await
369        }
370    }
371
372    async fn read_link(&self, path: &Path) -> BackendResult<PathBuf> {
373        if self.is_virtual_path(path) {
374            Ok(self.vfs.read_link(path).await?)
375        } else if self.is_shared_ancestor(path) {
376            Err(BackendError::InvalidOperation(format!(
377                "{} is a directory, not a symlink",
378                path.display()
379            )))
380        } else {
381            self.inner.read_link(path).await
382        }
383    }
384
385    async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()> {
386        if self.is_virtual_path(link) {
387            self.vfs.symlink(target, link).await?;
388            Ok(())
389        } else if self.is_shared_ancestor(link) {
390            Err(BackendError::AlreadyExists(synth_dir_note(link)))
391        } else {
392            self.inner.symlink(target, link).await
393        }
394    }
395
396    // ═══════════════════════════════════════════════════════════════════════════
397    // Tool Dispatch
398    // ═══════════════════════════════════════════════════════════════════════════
399
400    async fn call_tool(
401        &self,
402        name: &str,
403        args: ToolArgs,
404        ctx: &mut dyn ToolCtx,
405    ) -> BackendResult<ToolResult> {
406        // Tools are dispatched through the inner backend
407        self.inner.call_tool(name, args, ctx).await
408    }
409
410    async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
411        self.inner.list_tools().await
412    }
413
414    async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
415        self.inner.get_tool(name).await
416    }
417
418    // ═══════════════════════════════════════════════════════════════════════════
419    // Backend Information
420    // ═══════════════════════════════════════════════════════════════════════════
421
422    fn read_only(&self) -> bool {
423        // We're not read-only if either layer is writable
424        self.inner.read_only() && self.vfs.read_only()
425    }
426
427    fn backend_type(&self) -> &str {
428        "virtual-overlay"
429    }
430
431    fn mounts(&self) -> Vec<MountInfo> {
432        let mut mounts = self.inner.mounts();
433        mounts.extend(self.vfs.list_mounts());
434        mounts
435    }
436
437    fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
438        if self.is_virtual_path(path) {
439            // Virtual paths don't map to real filesystem
440            None
441        } else {
442            self.inner.resolve_real_path(path)
443        }
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::backend::testing::MockBackend;
451    use crate::vfs::MemoryFs;
452
453    async fn make_overlay() -> VirtualOverlayBackend {
454        // Create mock inner backend
455        let (mock, _) = MockBackend::new();
456        let inner: Arc<dyn KernelBackend> = Arc::new(mock);
457
458        // Production-like split: kaish mounts sit at /v/*, nothing at /v itself.
459        let mut vfs = VfsRouter::new();
460        let blobs = MemoryFs::new();
461        blobs.write(Path::new("test.bin"), b"blob data").await.unwrap();
462        vfs.mount("/v/blobs", blobs);
463        vfs.mount("/v/jobs", MemoryFs::new());
464
465        VirtualOverlayBackend::new(inner, Arc::new(vfs))
466    }
467
468    #[tokio::test]
469    async fn test_virtual_path_detection() {
470        let overlay = make_overlay().await;
471        // Covered by a kaish mount → virtual.
472        assert!(overlay.is_virtual_path(Path::new("/v/jobs")));
473        assert!(overlay.is_virtual_path(Path::new("/v/blobs")));
474        assert!(overlay.is_virtual_path(Path::new("/v/blobs/test.bin")));
475
476        // No lexical /v reservation any more: a shared ancestor with no mount of
477        // its own, and an unclaimed path under /v, are NOT virtual — they route
478        // to the union/synthesis paths or fall through to the embedder.
479        assert!(!overlay.is_virtual_path(Path::new("/v")));
480        assert!(!overlay.is_virtual_path(Path::new("/v/")));
481        assert!(!overlay.is_virtual_path(Path::new("/v/unclaimed")));
482
483        assert!(!overlay.is_virtual_path(Path::new("/docs")));
484        assert!(!overlay.is_virtual_path(Path::new("/g/repo")));
485        assert!(!overlay.is_virtual_path(Path::new("/")));
486        assert!(!overlay.is_virtual_path(Path::new("/var")));
487    }
488
489    #[tokio::test]
490    async fn test_non_v_mount_is_virtual_path() {
491        // A mount outside /v (e.g. Kernel::with_backend's /dev) must also be
492        // routed to the internal VFS, not silently delegated to the inner
493        // backend — this is the bug that let writes to /dev/null fail as
494        // "read-only filesystem" when the inner backend was read-only.
495        let (mock, _) = MockBackend::new();
496        let inner: Arc<dyn KernelBackend> = Arc::new(mock);
497        let mut vfs = VfsRouter::new();
498        vfs.mount("/dev", MemoryFs::new());
499        let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
500
501        assert!(overlay.is_virtual_path(Path::new("/dev/null")));
502        assert!(!overlay.is_virtual_path(Path::new("/docs")));
503    }
504
505    #[tokio::test]
506    async fn test_read_virtual_path() {
507        let overlay = make_overlay().await;
508        let content = overlay.read(Path::new("/v/blobs/test.bin"), None).await.unwrap();
509        assert_eq!(content, b"blob data");
510    }
511
512    #[tokio::test]
513    async fn test_write_virtual_path() {
514        let overlay = make_overlay().await;
515        overlay
516            .write(Path::new("/v/blobs/new.bin"), b"new data", WriteMode::Overwrite)
517            .await
518            .unwrap();
519        let content = overlay.read(Path::new("/v/blobs/new.bin"), None).await.unwrap();
520        assert_eq!(content, b"new data");
521    }
522
523    #[tokio::test]
524    async fn test_list_virtual_path() {
525        let overlay = make_overlay().await;
526        let entries = overlay.list(Path::new("/v")).await.unwrap();
527        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
528        assert!(names.contains(&"blobs"));
529        assert!(names.contains(&"jobs"));
530    }
531
532    #[tokio::test]
533    async fn test_root_listing_includes_v() {
534        let overlay = make_overlay().await;
535        let entries = overlay.list(Path::new("/")).await.unwrap();
536        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
537        assert!(names.contains(&"v"), "Root listing should include 'v' directory");
538    }
539
540    #[tokio::test]
541    async fn test_stat_virtual_path() {
542        let overlay = make_overlay().await;
543        let info = overlay.stat(Path::new("/v/blobs/test.bin")).await.unwrap();
544        assert!(info.is_file());
545        assert_eq!(info.size, 9); // "blob data".len()
546    }
547
548    #[tokio::test]
549    async fn test_exists_virtual_path() {
550        let overlay = make_overlay().await;
551        assert!(overlay.exists(Path::new("/v/blobs/test.bin")).await);
552        assert!(!overlay.exists(Path::new("/v/blobs/nonexistent")).await);
553    }
554
555    #[tokio::test]
556    async fn test_mkdir_virtual_path() {
557        let overlay = make_overlay().await;
558        // Under a covered mount (/v/blobs), so it stays in the kaish VFS.
559        overlay.mkdir(Path::new("/v/blobs/newdir")).await.unwrap();
560        assert!(overlay.exists(Path::new("/v/blobs/newdir")).await);
561    }
562
563    #[tokio::test]
564    async fn test_remove_virtual_path() {
565        let overlay = make_overlay().await;
566        overlay.remove(Path::new("/v/blobs/test.bin"), false).await.unwrap();
567        assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
568    }
569
570    #[tokio::test]
571    async fn test_rename_within_virtual() {
572        let overlay = make_overlay().await;
573        overlay
574            .rename(Path::new("/v/blobs/test.bin"), Path::new("/v/blobs/renamed.bin"))
575            .await
576            .unwrap();
577        assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
578        assert!(overlay.exists(Path::new("/v/blobs/renamed.bin")).await);
579    }
580
581    #[tokio::test]
582    async fn test_rename_across_boundary_fails() {
583        let overlay = make_overlay().await;
584        let result = overlay
585            .rename(Path::new("/v/blobs/test.bin"), Path::new("/docs/test.bin"))
586            .await;
587        assert!(matches!(result, Err(BackendError::InvalidOperation(_))));
588    }
589
590    #[tokio::test]
591    async fn test_backend_type() {
592        let overlay = make_overlay().await;
593        assert_eq!(overlay.backend_type(), "virtual-overlay");
594    }
595
596    #[tokio::test]
597    async fn test_resolve_real_path_virtual() {
598        let overlay = make_overlay().await;
599        // Virtual paths don't resolve to real paths
600        assert!(overlay.resolve_real_path(Path::new("/v/blobs/test.bin")).is_none());
601    }
602
603    // Inner backend that serves content under /v (an embedder CAS at /v/cas),
604    // alongside kaish's own /v/jobs + /dev mounts — the production-like split
605    // (kaish mounts sit at /v/*, nothing at /v itself). `cas` toggles whether
606    // the embedder actually has content under /v.
607    async fn overlay_over_inner(cas: bool) -> VirtualOverlayBackend {
608        let mut inner_router = VfsRouter::new();
609        let inner_mem = MemoryFs::new();
610        if cas {
611            inner_mem
612                .write(Path::new("v/cas/blob.bin"), b"cas data")
613                .await
614                .unwrap();
615        }
616        inner_router.mount("/", inner_mem);
617        let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
618
619        let mut vfs = VfsRouter::new();
620        vfs.mount("/v/jobs", MemoryFs::new());
621        vfs.mount("/dev", MemoryFs::new());
622        VirtualOverlayBackend::new(inner, Arc::new(vfs))
623    }
624
625    #[tokio::test]
626    async fn test_unclaimed_v_reaches_inner_backend() {
627        // /v/cas isn't mounted on kaish's side → a read must fall through to the
628        // embedder's backend instead of the old NotFound reservation.
629        let overlay = overlay_over_inner(true).await;
630        let data = overlay.read(Path::new("/v/cas/blob.bin"), None).await.unwrap();
631        assert_eq!(data, b"cas data");
632        assert!(overlay.exists(Path::new("/v/cas/blob.bin")).await);
633    }
634
635    #[tokio::test]
636    async fn test_v_listing_unions_kaish_and_inner() {
637        let overlay = overlay_over_inner(true).await;
638        let names: Vec<String> = overlay
639            .list(Path::new("/v"))
640            .await
641            .unwrap()
642            .into_iter()
643            .map(|e| e.name)
644            .collect();
645        assert!(names.iter().any(|n| n == "jobs"), "kaish mount missing: {names:?}");
646        assert!(names.iter().any(|n| n == "cas"), "embedder mount missing: {names:?}");
647    }
648
649    #[tokio::test]
650    async fn test_v_synthesized_when_inner_lacks_it() {
651        // Embedder has nothing under /v; kaish mounts /v/jobs. /v must still
652        // stat as a directory and list the kaish mount, so `cd /v` / `ls /v`
653        // work even though nothing is mounted at /v on either layer.
654        let overlay = overlay_over_inner(false).await;
655        assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir());
656        assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
657        assert!(overlay.exists(Path::new("/v")).await);
658        let names: Vec<String> = overlay
659            .list(Path::new("/v"))
660            .await
661            .unwrap()
662            .into_iter()
663            .map(|e| e.name)
664            .collect();
665        assert_eq!(names, vec!["jobs".to_string()]);
666    }
667
668    #[cfg(feature = "localfs")]
669    #[tokio::test]
670    async fn test_unclaimed_v_resolves_to_inner_real_path() {
671        use crate::vfs::LocalFs;
672        // Embedder root with real content under /v.
673        let dir = tempfile::tempdir().unwrap();
674        std::fs::create_dir_all(dir.path().join("v/cas")).unwrap();
675        std::fs::write(dir.path().join("v/cas/blob.bin"), b"x").unwrap();
676
677        let mut inner_router = VfsRouter::new();
678        inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
679        let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
680        let mut vfs = VfsRouter::new();
681        vfs.mount("/v/jobs", MemoryFs::new());
682        let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
683
684        // Unclaimed /v/* now resolves to the embedder's REAL path (was `None`
685        // under the old lexical reservation). This is the path that reaches the
686        // trash gate, so `is_trash_excluded` must NOT lexically exclude
687        // `/v` — otherwise this real content silently loses its safety net
688        // (see tools/context.rs).
689        let real = overlay.resolve_real_path(Path::new("/v/cas/blob.bin"));
690        assert!(real.is_some(), "unclaimed /v/* must resolve to the embedder real path");
691        assert!(real.unwrap().ends_with("v/cas/blob.bin"));
692        // A kaish-owned /v mount stays virtual — no real path.
693        assert!(overlay.resolve_real_path(Path::new("/v/jobs/1")).is_none());
694    }
695
696    #[tokio::test]
697    async fn test_root_lists_both_v_and_dev() {
698        // The generalized union at shared parents fixes the old root merge,
699        // which injected only a synthetic `v` and dropped `dev`.
700        let overlay = overlay_over_inner(false).await;
701        let names: Vec<String> = overlay
702            .list(Path::new("/"))
703            .await
704            .unwrap()
705            .into_iter()
706            .map(|e| e.name)
707            .collect();
708        assert!(names.iter().any(|n| n == "v"), "{names:?}");
709        assert!(names.iter().any(|n| n == "dev"), "{names:?}");
710    }
711
712    #[tokio::test]
713    async fn test_shared_ancestor_is_a_directory_even_over_an_inner_file() {
714        // Embedder has a FILE at /v, but kaish mounts /v/jobs beneath it. /v is
715        // authoritatively a directory (kaish mounts live under it); stat/exists/
716        // list all agree — no file leaks from stat, no `NotADirectory` leaks
717        // from list. This branch also subsumes a non-`NotFound` inner *error*
718        // (e.g. PermissionDenied): existence/type never depend on inner here.
719        let inner_mem = MemoryFs::new();
720        inner_mem.write(Path::new("v"), b"i am a file").await.unwrap();
721        let mut inner_router = VfsRouter::new();
722        inner_router.mount("/", inner_mem);
723        let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
724        let mut vfs = VfsRouter::new();
725        vfs.mount("/v/jobs", MemoryFs::new());
726        let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
727
728        assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir(), "kaish dir wins over inner file");
729        assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
730        assert!(overlay.exists(Path::new("/v")).await);
731        let names: Vec<String> = overlay
732            .list(Path::new("/v"))
733            .await
734            .unwrap()
735            .into_iter()
736            .map(|e| e.name)
737            .collect();
738        assert_eq!(names, vec!["jobs".to_string()], "lists kaish mount; no NotADirectory error");
739    }
740
741    #[cfg(feature = "localfs")]
742    #[tokio::test]
743    async fn test_listing_keeps_inner_real_metadata_for_intermediate_child() {
744        use crate::vfs::LocalFs;
745        // Embedder has a real /v directory; kaish mounts /v/jobs and /dev.
746        let dir = tempfile::tempdir().unwrap();
747        std::fs::create_dir_all(dir.path().join("v")).unwrap();
748
749        let mut inner_router = VfsRouter::new();
750        inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
751        let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
752        let mut vfs = VfsRouter::new();
753        vfs.mount("/v/jobs", MemoryFs::new());
754        vfs.mount("/dev", MemoryFs::new());
755        let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
756
757        let entries = overlay.list(Path::new("/")).await.unwrap();
758        let v = entries.iter().find(|e| e.name == "v").expect("v listed");
759        let dev = entries.iter().find(|e| e.name == "dev").expect("dev listed");
760        // `/v` is an *intermediate* (no kaish mount at /v), so the embedder's
761        // real dir entry — carrying real metadata — is kept, not the synthesized
762        // zero-metadata one.
763        assert!(v.is_dir());
764        assert!(v.modified.is_some(), "intermediate child keeps inner real metadata");
765        // `/dev` IS a kaish mount, so kaish's entry wins (synthesized, no mtime).
766        assert!(dev.is_dir());
767        assert!(dev.modified.is_none(), "real kaish mount shadows inner");
768    }
769
770    #[tokio::test]
771    async fn test_mutations_on_shared_ancestor_are_rejected_clearly() {
772        // Every direct mutation of the synthesized `/v` must fail with a clear
773        // error, not the misleading `NotFound` inner delegation produced — since
774        // stat/ls/exists all report it present.
775        let overlay = overlay_over_inner(false).await;
776
777        assert!(
778            matches!(overlay.mkdir(Path::new("/v")).await, Err(BackendError::AlreadyExists(_))),
779            "mkdir on an existing synthesized dir → AlreadyExists"
780        );
781        assert!(
782            matches!(overlay.remove(Path::new("/v"), true).await, Err(BackendError::InvalidOperation(_))),
783            "remove of a synthesized dir that holds kaish mounts → InvalidOperation"
784        );
785        assert!(
786            matches!(
787                overlay.set_mtime(Path::new("/v"), std::time::SystemTime::now()).await,
788                Err(BackendError::InvalidOperation(_))
789            ),
790            "set_mtime (touch) on a synthesized dir → InvalidOperation"
791        );
792        assert!(
793            matches!(
794                overlay.write(Path::new("/v"), b"x", WriteMode::Overwrite).await,
795                Err(BackendError::IsDirectory(_))
796            ),
797            "write to a synthesized dir → IsDirectory"
798        );
799        assert!(
800            matches!(overlay.read(Path::new("/v"), None).await, Err(BackendError::IsDirectory(_))),
801            "read of a synthesized dir → IsDirectory"
802        );
803    }
804}