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(&self, path: &Path) -> std::io::Result<()> {
190 if !self.within_root(path) {
191 return std::fs::create_dir(path);
192 }
193 let key = self.key(path);
194 let mut layer = self.layer.lock().expect("overlay layer poisoned");
195 match layer.get(&key) {
196 Some(OverlayEntry::File(_)) | Some(OverlayEntry::Directory) => {
197 return Err(std::io::Error::new(
198 std::io::ErrorKind::AlreadyExists,
199 format!("overlay: {} already exists", key.display()),
200 ));
201 }
202 Some(OverlayEntry::Deleted) | None => {}
203 }
204 if !matches!(layer.get(&key), Some(OverlayEntry::Deleted)) && path.exists() {
205 return Err(std::io::Error::new(
206 std::io::ErrorKind::AlreadyExists,
207 format!("overlay: {} already exists", key.display()),
208 ));
209 }
210 let parent = key.parent().ok_or_else(|| {
211 std::io::Error::new(
212 std::io::ErrorKind::NotFound,
213 format!("overlay: {} has no parent", key.display()),
214 )
215 })?;
216 match layer.get(parent) {
217 Some(OverlayEntry::Directory) => {}
218 Some(OverlayEntry::File(_)) => {
219 return Err(std::io::Error::new(
220 std::io::ErrorKind::NotADirectory,
221 format!("overlay: {} parent is a file", key.display()),
222 ));
223 }
224 Some(OverlayEntry::Deleted) => {
225 return Err(std::io::Error::new(
226 std::io::ErrorKind::NotFound,
227 format!("overlay: {} parent was deleted", key.display()),
228 ));
229 }
230 None if parent.is_dir() => {}
231 None => {
232 return Err(std::io::Error::new(
233 std::io::ErrorKind::NotFound,
234 format!("overlay: {} parent does not exist", key.display()),
235 ));
236 }
237 }
238 layer.insert(key, OverlayEntry::Directory);
239 Ok(())
240 }
241
242 pub fn create_dir_all(&self, path: &Path) -> std::io::Result<()> {
243 if !self.within_root(path) {
244 return std::fs::create_dir_all(path);
245 }
246 let key = self.key(path);
247 let mut layer = self.layer.lock().expect("overlay layer poisoned");
248 if key == self.root {
249 layer.insert(key, OverlayEntry::Directory);
250 return Ok(());
251 }
252 let relative = key.strip_prefix(&self.root).map_err(|_| {
253 std::io::Error::new(
254 std::io::ErrorKind::InvalidInput,
255 format!(
256 "overlay: {} is outside {}",
257 key.display(),
258 self.root.display()
259 ),
260 )
261 })?;
262 let mut current = self.root.clone();
263 for component in relative.components() {
264 current.push(component.as_os_str());
265 layer.insert(current.clone(), OverlayEntry::Directory);
266 }
267 Ok(())
268 }
269
270 pub fn read_dir(&self, path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
271 if !self.within_root(path) {
272 let mut entries = Vec::new();
273 for entry in std::fs::read_dir(path)? {
274 let entry = entry?;
275 entries.push(OverlayDirEntry {
276 path: entry.path(),
277 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
278 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
279 });
280 }
281 return Ok(entries);
282 }
283 let dir_key = self.key(path);
284 let virtual_dir_exists;
285 {
286 let layer = self.layer.lock().expect("overlay layer poisoned");
287 match layer.get(&dir_key) {
288 Some(OverlayEntry::Deleted) => {
289 return Err(std::io::Error::new(
290 std::io::ErrorKind::NotFound,
291 format!("overlay: {} was deleted", dir_key.display()),
292 ));
293 }
294 Some(OverlayEntry::File(_)) => {
295 return Err(std::io::Error::new(
296 std::io::ErrorKind::NotADirectory,
297 format!("overlay: {} is a file", dir_key.display()),
298 ));
299 }
300 Some(OverlayEntry::Directory) => {
301 virtual_dir_exists = true;
302 }
303 None => {
304 virtual_dir_exists = false;
305 }
306 }
307 }
308 let disk_dir_exists = path.exists();
309 let mut entries: BTreeMap<PathBuf, OverlayDirEntry> = BTreeMap::new();
310 if disk_dir_exists {
311 for entry in std::fs::read_dir(path)? {
312 let entry = entry?;
313 let p = entry.path();
314 entries.insert(
315 p.clone(),
316 OverlayDirEntry {
317 path: p,
318 is_dir: entry.file_type().map(|t| t.is_dir()).unwrap_or(false),
319 is_file: entry.file_type().map(|t| t.is_file()).unwrap_or(false),
320 },
321 );
322 }
323 }
324 let layer = self.layer.lock().expect("overlay layer poisoned");
325 for (key, entry) in layer.iter() {
326 if key.parent() != Some(dir_key.as_path()) {
327 continue;
328 }
329 match entry {
330 OverlayEntry::File(_) => {
331 entries.insert(
332 key.clone(),
333 OverlayDirEntry {
334 path: key.clone(),
335 is_dir: false,
336 is_file: true,
337 },
338 );
339 }
340 OverlayEntry::Directory => {
341 entries.insert(
342 key.clone(),
343 OverlayDirEntry {
344 path: key.clone(),
345 is_dir: true,
346 is_file: false,
347 },
348 );
349 }
350 OverlayEntry::Deleted => {
351 entries.remove(key);
352 }
353 }
354 }
355 if entries.is_empty() && !disk_dir_exists && !virtual_dir_exists {
356 return Err(std::io::Error::new(
357 std::io::ErrorKind::NotFound,
358 format!("overlay: {} was not found", dir_key.display()),
359 ));
360 }
361 Ok(entries.into_values().collect())
362 }
363
364 pub fn diff(&self) -> Vec<DiffEntry> {
366 let layer = self.layer.lock().expect("overlay layer poisoned");
367 let mut diff = Vec::new();
368 for (path, entry) in layer.iter() {
369 match entry {
370 OverlayEntry::File(content) => {
371 if path.exists() {
372 let underlying = std::fs::read(path).unwrap_or_default();
373 if &underlying != content {
374 diff.push(DiffEntry {
375 path: path.clone(),
376 kind: DiffKind::Modified {
377 content: content.clone(),
378 },
379 });
380 }
381 } else {
382 diff.push(DiffEntry {
383 path: path.clone(),
384 kind: DiffKind::Added {
385 content: content.clone(),
386 },
387 });
388 }
389 }
390 OverlayEntry::Deleted => {
391 if path.exists() {
392 diff.push(DiffEntry {
393 path: path.clone(),
394 kind: DiffKind::Deleted,
395 });
396 }
397 }
398 OverlayEntry::Directory => {}
399 }
400 }
401 diff
402 }
403
404 pub fn render_unified_diff(&self) -> String {
408 render_unified_diff(&self.diff())
409 }
410}
411
412pub fn render_unified_diff(diff: &[DiffEntry]) -> String {
417 let mut out = String::new();
418 for entry in diff {
419 match &entry.kind {
420 DiffKind::Added { content } => {
421 out.push_str(&format!("--- /dev/null\n+++ b/{}\n", entry.path.display()));
422 push_lines(&mut out, content, '+');
423 }
424 DiffKind::Modified { content } => {
425 let underlying = std::fs::read(&entry.path).unwrap_or_default();
426 out.push_str(&format!(
427 "--- a/{}\n+++ b/{}\n",
428 entry.path.display(),
429 entry.path.display()
430 ));
431 push_lines(&mut out, &underlying, '-');
432 push_lines(&mut out, content, '+');
433 }
434 DiffKind::Deleted => {
435 let underlying = std::fs::read(&entry.path).unwrap_or_default();
436 out.push_str(&format!("--- a/{}\n+++ /dev/null\n", entry.path.display()));
437 push_lines(&mut out, &underlying, '-');
438 }
439 }
440 }
441 out
442}
443
444#[derive(Debug, Clone)]
445pub struct OverlayDirEntry {
446 pub path: PathBuf,
447 pub is_dir: bool,
448 pub is_file: bool,
449}
450
451fn push_lines(out: &mut String, bytes: &[u8], prefix: char) {
452 let text = String::from_utf8_lossy(bytes);
453 for line in text.split_inclusive('\n') {
454 out.push(prefix);
455 out.push_str(line);
456 if !line.ends_with('\n') {
457 out.push('\n');
458 }
459 }
460}
461
462fn normalize_logical(path: &Path) -> PathBuf {
466 let absolute = if path.is_absolute() {
467 path.to_path_buf()
468 } else {
469 std::env::current_dir()
470 .map(|cwd| cwd.join(path))
471 .unwrap_or_else(|_| path.to_path_buf())
472 };
473 let mut out = PathBuf::new();
474 for component in absolute.components() {
475 match component {
476 Component::ParentDir => {
477 out.pop();
478 }
479 Component::CurDir => {}
480 other => out.push(other),
481 }
482 }
483 out
484}
485
486fn canonicalize_for_overlay(path: &Path) -> PathBuf {
492 let absolute = normalize_logical(path);
493 if let Ok(direct) = std::fs::canonicalize(&absolute) {
494 return direct;
495 }
496 let mut suffix = Vec::new();
497 let mut probe = absolute.clone();
498 loop {
499 if let Ok(canon) = std::fs::canonicalize(&probe) {
500 let mut joined = canon;
501 for component in suffix.iter().rev() {
502 joined.push(component);
503 }
504 return joined;
505 }
506 match probe.file_name().map(|n| n.to_owned()) {
507 Some(name) => {
508 suffix.push(name);
509 if !probe.pop() {
510 break;
511 }
512 }
513 None => break,
514 }
515 }
516 absolute
517}
518
519thread_local! {
520 static ACTIVE_OVERLAY: RefCell<Option<Arc<OverlayFs>>> = const { RefCell::new(None) };
521}
522
523pub struct OverlayFsGuard {
524 previous: Option<Arc<OverlayFs>>,
525}
526
527impl Drop for OverlayFsGuard {
528 fn drop(&mut self) {
529 let prev = self.previous.take();
530 ACTIVE_OVERLAY.with(|slot| {
531 *slot.borrow_mut() = prev;
532 });
533 }
534}
535
536pub fn install_overlay(overlay: Arc<OverlayFs>) -> OverlayFsGuard {
537 let previous = ACTIVE_OVERLAY.with(|slot| slot.replace(Some(overlay)));
538 OverlayFsGuard { previous }
539}
540
541pub fn active_overlay() -> Option<Arc<OverlayFs>> {
542 ACTIVE_OVERLAY.with(|slot| slot.borrow().clone())
543}
544
545pub mod helpers {
554 use super::*;
555
556 fn record_file_read(path: &Path, bytes: &[u8]) {
557 if tape::active_recorder().is_none() {
560 return;
561 }
562 let path_str = path.to_string_lossy().into_owned();
563 let len = bytes.len() as u64;
564 let hash = tape::content_hash(bytes);
565 tape::with_active_recorder(|_recorder| {
566 Some(TapeRecordKind::FileRead {
567 path: path_str,
568 content_hash: hash,
569 len_bytes: len,
570 })
571 });
572 }
573
574 fn record_file_write(path: &Path, bytes: &[u8]) {
575 if tape::active_recorder().is_none() {
576 return;
577 }
578 let path_str = path.to_string_lossy().into_owned();
579 let len = bytes.len() as u64;
580 let hash = tape::content_hash(bytes);
581 tape::with_active_recorder(|_recorder| {
582 Some(TapeRecordKind::FileWrite {
583 path: path_str,
584 content_hash: hash,
585 len_bytes: len,
586 })
587 });
588 }
589
590 fn record_file_delete(path: &Path) {
591 if tape::active_recorder().is_none() {
592 return;
593 }
594 let path_str = path.to_string_lossy().into_owned();
595 tape::with_active_recorder(|_recorder| Some(TapeRecordKind::FileDelete { path: path_str }));
596 }
597
598 pub fn read(path: &Path) -> std::io::Result<Vec<u8>> {
599 let result = match active_overlay() {
600 Some(overlay) => overlay.read(path),
601 None => std::fs::read(path),
602 };
603 if let Ok(bytes) = result.as_ref() {
604 record_file_read(path, bytes);
605 }
606 result
607 }
608
609 pub fn read_to_string(path: &Path) -> std::io::Result<String> {
610 let result = match active_overlay() {
611 Some(overlay) => overlay.read_to_string(path),
612 None => std::fs::read_to_string(path),
613 };
614 if let Ok(text) = result.as_ref() {
615 record_file_read(path, text.as_bytes());
616 }
617 result
618 }
619
620 pub fn write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
621 let result = match active_overlay() {
622 Some(overlay) => overlay.write(path, contents),
623 None => atomic_write(path, contents),
624 };
625 if result.is_ok() {
626 record_file_write(path, contents);
627 }
628 result
629 }
630
631 fn atomic_write(path: &Path, contents: &[u8]) -> std::io::Result<()> {
647 use std::io::Write;
648
649 let parent = path.parent().filter(|p| !p.as_os_str().is_empty());
650 let dir = parent.unwrap_or_else(|| Path::new("."));
651
652 let counter = {
656 use std::sync::atomic::{AtomicU64, Ordering};
657 static COUNTER: AtomicU64 = AtomicU64::new(0);
658 COUNTER.fetch_add(1, Ordering::Relaxed)
659 };
660 let file_name = path
661 .file_name()
662 .map(|n| n.to_string_lossy().into_owned())
663 .unwrap_or_default();
664 let tmp_name = format!(".{file_name}.harn-tmp.{}.{counter}", std::process::id());
665 let tmp_path = dir.join(tmp_name);
666
667 let write_result = (|| -> std::io::Result<()> {
670 let mut file = std::fs::File::create(&tmp_path)?;
671 file.write_all(contents)?;
672 file.flush()?;
673 file.sync_all()?;
674 Ok(())
675 })();
676 if let Err(err) = write_result {
677 let _ = std::fs::remove_file(&tmp_path);
679 return Err(err);
680 }
681
682 if let Err(err) = std::fs::rename(&tmp_path, path) {
685 let _ = std::fs::remove_file(&tmp_path);
686 return Err(err);
687 }
688 Ok(())
689 }
690
691 pub fn append(path: &Path, contents: &[u8]) -> std::io::Result<()> {
692 let result = match active_overlay() {
693 Some(overlay) => overlay.append(path, contents),
694 None => std::fs::OpenOptions::new()
695 .create(true)
696 .append(true)
697 .open(path)
698 .and_then(|mut file| std::io::Write::write_all(&mut file, contents)),
699 };
700 if result.is_ok() {
701 record_file_write(path, contents);
702 }
703 result
704 }
705
706 pub fn copy(src: &Path, dst: &Path) -> std::io::Result<u64> {
707 match active_overlay() {
708 Some(overlay) => {
709 let result = overlay.copy(src, dst);
710 if let Ok(bytes) = overlay.read(src) {
711 record_file_read(src, &bytes);
712 if result.is_ok() {
713 record_file_write(dst, &bytes);
714 }
715 }
716 result
717 }
718 None => {
719 let copied = std::fs::copy(src, dst)?;
720 if tape::active_recorder().is_some() {
721 let bytes = std::fs::read(dst)?;
722 record_file_read(src, &bytes);
723 record_file_write(dst, &bytes);
724 }
725 Ok(copied)
726 }
727 }
728 }
729
730 pub fn rename(src: &Path, dst: &Path) -> std::io::Result<u64> {
731 match active_overlay() {
732 Some(overlay) => {
733 let bytes_for_record = overlay.read(src).ok();
734 let result = overlay.rename(src, dst);
735 if result.is_ok() {
736 if let Some(bytes) = bytes_for_record.as_deref() {
737 record_file_read(src, bytes);
738 record_file_write(dst, bytes);
739 record_file_delete(src);
740 }
741 }
742 result
743 }
744 None => {
745 let bytes = tape::active_recorder()
746 .is_some()
747 .then(|| std::fs::read(src))
748 .transpose()?;
749 let len = bytes
750 .as_ref()
751 .map(|bytes| bytes.len() as u64)
752 .or_else(|| std::fs::metadata(src).ok().map(|metadata| metadata.len()))
753 .unwrap_or(0);
754 std::fs::rename(src, dst)?;
755 if let Some(bytes) = bytes.as_deref() {
756 record_file_read(src, bytes);
757 record_file_write(dst, bytes);
758 record_file_delete(src);
759 }
760 Ok(len)
761 }
762 }
763 }
764
765 pub fn exists(path: &Path) -> bool {
766 match active_overlay() {
767 Some(overlay) => overlay.exists(path),
768 None => path.exists(),
769 }
770 }
771
772 pub fn remove_file(path: &Path) -> std::io::Result<()> {
773 let result = match active_overlay() {
774 Some(overlay) => overlay.remove_file(path),
775 None => std::fs::remove_file(path),
776 };
777 if result.is_ok() {
778 record_file_delete(path);
779 }
780 result
781 }
782
783 pub fn create_dir_all(path: &Path) -> std::io::Result<()> {
784 match active_overlay() {
785 Some(overlay) => overlay.create_dir_all(path),
786 None => std::fs::create_dir_all(path),
787 }
788 }
789
790 pub fn create_dir(path: &Path) -> std::io::Result<()> {
791 match active_overlay() {
792 Some(overlay) => overlay.create_dir(path),
793 None => std::fs::create_dir(path),
794 }
795 }
796
797 pub fn read_dir(path: &Path) -> std::io::Result<Vec<OverlayDirEntry>> {
798 match active_overlay() {
799 Some(overlay) => overlay.read_dir(path),
800 None => {
801 let mut entries = Vec::new();
802 for entry in std::fs::read_dir(path)? {
803 let entry = entry?;
804 let file_type = entry.file_type()?;
805 entries.push(OverlayDirEntry {
806 path: entry.path(),
807 is_dir: file_type.is_dir(),
808 is_file: file_type.is_file(),
809 });
810 }
811 Ok(entries)
812 }
813 }
814 }
815}
816
817#[cfg(test)]
818mod tests {
819 use super::*;
820
821 #[test]
822 fn writes_land_in_overlay_only() {
823 let dir = tempfile::tempdir().unwrap();
824 let overlay = OverlayFs::rooted_at(dir.path());
825 overlay.write(&dir.path().join("hello.txt"), b"hi").unwrap();
826 assert!(!dir.path().join("hello.txt").exists());
828 assert_eq!(
830 overlay
831 .read_to_string(&dir.path().join("hello.txt"))
832 .unwrap(),
833 "hi"
834 );
835 }
836
837 #[test]
838 fn reads_pass_through_to_underlying_tree() {
839 let dir = tempfile::tempdir().unwrap();
840 std::fs::write(dir.path().join("seed.txt"), "underlying").unwrap();
841 let overlay = OverlayFs::rooted_at(dir.path());
842 assert_eq!(
843 overlay
844 .read_to_string(&dir.path().join("seed.txt"))
845 .unwrap(),
846 "underlying"
847 );
848 }
849
850 #[test]
851 fn delete_masks_underlying_file() {
852 let dir = tempfile::tempdir().unwrap();
853 std::fs::write(dir.path().join("doomed.txt"), "x").unwrap();
854 let overlay = OverlayFs::rooted_at(dir.path());
855 overlay.remove_file(&dir.path().join("doomed.txt")).unwrap();
856 assert!(!overlay.exists(&dir.path().join("doomed.txt")));
857 assert!(dir.path().join("doomed.txt").exists());
859 let diff = overlay.diff();
860 assert_eq!(diff.len(), 1);
861 assert!(matches!(diff[0].kind, DiffKind::Deleted));
862 }
863
864 #[test]
865 fn delete_masks_underlying_directory_contents() {
866 let dir = tempfile::tempdir().unwrap();
867 let nested = dir.path().join("doomed");
868 std::fs::create_dir_all(&nested).unwrap();
869 std::fs::write(nested.join("secret.txt"), "x").unwrap();
870 let overlay = OverlayFs::rooted_at(dir.path());
871
872 overlay.remove_file(&nested).unwrap();
873
874 assert!(!overlay.exists(&nested));
875 assert_eq!(
876 overlay.read_dir(&nested).unwrap_err().kind(),
877 std::io::ErrorKind::NotFound
878 );
879 assert!(nested.join("secret.txt").exists());
880 }
881
882 #[test]
883 fn recursive_mkdir_creates_visible_overlay_ancestors() {
884 let dir = tempfile::tempdir().unwrap();
885 let overlay = OverlayFs::rooted_at(dir.path());
886 overlay
887 .create_dir_all(&dir.path().join("alpha/beta/gamma"))
888 .unwrap();
889
890 let root_entries = overlay.read_dir(&dir.path().join("alpha")).unwrap();
891 assert_eq!(root_entries.len(), 1);
892 assert_eq!(
893 root_entries[0]
894 .path
895 .file_name()
896 .and_then(|name| name.to_str()),
897 Some("beta")
898 );
899 assert!(root_entries[0].is_dir);
900 }
901
902 #[test]
903 fn read_dir_reports_missing_empty_overlay_path() {
904 let dir = tempfile::tempdir().unwrap();
905 let overlay = OverlayFs::rooted_at(dir.path());
906
907 assert_eq!(
908 overlay
909 .read_dir(&dir.path().join("missing"))
910 .unwrap_err()
911 .kind(),
912 std::io::ErrorKind::NotFound
913 );
914 }
915
916 #[test]
919 fn no_overlay_write_replaces_content() {
920 let dir = tempfile::tempdir().unwrap();
921 let target = dir.path().join("important.txt");
922 std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
923 assert!(active_overlay().is_none(), "no overlay should be installed");
924
925 helpers::write(&target, b"NEW CONTENT").unwrap();
926
927 assert_eq!(std::fs::read_to_string(&target).unwrap(), "NEW CONTENT");
928 let leftovers: Vec<_> = std::fs::read_dir(dir.path())
930 .unwrap()
931 .filter_map(|e| e.ok())
932 .map(|e| e.file_name().to_string_lossy().into_owned())
933 .filter(|n| n.contains("harn-tmp"))
934 .collect();
935 assert!(
936 leftovers.is_empty(),
937 "temp files left behind: {leftovers:?}"
938 );
939 }
940
941 #[cfg(unix)]
954 #[test]
955 fn no_overlay_write_failure_preserves_original() {
956 use std::os::unix::fs::PermissionsExt;
957
958 let dir = tempfile::tempdir().unwrap();
959 let target = dir.path().join("important.txt");
960 std::fs::write(&target, "ORIGINAL IMPORTANT CONTENT").unwrap();
961
962 let mut perms = std::fs::metadata(dir.path()).unwrap().permissions();
965 perms.set_mode(0o500);
966 std::fs::set_permissions(dir.path(), perms).unwrap();
967
968 let result = helpers::write(&target, b"NEW CONTENT");
969
970 let mut restore = std::fs::metadata(dir.path()).unwrap().permissions();
972 restore.set_mode(0o700);
973 std::fs::set_permissions(dir.path(), restore).unwrap();
974
975 assert_eq!(
977 std::fs::read_to_string(&target).unwrap(),
978 "ORIGINAL IMPORTANT CONTENT",
979 "a write that cannot complete must not truncate or corrupt the original file"
980 );
981 assert!(
984 result.is_err(),
985 "atomic write should report failure when it cannot create its temp file"
986 );
987 }
988
989 #[test]
990 fn diff_distinguishes_added_vs_modified() {
991 let dir = tempfile::tempdir().unwrap();
992 std::fs::write(dir.path().join("existing.txt"), "v1").unwrap();
993 let overlay = OverlayFs::rooted_at(dir.path());
994 overlay
995 .write(&dir.path().join("existing.txt"), b"v2")
996 .unwrap();
997 overlay
998 .write(&dir.path().join("brand-new.txt"), b"hi")
999 .unwrap();
1000 let mut diff = overlay.diff();
1001 diff.sort_by(|a, b| a.path.cmp(&b.path));
1002 assert_eq!(diff.len(), 2);
1003 assert!(matches!(diff[0].kind, DiffKind::Added { .. }));
1004 assert!(matches!(diff[1].kind, DiffKind::Modified { .. }));
1005 }
1006}