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