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(crate) fn append_locked_scoped(
742        builtin: &str,
743        path: &Path,
744        contents: &[u8],
745        options: crate::stdlib::sandbox::AppendLockOptions,
746    ) -> std::io::Result<()> {
747        let result = match active_overlay() {
748            Some(overlay) => overlay.append(path, contents),
749            None => crate::stdlib::sandbox::append_locked_scoped_at_open(
750                builtin, path, contents, options,
751            ),
752        };
753        if result.is_ok() {
754            record_file_write(path, contents);
755        }
756        result
757    }
758
759    pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
760        match active_overlay() {
761            Some(overlay) => {
762                let result = overlay.copy(src, dst);
763                if let Ok(bytes) = overlay.read(src) {
764                    record_file_read(src, &bytes);
765                    if result.is_ok() {
766                        record_file_write(dst, &bytes);
767                    }
768                }
769                result
770            }
771            None => {
772                let copied = std::fs::copy(src, dst)?;
773                if tape::active_recorder().is_some() {
774                    let bytes = std::fs::read(dst)?;
775                    record_file_read(src, &bytes);
776                    record_file_write(dst, &bytes);
777                }
778                Ok(copied)
779            }
780        }
781    }
782
783    pub fn copy_scoped(builtin: &str, src: &Path, dst: &Path) -> std::io::Result<u64> {
784        match active_overlay() {
785            Some(overlay) => {
786                let result = overlay.copy(src, dst);
787                if let Ok(bytes) = overlay.read(src) {
788                    record_file_read(src, &bytes);
789                    if result.is_ok() {
790                        record_file_write(dst, &bytes);
791                    }
792                }
793                result
794            }
795            None => {
796                let copied = crate::stdlib::sandbox::copy_scoped_at_open(builtin, src, dst)?;
797                if tape::active_recorder().is_some() {
798                    let bytes = std::fs::read(dst)?;
799                    record_file_read(src, &bytes);
800                    record_file_write(dst, &bytes);
801                }
802                Ok(copied)
803            }
804        }
805    }
806
807    pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
808        match active_overlay() {
809            Some(overlay) => {
810                let bytes_for_record = overlay.read(src).ok();
811                let result = overlay.rename(src, dst);
812                if result.is_ok() {
813                    if let Some(bytes) = bytes_for_record.as_deref() {
814                        record_file_read(src, bytes);
815                        record_file_write(dst, bytes);
816                        record_file_delete(src);
817                    }
818                }
819                result
820            }
821            None => {
822                let bytes = tape::active_recorder()
823                    .is_some()
824                    .then(|| std::fs::read(src))
825                    .transpose()?;
826                let len = bytes
827                    .as_ref()
828                    .map(|bytes| bytes.len() as u64)
829                    .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
830                    .unwrap_or(0);
831                std::fs::rename(src, dst)?;
832                if let Some(bytes) = bytes.as_deref() {
833                    record_file_read(src, bytes);
834                    record_file_write(dst, bytes);
835                    record_file_delete(src);
836                }
837                Ok(len)
838            }
839        }
840    }
841
842    pub fn rename_scoped(builtin: &str, src: &Path, dst: &Path) -> std::io::Result<u64> {
843        match active_overlay() {
844            Some(overlay) => {
845                let bytes_for_record = overlay.read(src).ok();
846                let result = overlay.rename(src, dst);
847                if result.is_ok() {
848                    if let Some(bytes) = bytes_for_record.as_deref() {
849                        record_file_read(src, bytes);
850                        record_file_write(dst, bytes);
851                        record_file_delete(src);
852                    }
853                }
854                result
855            }
856            None => {
857                let bytes = tape::active_recorder()
858                    .is_some()
859                    .then(|| std::fs::read(src))
860                    .transpose()?;
861                let len = bytes
862                    .as_ref()
863                    .map(|bytes| bytes.len() as u64)
864                    .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
865                    .unwrap_or(0);
866                crate::stdlib::sandbox::rename_scoped_at_open(builtin, src, dst)?;
867                if let Some(bytes) = bytes.as_deref() {
868                    record_file_read(src, bytes);
869                    record_file_write(dst, bytes);
870                    record_file_delete(src);
871                }
872                Ok(len)
873            }
874        }
875    }
876
877    pub fn exists(path: &Path) -> bool {
878        match active_overlay() {
879            Some(overlay) => overlay.exists(path),
880            None => path.exists(),
881        }
882    }
883
884    pub fn remove_file(path: &Path) -> std::io::Result<()> {
885        let result = match active_overlay() {
886            Some(overlay) => overlay.remove_file(path),
887            None => std::fs::remove_file(path),
888        };
889        if result.is_ok() {
890            record_file_delete(path);
891        }
892        result
893    }
894
895    pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
896        match active_overlay() {
897            Some(overlay) => overlay.create_dir_all(path),
898            None => std::fs::create_dir_all(path),
899        }
900    }
901
902    pub fn create_dir(path: &Path) -> std::io::Result<()> {
903        match active_overlay() {
904            Some(overlay) => overlay.create_dir(path),
905            None => std::fs::create_dir(path),
906        }
907    }
908
909    pub fn create_dir_scoped(builtin: &str, path: &Path, recursive: bool) -> std::io::Result<()> {
910        match active_overlay() {
911            Some(overlay) if recursive => overlay.create_dir_all(path),
912            Some(overlay) => overlay.create_dir(path),
913            None => crate::stdlib::sandbox::create_dir_scoped_at_open(builtin, path, recursive),
914        }
915    }
916
917    pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
918        match active_overlay() {
919            Some(overlay) => overlay.read_dir(path),
920            None => {
921                let mut entries = Vec::new();
922                for entry in std::fs::read_dir(path)? {
923                    let entry = entry?;
924                    let file_type = entry.file_type()?;
925                    entries.push(OverlayDirEntry {
926                        path: entry.path(),
927                        is_dir: file_type.is_dir(),
928                        is_file: file_type.is_file(),
929                    });
930                }
931                Ok(entries)
932            }
933        }
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    #[test]
942    fn writes_land_in_overlay_only() {
943        let dir = tempfile::tempdir().unwrap();
944        let overlay = OverlayFs::rooted_at(dir.path());
945        overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
946        // Real disk untouched.
947        assert!(!dir.path().join("hello.txt").exists());
948        // Overlay reports it back.
949        assert_eq!(
950            overlay
951                .read_to_string(&dir.path().join("hello.txt"))
952                .unwrap(),
953            "hi"
954        );
955    }
956
957    #[test]
958    fn reads_pass_through_to_underlying_tree() {
959        let dir = tempfile::tempdir().unwrap();
960        std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
961        let overlay = OverlayFs::rooted_at(dir.path());
962        assert_eq!(
963            overlay
964                .read_to_string(&dir.path().join("seed.txt"))
965                .unwrap(),
966            "underlying"
967        );
968    }
969
970    #[test]
971    fn delete_masks_underlying_file() {
972        let dir = tempfile::tempdir().unwrap();
973        std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
974        let overlay = OverlayFs::rooted_at(dir.path());
975        overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
976        assert!(!overlay.exists(&dir.path().join("doomed.txt")));
977        // Real disk untouched.
978        assert!(dir.path().join("doomed.txt").exists());
979        let diff = overlay.diff();
980        assert_eq!(diff.len(), 1);
981        assert!(matches!(diff[0].kind, DiffKind::Deleted));
982    }
983
984    #[test]
985    fn delete_masks_underlying_directory_contents() {
986        let dir = tempfile::tempdir().unwrap();
987        let nested = dir.path().join("doomed");
988        std::fs::create_dir_all(&nested).unwrap();
989        std::fs::write(nested.join("secret.txt"), "x").unwrap();
990        let overlay = OverlayFs::rooted_at(dir.path());
991
992        overlay.remove_file(&nested).unwrap();
993
994        assert!(!overlay.exists(&nested));
995        assert_eq!(
996            overlay.read_dir(&nested).unwrap_err().kind(),
997            std::io::ErrorKind::NotFound
998        );
999        assert!(nested.join("secret.txt").exists());
1000    }
1001
1002    #[test]
1003    fn recursive_mkdir_creates_visible_overlay_ancestors() {
1004        let dir = tempfile::tempdir().unwrap();
1005        let overlay = OverlayFs::rooted_at(dir.path());
1006        overlay
1007            .create_dir_all(&dir.path().join("alpha/beta/gamma"))
1008            .unwrap();
1009
1010        let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
1011        assert_eq!(root_entries.len(), 1);
1012        assert_eq!(
1013            root_entries[0]
1014                .path
1015                .file_name()
1016                .and_then(|name| name.to_str()),
1017            Some("beta")
1018        );
1019        assert!(root_entries[0].is_dir);
1020    }
1021
1022    #[test]
1023    fn read_dir_reports_missing_empty_overlay_path() {
1024        let dir = tempfile::tempdir().unwrap();
1025        let overlay = OverlayFs::rooted_at(dir.path());
1026
1027        assert_eq!(
1028            overlay
1029                .read_dir(&dir.path().join("missing"))
1030                .unwrap_err()
1031                .kind(),
1032            std::io::ErrorKind::NotFound
1033        );
1034    }
1035
1036    /// Regression: the live (no-overlay) write path must be crash-safe. A
1037    /// successful overwrite replaces the content and leaves no temp files.
1038    #[test]
1039    fn no_overlay_write_replaces_content() {
1040        let dir = tempfile::tempdir().unwrap();
1041        let target = dir.path().join("important.txt");
1042        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
1043        assert!(active_overlay().is_none(), "no overlay should be installed");
1044
1045        helpers::write(&target, b"NEW CONTENT").unwrap();
1046
1047        assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW CONTENT");
1048        // No leftover temp files in the directory.
1049        let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1050            .unwrap()
1051            .filter_map(|e| e.ok())
1052            .map(|e| e.file_name().to_string_lossy().into_owned())
1053            .filter(|n| n.contains("harn-tmp"))
1054            .collect();
1055        assert!(
1056            leftovers.is_empty(),
1057            "temp files left behind: {leftovers:?}"
1058        );
1059    }
1060
1061    /// Regression for the non-atomic primary write path: a write that cannot
1062    /// be completed must leave the original file completely intact rather than
1063    /// truncating it.
1064    ///
1065    /// The trigger here is a read-only containing directory. The atomic path
1066    /// writes through a sibling temp file, so it cannot even start (temp
1067    /// `File::create` is denied) and the original survives untouched. The old
1068    /// `std::fs::write` path instead reopens the *existing* destination with
1069    /// `O_CREAT|O_TRUNC` — which needs no directory write permission — so it
1070    /// truncates and overwrites the original before any failure could protect
1071    /// it. The load-bearing assertion is therefore that the original content
1072    /// is preserved; under the buggy path it would read back as "NEW CONTENT".
1073    #[cfg(unix)]
1074    #[test]
1075    fn no_overlay_write_failure_preserves_original() {
1076        use std::os::unix::fs::PermissionsExt;
1077
1078        let dir = tempfile::tempdir().unwrap();
1079        let target = dir.path().join("important.txt");
1080        std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
1081
1082        // Read+exec but not writable: a new sibling temp file cannot be
1083        // created, but the existing destination file is still openable.
1084        let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
1085        perms.set_mode(0o500);
1086        std::fs::set_permissions(dir.path(), perms).unwrap();
1087
1088        let result = helpers::write(&target, b"NEW CONTENT");
1089
1090        // Restore write perms before asserting so tempdir drop/cleanup works.
1091        let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
1092        restore.set_mode(0o700);
1093        std::fs::set_permissions(dir.path(), restore).unwrap();
1094
1095        // Load-bearing invariant: the original content must survive.
1096        assert_eq!(
1097            std::fs::read_to_string(&target).unwrap(),
1098            "ORIGINAL IMPORTANT CONTENT",
1099            "a write that cannot complete must not truncate or corrupt the original file"
1100        );
1101        // The atomic path also surfaces the failure rather than reporting a
1102        // false success.
1103        assert!(
1104            result.is_err(),
1105            "atomic write should report failure when it cannot create its temp file"
1106        );
1107    }
1108
1109    #[test]
1110    fn diff_distinguishes_added_vs_modified() {
1111        let dir = tempfile::tempdir().unwrap();
1112        std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
1113        let overlay = OverlayFs::rooted_at(dir.path());
1114        overlay
1115            .write(&dir.path().join("existing.txt"), b"v2")
1116            .unwrap();
1117        overlay
1118            .write(&dir.path().join("brand-new.txt"), b"hi")
1119            .unwrap();
1120        let mut diff = overlay.diff();
1121        diff.sort_by(|a, b| a.path.cmp(&b.path));
1122        assert_eq!(diff.len(), 2);
1123        assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
1124        assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
1125    }
1126}