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**. POSIX `SEEK_DATA` / `SEEK_HOLE` walk of
12//!    the source's allocation map, with `copy_file_range(2)` on Linux
13//!    for in-kernel zero-copy of data extents. The destination is
14//!    `ftruncate`d to the source size up front so unallocated regions
15//!    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};
31use std::path::Path;
32
33#[cfg(windows)]
34use crate::extent::mark_sparse;
35
36//--------------------------------------------------------------------------------------------------
37// Functions
38//--------------------------------------------------------------------------------------------------
39
40/// Copy `src` to `dst`, preserving sparseness. Returns the apparent
41/// size of the destination in bytes.
42///
43/// Tries reflink first (zero-copy COW); on filesystems without reflink
44/// support, walks the source's allocation map and copies only its
45/// data extents into a `ftruncate`-established sparse destination.
46///
47/// **Blocking.** Callers in async contexts should wrap in
48/// `tokio::task::spawn_blocking`.
49pub fn fast_copy(src: &Path, dst: &Path) -> io::Result<u64> {
50    // Stat the source up front. This makes the missing-source error
51    // kind platform-consistent (`NotFound` everywhere); without it,
52    // reflink-copy on Linux surfaces `InvalidInput` with no errno
53    // for a non-existent path, which our `is_reflink_unsupported`
54    // check can't recognize as a fall-through.
55    let src_len = std::fs::metadata(src)?.len();
56
57    // Tier 1: reflink. Errors on unsupported FSes; we fall through to
58    // Tier 2. We do NOT use `reflink_or_copy`, which densifies on
59    // fallback via `std::fs::copy`.
60    match reflink_copy::reflink(src, dst) {
61        Ok(()) => return Ok(src_len),
62        Err(e) if is_reflink_unsupported(&e) => {
63            // fall through to sparse copy
64        }
65        Err(e) => return Err(e),
66    }
67
68    sparse_copy(src, dst)
69}
70
71/// Sparse-aware copy via `SEEK_DATA`/`SEEK_HOLE` and per-extent copy.
72///
73/// Public for callers that want to skip the reflink attempt — e.g.
74/// when they already know the destination filesystem doesn't support
75/// reflinks, or for tests that want to exercise the fallback path.
76pub fn sparse_copy(src: &Path, dst: &Path) -> io::Result<u64> {
77    sparse_copy_impl(src, dst)
78}
79
80#[cfg(unix)]
81fn sparse_copy_impl(src: &Path, dst: &Path) -> io::Result<u64> {
82    let src_file = File::open(src)?;
83    let len = src_file.metadata()?.len();
84
85    let dst_file = OpenOptions::new()
86        .read(true)
87        .write(true)
88        .create(true)
89        .truncate(true)
90        .open(dst)?;
91    // Establish destination as a fully-sparse hole of `len` bytes;
92    // only data extents will materialize into allocated blocks below.
93    dst_file.set_len(len)?;
94
95    let src_fd = src_file.as_raw_fd();
96    let dst_fd = dst_file.as_raw_fd();
97
98    let mut off: i64 = 0;
99    while (off as u64) < len {
100        // Find next data extent.
101        let data_start = unsafe { libc::lseek(src_fd, off, libc::SEEK_DATA) };
102        if data_start < 0 {
103            let err = io::Error::last_os_error();
104            // ENXIO: no more data past this offset → done.
105            if err.raw_os_error() == Some(libc::ENXIO) {
106                break;
107            }
108            return Err(err);
109        }
110        // Find the end of that extent (start of next hole, or EOF).
111        let data_end = unsafe { libc::lseek(src_fd, data_start, libc::SEEK_HOLE) };
112        if data_end < 0 {
113            return Err(io::Error::last_os_error());
114        }
115        let data_end = (data_end as u64).min(len);
116        let data_start = data_start as u64;
117        if data_end <= data_start {
118            break;
119        }
120
121        copy_extent(src_fd, dst_fd, data_start, data_end - data_start)?;
122        off = data_end as i64;
123    }
124
125    dst_file.sync_all()?;
126    Ok(len)
127}
128
129#[cfg(windows)]
130fn sparse_copy_impl(src: &Path, dst: &Path) -> io::Result<u64> {
131    const BUF_SIZE: usize = 1024 * 1024;
132
133    let mut src_file = File::open(src)?;
134    let len = src_file.metadata()?.len();
135
136    let mut dst_file = OpenOptions::new()
137        .read(true)
138        .write(true)
139        .create(true)
140        .truncate(true)
141        .open(dst)?;
142    dst_file.set_len(len)?;
143    mark_sparse(&dst_file)?;
144
145    let mut offset = 0u64;
146    let mut buf = vec![0u8; BUF_SIZE];
147    loop {
148        let n = src_file.read(&mut buf)?;
149        if n == 0 {
150            break;
151        }
152
153        write_nonzero_runs(&mut dst_file, offset, &buf[..n])?;
154        offset += n as u64;
155    }
156
157    dst_file.sync_all()?;
158    Ok(len)
159}
160
161//--------------------------------------------------------------------------------------------------
162// Functions: Helpers
163//--------------------------------------------------------------------------------------------------
164
165/// Reflink can fail with several different errnos depending on the
166/// filesystem and platform. Treat them all as "fall through to Tier 2"
167/// rather than propagating to the caller.
168///
169/// On Linux `ENOTSUP == EOPNOTSUPP`, so a single arm covers both;
170/// macOS / BSDs assign them distinct values and need both arms.
171fn is_reflink_unsupported(e: &io::Error) -> bool {
172    if matches!(e.kind(), io::ErrorKind::Unsupported) {
173        return true;
174    }
175
176    let Some(code) = e.raw_os_error() else {
177        return false;
178    };
179
180    #[cfg(target_os = "linux")]
181    let aliases: &[i32] = &[libc::ENOTSUP, libc::EXDEV, libc::EINVAL];
182    #[cfg(all(unix, not(target_os = "linux")))]
183    let aliases: &[i32] = &[libc::ENOTSUP, libc::EOPNOTSUPP, libc::EXDEV, libc::EINVAL];
184    #[cfg(windows)]
185    let aliases: &[i32] = &[
186        1,  // ERROR_INVALID_FUNCTION
187        17, // ERROR_NOT_SAME_DEVICE
188        50, // ERROR_NOT_SUPPORTED
189        87, // ERROR_INVALID_PARAMETER
190    ];
191
192    #[cfg(windows)]
193    {
194        let win32_code = (code as u32 & 0xffff) as i32;
195        aliases.contains(&code) || aliases.contains(&win32_code)
196    }
197
198    #[cfg(unix)]
199    aliases.contains(&code)
200}
201
202#[cfg(target_os = "linux")]
203fn copy_extent(src_fd: RawFd, dst_fd: RawFd, off: u64, len: u64) -> io::Result<()> {
204    let mut src_off = off as i64;
205    let mut dst_off = off as i64;
206    let mut remaining = len;
207
208    while remaining > 0 {
209        let chunk = remaining.min(usize::MAX as u64 / 2) as usize;
210        let n =
211            unsafe { libc::copy_file_range(src_fd, &mut src_off, dst_fd, &mut dst_off, chunk, 0) };
212        if n < 0 {
213            let err = io::Error::last_os_error();
214            // copy_file_range may not be supported on every kernel/FS
215            // combination (notably across-FS prior to 5.3, or older
216            // kernels). Fall back to pread/pwrite for the remainder of
217            // this extent.
218            if matches!(
219                err.raw_os_error(),
220                Some(libc::ENOSYS)
221                    | Some(libc::EXDEV)
222                    | Some(libc::EINVAL)
223                    | Some(libc::EOPNOTSUPP)
224            ) {
225                let consumed = len - remaining;
226                return read_write_extent(src_fd, dst_fd, off + consumed, remaining);
227            }
228            return Err(err);
229        }
230        if n == 0 {
231            // EOF — should not happen for a valid extent, but guard.
232            break;
233        }
234        remaining -= n as u64;
235    }
236    Ok(())
237}
238
239#[cfg(all(unix, not(target_os = "linux")))]
240fn copy_extent(src_fd: RawFd, dst_fd: RawFd, off: u64, len: u64) -> io::Result<()> {
241    read_write_extent(src_fd, dst_fd, off, len)
242}
243
244/// Copy `len` bytes from `src_fd` at `off` to `dst_fd` at `off` using
245/// `pread`/`pwrite`. Universal fallback for `copy_extent` on platforms
246/// or filesystems where `copy_file_range` doesn't apply.
247#[cfg(unix)]
248fn read_write_extent(src_fd: RawFd, dst_fd: RawFd, off: u64, len: u64) -> io::Result<()> {
249    const BUF_SIZE: usize = 64 * 1024;
250    let mut buf = [0u8; BUF_SIZE];
251    let mut copied: u64 = 0;
252
253    while copied < len {
254        let to_read = (len - copied).min(BUF_SIZE as u64) as usize;
255        let read_off = (off + copied) as i64;
256        let n = unsafe {
257            libc::pread(
258                src_fd,
259                buf.as_mut_ptr() as *mut libc::c_void,
260                to_read,
261                read_off,
262            )
263        };
264        if n < 0 {
265            return Err(io::Error::last_os_error());
266        }
267        if n == 0 {
268            return Err(io::Error::new(
269                io::ErrorKind::UnexpectedEof,
270                "unexpected EOF mid-extent",
271            ));
272        }
273        let n = n as usize;
274
275        let mut written: usize = 0;
276        while written < n {
277            let w_off = (off + copied + written as u64) as i64;
278            let w = unsafe {
279                libc::pwrite(
280                    dst_fd,
281                    buf[written..n].as_ptr() as *const libc::c_void,
282                    n - written,
283                    w_off,
284                )
285            };
286            if w < 0 {
287                return Err(io::Error::last_os_error());
288            }
289            if w == 0 {
290                return Err(io::Error::new(
291                    io::ErrorKind::WriteZero,
292                    "pwrite returned 0",
293                ));
294            }
295            written += w as usize;
296        }
297        copied += n as u64;
298    }
299    Ok(())
300}
301
302#[cfg(windows)]
303fn write_nonzero_runs(dst: &mut File, base_offset: u64, bytes: &[u8]) -> io::Result<()> {
304    let mut cursor = 0;
305    while cursor < bytes.len() {
306        while cursor < bytes.len() && bytes[cursor] == 0 {
307            cursor += 1;
308        }
309        if cursor == bytes.len() {
310            break;
311        }
312
313        let start = cursor;
314        while cursor < bytes.len() && bytes[cursor] != 0 {
315            cursor += 1;
316        }
317
318        dst.seek(SeekFrom::Start(base_offset + start as u64))?;
319        dst.write_all(&bytes[start..cursor])?;
320    }
321
322    Ok(())
323}
324
325//--------------------------------------------------------------------------------------------------
326// Tests
327//--------------------------------------------------------------------------------------------------
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332    use std::io::{Read, Seek, SeekFrom, Write};
333    #[cfg(unix)]
334    use std::os::unix::fs::MetadataExt;
335
336    /// Build a sparse source file: total apparent size `len`, with
337    /// 64 KiB of data written at each of the given offsets.
338    fn make_sparse(path: &Path, len: u64, data_offsets: &[u64]) -> io::Result<()> {
339        let mut f = OpenOptions::new()
340            .read(true)
341            .write(true)
342            .create(true)
343            .truncate(true)
344            .open(path)?;
345        f.set_len(len)?;
346        for &off in data_offsets {
347            let buf = vec![0xAB_u8; 64 * 1024];
348            f.seek(SeekFrom::Start(off))?;
349            f.write_all(&buf)?;
350        }
351        f.sync_all()?;
352        Ok(())
353    }
354
355    #[test]
356    fn round_trip_small() {
357        let dir = tempfile::tempdir().unwrap();
358        let src = dir.path().join("src.bin");
359        let dst = dir.path().join("dst.bin");
360
361        std::fs::write(&src, b"hello world").unwrap();
362        let n = fast_copy(&src, &dst).unwrap();
363        assert_eq!(n, 11);
364        assert_eq!(std::fs::read(&dst).unwrap(), b"hello world");
365    }
366
367    #[test]
368    fn sparse_copy_preserves_holes_and_data() {
369        // 16 MiB sparse file with 4 data extents at known offsets.
370        // Use sparse_copy directly to exercise Tier 2 regardless of
371        // the test-host filesystem.
372        let dir = tempfile::tempdir().unwrap();
373        let src = dir.path().join("src.bin");
374        let dst = dir.path().join("dst.bin");
375
376        let len: u64 = 16 * 1024 * 1024;
377        let offsets = [0u64, 4 * 1024 * 1024, 8 * 1024 * 1024, 12 * 1024 * 1024];
378        make_sparse(&src, len, &offsets).unwrap();
379
380        let n = sparse_copy(&src, &dst).unwrap();
381        assert_eq!(n, len);
382
383        // Apparent size matches.
384        let dst_meta = std::fs::metadata(&dst).unwrap();
385        assert_eq!(dst_meta.len(), len);
386
387        // Each data extent's bytes round-trip.
388        let mut buf = [0u8; 64 * 1024];
389        let mut dst_file = File::open(&dst).unwrap();
390        for &off in &offsets {
391            dst_file.seek(SeekFrom::Start(off)).unwrap();
392            dst_file.read_exact(&mut buf).unwrap();
393            assert!(buf.iter().all(|&b| b == 0xAB));
394        }
395
396        // Sparseness preservation: only meaningful if the source
397        // itself is sparse on this filesystem. Some test hosts (FAT,
398        // certain APFS configurations under tempfile mounts) don't
399        // produce a sparse source from `ftruncate + pwrite` — in that
400        // case sparseness is unachievable and we just confirm the
401        // destination didn't blow up beyond the source's footprint.
402        #[cfg(unix)]
403        {
404            let src_bytes_on_disk = std::fs::metadata(&src).unwrap().blocks() * 512;
405            let dst_bytes_on_disk = dst_meta.blocks() * 512;
406            if src_bytes_on_disk < len / 2 {
407                // Source IS sparse. Destination must also be sparse —
408                // this is the load-bearing regression test for the whole
409                // module.
410                assert!(
411                    dst_bytes_on_disk < len / 2,
412                    "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}",
413                );
414                assert!(
415                    dst_bytes_on_disk <= src_bytes_on_disk * 4 + 1024 * 1024,
416                    "destination allocated significantly more than source: src={src_bytes_on_disk} dst={dst_bytes_on_disk}",
417                );
418            } else {
419                eprintln!(
420                    "filesystem did not sparsify the source (src_bytes_on_disk={src_bytes_on_disk}, apparent={len}); sparseness preservation not exercised in this run",
421                );
422                // Without source sparseness we can't exceed source's
423                // footprint by much — guard against gross regressions.
424                assert!(
425                    dst_bytes_on_disk <= src_bytes_on_disk + 1024 * 1024,
426                    "destination grew beyond source footprint: src={src_bytes_on_disk} dst={dst_bytes_on_disk}",
427                );
428            }
429        }
430    }
431
432    #[test]
433    fn fast_copy_matches_source_size() {
434        let dir = tempfile::tempdir().unwrap();
435        let src = dir.path().join("src.bin");
436        let dst = dir.path().join("dst.bin");
437
438        let len: u64 = 4 * 1024 * 1024;
439        make_sparse(&src, len, &[0, 2 * 1024 * 1024]).unwrap();
440
441        let n = fast_copy(&src, &dst).unwrap();
442        assert_eq!(n, len);
443        assert_eq!(std::fs::metadata(&dst).unwrap().len(), len);
444    }
445
446    #[test]
447    fn missing_source_errors() {
448        let dir = tempfile::tempdir().unwrap();
449        let err = fast_copy(&dir.path().join("nope.bin"), &dir.path().join("dst.bin")).unwrap_err();
450        assert_eq!(err.kind(), io::ErrorKind::NotFound);
451    }
452}