1use 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
44fn 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
52fn synth_dir_note(path: &Path) -> String {
56 format!("{} is a synthesized directory that only holds kaish mounts", path.display())
57}
58
59pub struct VirtualOverlayBackend {
64 inner: Arc<dyn KernelBackend>,
66 vfs: Arc<VfsRouter>,
68}
69
70impl VirtualOverlayBackend {
71 pub fn new(inner: Arc<dyn KernelBackend>, vfs: Arc<VfsRouter>) -> Self {
84 Self { inner, vfs }
85 }
86
87 fn is_virtual_path(&self, path: &Path) -> bool {
98 self.vfs.has_mount(path)
99 }
100
101 fn is_shared_ancestor(&self, path: &Path) -> bool {
109 !self.is_virtual_path(path) && self.vfs.has_mount_under(path)
110 }
111
112 pub fn inner(&self) -> &Arc<dyn KernelBackend> {
114 &self.inner
115 }
116
117 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 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 _ => {
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 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 for op in ops {
216 LocalBackend::apply_patch_op(&mut content, op)?;
217 }
218
219 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 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 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 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 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 self.is_shared_ancestor(path) || self.inner.exists(path).await
352 }
353 }
354
355 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 async fn call_tool(
401 &self,
402 name: &str,
403 args: ToolArgs,
404 ctx: &mut dyn ToolCtx,
405 ) -> BackendResult<ToolResult> {
406 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 fn read_only(&self) -> bool {
423 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 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 let (mock, _) = MockBackend::new();
456 let inner: Arc<dyn KernelBackend> = Arc::new(mock);
457
458 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 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 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 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); }
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 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 assert!(overlay.resolve_real_path(Path::new("/v/blobs/test.bin")).is_none());
601 }
602
603 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 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 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 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 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 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 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 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 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 assert!(v.is_dir());
764 assert!(v.modified.is_some(), "intermediate child keeps inner real metadata");
765 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 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}