Skip to main content

maw/backend/
overlay.rs

1//! `OverlayFS` workspace backend (Linux only).
2//!
3//! Provides zero-copy workspace isolation using Linux overlayfs (via
4//! `fuse-overlayfs` or kernel overlayfs in user namespaces). Each workspace is
5//! an overlay mount with three layers:
6//!
7//! - **lowerdir**: `.manifold/epochs/e-{hash}/` — immutable epoch snapshot
8//! - **upperdir**: `.manifold/cow/<name>/upper/` — per-workspace changes
9//! - **workdir**:  `.manifold/cow/<name>/work/`  — overlayfs bookkeeping
10//! - **merged**:   `ws/<name>/` — the workspace working copy (mount point)
11//!
12//! The lowerdir is **always** an immutable epoch snapshot — never the mutable
13//! default workspace. This satisfies the §4.4 invariant: overlay mounts never
14//! become semantically stale as the epoch ref advances.
15//!
16//! # Platform requirements
17//! - Linux only (`OverlayBackendError::NotLinux` on other platforms).
18//! - Either `fuse-overlayfs` (preferred: user-space, persistent) **or** kernel
19//!   overlayfs >= 5.11 via `unshare -Ur` (persists within the calling process).
20//! - No root required.
21//!
22//! # Mount persistence
23//! `fuse-overlayfs` spawns a background FUSE daemon that persists across tool
24//! calls, making it the preferred implementation. Kernel overlay via `unshare`
25//! is tied to the calling process namespace and is therefore only suitable for
26//! single-process sessions (or use with a persistent daemon).
27//!
28//! The backend auto-remounts any workspace whose mount point is not active
29//! at the start of `status()` and `snapshot()` calls, ensuring that `maw exec`
30//! invocations always see a live overlay.
31//!
32//! # Epoch snapshot lifecycle
33//! Snapshots are created via `git archive | tar -x` on first use and stored in
34//! `.manifold/epochs/e-{hash}/`. They are retained as long as any workspace's
35//! upper-dir references that epoch (tracked via a ref-count file at
36//! `.manifold/epochs/e-{hash}/.refcount`). Snapshots are removed during
37//! workspace `destroy()` when the ref-count drops to zero.
38
39use 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// ---------------------------------------------------------------------------
48// Error type
49// ---------------------------------------------------------------------------
50
51/// Errors produced by the `OverlayFS` workspace backend.
52#[derive(Debug)]
53pub enum OverlayBackendError {
54    /// `OverlayFS` backend is Linux-only.
55    NotLinux,
56    /// Neither fuse-overlayfs nor kernel overlayfs (user namespaces) is available.
57    NotSupported { reason: String },
58    /// An I/O error occurred.
59    Io(std::io::Error),
60    /// An external command failed.
61    Command {
62        command: String,
63        stderr: String,
64        exit_code: Option<i32>,
65    },
66    /// The workspace does not exist.
67    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// ---------------------------------------------------------------------------
120// Mount strategy
121// ---------------------------------------------------------------------------
122
123/// Which `OverlayFS` mount mechanism to use.
124#[derive(Clone, Copy, Debug, PartialEq, Eq)]
125pub enum MountStrategy {
126    /// `fuse-overlayfs` user-space FUSE daemon (preferred — persistent).
127    FuseOverlayfs,
128    /// Kernel overlayfs in a user namespace (`unshare -Ur mount -t overlay …`).
129    KernelUserNamespace,
130}
131
132impl MountStrategy {
133    /// Auto-detect the best available strategy on this system.
134    ///
135    /// Returns `None` if neither strategy is available.
136    #[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
151// ---------------------------------------------------------------------------
152// OverlayBackend
153// ---------------------------------------------------------------------------
154
155/// `OverlayFS` workspace backend.
156///
157/// Creates isolated workspaces via overlay mounts using immutable epoch
158/// snapshots as the read-only lower layer.
159pub struct OverlayBackend {
160    /// Repository root (where `.git` and `.manifold/` live).
161    root: PathBuf,
162    /// Mount strategy in use.
163    strategy: MountStrategy,
164}
165
166impl OverlayBackend {
167    /// Create a new `OverlayBackend` for the given repository root.
168    ///
169    /// Auto-selects the best available mount strategy.
170    ///
171    /// # Errors
172    /// - `OverlayBackendError::NotLinux` on non-Linux platforms.
173    /// - `OverlayBackendError::NotSupported` if no mount strategy is available.
174    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    // --- directory helpers --------------------------------------------------
188
189    /// `ws/` directory (workspace mount points live here).
190    fn workspaces_dir(&self) -> PathBuf {
191        self.root.join("ws")
192    }
193
194    /// `ws/<name>/` — overlay mount point (the workspace working copy).
195    fn mount_point(&self, name: &WorkspaceId) -> PathBuf {
196        self.workspaces_dir().join(name.as_str())
197    }
198
199    /// `.manifold/epochs/e-{hash}/` — immutable epoch snapshot (lowerdir).
200    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    /// `.manifold/cow/<name>/upper/` — per-workspace writable layer.
208    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    /// `.manifold/cow/<name>/work/` — overlayfs bookkeeping dir.
217    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    /// `.manifold/cow/<name>/epoch` — records which epoch this workspace uses.
226    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    /// `.manifold/epochs/e-{hash}/.refcount` — snapshot reference count file.
235    fn epoch_refcount_path(&self, epoch: &EpochId) -> PathBuf {
236        self.epoch_snapshot_dir(epoch).join(".refcount")
237    }
238
239    // --- epoch snapshot management -----------------------------------------
240
241    /// Ensure that `.manifold/epochs/e-{hash}/` exists and is populated.
242    ///
243    /// If the snapshot already exists (directory non-empty), this is a no-op.
244    /// Otherwise, uses `git archive | tar -x` to materialize the epoch contents.
245    fn ensure_epoch_snapshot(&self, epoch: &EpochId) -> Result<PathBuf, OverlayBackendError> {
246        let snapshot_dir = self.epoch_snapshot_dir(epoch);
247
248        // Already populated: snapshot dir exists and has content (not just .refcount).
249        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        // Create snapshot directory.
259        fs::create_dir_all(&snapshot_dir)?;
260
261        // Extract epoch via `git archive <epoch> | tar -x -C <snapshot_dir>`.
262        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    /// Increment the reference count for an epoch snapshot.
288    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    /// Decrement the reference count and return the new count.
296    ///
297    /// Returns `0` if the file doesn't exist or contains an invalid value.
298    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    /// Read the current reference count for an epoch snapshot (0 if missing).
307    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    /// Remove an epoch snapshot if its reference count has reached zero.
316    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    /// Best-effort cleanup for a partially-created workspace.
328    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    // --- overlay mount operations ------------------------------------------
343
344    /// Mount the overlay for a workspace.
345    ///
346    /// If the overlay is already mounted, this is a no-op (idempotent).
347    fn mount_overlay(
348        &self,
349        name: &WorkspaceId,
350        epoch: &EpochId,
351    ) -> Result<(), OverlayBackendError> {
352        let mount_point = self.mount_point(name);
353
354        // Idempotent: already mounted.
355        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    /// Mount using `fuse-overlayfs` (persistent FUSE daemon).
380    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    /// Mount using kernel overlayfs in a user namespace (`unshare -Ur`).
410    ///
411    /// **Note**: This mount is tied to the calling process's namespace and
412    /// does not persist after the process exits. Prefer `fuse-overlayfs` for
413    /// persistent workspaces.
414    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    /// Unmount an overlay workspace.
445    ///
446    /// Tries `fusermount3 -u`, then `fusermount -u`, then `umount -l` as
447    /// fallbacks. Returns `Ok(())` if the mount point is not mounted at all
448    /// (idempotent).
449    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        // Try FUSE unmount first (works for both fuse-overlayfs and regular).
459        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        // Last resort: unshare umount
478        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            // If unmount failed but the mount point is inaccessible, that's ok.
487            // We'll proceed with best-effort cleanup.
488            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
489            if !stderr.is_empty() {
490                // Log the error but don't fail destroy — we still clean up the dirs.
491                tracing::warn!("unmount failed: {stderr}");
492            }
493        }
494
495        Ok(())
496    }
497
498    // --- workspace epoch file ----------------------------------------------
499
500    /// Write the epoch OID to the workspace's epoch file.
501    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    /// Read the epoch OID from the workspace's epoch file.
515    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
527// ---------------------------------------------------------------------------
528// WorkspaceBackend impl
529// ---------------------------------------------------------------------------
530
531impl 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        // Idempotent: if already mounted, return info.
538        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        // Ensure CoW directories exist.
553        fs::create_dir_all(self.upper_dir(name))?;
554        fs::create_dir_all(self.work_dir(name))?;
555
556        // Record which epoch this workspace is anchored to.
557        self.write_workspace_epoch(name, epoch)?;
558
559        // Ensure the immutable epoch snapshot exists before mount.
560        self.ensure_epoch_snapshot(epoch)?;
561
562        // Mount the overlay. If this fails, remove partial workspace state.
563        if let Err(err) = self.mount_overlay(name, epoch) {
564            self.cleanup_partial_workspace(name);
565            return Err(err);
566        }
567
568        // Count the mounted workspace as an epoch snapshot reference.
569        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        // Unmount before removing directories.
586        self.unmount_overlay(name)?;
587
588        // Read the epoch so we can decrement its ref-count.
589        let epoch_opt = self.read_workspace_epoch(name).ok();
590
591        // Remove mount point directory.
592        let mount_point = self.mount_point(name);
593        if mount_point.exists() {
594            fs::remove_dir_all(&mount_point)?;
595        }
596
597        // Remove CoW directories (upper + work).
598        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        // Decrement epoch ref-count and prune snapshot if no longer referenced.
604        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            // Read the epoch recorded for this workspace.
634            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 the mount point doesn't exist at all, the workspace was
642            // partially destroyed; skip it.
643            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                // Not mounted but CoW dirs exist: treatable as Stale (needs remount).
651                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        // Ensure workspace CoW directories exist.
669        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        // Auto-remount if the overlay is not active.
679        if !is_overlay_mounted(&mount_point) {
680            self.mount_overlay(name, &epoch)?;
681        }
682
683        // Collect dirty files by scanning the upper directory.
684        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        // Auto-remount if the overlay is not active.
700        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        // A workspace exists if its CoW upper directory is present.
716        self.upper_dir(name).exists()
717    }
718}
719
720// ---------------------------------------------------------------------------
721// Helper functions
722// ---------------------------------------------------------------------------
723
724/// Returns `true` if we're running on Linux.
725#[inline]
726fn is_linux() -> bool {
727    std::env::consts::OS == "linux"
728}
729
730/// Check whether a command is available in `$PATH`.
731fn 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
741/// Check whether kernel overlayfs via user namespaces is available.
742///
743/// Parses `uname -r` and probes `unshare -Ur mount -t overlay` in a tempdir.
744fn 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/// Check whether `path` is currently an overlay filesystem mount.
790///
791/// Reads `/proc/mounts` and looks for an entry whose mount point matches
792/// `path`. Only available on Linux; returns `false` on other platforms.
793#[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        // /proc/mounts format: <device> <mountpoint> <fstype> <options> <dump> <pass>
809        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/// Scan the upper directory of an overlay workspace and collect dirty files.
827///
828/// Returns all files present in the upper directory (excluding overlayfs
829/// whiteout files and the `work/` directory). Paths are relative to the
830/// upper directory root.
831#[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                // Recurse, but skip the overlay work directory marker.
847                let name = entry.file_name();
848                if name == "work" {
849                    continue;
850                }
851                walk(&path, base, out)?;
852            } else {
853                // Whiteout files are char devices with 0/0 major:minor; skip them.
854                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/// Returns `true` if `path` is an overlayfs whiteout file (char device 0:0).
870///
871/// Whiteout files represent deletions in the overlay upper layer. They have
872/// device type `c` with major and minor numbers both equal to 0.
873#[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            // Whiteout: char device (S_IFCHR = 0o20000) with rdev == 0.
880            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/// Compute added/modified/deleted by comparing an overlay upper layer to the
890/// immutable epoch snapshot (lowerdir).
891///
892/// - **Added**: path exists in `upper` but NOT in `lower`.
893/// - **Modified**: path exists in both `upper` and `lower` (and is not a whiteout).
894/// - **Deleted**: path is a whiteout file in `upper` (deletion marker).
895///
896/// All returned paths are relative to `upper` (== relative to the workspace root).
897#[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            // Compute relative path from upper base.
921            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                // Recurse into subdirectories.
928                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                // Whiteout: this file was deleted.
939                deleted.push(rel);
940            } else {
941                // Regular file: added if not in lower, modified if in lower.
942                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// ---------------------------------------------------------------------------
966// Tests
967// ---------------------------------------------------------------------------
968
969#[cfg(test)]
970#[allow(clippy::all, clippy::pedantic, clippy::nursery)]
971mod tests {
972    use super::*;
973
974    // ---- is_whiteout_file ------------------------------------------------
975
976    #[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    // ---- scan_upper_dir_for_dirty ----------------------------------------
993
994    #[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    // ---- diff_upper_vs_lower --------------------------------------------
1027
1028    #[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        // Only in upper → added
1048        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        // Same name in both → modified
1065        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        // File only in lower (not modified) → nothing reported
1083        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    // ---- is_overlay_mounted (smoke test on Linux) ------------------------
1090
1091    #[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    // ---- MountStrategy::detect -------------------------------------------
1101
1102    #[test]
1103    fn mount_strategy_detect_smoke() {
1104        // Just verify it doesn't panic; the result depends on the host OS.
1105        let _strategy = MountStrategy::detect();
1106    }
1107
1108    // ---- OverlayBackendError Display -------------------------------------
1109
1110    #[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    // ---- epoch refcount helpers (in-process, no mount needed) -----------
1153
1154    #[test]
1155    fn epoch_refcount_inc_dec_remove() {
1156        let dir = tempfile::tempdir().unwrap();
1157        let root = dir.path().to_path_buf();
1158
1159        // Build a minimal backend (strategy doesn't matter for refcount ops).
1160        let backend = OverlayBackend {
1161            root,
1162            strategy: MountStrategy::FuseOverlayfs,
1163        };
1164
1165        // We need a valid EpochId (40 lowercase hex chars).
1166        let oid = "a".repeat(40);
1167        let epoch = EpochId::new(&oid).unwrap();
1168
1169        // Initial count is 0.
1170        assert_eq!(backend.read_refcount(&epoch), 0);
1171
1172        // Create snapshot dir so the refcount file has a parent.
1173        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        // Snapshot dir should be removed when refcount hits 0.
1189        backend.maybe_remove_epoch_snapshot(&epoch).unwrap();
1190        assert!(!snap_dir.exists(), "snapshot dir should be pruned");
1191    }
1192
1193    // ---- ensure_epoch_snapshot (integration, requires git) ---------------
1194
1195    #[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        // Init a small git repo with one commit.
1203        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        // Idempotent: calling again should not fail.
1256        let snap2 = backend.ensure_epoch_snapshot(&epoch).unwrap();
1257        assert_eq!(snap, snap2);
1258    }
1259}