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