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