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