1use 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#[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 Added { content: Vec<u8> },
33 Modified { content: Vec<u8> },
35 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 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 fn within_root(&self, path: &Path) -> bool {
80 let key = self.key(path);
81 key.starts_with(&self.root)
82 }
83
84 pub fn read(&self, path: &Path) -> std::io::Result<Vec<u8>> {
85 if !self.within_root(path) {
86 return std::fs::read(path);
87 }
88 let key = self.key(path);
89 let layer = self.layer.lock().expect("overlay layer poisoned");
90 match layer.get(&key) {
91 Some(OverlayEntry::File(bytes)) => Ok(bytes.clone()),
92 Some(OverlayEntry::Deleted) => Err(std::io::Error::new(
93 std::io::ErrorKind::NotFound,
94 format!("overlay: {} was deleted", key.display()),
95 )),
96 Some(OverlayEntry::Directory) => Err(std::io::Error::new(
97 std::io::ErrorKind::IsADirectory,
98 format!("overlay: {} is a directory", key.display()),
99 )),
100 None => std::fs::read(path),
101 }
102 }
103
104 pub fn read_to_string(&self, path: &Path) -> std::io::Result<String> {
105 let bytes = self.read(path)?;
106 String::from_utf8(bytes)
107 .map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err.to_string()))
108 }
109
110 pub fn write(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
111 if !self.within_root(path) {
112 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
118 std::fs::create_dir_all(parent)?;
119 }
120 return std::fs::write(path, contents);
121 }
122 let key = self.key(path);
123 let mut layer = self.layer.lock().expect("overlay layer poisoned");
124 layer.insert(key, OverlayEntry::File(contents.to_vec()));
125 Ok(())
126 }
127
128 pub fn append(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
129 if !self.within_root(path) {
130 if let Some(parent) = path.parent().filter(|p| !p.as_os_str().is_empty()) {
133 std::fs::create_dir_all(parent)?;
134 }
135 return std::fs::OpenOptions::new()
136 .create(true)
137 .append(true)
138 .open(path)
139 .and_then(|mut file| std::io::Write::write_all(&mut file, contents));
140 }
141 let mut combined = match self.read(path) {
142 Ok(bytes) => bytes,
143 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
144 Err(err) => return Err(err),
145 };
146 combined.extend_from_slice(contents);
147 self.write(path, &combined)
148 }
149
150 pub fn copy(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
151 let bytes = self.read(src)?;
152 let len = bytes.len() as u64;
153 self.write(dst, &bytes)?;
154 Ok(len)
155 }
156
157 pub fn rename(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
158 let len = self.copy(src, dst)?;
159 self.remove_file(src)?;
160 Ok(len)
161 }
162
163 pub fn exists(&self, path: &Path) -> bool {
164 if !self.within_root(path) {
165 return path.exists();
166 }
167 let key = self.key(path);
168 let layer = self.layer.lock().expect("overlay layer poisoned");
169 match layer.get(&key) {
170 Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => true,
171 Some(OverlayEntry::Deleted) => false,
172 None => path.exists(),
173 }
174 }
175
176 pub fn remove_file(&self, path: &Path) -> std::io::Result<()> {
177 if !self.within_root(path) {
178 return std::fs::remove_file(path);
179 }
180 let key = self.key(path);
181 let mut layer = self.layer.lock().expect("overlay layer poisoned");
182 let underlying_present = path.exists();
185 match layer.get(&key) {
186 Some(OverlayEntry::Deleted) => Err(std::io::Error::new(
187 std::io::ErrorKind::NotFound,
188 format!("overlay: {} already deleted", key.display()),
189 )),
190 _ => {
191 layer.retain(|entry_path, _| !entry_path.starts_with(&key) || entry_path == &key);
192 if underlying_present {
193 layer.insert(key, OverlayEntry::Deleted);
194 } else {
195 layer.remove(&key);
196 }
197 Ok(())
198 }
199 }
200 }
201
202 pub fn create_dir(&self, path: &Path) -> std::io::Result<()> {
203 if !self.within_root(path) {
204 return std::fs::create_dir(path);
205 }
206 let key = self.key(path);
207 let mut layer = self.layer.lock().expect("overlay layer poisoned");
208 match layer.get(&key) {
209 Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => {
210 return Err(std::io::Error::new(
211 std::io::ErrorKind::AlreadyExists,
212 format!("overlay: {} already exists", key.display()),
213 ));
214 }
215 Some(OverlayEntry::Deleted) | None => {}
216 }
217 if !matches!(layer.get(&key), Some(OverlayEntry::Deleted)) && path.exists() {
218 return Err(std::io::Error::new(
219 std::io::ErrorKind::AlreadyExists,
220 format!("overlay: {} already exists", key.display()),
221 ));
222 }
223 let parent = key.parent().ok_or_else(|| {
224 std::io::Error::new(
225 std::io::ErrorKind::NotFound,
226 format!("overlay: {} has no parent", key.display()),
227 )
228 })?;
229 match layer.get(parent) {
230 Some(OverlayEntry::Directory) => {}
231 Some(OverlayEntry::File(_)) => {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::NotADirectory,
234 format!("overlay: {} parent is a file", key.display()),
235 ));
236 }
237 Some(OverlayEntry::Deleted) => {
238 return Err(std::io::Error::new(
239 std::io::ErrorKind::NotFound,
240 format!("overlay: {} parent was deleted", key.display()),
241 ));
242 }
243 None if parent.is_dir() => {}
244 None => {
245 return Err(std::io::Error::new(
246 std::io::ErrorKind::NotFound,
247 format!("overlay: {} parent does not exist", key.display()),
248 ));
249 }
250 }
251 layer.insert(key, OverlayEntry::Directory);
252 Ok(())
253 }
254
255 pub fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
256 if !self.within_root(path) {
257 return std::fs::create_dir_all(path);
258 }
259 let key = self.key(path);
260 let mut layer = self.layer.lock().expect("overlay layer poisoned");
261 if key == self.root {
262 layer.insert(key, OverlayEntry::Directory);
263 return Ok(());
264 }
265 let relative = key.strip_prefix(&self.root).map_err(|_| {
266 std::io::Error::new(
267 std::io::ErrorKind::InvalidInput,
268 format!(
269 "overlay: {} is outside {}",
270 key.display(),
271 self.root.display()
272 ),
273 )
274 })?;
275 let mut current = self.root.clone();
276 for component in relative.components() {
277 current.push(component.as_os_str());
278 layer.insert(current.clone(), OverlayEntry::Directory);
279 }
280 Ok(())
281 }
282
283 pub fn read_dir(&self, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
284 if !self.within_root(path) {
285 let mut entries = Vec::new();
286 for entry in std::fs::read_dir(path)? {
287 let entry = entry?;
288 entries.push(OverlayDirEntry {
289 path: entry.path(),
290 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
291 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
292 });
293 }
294 return Ok(entries);
295 }
296 let dir_key = self.key(path);
297 let virtual_dir_exists;
298 {
299 let layer = self.layer.lock().expect("overlay layer poisoned");
300 match layer.get(&dir_key) {
301 Some(OverlayEntry::Deleted) => {
302 return Err(std::io::Error::new(
303 std::io::ErrorKind::NotFound,
304 format!("overlay: {} was deleted", dir_key.display()),
305 ));
306 }
307 Some(OverlayEntry::File(_)) => {
308 return Err(std::io::Error::new(
309 std::io::ErrorKind::NotADirectory,
310 format!("overlay: {} is a file", dir_key.display()),
311 ));
312 }
313 Some(OverlayEntry::Directory) => {
314 virtual_dir_exists = true;
315 }
316 None => {
317 virtual_dir_exists = false;
318 }
319 }
320 }
321 let disk_dir_exists = path.exists();
322 let mut entries: BTreeMap<PathBuf, OverlayDirEntry> = BTreeMap::new();
323 if disk_dir_exists {
324 for entry in std::fs::read_dir(path)? {
325 let entry = entry?;
326 let p = entry.path();
327 entries.insert(
328 p.clone(),
329 OverlayDirEntry {
330 path: p,
331 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
332 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
333 },
334 );
335 }
336 }
337 let layer = self.layer.lock().expect("overlay layer poisoned");
338 for (key, entry) in layer.iter() {
339 if key.parent() != Some(dir_key.as_path()) {
340 continue;
341 }
342 match entry {
343 OverlayEntry::File(_) => {
344 entries.insert(
345 key.clone(),
346 OverlayDirEntry {
347 path: key.clone(),
348 is_dir: false,
349 is_file: true,
350 },
351 );
352 }
353 OverlayEntry::Directory => {
354 entries.insert(
355 key.clone(),
356 OverlayDirEntry {
357 path: key.clone(),
358 is_dir: true,
359 is_file: false,
360 },
361 );
362 }
363 OverlayEntry::Deleted => {
364 entries.remove(key);
365 }
366 }
367 }
368 if entries.is_empty() && !disk_dir_exists && !virtual_dir_exists {
369 return Err(std::io::Error::new(
370 std::io::ErrorKind::NotFound,
371 format!("overlay: {} was not found", dir_key.display()),
372 ));
373 }
374 Ok(entries.into_values().collect())
375 }
376
377 pub fn diff(&self) -> Vec<DiffEntry> {
379 let layer = self.layer.lock().expect("overlay layer poisoned");
380 let mut diff = Vec::new();
381 for (path, entry) in layer.iter() {
382 match entry {
383 OverlayEntry::File(content) => {
384 if path.exists() {
385 let underlying = std::fs::read(path).unwrap_or_default();
386 if &underlying != content {
387 diff.push(DiffEntry {
388 path: path.clone(),
389 kind: DiffKind::Modified {
390 content: content.clone(),
391 },
392 });
393 }
394 } else {
395 diff.push(DiffEntry {
396 path: path.clone(),
397 kind: DiffKind::Added {
398 content: content.clone(),
399 },
400 });
401 }
402 }
403 OverlayEntry::Deleted => {
404 if path.exists() {
405 diff.push(DiffEntry {
406 path: path.clone(),
407 kind: DiffKind::Deleted,
408 });
409 }
410 }
411 OverlayEntry::Directory => {}
412 }
413 }
414 diff
415 }
416
417 pub fn render_unified_diff(&self) -> String {
421 render_unified_diff(&self.diff())
422 }
423}
424
425pub fn render_unified_diff(diff: &[DiffEntry]) -> String {
430 let mut out = String::new();
431 for entry in diff {
432 match &entry.kind {
433 DiffKind::Added { content } => {
434 out.push_str(&format!("--- /dev/null\n+++ b/{}\n", entry.path.display()));
435 push_lines(&mut out, content, '+');
436 }
437 DiffKind::Modified { content } => {
438 let underlying = std::fs::read(&entry.path).unwrap_or_default();
439 out.push_str(&format!(
440 "--- a/{}\n+++ b/{}\n",
441 entry.path.display(),
442 entry.path.display()
443 ));
444 push_lines(&mut out, &underlying, '-');
445 push_lines(&mut out, content, '+');
446 }
447 DiffKind::Deleted => {
448 let underlying = std::fs::read(&entry.path).unwrap_or_default();
449 out.push_str(&format!("--- a/{}\n+++ /dev/null\n", entry.path.display()));
450 push_lines(&mut out, &underlying, '-');
451 }
452 }
453 }
454 out
455}
456
457#[derive(Debug, Clone)]
458pub struct OverlayDirEntry {
459 pub path: PathBuf,
460 pub is_dir: bool,
461 pub is_file: bool,
462}
463
464fn push_lines(out: &mut String, bytes: &[u8], prefix: char) {
465 let text = String::from_utf8_lossy(bytes);
466 for line in text.split_inclusive('\n') {
467 out.push(prefix);
468 out.push_str(line);
469 if !line.ends_with('\n') {
470 out.push('\n');
471 }
472 }
473}
474
475fn normalize_logical(path: &Path) -> PathBuf {
479 let absolute = if path.is_absolute() {
480 path.to_path_buf()
481 } else {
482 std::env::current_dir()
483 .map(|cwd| cwd.join(path))
484 .unwrap_or_else(|_| path.to_path_buf())
485 };
486 let mut out = PathBuf::new();
487 for component in absolute.components() {
488 match component {
489 Component::ParentDir => {
490 out.pop();
491 }
492 Component::CurDir => {}
493 other => out.push(other),
494 }
495 }
496 out
497}
498
499fn canonicalize_for_overlay(path: &Path) -> PathBuf {
505 let absolute = normalize_logical(path);
506 if let Ok(direct) = std::fs::canonicalize(&absolute) {
507 return direct;
508 }
509 let mut suffix = Vec::new();
510 let mut probe = absolute.clone();
511 loop {
512 if let Ok(canon) = std::fs::canonicalize(&probe) {
513 let mut joined = canon;
514 for component in suffix.iter().rev() {
515 joined.push(component);
516 }
517 return joined;
518 }
519 match probe.file_name().map(|n| n.to_owned()) {
520 Some(name) => {
521 suffix.push(name);
522 if !probe.pop() {
523 break;
524 }
525 }
526 None => break,
527 }
528 }
529 absolute
530}
531
532thread_local! {
533 static ACTIVE_OVERLAY: RefCell<Option<Arc<OverlayFs>>> = const { RefCell::new(None) };
534}
535
536pub struct OverlayFsGuard {
537 previous: Option<Arc<OverlayFs>>,
538}
539
540impl Drop for OverlayFsGuard {
541 fn drop(&mut self) {
542 let prev = self.previous.take();
543 ACTIVE_OVERLAY.with(|slot| {
544 *slot.borrow_mut() = prev;
545 });
546 }
547}
548
549pub fn install_overlay(overlay: Arc<OverlayFs>) -> OverlayFsGuard {
550 let previous = ACTIVE_OVERLAY.with(|slot| slot.replace(Some(overlay)));
551 OverlayFsGuard { previous }
552}
553
554pub fn active_overlay() -> Option<Arc<OverlayFs>> {
555 ACTIVE_OVERLAY.with(|slot| slot.borrow().clone())
556}
557
558pub mod helpers {
567 use super::*;
568
569 fn record_file_read(path: &Path, bytes: &[u8]) {
570 if tape::active_recorder().is_none() {
573 return;
574 }
575 let path_str = path.to_string_lossy().into_owned();
576 let len = bytes.len() as u64;
577 let hash = tape::content_hash(bytes);
578 tape::with_active_recorder(|_recorder| {
579 Some(TapeRecordKind::FileRead {
580 path: path_str,
581 content_hash: hash,
582 len_bytes: len,
583 })
584 });
585 }
586
587 fn record_file_write(path: &Path, bytes: &[u8]) {
588 if tape::active_recorder().is_none() {
589 return;
590 }
591 let path_str = path.to_string_lossy().into_owned();
592 let len = bytes.len() as u64;
593 let hash = tape::content_hash(bytes);
594 tape::with_active_recorder(|_recorder| {
595 Some(TapeRecordKind::FileWrite {
596 path: path_str,
597 content_hash: hash,
598 len_bytes: len,
599 })
600 });
601 }
602
603 fn record_file_delete(path: &Path) {
604 if tape::active_recorder().is_none() {
605 return;
606 }
607 let path_str = path.to_string_lossy().into_owned();
608 tape::with_active_recorder(|_recorder| Some(TapeRecordKind::FileDelete { path: path_str }));
609 }
610
611 pub fn read(path: &Path) -> std::io::Result<Vec<u8>> {
612 let result = match active_overlay() {
613 Some(overlay) => overlay.read(path),
614 None => std::fs::read(path),
615 };
616 if let Ok(bytes) = result.as_ref() {
617 record_file_read(path, bytes);
618 }
619 result
620 }
621
622 pub fn read_to_string(path: &Path) -> std::io::Result<String> {
623 let result = match active_overlay() {
624 Some(overlay) => overlay.read_to_string(path),
625 None => std::fs::read_to_string(path),
626 };
627 if let Ok(text) = result.as_ref() {
628 record_file_read(path, text.as_bytes());
629 }
630 result
631 }
632
633 pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
634 let result = match active_overlay() {
635 Some(overlay) => overlay.write(path, contents),
636 None => atomic_write(path, contents),
637 };
638 if result.is_ok() {
639 record_file_write(path, contents);
640 }
641 result
642 }
643
644 pub fn write_scoped(builtin: &str, path: &Path, contents: &[u8]) -> std::io::Result<()> {
645 let result = match active_overlay() {
646 Some(overlay) => overlay.write(path, contents),
647 None => crate::stdlib::sandbox::atomic_write_scoped_at_open(builtin, path, contents),
648 };
649 if result.is_ok() {
650 record_file_write(path, contents);
651 }
652 result
653 }
654
655 fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
671 use std::io::Write;
672
673 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
674 let dir = parent.unwrap_or_else(|| Path::new("."));
675
676 let counter = {
680 use std::sync::atomic::{AtomicU64, Ordering};
681 static COUNTER: AtomicU64 = AtomicU64::new(0);
682 COUNTER.fetch_add(1, Ordering::Relaxed)
683 };
684 let file_name = path
685 .file_name()
686 .map(|n| n.to_string_lossy().into_owned())
687 .unwrap_or_default();
688 let tmp_name = format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id());
689 let tmp_path = dir.join(tmp_name);
690
691 let write_result = (|| -> std::io::Result<()> {
694 let mut file = std::fs::File::create(&tmp_path)?;
695 file.write_all(contents)?;
696 file.flush()?;
697 file.sync_all()?;
698 Ok(())
699 })();
700 if let Err(err) = write_result {
701 let _ = std::fs::remove_file(&tmp_path);
703 return Err(err);
704 }
705
706 if let Err(err) = std::fs::rename(&tmp_path, path) {
709 let _ = std::fs::remove_file(&tmp_path);
710 return Err(err);
711 }
712 Ok(())
713 }
714
715 pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
716 let result = match active_overlay() {
717 Some(overlay) => overlay.append(path, contents),
718 None => std::fs::OpenOptions::new()
719 .create(true)
720 .append(true)
721 .open(path)
722 .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
723 };
724 if result.is_ok() {
725 record_file_write(path, contents);
726 }
727 result
728 }
729
730 pub fn append_scoped(builtin: &str, path: &Path, contents: &[u8]) -> std::io::Result<()> {
731 let result = match active_overlay() {
732 Some(overlay) => overlay.append(path, contents),
733 None => crate::stdlib::sandbox::append_scoped_at_open(builtin, path, contents),
734 };
735 if result.is_ok() {
736 record_file_write(path, contents);
737 }
738 result
739 }
740
741 pub(crate) fn append_locked_scoped(
742 builtin: &str,
743 path: &Path,
744 contents: &[u8],
745 options: crate::stdlib::sandbox::AppendLockOptions,
746 ) -> std::io::Result<()> {
747 let result = match active_overlay() {
748 Some(overlay) => overlay.append(path, contents),
749 None => crate::stdlib::sandbox::append_locked_scoped_at_open(
750 builtin, path, contents, options,
751 ),
752 };
753 if result.is_ok() {
754 record_file_write(path, contents);
755 }
756 result
757 }
758
759 pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
760 match active_overlay() {
761 Some(overlay) => {
762 let result = overlay.copy(src, dst);
763 if let Ok(bytes) = overlay.read(src) {
764 record_file_read(src, &bytes);
765 if result.is_ok() {
766 record_file_write(dst, &bytes);
767 }
768 }
769 result
770 }
771 None => {
772 let copied = std::fs::copy(src, dst)?;
773 if tape::active_recorder().is_some() {
774 let bytes = std::fs::read(dst)?;
775 record_file_read(src, &bytes);
776 record_file_write(dst, &bytes);
777 }
778 Ok(copied)
779 }
780 }
781 }
782
783 pub fn copy_scoped(builtin: &str, src: &Path, dst: &Path) -> std::io::Result<u64> {
784 match active_overlay() {
785 Some(overlay) => {
786 let result = overlay.copy(src, dst);
787 if let Ok(bytes) = overlay.read(src) {
788 record_file_read(src, &bytes);
789 if result.is_ok() {
790 record_file_write(dst, &bytes);
791 }
792 }
793 result
794 }
795 None => {
796 let copied = crate::stdlib::sandbox::copy_scoped_at_open(builtin, src, dst)?;
797 if tape::active_recorder().is_some() {
798 let bytes = std::fs::read(dst)?;
799 record_file_read(src, &bytes);
800 record_file_write(dst, &bytes);
801 }
802 Ok(copied)
803 }
804 }
805 }
806
807 pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
808 match active_overlay() {
809 Some(overlay) => {
810 let bytes_for_record = overlay.read(src).ok();
811 let result = overlay.rename(src, dst);
812 if result.is_ok() {
813 if let Some(bytes) = bytes_for_record.as_deref() {
814 record_file_read(src, bytes);
815 record_file_write(dst, bytes);
816 record_file_delete(src);
817 }
818 }
819 result
820 }
821 None => {
822 let bytes = tape::active_recorder()
823 .is_some()
824 .then(|| std::fs::read(src))
825 .transpose()?;
826 let len = bytes
827 .as_ref()
828 .map(|bytes| bytes.len() as u64)
829 .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
830 .unwrap_or(0);
831 std::fs::rename(src, dst)?;
832 if let Some(bytes) = bytes.as_deref() {
833 record_file_read(src, bytes);
834 record_file_write(dst, bytes);
835 record_file_delete(src);
836 }
837 Ok(len)
838 }
839 }
840 }
841
842 pub fn rename_scoped(builtin: &str, src: &Path, dst: &Path) -> std::io::Result<u64> {
843 match active_overlay() {
844 Some(overlay) => {
845 let bytes_for_record = overlay.read(src).ok();
846 let result = overlay.rename(src, dst);
847 if result.is_ok() {
848 if let Some(bytes) = bytes_for_record.as_deref() {
849 record_file_read(src, bytes);
850 record_file_write(dst, bytes);
851 record_file_delete(src);
852 }
853 }
854 result
855 }
856 None => {
857 let bytes = tape::active_recorder()
858 .is_some()
859 .then(|| std::fs::read(src))
860 .transpose()?;
861 let len = bytes
862 .as_ref()
863 .map(|bytes| bytes.len() as u64)
864 .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
865 .unwrap_or(0);
866 crate::stdlib::sandbox::rename_scoped_at_open(builtin, src, dst)?;
867 if let Some(bytes) = bytes.as_deref() {
868 record_file_read(src, bytes);
869 record_file_write(dst, bytes);
870 record_file_delete(src);
871 }
872 Ok(len)
873 }
874 }
875 }
876
877 pub fn exists(path: &Path) -> bool {
878 match active_overlay() {
879 Some(overlay) => overlay.exists(path),
880 None => path.exists(),
881 }
882 }
883
884 pub fn remove_file(path: &Path) -> std::io::Result<()> {
885 let result = match active_overlay() {
886 Some(overlay) => overlay.remove_file(path),
887 None => std::fs::remove_file(path),
888 };
889 if result.is_ok() {
890 record_file_delete(path);
891 }
892 result
893 }
894
895 pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
896 match active_overlay() {
897 Some(overlay) => overlay.create_dir_all(path),
898 None => std::fs::create_dir_all(path),
899 }
900 }
901
902 pub fn create_dir(path: &Path) -> std::io::Result<()> {
903 match active_overlay() {
904 Some(overlay) => overlay.create_dir(path),
905 None => std::fs::create_dir(path),
906 }
907 }
908
909 pub fn create_dir_scoped(builtin: &str, path: &Path, recursive: bool) -> std::io::Result<()> {
910 match active_overlay() {
911 Some(overlay) if recursive => overlay.create_dir_all(path),
912 Some(overlay) => overlay.create_dir(path),
913 None => crate::stdlib::sandbox::create_dir_scoped_at_open(builtin, path, recursive),
914 }
915 }
916
917 pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
918 match active_overlay() {
919 Some(overlay) => overlay.read_dir(path),
920 None => {
921 let mut entries = Vec::new();
922 for entry in std::fs::read_dir(path)? {
923 let entry = entry?;
924 let file_type = entry.file_type()?;
925 entries.push(OverlayDirEntry {
926 path: entry.path(),
927 is_dir: file_type.is_dir(),
928 is_file: file_type.is_file(),
929 });
930 }
931 Ok(entries)
932 }
933 }
934 }
935}
936
937#[cfg(test)]
938mod tests {
939 use super::*;
940
941 #[test]
942 fn writes_land_in_overlay_only() {
943 let dir = tempfile::tempdir().unwrap();
944 let overlay = OverlayFs::rooted_at(dir.path());
945 overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
946 assert!(!dir.path().join("hello.txt").exists());
948 assert_eq!(
950 overlay
951 .read_to_string(&dir.path().join("hello.txt"))
952 .unwrap(),
953 "hi"
954 );
955 }
956
957 #[test]
958 fn reads_pass_through_to_underlying_tree() {
959 let dir = tempfile::tempdir().unwrap();
960 std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
961 let overlay = OverlayFs::rooted_at(dir.path());
962 assert_eq!(
963 overlay
964 .read_to_string(&dir.path().join("seed.txt"))
965 .unwrap(),
966 "underlying"
967 );
968 }
969
970 #[test]
971 fn delete_masks_underlying_file() {
972 let dir = tempfile::tempdir().unwrap();
973 std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
974 let overlay = OverlayFs::rooted_at(dir.path());
975 overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
976 assert!(!overlay.exists(&dir.path().join("doomed.txt")));
977 assert!(dir.path().join("doomed.txt").exists());
979 let diff = overlay.diff();
980 assert_eq!(diff.len(), 1);
981 assert!(matches!(diff[0].kind, DiffKind::Deleted));
982 }
983
984 #[test]
985 fn delete_masks_underlying_directory_contents() {
986 let dir = tempfile::tempdir().unwrap();
987 let nested = dir.path().join("doomed");
988 std::fs::create_dir_all(&nested).unwrap();
989 std::fs::write(nested.join("secret.txt"), "x").unwrap();
990 let overlay = OverlayFs::rooted_at(dir.path());
991
992 overlay.remove_file(&nested).unwrap();
993
994 assert!(!overlay.exists(&nested));
995 assert_eq!(
996 overlay.read_dir(&nested).unwrap_err().kind(),
997 std::io::ErrorKind::NotFound
998 );
999 assert!(nested.join("secret.txt").exists());
1000 }
1001
1002 #[test]
1003 fn recursive_mkdir_creates_visible_overlay_ancestors() {
1004 let dir = tempfile::tempdir().unwrap();
1005 let overlay = OverlayFs::rooted_at(dir.path());
1006 overlay
1007 .create_dir_all(&dir.path().join("alpha/beta/gamma"))
1008 .unwrap();
1009
1010 let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
1011 assert_eq!(root_entries.len(), 1);
1012 assert_eq!(
1013 root_entries[0]
1014 .path
1015 .file_name()
1016 .and_then(|name| name.to_str()),
1017 Some("beta")
1018 );
1019 assert!(root_entries[0].is_dir);
1020 }
1021
1022 #[test]
1023 fn read_dir_reports_missing_empty_overlay_path() {
1024 let dir = tempfile::tempdir().unwrap();
1025 let overlay = OverlayFs::rooted_at(dir.path());
1026
1027 assert_eq!(
1028 overlay
1029 .read_dir(&dir.path().join("missing"))
1030 .unwrap_err()
1031 .kind(),
1032 std::io::ErrorKind::NotFound
1033 );
1034 }
1035
1036 #[test]
1039 fn no_overlay_write_replaces_content() {
1040 let dir = tempfile::tempdir().unwrap();
1041 let target = dir.path().join("important.txt");
1042 std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
1043 assert!(active_overlay().is_none(), "no overlay should be installed");
1044
1045 helpers::write(&target, b"NEW CONTENT").unwrap();
1046
1047 assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW CONTENT");
1048 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
1050 .unwrap()
1051 .filter_map(|e| e.ok())
1052 .map(|e| e.file_name().to_string_lossy().into_owned())
1053 .filter(|n| n.contains("harn-tmp"))
1054 .collect();
1055 assert!(
1056 leftovers.is_empty(),
1057 "temp files left behind: {leftovers:?}"
1058 );
1059 }
1060
1061 #[cfg(unix)]
1074 #[test]
1075 fn no_overlay_write_failure_preserves_original() {
1076 use std::os::unix::fs::PermissionsExt;
1077
1078 let dir = tempfile::tempdir().unwrap();
1079 let target = dir.path().join("important.txt");
1080 std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
1081
1082 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
1085 perms.set_mode(0o500);
1086 std::fs::set_permissions(dir.path(), perms).unwrap();
1087
1088 let result = helpers::write(&target, b"NEW CONTENT");
1089
1090 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
1092 restore.set_mode(0o700);
1093 std::fs::set_permissions(dir.path(), restore).unwrap();
1094
1095 assert_eq!(
1097 std::fs::read_to_string(&target).unwrap(),
1098 "ORIGINAL IMPORTANT CONTENT",
1099 "a write that cannot complete must not truncate or corrupt the original file"
1100 );
1101 assert!(
1104 result.is_err(),
1105 "atomic write should report failure when it cannot create its temp file"
1106 );
1107 }
1108
1109 #[test]
1110 fn diff_distinguishes_added_vs_modified() {
1111 let dir = tempfile::tempdir().unwrap();
1112 std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
1113 let overlay = OverlayFs::rooted_at(dir.path());
1114 overlay
1115 .write(&dir.path().join("existing.txt"), b"v2")
1116 .unwrap();
1117 overlay
1118 .write(&dir.path().join("brand-new.txt"), b"hi")
1119 .unwrap();
1120 let mut diff = overlay.diff();
1121 diff.sort_by(|a, b| a.path.cmp(&b.path));
1122 assert_eq!(diff.len(), 2);
1123 assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
1124 assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
1125 }
1126}