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