Skip to main content

vm/
clone.rs

1//! Linux rootfs clone. The base image is a 4 GB sparse ext4 file; a plain
2//! `fs::copy` materializes every hole as written zeros (~1.2 s on ext4), so
3//! clone with the cheapest mechanism the filesystem offers:
4//!
5//! 1. `ioctl(FICLONE)` — free reflink on XFS/btrfs.
6//! 2. Sparse copy — walk `lseek(SEEK_DATA)`/`lseek(SEEK_HOLE)` extents,
7//!    `copy_file_range` only the data, `ftruncate` to full size so holes
8//!    stay holes.
9//! 3. `fs::copy` — filesystems without SEEK_HOLE.
10#![allow(unsafe_code)]
11
12use std::fs::File;
13use std::io;
14use std::os::fd::AsRawFd;
15
16use anyhow::{Context, Result};
17
18/// Clone `src` to `dst`, preserving sparseness where the filesystem allows.
19pub fn clone_file(src: &str, dst: &str) -> Result<()> {
20    let sf = File::open(src).with_context(|| format!("failed to open {}", src))?;
21    let df = File::create(dst).with_context(|| format!("failed to create {}", dst))?;
22    if reflink(&sf, &df).is_ok() {
23        return Ok(());
24    }
25    if sparse_copy(&sf, &df).is_ok() {
26        return Ok(());
27    }
28    drop(df); // fs::copy reopens and truncates any partial sparse copy
29    std::fs::copy(src, dst).with_context(|| format!("failed to copy {} -> {}", src, dst))?;
30    Ok(())
31}
32
33/// Reflink `src` to `dst` (FICLONE), no fallback. Fails on filesystems
34/// without reflink (ext4, tmpfs) and across filesystems (EXDEV); the
35/// caller decides where the working copy should live in that case.
36pub fn reflink_file(src: &str, dst: &str) -> io::Result<()> {
37    let sf = File::open(src)?;
38    let df = File::create(dst)?;
39    let r = reflink(&sf, &df);
40    if r.is_err() {
41        drop(df);
42        let _ = std::fs::remove_file(dst);
43    }
44    r
45}
46
47fn reflink(src: &File, dst: &File) -> io::Result<()> {
48    // EOPNOTSUPP on ext4/tmpfs; the caller falls through to sparse_copy.
49    let ret = unsafe { libc::ioctl(dst.as_raw_fd(), libc::FICLONE, src.as_raw_fd()) };
50    if ret == 0 {
51        Ok(())
52    } else {
53        Err(io::Error::last_os_error())
54    }
55}
56
57fn sparse_copy(src: &File, dst: &File) -> io::Result<()> {
58    let len = src.metadata()?.len();
59    dst.set_len(len)?;
60    let (sfd, dfd) = (src.as_raw_fd(), dst.as_raw_fd());
61    // copy_file_range refuses cross-filesystem copies (EXDEV, kernels
62    // >= 5.19); pread/pwrite covers that at page-cache speed.
63    let mut use_rw = false;
64    let mut buf = Vec::new();
65    let mut off: i64 = 0;
66    while (off as u64) < len {
67        let data = unsafe { libc::lseek(sfd, off, libc::SEEK_DATA) };
68        if data < 0 {
69            let err = io::Error::last_os_error();
70            if err.raw_os_error() == Some(libc::ENXIO) {
71                break; // only holes from `off` to EOF
72            }
73            return Err(err); // EINVAL: no SEEK_DATA on this fs
74        }
75        let end = unsafe { libc::lseek(sfd, data, libc::SEEK_HOLE) };
76        if end < 0 {
77            return Err(io::Error::last_os_error());
78        }
79        let mut pos = data;
80        while pos < end {
81            if use_rw {
82                if buf.is_empty() {
83                    buf = vec![0u8; 1 << 20];
84                }
85                let want = ((end - pos) as usize).min(buf.len());
86                let n =
87                    unsafe { libc::pread(sfd, buf.as_mut_ptr() as *mut libc::c_void, want, pos) };
88                if n <= 0 {
89                    return Err(io::Error::last_os_error());
90                }
91                let n = n as i64;
92                let mut written: i64 = 0;
93                while written < n {
94                    let w = unsafe {
95                        libc::pwrite(
96                            dfd,
97                            buf.as_ptr().add(written as usize) as *const libc::c_void,
98                            (n - written) as usize,
99                            pos + written,
100                        )
101                    };
102                    if w <= 0 {
103                        return Err(io::Error::last_os_error());
104                    }
105                    written += w as i64;
106                }
107                pos += n;
108                continue;
109            }
110            let (mut s_off, mut d_off) = (pos, pos);
111            let n = unsafe {
112                libc::copy_file_range(sfd, &mut s_off, dfd, &mut d_off, (end - pos) as usize, 0)
113            };
114            if n < 0 {
115                let err = io::Error::last_os_error();
116                match err.raw_os_error() {
117                    Some(libc::EXDEV) | Some(libc::EINVAL) | Some(libc::ENOSYS) => {
118                        use_rw = true;
119                        continue;
120                    }
121                    _ => return Err(err),
122                }
123            }
124            if n == 0 {
125                return Err(io::Error::new(
126                    io::ErrorKind::UnexpectedEof,
127                    "copy_file_range returned 0 before extent end",
128                ));
129            }
130            pos += n as i64;
131        }
132        off = end;
133    }
134    Ok(())
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use std::io::{Seek, SeekFrom, Write};
141    use std::os::unix::fs::MetadataExt;
142
143    const HOLE: u64 = 100 * 1024 * 1024;
144
145    #[test]
146    fn clone_preserves_content_and_holes() {
147        let dir = std::env::temp_dir().join(format!("vm-clone-test-{}", std::process::id()));
148        std::fs::create_dir_all(&dir).unwrap();
149        let src = dir.join("src.img");
150        let dst = dir.join("dst.img");
151
152        let mut f = File::create(&src).unwrap();
153        f.write_all(b"head data").unwrap();
154        f.seek(SeekFrom::Start(HOLE)).unwrap();
155        f.write_all(b"tail data").unwrap();
156        f.set_len(HOLE + 4096).unwrap();
157        drop(f);
158
159        // On ext4/tmpfs the FICLONE attempt fails (EOPNOTSUPP) and the
160        // sparse copy takes over; on XFS/btrfs the reflink succeeds. Either
161        // way the result must be identical and stay sparse.
162        clone_file(src.to_str().unwrap(), dst.to_str().unwrap()).unwrap();
163
164        assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dst).unwrap());
165        let meta = std::fs::metadata(&dst).unwrap();
166        assert_eq!(meta.len(), HOLE + 4096);
167        let allocated = meta.blocks() * 512;
168        assert!(
169            allocated < 1024 * 1024,
170            "destination materialized holes: {} bytes allocated for {} apparent",
171            allocated,
172            meta.len()
173        );
174
175        std::fs::remove_dir_all(&dir).unwrap();
176    }
177
178    #[test]
179    fn clone_across_filesystems_stays_sparse() {
180        // ext4 -> tmpfs: copy_file_range returns EXDEV on kernels >= 5.19,
181        // so this exercises the pread/pwrite fallback.
182        if !std::path::Path::new("/dev/shm").is_dir() {
183            return;
184        }
185        let dir = std::env::temp_dir().join(format!("vm-clone-x-{}", std::process::id()));
186        std::fs::create_dir_all(&dir).unwrap();
187        let src = dir.join("src.img");
188        let dst = format!("/dev/shm/vm-clone-x-{}.img", std::process::id());
189
190        let mut f = File::create(&src).unwrap();
191        f.write_all(b"head data").unwrap();
192        f.seek(SeekFrom::Start(HOLE)).unwrap();
193        f.write_all(b"tail data").unwrap();
194        f.set_len(HOLE + 4096).unwrap();
195        drop(f);
196
197        clone_file(src.to_str().unwrap(), &dst).unwrap();
198
199        assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dst).unwrap());
200        let meta = std::fs::metadata(&dst).unwrap();
201        assert_eq!(meta.len(), HOLE + 4096);
202        let allocated = meta.blocks() * 512;
203        assert!(
204            allocated < 1024 * 1024,
205            "destination materialized holes: {} bytes allocated",
206            allocated
207        );
208
209        std::fs::remove_file(&dst).unwrap();
210        std::fs::remove_dir_all(&dir).unwrap();
211    }
212}