Skip to main content

common/
copy_data.rs

1//! Low-level data-copy primitive used by the copy path.
2//!
3//! This replaces `std::fs::copy` so the copy path can keep the destination fd
4//! open across the data copy and the subsequent metadata operations (closing
5//! the TOCTOU window between writing bytes and setting times/owner/mode).
6//!
7//! The copy uses a three-tier fallback chain, fastest first:
8//! 1. the in-kernel `copy_file_range` syscall, which is reflink- and
9//!    server-side-copy capable (e.g. on Btrfs/XFS/NFSv4.2);
10//! 2. when the kernel or filesystem cannot satisfy that, a sparse-aware
11//!    userspace copy that walks the source with `SEEK_DATA`/`SEEK_HOLE` and
12//!    preserves holes;
13//! 3. when the filesystem does not even support `SEEK_DATA`/`SEEK_HOLE` (some
14//!    FUSE/older/unusual filesystems return `EINVAL`/`ENOTSUP` for those
15//!    `lseek` whences), a plain dense read/write copy loop, which always works
16//!    on any regular file (this mirrors what `std::fs::copy` would have done
17//!    and exists purely for robustness — it does not preserve holes).
18//!
19//! # Snapshot-size semantics
20//!
21//! Callers pass `len`, the size captured when the source was classified. Both
22//! the primary and fallback paths copy *up to* `len` and agree on the result:
23//! - a source that *grew* after classification is intentionally **not**
24//!   over-copied — we copy at most `len` bytes and `dst` ends at `len`;
25//! - a source that *shrank* below `len` (e.g. a concurrent truncate — holding
26//!   the fd does not prevent it) copies and returns only what the source
27//!   provides and sizes `dst` to that actual end, **not** to `len` (no
28//!   spurious trailing padding);
29//! - a *legitimate* sparse trailing hole (the source's logical size still
30//!   equals `len`) keeps `dst` sized to `len`. Whether the trailing region is
31//!   left *unallocated* depends on the path: the sparse-aware fallback (tier 2)
32//!   preserves the hole, and the primary `copy_file_range` (tier 1) does so only
33//!   on reflink/sparse-capable filesystems (e.g. Btrfs/XFS) — on others (e.g.
34//!   ext4) the size is still `len` but the region may be fully allocated.
35//!
36//! # Durability
37//!
38//! These are synchronous syscalls, so once they return the writes are in the
39//! kernel. This function deliberately does **not** `fsync`/`flush` — durability
40//! is out of scope here. mtime correctness is the caller's responsibility (it
41//! sets times after this returns).
42use std::fs::File;
43use std::os::fd::AsFd;
44use std::os::unix::fs::FileExt;
45
46/// sparse-aware userspace fallback copy buffer size (1 MiB).
47const FALLBACK_BUF_SIZE: usize = 1024 * 1024;
48
49/// dense read/write fallback copy buffer size (128 KiB). Smaller than the
50/// sparse buffer because this path is the last-resort robustness fallback for
51/// filesystems that can't do `SEEK_DATA`/`SEEK_HOLE`, not a throughput path.
52const DENSE_BUF_SIZE: usize = 128 * 1024;
53
54/// Copy up to `len` bytes from `src` to `dst` using the in-kernel
55/// `copy_file_range` (reflink/server-side capable), falling back to a
56/// sparse-aware userspace copy when the kernel/filesystem can't. Both files are
57/// already open; offsets start at `0`. Returns the number of bytes copied.
58///
59/// See the module docs for snapshot-size and durability semantics.
60pub fn copy_file_range_all(src: &File, dst: &File, len: u64) -> std::io::Result<u64> {
61    // establish the documented offset-0 start on both fds. The caller may hand
62    // us descriptors whose offsets were advanced by an earlier read/stat, and
63    // copy_file_range with `None` offsets uses each fd's current position.
64    nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekSet)
65        .map_err(std::io::Error::from)?;
66    nix::unistd::lseek(dst.as_fd(), 0, nix::unistd::Whence::SeekSet)
67        .map_err(std::io::Error::from)?;
68    let mut copied: u64 = 0;
69    while copied < len {
70        let remaining = usize::try_from(len - copied).unwrap_or(usize::MAX);
71        // None offsets => the kernel uses and advances each fd's own offset,
72        // so successive calls naturally continue where the last left off.
73        match nix::fcntl::copy_file_range(src.as_fd(), None, dst.as_fd(), None, remaining) {
74            Ok(0) => {
75                // EOF: the source is shorter than `len` (a shrink). Stop here
76                // rather than spinning forever on a zero-byte copy.
77                return Ok(copied);
78            }
79            Ok(n) => copied += n as u64,
80            Err(errno) => {
81                // these errnos mean copy_file_range is unsupported for this
82                // pair (no kernel support, cross-filesystem, bad arguments,
83                // or the fs rejects it) -> fall back for the remaining range.
84                // note: ENOTSUP == EOPNOTSUPP numerically on Linux.
85                if matches!(
86                    errno,
87                    nix::errno::Errno::ENOSYS
88                        | nix::errno::Errno::EXDEV
89                        | nix::errno::Errno::EINVAL
90                        | nix::errno::Errno::EOPNOTSUPP
91                ) {
92                    // the fallback returns only the bytes IT moved ([copied, final]);
93                    // add the prefix the primary path already copied so the total is
94                    // reported correctly.
95                    return copy_sparse_fallback(src, dst, copied, len)
96                        .map(|fallback| copied + fallback);
97                }
98                return Err(std::io::Error::from(errno));
99            }
100        }
101    }
102    Ok(copied)
103}
104
105/// Classification of an initial `SEEK_DATA` probe, used to decide whether the
106/// sparse fallback can run or must degrade to a dense read/write copy.
107#[derive(Debug, Clone, Copy, PartialEq, Eq)]
108enum SparseProbe {
109    /// `SEEK_DATA` found a data region starting at this offset.
110    Data(u64),
111    /// `SEEK_DATA` returned `ENXIO`: no data at or after the probe offset, i.e.
112    /// the rest of the file up to its logical end is a hole.
113    TrailingHole,
114    /// the filesystem does not support `SEEK_DATA`/`SEEK_HOLE` (`EINVAL` or
115    /// `ENOTSUP`/`EOPNOTSUPP`): the caller must fall back to a dense copy.
116    Unsupported,
117}
118
119/// Classify the result of an `lseek(.., SEEK_DATA)` probe (the testable seam for
120/// the sparse-vs-dense routing decision).
121///
122/// - `Ok(off)` -> [`SparseProbe::Data`] at `off`;
123/// - `ENXIO` -> [`SparseProbe::TrailingHole`] (a legitimate trailing hole, not
124///   an error: the file simply has no more data);
125/// - `EINVAL` / `EOPNOTSUPP` (== `ENOTSUP` on Linux) -> [`SparseProbe::Unsupported`],
126///   meaning the filesystem rejects the `SEEK_DATA`/`SEEK_HOLE` whences and the
127///   sparse walk can't run on it;
128/// - any other errno (e.g. `EIO`, `EBADF`) is a genuine failure and is
129///   propagated — we deliberately do **not** mask real I/O errors as
130///   "unsupported".
131fn classify_seek_data(result: nix::Result<libc::off_t>) -> std::io::Result<SparseProbe> {
132    match result {
133        Ok(off) => Ok(SparseProbe::Data(off as u64)),
134        Err(nix::errno::Errno::ENXIO) => Ok(SparseProbe::TrailingHole),
135        // note: ENOTSUP == EOPNOTSUPP numerically on Linux, so this arm also
136        // covers ENOTSUP.
137        Err(nix::errno::Errno::EINVAL | nix::errno::Errno::EOPNOTSUPP) => {
138            Ok(SparseProbe::Unsupported)
139        }
140        Err(errno) => Err(std::io::Error::from(errno)),
141    }
142}
143
144/// Sparse-aware userspace copy of the range `[start, len)` from `src` to `dst`,
145/// with a dense read/write copy as a final fallback.
146///
147/// Used as the fallback when `copy_file_range` is unsupported. It first probes
148/// the source with `SEEK_DATA`; if the filesystem supports it, it walks the
149/// source's data regions with `SEEK_DATA`/`SEEK_HOLE` and copies only the data
150/// extents, so holes (e.g. in a sparse VM or Lustre image) are preserved
151/// instead of being expanded to fully-allocated zeros. If the filesystem does
152/// not support those whences (some FUSE/older/unusual filesystems return
153/// `EINVAL`/`ENOTSUP`), it degrades to [`dense_copy`], a plain read/write loop
154/// that always works on a regular file (this matches what `std::fs::copy` would
155/// have done — see the module docs). A genuine I/O error during the probe (e.g.
156/// `EIO`) is propagated rather than masked as "unsupported".
157///
158/// The final size is reconciled with the source's *actual* end so this path
159/// agrees with the primary one on a shrunk source: after the data loop it
160/// computes `final = min(len, actual_eof)` and `ftruncate`s `dst` to `final`.
161/// A legitimate trailing hole (source logical size still == `len`) leaves
162/// `actual_eof == len`, so `dst` ends at `len` with the trailing region
163/// unallocated; a source that shrank below `len` (`actual_eof < len`) sizes
164/// `dst` to `actual_eof` rather than padding a spurious hole up to `len`.
165///
166/// Returns `final - start`, i.e. the number of bytes of the logical copy that
167/// the source actually provided — matching the primary path's return.
168pub(crate) fn copy_sparse_fallback(
169    src: &File,
170    dst: &File,
171    start: u64,
172    len: u64,
173) -> std::io::Result<u64> {
174    if start >= len {
175        // only reachable with `start == len`: the primary path copied the whole
176        // logical range before erroring, so the source was at least `len` and
177        // there is nothing left to copy. `min(len, actual_eof) == len` here, so
178        // sizing `dst` to `len` is correct and can't drop already-copied bytes.
179        nix::unistd::ftruncate(dst.as_fd(), to_off_t(len)?).map_err(std::io::Error::from)?;
180        return Ok(0);
181    }
182    // probe the source for sparse support before committing to the sparse walk.
183    // if the filesystem can't do SEEK_DATA/SEEK_HOLE, fall back to a dense copy
184    // of the whole range; a genuine I/O error propagates from classify_seek_data.
185    let probe = classify_seek_data(nix::unistd::lseek(
186        src.as_fd(),
187        to_off_t(start)?,
188        nix::unistd::Whence::SeekData,
189    ))?;
190    let first_data = match probe {
191        SparseProbe::Unsupported => return dense_copy(src, dst, start, len),
192        // no data at all before EOF: nothing to copy, the trailing ftruncate
193        // below sizes dst (a fully-hole range).
194        SparseProbe::TrailingHole => len,
195        SparseProbe::Data(d) => d,
196    };
197    // the data loop reads/writes at explicit offsets via read_at/write_at, so we
198    // don't pre-seek the fds here; the scan starts from the probed first data
199    // offset and re-runs SEEK_DATA for subsequent regions.
200    let mut buf = vec![0u8; FALLBACK_BUF_SIZE];
201    let mut off = start;
202    let mut next_data = first_data;
203    while off < len {
204        // `next_data` holds the next data region at or after `off` (seeded by the
205        // initial probe, then refreshed by SEEK_DATA at the bottom of the loop).
206        let data = next_data;
207        if data >= len {
208            break;
209        }
210        // find the hole that ends this data region; clamp to `len`.
211        let hole =
212            match nix::unistd::lseek(src.as_fd(), to_off_t(data)?, nix::unistd::Whence::SeekHole) {
213                Ok(h) => (h as u64).min(len),
214                // a data region with no following hole means data extends to EOF;
215                // clamp to `len`.
216                Err(nix::errno::Errno::ENXIO) => len,
217                Err(errno) => return Err(std::io::Error::from(errno)),
218            };
219        copy_data_extent(src, dst, data, hole, &mut buf)?;
220        off = hole;
221        if off >= len {
222            break;
223        }
224        // find the next data region for the following iteration. the filesystem
225        // already proved it supports SEEK_DATA on the initial probe, so an
226        // Unsupported result here would be anomalous; handle it defensively by
227        // dense-copying the remaining range (dense_copy reconciles dst's final
228        // size for the whole file, so the already-copied prefix is preserved).
229        next_data = match classify_seek_data(nix::unistd::lseek(
230            src.as_fd(),
231            to_off_t(off)?,
232            nix::unistd::Whence::SeekData,
233        ))? {
234            SparseProbe::Data(d) => d,
235            SparseProbe::TrailingHole => break, // no more data -> trailing hole
236            SparseProbe::Unsupported => return dense_copy(src, dst, off, len),
237        };
238    }
239    // reconcile the final size with the source's actual end so we agree with the
240    // primary path on a shrunk source. `min(len, actual_eof)`: a legitimate
241    // trailing hole keeps `actual_eof == len` (dst ends at `len`, trailing region
242    // unallocated); a source that shrank below `len` sizes dst to `actual_eof`
243    // rather than padding a spurious hole up to `len`.
244    let actual_eof = nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekEnd)
245        .map_err(std::io::Error::from)? as u64;
246    let final_size = len.min(actual_eof);
247    nix::unistd::ftruncate(dst.as_fd(), to_off_t(final_size)?).map_err(std::io::Error::from)?;
248    // saturating: if the source shrank below `start` between the primary copy
249    // and this check, the fallback added no bytes (final < start).
250    Ok(final_size.saturating_sub(start))
251}
252
253/// Copy the data extent `[from, to)` from `src` to `dst` at the same offsets
254/// using explicit positioned reads/writes, handling short reads and writes.
255fn copy_data_extent(
256    src: &File,
257    dst: &File,
258    from: u64,
259    to: u64,
260    buf: &mut [u8],
261) -> std::io::Result<()> {
262    let mut pos = from;
263    while pos < to {
264        let want = usize::try_from((to - pos).min(buf.len() as u64)).unwrap_or(buf.len());
265        let n = src.read_at(&mut buf[..want], pos)?;
266        if n == 0 {
267            // source ended earlier than SEEK_HOLE implied (e.g. a concurrent
268            // shrink) -> stop; the trailing ftruncate will fix up the size.
269            break;
270        }
271        let mut written = 0;
272        while written < n {
273            let w = dst.write_at(&buf[written..n], pos + written as u64)?;
274            if w == 0 {
275                // a zero-length write on a non-empty buffer would spin forever.
276                return Err(std::io::Error::from(std::io::ErrorKind::WriteZero));
277            }
278            written += w;
279        }
280        pos += n as u64;
281    }
282    Ok(())
283}
284
285/// Dense read/write copy of the range `[start, len)` from `src` to `dst`, the
286/// final robustness fallback for filesystems that don't support
287/// `SEEK_DATA`/`SEEK_HOLE`.
288///
289/// Reads fixed-size buffers from `src` and writes them to `dst` at the same
290/// offsets until the source ends or `len` is reached, retrying on `EINTR` and
291/// handling short reads/writes. Unlike the sparse path it does not preserve
292/// holes — it reads zeros out of a hole and writes them — but it always works
293/// on any regular file (this matches what `std::fs::copy` would have done).
294///
295/// Size reconciliation matches [`copy_sparse_fallback`]: it `ftruncate`s `dst`
296/// to `min(len, actual_eof)` so a source that shrank below `len` sizes `dst` to
297/// its real end (no spurious trailing padding), and a source at least `len`
298/// long sizes `dst` to exactly `len`. Returns `final - start`, the number of
299/// bytes of the logical copy the source actually provided.
300fn dense_copy(src: &File, dst: &File, start: u64, len: u64) -> std::io::Result<u64> {
301    let mut buf = vec![0u8; DENSE_BUF_SIZE];
302    let mut pos = start;
303    while pos < len {
304        let want = usize::try_from((len - pos).min(buf.len() as u64)).unwrap_or(buf.len());
305        let n = match src.read_at(&mut buf[..want], pos) {
306            Ok(0) => break, // EOF: source is shorter than `len` (a shrink).
307            Ok(n) => n,
308            // a signal interrupted the read before any bytes moved: retry.
309            Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
310            Err(err) => return Err(err),
311        };
312        let mut written = 0;
313        while written < n {
314            match dst.write_at(&buf[written..n], pos + written as u64) {
315                // a zero-length write on a non-empty buffer would spin forever.
316                Ok(0) => return Err(std::io::Error::from(std::io::ErrorKind::WriteZero)),
317                Ok(w) => written += w,
318                Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
319                Err(err) => return Err(err),
320            }
321        }
322        pos += n as u64;
323    }
324    // reconcile the final size with the source's actual end, matching the sparse
325    // path: `min(len, actual_eof)` sizes dst to the real source end on a shrink
326    // and to exactly `len` otherwise. this also fixes up the size when the loop
327    // stopped early on an EOF that the read loop detected before reaching `len`.
328    let actual_eof = nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekEnd)
329        .map_err(std::io::Error::from)? as u64;
330    let final_size = len.min(actual_eof);
331    nix::unistd::ftruncate(dst.as_fd(), to_off_t(final_size)?).map_err(std::io::Error::from)?;
332    // saturating: if the source shrank below `start`, no bytes were added.
333    Ok(final_size.saturating_sub(start))
334}
335
336/// convert a `u64` byte offset/length to the libc `off_t` expected by nix's
337/// lseek/ftruncate, mapping overflow to an io error rather than panicking.
338fn to_off_t(value: u64) -> std::io::Result<libc::off_t> {
339    libc::off_t::try_from(value).map_err(|_| {
340        std::io::Error::new(
341            std::io::ErrorKind::InvalidInput,
342            "file offset exceeds off_t range",
343        )
344    })
345}
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350    use std::io::Write as _;
351    use std::os::unix::fs::MetadataExt;
352
353    fn make_file(dir: &std::path::Path, name: &str) -> File {
354        std::fs::OpenOptions::new()
355            .read(true)
356            .write(true)
357            .create(true)
358            .truncate(true)
359            .open(dir.join(name))
360            .expect("open temp file")
361    }
362
363    #[test]
364    fn copies_bytes_identically() {
365        let tmp = tempfile::tempdir().unwrap();
366        let contents = b"the quick brown fox jumps over the lazy dog";
367        let mut src = make_file(tmp.path(), "src");
368        src.write_all(contents).unwrap();
369        src.sync_all().unwrap();
370        let dst = make_file(tmp.path(), "dst");
371        let copied = copy_file_range_all(&src, &dst, contents.len() as u64).unwrap();
372        assert_eq!(copied, contents.len() as u64);
373        let got = std::fs::read(tmp.path().join("dst")).unwrap();
374        assert_eq!(got, contents);
375    }
376
377    #[test]
378    fn copies_large_file_fully() {
379        let tmp = tempfile::tempdir().unwrap();
380        let len: usize = 8 * 1024 * 1024;
381        // varied bytes so a truncated/short copy would be detectable.
382        let data: Vec<u8> = (0..len)
383            .map(|i| (i.wrapping_mul(31) ^ (i >> 7)) as u8)
384            .collect();
385        let mut src = make_file(tmp.path(), "src");
386        src.write_all(&data).unwrap();
387        src.sync_all().unwrap();
388        let dst = make_file(tmp.path(), "dst");
389        let copied = copy_file_range_all(&src, &dst, len as u64).unwrap();
390        assert_eq!(copied, len as u64);
391        let got = std::fs::read(tmp.path().join("dst")).unwrap();
392        assert_eq!(got.len(), len);
393        assert!(got == data, "destination bytes differ from source");
394    }
395
396    #[test]
397    fn sparse_fallback_preserves_holes() {
398        let tmp = tempfile::tempdir().unwrap();
399        let logical: u64 = 8 * 1024 * 1024;
400        let head = b"HEAD-region-bytes";
401        let tail = b"TAIL-region-bytes";
402        let tail_off = logical - tail.len() as u64;
403        let src = make_file(tmp.path(), "src");
404        // create a sparse file: size = logical, small data near start and end,
405        // big hole in the middle.
406        nix::unistd::ftruncate(src.as_fd(), to_off_t(logical).unwrap()).unwrap();
407        src.write_at(head, 0).unwrap();
408        src.write_at(tail, tail_off).unwrap();
409        src.sync_all().unwrap();
410        let dst = make_file(tmp.path(), "dst");
411        // call the fallback directly rather than relying on triggering EXDEV.
412        let copied = copy_sparse_fallback(&src, &dst, 0, logical).unwrap();
413        assert_eq!(copied, logical);
414        // (a) content byte-equal to src across the whole logical range.
415        let got = std::fs::read(tmp.path().join("dst")).unwrap();
416        let expected = std::fs::read(tmp.path().join("src")).unwrap();
417        assert_eq!(got.len() as u64, logical);
418        assert_eq!(got, expected, "destination content differs from source");
419        // spot-check the data regions explicitly.
420        assert_eq!(&got[..head.len()], head);
421        assert_eq!(&got[tail_off as usize..], tail);
422        // (b) dst size == logical.
423        let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
424        assert_eq!(dst_meta.len(), logical);
425        // (c) dst is actually sparse: allocated bytes << logical size.
426        let src_meta = std::fs::metadata(tmp.path().join("src")).unwrap();
427        let dst_allocated = dst_meta.blocks() * 512;
428        let src_allocated = src_meta.blocks() * 512;
429        eprintln!(
430            "sparse blocks: src={} blocks ({} bytes), dst={} blocks ({} bytes), logical={} bytes",
431            src_meta.blocks(),
432            src_allocated,
433            dst_meta.blocks(),
434            dst_allocated,
435            logical
436        );
437        assert!(
438            dst_allocated < logical,
439            "destination is not sparse: allocated {dst_allocated} >= logical {logical}"
440        );
441        // dst should be roughly as sparse as src (within a generous factor to
442        // tolerate filesystem block-allocation differences).
443        assert!(
444            dst_allocated <= src_allocated * 4 + 4096,
445            "destination far less sparse than source: dst={dst_allocated} src={src_allocated}"
446        );
447    }
448
449    #[test]
450    fn terminates_on_short_source() {
451        let tmp = tempfile::tempdir().unwrap();
452        let contents = b"short source contents";
453        let real_len = contents.len() as u64;
454        let mut src = make_file(tmp.path(), "src");
455        src.write_all(contents).unwrap();
456        src.sync_all().unwrap();
457        // create the destination so the worker thread can open it for writing.
458        let _dst = make_file(tmp.path(), "dst");
459        // claim a length larger than the real file: must return promptly having
460        // copied exactly the real bytes (the Ok(0)/EOF guard prevents a hang).
461        let claimed = real_len + 4096;
462        let (tx, rx) = std::sync::mpsc::channel();
463        let src_path = tmp.path().join("src");
464        let dst_path = tmp.path().join("dst");
465        std::thread::spawn(move || {
466            let src = std::fs::File::open(&src_path).unwrap();
467            let dst = std::fs::OpenOptions::new()
468                .read(true)
469                .write(true)
470                .open(&dst_path)
471                .unwrap();
472            let copied = copy_file_range_all(&src, &dst, claimed).unwrap();
473            tx.send(copied).unwrap();
474        });
475        let copied = rx
476            .recv_timeout(std::time::Duration::from_secs(10))
477            .expect("copy_file_range_all hung on a short source");
478        assert_eq!(copied, real_len);
479        let got = std::fs::read(tmp.path().join("dst")).unwrap();
480        assert_eq!(got, contents);
481    }
482
483    #[test]
484    fn copies_partial_len_on_both_paths() {
485        // caller asks for fewer bytes than the source has: both paths must copy
486        // exactly `len` bytes, leave dst at `len`, and return `len`.
487        let tmp = tempfile::tempdir().unwrap();
488        let full: Vec<u8> = (0u32..4096).map(|i| (i % 251) as u8).collect();
489        let len: u64 = 1000;
490        let mut src = make_file(tmp.path(), "src");
491        src.write_all(&full).unwrap();
492        src.sync_all().unwrap();
493        // primary path.
494        let dst_cfr = make_file(tmp.path(), "dst_cfr");
495        let copied_cfr = copy_file_range_all(&src, &dst_cfr, len).unwrap();
496        assert_eq!(copied_cfr, len);
497        let got_cfr = std::fs::read(tmp.path().join("dst_cfr")).unwrap();
498        assert_eq!(got_cfr.len() as u64, len);
499        assert_eq!(got_cfr, &full[..len as usize]);
500        // fallback path called directly.
501        let dst_fb = make_file(tmp.path(), "dst_fb");
502        let copied_fb = copy_sparse_fallback(&src, &dst_fb, 0, len).unwrap();
503        assert_eq!(copied_fb, len);
504        let got_fb = std::fs::read(tmp.path().join("dst_fb")).unwrap();
505        assert_eq!(got_fb.len() as u64, len);
506        assert_eq!(got_fb, &full[..len as usize]);
507    }
508
509    #[test]
510    fn fallback_shrunk_source_not_padded() {
511        // locks the primary/fallback reconciliation: when `len` exceeds the
512        // source size, the fallback must size dst to the actual source end (not
513        // pad a spurious trailing hole up to `len`) and return the actual bytes.
514        let tmp = tempfile::tempdir().unwrap();
515        let contents: Vec<u8> = (0u32..3000).map(|i| (i % 240) as u8).collect();
516        let s = contents.len() as u64;
517        let mut src = make_file(tmp.path(), "src");
518        src.write_all(&contents).unwrap();
519        src.sync_all().unwrap();
520        let dst = make_file(tmp.path(), "dst");
521        let copied = copy_sparse_fallback(&src, &dst, 0, s + 8192).unwrap();
522        assert_eq!(
523            copied, s,
524            "must return actual source bytes, not the claimed len"
525        );
526        let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
527        assert_eq!(
528            dst_meta.len(),
529            s,
530            "dst must be sized to the source, not padded"
531        );
532        let got = std::fs::read(tmp.path().join("dst")).unwrap();
533        assert_eq!(got, contents);
534    }
535
536    #[test]
537    fn copies_zero_length() {
538        let tmp = tempfile::tempdir().unwrap();
539        let mut src = make_file(tmp.path(), "src");
540        src.write_all(b"non-empty source contents").unwrap();
541        src.sync_all().unwrap();
542        let dst = make_file(tmp.path(), "dst");
543        let copied = copy_file_range_all(&src, &dst, 0).unwrap();
544        assert_eq!(copied, 0);
545        let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
546        assert_eq!(dst_meta.len(), 0, "zero-length copy must leave dst empty");
547    }
548
549    #[test]
550    fn fallback_all_hole_source() {
551        // a pure-hole source: SEEK_DATA returns ENXIO immediately, so the loop
552        // does no copies and only ftruncate sizes dst. dst must be all zeros,
553        // sized to `len`, and sparse.
554        let tmp = tempfile::tempdir().unwrap();
555        let logical: u64 = 4 * 1024 * 1024;
556        let src = make_file(tmp.path(), "src");
557        nix::unistd::ftruncate(src.as_fd(), to_off_t(logical).unwrap()).unwrap();
558        src.sync_all().unwrap();
559        let dst = make_file(tmp.path(), "dst");
560        let copied = copy_sparse_fallback(&src, &dst, 0, logical).unwrap();
561        assert_eq!(copied, logical);
562        let got = std::fs::read(tmp.path().join("dst")).unwrap();
563        assert_eq!(got.len() as u64, logical);
564        assert!(
565            got.iter().all(|&b| b == 0),
566            "all-hole copy must be all zeros"
567        );
568        let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
569        assert_eq!(dst_meta.len(), logical);
570        let dst_allocated = dst_meta.blocks() * 512;
571        eprintln!(
572            "all-hole: dst={} blocks ({dst_allocated} bytes), logical={logical}",
573            dst_meta.blocks()
574        );
575        assert!(
576            dst_allocated < logical,
577            "all-hole destination is not sparse: allocated {dst_allocated} >= logical {logical}"
578        );
579    }
580
581    #[test]
582    fn classify_seek_data_routes_by_errno() {
583        // the testable seam for the sparse-vs-dense decision. data offset and a
584        // legitimate trailing hole stay on the sparse path; the "unsupported"
585        // errnos route to dense; a genuine I/O error propagates.
586        assert_eq!(
587            classify_seek_data(Ok(4096)).unwrap(),
588            SparseProbe::Data(4096)
589        );
590        assert_eq!(
591            classify_seek_data(Err(nix::errno::Errno::ENXIO)).unwrap(),
592            SparseProbe::TrailingHole
593        );
594        // EINVAL and EOPNOTSUPP (== ENOTSUP on Linux) mean "fs can't SEEK_DATA".
595        assert_eq!(
596            classify_seek_data(Err(nix::errno::Errno::EINVAL)).unwrap(),
597            SparseProbe::Unsupported
598        );
599        assert_eq!(
600            classify_seek_data(Err(nix::errno::Errno::EOPNOTSUPP)).unwrap(),
601            SparseProbe::Unsupported
602        );
603        assert_eq!(
604            classify_seek_data(Err(nix::errno::Errno::ENOTSUP)).unwrap(),
605            SparseProbe::Unsupported
606        );
607        // a genuine I/O error is NOT masked as "unsupported" — it propagates.
608        let err = classify_seek_data(Err(nix::errno::Errno::EIO)).unwrap_err();
609        assert_eq!(err.raw_os_error(), Some(libc::EIO));
610    }
611
612    #[test]
613    fn dense_copy_is_byte_exact_with_embedded_zeros() {
614        // dense_copy must reproduce the source byte-for-byte, including embedded
615        // zero regions (it does not preserve them as holes, just copies zeros).
616        let tmp = tempfile::tempdir().unwrap();
617        let len: usize = 3 * DENSE_BUF_SIZE + 777; // multiple buffers + a tail.
618        let mut data: Vec<u8> = (0..len)
619            .map(|i| (i.wrapping_mul(37) ^ (i >> 5)) as u8)
620            .collect();
621        // carve out a couple of embedded zero regions (crossing a buffer edge).
622        for b in data.iter_mut().take(DENSE_BUF_SIZE + 4096).skip(100) {
623            *b = 0;
624        }
625        for b in data
626            .iter_mut()
627            .take(2 * DENSE_BUF_SIZE)
628            .skip(2 * DENSE_BUF_SIZE - 500)
629        {
630            *b = 0;
631        }
632        let mut src = make_file(tmp.path(), "src");
633        src.write_all(&data).unwrap();
634        src.sync_all().unwrap();
635        let dst = make_file(tmp.path(), "dst");
636        let copied = dense_copy(&src, &dst, 0, len as u64).unwrap();
637        assert_eq!(copied, len as u64);
638        let got = std::fs::read(tmp.path().join("dst")).unwrap();
639        assert_eq!(got.len(), len, "dense copy must size dst to the source");
640        assert!(got == data, "dense copy bytes differ from source");
641    }
642
643    #[test]
644    fn dense_copy_partial_len_sizes_dst_exactly() {
645        // when `len` is below the source size, dense_copy copies exactly `len`
646        // bytes and ftruncates dst to `len` (matching the sparse path).
647        let tmp = tempfile::tempdir().unwrap();
648        let full: Vec<u8> = (0u32..8192).map(|i| (i % 251) as u8).collect();
649        let len: u64 = 5000;
650        let mut src = make_file(tmp.path(), "src");
651        src.write_all(&full).unwrap();
652        src.sync_all().unwrap();
653        let dst = make_file(tmp.path(), "dst");
654        let copied = dense_copy(&src, &dst, 0, len).unwrap();
655        assert_eq!(copied, len);
656        let got = std::fs::read(tmp.path().join("dst")).unwrap();
657        assert_eq!(got.len() as u64, len);
658        assert_eq!(got, &full[..len as usize]);
659    }
660
661    #[test]
662    fn dense_copy_shrunk_source_not_padded() {
663        // mirrors `fallback_shrunk_source_not_padded` for the dense path: a `len`
664        // larger than the source must size dst to the actual source end and
665        // return the actual bytes, not pad up to `len`.
666        let tmp = tempfile::tempdir().unwrap();
667        let contents: Vec<u8> = (0u32..3000).map(|i| (i % 240) as u8).collect();
668        let s = contents.len() as u64;
669        let mut src = make_file(tmp.path(), "src");
670        src.write_all(&contents).unwrap();
671        src.sync_all().unwrap();
672        let dst = make_file(tmp.path(), "dst");
673        let copied = dense_copy(&src, &dst, 0, s + 8192).unwrap();
674        assert_eq!(
675            copied, s,
676            "dense copy must return actual source bytes, not the claimed len"
677        );
678        let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
679        assert_eq!(dst_meta.len(), s, "dst must be sized to the source");
680        let got = std::fs::read(tmp.path().join("dst")).unwrap();
681        assert_eq!(got, contents);
682    }
683
684    #[test]
685    fn unsupported_probe_routes_to_dense_byte_exact() {
686        // exercises the fallback selection end-to-end at the seam level: an
687        // "unsupported" SEEK_DATA probe must route to `dense_copy`, which on this
688        // (tmpfs) environment we drive directly because tmpfs supports SEEK_DATA
689        // and a true EINVAL can't be simulated here (see note below). We assert
690        // both halves of the routing contract: (1) the classifier maps the
691        // unsupported errnos to `SparseProbe::Unsupported`, and (2) the
692        // `Unsupported` branch's target (`dense_copy`) yields a byte-exact copy.
693        //
694        // NOTE: a genuine end-to-end SEEK_DATA EINVAL is not simulable in this
695        // environment (tmpfs/ext4 both support SEEK_DATA/SEEK_HOLE); it would
696        // require a FUSE/older filesystem that rejects those whences. The routing
697        // is therefore validated via the testable seam rather than a forced
698        // errno, with the byte-exactness of the dense target asserted directly.
699        assert_eq!(
700            classify_seek_data(Err(nix::errno::Errno::EINVAL)).unwrap(),
701            SparseProbe::Unsupported,
702            "unsupported probe must route to the dense fallback"
703        );
704        let tmp = tempfile::tempdir().unwrap();
705        let data: Vec<u8> = (0u32..200_000).map(|i| (i % 256) as u8).collect();
706        let mut src = make_file(tmp.path(), "src");
707        src.write_all(&data).unwrap();
708        src.sync_all().unwrap();
709        let dst = make_file(tmp.path(), "dst");
710        let copied = dense_copy(&src, &dst, 0, data.len() as u64).unwrap();
711        assert_eq!(copied, data.len() as u64);
712        let got = std::fs::read(tmp.path().join("dst")).unwrap();
713        assert_eq!(got, data, "dense fallback target must be byte-exact");
714    }
715}