Skip to main content

rightkit_fs/
clone.rs

1//! Copy-on-write file clones that never fall back to a byte copy.
2//!
3//! On macOS this calls `clonefile(2)` with `CLONE_NOFOLLOW`. The kernel either creates a
4//! clone on the same APFS volume or fails with a typed error; there is no `read`/`write`
5//! fallback, so a caller that asked for a clone can never silently receive a copy.
6//! Other platforms always return [`CloneError::Unsupported`].
7
8use std::io;
9use std::path::{Path, PathBuf};
10
11/// Why a clone was not created. Each variant maps to a stable code for callers and the helper binary.
12#[derive(Debug, thiserror::Error)]
13pub enum CloneError {
14    /// The source is a symbolic link. It is refused, never followed or cloned as a link.
15    #[error("{0} is a symbolic link; clone refuses links")]
16    Symlink(PathBuf),
17    /// The source is not a regular file (directory, device, socket, ...).
18    #[error("{0} is not a regular file")]
19    NotAFile(PathBuf),
20    /// Source and destination are on different volumes; clonefile needs one volume.
21    #[error("{src} and {dst} are on different volumes")]
22    CrossVolume { src: PathBuf, dst: PathBuf },
23    /// The volume or filesystem cannot clone (not APFS, or the kernel reports ENOTSUP/ENOSYS).
24    #[error("clonefile is not supported for {path}: {source}")]
25    Unsupported {
26        path: PathBuf,
27        #[source]
28        source: io::Error,
29    },
30    /// The source or destination path cannot be passed to the kernel (embedded NUL).
31    #[error("path contains a NUL byte: {0}")]
32    InvalidPath(PathBuf),
33    /// Any other failure (missing source, destination exists, permissions, ...).
34    #[error("clone of {path} failed: {source}")]
35    Io {
36        path: PathBuf,
37        #[source]
38        source: io::Error,
39    },
40}
41
42impl CloneError {
43    /// Stable machine code, shared with the `rightkit-clonefile` helper's JSON output.
44    pub fn code(&self) -> &'static str {
45        match self {
46            Self::Symlink(_) => "CLONE_SYMLINK_REFUSED",
47            Self::NotAFile(_) => "CLONE_NOT_A_FILE",
48            Self::CrossVolume { .. } => "CLONE_CROSS_VOLUME",
49            Self::Unsupported { .. } => "CLONE_UNSUPPORTED",
50            Self::InvalidPath(_) => "CLONE_INVALID_PATH",
51            Self::Io { .. } => "CLONE_IO",
52        }
53    }
54
55    /// Exit status used by the `rightkit-clonefile` helper for this failure.
56    pub fn exit_code(&self) -> u8 {
57        match self {
58            Self::Unsupported { .. } => 3,
59            Self::CrossVolume { .. } => 4,
60            Self::Symlink(_) => 5,
61            Self::NotAFile(_) => 6,
62            Self::InvalidPath(_) => 2,
63            Self::Io { .. } => 1,
64        }
65    }
66
67    #[cfg(target_os = "macos")]
68    fn io(path: &Path, source: io::Error) -> Self {
69        Self::Io {
70            path: path.to_path_buf(),
71            source,
72        }
73    }
74}
75
76/// Clones regular file `src` to a new path `dst` without following a symlink at `src`.
77///
78/// `dst` must not exist. The clone is created atomically by the kernel; on any failure no
79/// partial destination is left behind and no bytes are copied.
80#[cfg(target_os = "macos")]
81pub fn clone_file_nofollow(src: &Path, dst: &Path) -> Result<(), CloneError> {
82    use std::ffi::CString;
83    use std::os::unix::ffi::OsStrExt;
84
85    /// `CLONE_NOFOLLOW` from `<sys/clonefile.h>`: do not follow a symlink at the final `src` component.
86    const CLONE_NOFOLLOW: u32 = 0x0001;
87
88    let meta = std::fs::symlink_metadata(src).map_err(|source| CloneError::io(src, source))?;
89    if meta.file_type().is_symlink() {
90        return Err(CloneError::Symlink(src.to_path_buf()));
91    }
92    if !meta.is_file() {
93        return Err(CloneError::NotAFile(src.to_path_buf()));
94    }
95    let c_src = CString::new(src.as_os_str().as_bytes())
96        .map_err(|_| CloneError::InvalidPath(src.to_path_buf()))?;
97    let c_dst = CString::new(dst.as_os_str().as_bytes())
98        .map_err(|_| CloneError::InvalidPath(dst.to_path_buf()))?;
99    // SAFETY: both pointers are valid NUL-terminated strings that outlive the call; flags is a documented value.
100    let rc = unsafe { libc::clonefile(c_src.as_ptr(), c_dst.as_ptr(), CLONE_NOFOLLOW) };
101    if rc == 0 {
102        return Ok(());
103    }
104    let source = io::Error::last_os_error();
105    Err(match source.raw_os_error() {
106        Some(libc::EXDEV) => CloneError::CrossVolume {
107            src: src.to_path_buf(),
108            dst: dst.to_path_buf(),
109        },
110        Some(code) if code == libc::ENOTSUP || code == libc::ENOSYS => CloneError::Unsupported {
111            path: dst.to_path_buf(),
112            source,
113        },
114        _ => CloneError::io(dst, source),
115    })
116}
117
118/// Non-macOS platforms have no `clonefile(2)`; callers must treat this as unsupported and fail closed.
119#[cfg(not(target_os = "macos"))]
120pub fn clone_file_nofollow(src: &Path, dst: &Path) -> Result<(), CloneError> {
121    Err(CloneError::Unsupported {
122        path: dst.to_path_buf(),
123        source: io::Error::new(
124            io::ErrorKind::Unsupported,
125            format!("clonefile is macOS-only (source {})", src.display()),
126        ),
127    })
128}