Skip to main content

vivacity_core/
clone.rs

1//! Clone of a store tree into vendor/, the hot path of the install.
2//! macOS/APFS: `clonefile(2)` of the whole directory (one syscall, copy-on-write,
3//! measured 8x faster than extraction in M0). Elsewhere, or if clonefile
4//! fails (other FS, different volume): recursive walk with hardlinks (pnpm
5//! model), and a real copy as a last resort. Always towards an ABSENT
6//! destination (the caller removes the previous version first), so no mixed
7//! states.
8
9use crate::error::{Error, Result};
10use std::path::Path;
11
12pub fn clone_tree(src: &Path, dst: &Path) -> Result<()> {
13    if let Some(parent) = dst.parent() {
14        std::fs::create_dir_all(parent).map_err(Error::io(parent))?;
15    }
16
17    #[cfg(target_os = "macos")]
18    {
19        use std::os::unix::ffi::OsStrExt as _;
20        let c_src = std::ffi::CString::new(src.as_os_str().as_bytes());
21        let c_dst = std::ffi::CString::new(dst.as_os_str().as_bytes());
22        if let (Ok(c_src), Ok(c_dst)) = (c_src, c_dst) {
23            // SAFETY: FFI call to clonefile through libc, two valid C strings,
24            // no shared memory.
25            let rc = unsafe { libc::clonefile(c_src.as_ptr(), c_dst.as_ptr(), 0) };
26            if rc == 0 {
27                return Ok(());
28            }
29            // Failure (non-APFS FS, different volumes...): fall through to the next strategies.
30        }
31    }
32
33    link_or_copy_tree(src, dst)
34}
35
36fn link_or_copy_tree(src: &Path, dst: &Path) -> Result<()> {
37    std::fs::create_dir_all(dst).map_err(Error::io(dst))?;
38    for entry in std::fs::read_dir(src).map_err(Error::io(src))? {
39        let entry = entry.map_err(Error::io(src))?;
40        let from = entry.path();
41        let to = dst.join(entry.file_name());
42        let ftype = entry.file_type().map_err(Error::io(&from))?;
43        if ftype.is_dir() {
44            link_or_copy_tree(&from, &to)?;
45        } else if ftype.is_symlink() {
46            let target = std::fs::read_link(&from).map_err(Error::io(&from))?;
47            #[cfg(unix)]
48            std::os::unix::fs::symlink(&target, &to).map_err(Error::io(&to))?;
49            #[cfg(windows)]
50            clone_symlink_windows(&from, &target, &to)?;
51            #[cfg(not(any(unix, windows)))]
52            {
53                let _ = target;
54                std::fs::copy(&from, &to).map_err(Error::io(&to))?;
55            }
56        } else {
57            // Linux: try a reflink first (FICLONE, a CoW copy like clonefile
58            // on APFS) — independent of the store so safer than a hardlink,
59            // and a single syscall on btrfs/xfs. On ext4 (no reflink) the
60            // ioctl fails cleanly and we fall back to the hardlink.
61            #[cfg(target_os = "linux")]
62            let done = reflink(&from, &to);
63            #[cfg(not(target_os = "linux"))]
64            let done = false;
65            if !done {
66                // Hardlink first (free); copy if the FS refuses (other volume).
67                if std::fs::hard_link(&from, &to).is_err() {
68                    std::fs::copy(&from, &to).map_err(Error::io(&to))?;
69                }
70            }
71        }
72    }
73    Ok(())
74}
75
76/// Reflink (copy-on-write) `from` → `to` via the FICLONE ioctl. Creates an
77/// independent inode sharing the extents: modifying the clone does not touch
78/// the store. The new file's mode is re-applied from the source (FICLONE
79/// does not copy permissions), to preserve the executable bit of binaries.
80/// Returns false (without leaving a partial file) if the FS does not support
81/// reflink — the caller then falls back to the hardlink.
82#[cfg(target_os = "linux")]
83fn reflink(from: &Path, to: &Path) -> bool {
84    use std::os::unix::io::AsRawFd as _;
85    // Once a filesystem has said it cannot reflink, stop asking: the probe
86    // is an open/create/ioctl/unlink per file, and the answer does not
87    // change within a run (the store and vendor/ stay where they are).
88    static UNSUPPORTED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
89    if UNSUPPORTED.load(std::sync::atomic::Ordering::Relaxed) {
90        return false;
91    }
92    let Ok(src) = std::fs::File::open(from) else {
93        return false;
94    };
95    let dst = match std::fs::OpenOptions::new()
96        .write(true)
97        .create(true)
98        .truncate(true)
99        .open(to)
100    {
101        Ok(f) => f,
102        Err(_) => return false,
103    };
104    // SAFETY: FICLONE ioctl on two valid, open descriptors; no shared
105    // memory. The kernel reads src, writes dst.
106    let rc = unsafe { libc::ioctl(dst.as_raw_fd(), libc::FICLONE, src.as_raw_fd()) };
107    if rc != 0 {
108        let err = std::io::Error::last_os_error();
109        drop(dst);
110        let _ = std::fs::remove_file(to); // empty file created by the open
111                                          // ENOTTY / EOPNOTSUPP / EXDEV / EINVAL: this filesystem (or this
112                                          // pair of filesystems) never will; other errors are per file.
113        if matches!(
114            err.raw_os_error(),
115            Some(libc::ENOTTY) | Some(libc::EOPNOTSUPP) | Some(libc::EXDEV) | Some(libc::EINVAL)
116        ) {
117            UNSUPPORTED.store(true, std::sync::atomic::Ordering::Relaxed);
118        }
119        return false;
120    }
121    if let Ok(meta) = src.metadata() {
122        let _ = std::fs::set_permissions(to, meta.permissions());
123    }
124    true
125}
126
127/// Windows: creating symlinks requires a privilege (Developer Mode or
128/// SeCreateSymbolicLinkPrivilege). We try the real symlink, and failing that
129/// copy the RESOLVED content — the resulting vendor/ is functional but no
130/// longer a link (a parity divergence documented in docs/windows.md). A
131/// dangling link fails loudly instead of vanishing silently.
132#[cfg(windows)]
133fn clone_symlink_windows(from: &Path, target: &Path, to: &Path) -> Result<()> {
134    let is_dir = std::fs::metadata(from).map(|m| m.is_dir()).unwrap_or(false);
135    let made = if is_dir {
136        std::os::windows::fs::symlink_dir(target, to)
137    } else {
138        std::os::windows::fs::symlink_file(target, to)
139    };
140    if made.is_ok() {
141        return Ok(());
142    }
143    if is_dir {
144        link_or_copy_tree(from, to) // read_dir follows the link
145    } else {
146        if std::fs::hard_link(from, to).is_err() {
147            std::fs::copy(from, to).map_err(Error::io(to))?;
148        }
149        Ok(())
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156
157    #[test]
158    fn clones_files_dirs_symlinks_and_exec_bits() {
159        let tmp = tempfile::tempdir().expect("tmp");
160        let src = tmp.path().join("src");
161        std::fs::create_dir_all(src.join("sub")).expect("mkdir");
162        std::fs::write(src.join("a.txt"), b"hello").expect("write");
163        std::fs::write(src.join("sub/tool"), b"#!/bin/sh\n").expect("write");
164        #[cfg(unix)]
165        {
166            use std::os::unix::fs::PermissionsExt as _;
167            std::fs::set_permissions(src.join("sub/tool"), std::fs::Permissions::from_mode(0o755))
168                .expect("chmod");
169            std::os::unix::fs::symlink("a.txt", src.join("link")).expect("ln");
170        }
171        // Windows: the fixture's link requires Developer Mode or
172        // SeCreateSymbolicLinkPrivilege — without it we cannot build the
173        // fixture, so that portion is announced and then skipped (the copy
174        // fallback of clone_symlink_windows cannot be forced from here).
175        #[cfg(windows)]
176        let with_link = match std::os::windows::fs::symlink_file("a.txt", src.join("link")) {
177            Ok(()) => true,
178            Err(e) => {
179                eprintln!("symlink refused on this host ({e}) — link portion not exercised");
180                false
181            }
182        };
183
184        let dst = tmp.path().join("dst/pkg");
185        clone_tree(&src, &dst).expect("clone");
186        assert_eq!(std::fs::read(dst.join("a.txt")).expect("read"), b"hello");
187        #[cfg(unix)]
188        {
189            use std::os::unix::fs::PermissionsExt as _;
190            let mode = std::fs::metadata(dst.join("sub/tool"))
191                .expect("meta")
192                .permissions()
193                .mode();
194            assert_eq!(mode & 0o111, 0o111, "executable bit lost on clone");
195            assert!(dst
196                .join("link")
197                .symlink_metadata()
198                .expect("meta")
199                .file_type()
200                .is_symlink());
201        }
202        #[cfg(windows)]
203        if with_link {
204            // Never a lost entry: either a real link (privilege present —
205            // the same one that allowed the fixture), or a copy of the
206            // resolved content; in both cases reading yields the target's
207            // content.
208            let meta = dst.join("link").symlink_metadata().expect("entry lost");
209            assert!(
210                meta.file_type().is_symlink() || meta.file_type().is_file(),
211                "neither link nor file"
212            );
213            assert_eq!(std::fs::read(dst.join("link")).expect("read"), b"hello");
214        }
215        // Modifying the clone does not touch the source (CoW or hardlink:
216        // we replace the file, we do not edit it in place).
217        std::fs::write(dst.join("a.txt"), b"changed").expect("write");
218        #[cfg(target_os = "macos")]
219        assert_eq!(std::fs::read(src.join("a.txt")).expect("read"), b"hello");
220    }
221}