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