Skip to main content

ripsync_core/
copy.rs

1//! The regular-file copy ladder: reflink (`CoW` clone) → kernel-side copy
2//! (`fcopyfile` on macOS, `copy_file_range` on Linux) → buffered fallback with
3//! page-aligned I/O buffers. Each strategy writes into the caller's temporary
4//! path so the atomic-rename invariant is preserved.
5
6#[cfg(not(windows))]
7use std::fs::File;
8use std::io;
9#[cfg(not(windows))]
10use std::io::{BufReader, BufWriter};
11use std::path::Path;
12
13use crate::util::AlignedBuf;
14
15/// How aggressively to attempt reflink (copy-on-write) clones.
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
17pub enum ReflinkMode {
18    /// Try reflink, silently fall back when unsupported/cross-filesystem.
19    #[default]
20    Auto,
21    /// Require reflink; error if it cannot be done.
22    Always,
23    /// Never attempt reflink.
24    Never,
25}
26
27/// When to `fsync` file data before the rename.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum FsyncMode {
30    /// Skip per-file fsync; the apply phase fsyncs touched directories once.
31    #[default]
32    Auto,
33    /// Fsync every file before rename (paranoid; survives power loss per file).
34    Always,
35    /// Skip all fsync, including directories.
36    Never,
37}
38
39/// Default buffer size for the portable fallback copy (1 MiB).
40const DEFAULT_BUF: usize = 1 << 20;
41
42/// Copy `src` into the (not-yet-existing) `tmp`, returning bytes written.
43///
44/// Strategy ladder: reflink → kernel copy → buffered. `tmp` must not exist
45/// yet (reflink/clone requires creating the destination).
46///
47/// Uses the default 1 MiB buffer for the fallback path. For tuned buffer
48/// sizes, use [`copy_file_into_sized`].
49///
50/// # Errors
51///
52/// Returns the underlying I/O error if every applicable strategy fails, or if
53/// [`ReflinkMode::Always`] is set and the clone is unsupported.
54pub fn copy_file_into(
55    src: &Path,
56    tmp: &Path,
57    reflink: ReflinkMode,
58    sparse: bool,
59) -> io::Result<u64> {
60    copy_file_into_sized(src, tmp, reflink, sparse, DEFAULT_BUF)
61}
62
63/// Copy `src` into `tmp` using a caller-specified buffer size for the
64/// fallback path. All other behavior is identical to [`copy_file_into`].
65///
66/// # Errors
67///
68/// Returns the underlying I/O error if every applicable strategy fails.
69pub fn copy_file_into_sized(
70    src: &Path,
71    tmp: &Path,
72    reflink: ReflinkMode,
73    sparse: bool,
74    buffer_size: usize,
75) -> io::Result<u64> {
76    // Windows has its own ladder (block clone → CopyFileExW → buffered); sparse
77    // preservation is not offered on that backend.
78    #[cfg(not(windows))]
79    {
80        copy_file_into_portable(src, tmp, reflink, sparse, buffer_size)
81    }
82    #[cfg(windows)]
83    {
84        let _ = sparse;
85        let _ = buffer_size;
86        crate::io::windows::copy_file_into(src, tmp, reflink)
87    }
88}
89
90/// The POSIX copy ladder:
91///   reflink → sparse → fcopyfile (macOS) → `copy_file_range` (Linux) → buffered.
92#[cfg(not(windows))]
93fn copy_file_into_portable(
94    src: &Path,
95    tmp: &Path,
96    reflink: ReflinkMode,
97    sparse: bool,
98    buffer_size: usize,
99) -> io::Result<u64> {
100    // 1. Reflink (CoW clone) — fastest, no data movement.
101    if reflink != ReflinkMode::Never {
102        match reflink_copy::reflink(src, tmp) {
103            Ok(()) => return std::fs::metadata(tmp).map(|m| m.len()),
104            Err(e) => {
105                let _ = std::fs::remove_file(tmp);
106                if reflink == ReflinkMode::Always {
107                    return Err(e);
108                }
109            }
110        }
111    }
112
113    // 2. Sparse copy — only copy allocated extents.
114    #[cfg(any(
115        target_os = "linux",
116        target_os = "macos",
117        target_os = "ios",
118        target_os = "freebsd",
119        target_os = "dragonfly",
120        target_os = "solaris"
121    ))]
122    if sparse {
123        match sparse_copy_sized(src, tmp, buffer_size) {
124            Ok(n) => return Ok(n),
125            Err(_) => {
126                let _ = std::fs::remove_file(tmp);
127            }
128        }
129    }
130
131    // 3. macOS kernel-side copy via fcopyfile.
132    #[cfg(target_os = "macos")]
133    {
134        match macos_fcopyfile_copy(src, tmp) {
135            Ok(n) => return Ok(n),
136            Err(_) => {
137                let _ = std::fs::remove_file(tmp);
138            }
139        }
140    }
141
142    // 4. io_uring splice for medium files (1–64 MiB) when available.
143    #[cfg(all(target_os = "linux", feature = "io-uring"))]
144    {
145        let file_len = std::fs::metadata(src).map(|m| m.len()).unwrap_or(0);
146        if file_len > 1_048_576 && file_len <= 67_108_864 {
147            match crate::io::uring::copy_single_large(src, tmp, file_len) {
148                Ok(n) => return Ok(n),
149                Err(_) => {
150                    let _ = std::fs::remove_file(tmp);
151                }
152            }
153        }
154    }
155
156    // 5. Kernel-side copy_file_range (Linux).
157    #[cfg(target_os = "linux")]
158    {
159        match copy_file_range_all(src, tmp) {
160            Ok(n) => return Ok(n),
161            Err(_) => {
162                let _ = std::fs::remove_file(tmp);
163            }
164        }
165    }
166
167    // 6. Buffered fallback with page-aligned buffer.
168    buffered_copy_sized(src, tmp, buffer_size)
169}
170
171// ---------------------------------------------------------------------------
172// macOS fcopyfile fast path
173// ---------------------------------------------------------------------------
174
175/// Try `fcopyfile` for a kernel-side copy on macOS.
176#[cfg(target_os = "macos")]
177fn macos_fcopyfile_copy(src: &Path, tmp: &Path) -> io::Result<u64> {
178    use std::os::unix::io::AsRawFd;
179
180    let infile = File::open(src)?;
181    let len = infile.metadata()?.len();
182
183    // For large files, hint the kernel to avoid polluting the UBC.
184    if len >= 8 * 1024 * 1024 {
185        let _ = crate::io::macos::set_nocache(infile.as_raw_fd());
186    }
187
188    let outfile = std::fs::OpenOptions::new()
189        .write(true)
190        .create_new(true)
191        .open(tmp)?;
192
193    if len >= 8 * 1024 * 1024 {
194        let _ = crate::io::macos::set_nocache(outfile.as_raw_fd());
195    }
196
197    crate::io::macos::copy_file_with_fcopyfile(infile.as_raw_fd(), outfile.as_raw_fd())
198}
199
200// ---------------------------------------------------------------------------
201// Sparse copy
202// ---------------------------------------------------------------------------
203
204/// Copy allocated extents only, preserving holes in sparse files.
205#[cfg(any(
206    target_os = "linux",
207    target_os = "macos",
208    target_os = "ios",
209    target_os = "freebsd",
210    target_os = "dragonfly",
211    target_os = "solaris"
212))]
213fn sparse_copy_sized(src: &Path, tmp: &Path, buffer_size: usize) -> io::Result<u64> {
214    use std::os::unix::fs::FileExt;
215
216    use rustix::fs::SeekFrom;
217
218    let infile = File::open(src)?;
219    let outfile = File::create(tmp)?;
220    let len = infile.metadata()?.len();
221    outfile.set_len(len)?;
222
223    let mut offset = 0_u64;
224    let mut buf = AlignedBuf::new(buffer_size);
225    let mut bytes_written = 0_u64;
226    while offset < len {
227        let data = match rustix::fs::seek(&infile, SeekFrom::Data(offset)) {
228            Ok(data) => data,
229            Err(error) if error == rustix::io::Errno::NXIO => break,
230            Err(error) => return Err(io::Error::from(error)),
231        };
232        let hole = rustix::fs::seek(&infile, SeekFrom::Hole(data))?.min(len);
233        let mut position = data;
234        while position < hole {
235            let wanted = usize::try_from((hole - position).min(buf.len() as u64))
236                .unwrap_or(buf.len());
237            let read = infile.read_at(&mut buf[..wanted], position)?;
238            if read == 0 {
239                return Err(io::Error::new(
240                    io::ErrorKind::UnexpectedEof,
241                    "short sparse extent read",
242                ));
243            }
244            let mut written = 0;
245            while written < read {
246                let count =
247                    outfile.write_at(&buf[written..read], position + written as u64)?;
248                if count == 0 {
249                    return Err(io::Error::new(
250                        io::ErrorKind::WriteZero,
251                        "short sparse extent write",
252                    ));
253                }
254                written += count;
255            }
256            position += read as u64;
257        }
258        bytes_written = hole;
259        offset = hole;
260    }
261    outfile.set_len(bytes_written)?;
262    Ok(bytes_written)
263}
264
265/// Hint the kernel to read a large source sequentially and prefetch it. No-op
266/// off Linux and for small files (where the syscalls are not worth it).
267#[cfg(not(windows))]
268fn advise_sequential(file: &File, len: u64) {
269    #[cfg(target_os = "linux")]
270    {
271        use rustix::fs::{Advice, fadvise};
272        if len >= 8 * 1024 * 1024 {
273            let span = std::num::NonZeroU64::new(len);
274            let _ = fadvise(file, 0, span, Advice::Sequential);
275            let _ = fadvise(file, 0, span, Advice::WillNeed);
276        }
277    }
278    #[cfg(not(target_os = "linux"))]
279    let _ = (file, len);
280}
281
282/// Portable buffered copy with a page-aligned buffer.
283#[cfg(not(windows))]
284fn buffered_copy_sized(src: &Path, tmp: &Path, buffer_size: usize) -> io::Result<u64> {
285    let reader = File::open(src)?;
286    let writer = File::create(tmp)?;
287    let file_len = reader.metadata()?.len();
288    advise_sequential(&reader, file_len);
289
290    // On macOS, hint the kernel to avoid UBC pollution for large files.
291    #[cfg(target_os = "macos")]
292    if file_len >= 8 * 1024 * 1024 {
293        use std::os::unix::io::AsRawFd;
294        let _ = crate::io::macos::set_nocache(reader.as_raw_fd());
295        let _ = crate::io::macos::set_nocache(writer.as_raw_fd());
296    }
297
298    let mut r = BufReader::with_capacity(buffer_size, reader);
299    let mut w = BufWriter::with_capacity(buffer_size, writer);
300    let n = io::copy(&mut r, &mut w)?;
301    // Flush the buffer to the OS, but do NOT force a device flush here: per-file
302    // durability is the caller's job (`finalize_file` fsyncs each file in
303    // `FsyncMode::Always`; `Auto` fsyncs the touched directories once). An
304    // unconditional `sync_all` here both double-fsyncs in `Always` and violates
305    // the documented `Auto`/`Never` "skip per-file fsync" contract — and on
306    // macOS it lowers to `F_FULLFSYNC` (a full drive flush), which made the
307    // non-reflink path pathologically slow on many small files.
308    w.into_inner()?;
309    Ok(n)
310}
311
312/// Loop `copy_file_range` until the whole file is copied.
313#[cfg(target_os = "linux")]
314fn copy_file_range_all(src: &Path, tmp: &Path) -> io::Result<u64> {
315    let infile = File::open(src)?;
316    let outfile = File::create(tmp)?;
317    let len = infile.metadata()?.len();
318    advise_sequential(&infile, len);
319    let mut remaining = len;
320    while remaining > 0 {
321        let chunk = usize::try_from(remaining.min(1 << 30)).unwrap_or(usize::MAX);
322        let copied = rustix::fs::copy_file_range(&infile, None, &outfile, None, chunk)?;
323        if copied == 0 {
324            return Err(io::Error::new(
325                io::ErrorKind::UnexpectedEof,
326                "source file truncated during copy",
327            ));
328        }
329        remaining -= copied as u64;
330    }
331    Ok(len - remaining)
332}