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//! - `/v/*` → always routed to the internal VFS, whether or not anything is
19//!   mounted there (the whole `/v` namespace is reserved, so a miss returns
20//!   `NotFound` from the VFS rather than leaking through to the embedder).
21//! - Any other path actually covered by a mount registered on the internal
22//!   VFS (e.g. `/dev`, added by `Kernel::with_backend`) → also routed there.
23//! - Everything else → custom backend
24
25use async_trait::async_trait;
26use std::path::{Path, PathBuf};
27use std::sync::Arc;
28
29use super::{
30    BackendError, BackendResult, KernelBackend, LocalBackend, PatchOp, ReadRange,
31    ToolInfo, ToolResult, WriteMode,
32};
33use crate::tools::{ToolArgs, ToolCtx};
34use crate::vfs::{DirEntry, Filesystem, MountInfo, VfsRouter};
35
36/// Backend that overlays virtual paths (`/v/*`) on top of a custom backend.
37///
38/// This enables embedders to provide their own storage backend while still
39/// getting kaish's virtual filesystem features like `/v/jobs` for job observability.
40pub struct VirtualOverlayBackend {
41    /// Custom backend for most paths (embedder-provided).
42    inner: Arc<dyn KernelBackend>,
43    /// VFS for /v/* paths (internal virtual filesystems).
44    vfs: Arc<VfsRouter>,
45}
46
47impl VirtualOverlayBackend {
48    /// Create a new virtual overlay backend.
49    ///
50    /// # Arguments
51    ///
52    /// * `inner` - The custom backend to delegate non-virtual paths to
53    /// * `vfs` - VFS router containing virtual filesystem mounts (typically at /v/*)
54    ///
55    /// # Example
56    ///
57    /// ```ignore
58    /// let overlay = VirtualOverlayBackend::new(my_backend, vfs);
59    /// ```
60    pub fn new(inner: Arc<dyn KernelBackend>, vfs: Arc<VfsRouter>) -> Self {
61        Self { inner, vfs }
62    }
63
64    /// Check if a path should be handled by the VFS rather than the inner backend.
65    ///
66    /// `/v` and `/v/*` are always reserved for the VFS, even where nothing is
67    /// mounted (so callers get `NotFound` from the VFS, not the embedder's
68    /// backend). Any other path is checked against the VFS's actual mount
69    /// table — this is what lets `Kernel::with_backend`'s `/dev` mount (or an
70    /// embedder's own `configure_vfs` mounts outside `/v`) take effect instead
71    /// of being silently swallowed by the inner backend.
72    fn is_virtual_path(&self, path: &Path) -> bool {
73        let path_str = path.to_string_lossy();
74        path_str == "/v" || path_str.starts_with("/v/") || self.vfs.has_mount(path)
75    }
76
77    /// Get the inner backend.
78    pub fn inner(&self) -> &Arc<dyn KernelBackend> {
79        &self.inner
80    }
81
82    /// Get the VFS router.
83    pub fn vfs(&self) -> &Arc<VfsRouter> {
84        &self.vfs
85    }
86}
87
88impl std::fmt::Debug for VirtualOverlayBackend {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.debug_struct("VirtualOverlayBackend")
91            .field("inner_type", &self.inner.backend_type())
92            .field("vfs", &self.vfs)
93            .finish()
94    }
95}
96
97#[async_trait]
98impl KernelBackend for VirtualOverlayBackend {
99    // ═══════════════════════════════════════════════════════════════════════════
100    // File Operations
101    // ═══════════════════════════════════════════════════════════════════════════
102
103    async fn read(&self, path: &Path, range: Option<ReadRange>) -> BackendResult<Vec<u8>> {
104        if self.is_virtual_path(path) {
105            Ok(self.vfs.read_range(path, range).await?)
106        } else {
107            self.inner.read(path, range).await
108        }
109    }
110
111    async fn write(&self, path: &Path, content: &[u8], mode: WriteMode) -> BackendResult<()> {
112        if self.is_virtual_path(path) {
113            match mode {
114                WriteMode::CreateNew => {
115                    if self.vfs.exists(path).await {
116                        return Err(BackendError::AlreadyExists(path.display().to_string()));
117                    }
118                    self.vfs.write(path, content).await?;
119                }
120                WriteMode::Overwrite | WriteMode::Truncate => {
121                    self.vfs.write(path, content).await?;
122                }
123                WriteMode::UpdateOnly => {
124                    if !self.vfs.exists(path).await {
125                        return Err(BackendError::NotFound(path.display().to_string()));
126                    }
127                    self.vfs.write(path, content).await?;
128                }
129                // WriteMode is #[non_exhaustive] — treat unknown modes as Overwrite
130                _ => {
131                    self.vfs.write(path, content).await?;
132                }
133            }
134            Ok(())
135        } else {
136            self.inner.write(path, content, mode).await
137        }
138    }
139
140    async fn set_mtime(&self, path: &Path, mtime: std::time::SystemTime) -> BackendResult<()> {
141        if self.is_virtual_path(path) {
142            self.vfs.set_mtime(path, mtime).await?;
143            Ok(())
144        } else {
145            self.inner.set_mtime(path, mtime).await
146        }
147    }
148
149    async fn append(&self, path: &Path, content: &[u8]) -> BackendResult<()> {
150        if self.is_virtual_path(path) {
151            let mut existing = match self.vfs.read(path).await {
152                Ok(data) => data,
153                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
154                Err(e) => return Err(e.into()),
155            };
156            existing.extend_from_slice(content);
157            self.vfs.write(path, &existing).await?;
158            Ok(())
159        } else {
160            self.inner.append(path, content).await
161        }
162    }
163
164    async fn patch(&self, path: &Path, ops: &[PatchOp]) -> BackendResult<()> {
165        if self.is_virtual_path(path) {
166            // Read existing content
167            let data = self.vfs.read(path).await?;
168            let mut content = String::from_utf8(data)
169                .map_err(|e| BackendError::InvalidOperation(format!("file is not valid UTF-8: {}", e)))?;
170
171            // Apply each patch operation
172            for op in ops {
173                LocalBackend::apply_patch_op(&mut content, op)?;
174            }
175
176            // Write back
177            self.vfs.write(path, content.as_bytes()).await?;
178            Ok(())
179        } else {
180            self.inner.patch(path, ops).await
181        }
182    }
183
184    // ═══════════════════════════════════════════════════════════════════════════
185    // Directory Operations
186    // ═══════════════════════════════════════════════════════════════════════════
187
188    async fn list(&self, path: &Path) -> BackendResult<Vec<DirEntry>> {
189        if self.is_virtual_path(path) {
190            Ok(self.vfs.list(path).await?)
191        } else if path.to_string_lossy() == "/" || path.to_string_lossy().is_empty() {
192            // Root listing: combine inner backend's root with /v
193            let mut entries = self.inner.list(path).await?;
194            // Add /v if not already present
195            if !entries.iter().any(|e| e.name == "v") {
196                entries.push(DirEntry::directory("v"));
197            }
198            Ok(entries)
199        } else {
200            self.inner.list(path).await
201        }
202    }
203
204    async fn stat(&self, path: &Path) -> BackendResult<DirEntry> {
205        if self.is_virtual_path(path) {
206            Ok(self.vfs.stat(path).await?)
207        } else {
208            self.inner.stat(path).await
209        }
210    }
211
212    async fn mkdir(&self, path: &Path) -> BackendResult<()> {
213        if self.is_virtual_path(path) {
214            self.vfs.mkdir(path).await?;
215            Ok(())
216        } else {
217            self.inner.mkdir(path).await
218        }
219    }
220
221    async fn remove(&self, path: &Path, recursive: bool) -> BackendResult<()> {
222        if self.is_virtual_path(path) {
223            if recursive
224                && let Ok(entry) = self.vfs.lstat(path).await
225                && entry.is_dir()
226                && let Ok(entries) = self.vfs.list(path).await
227            {
228                for entry in entries {
229                    let child_path = path.join(&entry.name);
230                    Box::pin(self.remove(&child_path, true)).await?;
231                }
232            }
233            self.vfs.remove(path).await?;
234            Ok(())
235        } else {
236            self.inner.remove(path, recursive).await
237        }
238    }
239
240    async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()> {
241        let from_virtual = self.is_virtual_path(from);
242        let to_virtual = self.is_virtual_path(to);
243
244        if from_virtual != to_virtual {
245            return Err(BackendError::InvalidOperation(
246                "cannot rename between virtual and non-virtual paths".into(),
247            ));
248        }
249
250        if from_virtual {
251            self.vfs.rename(from, to).await?;
252            Ok(())
253        } else {
254            self.inner.rename(from, to).await
255        }
256    }
257
258    async fn exists(&self, path: &Path) -> bool {
259        if self.is_virtual_path(path) {
260            self.vfs.exists(path).await
261        } else {
262            self.inner.exists(path).await
263        }
264    }
265
266    // ═══════════════════════════════════════════════════════════════════════════
267    // Symlink Operations
268    // ═══════════════════════════════════════════════════════════════════════════
269
270    async fn lstat(&self, path: &Path) -> BackendResult<DirEntry> {
271        if self.is_virtual_path(path) {
272            Ok(self.vfs.lstat(path).await?)
273        } else {
274            self.inner.lstat(path).await
275        }
276    }
277
278    async fn read_link(&self, path: &Path) -> BackendResult<PathBuf> {
279        if self.is_virtual_path(path) {
280            Ok(self.vfs.read_link(path).await?)
281        } else {
282            self.inner.read_link(path).await
283        }
284    }
285
286    async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()> {
287        if self.is_virtual_path(link) {
288            self.vfs.symlink(target, link).await?;
289            Ok(())
290        } else {
291            self.inner.symlink(target, link).await
292        }
293    }
294
295    // ═══════════════════════════════════════════════════════════════════════════
296    // Tool Dispatch
297    // ═══════════════════════════════════════════════════════════════════════════
298
299    async fn call_tool(
300        &self,
301        name: &str,
302        args: ToolArgs,
303        ctx: &mut dyn ToolCtx,
304    ) -> BackendResult<ToolResult> {
305        // Tools are dispatched through the inner backend
306        self.inner.call_tool(name, args, ctx).await
307    }
308
309    async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
310        self.inner.list_tools().await
311    }
312
313    async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
314        self.inner.get_tool(name).await
315    }
316
317    // ═══════════════════════════════════════════════════════════════════════════
318    // Backend Information
319    // ═══════════════════════════════════════════════════════════════════════════
320
321    fn read_only(&self) -> bool {
322        // We're not read-only if either layer is writable
323        self.inner.read_only() && self.vfs.read_only()
324    }
325
326    fn backend_type(&self) -> &str {
327        "virtual-overlay"
328    }
329
330    fn mounts(&self) -> Vec<MountInfo> {
331        let mut mounts = self.inner.mounts();
332        mounts.extend(self.vfs.list_mounts());
333        mounts
334    }
335
336    fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
337        if self.is_virtual_path(path) {
338            // Virtual paths don't map to real filesystem
339            None
340        } else {
341            self.inner.resolve_real_path(path)
342        }
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349    use crate::backend::testing::MockBackend;
350    use crate::vfs::MemoryFs;
351
352    async fn make_overlay() -> VirtualOverlayBackend {
353        // Create mock inner backend
354        let (mock, _) = MockBackend::new();
355        let inner: Arc<dyn KernelBackend> = Arc::new(mock);
356
357        // Create VFS with /v mounted
358        let mut vfs = VfsRouter::new();
359        let mem = MemoryFs::new();
360        mem.write(Path::new("blobs/test.bin"), b"blob data").await.unwrap();
361        mem.mkdir(Path::new("jobs")).await.unwrap();
362        vfs.mount("/v", mem);
363
364        VirtualOverlayBackend::new(inner, Arc::new(vfs))
365    }
366
367    #[tokio::test]
368    async fn test_virtual_path_detection() {
369        let overlay = make_overlay().await;
370        assert!(overlay.is_virtual_path(Path::new("/v")));
371        assert!(overlay.is_virtual_path(Path::new("/v/")));
372        assert!(overlay.is_virtual_path(Path::new("/v/jobs")));
373        assert!(overlay.is_virtual_path(Path::new("/v/blobs/test.bin")));
374
375        assert!(!overlay.is_virtual_path(Path::new("/docs")));
376        assert!(!overlay.is_virtual_path(Path::new("/g/repo")));
377        assert!(!overlay.is_virtual_path(Path::new("/")));
378        assert!(!overlay.is_virtual_path(Path::new("/var")));
379    }
380
381    #[tokio::test]
382    async fn test_non_v_mount_is_virtual_path() {
383        // A mount outside /v (e.g. Kernel::with_backend's /dev) must also be
384        // routed to the internal VFS, not silently delegated to the inner
385        // backend — this is the bug that let writes to /dev/null fail as
386        // "read-only filesystem" when the inner backend was read-only.
387        let (mock, _) = MockBackend::new();
388        let inner: Arc<dyn KernelBackend> = Arc::new(mock);
389        let mut vfs = VfsRouter::new();
390        vfs.mount("/dev", MemoryFs::new());
391        let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
392
393        assert!(overlay.is_virtual_path(Path::new("/dev/null")));
394        assert!(!overlay.is_virtual_path(Path::new("/docs")));
395    }
396
397    #[tokio::test]
398    async fn test_read_virtual_path() {
399        let overlay = make_overlay().await;
400        let content = overlay.read(Path::new("/v/blobs/test.bin"), None).await.unwrap();
401        assert_eq!(content, b"blob data");
402    }
403
404    #[tokio::test]
405    async fn test_write_virtual_path() {
406        let overlay = make_overlay().await;
407        overlay
408            .write(Path::new("/v/blobs/new.bin"), b"new data", WriteMode::Overwrite)
409            .await
410            .unwrap();
411        let content = overlay.read(Path::new("/v/blobs/new.bin"), None).await.unwrap();
412        assert_eq!(content, b"new data");
413    }
414
415    #[tokio::test]
416    async fn test_list_virtual_path() {
417        let overlay = make_overlay().await;
418        let entries = overlay.list(Path::new("/v")).await.unwrap();
419        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
420        assert!(names.contains(&"blobs"));
421        assert!(names.contains(&"jobs"));
422    }
423
424    #[tokio::test]
425    async fn test_root_listing_includes_v() {
426        let overlay = make_overlay().await;
427        let entries = overlay.list(Path::new("/")).await.unwrap();
428        let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
429        assert!(names.contains(&"v"), "Root listing should include 'v' directory");
430    }
431
432    #[tokio::test]
433    async fn test_stat_virtual_path() {
434        let overlay = make_overlay().await;
435        let info = overlay.stat(Path::new("/v/blobs/test.bin")).await.unwrap();
436        assert!(info.is_file());
437        assert_eq!(info.size, 9); // "blob data".len()
438    }
439
440    #[tokio::test]
441    async fn test_exists_virtual_path() {
442        let overlay = make_overlay().await;
443        assert!(overlay.exists(Path::new("/v/blobs/test.bin")).await);
444        assert!(!overlay.exists(Path::new("/v/blobs/nonexistent")).await);
445    }
446
447    #[tokio::test]
448    async fn test_mkdir_virtual_path() {
449        let overlay = make_overlay().await;
450        overlay.mkdir(Path::new("/v/newdir")).await.unwrap();
451        assert!(overlay.exists(Path::new("/v/newdir")).await);
452    }
453
454    #[tokio::test]
455    async fn test_remove_virtual_path() {
456        let overlay = make_overlay().await;
457        overlay.remove(Path::new("/v/blobs/test.bin"), false).await.unwrap();
458        assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
459    }
460
461    #[tokio::test]
462    async fn test_rename_within_virtual() {
463        let overlay = make_overlay().await;
464        overlay
465            .rename(Path::new("/v/blobs/test.bin"), Path::new("/v/blobs/renamed.bin"))
466            .await
467            .unwrap();
468        assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
469        assert!(overlay.exists(Path::new("/v/blobs/renamed.bin")).await);
470    }
471
472    #[tokio::test]
473    async fn test_rename_across_boundary_fails() {
474        let overlay = make_overlay().await;
475        let result = overlay
476            .rename(Path::new("/v/blobs/test.bin"), Path::new("/docs/test.bin"))
477            .await;
478        assert!(matches!(result, Err(BackendError::InvalidOperation(_))));
479    }
480
481    #[tokio::test]
482    async fn test_backend_type() {
483        let overlay = make_overlay().await;
484        assert_eq!(overlay.backend_type(), "virtual-overlay");
485    }
486
487    #[tokio::test]
488    async fn test_resolve_real_path_virtual() {
489        let overlay = make_overlay().await;
490        // Virtual paths don't resolve to real paths
491        assert!(overlay.resolve_real_path(Path::new("/v/blobs/test.bin")).is_none());
492    }
493}