Skip to main content

hm_util/
cow.rs

1//! Platform-native copy-on-write directory cloning.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5use std::sync::OnceLock;
6
7use anyhow::{Context, Result, bail};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub enum CowStrategy {
11    ApfsClone,
12    Reflink,
13    FuseOverlay,
14    FullCopy,
15}
16
17/// Detect the best available COW strategy for the current platform.
18/// Result is cached after the first call.
19#[must_use]
20pub fn detect_strategy() -> CowStrategy {
21    static STRATEGY: OnceLock<CowStrategy> = OnceLock::new();
22    *STRATEGY.get_or_init(detect_strategy_inner)
23}
24
25/// Probe result for a single strategy.
26#[derive(Debug, Clone)]
27pub struct StrategyProbe {
28    pub strategy: CowStrategy,
29    pub available: bool,
30    pub reason: &'static str,
31}
32
33/// Test all strategies and report which are available.
34/// Used for diagnostics / user-facing warnings.
35#[must_use]
36#[allow(clippy::vec_init_then_push)]
37pub fn diagnose_strategies() -> Vec<StrategyProbe> {
38    let mut probes = Vec::new();
39
40    #[cfg(target_os = "macos")]
41    probes.push(StrategyProbe {
42        strategy: CowStrategy::ApfsClone,
43        available: true,
44        reason: "macOS APFS detected",
45    });
46
47    #[cfg(target_os = "linux")]
48    {
49        probes.push(StrategyProbe {
50            strategy: CowStrategy::Reflink,
51            available: probe_reflink(),
52            reason: if probe_reflink() {
53                "filesystem supports reflinks"
54            } else {
55                "filesystem does not support reflinks (btrfs/XFS required)"
56            },
57        });
58        let fuse_ok = probe_fuse_overlayfs();
59        probes.push(StrategyProbe {
60            strategy: CowStrategy::FuseOverlay,
61            available: fuse_ok,
62            reason: if fuse_ok {
63                "fuse-overlayfs mount succeeded"
64            } else if which::which("fuse-overlayfs").is_err() {
65                "fuse-overlayfs not installed"
66            } else {
67                "fuse-overlayfs mount failed (missing /dev/fuse or user_allow_other?)"
68            },
69        });
70    }
71
72    probes.push(StrategyProbe {
73        strategy: CowStrategy::FullCopy,
74        available: true,
75        reason: "always available (slow)",
76    });
77
78    probes
79}
80
81#[allow(clippy::missing_const_for_fn)]
82fn detect_strategy_inner() -> CowStrategy {
83    #[cfg(target_os = "macos")]
84    {
85        return CowStrategy::ApfsClone;
86    }
87
88    #[cfg(target_os = "linux")]
89    {
90        if probe_reflink() {
91            return CowStrategy::Reflink;
92        }
93        if probe_fuse_overlayfs() {
94            return CowStrategy::FuseOverlay;
95        }
96        return CowStrategy::FullCopy;
97    }
98
99    #[allow(unreachable_code)]
100    CowStrategy::FullCopy
101}
102
103#[cfg(target_os = "linux")]
104fn probe_reflink() -> bool {
105    let Ok(tmp) = tempfile::tempdir() else {
106        return false;
107    };
108    let src = tmp.path().join("src");
109    let dst = tmp.path().join("dst");
110    if std::fs::write(&src, b"x").is_err() {
111        return false;
112    }
113    Command::new("cp")
114        .args(["--reflink=always"])
115        .arg(&src)
116        .arg(&dst)
117        .stderr(std::process::Stdio::null())
118        .status()
119        .is_ok_and(|s| s.success())
120}
121
122#[cfg(target_os = "linux")]
123fn probe_fuse_overlayfs() -> bool {
124    if which::which("fuse-overlayfs").is_err() {
125        return false;
126    }
127    let Ok(tmp) = tempfile::tempdir() else {
128        return false;
129    };
130    let lower = tmp.path().join("lower");
131    let upper = tmp.path().join("upper");
132    let work = tmp.path().join("work");
133    let merged = tmp.path().join("merged");
134    for d in [&lower, &upper, &work, &merged] {
135        if std::fs::create_dir(d).is_err() {
136            return false;
137        }
138    }
139    let opts = format!(
140        "lowerdir={},upperdir={},workdir={},allow_other,squash_to_uid=0,squash_to_gid=0",
141        lower.display(),
142        upper.display(),
143        work.display(),
144    );
145    let ok = Command::new("fuse-overlayfs")
146        .args(["-o", &opts])
147        .arg(&merged)
148        .stderr(std::process::Stdio::null())
149        .status()
150        .is_ok_and(|s| s.success());
151    if ok {
152        let bin = if which::which("fusermount3").is_ok() {
153            "fusermount3"
154        } else {
155            "fusermount"
156        };
157        let _ = Command::new(bin)
158            .args(["-u"])
159            .arg(&merged)
160            .stderr(std::process::Stdio::null())
161            .status();
162    }
163    ok
164}
165
166/// Clone `src` to `dst` using the best available COW mechanism.
167///
168/// # Errors
169///
170/// Returns an error if `dst` already exists, if parent directories cannot
171/// be created, or if the underlying copy operation fails.
172pub fn cow_clone_dir(src: &Path, dst: &Path) -> Result<()> {
173    if dst.exists() {
174        bail!("destination already exists: {}", dst.display());
175    }
176    if let Some(parent) = dst.parent() {
177        std::fs::create_dir_all(parent)
178            .with_context(|| format!("create parent dirs for {}", dst.display()))?;
179    }
180
181    if try_platform_cow(src, dst)? {
182        return Ok(());
183    }
184
185    copy_dir_recursive(src, dst)
186}
187
188fn try_platform_cow(src: &Path, dst: &Path) -> Result<bool> {
189    #[cfg(target_os = "macos")]
190    {
191        let status = Command::new("cp")
192            .args(["-c", "-R", "-p"])
193            .arg(src)
194            .arg(dst)
195            .stderr(std::process::Stdio::null())
196            .status()
197            .context("spawn cp -c")?;
198        if status.success() {
199            return Ok(true);
200        }
201        let _ = std::fs::remove_dir_all(dst);
202    }
203
204    #[cfg(target_os = "linux")]
205    {
206        let status = Command::new("cp")
207            .args(["--reflink=always", "-a"])
208            .arg(src)
209            .arg(dst)
210            .stderr(std::process::Stdio::null())
211            .status()
212            .context("spawn cp --reflink")?;
213        if status.success() {
214            return Ok(true);
215        }
216        let _ = std::fs::remove_dir_all(dst);
217
218        let status = Command::new("cp")
219            .args(["-a"])
220            .arg(src)
221            .arg(dst)
222            .stderr(std::process::Stdio::null())
223            .status()
224            .context("spawn cp -a")?;
225        if status.success() {
226            return Ok(true);
227        }
228        let _ = std::fs::remove_dir_all(dst);
229    }
230
231    Ok(false)
232}
233
234fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<()> {
235    std::fs::create_dir_all(dst).with_context(|| format!("create {}", dst.display()))?;
236    for entry in std::fs::read_dir(src).with_context(|| format!("read dir {}", src.display()))? {
237        let entry = entry?;
238        let ty = entry.file_type()?;
239        let src_path = entry.path();
240        let dst_path = dst.join(entry.file_name());
241        if ty.is_dir() {
242            copy_dir_recursive(&src_path, &dst_path)?;
243        } else if ty.is_symlink() {
244            let target = std::fs::read_link(&src_path)?;
245            #[cfg(unix)]
246            std::os::unix::fs::symlink(&target, &dst_path)?;
247            #[cfg(windows)]
248            std::os::windows::fs::symlink_file(&target, &dst_path)?;
249        } else {
250            std::fs::copy(&src_path, &dst_path)
251                .with_context(|| format!("copy {}", src_path.display()))?;
252        }
253    }
254    Ok(())
255}
256
257pub struct OverlayMount {
258    merged: PathBuf,
259    upper: PathBuf,
260    mounted: std::sync::atomic::AtomicBool,
261}
262
263impl OverlayMount {
264    /// Mount a fuse-overlayfs filesystem merging the given layers.
265    ///
266    /// # Errors
267    ///
268    /// Returns an error if directory creation fails or `fuse-overlayfs`
269    /// exits with a non-zero status.
270    pub fn mount(
271        lower_dirs: &[&Path],
272        upper_dir: &Path,
273        work_dir: &Path,
274        merged_path: &Path,
275    ) -> Result<Self> {
276        std::fs::create_dir_all(upper_dir)?;
277        std::fs::create_dir_all(work_dir)?;
278        std::fs::create_dir_all(merged_path)?;
279
280        let lowerdir: String = lower_dirs
281            .iter()
282            .map(|p| p.to_string_lossy().into_owned())
283            .collect::<Vec<_>>()
284            .join(":");
285
286        let opts = format!(
287            "lowerdir={lowerdir},upperdir={},workdir={},allow_other,squash_to_uid=0,squash_to_gid=0",
288            upper_dir.display(),
289            work_dir.display(),
290        );
291
292        let output = Command::new("fuse-overlayfs")
293            .args(["-o", &opts])
294            .arg(merged_path)
295            .output()
296            .context("spawn fuse-overlayfs")?;
297
298        if !output.status.success() {
299            let stderr = String::from_utf8_lossy(&output.stderr);
300            bail!(
301                "fuse-overlayfs mount failed (exit {}): {stderr}\nlowerdir={}, upper={}, merged={}",
302                output.status.code().unwrap_or(-1),
303                lowerdir,
304                upper_dir.display(),
305                merged_path.display(),
306            );
307        }
308
309        Ok(Self {
310            merged: merged_path.to_path_buf(),
311            upper: upper_dir.to_path_buf(),
312            mounted: std::sync::atomic::AtomicBool::new(true),
313        })
314    }
315
316    #[must_use]
317    pub fn merged_path(&self) -> &Path {
318        &self.merged
319    }
320
321    #[must_use]
322    pub fn upper_dir(&self) -> &Path {
323        &self.upper
324    }
325
326    /// Unmount the fuse-overlayfs filesystem. Safe to call multiple times.
327    ///
328    /// # Errors
329    ///
330    /// Returns an error if `fusermount` cannot be spawned or exits
331    /// with a non-zero status.
332    pub fn unmount(&self) -> Result<()> {
333        if !self
334            .mounted
335            .swap(false, std::sync::atomic::Ordering::AcqRel)
336        {
337            return Ok(());
338        }
339        let bin = if which::which("fusermount3").is_ok() {
340            "fusermount3"
341        } else {
342            "fusermount"
343        };
344        let status = Command::new(bin)
345            .args(["-u"])
346            .arg(&self.merged)
347            .stderr(std::process::Stdio::null())
348            .status()
349            .with_context(|| format!("spawn {bin} -u"))?;
350        if !status.success() {
351            bail!("{bin} -u {} failed", self.merged.display());
352        }
353        Ok(())
354    }
355}
356
357impl std::fmt::Debug for OverlayMount {
358    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359        f.debug_struct("OverlayMount")
360            .field("merged", &self.merged)
361            .field("upper", &self.upper)
362            .finish_non_exhaustive()
363    }
364}
365
366impl Drop for OverlayMount {
367    fn drop(&mut self) {
368        if let Err(e) = self.unmount() {
369            tracing::warn!(%e, path = %self.merged.display(), "fuse-overlayfs unmount failed");
370        }
371    }
372}
373
374#[cfg(test)]
375#[allow(clippy::unwrap_used)]
376mod tests {
377    use super::*;
378    use std::fs;
379
380    #[test]
381    fn cow_clone_creates_identical_tree() {
382        let tmp = tempfile::tempdir().unwrap();
383        let src = tmp.path().join("src");
384        fs::create_dir_all(src.join("sub")).unwrap();
385        fs::write(src.join("a.txt"), b"hello").unwrap();
386        fs::write(src.join("sub/b.txt"), b"world").unwrap();
387
388        let dst = tmp.path().join("dst");
389        cow_clone_dir(&src, &dst).unwrap();
390
391        assert_eq!(fs::read_to_string(dst.join("a.txt")).unwrap(), "hello");
392        assert_eq!(fs::read_to_string(dst.join("sub/b.txt")).unwrap(), "world");
393    }
394
395    #[test]
396    fn cow_clone_is_isolated() {
397        let tmp = tempfile::tempdir().unwrap();
398        let src = tmp.path().join("src");
399        fs::create_dir(&src).unwrap();
400        fs::write(src.join("f.txt"), b"original").unwrap();
401
402        let dst = tmp.path().join("dst");
403        cow_clone_dir(&src, &dst).unwrap();
404
405        // Mutate dst; src must be unchanged.
406        fs::write(dst.join("f.txt"), b"modified").unwrap();
407        assert_eq!(fs::read_to_string(src.join("f.txt")).unwrap(), "original");
408        assert_eq!(fs::read_to_string(dst.join("f.txt")).unwrap(), "modified");
409    }
410
411    #[test]
412    fn cow_clone_fails_if_dst_exists() {
413        let tmp = tempfile::tempdir().unwrap();
414        let src = tmp.path().join("src");
415        fs::create_dir(&src).unwrap();
416        let dst = tmp.path().join("dst");
417        fs::create_dir(&dst).unwrap();
418
419        assert!(cow_clone_dir(&src, &dst).is_err());
420    }
421
422    #[test]
423    fn detect_strategy_returns_something() {
424        // Should always detect at least FullCopy.
425        let s = detect_strategy();
426        assert!(!matches!(s, CowStrategy::FuseOverlay));
427        // Can't assert specific strategy (platform-dependent) but it must not panic.
428    }
429}