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 absorbs_mutation(&self, path: &Path) -> bool {
94 self.within_root(path)
95 }
96
97 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 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 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 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 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 pub fn render_unified_diff(&self) -> String {
560 render_unified_diff(&self.diff())
561 }
562}
563
564pub 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
614fn 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
638fn 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
697pub mod helpers {
706 use super::*;
707
708 fn record_file_read(path: &Path, bytes: &[u8]) {
709 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 assert!(!dir.path().join("hello.txt").exists());
1080 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 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 #[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 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 #[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 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 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 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 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}