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