use std::fs::File;
use std::os::fd::AsFd;
use std::os::unix::fs::FileExt;
const FALLBACK_BUF_SIZE: usize = 1024 * 1024;
const DENSE_BUF_SIZE: usize = 128 * 1024;
pub fn copy_file_range_all(src: &File, dst: &File, len: u64) -> std::io::Result<u64> {
nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekSet)
.map_err(std::io::Error::from)?;
nix::unistd::lseek(dst.as_fd(), 0, nix::unistd::Whence::SeekSet)
.map_err(std::io::Error::from)?;
let mut copied: u64 = 0;
while copied < len {
let remaining = usize::try_from(len - copied).unwrap_or(usize::MAX);
match nix::fcntl::copy_file_range(src.as_fd(), None, dst.as_fd(), None, remaining) {
Ok(0) => {
return Ok(copied);
}
Ok(n) => copied += n as u64,
Err(errno) => {
if matches!(
errno,
nix::errno::Errno::ENOSYS
| nix::errno::Errno::EXDEV
| nix::errno::Errno::EINVAL
| nix::errno::Errno::EOPNOTSUPP
) {
return copy_sparse_fallback(src, dst, copied, len)
.map(|fallback| copied + fallback);
}
return Err(std::io::Error::from(errno));
}
}
}
Ok(copied)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SparseProbe {
Data(u64),
TrailingHole,
Unsupported,
}
fn classify_seek_data(result: nix::Result<libc::off_t>) -> std::io::Result<SparseProbe> {
match result {
Ok(off) => Ok(SparseProbe::Data(off as u64)),
Err(nix::errno::Errno::ENXIO) => Ok(SparseProbe::TrailingHole),
Err(nix::errno::Errno::EINVAL | nix::errno::Errno::EOPNOTSUPP) => {
Ok(SparseProbe::Unsupported)
}
Err(errno) => Err(std::io::Error::from(errno)),
}
}
pub(crate) fn copy_sparse_fallback(
src: &File,
dst: &File,
start: u64,
len: u64,
) -> std::io::Result<u64> {
if start >= len {
nix::unistd::ftruncate(dst.as_fd(), to_off_t(len)?).map_err(std::io::Error::from)?;
return Ok(0);
}
let probe = classify_seek_data(nix::unistd::lseek(
src.as_fd(),
to_off_t(start)?,
nix::unistd::Whence::SeekData,
))?;
let first_data = match probe {
SparseProbe::Unsupported => return dense_copy(src, dst, start, len),
SparseProbe::TrailingHole => len,
SparseProbe::Data(d) => d,
};
let mut buf = vec![0u8; FALLBACK_BUF_SIZE];
let mut off = start;
let mut next_data = first_data;
while off < len {
let data = next_data;
if data >= len {
break;
}
let hole =
match nix::unistd::lseek(src.as_fd(), to_off_t(data)?, nix::unistd::Whence::SeekHole) {
Ok(h) => (h as u64).min(len),
Err(nix::errno::Errno::ENXIO) => len,
Err(errno) => return Err(std::io::Error::from(errno)),
};
copy_data_extent(src, dst, data, hole, &mut buf)?;
off = hole;
if off >= len {
break;
}
next_data = match classify_seek_data(nix::unistd::lseek(
src.as_fd(),
to_off_t(off)?,
nix::unistd::Whence::SeekData,
))? {
SparseProbe::Data(d) => d,
SparseProbe::TrailingHole => break, SparseProbe::Unsupported => return dense_copy(src, dst, off, len),
};
}
let actual_eof = nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekEnd)
.map_err(std::io::Error::from)? as u64;
let final_size = len.min(actual_eof);
nix::unistd::ftruncate(dst.as_fd(), to_off_t(final_size)?).map_err(std::io::Error::from)?;
Ok(final_size.saturating_sub(start))
}
fn copy_data_extent(
src: &File,
dst: &File,
from: u64,
to: u64,
buf: &mut [u8],
) -> std::io::Result<()> {
let mut pos = from;
while pos < to {
let want = usize::try_from((to - pos).min(buf.len() as u64)).unwrap_or(buf.len());
let n = src.read_at(&mut buf[..want], pos)?;
if n == 0 {
break;
}
let mut written = 0;
while written < n {
let w = dst.write_at(&buf[written..n], pos + written as u64)?;
if w == 0 {
return Err(std::io::Error::from(std::io::ErrorKind::WriteZero));
}
written += w;
}
pos += n as u64;
}
Ok(())
}
fn dense_copy(src: &File, dst: &File, start: u64, len: u64) -> std::io::Result<u64> {
let mut buf = vec![0u8; DENSE_BUF_SIZE];
let mut pos = start;
while pos < len {
let want = usize::try_from((len - pos).min(buf.len() as u64)).unwrap_or(buf.len());
let n = match src.read_at(&mut buf[..want], pos) {
Ok(0) => break, Ok(n) => n,
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
};
let mut written = 0;
while written < n {
match dst.write_at(&buf[written..n], pos + written as u64) {
Ok(0) => return Err(std::io::Error::from(std::io::ErrorKind::WriteZero)),
Ok(w) => written += w,
Err(err) if err.kind() == std::io::ErrorKind::Interrupted => continue,
Err(err) => return Err(err),
}
}
pos += n as u64;
}
let actual_eof = nix::unistd::lseek(src.as_fd(), 0, nix::unistd::Whence::SeekEnd)
.map_err(std::io::Error::from)? as u64;
let final_size = len.min(actual_eof);
nix::unistd::ftruncate(dst.as_fd(), to_off_t(final_size)?).map_err(std::io::Error::from)?;
Ok(final_size.saturating_sub(start))
}
fn to_off_t(value: u64) -> std::io::Result<libc::off_t> {
libc::off_t::try_from(value).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"file offset exceeds off_t range",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write as _;
use std::os::unix::fs::MetadataExt;
fn make_file(dir: &std::path::Path, name: &str) -> File {
std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(dir.join(name))
.expect("open temp file")
}
#[test]
fn copies_bytes_identically() {
let tmp = tempfile::tempdir().unwrap();
let contents = b"the quick brown fox jumps over the lazy dog";
let mut src = make_file(tmp.path(), "src");
src.write_all(contents).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_file_range_all(&src, &dst, contents.len() as u64).unwrap();
assert_eq!(copied, contents.len() as u64);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got, contents);
}
#[test]
fn copies_large_file_fully() {
let tmp = tempfile::tempdir().unwrap();
let len: usize = 8 * 1024 * 1024;
let data: Vec<u8> = (0..len)
.map(|i| (i.wrapping_mul(31) ^ (i >> 7)) as u8)
.collect();
let mut src = make_file(tmp.path(), "src");
src.write_all(&data).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_file_range_all(&src, &dst, len as u64).unwrap();
assert_eq!(copied, len as u64);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got.len(), len);
assert!(got == data, "destination bytes differ from source");
}
#[test]
fn sparse_fallback_preserves_holes() {
let tmp = tempfile::tempdir().unwrap();
let logical: u64 = 8 * 1024 * 1024;
let head = b"HEAD-region-bytes";
let tail = b"TAIL-region-bytes";
let tail_off = logical - tail.len() as u64;
let src = make_file(tmp.path(), "src");
nix::unistd::ftruncate(src.as_fd(), to_off_t(logical).unwrap()).unwrap();
src.write_at(head, 0).unwrap();
src.write_at(tail, tail_off).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_sparse_fallback(&src, &dst, 0, logical).unwrap();
assert_eq!(copied, logical);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
let expected = std::fs::read(tmp.path().join("src")).unwrap();
assert_eq!(got.len() as u64, logical);
assert_eq!(got, expected, "destination content differs from source");
assert_eq!(&got[..head.len()], head);
assert_eq!(&got[tail_off as usize..], tail);
let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
assert_eq!(dst_meta.len(), logical);
let src_meta = std::fs::metadata(tmp.path().join("src")).unwrap();
let dst_allocated = dst_meta.blocks() * 512;
let src_allocated = src_meta.blocks() * 512;
eprintln!(
"sparse blocks: src={} blocks ({} bytes), dst={} blocks ({} bytes), logical={} bytes",
src_meta.blocks(),
src_allocated,
dst_meta.blocks(),
dst_allocated,
logical
);
assert!(
dst_allocated < logical,
"destination is not sparse: allocated {dst_allocated} >= logical {logical}"
);
assert!(
dst_allocated <= src_allocated * 4 + 4096,
"destination far less sparse than source: dst={dst_allocated} src={src_allocated}"
);
}
#[test]
fn terminates_on_short_source() {
let tmp = tempfile::tempdir().unwrap();
let contents = b"short source contents";
let real_len = contents.len() as u64;
let mut src = make_file(tmp.path(), "src");
src.write_all(contents).unwrap();
src.sync_all().unwrap();
let _dst = make_file(tmp.path(), "dst");
let claimed = real_len + 4096;
let (tx, rx) = std::sync::mpsc::channel();
let src_path = tmp.path().join("src");
let dst_path = tmp.path().join("dst");
std::thread::spawn(move || {
let src = std::fs::File::open(&src_path).unwrap();
let dst = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&dst_path)
.unwrap();
let copied = copy_file_range_all(&src, &dst, claimed).unwrap();
tx.send(copied).unwrap();
});
let copied = rx
.recv_timeout(std::time::Duration::from_secs(10))
.expect("copy_file_range_all hung on a short source");
assert_eq!(copied, real_len);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got, contents);
}
#[test]
fn copies_partial_len_on_both_paths() {
let tmp = tempfile::tempdir().unwrap();
let full: Vec<u8> = (0u32..4096).map(|i| (i % 251) as u8).collect();
let len: u64 = 1000;
let mut src = make_file(tmp.path(), "src");
src.write_all(&full).unwrap();
src.sync_all().unwrap();
let dst_cfr = make_file(tmp.path(), "dst_cfr");
let copied_cfr = copy_file_range_all(&src, &dst_cfr, len).unwrap();
assert_eq!(copied_cfr, len);
let got_cfr = std::fs::read(tmp.path().join("dst_cfr")).unwrap();
assert_eq!(got_cfr.len() as u64, len);
assert_eq!(got_cfr, &full[..len as usize]);
let dst_fb = make_file(tmp.path(), "dst_fb");
let copied_fb = copy_sparse_fallback(&src, &dst_fb, 0, len).unwrap();
assert_eq!(copied_fb, len);
let got_fb = std::fs::read(tmp.path().join("dst_fb")).unwrap();
assert_eq!(got_fb.len() as u64, len);
assert_eq!(got_fb, &full[..len as usize]);
}
#[test]
fn fallback_shrunk_source_not_padded() {
let tmp = tempfile::tempdir().unwrap();
let contents: Vec<u8> = (0u32..3000).map(|i| (i % 240) as u8).collect();
let s = contents.len() as u64;
let mut src = make_file(tmp.path(), "src");
src.write_all(&contents).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_sparse_fallback(&src, &dst, 0, s + 8192).unwrap();
assert_eq!(
copied, s,
"must return actual source bytes, not the claimed len"
);
let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
assert_eq!(
dst_meta.len(),
s,
"dst must be sized to the source, not padded"
);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got, contents);
}
#[test]
fn copies_zero_length() {
let tmp = tempfile::tempdir().unwrap();
let mut src = make_file(tmp.path(), "src");
src.write_all(b"non-empty source contents").unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_file_range_all(&src, &dst, 0).unwrap();
assert_eq!(copied, 0);
let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
assert_eq!(dst_meta.len(), 0, "zero-length copy must leave dst empty");
}
#[test]
fn fallback_all_hole_source() {
let tmp = tempfile::tempdir().unwrap();
let logical: u64 = 4 * 1024 * 1024;
let src = make_file(tmp.path(), "src");
nix::unistd::ftruncate(src.as_fd(), to_off_t(logical).unwrap()).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = copy_sparse_fallback(&src, &dst, 0, logical).unwrap();
assert_eq!(copied, logical);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got.len() as u64, logical);
assert!(
got.iter().all(|&b| b == 0),
"all-hole copy must be all zeros"
);
let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
assert_eq!(dst_meta.len(), logical);
let dst_allocated = dst_meta.blocks() * 512;
eprintln!(
"all-hole: dst={} blocks ({dst_allocated} bytes), logical={logical}",
dst_meta.blocks()
);
assert!(
dst_allocated < logical,
"all-hole destination is not sparse: allocated {dst_allocated} >= logical {logical}"
);
}
#[test]
fn classify_seek_data_routes_by_errno() {
assert_eq!(
classify_seek_data(Ok(4096)).unwrap(),
SparseProbe::Data(4096)
);
assert_eq!(
classify_seek_data(Err(nix::errno::Errno::ENXIO)).unwrap(),
SparseProbe::TrailingHole
);
assert_eq!(
classify_seek_data(Err(nix::errno::Errno::EINVAL)).unwrap(),
SparseProbe::Unsupported
);
assert_eq!(
classify_seek_data(Err(nix::errno::Errno::EOPNOTSUPP)).unwrap(),
SparseProbe::Unsupported
);
assert_eq!(
classify_seek_data(Err(nix::errno::Errno::ENOTSUP)).unwrap(),
SparseProbe::Unsupported
);
let err = classify_seek_data(Err(nix::errno::Errno::EIO)).unwrap_err();
assert_eq!(err.raw_os_error(), Some(libc::EIO));
}
#[test]
fn dense_copy_is_byte_exact_with_embedded_zeros() {
let tmp = tempfile::tempdir().unwrap();
let len: usize = 3 * DENSE_BUF_SIZE + 777; let mut data: Vec<u8> = (0..len)
.map(|i| (i.wrapping_mul(37) ^ (i >> 5)) as u8)
.collect();
for b in data.iter_mut().take(DENSE_BUF_SIZE + 4096).skip(100) {
*b = 0;
}
for b in data
.iter_mut()
.take(2 * DENSE_BUF_SIZE)
.skip(2 * DENSE_BUF_SIZE - 500)
{
*b = 0;
}
let mut src = make_file(tmp.path(), "src");
src.write_all(&data).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = dense_copy(&src, &dst, 0, len as u64).unwrap();
assert_eq!(copied, len as u64);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got.len(), len, "dense copy must size dst to the source");
assert!(got == data, "dense copy bytes differ from source");
}
#[test]
fn dense_copy_partial_len_sizes_dst_exactly() {
let tmp = tempfile::tempdir().unwrap();
let full: Vec<u8> = (0u32..8192).map(|i| (i % 251) as u8).collect();
let len: u64 = 5000;
let mut src = make_file(tmp.path(), "src");
src.write_all(&full).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = dense_copy(&src, &dst, 0, len).unwrap();
assert_eq!(copied, len);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got.len() as u64, len);
assert_eq!(got, &full[..len as usize]);
}
#[test]
fn dense_copy_shrunk_source_not_padded() {
let tmp = tempfile::tempdir().unwrap();
let contents: Vec<u8> = (0u32..3000).map(|i| (i % 240) as u8).collect();
let s = contents.len() as u64;
let mut src = make_file(tmp.path(), "src");
src.write_all(&contents).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = dense_copy(&src, &dst, 0, s + 8192).unwrap();
assert_eq!(
copied, s,
"dense copy must return actual source bytes, not the claimed len"
);
let dst_meta = std::fs::metadata(tmp.path().join("dst")).unwrap();
assert_eq!(dst_meta.len(), s, "dst must be sized to the source");
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got, contents);
}
#[test]
fn unsupported_probe_routes_to_dense_byte_exact() {
assert_eq!(
classify_seek_data(Err(nix::errno::Errno::EINVAL)).unwrap(),
SparseProbe::Unsupported,
"unsupported probe must route to the dense fallback"
);
let tmp = tempfile::tempdir().unwrap();
let data: Vec<u8> = (0u32..200_000).map(|i| (i % 256) as u8).collect();
let mut src = make_file(tmp.path(), "src");
src.write_all(&data).unwrap();
src.sync_all().unwrap();
let dst = make_file(tmp.path(), "dst");
let copied = dense_copy(&src, &dst, 0, data.len() as u64).unwrap();
assert_eq!(copied, data.len() as u64);
let got = std::fs::read(tmp.path().join("dst")).unwrap();
assert_eq!(got, data, "dense fallback target must be byte-exact");
}
}