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 => atomic_write(path, contents),
571        };
572        if result.is_ok() {
573            record_file_write(path, contents);
574        }
575        result
576    }
577
578    /// Crash-safe replacement for `std::fs::write`.
579    ///
580    /// `std::fs::write` opens the destination with `O_CREAT|O_TRUNC`, so it
581    /// truncates an existing file to zero length *before* any byte is
582    /// written. Any failure between that truncation and the completion of
583    /// `write_all` (ENOSPC/EDQUOT, a failing/network fs returning EIO, or the
584    /// process being killed mid-write) leaves the original content destroyed
585    /// and unrecoverable, while the caller assumes the prior content survived.
586    ///
587    /// Instead we write the full contents into a sibling temp file, flush it,
588    /// and atomically `rename` it over the destination. On POSIX `rename` is
589    /// atomic and never leaves a half-written destination; if anything fails
590    /// before the rename, the original file is untouched. The temp file is
591    /// created in the destination's own directory so the rename stays within a
592    /// single filesystem (a cross-device rename would fail with EXDEV).
593    fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
594        use std::io::Write;
595
596        let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
597        let dir = parent.unwrap_or_else(|| Path::new("."));
598
599        // Unique, hidden sibling temp name. Including the pid and an atomic
600        // counter keeps concurrent writers from colliding on the same temp
601        // path.
602        let counter = {
603            use std::sync::atomic::{AtomicU64, Ordering};
604            static COUNTER: AtomicU64 = AtomicU64::new(0);
605            COUNTER.fetch_add(1, Ordering::Relaxed)
606        };
607        let file_name = path
608            .file_name()
609            .map(|n| n.to_string_lossy().into_owned())
610            .unwrap_or_default();
611        let tmp_name = format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id());
612        let tmp_path = dir.join(tmp_name);
613
614        // Write the full contents to the temp file, then fsync so the bytes
615        // are durable before we swap it into place.
616        let write_result = (|| -> std::io::Result<()> {
617            let mut file = std::fs::File::create(&tmp_path)?;
618            file.write_all(contents)?;
619            file.flush()?;
620            file.sync_all()?;
621            Ok(())
622        })();
623        if let Err(err) = write_result {
624            // Best-effort cleanup; the destination was never touched.
625            let _ = std::fs::remove_file(&tmp_path);
626            return Err(err);
627        }
628
629        // Atomically replace the destination. On failure, clean up the temp
630        // file and leave the original intact.
631        if let Err(err) = std::fs::rename(&tmp_path, path) {
632            let _ = std::fs::remove_file(&tmp_path);
633            return Err(err);
634        }
635        Ok(())
636    }
637
638    pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
639        let result = match active_overlay() {
640            Some(overlay) => overlay.append(path, contents),
641            None => std::fs::OpenOptions::new()
642                .create(true)
643                .append(true)
644                .open(path)
645                .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
646        };
647        if result.is_ok() {
648            record_file_write(path, contents);
649        }
650        result
651    }
652
653    pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
654        match active_overlay() {
655            Some(overlay) => {
656                let result = overlay.copy(src, dst);
657                if let Ok(bytes) = overlay.read(src) {
658                    record_file_read(src, &bytes);
659                    if result.is_ok() {
660                        record_file_write(dst, &bytes);
661                    }
662                }
663                result
664            }
665            None => {
666                let copied = std::fs::copy(src, dst)?;
667                if tape::active_recorder().is_some() {
668                    let bytes = std::fs::read(dst)?;
669                    record_file_read(src, &bytes);
670                    record_file_write(dst, &bytes);
671                }
672                Ok(copied)
673            }
674        }
675    }
676
677    pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
678        match active_overlay() {
679            Some(overlay) => {
680                let bytes_for_record = overlay.read(src).ok();
681                let result = overlay.rename(src, dst);
682                if result.is_ok() {
683                    if let Some(bytes) = bytes_for_record.as_deref() {
684                        record_file_read(src, bytes);
685                        record_file_write(dst, bytes);
686                        record_file_delete(src);
687                    }
688                }
689                result
690            }
691            None => {
692                let bytes = tape::active_recorder()
693                    .is_some()
694                    .then(|| std::fs::read(src))
695                    .transpose()?;
696                let len = bytes
697                    .as_ref()
698                    .map(|bytes| bytes.len() as u64)
699                    .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
700                    .unwrap_or(0);
701                std::fs::rename(src, dst)?;
702                if let Some(bytes) = bytes.as_deref() {
703                    record_file_read(src, bytes);
704                    record_file_write(dst, bytes);
705                    record_file_delete(src);
706                }
707                Ok(len)
708            }
709        }
710    }
711
712    pub fn exists(path: &Path) -> bool {
713        match active_overlay() {
714            Some(overlay) => overlay.exists(path),
715            None => path.exists(),
716        }
717    }
718
719    pub fn remove_file(path: &Path) -> std::io::Result<()> {
720        let result = match active_overlay() {
721            Some(overlay) => overlay.remove_file(path),
722            None => std::fs::remove_file(path),
723        };
724        if result.is_ok() {
725            record_file_delete(path);
726        }
727        result
728    }
729
730    pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
731        match active_overlay() {
732            Some(overlay) => overlay.create_dir_all(path),
733            None => std::fs::create_dir_all(path),
734        }
735    }
736
737    pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
738        match active_overlay() {
739            Some(overlay) => overlay.read_dir(path),
740            None => {
741                let mut entries = Vec::new();
742                for entry in std::fs::read_dir(path)? {
743                    let entry = entry?;
744                    let file_type = entry.file_type()?;
745                    entries.push(OverlayDirEntry {
746                        path: entry.path(),
747                        is_dir: file_type.is_dir(),
748                        is_file: file_type.is_file(),
749                    });
750                }
751                Ok(entries)
752            }
753        }
754    }
755}
756
757#[cfg(test)]
758mod tests {
759    use super::*;
760
761    #[test]
762    fn writes_land_in_overlay_only() {
763        let dir = tempfile::tempdir().unwrap();
764        let overlay = OverlayFs::rooted_at(dir.path());
765        overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
766        // Real disk untouched.
767        assert!(!dir.path().join("hello.txt").exists());
768        // Overlay reports it back.
769        assert_eq!(
770            overlay
771                .read_to_string(&dir.path().join("hello.txt"))
772                .unwrap(),
773            "hi"
774        );
775    }
776
777    #[test]
778    fn reads_pass_through_to_underlying_tree() {
779        let dir = tempfile::tempdir().unwrap();
780        std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
781        let overlay = OverlayFs::rooted_at(dir.path());
782        assert_eq!(
783            overlay
784                .read_to_string(&dir.path().join("seed.txt"))
785                .unwrap(),
786            "underlying"
787        );
788    }
789
790    #[test]
791    fn delete_masks_underlying_file() {
792        let dir = tempfile::tempdir().unwrap();
793        std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
794        let overlay = OverlayFs::rooted_at(dir.path());
795        overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
796        assert!(!overlay.exists(&dir.path().join("doomed.txt")));
797        // Real disk untouched.
798        assert!(dir.path().join("doomed.txt").exists());
799        let diff = overlay.diff();
800        assert_eq!(diff.len(), 1);
801        assert!(matches!(diff[0].kind, DiffKind::Deleted));
802    }
803
804    #[test]
805    fn delete_masks_underlying_directory_contents() {
806        let dir = tempfile::tempdir().unwrap();
807        let nested = dir.path().join("doomed");
808        std::fs::create_dir_all(&nested).unwrap();
809        std::fs::write(nested.join("secret.txt"), "x").unwrap();
810        let overlay = OverlayFs::rooted_at(dir.path());
811
812        overlay.remove_file(&nested).unwrap();
813
814        assert!(!overlay.exists(&nested));
815        assert_eq!(
816            overlay.read_dir(&nested).unwrap_err().kind(),
817            std::io::ErrorKind::NotFound
818        );
819        assert!(nested.join("secret.txt").exists());
820    }
821
822    #[test]
823    fn recursive_mkdir_creates_visible_overlay_ancestors() {
824        let dir = tempfile::tempdir().unwrap();
825        let overlay = OverlayFs::rooted_at(dir.path());
826        overlay
827            .create_dir_all(&dir.path().join("alpha/beta/gamma"))
828            .unwrap();
829
830        let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
831        assert_eq!(root_entries.len(), 1);
832        assert_eq!(
833            root_entries[0]
834                .path
835                .file_name()
836                .and_then(|name| name.to_str()),
837            Some("beta")
838        );
839        assert!(root_entries[0].is_dir);
840    }
841
842    #[test]
843    fn read_dir_reports_missing_empty_overlay_path() {
844        let dir = tempfile::tempdir().unwrap();
845        let overlay = OverlayFs::rooted_at(dir.path());
846
847        assert_eq!(
848            overlay
849                .read_dir(&dir.path().join("missing"))
850                .unwrap_err()
851                .kind(),
852            std::io::ErrorKind::NotFound
853        );
854    }
855
856    /// Regression: the live (no-overlay) write path must be crash-safe. A
857    /// successful overwrite replaces the content and leaves no temp files.
858    #[test]
859    fn no_overlay_write_replaces_content() {
860        let dir = tempfile::tempdir().unwrap();
861        let target = dir.path().join("important.txt");
862        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
863        assert!(active_overlay().is_none(), "no overlay should be installed");
864
865        helpers::write(&target, b"NEW CONTENT").unwrap();
866
867        assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW CONTENT");
868        // No leftover temp files in the directory.
869        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
870            .unwrap()
871            .filter_map(|e| e.ok())
872            .map(|e| e.file_name().to_string_lossy().into_owned())
873            .filter(|n| n.contains("harn-tmp"))
874            .collect();
875        assert!(
876            leftovers.is_empty(),
877            "temp files left behind: {leftovers:?}"
878        );
879    }
880
881    /// Regression for the non-atomic primary write path: a write that cannot
882    /// be completed must leave the original file completely intact rather than
883    /// truncating it.
884    ///
885    /// The trigger here is a read-only containing directory. The atomic path
886    /// writes through a sibling temp file, so it cannot even start (temp
887    /// `File::create` is denied) and the original survives untouched. The old
888    /// `std::fs::write` path instead reopens the *existing* destination with
889    /// `O_CREAT|O_TRUNC` — which needs no directory write permission — so it
890    /// truncates and overwrites the original before any failure could protect
891    /// it. The load-bearing assertion is therefore that the original content
892    /// is preserved; under the buggy path it would read back as "NEW CONTENT".
893    #[cfg(unix)]
894    #[test]
895    fn no_overlay_write_failure_preserves_original() {
896        use std::os::unix::fs::PermissionsExt;
897
898        let dir = tempfile::tempdir().unwrap();
899        let target = dir.path().join("important.txt");
900        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
901
902        // Read+exec but not writable: a new sibling temp file cannot be
903        // created, but the existing destination file is still openable.
904        let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
905        perms.set_mode(0o500);
906        std::fs::set_permissions(dir.path(), perms).unwrap();
907
908        let result = helpers::write(&target, b"NEW CONTENT");
909
910        // Restore write perms before asserting so tempdir drop/cleanup works.
911        let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
912        restore.set_mode(0o700);
913        std::fs::set_permissions(dir.path(), restore).unwrap();
914
915        // Load-bearing invariant: the original content must survive.
916        assert_eq!(
917            std::fs::read_to_string(&target).unwrap(),
918            "ORIGINAL IMPORTANT CONTENT",
919            "a write that cannot complete must not truncate or corrupt the original file"
920        );
921        // The atomic path also surfaces the failure rather than reporting a
922        // false success.
923        assert!(
924            result.is_err(),
925            "atomic write should report failure when it cannot create its temp file"
926        );
927    }
928
929    #[test]
930    fn diff_distinguishes_added_vs_modified() {
931        let dir = tempfile::tempdir().unwrap();
932        std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
933        let overlay = OverlayFs::rooted_at(dir.path());
934        overlay
935            .write(&dir.path().join("existing.txt"), b"v2")
936            .unwrap();
937        overlay
938            .write(&dir.path().join("brand-new.txt"), b"hi")
939            .unwrap();
940        let mut diff = overlay.diff();
941        diff.sort_by(|a, b| a.path.cmp(&b.path));
942        assert_eq!(diff.len(), 2);
943        assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
944        assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
945    }
946}