1use 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#[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#[derive(Debug, Clone)]
27pub struct StrategyProbe {
28 pub strategy: CowStrategy,
29 pub available: bool,
30 pub reason: &'static str,
31}
32
33#[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
166pub 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 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 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 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 let s = detect_strategy();
426 assert!(!matches!(s, CowStrategy::FuseOverlay));
427 }
429}