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 return std::fs::write(path, contents);
113 }
114 let key = self.key(path);
115 let mut layer = self.layer.lock().expect("overlay layer poisoned");
116 layer.insert(key, OverlayEntry::File(contents.to_vec()));
117 Ok(())
118 }
119
120 pub fn append(&self, path: &Path, contents: &[u8]) -> std::io::Result<()> {
121 if !self.within_root(path) {
122 return std::fs::OpenOptions::new()
123 .create(true)
124 .append(true)
125 .open(path)
126 .and_then(|mut file| std::io::Write::write_all(&mut file, contents));
127 }
128 let mut combined = match self.read(path) {
129 Ok(bytes) => bytes,
130 Err(err) if err.kind() == std::io::ErrorKind::NotFound => Vec::new(),
131 Err(err) => return Err(err),
132 };
133 combined.extend_from_slice(contents);
134 self.write(path, &combined)
135 }
136
137 pub fn copy(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
138 let bytes = self.read(src)?;
139 let len = bytes.len() as u64;
140 self.write(dst, &bytes)?;
141 Ok(len)
142 }
143
144 pub fn rename(&self, src: &Path, dst: &Path) -> std::io::Result<u64> {
145 let len = self.copy(src, dst)?;
146 self.remove_file(src)?;
147 Ok(len)
148 }
149
150 pub fn exists(&self, path: &Path) -> bool {
151 if !self.within_root(path) {
152 return path.exists();
153 }
154 let key = self.key(path);
155 let layer = self.layer.lock().expect("overlay layer poisoned");
156 match layer.get(&key) {
157 Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => true,
158 Some(OverlayEntry::Deleted) => false,
159 None => path.exists(),
160 }
161 }
162
163 pub fn remove_file(&self, path: &Path) -> std::io::Result<()> {
164 if !self.within_root(path) {
165 return std::fs::remove_file(path);
166 }
167 let key = self.key(path);
168 let mut layer = self.layer.lock().expect("overlay layer poisoned");
169 let underlying_present = path.exists();
172 match layer.get(&key) {
173 Some(OverlayEntry::Deleted) => Err(std::io::Error::new(
174 std::io::ErrorKind::NotFound,
175 format!("overlay: {} already deleted", key.display()),
176 )),
177 _ => {
178 layer.retain(|entry_path, _| !entry_path.starts_with(&key) || entry_path == &key);
179 if underlying_present {
180 layer.insert(key, OverlayEntry::Deleted);
181 } else {
182 layer.remove(&key);
183 }
184 Ok(())
185 }
186 }
187 }
188
189 pub fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
190 if !self.within_root(path) {
191 return std::fs::create_dir_all(path);
192 }
193 let key = self.key(path);
194 let mut layer = self.layer.lock().expect("overlay layer poisoned");
195 if key == self.root {
196 layer.insert(key, OverlayEntry::Directory);
197 return Ok(());
198 }
199 let relative = key.strip_prefix(&self.root).map_err(|_| {
200 std::io::Error::new(
201 std::io::ErrorKind::InvalidInput,
202 format!(
203 "overlay: {} is outside {}",
204 key.display(),
205 self.root.display()
206 ),
207 )
208 })?;
209 let mut current = self.root.clone();
210 for component in relative.components() {
211 current.push(component.as_os_str());
212 layer.insert(current.clone(), OverlayEntry::Directory);
213 }
214 Ok(())
215 }
216
217 pub fn read_dir(&self, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
218 if !self.within_root(path) {
219 let mut entries = Vec::new();
220 for entry in std::fs::read_dir(path)? {
221 let entry = entry?;
222 entries.push(OverlayDirEntry {
223 path: entry.path(),
224 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
225 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
226 });
227 }
228 return Ok(entries);
229 }
230 let dir_key = self.key(path);
231 let virtual_dir_exists;
232 {
233 let layer = self.layer.lock().expect("overlay layer poisoned");
234 match layer.get(&dir_key) {
235 Some(OverlayEntry::Deleted) => {
236 return Err(std::io::Error::new(
237 std::io::ErrorKind::NotFound,
238 format!("overlay: {} was deleted", dir_key.display()),
239 ));
240 }
241 Some(OverlayEntry::File(_)) => {
242 return Err(std::io::Error::new(
243 std::io::ErrorKind::NotADirectory,
244 format!("overlay: {} is a file", dir_key.display()),
245 ));
246 }
247 Some(OverlayEntry::Directory) => {
248 virtual_dir_exists = true;
249 }
250 None => {
251 virtual_dir_exists = false;
252 }
253 }
254 }
255 let disk_dir_exists = path.exists();
256 let mut entries: BTreeMap<PathBuf, OverlayDirEntry> = BTreeMap::new();
257 if disk_dir_exists {
258 for entry in std::fs::read_dir(path)? {
259 let entry = entry?;
260 let p = entry.path();
261 entries.insert(
262 p.clone(),
263 OverlayDirEntry {
264 path: p,
265 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
266 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
267 },
268 );
269 }
270 }
271 let layer = self.layer.lock().expect("overlay layer poisoned");
272 for (key, entry) in layer.iter() {
273 if key.parent() != Some(dir_key.as_path()) {
274 continue;
275 }
276 match entry {
277 OverlayEntry::File(_) => {
278 entries.insert(
279 key.clone(),
280 OverlayDirEntry {
281 path: key.clone(),
282 is_dir: false,
283 is_file: true,
284 },
285 );
286 }
287 OverlayEntry::Directory => {
288 entries.insert(
289 key.clone(),
290 OverlayDirEntry {
291 path: key.clone(),
292 is_dir: true,
293 is_file: false,
294 },
295 );
296 }
297 OverlayEntry::Deleted => {
298 entries.remove(key);
299 }
300 }
301 }
302 if entries.is_empty() && !disk_dir_exists && !virtual_dir_exists {
303 return Err(std::io::Error::new(
304 std::io::ErrorKind::NotFound,
305 format!("overlay: {} was not found", dir_key.display()),
306 ));
307 }
308 Ok(entries.into_values().collect())
309 }
310
311 pub fn diff(&self) -> Vec<DiffEntry> {
313 let layer = self.layer.lock().expect("overlay layer poisoned");
314 let mut diff = Vec::new();
315 for (path, entry) in layer.iter() {
316 match entry {
317 OverlayEntry::File(content) => {
318 if path.exists() {
319 let underlying = std::fs::read(path).unwrap_or_default();
320 if &underlying != content {
321 diff.push(DiffEntry {
322 path: path.clone(),
323 kind: DiffKind::Modified {
324 content: content.clone(),
325 },
326 });
327 }
328 } else {
329 diff.push(DiffEntry {
330 path: path.clone(),
331 kind: DiffKind::Added {
332 content: content.clone(),
333 },
334 });
335 }
336 }
337 OverlayEntry::Deleted => {
338 if path.exists() {
339 diff.push(DiffEntry {
340 path: path.clone(),
341 kind: DiffKind::Deleted,
342 });
343 }
344 }
345 OverlayEntry::Directory => {}
346 }
347 }
348 diff
349 }
350
351 pub fn render_unified_diff(&self) -> String {
355 render_unified_diff(&self.diff())
356 }
357}
358
359pub fn render_unified_diff(diff: &[DiffEntry]) -> String {
364 let mut out = String::new();
365 for entry in diff {
366 match &entry.kind {
367 DiffKind::Added { content } => {
368 out.push_str(&format!("--- /dev/null\n+++ b/{}\n", entry.path.display()));
369 push_lines(&mut out, content, '+');
370 }
371 DiffKind::Modified { content } => {
372 let underlying = std::fs::read(&entry.path).unwrap_or_default();
373 out.push_str(&format!(
374 "--- a/{}\n+++ b/{}\n",
375 entry.path.display(),
376 entry.path.display()
377 ));
378 push_lines(&mut out, &underlying, '-');
379 push_lines(&mut out, content, '+');
380 }
381 DiffKind::Deleted => {
382 let underlying = std::fs::read(&entry.path).unwrap_or_default();
383 out.push_str(&format!("--- a/{}\n+++ /dev/null\n", entry.path.display()));
384 push_lines(&mut out, &underlying, '-');
385 }
386 }
387 }
388 out
389}
390
391#[derive(Debug, Clone)]
392pub struct OverlayDirEntry {
393 pub path: PathBuf,
394 pub is_dir: bool,
395 pub is_file: bool,
396}
397
398fn push_lines(out: &mut String, bytes: &[u8], prefix: char) {
399 let text = String::from_utf8_lossy(bytes);
400 for line in text.split_inclusive('\n') {
401 out.push(prefix);
402 out.push_str(line);
403 if !line.ends_with('\n') {
404 out.push('\n');
405 }
406 }
407}
408
409fn normalize_logical(path: &Path) -> PathBuf {
413 let absolute = if path.is_absolute() {
414 path.to_path_buf()
415 } else {
416 std::env::current_dir()
417 .map(|cwd| cwd.join(path))
418 .unwrap_or_else(|_| path.to_path_buf())
419 };
420 let mut out = PathBuf::new();
421 for component in absolute.components() {
422 match component {
423 Component::ParentDir => {
424 out.pop();
425 }
426 Component::CurDir => {}
427 other => out.push(other),
428 }
429 }
430 out
431}
432
433fn canonicalize_for_overlay(path: &Path) -> PathBuf {
439 let absolute = normalize_logical(path);
440 if let Ok(direct) = std::fs::canonicalize(&absolute) {
441 return direct;
442 }
443 let mut suffix = Vec::new();
444 let mut probe = absolute.clone();
445 loop {
446 if let Ok(canon) = std::fs::canonicalize(&probe) {
447 let mut joined = canon;
448 for component in suffix.iter().rev() {
449 joined.push(component);
450 }
451 return joined;
452 }
453 match probe.file_name().map(|n| n.to_owned()) {
454 Some(name) => {
455 suffix.push(name);
456 if !probe.pop() {
457 break;
458 }
459 }
460 None => break,
461 }
462 }
463 absolute
464}
465
466thread_local! {
467 static ACTIVE_OVERLAY: RefCell<Option<Arc<OverlayFs>>> = const { RefCell::new(None) };
468}
469
470pub struct OverlayFsGuard {
471 previous: Option<Arc<OverlayFs>>,
472}
473
474impl Drop for OverlayFsGuard {
475 fn drop(&mut self) {
476 let prev = self.previous.take();
477 ACTIVE_OVERLAY.with(|slot| {
478 *slot.borrow_mut() = prev;
479 });
480 }
481}
482
483pub fn install_overlay(overlay: Arc<OverlayFs>) -> OverlayFsGuard {
484 let previous = ACTIVE_OVERLAY.with(|slot| slot.replace(Some(overlay)));
485 OverlayFsGuard { previous }
486}
487
488pub fn active_overlay() -> Option<Arc<OverlayFs>> {
489 ACTIVE_OVERLAY.with(|slot| slot.borrow().clone())
490}
491
492pub mod helpers {
501 use super::*;
502
503 fn record_file_read(path: &Path, bytes: &[u8]) {
504 if tape::active_recorder().is_none() {
507 return;
508 }
509 let path_str = path.to_string_lossy().into_owned();
510 let len = bytes.len() as u64;
511 let hash = tape::content_hash(bytes);
512 tape::with_active_recorder(|_recorder| {
513 Some(TapeRecordKind::FileRead {
514 path: path_str,
515 content_hash: hash,
516 len_bytes: len,
517 })
518 });
519 }
520
521 fn record_file_write(path: &Path, bytes: &[u8]) {
522 if tape::active_recorder().is_none() {
523 return;
524 }
525 let path_str = path.to_string_lossy().into_owned();
526 let len = bytes.len() as u64;
527 let hash = tape::content_hash(bytes);
528 tape::with_active_recorder(|_recorder| {
529 Some(TapeRecordKind::FileWrite {
530 path: path_str,
531 content_hash: hash,
532 len_bytes: len,
533 })
534 });
535 }
536
537 fn record_file_delete(path: &Path) {
538 if tape::active_recorder().is_none() {
539 return;
540 }
541 let path_str = path.to_string_lossy().into_owned();
542 tape::with_active_recorder(|_recorder| Some(TapeRecordKind::FileDelete { path: path_str }));
543 }
544
545 pub fn read(path: &Path) -> std::io::Result<Vec<u8>> {
546 let result = match active_overlay() {
547 Some(overlay) => overlay.read(path),
548 None => std::fs::read(path),
549 };
550 if let Ok(bytes) = result.as_ref() {
551 record_file_read(path, bytes);
552 }
553 result
554 }
555
556 pub fn read_to_string(path: &Path) -> std::io::Result<String> {
557 let result = match active_overlay() {
558 Some(overlay) => overlay.read_to_string(path),
559 None => std::fs::read_to_string(path),
560 };
561 if let Ok(text) = result.as_ref() {
562 record_file_read(path, text.as_bytes());
563 }
564 result
565 }
566
567 pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
568 let result = match active_overlay() {
569 Some(overlay) => overlay.write(path, contents),
570 None => std::fs::write(path, contents),
571 };
572 if result.is_ok() {
573 record_file_write(path, contents);
574 }
575 result
576 }
577
578 pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
579 let result = match active_overlay() {
580 Some(overlay) => overlay.append(path, contents),
581 None => std::fs::OpenOptions::new()
582 .create(true)
583 .append(true)
584 .open(path)
585 .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
586 };
587 if result.is_ok() {
588 record_file_write(path, contents);
589 }
590 result
591 }
592
593 pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
594 match active_overlay() {
595 Some(overlay) => {
596 let result = overlay.copy(src, dst);
597 if let Ok(bytes) = overlay.read(src) {
598 record_file_read(src, &bytes);
599 if result.is_ok() {
600 record_file_write(dst, &bytes);
601 }
602 }
603 result
604 }
605 None => {
606 let copied = std::fs::copy(src, dst)?;
607 if tape::active_recorder().is_some() {
608 let bytes = std::fs::read(dst)?;
609 record_file_read(src, &bytes);
610 record_file_write(dst, &bytes);
611 }
612 Ok(copied)
613 }
614 }
615 }
616
617 pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
618 match active_overlay() {
619 Some(overlay) => {
620 let bytes_for_record = overlay.read(src).ok();
621 let result = overlay.rename(src, dst);
622 if result.is_ok() {
623 if let Some(bytes) = bytes_for_record.as_deref() {
624 record_file_read(src, bytes);
625 record_file_write(dst, bytes);
626 record_file_delete(src);
627 }
628 }
629 result
630 }
631 None => {
632 let bytes = tape::active_recorder()
633 .is_some()
634 .then(|| std::fs::read(src))
635 .transpose()?;
636 let len = bytes
637 .as_ref()
638 .map(|bytes| bytes.len() as u64)
639 .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
640 .unwrap_or(0);
641 std::fs::rename(src, dst)?;
642 if let Some(bytes) = bytes.as_deref() {
643 record_file_read(src, bytes);
644 record_file_write(dst, bytes);
645 record_file_delete(src);
646 }
647 Ok(len)
648 }
649 }
650 }
651
652 pub fn exists(path: &Path) -> bool {
653 match active_overlay() {
654 Some(overlay) => overlay.exists(path),
655 None => path.exists(),
656 }
657 }
658
659 pub fn remove_file(path: &Path) -> std::io::Result<()> {
660 let result = match active_overlay() {
661 Some(overlay) => overlay.remove_file(path),
662 None => std::fs::remove_file(path),
663 };
664 if result.is_ok() {
665 record_file_delete(path);
666 }
667 result
668 }
669
670 pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
671 match active_overlay() {
672 Some(overlay) => overlay.create_dir_all(path),
673 None => std::fs::create_dir_all(path),
674 }
675 }
676
677 pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
678 match active_overlay() {
679 Some(overlay) => overlay.read_dir(path),
680 None => {
681 let mut entries = Vec::new();
682 for entry in std::fs::read_dir(path)? {
683 let entry = entry?;
684 let file_type = entry.file_type()?;
685 entries.push(OverlayDirEntry {
686 path: entry.path(),
687 is_dir: file_type.is_dir(),
688 is_file: file_type.is_file(),
689 });
690 }
691 Ok(entries)
692 }
693 }
694 }
695}
696
697#[cfg(test)]
698mod tests {
699 use super::*;
700
701 #[test]
702 fn writes_land_in_overlay_only() {
703 let dir = tempfile::tempdir().unwrap();
704 let overlay = OverlayFs::rooted_at(dir.path());
705 overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
706 assert!(!dir.path().join("hello.txt").exists());
708 assert_eq!(
710 overlay
711 .read_to_string(&dir.path().join("hello.txt"))
712 .unwrap(),
713 "hi"
714 );
715 }
716
717 #[test]
718 fn reads_pass_through_to_underlying_tree() {
719 let dir = tempfile::tempdir().unwrap();
720 std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
721 let overlay = OverlayFs::rooted_at(dir.path());
722 assert_eq!(
723 overlay
724 .read_to_string(&dir.path().join("seed.txt"))
725 .unwrap(),
726 "underlying"
727 );
728 }
729
730 #[test]
731 fn delete_masks_underlying_file() {
732 let dir = tempfile::tempdir().unwrap();
733 std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
734 let overlay = OverlayFs::rooted_at(dir.path());
735 overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
736 assert!(!overlay.exists(&dir.path().join("doomed.txt")));
737 assert!(dir.path().join("doomed.txt").exists());
739 let diff = overlay.diff();
740 assert_eq!(diff.len(), 1);
741 assert!(matches!(diff[0].kind, DiffKind::Deleted));
742 }
743
744 #[test]
745 fn delete_masks_underlying_directory_contents() {
746 let dir = tempfile::tempdir().unwrap();
747 let nested = dir.path().join("doomed");
748 std::fs::create_dir_all(&nested).unwrap();
749 std::fs::write(nested.join("secret.txt"), "x").unwrap();
750 let overlay = OverlayFs::rooted_at(dir.path());
751
752 overlay.remove_file(&nested).unwrap();
753
754 assert!(!overlay.exists(&nested));
755 assert_eq!(
756 overlay.read_dir(&nested).unwrap_err().kind(),
757 std::io::ErrorKind::NotFound
758 );
759 assert!(nested.join("secret.txt").exists());
760 }
761
762 #[test]
763 fn recursive_mkdir_creates_visible_overlay_ancestors() {
764 let dir = tempfile::tempdir().unwrap();
765 let overlay = OverlayFs::rooted_at(dir.path());
766 overlay
767 .create_dir_all(&dir.path().join("alpha/beta/gamma"))
768 .unwrap();
769
770 let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
771 assert_eq!(root_entries.len(), 1);
772 assert_eq!(
773 root_entries[0]
774 .path
775 .file_name()
776 .and_then(|name| name.to_str()),
777 Some("beta")
778 );
779 assert!(root_entries[0].is_dir);
780 }
781
782 #[test]
783 fn read_dir_reports_missing_empty_overlay_path() {
784 let dir = tempfile::tempdir().unwrap();
785 let overlay = OverlayFs::rooted_at(dir.path());
786
787 assert_eq!(
788 overlay
789 .read_dir(&dir.path().join("missing"))
790 .unwrap_err()
791 .kind(),
792 std::io::ErrorKind::NotFound
793 );
794 }
795
796 #[test]
797 fn diff_distinguishes_added_vs_modified() {
798 let dir = tempfile::tempdir().unwrap();
799 std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
800 let overlay = OverlayFs::rooted_at(dir.path());
801 overlay
802 .write(&dir.path().join("existing.txt"), b"v2")
803 .unwrap();
804 overlay
805 .write(&dir.path().join("brand-new.txt"), b"hi")
806 .unwrap();
807 let mut diff = overlay.diff();
808 diff.sort_by(|a, b| a.path.cmp(&b.path));
809 assert_eq!(diff.len(), 2);
810 assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
811 assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
812 }
813}