vm: cross-filesystem sparse clone, reflink_file

copy_file_range refuses cross-filesystem copies (EXDEV, kernels >= 5.19),
which made any clone that crossed filesystems fall through to fs::copy and
materialize every hole. The sparse copy now switches to pread/pwrite per
extent when copy_file_range refuses, keeping the copy sparse at page-cache
speed. reflink_file exposes the FICLONE-only path so callers can decide
where the working copy lives when the filesystem cannot reflink.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-09-01 21:22:04 -07:00
co-authored by Hanzo Dev
parent 2528762f87
commit 26bc528679
2 changed files with 91 additions and 2 deletions
+90 -1
View File
@@ -30,6 +30,20 @@ pub fn clone_file(src: &str, dst: &str) -> Result<()> {
Ok(())
}
/// Reflink `src` to `dst` (FICLONE), no fallback. Fails on filesystems
/// without reflink (ext4, tmpfs) and across filesystems (EXDEV); the
/// caller decides where the working copy should live in that case.
pub fn reflink_file(src: &str, dst: &str) -> io::Result<()> {
let sf = File::open(src)?;
let df = File::create(dst)?;
let r = reflink(&sf, &df);
if r.is_err() {
drop(df);
let _ = std::fs::remove_file(dst);
}
r
}
fn reflink(src: &File, dst: &File) -> io::Result<()> {
// EOPNOTSUPP on ext4/tmpfs; the caller falls through to sparse_copy.
let ret = unsafe { libc::ioctl(dst.as_raw_fd(), libc::FICLONE, src.as_raw_fd()) };
@@ -44,6 +58,10 @@ fn sparse_copy(src: &File, dst: &File) -> io::Result<()> {
let len = src.metadata()?.len();
dst.set_len(len)?;
let (sfd, dfd) = (src.as_raw_fd(), dst.as_raw_fd());
// copy_file_range refuses cross-filesystem copies (EXDEV, kernels
// >= 5.19); pread/pwrite covers that at page-cache speed.
let mut use_rw = false;
let mut buf = Vec::new();
let mut off: i64 = 0;
while (off as u64) < len {
let data = unsafe { libc::lseek(sfd, off, libc::SEEK_DATA) };
@@ -60,12 +78,48 @@ fn sparse_copy(src: &File, dst: &File) -> io::Result<()> {
}
let mut pos = data;
while pos < end {
if use_rw {
if buf.is_empty() {
buf = vec![0u8; 1 << 20];
}
let want = ((end - pos) as usize).min(buf.len());
let n =
unsafe { libc::pread(sfd, buf.as_mut_ptr() as *mut libc::c_void, want, pos) };
if n <= 0 {
return Err(io::Error::last_os_error());
}
let n = n as i64;
let mut written: i64 = 0;
while written < n {
let w = unsafe {
libc::pwrite(
dfd,
buf.as_ptr().add(written as usize) as *const libc::c_void,
(n - written) as usize,
pos + written,
)
};
if w <= 0 {
return Err(io::Error::last_os_error());
}
written += w as i64;
}
pos += n;
continue;
}
let (mut s_off, mut d_off) = (pos, pos);
let n = unsafe {
libc::copy_file_range(sfd, &mut s_off, dfd, &mut d_off, (end - pos) as usize, 0)
};
if n < 0 {
return Err(io::Error::last_os_error());
let err = io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::EXDEV) | Some(libc::EINVAL) | Some(libc::ENOSYS) => {
use_rw = true;
continue;
}
_ => return Err(err),
}
}
if n == 0 {
return Err(io::Error::new(
@@ -120,4 +174,39 @@ mod tests {
std::fs::remove_dir_all(&dir).unwrap();
}
#[test]
fn clone_across_filesystems_stays_sparse() {
// ext4 -> tmpfs: copy_file_range returns EXDEV on kernels >= 5.19,
// so this exercises the pread/pwrite fallback.
if !std::path::Path::new("/dev/shm").is_dir() {
return;
}
let dir = std::env::temp_dir().join(format!("vm-clone-x-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let src = dir.join("src.img");
let dst = format!("/dev/shm/vm-clone-x-{}.img", std::process::id());
let mut f = File::create(&src).unwrap();
f.write_all(b"head data").unwrap();
f.seek(SeekFrom::Start(HOLE)).unwrap();
f.write_all(b"tail data").unwrap();
f.set_len(HOLE + 4096).unwrap();
drop(f);
clone_file(src.to_str().unwrap(), &dst).unwrap();
assert_eq!(std::fs::read(&src).unwrap(), std::fs::read(&dst).unwrap());
let meta = std::fs::metadata(&dst).unwrap();
assert_eq!(meta.len(), HOLE + 4096);
let allocated = meta.blocks() * 512;
assert!(
allocated < 1024 * 1024,
"destination materialized holes: {} bytes allocated",
allocated
);
std::fs::remove_file(&dst).unwrap();
std::fs::remove_dir_all(&dir).unwrap();
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ mod clone;
mod sandbox;
#[cfg(target_os = "linux")]
pub use clone::clone_file;
pub use clone::{clone_file, reflink_file};
pub use sandbox::{MountConfig, PortForwardHandle, Sandbox, VmConfigBuilder};
pub use vm_proto::{