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};
43use kaish_types::PathAccess;
44
45fn 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
53fn synth_dir_note(path: &Path) -> String {
57 format!("{} is a synthesized directory that only holds kaish mounts", path.display())
58}
59
60pub struct VirtualOverlayBackend {
65 inner: Arc<dyn KernelBackend>,
67 vfs: Arc<VfsRouter>,
69}
70
71impl VirtualOverlayBackend {
72 pub fn new(inner: Arc<dyn KernelBackend>, vfs: Arc<VfsRouter>) -> Self {
85 Self { inner, vfs }
86 }
87
88 fn is_virtual_path(&self, path: &Path) -> bool {
99 self.vfs.has_mount(path)
100 }
101
102 fn is_shared_ancestor(&self, path: &Path) -> bool {
110 !self.is_virtual_path(path) && self.vfs.has_mount_under(path)
111 }
112
113 pub fn inner(&self) -> &Arc<dyn KernelBackend> {
115 &self.inner
116 }
117
118 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 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 _ => {
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 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 for op in ops {
211 LocalBackend::apply_patch_op(&mut content, op)?;
212 }
213
214 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 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 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 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 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 self.is_shared_ancestor(path) || self.inner.exists(path).await
347 }
348 }
349
350 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 async fn call_tool(
396 &self,
397 name: &str,
398 args: ToolArgs,
399 ctx: &mut dyn ToolCtx,
400 ) -> BackendResult<ToolResult> {
401 self.inner.call_tool(name, args, ctx).await
403 }
404
405 async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
406 self.inner.list_tools().await
407 }
408
409 async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
410 self.inner.get_tool(name).await
411 }
412
413 fn read_only(&self) -> bool {
418 self.inner.read_only() && self.vfs.read_only()
420 }
421
422 async fn path_access(&self, path: &Path) -> BackendResult<PathAccess> {
426 if self.is_virtual_path(path) {
427 Ok(self.vfs.path_access(path).await?)
428 } else if self.is_shared_ancestor(path) {
429 Ok(PathAccess::resolve(Some(0o555), true))
432 } else {
433 self.inner.path_access(path).await
434 }
435 }
436
437 fn backend_type(&self) -> &str {
438 "virtual-overlay"
439 }
440
441 fn mounts(&self) -> Vec<MountInfo> {
442 let mut mounts = self.inner.mounts();
443 mounts.extend(self.vfs.list_mounts());
444 mounts
445 }
446
447 fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
448 if self.is_virtual_path(path) {
449 None
451 } else {
452 self.inner.resolve_real_path(path)
453 }
454 }
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460 use crate::backend::testing::MockBackend;
461 use crate::vfs::MemoryFs;
462
463 async fn make_overlay() -> VirtualOverlayBackend {
464 let (mock, _) = MockBackend::new();
466 let inner: Arc<dyn KernelBackend> = Arc::new(mock);
467
468 let mut vfs = VfsRouter::new();
470 let blobs = MemoryFs::new();
471 blobs.write(Path::new("test.bin"), b"blob data").await.unwrap();
472 vfs.mount("/v/blobs", blobs);
473 vfs.mount("/v/jobs", MemoryFs::new());
474
475 VirtualOverlayBackend::new(inner, Arc::new(vfs))
476 }
477
478 #[tokio::test]
479 async fn test_virtual_path_detection() {
480 let overlay = make_overlay().await;
481 assert!(overlay.is_virtual_path(Path::new("/v/jobs")));
483 assert!(overlay.is_virtual_path(Path::new("/v/blobs")));
484 assert!(overlay.is_virtual_path(Path::new("/v/blobs/test.bin")));
485
486 assert!(!overlay.is_virtual_path(Path::new("/v")));
490 assert!(!overlay.is_virtual_path(Path::new("/v/")));
491 assert!(!overlay.is_virtual_path(Path::new("/v/unclaimed")));
492
493 assert!(!overlay.is_virtual_path(Path::new("/docs")));
494 assert!(!overlay.is_virtual_path(Path::new("/g/repo")));
495 assert!(!overlay.is_virtual_path(Path::new("/")));
496 assert!(!overlay.is_virtual_path(Path::new("/var")));
497 }
498
499 #[tokio::test]
500 async fn test_non_v_mount_is_virtual_path() {
501 let (mock, _) = MockBackend::new();
506 let inner: Arc<dyn KernelBackend> = Arc::new(mock);
507 let mut vfs = VfsRouter::new();
508 vfs.mount("/dev", MemoryFs::new());
509 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
510
511 assert!(overlay.is_virtual_path(Path::new("/dev/null")));
512 assert!(!overlay.is_virtual_path(Path::new("/docs")));
513 }
514
515 #[tokio::test]
516 async fn test_read_virtual_path() {
517 let overlay = make_overlay().await;
518 let content = overlay.read(Path::new("/v/blobs/test.bin"), None).await.unwrap();
519 assert_eq!(content, b"blob data");
520 }
521
522 #[tokio::test]
523 async fn test_write_virtual_path() {
524 let overlay = make_overlay().await;
525 overlay
526 .write(Path::new("/v/blobs/new.bin"), b"new data", WriteMode::Overwrite)
527 .await
528 .unwrap();
529 let content = overlay.read(Path::new("/v/blobs/new.bin"), None).await.unwrap();
530 assert_eq!(content, b"new data");
531 }
532
533 #[tokio::test]
534 async fn test_list_virtual_path() {
535 let overlay = make_overlay().await;
536 let entries = overlay.list(Path::new("/v")).await.unwrap();
537 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
538 assert!(names.contains(&"blobs"));
539 assert!(names.contains(&"jobs"));
540 }
541
542 #[tokio::test]
543 async fn test_root_listing_includes_v() {
544 let overlay = make_overlay().await;
545 let entries = overlay.list(Path::new("/")).await.unwrap();
546 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
547 assert!(names.contains(&"v"), "Root listing should include 'v' directory");
548 }
549
550 #[tokio::test]
551 async fn test_stat_virtual_path() {
552 let overlay = make_overlay().await;
553 let info = overlay.stat(Path::new("/v/blobs/test.bin")).await.unwrap();
554 assert!(info.is_file());
555 assert_eq!(info.size, 9); }
557
558 #[tokio::test]
559 async fn test_exists_virtual_path() {
560 let overlay = make_overlay().await;
561 assert!(overlay.exists(Path::new("/v/blobs/test.bin")).await);
562 assert!(!overlay.exists(Path::new("/v/blobs/nonexistent")).await);
563 }
564
565 #[tokio::test]
566 async fn test_mkdir_virtual_path() {
567 let overlay = make_overlay().await;
568 overlay.mkdir(Path::new("/v/blobs/newdir")).await.unwrap();
570 assert!(overlay.exists(Path::new("/v/blobs/newdir")).await);
571 }
572
573 #[tokio::test]
574 async fn test_remove_virtual_path() {
575 let overlay = make_overlay().await;
576 overlay.remove(Path::new("/v/blobs/test.bin"), false).await.unwrap();
577 assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
578 }
579
580 #[tokio::test]
581 async fn test_rename_within_virtual() {
582 let overlay = make_overlay().await;
583 overlay
584 .rename(Path::new("/v/blobs/test.bin"), Path::new("/v/blobs/renamed.bin"))
585 .await
586 .unwrap();
587 assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
588 assert!(overlay.exists(Path::new("/v/blobs/renamed.bin")).await);
589 }
590
591 #[tokio::test]
592 async fn test_rename_across_boundary_fails() {
593 let overlay = make_overlay().await;
594 let result = overlay
595 .rename(Path::new("/v/blobs/test.bin"), Path::new("/docs/test.bin"))
596 .await;
597 assert!(matches!(result, Err(BackendError::InvalidOperation(_))));
598 }
599
600 #[tokio::test]
601 async fn test_backend_type() {
602 let overlay = make_overlay().await;
603 assert_eq!(overlay.backend_type(), "virtual-overlay");
604 }
605
606 #[tokio::test]
607 async fn test_resolve_real_path_virtual() {
608 let overlay = make_overlay().await;
609 assert!(overlay.resolve_real_path(Path::new("/v/blobs/test.bin")).is_none());
611 }
612
613 async fn overlay_over_inner(cas: bool) -> VirtualOverlayBackend {
618 let mut inner_router = VfsRouter::new();
619 let inner_mem = MemoryFs::new();
620 if cas {
621 inner_mem
622 .write(Path::new("v/cas/blob.bin"), b"cas data")
623 .await
624 .unwrap();
625 }
626 inner_router.mount("/", inner_mem);
627 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
628
629 let mut vfs = VfsRouter::new();
630 vfs.mount("/v/jobs", MemoryFs::new());
631 vfs.mount("/dev", MemoryFs::new());
632 VirtualOverlayBackend::new(inner, Arc::new(vfs))
633 }
634
635 #[tokio::test]
636 async fn test_unclaimed_v_reaches_inner_backend() {
637 let overlay = overlay_over_inner(true).await;
640 let data = overlay.read(Path::new("/v/cas/blob.bin"), None).await.unwrap();
641 assert_eq!(data, b"cas data");
642 assert!(overlay.exists(Path::new("/v/cas/blob.bin")).await);
643 }
644
645 #[tokio::test]
646 async fn test_v_listing_unions_kaish_and_inner() {
647 let overlay = overlay_over_inner(true).await;
648 let names: Vec<String> = overlay
649 .list(Path::new("/v"))
650 .await
651 .unwrap()
652 .into_iter()
653 .map(|e| e.name)
654 .collect();
655 assert!(names.iter().any(|n| n == "jobs"), "kaish mount missing: {names:?}");
656 assert!(names.iter().any(|n| n == "cas"), "embedder mount missing: {names:?}");
657 }
658
659 #[tokio::test]
660 async fn test_v_synthesized_when_inner_lacks_it() {
661 let overlay = overlay_over_inner(false).await;
665 assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir());
666 assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
667 assert!(overlay.exists(Path::new("/v")).await);
668 let names: Vec<String> = overlay
669 .list(Path::new("/v"))
670 .await
671 .unwrap()
672 .into_iter()
673 .map(|e| e.name)
674 .collect();
675 assert_eq!(names, vec!["jobs".to_string()]);
676 }
677
678 #[cfg(feature = "localfs")]
679 #[tokio::test]
680 async fn test_unclaimed_v_resolves_to_inner_real_path() {
681 use crate::vfs::LocalFs;
682 let dir = tempfile::tempdir().unwrap();
684 std::fs::create_dir_all(dir.path().join("v/cas")).unwrap();
685 std::fs::write(dir.path().join("v/cas/blob.bin"), b"x").unwrap();
686
687 let mut inner_router = VfsRouter::new();
688 inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
689 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
690 let mut vfs = VfsRouter::new();
691 vfs.mount("/v/jobs", MemoryFs::new());
692 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
693
694 let real = overlay.resolve_real_path(Path::new("/v/cas/blob.bin"));
700 assert!(real.is_some(), "unclaimed /v/* must resolve to the embedder real path");
701 assert!(real.unwrap().ends_with("v/cas/blob.bin"));
702 assert!(overlay.resolve_real_path(Path::new("/v/jobs/1")).is_none());
704 }
705
706 #[tokio::test]
707 async fn test_root_lists_both_v_and_dev() {
708 let overlay = overlay_over_inner(false).await;
711 let names: Vec<String> = overlay
712 .list(Path::new("/"))
713 .await
714 .unwrap()
715 .into_iter()
716 .map(|e| e.name)
717 .collect();
718 assert!(names.iter().any(|n| n == "v"), "{names:?}");
719 assert!(names.iter().any(|n| n == "dev"), "{names:?}");
720 }
721
722 #[tokio::test]
723 async fn test_shared_ancestor_is_a_directory_even_over_an_inner_file() {
724 let inner_mem = MemoryFs::new();
730 inner_mem.write(Path::new("v"), b"i am a file").await.unwrap();
731 let mut inner_router = VfsRouter::new();
732 inner_router.mount("/", inner_mem);
733 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
734 let mut vfs = VfsRouter::new();
735 vfs.mount("/v/jobs", MemoryFs::new());
736 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
737
738 assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir(), "kaish dir wins over inner file");
739 assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
740 assert!(overlay.exists(Path::new("/v")).await);
741 let names: Vec<String> = overlay
742 .list(Path::new("/v"))
743 .await
744 .unwrap()
745 .into_iter()
746 .map(|e| e.name)
747 .collect();
748 assert_eq!(names, vec!["jobs".to_string()], "lists kaish mount; no NotADirectory error");
749 }
750
751 #[cfg(feature = "localfs")]
752 #[tokio::test]
753 async fn test_listing_keeps_inner_real_metadata_for_intermediate_child() {
754 use crate::vfs::LocalFs;
755 let dir = tempfile::tempdir().unwrap();
757 std::fs::create_dir_all(dir.path().join("v")).unwrap();
758
759 let mut inner_router = VfsRouter::new();
760 inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
761 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
762 let mut vfs = VfsRouter::new();
763 vfs.mount("/v/jobs", MemoryFs::new());
764 vfs.mount("/dev", MemoryFs::new());
765 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
766
767 let entries = overlay.list(Path::new("/")).await.unwrap();
768 let v = entries.iter().find(|e| e.name == "v").expect("v listed");
769 let dev = entries.iter().find(|e| e.name == "dev").expect("dev listed");
770 assert!(v.is_dir());
774 assert!(v.modified.is_some(), "intermediate child keeps inner real metadata");
775 assert!(dev.is_dir());
777 assert!(dev.modified.is_none(), "real kaish mount shadows inner");
778 }
779
780 #[tokio::test]
781 async fn test_mutations_on_shared_ancestor_are_rejected_clearly() {
782 let overlay = overlay_over_inner(false).await;
786
787 assert!(
788 matches!(overlay.mkdir(Path::new("/v")).await, Err(BackendError::AlreadyExists(_))),
789 "mkdir on an existing synthesized dir → AlreadyExists"
790 );
791 assert!(
792 matches!(overlay.remove(Path::new("/v"), true).await, Err(BackendError::InvalidOperation(_))),
793 "remove of a synthesized dir that holds kaish mounts → InvalidOperation"
794 );
795 assert!(
796 matches!(
797 overlay.set_mtime(Path::new("/v"), std::time::SystemTime::now()).await,
798 Err(BackendError::InvalidOperation(_))
799 ),
800 "set_mtime (touch) on a synthesized dir → InvalidOperation"
801 );
802 assert!(
803 matches!(
804 overlay.write(Path::new("/v"), b"x", WriteMode::Overwrite).await,
805 Err(BackendError::IsDirectory(_))
806 ),
807 "write to a synthesized dir → IsDirectory"
808 );
809 assert!(
810 matches!(overlay.read(Path::new("/v"), None).await, Err(BackendError::IsDirectory(_))),
811 "read of a synthesized dir → IsDirectory"
812 );
813 }
814}