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}