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