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(&self, path: &Path) -> std::io::Result<()> {
190        if !self.within_root(path) {
191            return std::fs::create_dir(path);
192        }
193        let key = self.key(path);
194        let mut layer = self.layer.lock().expect("overlay layer poisoned");
195        match layer.get(&key) {
196            Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => {
197                return Err(std::io::Error::new(
198                    std::io::ErrorKind::AlreadyExists,
199                    format!("overlay: {} already exists", key.display()),
200                ));
201            }
202            Some(OverlayEntry::Deleted) | None => {}
203        }
204        if !matches!(layer.get(&key), Some(OverlayEntry::Deleted)) && path.exists() {
205            return Err(std::io::Error::new(
206                std::io::ErrorKind::AlreadyExists,
207                format!("overlay: {} already exists", key.display()),
208            ));
209        }
210        let parent = key.parent().ok_or_else(|| {
211            std::io::Error::new(
212                std::io::ErrorKind::NotFound,
213                format!("overlay: {} has no parent", key.display()),
214            )
215        })?;
216        match layer.get(parent) {
217            Some(OverlayEntry::Directory) => {}
218            Some(OverlayEntry::File(_)) => {
219                return Err(std::io::Error::new(
220                    std::io::ErrorKind::NotADirectory,
221                    format!("overlay: {} parent is a file", key.display()),
222                ));
223            }
224            Some(OverlayEntry::Deleted) => {
225                return Err(std::io::Error::new(
226                    std::io::ErrorKind::NotFound,
227                    format!("overlay: {} parent was deleted", key.display()),
228                ));
229            }
230            None if parent.is_dir() => {}
231            None => {
232                return Err(std::io::Error::new(
233                    std::io::ErrorKind::NotFound,
234                    format!("overlay: {} parent does not exist", key.display()),
235                ));
236            }
237        }
238        layer.insert(key, OverlayEntry::Directory);
239        Ok(())
240    }
241
242    pub fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
243        if !self.within_root(path) {
244            return std::fs::create_dir_all(path);
245        }
246        let key = self.key(path);
247        let mut layer = self.layer.lock().expect("overlay layer poisoned");
248        if key == self.root {
249            layer.insert(key, OverlayEntry::Directory);
250            return Ok(());
251        }
252        let relative = key.strip_prefix(&self.root).map_err(|_| {
253            std::io::Error::new(
254                std::io::ErrorKind::InvalidInput,
255                format!(
256                    "overlay: {} is outside {}",
257                    key.display(),
258                    self.root.display()
259                ),
260            )
261        })?;
262        let mut current = self.root.clone();
263        for component in relative.components() {
264            current.push(component.as_os_str());
265            layer.insert(current.clone(), OverlayEntry::Directory);
266        }
267        Ok(())
268    }
269
270    pub fn read_dir(&self, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
271        if !self.within_root(path) {
272            let mut entries = Vec::new();
273            for entry in std::fs::read_dir(path)? {
274                let entry = entry?;
275                entries.push(OverlayDirEntry {
276                    path: entry.path(),
277                    is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
278                    is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
279                });
280            }
281            return Ok(entries);
282        }
283        let dir_key = self.key(path);
284        let virtual_dir_exists;
285        {
286            let layer = self.layer.lock().expect("overlay layer poisoned");
287            match layer.get(&dir_key) {
288                Some(OverlayEntry::Deleted) => {
289                    return Err(std::io::Error::new(
290                        std::io::ErrorKind::NotFound,
291                        format!("overlay: {} was deleted", dir_key.display()),
292                    ));
293                }
294                Some(OverlayEntry::File(_)) => {
295                    return Err(std::io::Error::new(
296                        std::io::ErrorKind::NotADirectory,
297                        format!("overlay: {} is a file", dir_key.display()),
298                    ));
299                }
300                Some(OverlayEntry::Directory) => {
301                    virtual_dir_exists = true;
302                }
303                None => {
304                    virtual_dir_exists = false;
305                }
306            }
307        }
308        let disk_dir_exists = path.exists();
309        let mut entries: BTreeMap<PathBuf, OverlayDirEntry> = BTreeMap::new();
310        if disk_dir_exists {
311            for entry in std::fs::read_dir(path)? {
312                let entry = entry?;
313                let p = entry.path();
314                entries.insert(
315                    p.clone(),
316                    OverlayDirEntry {
317                        path: p,
318                        is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
319                        is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
320                    },
321                );
322            }
323        }
324        let layer = self.layer.lock().expect("overlay layer poisoned");
325        for (key, entry) in layer.iter() {
326            if key.parent() != Some(dir_key.as_path()) {
327                continue;
328            }
329            match entry {
330                OverlayEntry::File(_) => {
331                    entries.insert(
332                        key.clone(),
333                        OverlayDirEntry {
334                            path: key.clone(),
335                            is_dir: false,
336                            is_file: true,
337                        },
338                    );
339                }
340                OverlayEntry::Directory => {
341                    entries.insert(
342                        key.clone(),
343                        OverlayDirEntry {
344                            path: key.clone(),
345                            is_dir: true,
346                            is_file: false,
347                        },
348                    );
349                }
350                OverlayEntry::Deleted => {
351                    entries.remove(key);
352                }
353            }
354        }
355        if entries.is_empty() && !disk_dir_exists && !virtual_dir_exists {
356            return Err(std::io::Error::new(
357                std::io::ErrorKind::NotFound,
358                format!("overlay: {} was not found", dir_key.display()),
359            ));
360        }
361        Ok(entries.into_values().collect())
362    }
363
364    /// Snapshot of every overlay change relative to the underlying tree.
365    pub fn diff(&self) -> Vec<DiffEntry> {
366        let layer = self.layer.lock().expect("overlay layer poisoned");
367        let mut diff = Vec::new();
368        for (path, entry) in layer.iter() {
369            match entry {
370                OverlayEntry::File(content) => {
371                    if path.exists() {
372                        let underlying = std::fs::read(path).unwrap_or_default();
373                        if &underlying != content {
374                            diff.push(DiffEntry {
375                                path: path.clone(),
376                                kind: DiffKind::Modified {
377                                    content: content.clone(),
378                                },
379                            });
380                        }
381                    } else {
382                        diff.push(DiffEntry {
383                            path: path.clone(),
384                            kind: DiffKind::Added {
385                                content: content.clone(),
386                            },
387                        });
388                    }
389                }
390                OverlayEntry::Deleted => {
391                    if path.exists() {
392                        diff.push(DiffEntry {
393                            path: path.clone(),
394                            kind: DiffKind::Deleted,
395                        });
396                    }
397                }
398                OverlayEntry::Directory => {}
399            }
400        }
401        diff
402    }
403
404    /// Render the overlay's diff in unified-style format. Convenience
405    /// wrapper around the standalone [`render_unified_diff`] that
406    /// snapshots the layer first.
407    pub fn render_unified_diff(&self) -> String {
408        render_unified_diff(&self.diff())
409    }
410}
411
412/// Render an overlay diff in unified-style format. Binary-safe but
413/// non-text bytes are escaped via `String::from_utf8_lossy`, so this
414/// is informational and not roundtrippable through `git apply` for
415/// non-utf8 files.
416pub fn render_unified_diff(diff: &[DiffEntry]) -> String {
417    let mut out = String::new();
418    for entry in diff {
419        match &entry.kind {
420            DiffKind::Added { content } => {
421                out.push_str(&format!("--- /dev/null\n+++ b/{}\n", entry.path.display()));
422                push_lines(&mut out, content, '+');
423            }
424            DiffKind::Modified { content } => {
425                let underlying = std::fs::read(&entry.path).unwrap_or_default();
426                out.push_str(&format!(
427                    "--- a/{}\n+++ b/{}\n",
428                    entry.path.display(),
429                    entry.path.display()
430                ));
431                push_lines(&mut out, &underlying, '-');
432                push_lines(&mut out, content, '+');
433            }
434            DiffKind::Deleted => {
435                let underlying = std::fs::read(&entry.path).unwrap_or_default();
436                out.push_str(&format!("--- a/{}\n+++ /dev/null\n", entry.path.display()));
437                push_lines(&mut out, &underlying, '-');
438            }
439        }
440    }
441    out
442}
443
444#[derive(Debug, Clone)]
445pub struct OverlayDirEntry {
446    pub path: PathBuf,
447    pub is_dir: bool,
448    pub is_file: bool,
449}
450
451fn push_lines(out: &mut String, bytes: &[u8], prefix: char) {
452    let text = String::from_utf8_lossy(bytes);
453    for line in text.split_inclusive('\n') {
454        out.push(prefix);
455        out.push_str(line);
456        if !line.ends_with('\n') {
457            out.push('\n');
458        }
459    }
460}
461
462/// Lexically normalize without resolving symlinks. Required because the
463/// overlay layer is a logical map keyed by absolute path, not a real
464/// filesystem; symlink chasing would be a security footgun.
465fn normalize_logical(path: &Path) -> PathBuf {
466    let absolute = if path.is_absolute() {
467        path.to_path_buf()
468    } else {
469        std::env::current_dir()
470            .map(|cwd| cwd.join(path))
471            .unwrap_or_else(|_| path.to_path_buf())
472    };
473    let mut out = PathBuf::new();
474    for component in absolute.components() {
475        match component {
476            Component::ParentDir => {
477                out.pop();
478            }
479            Component::CurDir => {}
480            other => out.push(other),
481        }
482    }
483    out
484}
485
486/// Make a path comparable to a canonicalized overlay root. If the file
487/// itself canonicalizes (it exists on disk), use that. Otherwise
488/// canonicalize the deepest existing ancestor and re-join the trailing
489/// non-existent components, so a not-yet-written file under a real
490/// directory still lands in the same key-space as the root.
491fn canonicalize_for_overlay(path: &Path) -> PathBuf {
492    let absolute = normalize_logical(path);
493    if let Ok(direct) = std::fs::canonicalize(&absolute) {
494        return direct;
495    }
496    let mut suffix = Vec::new();
497    let mut probe = absolute.clone();
498    loop {
499        if let Ok(canon) = std::fs::canonicalize(&probe) {
500            let mut joined = canon;
501            for component in suffix.iter().rev() {
502                joined.push(component);
503            }
504            return joined;
505        }
506        match probe.file_name().map(|n| n.to_owned()) {
507            Some(name) => {
508                suffix.push(name);
509                if !probe.pop() {
510                    break;
511                }
512            }
513            None => break,
514        }
515    }
516    absolute
517}
518
519thread_local! {
520    static ACTIVE_OVERLAY: RefCell<Option<Arc<OverlayFs>>> = const { RefCell::new(None) };
521}
522
523pub struct OverlayFsGuard {
524    previous: Option<Arc<OverlayFs>>,
525}
526
527impl Drop for OverlayFsGuard {
528    fn drop(&mut self) {
529        let prev = self.previous.take();
530        ACTIVE_OVERLAY.with(|slot| {
531            *slot.borrow_mut() = prev;
532        });
533    }
534}
535
536pub fn install_overlay(overlay: Arc<OverlayFs>) -> OverlayFsGuard {
537    let previous = ACTIVE_OVERLAY.with(|slot| slot.replace(Some(overlay)));
538    OverlayFsGuard { previous }
539}
540
541pub fn active_overlay() -> Option<Arc<OverlayFs>> {
542    ACTIVE_OVERLAY.with(|slot| slot.borrow().clone())
543}
544
545/// Helpers for fs builtins. Each helper falls through to `std::fs` when
546/// no overlay is active, keeping the testbench opt-in.
547///
548/// Every successful read/write/delete also pushes a [`TapeRecordKind`]
549/// into the active unified-tape recorder when one is installed, so the
550/// fidelity oracle can compare FS effects across runs even when the
551/// per-axis overlay diff is identical (the order in which writes land
552/// also matters for replay determinism).
553pub mod helpers {
554    use super::*;
555
556    fn record_file_read(path: &Path, bytes: &[u8]) {
557        // Skip the hash + path stringification when no recorder is
558        // installed — the fast path is the production path.
559        if tape::active_recorder().is_none() {
560            return;
561        }
562        let path_str = path.to_string_lossy().into_owned();
563        let len = bytes.len() as u64;
564        let hash = tape::content_hash(bytes);
565        tape::with_active_recorder(|_recorder| {
566            Some(TapeRecordKind::FileRead {
567                path: path_str,
568                content_hash: hash,
569                len_bytes: len,
570            })
571        });
572    }
573
574    fn record_file_write(path: &Path, bytes: &[u8]) {
575        if tape::active_recorder().is_none() {
576            return;
577        }
578        let path_str = path.to_string_lossy().into_owned();
579        let len = bytes.len() as u64;
580        let hash = tape::content_hash(bytes);
581        tape::with_active_recorder(|_recorder| {
582            Some(TapeRecordKind::FileWrite {
583                path: path_str,
584                content_hash: hash,
585                len_bytes: len,
586            })
587        });
588    }
589
590    fn record_file_delete(path: &Path) {
591        if tape::active_recorder().is_none() {
592            return;
593        }
594        let path_str = path.to_string_lossy().into_owned();
595        tape::with_active_recorder(|_recorder| Some(TapeRecordKind::FileDelete { path: path_str }));
596    }
597
598    pub fn read(path: &Path) -> std::io::Result<Vec<u8>> {
599        let result = match active_overlay() {
600            Some(overlay) => overlay.read(path),
601            None => std::fs::read(path),
602        };
603        if let Ok(bytes) = result.as_ref() {
604            record_file_read(path, bytes);
605        }
606        result
607    }
608
609    pub fn read_to_string(path: &Path) -> std::io::Result<String> {
610        let result = match active_overlay() {
611            Some(overlay) => overlay.read_to_string(path),
612            None => std::fs::read_to_string(path),
613        };
614        if let Ok(text) = result.as_ref() {
615            record_file_read(path, text.as_bytes());
616        }
617        result
618    }
619
620    pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
621        let result = match active_overlay() {
622            Some(overlay) => overlay.write(path, contents),
623            None => atomic_write(path, contents),
624        };
625        if result.is_ok() {
626            record_file_write(path, contents);
627        }
628        result
629    }
630
631    /// Crash-safe replacement for `std::fs::write`.
632    ///
633    /// `std::fs::write` opens the destination with `O_CREAT|O_TRUNC`, so it
634    /// truncates an existing file to zero length *before* any byte is
635    /// written. Any failure between that truncation and the completion of
636    /// `write_all` (ENOSPC/EDQUOT, a failing/network fs returning EIO, or the
637    /// process being killed mid-write) leaves the original content destroyed
638    /// and unrecoverable, while the caller assumes the prior content survived.
639    ///
640    /// Instead we write the full contents into a sibling temp file, flush it,
641    /// and atomically `rename` it over the destination. On POSIX `rename` is
642    /// atomic and never leaves a half-written destination; if anything fails
643    /// before the rename, the original file is untouched. The temp file is
644    /// created in the destination's own directory so the rename stays within a
645    /// single filesystem (a cross-device rename would fail with EXDEV).
646    fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
647        use std::io::Write;
648
649        let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
650        let dir = parent.unwrap_or_else(|| Path::new("."));
651
652        // Unique, hidden sibling temp name. Including the pid and an atomic
653        // counter keeps concurrent writers from colliding on the same temp
654        // path.
655        let counter = {
656            use std::sync::atomic::{AtomicU64, Ordering};
657            static COUNTER: AtomicU64 = AtomicU64::new(0);
658            COUNTER.fetch_add(1, Ordering::Relaxed)
659        };
660        let file_name = path
661            .file_name()
662            .map(|n| n.to_string_lossy().into_owned())
663            .unwrap_or_default();
664        let tmp_name = format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id());
665        let tmp_path = dir.join(tmp_name);
666
667        // Write the full contents to the temp file, then fsync so the bytes
668        // are durable before we swap it into place.
669        let write_result = (|| -> std::io::Result<()> {
670            let mut file = std::fs::File::create(&tmp_path)?;
671            file.write_all(contents)?;
672            file.flush()?;
673            file.sync_all()?;
674            Ok(())
675        })();
676        if let Err(err) = write_result {
677            // Best-effort cleanup; the destination was never touched.
678            let _ = std::fs::remove_file(&tmp_path);
679            return Err(err);
680        }
681
682        // Atomically replace the destination. On failure, clean up the temp
683        // file and leave the original intact.
684        if let Err(err) = std::fs::rename(&tmp_path, path) {
685            let _ = std::fs::remove_file(&tmp_path);
686            return Err(err);
687        }
688        Ok(())
689    }
690
691    pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
692        let result = match active_overlay() {
693            Some(overlay) => overlay.append(path, contents),
694            None => std::fs::OpenOptions::new()
695                .create(true)
696                .append(true)
697                .open(path)
698                .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
699        };
700        if result.is_ok() {
701            record_file_write(path, contents);
702        }
703        result
704    }
705
706    pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
707        match active_overlay() {
708            Some(overlay) => {
709                let result = overlay.copy(src, dst);
710                if let Ok(bytes) = overlay.read(src) {
711                    record_file_read(src, &bytes);
712                    if result.is_ok() {
713                        record_file_write(dst, &bytes);
714                    }
715                }
716                result
717            }
718            None => {
719                let copied = std::fs::copy(src, dst)?;
720                if tape::active_recorder().is_some() {
721                    let bytes = std::fs::read(dst)?;
722                    record_file_read(src, &bytes);
723                    record_file_write(dst, &bytes);
724                }
725                Ok(copied)
726            }
727        }
728    }
729
730    pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
731        match active_overlay() {
732            Some(overlay) => {
733                let bytes_for_record = overlay.read(src).ok();
734                let result = overlay.rename(src, dst);
735                if result.is_ok() {
736                    if let Some(bytes) = bytes_for_record.as_deref() {
737                        record_file_read(src, bytes);
738                        record_file_write(dst, bytes);
739                        record_file_delete(src);
740                    }
741                }
742                result
743            }
744            None => {
745                let bytes = tape::active_recorder()
746                    .is_some()
747                    .then(|| std::fs::read(src))
748                    .transpose()?;
749                let len = bytes
750                    .as_ref()
751                    .map(|bytes| bytes.len() as u64)
752                    .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
753                    .unwrap_or(0);
754                std::fs::rename(src, dst)?;
755                if let Some(bytes) = bytes.as_deref() {
756                    record_file_read(src, bytes);
757                    record_file_write(dst, bytes);
758                    record_file_delete(src);
759                }
760                Ok(len)
761            }
762        }
763    }
764
765    pub fn exists(path: &Path) -> bool {
766        match active_overlay() {
767            Some(overlay) => overlay.exists(path),
768            None => path.exists(),
769        }
770    }
771
772    pub fn remove_file(path: &Path) -> std::io::Result<()> {
773        let result = match active_overlay() {
774            Some(overlay) => overlay.remove_file(path),
775            None => std::fs::remove_file(path),
776        };
777        if result.is_ok() {
778            record_file_delete(path);
779        }
780        result
781    }
782
783    pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
784        match active_overlay() {
785            Some(overlay) => overlay.create_dir_all(path),
786            None => std::fs::create_dir_all(path),
787        }
788    }
789
790    pub fn create_dir(path: &Path) -> std::io::Result<()> {
791        match active_overlay() {
792            Some(overlay) => overlay.create_dir(path),
793            None => std::fs::create_dir(path),
794        }
795    }
796
797    pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
798        match active_overlay() {
799            Some(overlay) => overlay.read_dir(path),
800            None => {
801                let mut entries = Vec::new();
802                for entry in std::fs::read_dir(path)? {
803                    let entry = entry?;
804                    let file_type = entry.file_type()?;
805                    entries.push(OverlayDirEntry {
806                        path: entry.path(),
807                        is_dir: file_type.is_dir(),
808                        is_file: file_type.is_file(),
809                    });
810                }
811                Ok(entries)
812            }
813        }
814    }
815}
816
817#[cfg(test)]
818mod tests {
819    use super::*;
820
821    #[test]
822    fn writes_land_in_overlay_only() {
823        let dir = tempfile::tempdir().unwrap();
824        let overlay = OverlayFs::rooted_at(dir.path());
825        overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
826        // Real disk untouched.
827        assert!(!dir.path().join("hello.txt").exists());
828        // Overlay reports it back.
829        assert_eq!(
830            overlay
831                .read_to_string(&dir.path().join("hello.txt"))
832                .unwrap(),
833            "hi"
834        );
835    }
836
837    #[test]
838    fn reads_pass_through_to_underlying_tree() {
839        let dir = tempfile::tempdir().unwrap();
840        std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
841        let overlay = OverlayFs::rooted_at(dir.path());
842        assert_eq!(
843            overlay
844                .read_to_string(&dir.path().join("seed.txt"))
845                .unwrap(),
846            "underlying"
847        );
848    }
849
850    #[test]
851    fn delete_masks_underlying_file() {
852        let dir = tempfile::tempdir().unwrap();
853        std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
854        let overlay = OverlayFs::rooted_at(dir.path());
855        overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
856        assert!(!overlay.exists(&dir.path().join("doomed.txt")));
857        // Real disk untouched.
858        assert!(dir.path().join("doomed.txt").exists());
859        let diff = overlay.diff();
860        assert_eq!(diff.len(), 1);
861        assert!(matches!(diff[0].kind, DiffKind::Deleted));
862    }
863
864    #[test]
865    fn delete_masks_underlying_directory_contents() {
866        let dir = tempfile::tempdir().unwrap();
867        let nested = dir.path().join("doomed");
868        std::fs::create_dir_all(&nested).unwrap();
869        std::fs::write(nested.join("secret.txt"), "x").unwrap();
870        let overlay = OverlayFs::rooted_at(dir.path());
871
872        overlay.remove_file(&nested).unwrap();
873
874        assert!(!overlay.exists(&nested));
875        assert_eq!(
876            overlay.read_dir(&nested).unwrap_err().kind(),
877            std::io::ErrorKind::NotFound
878        );
879        assert!(nested.join("secret.txt").exists());
880    }
881
882    #[test]
883    fn recursive_mkdir_creates_visible_overlay_ancestors() {
884        let dir = tempfile::tempdir().unwrap();
885        let overlay = OverlayFs::rooted_at(dir.path());
886        overlay
887            .create_dir_all(&dir.path().join("alpha/beta/gamma"))
888            .unwrap();
889
890        let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
891        assert_eq!(root_entries.len(), 1);
892        assert_eq!(
893            root_entries[0]
894                .path
895                .file_name()
896                .and_then(|name| name.to_str()),
897            Some("beta")
898        );
899        assert!(root_entries[0].is_dir);
900    }
901
902    #[test]
903    fn read_dir_reports_missing_empty_overlay_path() {
904        let dir = tempfile::tempdir().unwrap();
905        let overlay = OverlayFs::rooted_at(dir.path());
906
907        assert_eq!(
908            overlay
909                .read_dir(&dir.path().join("missing"))
910                .unwrap_err()
911                .kind(),
912            std::io::ErrorKind::NotFound
913        );
914    }
915
916    /// Regression: the live (no-overlay) write path must be crash-safe. A
917    /// successful overwrite replaces the content and leaves no temp files.
918    #[test]
919    fn no_overlay_write_replaces_content() {
920        let dir = tempfile::tempdir().unwrap();
921        let target = dir.path().join("important.txt");
922        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
923        assert!(active_overlay().is_none(), "no overlay should be installed");
924
925        helpers::write(&target, b"NEW CONTENT").unwrap();
926
927        assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW CONTENT");
928        // No leftover temp files in the directory.
929        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
930            .unwrap()
931            .filter_map(|e| e.ok())
932            .map(|e| e.file_name().to_string_lossy().into_owned())
933            .filter(|n| n.contains("harn-tmp"))
934            .collect();
935        assert!(
936            leftovers.is_empty(),
937            "temp files left behind: {leftovers:?}"
938        );
939    }
940
941    /// Regression for the non-atomic primary write path: a write that cannot
942    /// be completed must leave the original file completely intact rather than
943    /// truncating it.
944    ///
945    /// The trigger here is a read-only containing directory. The atomic path
946    /// writes through a sibling temp file, so it cannot even start (temp
947    /// `File::create` is denied) and the original survives untouched. The old
948    /// `std::fs::write` path instead reopens the *existing* destination with
949    /// `O_CREAT|O_TRUNC` — which needs no directory write permission — so it
950    /// truncates and overwrites the original before any failure could protect
951    /// it. The load-bearing assertion is therefore that the original content
952    /// is preserved; under the buggy path it would read back as "NEW CONTENT".
953    #[cfg(unix)]
954    #[test]
955    fn no_overlay_write_failure_preserves_original() {
956        use std::os::unix::fs::PermissionsExt;
957
958        let dir = tempfile::tempdir().unwrap();
959        let target = dir.path().join("important.txt");
960        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
961
962        // Read+exec but not writable: a new sibling temp file cannot be
963        // created, but the existing destination file is still openable.
964        let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
965        perms.set_mode(0o500);
966        std::fs::set_permissions(dir.path(), perms).unwrap();
967
968        let result = helpers::write(&target, b"NEW CONTENT");
969
970        // Restore write perms before asserting so tempdir drop/cleanup works.
971        let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
972        restore.set_mode(0o700);
973        std::fs::set_permissions(dir.path(), restore).unwrap();
974
975        // Load-bearing invariant: the original content must survive.
976        assert_eq!(
977            std::fs::read_to_string(&target).unwrap(),
978            "ORIGINAL IMPORTANT CONTENT",
979            "a write that cannot complete must not truncate or corrupt the original file"
980        );
981        // The atomic path also surfaces the failure rather than reporting a
982        // false success.
983        assert!(
984            result.is_err(),
985            "atomic write should report failure when it cannot create its temp file"
986        );
987    }
988
989    #[test]
990    fn diff_distinguishes_added_vs_modified() {
991        let dir = tempfile::tempdir().unwrap();
992        std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
993        let overlay = OverlayFs::rooted_at(dir.path());
994        overlay
995            .write(&dir.path().join("existing.txt"), b"v2")
996            .unwrap();
997        overlay
998            .write(&dir.path().join("brand-new.txt"), b"hi")
999            .unwrap();
1000        let mut diff = overlay.diff();
1001        diff.sort_by(|a, b| a.path.cmp(&b.path));
1002        assert_eq!(diff.len(), 2);
1003        assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
1004        assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
1005    }
1006}