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 self.vfs.append(path, content).await?;
193 Ok(())
194 } else if self.is_shared_ancestor(path) {
195 Err(BackendError::IsDirectory(synth_dir_note(path)))
196 } else {
197 self.inner.append(path, content).await
198 }
199 }
200
201 async fn patch(&self, path: &Path, ops: &[PatchOp]) -> BackendResult<()> {
202 if self.is_virtual_path(path) {
203 let data = self.vfs.read(path).await?;
205 let mut content = String::from_utf8(data)
206 .map_err(|e| BackendError::InvalidOperation(format!("file is not valid UTF-8: {}", e)))?;
207
208 for op in ops {
210 LocalBackend::apply_patch_op(&mut content, op)?;
211 }
212
213 self.vfs.write(path, content.as_bytes()).await?;
215 Ok(())
216 } else if self.is_shared_ancestor(path) {
217 Err(BackendError::IsDirectory(synth_dir_note(path)))
218 } else {
219 self.inner.patch(path, ops).await
220 }
221 }
222
223 async fn list(&self, path: &Path) -> BackendResult<Vec<DirEntry>> {
228 if self.is_virtual_path(path) {
229 Ok(self.vfs.list(path).await?)
230 } else if self.is_shared_ancestor(path) {
231 let mut by_name: std::collections::HashMap<String, DirEntry> =
243 std::collections::HashMap::new();
244 if let Ok(inner_entries) = self.inner.list(path).await {
245 for entry in inner_entries {
246 by_name.insert(entry.name.clone(), entry);
247 }
248 }
249 for entry in self.vfs.list(path).await? {
250 if self.vfs.has_mount(&path.join(&entry.name)) {
251 by_name.insert(entry.name.clone(), entry);
252 } else {
253 by_name.entry(entry.name.clone()).or_insert(entry);
254 }
255 }
256 let mut entries: Vec<DirEntry> = by_name.into_values().collect();
257 entries.sort_by(|a, b| a.name.cmp(&b.name));
258 Ok(entries)
259 } else {
260 self.inner.list(path).await
261 }
262 }
263
264 async fn stat(&self, path: &Path) -> BackendResult<DirEntry> {
265 if self.is_virtual_path(path) {
266 Ok(self.vfs.stat(path).await?)
267 } else if self.is_shared_ancestor(path) {
268 match self.inner.stat(path).await {
272 Ok(entry) if entry.is_dir() => Ok(entry),
273 _ => Ok(DirEntry::directory(dir_basename(path))),
274 }
275 } else {
276 self.inner.stat(path).await
277 }
278 }
279
280 async fn mkdir(&self, path: &Path) -> BackendResult<()> {
281 if self.is_virtual_path(path) {
282 self.vfs.mkdir(path).await?;
283 Ok(())
284 } else if self.is_shared_ancestor(path) {
285 Err(BackendError::AlreadyExists(synth_dir_note(path)))
286 } else {
287 self.inner.mkdir(path).await
288 }
289 }
290
291 async fn remove(&self, path: &Path, recursive: bool) -> BackendResult<()> {
292 if self.is_virtual_path(path) {
293 if recursive
294 && let Ok(entry) = self.vfs.lstat(path).await
295 && entry.is_dir()
296 && let Ok(entries) = self.vfs.list(path).await
297 {
298 for entry in entries {
299 let child_path = path.join(&entry.name);
300 Box::pin(self.remove(&child_path, true)).await?;
301 }
302 }
303 self.vfs.remove(path).await?;
304 Ok(())
305 } else if self.is_shared_ancestor(path) {
306 Err(BackendError::InvalidOperation(format!("cannot remove: {}", synth_dir_note(path))))
309 } else {
310 self.inner.remove(path, recursive).await
311 }
312 }
313
314 async fn rename(&self, from: &Path, to: &Path) -> BackendResult<()> {
315 let from_virtual = self.is_virtual_path(from);
316 let to_virtual = self.is_virtual_path(to);
317
318 if from_virtual != to_virtual {
319 return Err(BackendError::InvalidOperation(
320 "cannot rename between virtual and non-virtual paths".into(),
321 ));
322 }
323
324 if self.is_shared_ancestor(from) || self.is_shared_ancestor(to) {
325 return Err(BackendError::InvalidOperation(format!(
326 "cannot rename: {} is a synthesized directory",
327 if self.is_shared_ancestor(from) { from.display() } else { to.display() }
328 )));
329 }
330
331 if from_virtual {
332 self.vfs.rename(from, to).await?;
333 Ok(())
334 } else {
335 self.inner.rename(from, to).await
336 }
337 }
338
339 async fn exists(&self, path: &Path) -> bool {
340 if self.is_virtual_path(path) {
341 self.vfs.exists(path).await
342 } else {
343 self.is_shared_ancestor(path) || self.inner.exists(path).await
346 }
347 }
348
349 async fn lstat(&self, path: &Path) -> BackendResult<DirEntry> {
354 if self.is_virtual_path(path) {
355 Ok(self.vfs.lstat(path).await?)
356 } else if self.is_shared_ancestor(path) {
357 match self.inner.lstat(path).await {
358 Ok(entry) if entry.is_dir() => Ok(entry),
359 _ => Ok(DirEntry::directory(dir_basename(path))),
360 }
361 } else {
362 self.inner.lstat(path).await
363 }
364 }
365
366 async fn read_link(&self, path: &Path) -> BackendResult<PathBuf> {
367 if self.is_virtual_path(path) {
368 Ok(self.vfs.read_link(path).await?)
369 } else if self.is_shared_ancestor(path) {
370 Err(BackendError::InvalidOperation(format!(
371 "{} is a directory, not a symlink",
372 path.display()
373 )))
374 } else {
375 self.inner.read_link(path).await
376 }
377 }
378
379 async fn symlink(&self, target: &Path, link: &Path) -> BackendResult<()> {
380 if self.is_virtual_path(link) {
381 self.vfs.symlink(target, link).await?;
382 Ok(())
383 } else if self.is_shared_ancestor(link) {
384 Err(BackendError::AlreadyExists(synth_dir_note(link)))
385 } else {
386 self.inner.symlink(target, link).await
387 }
388 }
389
390 async fn call_tool(
395 &self,
396 name: &str,
397 args: ToolArgs,
398 ctx: &mut dyn ToolCtx,
399 ) -> BackendResult<ToolResult> {
400 self.inner.call_tool(name, args, ctx).await
402 }
403
404 async fn list_tools(&self) -> BackendResult<Vec<ToolInfo>> {
405 self.inner.list_tools().await
406 }
407
408 async fn get_tool(&self, name: &str) -> BackendResult<Option<ToolInfo>> {
409 self.inner.get_tool(name).await
410 }
411
412 fn read_only(&self) -> bool {
417 self.inner.read_only() && self.vfs.read_only()
419 }
420
421 fn backend_type(&self) -> &str {
422 "virtual-overlay"
423 }
424
425 fn mounts(&self) -> Vec<MountInfo> {
426 let mut mounts = self.inner.mounts();
427 mounts.extend(self.vfs.list_mounts());
428 mounts
429 }
430
431 fn resolve_real_path(&self, path: &Path) -> Option<PathBuf> {
432 if self.is_virtual_path(path) {
433 None
435 } else {
436 self.inner.resolve_real_path(path)
437 }
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444 use crate::backend::testing::MockBackend;
445 use crate::vfs::MemoryFs;
446
447 async fn make_overlay() -> VirtualOverlayBackend {
448 let (mock, _) = MockBackend::new();
450 let inner: Arc<dyn KernelBackend> = Arc::new(mock);
451
452 let mut vfs = VfsRouter::new();
454 let blobs = MemoryFs::new();
455 blobs.write(Path::new("test.bin"), b"blob data").await.unwrap();
456 vfs.mount("/v/blobs", blobs);
457 vfs.mount("/v/jobs", MemoryFs::new());
458
459 VirtualOverlayBackend::new(inner, Arc::new(vfs))
460 }
461
462 #[tokio::test]
463 async fn test_virtual_path_detection() {
464 let overlay = make_overlay().await;
465 assert!(overlay.is_virtual_path(Path::new("/v/jobs")));
467 assert!(overlay.is_virtual_path(Path::new("/v/blobs")));
468 assert!(overlay.is_virtual_path(Path::new("/v/blobs/test.bin")));
469
470 assert!(!overlay.is_virtual_path(Path::new("/v")));
474 assert!(!overlay.is_virtual_path(Path::new("/v/")));
475 assert!(!overlay.is_virtual_path(Path::new("/v/unclaimed")));
476
477 assert!(!overlay.is_virtual_path(Path::new("/docs")));
478 assert!(!overlay.is_virtual_path(Path::new("/g/repo")));
479 assert!(!overlay.is_virtual_path(Path::new("/")));
480 assert!(!overlay.is_virtual_path(Path::new("/var")));
481 }
482
483 #[tokio::test]
484 async fn test_non_v_mount_is_virtual_path() {
485 let (mock, _) = MockBackend::new();
490 let inner: Arc<dyn KernelBackend> = Arc::new(mock);
491 let mut vfs = VfsRouter::new();
492 vfs.mount("/dev", MemoryFs::new());
493 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
494
495 assert!(overlay.is_virtual_path(Path::new("/dev/null")));
496 assert!(!overlay.is_virtual_path(Path::new("/docs")));
497 }
498
499 #[tokio::test]
500 async fn test_read_virtual_path() {
501 let overlay = make_overlay().await;
502 let content = overlay.read(Path::new("/v/blobs/test.bin"), None).await.unwrap();
503 assert_eq!(content, b"blob data");
504 }
505
506 #[tokio::test]
507 async fn test_write_virtual_path() {
508 let overlay = make_overlay().await;
509 overlay
510 .write(Path::new("/v/blobs/new.bin"), b"new data", WriteMode::Overwrite)
511 .await
512 .unwrap();
513 let content = overlay.read(Path::new("/v/blobs/new.bin"), None).await.unwrap();
514 assert_eq!(content, b"new data");
515 }
516
517 #[tokio::test]
518 async fn test_list_virtual_path() {
519 let overlay = make_overlay().await;
520 let entries = overlay.list(Path::new("/v")).await.unwrap();
521 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
522 assert!(names.contains(&"blobs"));
523 assert!(names.contains(&"jobs"));
524 }
525
526 #[tokio::test]
527 async fn test_root_listing_includes_v() {
528 let overlay = make_overlay().await;
529 let entries = overlay.list(Path::new("/")).await.unwrap();
530 let names: Vec<&str> = entries.iter().map(|e| e.name.as_str()).collect();
531 assert!(names.contains(&"v"), "Root listing should include 'v' directory");
532 }
533
534 #[tokio::test]
535 async fn test_stat_virtual_path() {
536 let overlay = make_overlay().await;
537 let info = overlay.stat(Path::new("/v/blobs/test.bin")).await.unwrap();
538 assert!(info.is_file());
539 assert_eq!(info.size, 9); }
541
542 #[tokio::test]
543 async fn test_exists_virtual_path() {
544 let overlay = make_overlay().await;
545 assert!(overlay.exists(Path::new("/v/blobs/test.bin")).await);
546 assert!(!overlay.exists(Path::new("/v/blobs/nonexistent")).await);
547 }
548
549 #[tokio::test]
550 async fn test_mkdir_virtual_path() {
551 let overlay = make_overlay().await;
552 overlay.mkdir(Path::new("/v/blobs/newdir")).await.unwrap();
554 assert!(overlay.exists(Path::new("/v/blobs/newdir")).await);
555 }
556
557 #[tokio::test]
558 async fn test_remove_virtual_path() {
559 let overlay = make_overlay().await;
560 overlay.remove(Path::new("/v/blobs/test.bin"), false).await.unwrap();
561 assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
562 }
563
564 #[tokio::test]
565 async fn test_rename_within_virtual() {
566 let overlay = make_overlay().await;
567 overlay
568 .rename(Path::new("/v/blobs/test.bin"), Path::new("/v/blobs/renamed.bin"))
569 .await
570 .unwrap();
571 assert!(!overlay.exists(Path::new("/v/blobs/test.bin")).await);
572 assert!(overlay.exists(Path::new("/v/blobs/renamed.bin")).await);
573 }
574
575 #[tokio::test]
576 async fn test_rename_across_boundary_fails() {
577 let overlay = make_overlay().await;
578 let result = overlay
579 .rename(Path::new("/v/blobs/test.bin"), Path::new("/docs/test.bin"))
580 .await;
581 assert!(matches!(result, Err(BackendError::InvalidOperation(_))));
582 }
583
584 #[tokio::test]
585 async fn test_backend_type() {
586 let overlay = make_overlay().await;
587 assert_eq!(overlay.backend_type(), "virtual-overlay");
588 }
589
590 #[tokio::test]
591 async fn test_resolve_real_path_virtual() {
592 let overlay = make_overlay().await;
593 assert!(overlay.resolve_real_path(Path::new("/v/blobs/test.bin")).is_none());
595 }
596
597 async fn overlay_over_inner(cas: bool) -> VirtualOverlayBackend {
602 let mut inner_router = VfsRouter::new();
603 let inner_mem = MemoryFs::new();
604 if cas {
605 inner_mem
606 .write(Path::new("v/cas/blob.bin"), b"cas data")
607 .await
608 .unwrap();
609 }
610 inner_router.mount("/", inner_mem);
611 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
612
613 let mut vfs = VfsRouter::new();
614 vfs.mount("/v/jobs", MemoryFs::new());
615 vfs.mount("/dev", MemoryFs::new());
616 VirtualOverlayBackend::new(inner, Arc::new(vfs))
617 }
618
619 #[tokio::test]
620 async fn test_unclaimed_v_reaches_inner_backend() {
621 let overlay = overlay_over_inner(true).await;
624 let data = overlay.read(Path::new("/v/cas/blob.bin"), None).await.unwrap();
625 assert_eq!(data, b"cas data");
626 assert!(overlay.exists(Path::new("/v/cas/blob.bin")).await);
627 }
628
629 #[tokio::test]
630 async fn test_v_listing_unions_kaish_and_inner() {
631 let overlay = overlay_over_inner(true).await;
632 let names: Vec<String> = overlay
633 .list(Path::new("/v"))
634 .await
635 .unwrap()
636 .into_iter()
637 .map(|e| e.name)
638 .collect();
639 assert!(names.iter().any(|n| n == "jobs"), "kaish mount missing: {names:?}");
640 assert!(names.iter().any(|n| n == "cas"), "embedder mount missing: {names:?}");
641 }
642
643 #[tokio::test]
644 async fn test_v_synthesized_when_inner_lacks_it() {
645 let overlay = overlay_over_inner(false).await;
649 assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir());
650 assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
651 assert!(overlay.exists(Path::new("/v")).await);
652 let names: Vec<String> = overlay
653 .list(Path::new("/v"))
654 .await
655 .unwrap()
656 .into_iter()
657 .map(|e| e.name)
658 .collect();
659 assert_eq!(names, vec!["jobs".to_string()]);
660 }
661
662 #[cfg(feature = "localfs")]
663 #[tokio::test]
664 async fn test_unclaimed_v_resolves_to_inner_real_path() {
665 use crate::vfs::LocalFs;
666 let dir = tempfile::tempdir().unwrap();
668 std::fs::create_dir_all(dir.path().join("v/cas")).unwrap();
669 std::fs::write(dir.path().join("v/cas/blob.bin"), b"x").unwrap();
670
671 let mut inner_router = VfsRouter::new();
672 inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
673 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
674 let mut vfs = VfsRouter::new();
675 vfs.mount("/v/jobs", MemoryFs::new());
676 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
677
678 let real = overlay.resolve_real_path(Path::new("/v/cas/blob.bin"));
684 assert!(real.is_some(), "unclaimed /v/* must resolve to the embedder real path");
685 assert!(real.unwrap().ends_with("v/cas/blob.bin"));
686 assert!(overlay.resolve_real_path(Path::new("/v/jobs/1")).is_none());
688 }
689
690 #[tokio::test]
691 async fn test_root_lists_both_v_and_dev() {
692 let overlay = overlay_over_inner(false).await;
695 let names: Vec<String> = overlay
696 .list(Path::new("/"))
697 .await
698 .unwrap()
699 .into_iter()
700 .map(|e| e.name)
701 .collect();
702 assert!(names.iter().any(|n| n == "v"), "{names:?}");
703 assert!(names.iter().any(|n| n == "dev"), "{names:?}");
704 }
705
706 #[tokio::test]
707 async fn test_shared_ancestor_is_a_directory_even_over_an_inner_file() {
708 let inner_mem = MemoryFs::new();
714 inner_mem.write(Path::new("v"), b"i am a file").await.unwrap();
715 let mut inner_router = VfsRouter::new();
716 inner_router.mount("/", inner_mem);
717 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
718 let mut vfs = VfsRouter::new();
719 vfs.mount("/v/jobs", MemoryFs::new());
720 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
721
722 assert!(overlay.stat(Path::new("/v")).await.unwrap().is_dir(), "kaish dir wins over inner file");
723 assert!(overlay.lstat(Path::new("/v")).await.unwrap().is_dir());
724 assert!(overlay.exists(Path::new("/v")).await);
725 let names: Vec<String> = overlay
726 .list(Path::new("/v"))
727 .await
728 .unwrap()
729 .into_iter()
730 .map(|e| e.name)
731 .collect();
732 assert_eq!(names, vec!["jobs".to_string()], "lists kaish mount; no NotADirectory error");
733 }
734
735 #[cfg(feature = "localfs")]
736 #[tokio::test]
737 async fn test_listing_keeps_inner_real_metadata_for_intermediate_child() {
738 use crate::vfs::LocalFs;
739 let dir = tempfile::tempdir().unwrap();
741 std::fs::create_dir_all(dir.path().join("v")).unwrap();
742
743 let mut inner_router = VfsRouter::new();
744 inner_router.mount("/", LocalFs::read_only(dir.path().to_path_buf()));
745 let inner: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(inner_router)));
746 let mut vfs = VfsRouter::new();
747 vfs.mount("/v/jobs", MemoryFs::new());
748 vfs.mount("/dev", MemoryFs::new());
749 let overlay = VirtualOverlayBackend::new(inner, Arc::new(vfs));
750
751 let entries = overlay.list(Path::new("/")).await.unwrap();
752 let v = entries.iter().find(|e| e.name == "v").expect("v listed");
753 let dev = entries.iter().find(|e| e.name == "dev").expect("dev listed");
754 assert!(v.is_dir());
758 assert!(v.modified.is_some(), "intermediate child keeps inner real metadata");
759 assert!(dev.is_dir());
761 assert!(dev.modified.is_none(), "real kaish mount shadows inner");
762 }
763
764 #[tokio::test]
765 async fn test_mutations_on_shared_ancestor_are_rejected_clearly() {
766 let overlay = overlay_over_inner(false).await;
770
771 assert!(
772 matches!(overlay.mkdir(Path::new("/v")).await, Err(BackendError::AlreadyExists(_))),
773 "mkdir on an existing synthesized dir → AlreadyExists"
774 );
775 assert!(
776 matches!(overlay.remove(Path::new("/v"), true).await, Err(BackendError::InvalidOperation(_))),
777 "remove of a synthesized dir that holds kaish mounts → InvalidOperation"
778 );
779 assert!(
780 matches!(
781 overlay.set_mtime(Path::new("/v"), std::time::SystemTime::now()).await,
782 Err(BackendError::InvalidOperation(_))
783 ),
784 "set_mtime (touch) on a synthesized dir → InvalidOperation"
785 );
786 assert!(
787 matches!(
788 overlay.write(Path::new("/v"), b"x", WriteMode::Overwrite).await,
789 Err(BackendError::IsDirectory(_))
790 ),
791 "write to a synthesized dir → IsDirectory"
792 );
793 assert!(
794 matches!(overlay.read(Path::new("/v"), None).await, Err(BackendError::IsDirectory(_))),
795 "read of a synthesized dir → IsDirectory"
796 );
797 }
798}