Skip to main content

microsandbox_utils/
copy.rs

1//! Sparse-aware fast copy with reflink fallback.
2//!
3//! Two-tier strategy that preserves sparseness on every supported
4//! platform:
5//!
6//! 1. **Reflink** (zero-copy COW). Tries `clonefile(2)` on macOS and
7//!    `ioctl(FICLONE)` on Linux via `reflink-copy`. Succeeds instantly
8//!    on APFS, btrfs, XFS (with `reflink=1`), and bcachefs. Returns
9//!    `EOPNOTSUPP` (or similar) on ext4 and other non-COW filesystems.
10//!
11//! 2. **Sparse-aware copy**. Walks the source's allocation map with
12//!    POSIX `SEEK_DATA` / `SEEK_HOLE` or Windows
13//!    `FSCTL_QUERY_ALLOCATED_RANGES`, then copies only allocated
14//!    extents. The destination is extended to the source size up
15//!    front so unallocated regions stay holes.
16//!
17//! Never falls back to a naive byte-for-byte copy — that would
18//! densify a 4 GiB sparse file with a few MB of data into 4 GiB on
19//! disk, which is the exact failure mode this module exists to
20//! prevent.
21//!
22//! See `planning/microsandbox/implementation/snapshots.md` for the
23//! full design and tradeoffs.
24
25use std::fs::{File, OpenOptions};
26use std::io;
27#[cfg(windows)]
28use std::io::{Read, Seek, SeekFrom, Write};
29#[cfg(unix)]
30use std::os::unix::io::{AsRawFd, RawFd};
31#[cfg(windows)]
32use std::os::windows::io::AsRawHandle;
33use std::path::Path;
34#[cfg(windows)]
35use std::ptr;
36
37#[cfg(windows)]
38use crate::extent::{ExtentMap, mark_sparse};
39#[cfg(windows)]
40use windows_sys::Win32::Foundation::HANDLE;
41#[cfg(windows)]
42use windows_sys::Win32::Storage::FileSystem::GetVolumeInformationByHandleW;
43#[cfg(windows)]
44use windows_sys::Win32::System::IO::DeviceIoControl;
45#[cfg(windows)]
46use windows_sys::Win32::System::Ioctl::{
47    DUPLICATE_EXTENTS_DATA, FSCTL_DUPLICATE_EXTENTS_TO_FILE, FSCTL_GET_INTEGRITY_INFORMATION,
48    FSCTL_GET_INTEGRITY_INFORMATION_BUFFER, FSCTL_SET_INTEGRITY_INFORMATION,
49    FSCTL_SET_INTEGRITY_INFORMATION_BUFFER,
50};
51#[cfg(windows)]
52use windows_sys::Win32::System::SystemServices::FILE_SUPPORTS_BLOCK_REFCOUNTING;
53
54//--------------------------------------------------------------------------------------------------
55// Constants
56//--------------------------------------------------------------------------------------------------
57
58/// ReFS supports 4 KiB and 64 KiB clusters. Aligning to the larger unit is valid on both.
59#[cfg(windows)]
60const WINDOWS_CLONE_ALIGNMENT: u64 = 64 * 1024;
61
62/// Windows requires each duplicate-extents request to be strictly smaller than 4 GiB.
63#[cfg(windows)]
64const WINDOWS_MAX_CLONE_CHUNK: u64 =
65    (u32::MAX as u64 / WINDOWS_CLONE_ALIGNMENT) * WINDOWS_CLONE_ALIGNMENT;
66
67//--------------------------------------------------------------------------------------------------
68// Types
69//--------------------------------------------------------------------------------------------------
70
71/// Strategy that successfully created a destination in [`fast_copy_with_strategy`].
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum FastCopyStrategy {
74    /// The destination shares source extents through filesystem copy-on-write.
75    Reflink,
76    /// The destination is an independent sparse-aware copy.
77    SparseCopy,
78}
79
80/// Windows strategy used to materialize the sparse destination's data.
81#[cfg(windows)]
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
83enum WindowsSparseCopyStrategy {
84    /// Copy the filesystem-allocated ranges reported by `FSCTL_QUERY_ALLOCATED_RANGES`.
85    AllocatedRanges,
86    /// Preserve holes by finding non-zero byte runs when allocation metadata is unavailable.
87    NonzeroRuns,
88}
89
90//--------------------------------------------------------------------------------------------------
91// Functions
92//--------------------------------------------------------------------------------------------------
93
94/// Copy `src` to `dst`, preserving sparseness. Returns the apparent
95/// size of the destination in bytes.
96///
97/// Tries reflink first (zero-copy COW); on filesystems without reflink
98/// support, walks the source's allocation map and copies only its
99/// data extents into a `ftruncate`-established sparse destination.
100///
101/// **Blocking.** Callers in async contexts should wrap in
102/// `tokio::task::spawn_blocking`.
103pub fn fast_copy(src: &Path, dst: &Path) -> io::Result<u64> {
104    fast_copy_with_strategy(src, dst).map(|(len, _)| len)
105}
106
107/// Copy using the fastest safe strategy and report which strategy resolved.
108pub fn fast_copy_with_strategy(src: &Path, dst: &Path) -> io::Result<(u64, FastCopyStrategy)> {
109    // Stat the source up front. This makes the missing-source error
110    // kind platform-consistent (`NotFound` everywhere); without it,
111    // reflink-copy on Linux surfaces `InvalidInput` with no errno
112    // for a non-existent path, which our `is_reflink_unsupported`
113    // check can't recognize as a fall-through.
114    let src_len = std::fs::metadata(src)?.len();
115
116    // Tier 1: reflink. Errors on unsupported FSes; we fall through to
117    // Tier 2. We do NOT use `reflink_or_copy`, which densifies on
118    // fallback via `std::fs::copy`.
119    match reflink_impl(src, dst) {
120        Ok(()) => return Ok((src_len, FastCopyStrategy::Reflink)),
121        Err(e) if is_reflink_unsupported(&e) => {
122            // fall through to sparse copy
123        }
124        Err(e) => return Err(e),
125    }
126
127    sparse_copy(src, dst).map(|len| (len, FastCopyStrategy::SparseCopy))
128}
129
130/// Require a filesystem copy-on-write clone with no fallback.
131pub fn reflink(src: &Path, dst: &Path) -> io::Result<u64> {
132    let src_len = std::fs::metadata(src)?.len();
133    reflink_impl(src, dst)?;
134    Ok(src_len)
135}
136
137/// Sparse-aware copy via platform allocation metadata and per-extent copy.
138///
139/// Public for callers that want to skip the reflink attempt — e.g.
140/// when they already know the destination filesystem doesn't support
141/// reflinks, or for tests that want to exercise the fallback path.
142pub fn sparse_copy(src: &Path, dst: &Path) -> io::Result<u64> {
143    sparse_copy_impl(src, dst)
144}
145
146#[cfg(unix)]
147fn sparse_copy_impl(src: &Path, dst: &Path) -> io::Result<u64> {
148    let src_file = File::open(src)?;
149    let len = src_file.metadata()?.len();
150
151    let dst_file = OpenOptions::new()
152        .read(true)
153        .write(true)
154        .create(true)
155        .truncate(true)
156        .open(dst)?;
157    // Establish destination as a fully-sparse hole of `len` bytes;
158    // only data extents will materialize into allocated blocks below.
159    dst_file.set_len(len)?;
160
161    let src_fd = src_file.as_raw_fd();
162    let dst_fd = dst_file.as_raw_fd();
163
164    let mut off: i64 = 0;
165    while (off as u64) < len {
166        // Find next data extent.
167        let data_start = unsafe { libc::lseek(src_fd, off, libc::SEEK_DATA) };
168        if data_start < 0 {
169            let err = io::Error::last_os_error();
170            // ENXIO: no more data past this offset → done.
171            if err.raw_os_error() == Some(libc::ENXIO) {
172                break;
173            }
174            return Err(err);
175        }
176        // Find the end of that extent (start of next hole, or EOF).
177        let data_end = unsafe { libc::lseek(src_fd, data_start, libc::SEEK_HOLE) };
178        if data_end < 0 {
179            return Err(io::Error::last_os_error());
180        }
181        let data_end = (data_end as u64).min(len);
182        let data_start = data_start as u64;
183        if data_end <= data_start {
184            break;
185        }
186
187        copy_extent(src_fd, dst_fd, data_start, data_end - data_start)?;
188        off = data_end as i64;
189    }
190
191    dst_file.sync_all()?;
192    Ok(len)
193}
194
195/// Use the platform's native copy-on-write file-clone primitive.
196#[cfg(unix)]
197fn reflink_impl(src: &Path, dst: &Path) -> io::Result<()> {
198    reflink_copy::reflink(src, dst)
199}
200
201/// Clone a file on a Windows volume that explicitly supports block refcounting.
202#[cfg(windows)]
203fn reflink_impl(src: &Path, dst: &Path) -> io::Result<()> {
204    let mut src_file = File::open(src)?;
205    let mut dst_file = OpenOptions::new()
206        .read(true)
207        .write(true)
208        .create_new(true)
209        .open(dst)?;
210
211    let result = reflink_windows_files(&mut src_file, &mut dst_file);
212    drop(dst_file);
213    drop(src_file);
214    if result.is_err() {
215        let _ = std::fs::remove_file(dst);
216    }
217    result
218}
219
220#[cfg(not(any(unix, windows)))]
221fn reflink_impl(_src: &Path, _dst: &Path) -> io::Result<()> {
222    Err(io::Error::new(
223        io::ErrorKind::Unsupported,
224        "filesystem reflinks are unsupported on this platform",
225    ))
226}
227
228#[cfg(windows)]
229fn sparse_copy_impl(src: &Path, dst: &Path) -> io::Result<u64> {
230    const BUF_SIZE: usize = 1024 * 1024;
231
232    let mut src_file = File::open(src)?;
233    let len = src_file.metadata()?.len();
234
235    let mut dst_file = OpenOptions::new()
236        .read(true)
237        .write(true)
238        .create(true)
239        .truncate(true)
240        .open(dst)?;
241    dst_file.set_len(len)?;
242    mark_sparse(&dst_file)?;
243
244    copy_windows_sparse_data(&mut src_file, &mut dst_file, BUF_SIZE)?;
245
246    dst_file.sync_all()?;
247    Ok(len)
248}
249
250//--------------------------------------------------------------------------------------------------
251// Functions: Helpers
252//--------------------------------------------------------------------------------------------------
253
254#[cfg(windows)]
255fn reflink_windows_files(src: &mut File, dst: &mut File) -> io::Result<()> {
256    let src_volume = windows_volume_identity(src)?;
257    let dst_volume = windows_volume_identity(dst)?;
258    if src_volume.0 != dst_volume.0 {
259        return Err(io::Error::new(
260            io::ErrorKind::Unsupported,
261            "Windows block cloning requires source and destination on the same volume",
262        ));
263    }
264    if src_volume.1 & FILE_SUPPORTS_BLOCK_REFCOUNTING == 0
265        || dst_volume.1 & FILE_SUPPORTS_BLOCK_REFCOUNTING == 0
266    {
267        return Err(io::Error::new(
268            io::ErrorKind::Unsupported,
269            "destination volume does not advertise block-refcounting support",
270        ));
271    }
272
273    mark_sparse(dst)?;
274    match_windows_integrity(src, dst)?;
275
276    let len = src.metadata()?.len();
277    dst.set_len(len)?;
278    let clone_len = len / WINDOWS_CLONE_ALIGNMENT * WINDOWS_CLONE_ALIGNMENT;
279    let mut offset = 0u64;
280    while offset < clone_len {
281        let chunk = (clone_len - offset).min(WINDOWS_MAX_CLONE_CHUNK);
282        duplicate_windows_extents(src, dst, offset, chunk)?;
283        offset += chunk;
284    }
285    if clone_len < len {
286        copy_windows_tail(src, dst, clone_len, len - clone_len)?;
287    }
288    Ok(())
289}
290
291#[cfg(windows)]
292fn windows_volume_identity(file: &File) -> io::Result<(u32, u32)> {
293    let mut serial = 0u32;
294    let mut flags = 0u32;
295    let ok = unsafe {
296        GetVolumeInformationByHandleW(
297            file.as_raw_handle() as HANDLE,
298            ptr::null_mut(),
299            0,
300            &mut serial,
301            ptr::null_mut(),
302            &mut flags,
303            ptr::null_mut(),
304            0,
305        )
306    };
307    if ok == 0 {
308        return Err(io::Error::last_os_error());
309    }
310    Ok((serial, flags))
311}
312
313#[cfg(windows)]
314fn match_windows_integrity(src: &File, dst: &File) -> io::Result<()> {
315    let Some(src_info) = get_windows_integrity(src)? else {
316        return Ok(());
317    };
318    let Some(dst_info) = get_windows_integrity(dst)? else {
319        return Ok(());
320    };
321    if src_info.ChecksumAlgorithm == dst_info.ChecksumAlgorithm && src_info.Flags == dst_info.Flags
322    {
323        return Ok(());
324    }
325
326    let info = FSCTL_SET_INTEGRITY_INFORMATION_BUFFER {
327        ChecksumAlgorithm: src_info.ChecksumAlgorithm,
328        Reserved: 0,
329        Flags: src_info.Flags,
330    };
331    let mut returned = 0u32;
332    let ok = unsafe {
333        DeviceIoControl(
334            dst.as_raw_handle() as HANDLE,
335            FSCTL_SET_INTEGRITY_INFORMATION,
336            &info as *const _ as *const _,
337            size_of::<FSCTL_SET_INTEGRITY_INFORMATION_BUFFER>() as u32,
338            ptr::null_mut(),
339            0,
340            &mut returned,
341            ptr::null_mut(),
342        )
343    };
344    if ok == 0 {
345        return Err(io::Error::last_os_error());
346    }
347    Ok(())
348}
349
350#[cfg(windows)]
351fn get_windows_integrity(
352    file: &File,
353) -> io::Result<Option<FSCTL_GET_INTEGRITY_INFORMATION_BUFFER>> {
354    let mut info = FSCTL_GET_INTEGRITY_INFORMATION_BUFFER::default();
355    let mut returned = 0u32;
356    let ok = unsafe {
357        DeviceIoControl(
358            file.as_raw_handle() as HANDLE,
359            FSCTL_GET_INTEGRITY_INFORMATION,
360            ptr::null(),
361            0,
362            &mut info as *mut _ as *mut _,
363            size_of::<FSCTL_GET_INTEGRITY_INFORMATION_BUFFER>() as u32,
364            &mut returned,
365            ptr::null_mut(),
366        )
367    };
368    if ok != 0 {
369        return Ok(Some(info));
370    }
371    let error = io::Error::last_os_error();
372    if is_reflink_unsupported(&error) {
373        Ok(None)
374    } else {
375        Err(error)
376    }
377}
378
379#[cfg(windows)]
380fn duplicate_windows_extents(src: &File, dst: &File, offset: u64, len: u64) -> io::Result<()> {
381    let request = DUPLICATE_EXTENTS_DATA {
382        FileHandle: src.as_raw_handle() as HANDLE,
383        SourceFileOffset: offset as i64,
384        TargetFileOffset: offset as i64,
385        ByteCount: len as i64,
386    };
387    let mut returned = 0u32;
388    let ok = unsafe {
389        DeviceIoControl(
390            dst.as_raw_handle() as HANDLE,
391            FSCTL_DUPLICATE_EXTENTS_TO_FILE,
392            &request as *const _ as *const _,
393            size_of::<DUPLICATE_EXTENTS_DATA>() as u32,
394            ptr::null_mut(),
395            0,
396            &mut returned,
397            ptr::null_mut(),
398        )
399    };
400    if ok == 0 {
401        return Err(io::Error::last_os_error());
402    }
403    Ok(())
404}
405
406#[cfg(windows)]
407fn copy_windows_tail(src: &mut File, dst: &mut File, offset: u64, len: u64) -> io::Result<()> {
408    src.seek(SeekFrom::Start(offset))?;
409    dst.seek(SeekFrom::Start(offset))?;
410    let copied = io::copy(&mut src.take(len), dst)?;
411    if copied != len {
412        return Err(io::Error::new(
413            io::ErrorKind::UnexpectedEof,
414            format!("Windows reflink tail copied {copied} of {len} bytes"),
415        ));
416    }
417    Ok(())
418}
419
420#[cfg(windows)]
421fn copy_windows_range(
422    src: &mut File,
423    dst: &mut File,
424    offset: u64,
425    len: u64,
426    buf: &mut [u8],
427) -> io::Result<()> {
428    src.seek(SeekFrom::Start(offset))?;
429    dst.seek(SeekFrom::Start(offset))?;
430
431    let mut remaining = len;
432    while remaining != 0 {
433        let chunk_len = remaining.min(buf.len() as u64) as usize;
434        src.read_exact(&mut buf[..chunk_len])?;
435        dst.write_all(&buf[..chunk_len])?;
436        remaining -= chunk_len as u64;
437    }
438    Ok(())
439}
440
441#[cfg(windows)]
442fn copy_windows_sparse_data(
443    src: &mut File,
444    dst: &mut File,
445    buf_size: usize,
446) -> io::Result<WindowsSparseCopyStrategy> {
447    if let Some(map) = ExtentMap::scan_file(src)? {
448        // NTFS can enumerate the ranges that actually occupy filesystem blocks. Copy those ranges
449        // wholesale: inspecting zero/non-zero byte runs inside an allocated extent turns raw disk
450        // images into millions of tiny seeks and writes.
451        let mut buf = vec![0u8; buf_size];
452        for (offset, extent_len) in map.extents {
453            copy_windows_range(src, dst, offset, extent_len, &mut buf)?;
454        }
455        Ok(WindowsSparseCopyStrategy::AllocatedRanges)
456    } else {
457        // Filesystems without FSCTL_QUERY_ALLOCATED_RANGES cannot expose their allocation map.
458        // Preserve sparseness there with the slower byte-run fallback instead of densifying the
459        // destination with a naive full-file copy.
460        copy_windows_nonzero_runs(src, dst, buf_size)?;
461        Ok(WindowsSparseCopyStrategy::NonzeroRuns)
462    }
463}
464
465#[cfg(windows)]
466fn copy_windows_nonzero_runs(src: &mut File, dst: &mut File, buf_size: usize) -> io::Result<()> {
467    src.seek(SeekFrom::Start(0))?;
468    let mut offset = 0u64;
469    let mut buf = vec![0u8; buf_size];
470    loop {
471        let n = src.read(&mut buf)?;
472        if n == 0 {
473            break;
474        }
475
476        write_nonzero_runs(dst, offset, &buf[..n])?;
477        offset += n as u64;
478    }
479    Ok(())
480}
481
482/// Reflink can fail with several different errnos depending on the
483/// filesystem and platform. Treat them all as "fall through to Tier 2"
484/// rather than propagating to the caller.
485///
486/// On Linux `ENOTSUP == EOPNOTSUPP`, so a single arm covers both;
487/// macOS / BSDs assign them distinct values and need both arms.
488fn is_reflink_unsupported(e: &io::Error) -> bool {
489    if matches!(e.kind(), io::ErrorKind::Unsupported) {
490        return true;
491    }
492
493    let Some(code) = e.raw_os_error() else {
494        return false;
495    };
496
497    #[cfg(target_os = "linux")]
498    let aliases: &[i32] = &[libc::ENOTSUP, libc::EXDEV, libc::EINVAL];
499    #[cfg(all(unix, not(target_os = "linux")))]
500    let aliases: &[i32] = &[libc::ENOTSUP, libc::EOPNOTSUPP, libc::EXDEV, libc::EINVAL];
501    #[cfg(windows)]
502    let aliases: &[i32] = &[
503        1,   // ERROR_INVALID_FUNCTION
504        17,  // ERROR_NOT_SAME_DEVICE
505        50,  // ERROR_NOT_SUPPORTED
506        87,  // ERROR_INVALID_PARAMETER
507        124, // ERROR_INVALID_LEVEL
508        775, // ERROR_NOT_CAPABLE
509    ];
510
511    #[cfg(windows)]
512    {
513        let win32_code = (code as u32 & 0xffff) as i32;
514        aliases.contains(&code) || aliases.contains(&win32_code)
515    }
516
517    #[cfg(unix)]
518    aliases.contains(&code)
519}
520
521#[cfg(unix)]
522fn copy_extent(src_fd: RawFd, dst_fd: RawFd, off: u64, len: u64) -> io::Result<()> {
523    // Explicit copy must never ask the filesystem to satisfy the transfer with shared COW extents.
524    read_write_extent(src_fd, dst_fd, off, len)
525}
526
527/// Copy `len` bytes from `src_fd` at `off` to `dst_fd` at `off` with
528/// `pread`/`pwrite`.
529///
530/// This is the explicit-copy backend for `copy_extent`; avoiding clone and
531/// `copy_file_range` operations prevents the destination from sharing COW
532/// extents with the source.
533#[cfg(unix)]
534fn read_write_extent(src_fd: RawFd, dst_fd: RawFd, off: u64, len: u64) -> io::Result<()> {
535    const BUF_SIZE: usize = 1024 * 1024;
536    let mut buf = vec![0u8; BUF_SIZE];
537    let mut copied: u64 = 0;
538
539    while copied < len {
540        let to_read = (len - copied).min(BUF_SIZE as u64) as usize;
541        let read_off = (off + copied) as i64;
542        let n = unsafe {
543            libc::pread(
544                src_fd,
545                buf.as_mut_ptr() as *mut libc::c_void,
546                to_read,
547                read_off,
548            )
549        };
550        if n < 0 {
551            return Err(io::Error::last_os_error());
552        }
553        if n == 0 {
554            return Err(io::Error::new(
555                io::ErrorKind::UnexpectedEof,
556                "unexpected EOF mid-extent",
557            ));
558        }
559        let n = n as usize;
560
561        let mut written: usize = 0;
562        while written < n {
563            let w_off = (off + copied + written as u64) as i64;
564            let w = unsafe {
565                libc::pwrite(
566                    dst_fd,
567                    buf[written..n].as_ptr() as *const libc::c_void,
568                    n - written,
569                    w_off,
570                )
571            };
572            if w < 0 {
573                return Err(io::Error::last_os_error());
574            }
575            if w == 0 {
576                return Err(io::Error::new(
577                    io::ErrorKind::WriteZero,
578                    "pwrite returned 0",
579                ));
580            }
581            written += w as usize;
582        }
583        copied += n as u64;
584    }
585    Ok(())
586}
587
588#[cfg(windows)]
589fn write_nonzero_runs(dst: &mut File, base_offset: u64, bytes: &[u8]) -> io::Result<()> {
590    let mut cursor = 0;
591    while cursor < bytes.len() {
592        while cursor < bytes.len() && bytes[cursor] == 0 {
593            cursor += 1;
594        }
595        if cursor == bytes.len() {
596            break;
597        }
598
599        let start = cursor;
600        while cursor < bytes.len() && bytes[cursor] != 0 {
601            cursor += 1;
602        }
603
604        dst.seek(SeekFrom::Start(base_offset + start as u64))?;
605        dst.write_all(&bytes[start..cursor])?;
606    }
607
608    Ok(())
609}
610
611//--------------------------------------------------------------------------------------------------
612// Tests
613//--------------------------------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use std::io::{Read, Seek, SeekFrom, Write};
619    #[cfg(unix)]
620    use std::os::unix::fs::MetadataExt;
621
622    /// Build a sparse source file: total apparent size `len`, with
623    /// 64 KiB of data written at each of the given offsets.
624    fn make_sparse(path: &Path, len: u64, data_offsets: &[u64]) -> io::Result<()> {
625        let mut f = OpenOptions::new()
626            .read(true)
627            .write(true)
628            .create(true)
629            .truncate(true)
630            .open(path)?;
631        #[cfg(windows)]
632        mark_sparse(&f)?;
633        f.set_len(len)?;
634        for &off in data_offsets {
635            let buf = vec![0xAB_u8; 64 * 1024];
636            f.seek(SeekFrom::Start(off))?;
637            f.write_all(&buf)?;
638        }
639        f.sync_all()?;
640        Ok(())
641    }
642
643    #[test]
644    fn round_trip_small() {
645        let dir = tempfile::tempdir().unwrap();
646        let src = dir.path().join("src.bin");
647        let dst = dir.path().join("dst.bin");
648
649        std::fs::write(&src, b"hello world").unwrap();
650        let n = fast_copy(&src, &dst).unwrap();
651        assert_eq!(n, 11);
652        assert_eq!(std::fs::read(&dst).unwrap(), b"hello world");
653    }
654
655    #[test]
656    fn sparse_copy_preserves_holes_and_data() {
657        // 16 MiB sparse file with 4 data extents at known offsets.
658        // Use sparse_copy directly to exercise Tier 2 regardless of
659        // the test-host filesystem.
660        let dir = tempfile::tempdir().unwrap();
661        let src = dir.path().join("src.bin");
662        let dst = dir.path().join("dst.bin");
663
664        let len: u64 = 16 * 1024 * 1024;
665        let offsets = [0u64, 4 * 1024 * 1024, 8 * 1024 * 1024, 12 * 1024 * 1024];
666        make_sparse(&src, len, &offsets).unwrap();
667
668        let n = sparse_copy(&src, &dst).unwrap();
669        assert_eq!(n, len);
670
671        // Apparent size matches.
672        let dst_meta = std::fs::metadata(&dst).unwrap();
673        assert_eq!(dst_meta.len(), len);
674
675        // Each data extent's bytes round-trip.
676        let mut buf = [0u8; 64 * 1024];
677        let mut dst_file = File::open(&dst).unwrap();
678        for &off in &offsets {
679            dst_file.seek(SeekFrom::Start(off)).unwrap();
680            dst_file.read_exact(&mut buf).unwrap();
681            assert!(buf.iter().all(|&b| b == 0xAB));
682        }
683
684        // Sparseness preservation: only meaningful if the source
685        // itself is sparse on this filesystem. Some test hosts (FAT,
686        // certain APFS configurations under tempfile mounts) don't
687        // produce a sparse source from `ftruncate + pwrite` — in that
688        // case sparseness is unachievable and we just confirm the
689        // destination didn't blow up beyond the source's footprint.
690        #[cfg(unix)]
691        {
692            let src_bytes_on_disk = std::fs::metadata(&src).unwrap().blocks() * 512;
693            let dst_bytes_on_disk = dst_meta.blocks() * 512;
694            if src_bytes_on_disk < len / 2 {
695                // Source IS sparse. Destination must also be sparse —
696                // this is the load-bearing regression test for the whole
697                // module.
698                assert!(
699                    dst_bytes_on_disk < len / 2,
700                    "source is sparse ({src_bytes_on_disk} bytes on disk) but destination densified to {dst_bytes_on_disk} bytes for an apparent size of {len}",
701                );
702                assert!(
703                    dst_bytes_on_disk <= src_bytes_on_disk * 4 + 1024 * 1024,
704                    "destination allocated significantly more than source: src={src_bytes_on_disk} dst={dst_bytes_on_disk}",
705                );
706            } else {
707                eprintln!(
708                    "filesystem did not sparsify the source (src_bytes_on_disk={src_bytes_on_disk}, apparent={len}); sparseness preservation not exercised in this run",
709                );
710                // Without source sparseness we can't exceed source's
711                // footprint by much — guard against gross regressions.
712                assert!(
713                    dst_bytes_on_disk <= src_bytes_on_disk + 1024 * 1024,
714                    "destination grew beyond source footprint: src={src_bytes_on_disk} dst={dst_bytes_on_disk}",
715                );
716            }
717        }
718    }
719
720    #[cfg(windows)]
721    #[test]
722    fn windows_sparse_copy_uses_allocated_ranges_when_available() {
723        let dir = tempfile::tempdir().unwrap();
724        let src = dir.path().join("src.bin");
725        let dst = dir.path().join("dst.bin");
726        let len = 8 * 1024 * 1024;
727
728        make_sparse(&src, len, &[0, 4 * 1024 * 1024]).unwrap();
729        let mut src_file = File::open(&src).unwrap();
730        if ExtentMap::scan_file(&src_file).unwrap().is_none() {
731            eprintln!("filesystem cannot enumerate allocated ranges; strategy not exercised");
732            return;
733        }
734
735        let mut dst_file = OpenOptions::new()
736            .read(true)
737            .write(true)
738            .create(true)
739            .truncate(true)
740            .open(&dst)
741            .unwrap();
742        dst_file.set_len(len).unwrap();
743        mark_sparse(&dst_file).unwrap();
744
745        let strategy = copy_windows_sparse_data(&mut src_file, &mut dst_file, 1024 * 1024).unwrap();
746        assert_eq!(strategy, WindowsSparseCopyStrategy::AllocatedRanges);
747    }
748
749    #[test]
750    fn fast_copy_matches_source_size() {
751        let dir = tempfile::tempdir().unwrap();
752        let src = dir.path().join("src.bin");
753        let dst = dir.path().join("dst.bin");
754
755        let len: u64 = 4 * 1024 * 1024;
756        make_sparse(&src, len, &[0, 2 * 1024 * 1024]).unwrap();
757
758        let n = fast_copy(&src, &dst).unwrap();
759        assert_eq!(n, len);
760        assert_eq!(std::fs::metadata(&dst).unwrap().len(), len);
761    }
762
763    #[test]
764    fn missing_source_errors() {
765        let dir = tempfile::tempdir().unwrap();
766        let err = fast_copy(&dir.path().join("nope.bin"), &dir.path().join("dst.bin")).unwrap_err();
767        assert_eq!(err.kind(), io::ErrorKind::NotFound);
768    }
769}