Skip to main content

harn_vm/testbench/
overlay_fs.rs

1//! Copy-on-write filesystem overlay.
2//!
3//! Reads pass through to the real filesystem under [`OverlayFs::root`].
4//! Writes (and deletes) land in an in-memory layer keyed by absolute
5//! path, so a hermetic run can observe the underlying tree without ever
6//! mutating it. Once the run finishes, [`OverlayFs::diff`] surfaces a
7//! readable summary of every change — emit it as a unified diff, apply
8//! it back with `git apply`, or discard it.
9//!
10//! Only the surface that stdlib `fs.*` builtins exercise is intercepted:
11//! read/write text and bytes, append, exists, remove, copy, rename, list,
12//! and create_dir. Metadata still falls through to the underlying fs.
13
14use std::cell::RefCell;
15use std::collections::BTreeMap;
16use std::path::{Component, Path, PathBuf};
17use std::sync::{Arc, Mutex};
18
19use crate::testbench::tape::{self, TapeRecordKind};
20
21/// One change in the overlay's write layer relative to the underlying
22/// tree.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct DiffEntry {
25    pub path: PathBuf,
26    pub kind: DiffKind,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub enum DiffKind {
31    /// File created in the overlay (not in the underlying tree).
32    Added { content: Vec<u8> },
33    /// File present in the underlying tree, content changed in overlay.
34    Modified { content: Vec<u8> },
35    /// File present in the underlying tree, deleted in overlay.
36    Deleted,
37}
38
39#[derive(Debug, Clone)]
40enum OverlayEntry {
41    File(Vec<u8>),
42    Deleted,
43    Directory,
44}
45
46#[derive(Debug)]
47pub struct OverlayFs {
48    root: PathBuf,
49    layer: Mutex<BTreeMap<PathBuf, OverlayEntry>>,
50}
51
52impl OverlayFs {
53    pub fn rooted_at(root: impl Into<PathBuf>) -> Self {
54        let root = root.into();
55        // On macOS the kernel reports `getcwd` as the canonical
56        // (`/private`-prefixed) path even when callers `set_current_dir`
57        // to the un-prefixed form. Canonicalize the overlay root so
58        // `within_root(...)` lines up with `resolve_source_relative_path`,
59        // which sees post-canonicalization paths.
60        let canonical = std::fs::canonicalize(&root).unwrap_or_else(|_| root.clone());
61        Self {
62            root: normalize_logical(&canonical),
63            layer: Mutex::new(BTreeMap::new()),
64        }
65    }
66
67    pub fn root(&self) -> &Path {
68        &self.root
69    }
70
71    fn key(&self, path: &Path) -> PathBuf {
72        canonicalize_for_overlay(path)
73    }
74
75    /// Whether `path` is inside the overlay's root. Calls outside the
76    /// root fall through to the real filesystem so testbench-unaware
77    /// helpers (the LLM provider's own caches, the runtime's session
78    /// store) keep working.
79    fn within_root(&self, path: &Path) -> bool {
80        let key = self.key(path);
81        key.starts_with(&self.root)
82    }
83
84    pub fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
85        if !self.within_root(path) {
86            return std::fs::read(path);
87        }
88        let key = self.key(path);
89        let layer = self.layer.lock().expect("overlay layer poisoned");
90        match layer.get(&key) {
91            Some(OverlayEntry::File(bytes)) => Ok(bytes.clone()),
92            Some(OverlayEntry::Deleted) => Err(std::io::Error::new(
93                std::io::ErrorKind::NotFound,
94                format!("overlay: {} was deleted", key.display()),
95            )),
96            Some(OverlayEntry::Directory) => Err(std::io::Error::new(
97                std::io::ErrorKind::IsADirectory,
98                format!("overlay: {} is a directory", key.display()),
99            )),
100            None => std::fs::read(path),
101        }
102    }
103
104    pub fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
105        let bytes = self.read(path)?;
106        String::from_utf8(bytes)
107            .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))
108    }
109
110    pub fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
111        if !self.within_root(path) {
112            return std::fs::write(path, contents);
113        }
114        let key = self.key(path);
115        let mut layer = self.layer.lock().expect("overlay layer poisoned");
116        layer.insert(key, OverlayEntry::File(contents.to_vec()));
117        Ok(())
118    }
119
120    pub fn append(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
121        if !self.within_root(path) {
122            return std::fs::OpenOptions::new()
123                .create(true)
124                .append(true)
125                .open(path)
126                .and_then(|mut file| std::io::Write::write_all(&mut file, contents));
127        }
128        let mut combined = match self.read(path) {
129            Ok(bytes) => bytes,
130            Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
131            Err(err) => return Err(err),
132        };
133        combined.extend_from_slice(contents);
134        self.write(path, &combined)
135    }
136
137    pub fn copy(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
138        let bytes = self.read(src)?;
139        let len = bytes.len() as u64;
140        self.write(dst, &bytes)?;
141        Ok(len)
142    }
143
144    pub fn rename(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
145        let len = self.copy(src, dst)?;
146        self.remove_file(src)?;
147        Ok(len)
148    }
149
150    pub fn exists(&self, path: &Path) -> bool {
151        if !self.within_root(path) {
152            return path.exists();
153        }
154        let key = self.key(path);
155        let layer = self.layer.lock().expect("overlay layer poisoned");
156        match layer.get(&key) {
157            Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => true,
158            Some(OverlayEntry::Deleted) => false,
159            None => path.exists(),
160        }
161    }
162
163    pub fn remove_file(&self, path: &Path) -> std::io::Result<()> {
164        if !self.within_root(path) {
165            return std::fs::remove_file(path);
166        }
167        let key = self.key(path);
168        let mut layer = self.layer.lock().expect("overlay layer poisoned");
169        // Remove regardless of whether it exists in the underlying tree;
170        // when the original is absent and the overlay had no entry, no-op.
171        let underlying_present = path.exists();
172        match layer.get(&key) {
173            Some(OverlayEntry::Deleted) => Err(std::io::Error::new(
174                std::io::ErrorKind::NotFound,
175                format!("overlay: {} already deleted", key.display()),
176            )),
177            _ => {
178                layer.retain(|entry_path, _| !entry_path.starts_with(&key) || entry_path == &key);
179                if underlying_present {
180                    layer.insert(key, OverlayEntry::Deleted);
181                } else {
182                    layer.remove(&key);
183                }
184                Ok(())
185            }
186        }
187    }
188
189    pub fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
190        if !self.within_root(path) {
191            return std::fs::create_dir_all(path);
192        }
193        let key = self.key(path);
194        let mut layer = self.layer.lock().expect("overlay layer poisoned");
195        if key == self.root {
196            layer.insert(key, OverlayEntry::Directory);
197            return Ok(());
198        }
199        let relative = key.strip_prefix(&self.root).map_err(|_| {
200            std::io::Error::new(
201                std::io::ErrorKind::InvalidInput,
202                format!(
203                    "overlay: {} is outside {}",
204                    key.display(),
205                    self.root.display()
206                ),
207            )
208        })?;
209        let mut current = self.root.clone();
210        for component in relative.components() {
211            current.push(component.as_os_str());
212            layer.insert(current.clone(), OverlayEntry::Directory);
213        }
214        Ok(())
215    }
216
217    pub fn read_dir(&self, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
218        if !self.within_root(path) {
219            let mut entries = Vec::new();
220            for entry in std::fs::read_dir(path)? {
221                let entry = entry?;
222                entries.push(OverlayDirEntry {
223                    path: entry.path(),
224                    is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
225                    is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
226                });
227            }
228            return Ok(entries);
229        }
230        let dir_key = self.key(path);
231        let virtual_dir_exists;
232        {
233            let layer = self.layer.lock().expect("overlay layer poisoned");
234            match layer.get(&dir_key) {
235                Some(OverlayEntry::Deleted) => {
236                    return Err(std::io::Error::new(
237                        std::io::ErrorKind::NotFound,
238                        format!("overlay: {} was deleted", dir_key.display()),
239                    ));
240                }
241                Some(OverlayEntry::File(_)) => {
242                    return Err(std::io::Error::new(
243                        std::io::ErrorKind::NotADirectory,
244                        format!("overlay: {} is a file", dir_key.display()),
245                    ));
246                }
247                Some(OverlayEntry::Directory) => {
248                    virtual_dir_exists = true;
249                }
250                None => {
251                    virtual_dir_exists = false;
252                }
253            }
254        }
255        let disk_dir_exists = path.exists();
256        let mut entries: BTreeMap<PathBuf, OverlayDirEntry> = BTreeMap::new();
257        if disk_dir_exists {
258            for entry in std::fs::read_dir(path)? {
259                let entry = entry?;
260                let p = entry.path();
261                entries.insert(
262                    p.clone(),
263                    OverlayDirEntry {
264                        path: p,
265                        is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
266                        is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
267                    },
268                );
269            }
270        }
271        let layer = self.layer.lock().expect("overlay layer poisoned");
272        for (key, entry) in layer.iter() {
273            if key.parent() != Some(dir_key.as_path()) {
274                continue;
275            }
276            match entry {
277                OverlayEntry::File(_) => {
278                    entries.insert(
279                        key.clone(),
280                        OverlayDirEntry {
281                            path: key.clone(),
282                            is_dir: false,
283                            is_file: true,
284                        },
285                    );
286                }
287                OverlayEntry::Directory => {
288                    entries.insert(
289                        key.clone(),
290                        OverlayDirEntry {
291                            path: key.clone(),
292                            is_dir: true,
293                            is_file: false,
294                        },
295                    );
296                }
297                OverlayEntry::Deleted => {
298                    entries.remove(key);
299                }
300            }
301        }
302        if entries.is_empty() && !disk_dir_exists && !virtual_dir_exists {
303            return Err(std::io::Error::new(
304                std::io::ErrorKind::NotFound,
305                format!("overlay: {} was not found", dir_key.display()),
306            ));
307        }
308        Ok(entries.into_values().collect())
309    }
310
311    /// Snapshot of every overlay change relative to the underlying tree.
312    pub fn diff(&self) -> Vec<DiffEntry> {
313        let layer = self.layer.lock().expect("overlay layer poisoned");
314        let mut diff = Vec::new();
315        for (path, entry) in layer.iter() {
316            match entry {
317                OverlayEntry::File(content) => {
318                    if path.exists() {
319                        let underlying = std::fs::read(path).unwrap_or_default();
320                        if &underlying != content {
321                            diff.push(DiffEntry {
322                                path: path.clone(),
323                                kind: DiffKind::Modified {
324                                    content: content.clone(),
325                                },
326                            });
327                        }
328                    } else {
329                        diff.push(DiffEntry {
330                            path: path.clone(),
331                            kind: DiffKind::Added {
332                                content: content.clone(),
333                            },
334                        });
335                    }
336                }
337                OverlayEntry::Deleted => {
338                    if path.exists() {
339                        diff.push(DiffEntry {
340                            path: path.clone(),
341                            kind: DiffKind::Deleted,
342                        });
343                    }
344                }
345                OverlayEntry::Directory => {}
346            }
347        }
348        diff
349    }
350
351    /// Render the overlay's diff in unified-style format. Convenience
352    /// wrapper around the standalone [`render_unified_diff`] that
353    /// snapshots the layer first.
354    pub fn render_unified_diff(&self) -> String {
355        render_unified_diff(&self.diff())
356    }
357}
358
359/// Render an overlay diff in unified-style format. Binary-safe but
360/// non-text bytes are escaped via `String::from_utf8_lossy`, so this
361/// is informational and not roundtrippable through `git apply` for
362/// non-utf8 files.
363pub fn render_unified_diff(diff: &[DiffEntry]) -> String {
364    let mut out = String::new();
365    for entry in diff {
366        match &entry.kind {
367            DiffKind::Added { content } => {
368                out.push_str(&format!("--- /dev/null\n+++ b/{}\n", entry.path.display()));
369                push_lines(&mut out, content, '+');
370            }
371            DiffKind::Modified { content } => {
372                let underlying = std::fs::read(&entry.path).unwrap_or_default();
373                out.push_str(&format!(
374                    "--- a/{}\n+++ b/{}\n",
375                    entry.path.display(),
376                    entry.path.display()
377                ));
378                push_lines(&mut out, &underlying, '-');
379                push_lines(&mut out, content, '+');
380            }
381            DiffKind::Deleted => {
382                let underlying = std::fs::read(&entry.path).unwrap_or_default();
383                out.push_str(&format!("--- a/{}\n+++ /dev/null\n", entry.path.display()));
384                push_lines(&mut out, &underlying, '-');
385            }
386        }
387    }
388    out
389}
390
391#[derive(Debug, Clone)]
392pub struct OverlayDirEntry {
393    pub path: PathBuf,
394    pub is_dir: bool,
395    pub is_file: bool,
396}
397
398fn push_lines(out: &mut String, bytes: &[u8], prefix: char) {
399    let text = String::from_utf8_lossy(bytes);
400    for line in text.split_inclusive('\n') {
401        out.push(prefix);
402        out.push_str(line);
403        if !line.ends_with('\n') {
404            out.push('\n');
405        }
406    }
407}
408
409/// Lexically normalize without resolving symlinks. Required because the
410/// overlay layer is a logical map keyed by absolute path, not a real
411/// filesystem; symlink chasing would be a security footgun.
412fn normalize_logical(path: &Path) -> PathBuf {
413    let absolute = if path.is_absolute() {
414        path.to_path_buf()
415    } else {
416        std::env::current_dir()
417            .map(|cwd| cwd.join(path))
418            .unwrap_or_else(|_| path.to_path_buf())
419    };
420    let mut out = PathBuf::new();
421    for component in absolute.components() {
422        match component {
423            Component::ParentDir => {
424                out.pop();
425            }
426            Component::CurDir => {}
427            other => out.push(other),
428        }
429    }
430    out
431}
432
433/// Make a path comparable to a canonicalized overlay root. If the file
434/// itself canonicalizes (it exists on disk), use that. Otherwise
435/// canonicalize the deepest existing ancestor and re-join the trailing
436/// non-existent components, so a not-yet-written file under a real
437/// directory still lands in the same key-space as the root.
438fn canonicalize_for_overlay(path: &Path) -> PathBuf {
439    let absolute = normalize_logical(path);
440    if let Ok(direct) = std::fs::canonicalize(&absolute) {
441        return direct;
442    }
443    let mut suffix = Vec::new();
444    let mut probe = absolute.clone();
445    loop {
446        if let Ok(canon) = std::fs::canonicalize(&probe) {
447            let mut joined = canon;
448            for component in suffix.iter().rev() {
449                joined.push(component);
450            }
451            return joined;
452        }
453        match probe.file_name().map(|n| n.to_owned()) {
454            Some(name) => {
455                suffix.push(name);
456                if !probe.pop() {
457                    break;
458                }
459            }
460            None => break,
461        }
462    }
463    absolute
464}
465
466thread_local! {
467    static ACTIVE_OVERLAY: RefCell<Option<Arc<OverlayFs>>> = const { RefCell::new(None) };
468}
469
470pub struct OverlayFsGuard {
471    previous: Option<Arc<OverlayFs>>,
472}
473
474impl Drop for OverlayFsGuard {
475    fn drop(&mut self) {
476        let prev = self.previous.take();
477        ACTIVE_OVERLAY.with(|slot| {
478            *slot.borrow_mut() = prev;
479        });
480    }
481}
482
483pub fn install_overlay(overlay: Arc<OverlayFs>) -> OverlayFsGuard {
484    let previous = ACTIVE_OVERLAY.with(|slot| slot.replace(Some(overlay)));
485    OverlayFsGuard { previous }
486}
487
488pub fn active_overlay() -> Option<Arc<OverlayFs>> {
489    ACTIVE_OVERLAY.with(|slot| slot.borrow().clone())
490}
491
492/// Helpers for fs builtins. Each helper falls through to `std::fs` when
493/// no overlay is active, keeping the testbench opt-in.
494///
495/// Every successful read/write/delete also pushes a [`TapeRecordKind`]
496/// into the active unified-tape recorder when one is installed, so the
497/// fidelity oracle can compare FS effects across runs even when the
498/// per-axis overlay diff is identical (the order in which writes land
499/// also matters for replay determinism).
500pub mod helpers {
501    use super::*;
502
503    fn record_file_read(path: &Path, bytes: &[u8]) {
504        // Skip the hash + path stringification when no recorder is
505        // installed — the fast path is the production path.
506        if tape::active_recorder().is_none() {
507            return;
508        }
509        let path_str = path.to_string_lossy().into_owned();
510        let len = bytes.len() as u64;
511        let hash = tape::content_hash(bytes);
512        tape::with_active_recorder(|_recorder| {
513            Some(TapeRecordKind::FileRead {
514                path: path_str,
515                content_hash: hash,
516                len_bytes: len,
517            })
518        });
519    }
520
521    fn record_file_write(path: &Path, bytes: &[u8]) {
522        if tape::active_recorder().is_none() {
523            return;
524        }
525        let path_str = path.to_string_lossy().into_owned();
526        let len = bytes.len() as u64;
527        let hash = tape::content_hash(bytes);
528        tape::with_active_recorder(|_recorder| {
529            Some(TapeRecordKind::FileWrite {
530                path: path_str,
531                content_hash: hash,
532                len_bytes: len,
533            })
534        });
535    }
536
537    fn record_file_delete(path: &Path) {
538        if tape::active_recorder().is_none() {
539            return;
540        }
541        let path_str = path.to_string_lossy().into_owned();
542        tape::with_active_recorder(|_recorder| Some(TapeRecordKind::FileDelete { path: path_str }));
543    }
544
545    pub fn read(path: &Path) -> std::io::Result<Vec<u8>> {
546        let result = match active_overlay() {
547            Some(overlay) => overlay.read(path),
548            None => std::fs::read(path),
549        };
550        if let Ok(bytes) = result.as_ref() {
551            record_file_read(path, bytes);
552        }
553        result
554    }
555
556    pub fn read_to_string(path: &Path) -> std::io::Result<String> {
557        let result = match active_overlay() {
558            Some(overlay) => overlay.read_to_string(path),
559            None => std::fs::read_to_string(path),
560        };
561        if let Ok(text) = result.as_ref() {
562            record_file_read(path, text.as_bytes());
563        }
564        result
565    }
566
567    pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
568        let result = match active_overlay() {
569            Some(overlay) => overlay.write(path, contents),
570            None => std::fs::write(path, contents),
571        };
572        if result.is_ok() {
573            record_file_write(path, contents);
574        }
575        result
576    }
577
578    pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
579        let result = match active_overlay() {
580            Some(overlay) => overlay.append(path, contents),
581            None => std::fs::OpenOptions::new()
582                .create(true)
583                .append(true)
584                .open(path)
585                .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
586        };
587        if result.is_ok() {
588            record_file_write(path, contents);
589        }
590        result
591    }
592
593    pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
594        match active_overlay() {
595            Some(overlay) => {
596                let result = overlay.copy(src, dst);
597                if let Ok(bytes) = overlay.read(src) {
598                    record_file_read(src, &bytes);
599                    if result.is_ok() {
600                        record_file_write(dst, &bytes);
601                    }
602                }
603                result
604            }
605            None => {
606                let copied = std::fs::copy(src, dst)?;
607                if tape::active_recorder().is_some() {
608                    let bytes = std::fs::read(dst)?;
609                    record_file_read(src, &bytes);
610                    record_file_write(dst, &bytes);
611                }
612                Ok(copied)
613            }
614        }
615    }
616
617    pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
618        match active_overlay() {
619            Some(overlay) => {
620                let bytes_for_record = overlay.read(src).ok();
621                let result = overlay.rename(src, dst);
622                if result.is_ok() {
623                    if let Some(bytes) = bytes_for_record.as_deref() {
624                        record_file_read(src, bytes);
625                        record_file_write(dst, bytes);
626                        record_file_delete(src);
627                    }
628                }
629                result
630            }
631            None => {
632                let bytes = tape::active_recorder()
633                    .is_some()
634                    .then(|| std::fs::read(src))
635                    .transpose()?;
636                let len = bytes
637                    .as_ref()
638                    .map(|bytes| bytes.len() as u64)
639                    .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
640                    .unwrap_or(0);
641                std::fs::rename(src, dst)?;
642                if let Some(bytes) = bytes.as_deref() {
643                    record_file_read(src, bytes);
644                    record_file_write(dst, bytes);
645                    record_file_delete(src);
646                }
647                Ok(len)
648            }
649        }
650    }
651
652    pub fn exists(path: &Path) -> bool {
653        match active_overlay() {
654            Some(overlay) => overlay.exists(path),
655            None => path.exists(),
656        }
657    }
658
659    pub fn remove_file(path: &Path) -> std::io::Result<()> {
660        let result = match active_overlay() {
661            Some(overlay) => overlay.remove_file(path),
662            None => std::fs::remove_file(path),
663        };
664        if result.is_ok() {
665            record_file_delete(path);
666        }
667        result
668    }
669
670    pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
671        match active_overlay() {
672            Some(overlay) => overlay.create_dir_all(path),
673            None => std::fs::create_dir_all(path),
674        }
675    }
676
677    pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
678        match active_overlay() {
679            Some(overlay) => overlay.read_dir(path),
680            None => {
681                let mut entries = Vec::new();
682                for entry in std::fs::read_dir(path)? {
683                    let entry = entry?;
684                    let file_type = entry.file_type()?;
685                    entries.push(OverlayDirEntry {
686                        path: entry.path(),
687                        is_dir: file_type.is_dir(),
688                        is_file: file_type.is_file(),
689                    });
690                }
691                Ok(entries)
692            }
693        }
694    }
695}
696
697#[cfg(test)]
698mod tests {
699    use super::*;
700
701    #[test]
702    fn writes_land_in_overlay_only() {
703        let dir = tempfile::tempdir().unwrap();
704        let overlay = OverlayFs::rooted_at(dir.path());
705        overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
706        // Real disk untouched.
707        assert!(!dir.path().join("hello.txt").exists());
708        // Overlay reports it back.
709        assert_eq!(
710            overlay
711                .read_to_string(&dir.path().join("hello.txt"))
712                .unwrap(),
713            "hi"
714        );
715    }
716
717    #[test]
718    fn reads_pass_through_to_underlying_tree() {
719        let dir = tempfile::tempdir().unwrap();
720        std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
721        let overlay = OverlayFs::rooted_at(dir.path());
722        assert_eq!(
723            overlay
724                .read_to_string(&dir.path().join("seed.txt"))
725                .unwrap(),
726            "underlying"
727        );
728    }
729
730    #[test]
731    fn delete_masks_underlying_file() {
732        let dir = tempfile::tempdir().unwrap();
733        std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
734        let overlay = OverlayFs::rooted_at(dir.path());
735        overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
736        assert!(!overlay.exists(&dir.path().join("doomed.txt")));
737        // Real disk untouched.
738        assert!(dir.path().join("doomed.txt").exists());
739        let diff = overlay.diff();
740        assert_eq!(diff.len(), 1);
741        assert!(matches!(diff[0].kind, DiffKind::Deleted));
742    }
743
744    #[test]
745    fn delete_masks_underlying_directory_contents() {
746        let dir = tempfile::tempdir().unwrap();
747        let nested = dir.path().join("doomed");
748        std::fs::create_dir_all(&nested).unwrap();
749        std::fs::write(nested.join("secret.txt"), "x").unwrap();
750        let overlay = OverlayFs::rooted_at(dir.path());
751
752        overlay.remove_file(&nested).unwrap();
753
754        assert!(!overlay.exists(&nested));
755        assert_eq!(
756            overlay.read_dir(&nested).unwrap_err().kind(),
757            std::io::ErrorKind::NotFound
758        );
759        assert!(nested.join("secret.txt").exists());
760    }
761
762    #[test]
763    fn recursive_mkdir_creates_visible_overlay_ancestors() {
764        let dir = tempfile::tempdir().unwrap();
765        let overlay = OverlayFs::rooted_at(dir.path());
766        overlay
767            .create_dir_all(&dir.path().join("alpha/beta/gamma"))
768            .unwrap();
769
770        let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
771        assert_eq!(root_entries.len(), 1);
772        assert_eq!(
773            root_entries[0]
774                .path
775                .file_name()
776                .and_then(|name| name.to_str()),
777            Some("beta")
778        );
779        assert!(root_entries[0].is_dir);
780    }
781
782    #[test]
783    fn read_dir_reports_missing_empty_overlay_path() {
784        let dir = tempfile::tempdir().unwrap();
785        let overlay = OverlayFs::rooted_at(dir.path());
786
787        assert_eq!(
788            overlay
789                .read_dir(&dir.path().join("missing"))
790                .unwrap_err()
791                .kind(),
792            std::io::ErrorKind::NotFound
793        );
794    }
795
796    #[test]
797    fn diff_distinguishes_added_vs_modified() {
798        let dir = tempfile::tempdir().unwrap();
799        std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
800        let overlay = OverlayFs::rooted_at(dir.path());
801        overlay
802            .write(&dir.path().join("existing.txt"), b"v2")
803            .unwrap();
804        overlay
805            .write(&dir.path().join("brand-new.txt"), b"hi")
806            .unwrap();
807        let mut diff = overlay.diff();
808        diff.sort_by(|a, b| a.path.cmp(&b.path));
809        assert_eq!(diff.len(), 2);
810        assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
811        assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
812    }
813}