1use std::fmt;
40use std::fs;
41use std::path::{Path, PathBuf};
42use std::process::{Command, Stdio};
43
44use super::{SnapshotResult, WorkspaceBackend, WorkspaceStatus};
45use crate::model::types::{EpochId, WorkspaceId, WorkspaceInfo, WorkspaceMode, WorkspaceState};
46
47#[derive(Debug)]
53pub enum OverlayBackendError {
54 NotLinux,
56 NotSupported { reason: String },
58 Io(std::io::Error),
60 Command {
62 command: String,
63 stderr: String,
64 exit_code: Option<i32>,
65 },
66 NotFound { name: String },
68}
69
70impl fmt::Display for OverlayBackendError {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 match self {
73 Self::NotLinux => write!(
74 f,
75 "OverlayFS backend is Linux-only. \
76 Use the git-worktree or reflink backend on this platform."
77 ),
78 Self::NotSupported { reason } => write!(
79 f,
80 "OverlayFS not available on this system: {reason}\n\
81 Install fuse-overlayfs (>= 0.7) or use a kernel >= 5.11 with \
82 user namespace overlayfs support."
83 ),
84 Self::Io(e) => write!(f, "I/O error: {e}"),
85 Self::Command {
86 command,
87 stderr,
88 exit_code,
89 } => {
90 write!(f, "`{command}` failed")?;
91 if let Some(code) = exit_code {
92 write!(f, " (exit {code})")?;
93 }
94 if !stderr.is_empty() {
95 write!(f, ": {stderr}")?;
96 }
97 Ok(())
98 }
99 Self::NotFound { name } => write!(f, "workspace '{name}' not found"),
100 }
101 }
102}
103
104impl std::error::Error for OverlayBackendError {
105 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
106 match self {
107 Self::Io(e) => Some(e),
108 _ => None,
109 }
110 }
111}
112
113impl From<std::io::Error> for OverlayBackendError {
114 fn from(e: std::io::Error) -> Self {
115 Self::Io(e)
116 }
117}
118
119#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum MountStrategy {
126 FuseOverlayfs,
128 KernelUserNamespace,
130}
131
132impl MountStrategy {
133 #[must_use]
137 pub fn detect() -> Option<Self> {
138 if !is_linux() {
139 return None;
140 }
141 if command_available("fuse-overlayfs") {
142 return Some(Self::FuseOverlayfs);
143 }
144 if kernel_userns_overlay_available() {
145 return Some(Self::KernelUserNamespace);
146 }
147 None
148 }
149}
150
151pub struct OverlayBackend {
160 root: PathBuf,
162 strategy: MountStrategy,
164}
165
166impl OverlayBackend {
167 pub fn new(root: PathBuf) -> Result<Self, OverlayBackendError> {
175 if !is_linux() {
176 return Err(OverlayBackendError::NotLinux);
177 }
178 let strategy =
179 MountStrategy::detect().ok_or_else(|| OverlayBackendError::NotSupported {
180 reason:
181 "no fuse-overlayfs binary found and kernel user-namespace overlay unavailable"
182 .to_owned(),
183 })?;
184 Ok(Self { root, strategy })
185 }
186
187 fn workspaces_dir(&self) -> PathBuf {
191 self.root.join("ws")
192 }
193
194 fn mount_point(&self, name: &WorkspaceId) -> PathBuf {
196 self.workspaces_dir().join(name.as_str())
197 }
198
199 fn epoch_snapshot_dir(&self, epoch: &EpochId) -> PathBuf {
201 self.root
202 .join(".manifold")
203 .join("epochs")
204 .join(format!("e-{}", epoch.as_str()))
205 }
206
207 fn upper_dir(&self, name: &WorkspaceId) -> PathBuf {
209 self.root
210 .join(".manifold")
211 .join("cow")
212 .join(name.as_str())
213 .join("upper")
214 }
215
216 fn work_dir(&self, name: &WorkspaceId) -> PathBuf {
218 self.root
219 .join(".manifold")
220 .join("cow")
221 .join(name.as_str())
222 .join("work")
223 }
224
225 fn workspace_epoch_file(&self, name: &WorkspaceId) -> PathBuf {
227 self.root
228 .join(".manifold")
229 .join("cow")
230 .join(name.as_str())
231 .join("epoch")
232 }
233
234 fn epoch_refcount_path(&self, epoch: &EpochId) -> PathBuf {
236 self.epoch_snapshot_dir(epoch).join(".refcount")
237 }
238
239 fn ensure_epoch_snapshot(&self, epoch: &EpochId) -> Result<PathBuf, OverlayBackendError> {
246 let snapshot_dir = self.epoch_snapshot_dir(epoch);
247
248 if snapshot_dir.exists() {
250 let has_content = fs::read_dir(&snapshot_dir)
251 .map(|mut rd| rd.any(|e| e.ok().is_some_and(|e| e.file_name() != ".refcount")))
252 .unwrap_or(false);
253 if has_content {
254 return Ok(snapshot_dir);
255 }
256 }
257
258 fs::create_dir_all(&snapshot_dir)?;
260
261 let archive_cmd = format!(
263 "git -C '{}' archive '{}' | tar -x -C '{}'",
264 self.root.display(),
265 epoch.as_str(),
266 snapshot_dir.display()
267 );
268
269 let output = Command::new("sh")
270 .args(["-c", &archive_cmd])
271 .stdout(Stdio::null())
272 .stderr(Stdio::piped())
273 .output()?;
274
275 if !output.status.success() {
276 let _ = fs::remove_dir_all(&snapshot_dir);
277 return Err(OverlayBackendError::Command {
278 command: format!("git archive {} | tar -x", epoch.as_str()),
279 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
280 exit_code: output.status.code(),
281 });
282 }
283
284 Ok(snapshot_dir)
285 }
286
287 fn epoch_refcount_inc(&self, epoch: &EpochId) -> Result<(), OverlayBackendError> {
289 let path = self.epoch_refcount_path(epoch);
290 let count = self.read_refcount(epoch);
291 fs::write(&path, (count + 1).to_string())?;
292 Ok(())
293 }
294
295 fn epoch_refcount_dec(&self, epoch: &EpochId) -> Result<u32, OverlayBackendError> {
299 let count = self.read_refcount(epoch);
300 let new_count = count.saturating_sub(1);
301 let path = self.epoch_refcount_path(epoch);
302 fs::write(&path, new_count.to_string())?;
303 Ok(new_count)
304 }
305
306 fn read_refcount(&self, epoch: &EpochId) -> u32 {
308 let path = self.epoch_refcount_path(epoch);
309 fs::read_to_string(path)
310 .ok()
311 .and_then(|s| s.trim().parse::<u32>().ok())
312 .unwrap_or(0)
313 }
314
315 fn maybe_remove_epoch_snapshot(&self, epoch: &EpochId) -> Result<(), OverlayBackendError> {
317 let count = self.read_refcount(epoch);
318 if count == 0 {
319 let snapshot_dir = self.epoch_snapshot_dir(epoch);
320 if snapshot_dir.exists() {
321 fs::remove_dir_all(&snapshot_dir)?;
322 }
323 }
324 Ok(())
325 }
326
327 fn cleanup_partial_workspace(&self, name: &WorkspaceId) {
329 let _ = self.unmount_overlay(name);
330
331 let mount_point = self.mount_point(name);
332 if mount_point.exists() {
333 let _ = fs::remove_dir_all(&mount_point);
334 }
335
336 let cow_dir = self.root.join(".manifold").join("cow").join(name.as_str());
337 if cow_dir.exists() {
338 let _ = fs::remove_dir_all(&cow_dir);
339 }
340 }
341
342 fn mount_overlay(
348 &self,
349 name: &WorkspaceId,
350 epoch: &EpochId,
351 ) -> Result<(), OverlayBackendError> {
352 let mount_point = self.mount_point(name);
353
354 if is_overlay_mounted(&mount_point) {
356 return Ok(());
357 }
358
359 let snapshot_dir = self.ensure_epoch_snapshot(epoch)?;
360 let upper_dir = self.upper_dir(name);
361 let work_dir = self.work_dir(name);
362
363 fs::create_dir_all(&mount_point)?;
364 fs::create_dir_all(&upper_dir)?;
365 fs::create_dir_all(&work_dir)?;
366
367 match self.strategy {
368 MountStrategy::FuseOverlayfs => {
369 Self::mount_fuse_overlayfs(&snapshot_dir, &upper_dir, &work_dir, &mount_point)?;
370 }
371 MountStrategy::KernelUserNamespace => {
372 Self::mount_kernel_overlay(&snapshot_dir, &upper_dir, &work_dir, &mount_point)?;
373 }
374 }
375
376 Ok(())
377 }
378
379 fn mount_fuse_overlayfs(
381 lower: &Path,
382 upper: &Path,
383 work: &Path,
384 merged: &Path,
385 ) -> Result<(), OverlayBackendError> {
386 let options = format!(
387 "lowerdir={},upperdir={},workdir={}",
388 lower.display(),
389 upper.display(),
390 work.display()
391 );
392 let output = Command::new("fuse-overlayfs")
393 .args(["-o", &options, merged.to_str().unwrap()])
394 .stdout(Stdio::null())
395 .stderr(Stdio::piped())
396 .output()?;
397
398 if !output.status.success() {
399 return Err(OverlayBackendError::Command {
400 command: "fuse-overlayfs".to_owned(),
401 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
402 exit_code: output.status.code(),
403 });
404 }
405
406 Ok(())
407 }
408
409 fn mount_kernel_overlay(
415 lower: &Path,
416 upper: &Path,
417 work: &Path,
418 merged: &Path,
419 ) -> Result<(), OverlayBackendError> {
420 let shell_cmd = format!(
421 "mount -t overlay overlay -o lowerdir='{}',upperdir='{}',workdir='{}' '{}'",
422 lower.display(),
423 upper.display(),
424 work.display(),
425 merged.display()
426 );
427 let output = Command::new("unshare")
428 .args(["-Ur", "sh", "-c", &shell_cmd])
429 .stdout(Stdio::null())
430 .stderr(Stdio::piped())
431 .output()?;
432
433 if !output.status.success() {
434 return Err(OverlayBackendError::Command {
435 command: "unshare -Ur mount -t overlay".to_owned(),
436 stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
437 exit_code: output.status.code(),
438 });
439 }
440
441 Ok(())
442 }
443
444 fn unmount_overlay(&self, name: &WorkspaceId) -> Result<(), OverlayBackendError> {
450 let mount_point = self.mount_point(name);
451
452 if !mount_point.exists() || !is_overlay_mounted(&mount_point) {
453 return Ok(());
454 }
455
456 let mp_str = mount_point.to_str().unwrap();
457
458 for cmd in &[
460 vec!["fusermount3", "-u", mp_str],
461 vec!["fusermount", "-u", mp_str],
462 vec!["umount", "-l", mp_str],
463 ] {
464 let status = Command::new(cmd[0])
465 .args(&cmd[1..])
466 .stdout(Stdio::null())
467 .stderr(Stdio::null())
468 .status();
469
470 if let Ok(s) = status
471 && s.success()
472 {
473 return Ok(());
474 }
475 }
476
477 let shell_cmd = format!("umount -l '{mp_str}'");
479 let output = Command::new("unshare")
480 .args(["-Ur", "sh", "-c", &shell_cmd])
481 .stdout(Stdio::null())
482 .stderr(Stdio::piped())
483 .output()?;
484
485 if !output.status.success() {
486 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
489 if !stderr.is_empty() {
490 tracing::warn!("unmount failed: {stderr}");
492 }
493 }
494
495 Ok(())
496 }
497
498 fn write_workspace_epoch(
502 &self,
503 name: &WorkspaceId,
504 epoch: &EpochId,
505 ) -> Result<(), OverlayBackendError> {
506 let epoch_file = self.workspace_epoch_file(name);
507 if let Some(parent) = epoch_file.parent() {
508 fs::create_dir_all(parent)?;
509 }
510 fs::write(&epoch_file, epoch.as_str())?;
511 Ok(())
512 }
513
514 fn read_workspace_epoch(&self, name: &WorkspaceId) -> Result<EpochId, OverlayBackendError> {
516 let epoch_file = self.workspace_epoch_file(name);
517 let content = fs::read_to_string(&epoch_file)?;
518 let oid = content.trim();
519 EpochId::new(oid).map_err(|e| OverlayBackendError::Command {
520 command: format!("read epoch file for workspace '{}'", name.as_str()),
521 stderr: format!("invalid OID in epoch file: {e}"),
522 exit_code: None,
523 })
524 }
525}
526
527impl WorkspaceBackend for OverlayBackend {
532 type Error = OverlayBackendError;
533
534 fn create(&self, name: &WorkspaceId, epoch: &EpochId) -> Result<WorkspaceInfo, Self::Error> {
535 let mount_point = self.mount_point(name);
536
537 if is_overlay_mounted(&mount_point) {
539 let stored_epoch = self
540 .read_workspace_epoch(name)
541 .unwrap_or_else(|_| epoch.clone());
542 return Ok(WorkspaceInfo {
543 id: name.clone(),
544 path: mount_point,
545 epoch: stored_epoch,
546 state: WorkspaceState::Active,
547 mode: WorkspaceMode::default(),
548 commits_ahead: 0,
549 });
550 }
551
552 fs::create_dir_all(self.upper_dir(name))?;
554 fs::create_dir_all(self.work_dir(name))?;
555
556 self.write_workspace_epoch(name, epoch)?;
558
559 self.ensure_epoch_snapshot(epoch)?;
561
562 if let Err(err) = self.mount_overlay(name, epoch) {
564 self.cleanup_partial_workspace(name);
565 return Err(err);
566 }
567
568 if let Err(err) = self.epoch_refcount_inc(epoch) {
570 self.cleanup_partial_workspace(name);
571 return Err(err);
572 }
573
574 Ok(WorkspaceInfo {
575 id: name.clone(),
576 path: mount_point,
577 epoch: epoch.clone(),
578 state: WorkspaceState::Active,
579 mode: WorkspaceMode::default(),
580 commits_ahead: 0,
581 })
582 }
583
584 fn destroy(&self, name: &WorkspaceId) -> Result<(), Self::Error> {
585 self.unmount_overlay(name)?;
587
588 let epoch_opt = self.read_workspace_epoch(name).ok();
590
591 let mount_point = self.mount_point(name);
593 if mount_point.exists() {
594 fs::remove_dir_all(&mount_point)?;
595 }
596
597 let cow_dir = self.root.join(".manifold").join("cow").join(name.as_str());
599 if cow_dir.exists() {
600 fs::remove_dir_all(&cow_dir)?;
601 }
602
603 if let Some(epoch) = epoch_opt {
605 let remaining = self.epoch_refcount_dec(&epoch)?;
606 if remaining == 0 {
607 self.maybe_remove_epoch_snapshot(&epoch)?;
608 }
609 }
610
611 Ok(())
612 }
613
614 fn list(&self) -> Result<Vec<WorkspaceInfo>, Self::Error> {
615 let cow_dir = self.root.join(".manifold").join("cow");
616 if !cow_dir.exists() {
617 return Ok(vec![]);
618 }
619
620 let mut infos = Vec::new();
621
622 for entry in fs::read_dir(&cow_dir)? {
623 let entry = entry?;
624 let file_name = entry.file_name();
625 let Some(name_str) = file_name.to_str() else {
626 continue;
627 };
628
629 let Ok(name) = WorkspaceId::new(name_str) else {
630 continue;
631 };
632
633 let Ok(epoch) = self.read_workspace_epoch(&name) else {
635 continue;
636 };
637
638 let mount_point = self.mount_point(&name);
639 let is_mounted = is_overlay_mounted(&mount_point);
640
641 if !mount_point.exists() && !self.upper_dir(&name).exists() {
644 continue;
645 }
646
647 let state = if is_mounted {
648 WorkspaceState::Active
649 } else {
650 WorkspaceState::Stale { behind_epochs: 0 }
652 };
653
654 infos.push(WorkspaceInfo {
655 id: name.clone(),
656 path: mount_point,
657 epoch,
658 state,
659 mode: WorkspaceMode::default(),
660 commits_ahead: 0,
661 });
662 }
663
664 Ok(infos)
665 }
666
667 fn status(&self, name: &WorkspaceId) -> Result<WorkspaceStatus, Self::Error> {
668 if !self.upper_dir(name).exists() {
670 return Err(OverlayBackendError::NotFound {
671 name: name.as_str().to_owned(),
672 });
673 }
674
675 let epoch = self.read_workspace_epoch(name)?;
676 let mount_point = self.mount_point(name);
677
678 if !is_overlay_mounted(&mount_point) {
680 self.mount_overlay(name, &epoch)?;
681 }
682
683 let dirty_files = scan_upper_dir_for_dirty(&self.upper_dir(name))?;
685
686 Ok(WorkspaceStatus::new(epoch, dirty_files, false))
687 }
688
689 fn snapshot(&self, name: &WorkspaceId) -> Result<SnapshotResult, Self::Error> {
690 if !self.upper_dir(name).exists() {
691 return Err(OverlayBackendError::NotFound {
692 name: name.as_str().to_owned(),
693 });
694 }
695
696 let epoch = self.read_workspace_epoch(name)?;
697 let mount_point = self.mount_point(name);
698
699 if !is_overlay_mounted(&mount_point) {
701 self.mount_overlay(name, &epoch)?;
702 }
703
704 let snapshot_dir = self.epoch_snapshot_dir(&epoch);
705 let upper_dir = self.upper_dir(name);
706
707 diff_upper_vs_lower(&upper_dir, &snapshot_dir)
708 }
709
710 fn workspace_path(&self, name: &WorkspaceId) -> PathBuf {
711 self.mount_point(name)
712 }
713
714 fn exists(&self, name: &WorkspaceId) -> bool {
715 self.upper_dir(name).exists()
717 }
718}
719
720#[inline]
726fn is_linux() -> bool {
727 std::env::consts::OS == "linux"
728}
729
730fn command_available(cmd: &str) -> bool {
732 Command::new("sh")
733 .args(["-c", &format!("command -v {cmd} >/dev/null 2>&1")])
734 .stdout(Stdio::null())
735 .stderr(Stdio::null())
736 .status()
737 .map(|s| s.success())
738 .unwrap_or(false)
739}
740
741fn kernel_userns_overlay_available() -> bool {
745 if !is_linux() {
746 return false;
747 }
748 if !command_available("unshare") {
749 return false;
750 }
751
752 let Ok(dir) = tempfile::tempdir() else {
753 return false;
754 };
755
756 let lower = dir.path().join("lower");
757 let upper = dir.path().join("upper");
758 let work = dir.path().join("work");
759 let merged = dir.path().join("merged");
760
761 if fs::create_dir_all(&lower).is_err()
762 || fs::create_dir_all(&upper).is_err()
763 || fs::create_dir_all(&work).is_err()
764 || fs::create_dir_all(&merged).is_err()
765 || fs::write(lower.join("probe"), b"ok").is_err()
766 {
767 return false;
768 }
769
770 let shell_cmd = format!(
771 "mount -t overlay overlay \
772 -o lowerdir='{}',upperdir='{}',workdir='{}' '{}' && umount '{}'",
773 lower.display(),
774 upper.display(),
775 work.display(),
776 merged.display(),
777 merged.display()
778 );
779
780 Command::new("unshare")
781 .args(["-Ur", "sh", "-c", &shell_cmd])
782 .stdout(Stdio::null())
783 .stderr(Stdio::null())
784 .status()
785 .map(|s| s.success())
786 .unwrap_or(false)
787}
788
789#[must_use]
794pub fn is_overlay_mounted(path: &Path) -> bool {
795 if !is_linux() {
796 return false;
797 }
798
799 let Some(path_str) = path.to_str() else {
800 return false;
801 };
802
803 let Ok(mounts) = fs::read_to_string("/proc/mounts") else {
804 return false;
805 };
806
807 for line in mounts.lines() {
808 let mut fields = line.split_whitespace();
810 let _device = fields.next();
811 let Some(mountpoint) = fields.next() else {
812 continue;
813 };
814 let Some(fstype) = fields.next() else {
815 continue;
816 };
817
818 if (fstype == "overlay" || fstype == "fuse.fuse-overlayfs") && mountpoint == path_str {
819 return true;
820 }
821 }
822
823 false
824}
825
826#[allow(clippy::items_after_statements)]
832fn scan_upper_dir_for_dirty(upper: &Path) -> Result<Vec<PathBuf>, OverlayBackendError> {
833 let mut dirty = Vec::new();
834
835 if !upper.exists() {
836 return Ok(dirty);
837 }
838
839 fn walk(dir: &Path, base: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
840 for entry in fs::read_dir(dir)? {
841 let entry = entry?;
842 let path = entry.path();
843 let ft = entry.file_type()?;
844
845 if ft.is_dir() {
846 let name = entry.file_name();
848 if name == "work" {
849 continue;
850 }
851 walk(&path, base, out)?;
852 } else {
853 if is_whiteout_file(&path) {
855 continue;
856 }
857 let rel = path.strip_prefix(base).unwrap_or(&path);
858 out.push(rel.to_path_buf());
859 }
860 }
861 Ok(())
862 }
863
864 walk(upper, upper, &mut dirty)?;
865 dirty.sort();
866 Ok(dirty)
867}
868
869#[must_use]
874fn is_whiteout_file(path: &Path) -> bool {
875 #[cfg(target_os = "linux")]
876 {
877 use std::os::unix::fs::MetadataExt;
878 if let Ok(meta) = fs::metadata(path) {
879 let is_char_dev = (meta.mode() & 0o170_000) == 0o020_000;
881 return is_char_dev && meta.rdev() == 0;
882 }
883 }
884 #[cfg(not(target_os = "linux"))]
885 let _ = path;
886 false
887}
888
889#[allow(clippy::items_after_statements)]
898fn diff_upper_vs_lower(upper: &Path, lower: &Path) -> Result<SnapshotResult, OverlayBackendError> {
899 let mut added = Vec::new();
900 let mut modified = Vec::new();
901 let mut deleted = Vec::new();
902
903 if !upper.exists() {
904 return Ok(SnapshotResult::new(added, modified, deleted));
905 }
906
907 fn walk(
908 upper_dir: &Path,
909 lower_dir: &Path,
910 upper_base: &Path,
911 added: &mut Vec<PathBuf>,
912 modified: &mut Vec<PathBuf>,
913 deleted: &mut Vec<PathBuf>,
914 ) -> std::io::Result<()> {
915 for entry in fs::read_dir(upper_dir)? {
916 let entry = entry?;
917 let upper_path = entry.path();
918 let ft = entry.file_type()?;
919
920 let rel = upper_path
922 .strip_prefix(upper_base)
923 .unwrap_or(&upper_path)
924 .to_path_buf();
925
926 if ft.is_dir() {
927 let lower_subdir = lower_dir.join(rel.file_name().unwrap_or_default());
929 walk(
930 &upper_path,
931 &lower_subdir,
932 upper_base,
933 added,
934 modified,
935 deleted,
936 )?;
937 } else if is_whiteout_file(&upper_path) {
938 deleted.push(rel);
940 } else {
941 let lower_path = lower_dir.join(rel.file_name().unwrap_or_default());
943 if lower_path.exists() {
944 modified.push(rel);
945 } else {
946 added.push(rel);
947 }
948 }
949 }
950 Ok(())
951 }
952
953 walk(upper, lower, upper, &mut added, &mut modified, &mut deleted)?;
954
955 added.sort();
956 added.dedup();
957 modified.sort();
958 modified.dedup();
959 deleted.sort();
960 deleted.dedup();
961
962 Ok(SnapshotResult::new(added, modified, deleted))
963}
964
965#[cfg(test)]
970#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
971mod tests {
972 use super::*;
973
974 #[test]
977 fn whiteout_file_regular_is_not_whiteout() {
978 let dir = tempfile::tempdir().unwrap();
979 let path = dir.path().join("regular.txt");
980 fs::write(&path, b"hello").unwrap();
981 assert!(!is_whiteout_file(&path));
982 }
983
984 #[test]
985 fn whiteout_file_directory_is_not_whiteout() {
986 let dir = tempfile::tempdir().unwrap();
987 let subdir = dir.path().join("subdir");
988 fs::create_dir(&subdir).unwrap();
989 assert!(!is_whiteout_file(&subdir));
990 }
991
992 #[test]
995 fn scan_empty_upper_returns_empty() {
996 let dir = tempfile::tempdir().unwrap();
997 let upper = dir.path().join("upper");
998 fs::create_dir_all(&upper).unwrap();
999
1000 let dirty = scan_upper_dir_for_dirty(&upper).unwrap();
1001 assert!(dirty.is_empty(), "empty upper → no dirty files: {dirty:?}");
1002 }
1003
1004 #[test]
1005 fn scan_upper_reports_regular_files() {
1006 let dir = tempfile::tempdir().unwrap();
1007 let upper = dir.path().join("upper");
1008 fs::create_dir_all(&upper).unwrap();
1009
1010 fs::write(upper.join("modified.rs"), b"changed").unwrap();
1011 fs::create_dir_all(upper.join("src")).unwrap();
1012 fs::write(upper.join("src").join("new.rs"), b"added").unwrap();
1013
1014 let mut dirty = scan_upper_dir_for_dirty(&upper).unwrap();
1015 dirty.sort();
1016 assert!(
1017 dirty.iter().any(|p| p == &PathBuf::from("modified.rs")),
1018 "should contain modified.rs: {dirty:?}"
1019 );
1020 assert!(
1021 dirty.iter().any(|p| p == &PathBuf::from("src/new.rs")),
1022 "should contain src/new.rs: {dirty:?}"
1023 );
1024 }
1025
1026 #[test]
1029 fn diff_empty_upper_empty_lower() {
1030 let dir = tempfile::tempdir().unwrap();
1031 let upper = dir.path().join("upper");
1032 let lower = dir.path().join("lower");
1033 fs::create_dir_all(&upper).unwrap();
1034 fs::create_dir_all(&lower).unwrap();
1035
1036 let result = diff_upper_vs_lower(&upper, &lower).unwrap();
1037 assert!(result.is_empty(), "nothing changed: {result:?}");
1038 }
1039
1040 #[test]
1041 fn diff_added_file_not_in_lower() {
1042 let dir = tempfile::tempdir().unwrap();
1043 let upper = dir.path().join("upper");
1044 let lower = dir.path().join("lower");
1045 fs::create_dir_all(&upper).unwrap();
1046 fs::create_dir_all(&lower).unwrap();
1047 fs::write(upper.join("new.rs"), b"fn main() {}").unwrap();
1049
1050 let result = diff_upper_vs_lower(&upper, &lower).unwrap();
1051 assert_eq!(result.added.len(), 1, "one added file: {result:?}");
1052 assert_eq!(result.added[0], PathBuf::from("new.rs"));
1053 assert!(result.modified.is_empty());
1054 assert!(result.deleted.is_empty());
1055 }
1056
1057 #[test]
1058 fn diff_modified_file_in_both() {
1059 let dir = tempfile::tempdir().unwrap();
1060 let upper = dir.path().join("upper");
1061 let lower = dir.path().join("lower");
1062 fs::create_dir_all(&upper).unwrap();
1063 fs::create_dir_all(&lower).unwrap();
1064 fs::write(lower.join("README.md"), b"original").unwrap();
1066 fs::write(upper.join("README.md"), b"modified").unwrap();
1067
1068 let result = diff_upper_vs_lower(&upper, &lower).unwrap();
1069 assert!(result.added.is_empty());
1070 assert_eq!(result.modified.len(), 1, "one modified file: {result:?}");
1071 assert_eq!(result.modified[0], PathBuf::from("README.md"));
1072 assert!(result.deleted.is_empty());
1073 }
1074
1075 #[test]
1076 fn diff_empty_upper_no_changes() {
1077 let dir = tempfile::tempdir().unwrap();
1078 let upper = dir.path().join("upper");
1079 let lower = dir.path().join("lower");
1080 fs::create_dir_all(&upper).unwrap();
1081 fs::create_dir_all(&lower).unwrap();
1082 fs::write(lower.join("base.rs"), b"base").unwrap();
1084
1085 let result = diff_upper_vs_lower(&upper, &lower).unwrap();
1086 assert!(result.is_empty(), "no upper changes → empty: {result:?}");
1087 }
1088
1089 #[test]
1092 fn is_overlay_mounted_returns_false_for_regular_dir() {
1093 let dir = tempfile::tempdir().unwrap();
1094 assert!(
1095 !is_overlay_mounted(dir.path()),
1096 "regular tempdir should not be an overlay mount"
1097 );
1098 }
1099
1100 #[test]
1103 fn mount_strategy_detect_smoke() {
1104 let _strategy = MountStrategy::detect();
1106 }
1107
1108 #[test]
1111 fn error_display_not_linux() {
1112 let msg = format!("{}", OverlayBackendError::NotLinux);
1113 assert!(msg.contains("Linux-only"));
1114 }
1115
1116 #[test]
1117 fn error_display_not_supported() {
1118 let msg = format!(
1119 "{}",
1120 OverlayBackendError::NotSupported {
1121 reason: "no binary".to_owned()
1122 }
1123 );
1124 assert!(msg.contains("no binary"));
1125 }
1126
1127 #[test]
1128 fn error_display_not_found() {
1129 let msg = format!(
1130 "{}",
1131 OverlayBackendError::NotFound {
1132 name: "my-ws".to_owned()
1133 }
1134 );
1135 assert!(msg.contains("my-ws"));
1136 }
1137
1138 #[test]
1139 fn error_display_command() {
1140 let msg = format!(
1141 "{}",
1142 OverlayBackendError::Command {
1143 command: "fuse-overlayfs".to_owned(),
1144 stderr: "permission denied".to_owned(),
1145 exit_code: Some(1),
1146 }
1147 );
1148 assert!(msg.contains("fuse-overlayfs"));
1149 assert!(msg.contains("permission denied"));
1150 }
1151
1152 #[test]
1155 fn epoch_refcount_inc_dec_remove() {
1156 let dir = tempfile::tempdir().unwrap();
1157 let root = dir.path().to_path_buf();
1158
1159 let backend = OverlayBackend {
1161 root,
1162 strategy: MountStrategy::FuseOverlayfs,
1163 };
1164
1165 let oid = "a".repeat(40);
1167 let epoch = EpochId::new(&oid).unwrap();
1168
1169 assert_eq!(backend.read_refcount(&epoch), 0);
1171
1172 let snap_dir = backend.epoch_snapshot_dir(&epoch);
1174 fs::create_dir_all(&snap_dir).unwrap();
1175
1176 backend.epoch_refcount_inc(&epoch).unwrap();
1177 assert_eq!(backend.read_refcount(&epoch), 1);
1178
1179 backend.epoch_refcount_inc(&epoch).unwrap();
1180 assert_eq!(backend.read_refcount(&epoch), 2);
1181
1182 let remaining = backend.epoch_refcount_dec(&epoch).unwrap();
1183 assert_eq!(remaining, 1);
1184
1185 let remaining = backend.epoch_refcount_dec(&epoch).unwrap();
1186 assert_eq!(remaining, 0);
1187
1188 backend.maybe_remove_epoch_snapshot(&epoch).unwrap();
1190 assert!(!snap_dir.exists(), "snapshot dir should be pruned");
1191 }
1192
1193 #[test]
1196 fn ensure_epoch_snapshot_creates_files() {
1197 use std::process::Command as Cmd;
1198
1199 let dir = tempfile::tempdir().unwrap();
1200 let root = dir.path().to_path_buf();
1201
1202 Cmd::new("git")
1204 .args(["init"])
1205 .current_dir(&root)
1206 .output()
1207 .unwrap();
1208 Cmd::new("git")
1209 .args(["config", "user.name", "Test"])
1210 .current_dir(&root)
1211 .output()
1212 .unwrap();
1213 Cmd::new("git")
1214 .args(["config", "user.email", "t@t.com"])
1215 .current_dir(&root)
1216 .output()
1217 .unwrap();
1218 Cmd::new("git")
1219 .args(["config", "commit.gpgsign", "false"])
1220 .current_dir(&root)
1221 .output()
1222 .unwrap();
1223 fs::write(root.join("hello.txt"), b"hello world").unwrap();
1224 Cmd::new("git")
1225 .args(["add", "hello.txt"])
1226 .current_dir(&root)
1227 .output()
1228 .unwrap();
1229 Cmd::new("git")
1230 .args(["commit", "-m", "init"])
1231 .current_dir(&root)
1232 .output()
1233 .unwrap();
1234
1235 let head = Cmd::new("git")
1236 .args(["rev-parse", "HEAD"])
1237 .current_dir(&root)
1238 .output()
1239 .unwrap();
1240 let oid_str = String::from_utf8(head.stdout).unwrap().trim().to_owned();
1241 let epoch = EpochId::new(&oid_str).unwrap();
1242
1243 let backend = OverlayBackend {
1244 root,
1245 strategy: MountStrategy::FuseOverlayfs,
1246 };
1247
1248 let snap = backend.ensure_epoch_snapshot(&epoch).unwrap();
1249 assert!(snap.exists(), "snapshot dir should exist");
1250 assert!(
1251 snap.join("hello.txt").exists(),
1252 "snapshot should contain hello.txt"
1253 );
1254
1255 let snap2 = backend.ensure_epoch_snapshot(&epoch).unwrap();
1257 assert_eq!(snap, snap2);
1258 }
1259}