vmm: Extract sparse file-copy helpers into a reusable module

Move next_data_extent and write_region_sparse out of memory_manager.rs
into a new vmm::sparse module so the snapshot writer, the restore
reader, and the offload daemon can share one implementation.

No functional change intended.

Signed-off-by: Sebastien Boeuf <sboeuf@meta.com>
Assisted-by: Claude:claude-opus-4-7
This commit is contained in:
Sebastien Boeuf
2026-06-10 08:05:39 -07:00
parent 6a74021ad5
commit df5d2d6003
3 changed files with 403 additions and 355 deletions

View File

@@ -95,6 +95,7 @@ mod serial_manager;
#[cfg(all(feature = "kvm", feature = "sev_snp", feature = "fw_cfg"))]
pub(crate) mod sev;
mod sigwinch_listener;
pub mod sparse;
mod sync_utils;
mod uffd;
mod userfaultfd;

View File

@@ -10,7 +10,7 @@ use std::fs::{File, OpenOptions};
use std::io::{self, Seek, SeekFrom};
use std::num::NonZeroUsize;
use std::ops::{BitAnd, Not, Sub};
use std::os::fd::{AsFd, BorrowedFd, OwnedFd};
use std::os::fd::{AsFd, OwnedFd};
use std::os::unix::fs::FileExt;
use std::os::unix::io::{AsRawFd, FromRawFd, RawFd};
use std::path::{Path, PathBuf};
@@ -56,45 +56,10 @@ use crate::coredump::{
CoredumpMemoryRegion, CoredumpMemoryRegions, DumpState, GuestDebuggableError,
};
use crate::migration::url_to_path;
use crate::sparse::{next_data_extent, write_region_sparse};
use crate::vm_config::{HotplugMethod, MemoryConfig, MemoryZoneConfig};
use crate::{GuestMemoryMmap, GuestRegionMmap, MEMORY_MANAGER_SNAPSHOT_ID, uffd};
/// Find the next populated (data) extent in `[cursor, end)` of `fd`,
/// driven by `lseek(SEEK_DATA)` / `lseek(SEEK_HOLE)`. Returns
/// `Ok(Some((offset, len)))` for the next data extent within the window,
/// or `Ok(None)` if there is no more data. Returns `Err` if the fd or
/// filesystem does not support `SEEK_HOLE` (e.g. hugetlbfs) or for any
/// other I/O error. Callers should treat a first-call error as "fall
/// back to the dense path".
fn next_data_extent(fd: BorrowedFd<'_>, cursor: u64, end: u64) -> io::Result<Option<(u64, u64)>> {
let raw = fd.as_raw_fd();
// SAFETY: BorrowedFd guarantees the fd is valid for the lifetime of
// the borrow; lseek does not consume or close it.
let data_off = unsafe { libc::lseek(raw, cursor as i64, libc::SEEK_DATA) };
if data_off < 0 {
let e = io::Error::last_os_error();
// ENXIO from SEEK_DATA means there is no more data at or after
// cursor. Any other error means the filesystem does not support
// SEEK_HOLE; the caller falls back to the dense path.
return if e.raw_os_error() == Some(libc::ENXIO) {
Ok(None)
} else {
Err(e)
};
}
let data_off = data_off as u64;
if data_off >= end {
return Ok(None);
}
// SAFETY: same as above.
let hole_off = unsafe { libc::lseek(raw, data_off as i64, libc::SEEK_HOLE) };
if hole_off < 0 {
return Err(io::Error::last_os_error());
}
let hole_off = (hole_off as u64).min(end);
Ok(Some((data_off, hole_off - data_off)))
}
struct UffdHandler {
stop_event: EventFd,
result_rx: Receiver<Result<(), io::Error>>,
@@ -3223,74 +3188,6 @@ impl Snapshottable for MemoryManager {
}
}
/// Write a single guest RAM region to the snapshot file at `dst_offset`,
/// streaming populated extents via `SEEK_DATA` / `SEEK_HOLE` on the
/// backing fd. `set_len(total)` must have been called on `dst` by the
/// caller. Returns `Ok(true)` if the region was written sparsely, or
/// `Ok(false)` if `SEEK_HOLE` is unsupported on the source fd (caller
/// should fall back to dense write).
fn write_region_sparse(
src: &File,
src_offset: u64,
dst: &File,
dst_offset: u64,
len: u64,
) -> io::Result<bool> {
let src_fd = src.as_fd();
let end = src_offset
.checked_add(len)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "range overflow"))?;
// First call to next_data_extent doubles as a SEEK_HOLE-support probe:
// a non-ENXIO error means the filesystem doesn't support sparse-seek;
// tell the caller to use the dense path instead.
let mut next = match next_data_extent(src_fd, src_offset, end) {
Ok(opt) => opt,
Err(_) => return Ok(false),
};
const CHUNK: usize = 1 << 20;
let mut buf = vec![0u8; CHUNK];
while let Some((data_off, ext_len)) = next {
debug_assert!(data_off >= src_offset);
let in_region = data_off
.checked_sub(src_offset)
.expect("extent precedes src_offset");
let mut written = 0u64;
while written < ext_len {
let this = ((ext_len - written) as usize).min(CHUNK);
let slice = &mut buf[..this];
let read = src.read_at(slice, data_off + written)?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"read_at returned 0 inside data extent",
));
}
let mut wrote_total = 0;
while wrote_total < read {
let n = dst.write_at(
&slice[wrote_total..read],
dst_offset + in_region + written + wrote_total as u64,
)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"write_at returned 0",
));
}
wrote_total += n;
}
written += read as u64;
}
// Subsequent next_data_extent failures are real I/O errors, not
// unsupported-FS, since the first probe succeeded.
next = next_data_extent(src_fd, data_off + ext_len, end)?;
}
Ok(true)
}
impl Transportable for MemoryManager {
fn send(
&self,
@@ -3456,253 +3353,3 @@ impl Migratable for MemoryManager {
Ok(table)
}
}
#[cfg(test)]
mod unit_tests {
use std::io::Write;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd};
use std::os::unix::fs::FileExt;
use super::{next_data_extent, write_region_sparse};
fn make_memfd(size: u64) -> std::fs::File {
// SAFETY: memfd_create is a self-contained syscall; we own the
// returned fd.
let fd = unsafe { libc::syscall(libc::SYS_memfd_create, c"sparse-test".as_ptr(), 0u32) };
assert!(fd >= 0, "memfd_create failed");
// SAFETY: memfd_create returned a valid fd that we now own; wrap it
// in File so it is closed on drop.
let f = unsafe { std::fs::File::from_raw_fd(fd as i32) };
f.set_len(size).unwrap();
f
}
fn collect_extents(
fd: BorrowedFd<'_>,
start: u64,
end: u64,
) -> std::io::Result<Vec<(u64, u64)>> {
let mut out = Vec::new();
let mut cursor = start;
while let Some((off, len)) = next_data_extent(fd, cursor, end)? {
out.push((off, len));
cursor = off + len;
}
Ok(out)
}
/// Punch an explicit hole into `f`. Tests use this instead of relying on
/// "didn't write here" to mean "is a hole": modern shmem/tmpfs may
/// allocate a multi-page folio on the first write and report the whole
/// folio as data, so per-page hole tracking after a partial write is
/// not portable. `fallocate(PUNCH_HOLE)` is the explicit "deallocate
/// these pages" syscall and is honored on every Linux filesystem we
/// run tests on (tmpfs, ext4, xfs, btrfs).
fn punch_hole(f: &std::fs::File, off: u64, len: u64) {
// SAFETY: FFI call; f is a valid open fd for the duration of the
// call.
let r = unsafe {
libc::fallocate(
f.as_raw_fd(),
libc::FALLOC_FL_PUNCH_HOLE | libc::FALLOC_FL_KEEP_SIZE,
off as libc::off_t,
len as libc::off_t,
)
};
assert_eq!(
r,
0,
"fallocate PUNCH_HOLE off={off} len={len}: {}",
std::io::Error::last_os_error(),
);
}
/// Build a file with a deterministic sparse layout: write each `(off,
/// len, byte)` data extent, then punch every gap into a real hole.
/// The resulting `SEEK_DATA`/`SEEK_HOLE` extents match `data` exactly,
/// regardless of folio/THP allocation policy on the backing FS.
fn sparse_layout(f: &std::fs::File, total: u64, data: &[(u64, u64, u8)]) {
f.set_len(total).unwrap();
for &(off, len, byte) in data {
f.write_all_at(&vec![byte; len as usize], off).unwrap();
}
let mut sorted: Vec<(u64, u64)> = data.iter().map(|&(o, l, _)| (o, l)).collect();
sorted.sort_unstable();
let mut cursor = 0u64;
for (off, len) in sorted {
assert!(off >= cursor, "overlapping data extents");
if off > cursor {
punch_hole(f, cursor, off - cursor);
}
cursor = off + len;
}
if cursor < total {
punch_hole(f, cursor, total - cursor);
}
}
#[test]
fn empty_memfd_has_no_data_extents() {
let f = make_memfd(4096 * 16);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert!(extents.is_empty(), "got {extents:?}");
}
#[test]
fn written_pages_show_as_data_extents() {
let f = make_memfd(0);
sparse_layout(
&f,
4096 * 16,
&[(4096 * 2, 4096, 0xAB), (4096 * 5, 4096 * 2, 0xCD)],
);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert_eq!(extents, vec![(4096 * 2, 4096), (4096 * 5, 4096 * 2)]);
}
#[test]
fn enumeration_respects_window() {
let f = make_memfd(4096 * 16);
// Fully populate the file, then leave it as one big data extent.
f.write_all_at(&vec![0xEEu8; 4096 * 16], 0).unwrap();
let extents = collect_extents(f.as_fd(), 4096 * 4, 4096 * 8).unwrap();
assert_eq!(extents, vec![(4096 * 4, 4096 * 4)]);
}
#[test]
fn dense_file_yields_single_extent() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&vec![0xEEu8; 4096 * 8]).unwrap();
let f = tmp.reopen().unwrap();
let extents = collect_extents(f.as_fd(), 0, 4096 * 8).unwrap();
assert_eq!(extents, vec![(0, 4096 * 8)]);
}
#[test]
fn sparse_file_yields_extents_at_written_positions() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let f = tmp.reopen().unwrap();
sparse_layout(&f, 4096 * 16, &[(4096 * 4, 4096 * 2, 0x55)]);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert_eq!(extents, vec![(4096 * 4, 4096 * 2)]);
}
#[test]
fn single_extent_at_zero_offset() {
let src = make_memfd(0);
sparse_layout(&src, 4096 * 16, &[(4096 * 3, 4096 * 2, 0x42)]);
// Pre-fill dst with a sentinel byte so we can verify that
// write_region_sparse only wrote where the source had data: any
// byte outside the source-data extent must remain the sentinel.
// This is FS-independent (does not depend on whether the dst
// filesystem reports holes after a partial write).
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
dst.write_all_at(&vec![0xFE; 4096 * 16], 0).unwrap();
let used = write_region_sparse(&src, 0, &dst, 0, 4096 * 16).unwrap();
assert!(used);
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096 * 3].iter().all(|&b| b == 0xFE));
assert!(buf[4096 * 3..4096 * 5].iter().all(|&b| b == 0x42));
assert!(buf[4096 * 5..].iter().all(|&b| b == 0xFE));
}
#[test]
fn two_regions_in_same_destination_file_at_dst_offset() {
let src_a = make_memfd(0);
sparse_layout(&src_a, 4096 * 16, &[(4096, 4096 * 2, 0xAA)]);
let src_b = make_memfd(0);
sparse_layout(&src_b, 4096 * 16, &[(4096 * 5, 4096 * 3, 0xBB)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
sparse_layout(&dst, 4096 * 32, &[]);
let _ = write_region_sparse(&src_a, 0, &dst, 0, 4096 * 16).unwrap();
let _ = write_region_sparse(&src_b, 0, &dst, 4096 * 16, 4096 * 16).unwrap();
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096].iter().all(|&b| b == 0));
assert!(buf[4096..4096 * 3].iter().all(|&b| b == 0xAA));
assert!(buf[4096 * 3..4096 * 16].iter().all(|&b| b == 0));
assert!(buf[4096 * 16..4096 * 21].iter().all(|&b| b == 0));
assert!(buf[4096 * 21..4096 * 24].iter().all(|&b| b == 0xBB));
assert!(buf[4096 * 24..].iter().all(|&b| b == 0));
}
#[test]
fn extent_at_non_zero_src_offset() {
let src = make_memfd(0);
sparse_layout(&src, 4096 * 32, &[(4096 * 20, 4096 * 2, 0x77)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
sparse_layout(&dst, 4096 * 16, &[]);
let used = write_region_sparse(&src, 4096 * 16, &dst, 0, 4096 * 16).unwrap();
assert!(used);
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096 * 4].iter().all(|&b| b == 0));
assert!(buf[4096 * 4..4096 * 6].iter().all(|&b| b == 0x77));
assert!(buf[4096 * 6..].iter().all(|&b| b == 0));
}
/// Round-trip: write two regions sparsely into a snapshot file, then
/// read them back using the same next_data_extent + read_at pattern
/// that fill_saved_regions uses. Verifies the restore path recovers
/// the original content including holes.
#[test]
fn round_trip_sparse_write_then_read() {
let src_a = make_memfd(0);
sparse_layout(&src_a, 4096 * 16, &[(4096 * 2, 4096 * 3, 0xAA)]);
let src_b = make_memfd(0);
sparse_layout(&src_b, 4096 * 16, &[(4096 * 10, 4096 * 4, 0xBB)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
let total = 4096u64 * 32;
sparse_layout(&dst, total, &[]);
write_region_sparse(&src_a, 0, &dst, 0, 4096 * 16).unwrap();
write_region_sparse(&src_b, 0, &dst, 4096 * 16, 4096 * 16).unwrap();
// Read back using next_data_extent + read_at, mirroring
// fill_saved_regions's sparse restore path.
let snap = tmp.reopen().unwrap();
let regions: Vec<(u64, u64)> = vec![(0, 4096 * 16), (4096 * 16, 4096 * 16)];
let mut restored = vec![0u8; total as usize];
for &(file_cursor, region_len) in &regions {
let end = file_cursor + region_len;
let mut cursor = file_cursor;
while let Some((data_off, ext_len)) =
next_data_extent(snap.as_fd(), cursor, end).unwrap()
{
let in_region = (data_off - file_cursor) as usize;
let dst_start = file_cursor as usize + in_region;
snap.read_at(
&mut restored[dst_start..dst_start + ext_len as usize],
data_off,
)
.unwrap();
cursor = data_off + ext_len;
}
}
// Verify content matches a dense read.
let dense = std::fs::read(tmp.path()).unwrap();
assert_eq!(restored, dense);
// Verify the actual data landed in the right places.
assert!(restored[..4096 * 2].iter().all(|&b| b == 0));
assert!(restored[4096 * 2..4096 * 5].iter().all(|&b| b == 0xAA));
assert!(restored[4096 * 5..4096 * 26].iter().all(|&b| b == 0));
assert!(restored[4096 * 26..4096 * 30].iter().all(|&b| b == 0xBB));
assert!(restored[4096 * 30..].iter().all(|&b| b == 0));
}
}

400
vmm/src/sparse.rs Normal file
View File

@@ -0,0 +1,400 @@
// Copyright © 2026 Contributors to the Cloud Hypervisor project
//
// SPDX-License-Identifier: Apache-2.0
//
//! Sparse file-copy helpers shared between snapshot/restore paths (the
//! `MemoryManager` snapshot writer and the offload daemon). Holes are
//! detected via `lseek(SEEK_DATA)`/`lseek(SEEK_HOLE)` and left as holes in
//! the destination, with a dense fallback when the source filesystem does
//! not support sparse-seek.
use std::fs::File;
use std::io;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd};
use std::os::unix::fs::FileExt;
/// Find the next populated (data) extent in `[cursor, end)` of `fd`,
/// driven by `lseek(SEEK_DATA)` / `lseek(SEEK_HOLE)`. Returns
/// `Ok(Some((offset, len)))` for the next data extent within the window,
/// or `Ok(None)` if there is no more data. Returns `Err` if the fd or
/// filesystem does not support `SEEK_HOLE` (e.g. hugetlbfs) or for any
/// other I/O error. Callers should treat a first-call error as "fall
/// back to the dense path".
pub fn next_data_extent(
fd: BorrowedFd<'_>,
cursor: u64,
end: u64,
) -> io::Result<Option<(u64, u64)>> {
let raw = fd.as_raw_fd();
// SAFETY: BorrowedFd guarantees the fd is valid for the lifetime of
// the borrow; lseek does not consume or close it.
let data_off = unsafe { libc::lseek(raw, cursor as i64, libc::SEEK_DATA) };
if data_off < 0 {
let e = io::Error::last_os_error();
// ENXIO from SEEK_DATA means there is no more data at or after
// cursor. Any other error means the filesystem does not support
// SEEK_HOLE; the caller falls back to the dense path.
return if e.raw_os_error() == Some(libc::ENXIO) {
Ok(None)
} else {
Err(e)
};
}
let data_off = data_off as u64;
if data_off >= end {
return Ok(None);
}
// SAFETY: same as above.
let hole_off = unsafe { libc::lseek(raw, data_off as i64, libc::SEEK_HOLE) };
if hole_off < 0 {
return Err(io::Error::last_os_error());
}
let hole_off = (hole_off as u64).min(end);
Ok(Some((data_off, hole_off - data_off)))
}
/// Copy `[src_offset, src_offset+len)` of `src` to `dst` at `dst_offset`,
/// writing only the populated extents so `dst` keeps its holes. `dst` must
/// already be sized (e.g. via `set_len`). Returns `Ok(true)` if the region
/// was written sparsely, or `Ok(false)` if `SEEK_HOLE` is unsupported on the
/// source fd (caller should fall back to a dense copy).
pub fn write_region_sparse(
src: &File,
src_offset: u64,
dst: &File,
dst_offset: u64,
len: u64,
) -> io::Result<bool> {
let src_fd = src.as_fd();
let end = src_offset
.checked_add(len)
.ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "range overflow"))?;
// First call to next_data_extent doubles as a SEEK_HOLE-support probe:
// a non-ENXIO error means the filesystem doesn't support sparse-seek;
// tell the caller to use the dense path instead.
let mut next = match next_data_extent(src_fd, src_offset, end) {
Ok(opt) => opt,
Err(_) => return Ok(false),
};
const CHUNK: usize = 1 << 20;
let mut buf = vec![0u8; CHUNK];
while let Some((data_off, ext_len)) = next {
debug_assert!(data_off >= src_offset);
let in_region = data_off
.checked_sub(src_offset)
.expect("extent precedes src_offset");
let mut written = 0u64;
while written < ext_len {
let this = ((ext_len - written) as usize).min(CHUNK);
let slice = &mut buf[..this];
let read = src.read_at(slice, data_off + written)?;
if read == 0 {
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
"read_at returned 0 inside data extent",
));
}
let mut wrote_total = 0;
while wrote_total < read {
let n = dst.write_at(
&slice[wrote_total..read],
dst_offset + in_region + written + wrote_total as u64,
)?;
if n == 0 {
return Err(io::Error::new(
io::ErrorKind::WriteZero,
"write_at returned 0",
));
}
wrote_total += n;
}
written += read as u64;
}
// Subsequent next_data_extent failures are real I/O errors, not
// unsupported-FS, since the first probe succeeded.
next = next_data_extent(src_fd, data_off + ext_len, end)?;
}
Ok(true)
}
/// Copy `[src_offset, src_offset+len)` of `src` to `dst` at `dst_offset`,
/// keeping `dst` sparse when the source filesystem supports `SEEK_HOLE` and
/// falling back to a dense byte copy otherwise (e.g. hugetlbfs). `dst` must
/// already be sized (e.g. via `set_len`). This is the sparse-or-dense wrapper
/// shared by all file-to-file copy consumers (e.g. the offload daemon).
pub fn copy_region(
src: &File,
src_offset: u64,
dst: &File,
dst_offset: u64,
len: u64,
) -> io::Result<()> {
if write_region_sparse(src, src_offset, dst, dst_offset, len)? {
return Ok(());
}
// Dense fallback: source filesystem lacks SEEK_HOLE (e.g. hugetlbfs).
const CHUNK: usize = 1 << 20;
let mut buf = vec![0u8; CHUNK];
let mut done = 0u64;
while done < len {
let this = ((len - done) as usize).min(CHUNK);
src.read_exact_at(&mut buf[..this], src_offset + done)?;
dst.write_all_at(&buf[..this], dst_offset + done)?;
done += this as u64;
}
Ok(())
}
#[cfg(test)]
mod unit_tests {
use std::io::Write;
use std::os::fd::{AsFd, AsRawFd, BorrowedFd, FromRawFd};
use std::os::unix::fs::FileExt;
use super::{next_data_extent, write_region_sparse};
fn make_memfd(size: u64) -> std::fs::File {
// SAFETY: memfd_create is a self-contained syscall; we own the
// returned fd.
let fd = unsafe { libc::syscall(libc::SYS_memfd_create, c"sparse-test".as_ptr(), 0u32) };
assert!(fd >= 0, "memfd_create failed");
// SAFETY: memfd_create returned a valid fd that we now own; wrap it
// in File so it is closed on drop.
let f = unsafe { std::fs::File::from_raw_fd(fd as i32) };
f.set_len(size).unwrap();
f
}
fn collect_extents(
fd: BorrowedFd<'_>,
start: u64,
end: u64,
) -> std::io::Result<Vec<(u64, u64)>> {
let mut out = Vec::new();
let mut cursor = start;
while let Some((off, len)) = next_data_extent(fd, cursor, end)? {
out.push((off, len));
cursor = off + len;
}
Ok(out)
}
/// Punch an explicit hole into `f`. Tests use this instead of relying on
/// "didn't write here" to mean "is a hole": modern shmem/tmpfs may
/// allocate a multi-page folio on the first write and report the whole
/// folio as data, so per-page hole tracking after a partial write is
/// not portable. `fallocate(PUNCH_HOLE)` is the explicit "deallocate
/// these pages" syscall and is honored on every Linux filesystem we
/// run tests on (tmpfs, ext4, xfs, btrfs).
fn punch_hole(f: &std::fs::File, off: u64, len: u64) {
// SAFETY: FFI call; f is a valid open fd for the duration of the
// call.
let r = unsafe {
libc::fallocate(
f.as_raw_fd(),
libc::FALLOC_FL_PUNCH_HOLE | libc::FALLOC_FL_KEEP_SIZE,
off as libc::off_t,
len as libc::off_t,
)
};
assert_eq!(
r,
0,
"fallocate PUNCH_HOLE off={off} len={len}: {}",
std::io::Error::last_os_error(),
);
}
/// Build a file with a deterministic sparse layout: write each `(off,
/// len, byte)` data extent, then punch every gap into a real hole.
/// The resulting `SEEK_DATA`/`SEEK_HOLE` extents match `data` exactly,
/// regardless of folio/THP allocation policy on the backing FS.
fn sparse_layout(f: &std::fs::File, total: u64, data: &[(u64, u64, u8)]) {
f.set_len(total).unwrap();
for &(off, len, byte) in data {
f.write_all_at(&vec![byte; len as usize], off).unwrap();
}
let mut sorted: Vec<(u64, u64)> = data.iter().map(|&(o, l, _)| (o, l)).collect();
sorted.sort_unstable();
let mut cursor = 0u64;
for (off, len) in sorted {
assert!(off >= cursor, "overlapping data extents");
if off > cursor {
punch_hole(f, cursor, off - cursor);
}
cursor = off + len;
}
if cursor < total {
punch_hole(f, cursor, total - cursor);
}
}
#[test]
fn empty_memfd_has_no_data_extents() {
let f = make_memfd(4096 * 16);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert!(extents.is_empty(), "got {extents:?}");
}
#[test]
fn written_pages_show_as_data_extents() {
let f = make_memfd(0);
sparse_layout(
&f,
4096 * 16,
&[(4096 * 2, 4096, 0xAB), (4096 * 5, 4096 * 2, 0xCD)],
);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert_eq!(extents, vec![(4096 * 2, 4096), (4096 * 5, 4096 * 2)]);
}
#[test]
fn enumeration_respects_window() {
let f = make_memfd(4096 * 16);
// Fully populate the file, then leave it as one big data extent.
f.write_all_at(&vec![0xEEu8; 4096 * 16], 0).unwrap();
let extents = collect_extents(f.as_fd(), 4096 * 4, 4096 * 8).unwrap();
assert_eq!(extents, vec![(4096 * 4, 4096 * 4)]);
}
#[test]
fn dense_file_yields_single_extent() {
let mut tmp = tempfile::NamedTempFile::new().unwrap();
tmp.write_all(&vec![0xEEu8; 4096 * 8]).unwrap();
let f = tmp.reopen().unwrap();
let extents = collect_extents(f.as_fd(), 0, 4096 * 8).unwrap();
assert_eq!(extents, vec![(0, 4096 * 8)]);
}
#[test]
fn sparse_file_yields_extents_at_written_positions() {
let tmp = tempfile::NamedTempFile::new().unwrap();
let f = tmp.reopen().unwrap();
sparse_layout(&f, 4096 * 16, &[(4096 * 4, 4096 * 2, 0x55)]);
let extents = collect_extents(f.as_fd(), 0, 4096 * 16).unwrap();
assert_eq!(extents, vec![(4096 * 4, 4096 * 2)]);
}
#[test]
fn single_extent_at_zero_offset() {
let src = make_memfd(0);
sparse_layout(&src, 4096 * 16, &[(4096 * 3, 4096 * 2, 0x42)]);
// Pre-fill dst with a sentinel byte so we can verify that
// write_region_sparse only wrote where the source had data: any
// byte outside the source-data extent must remain the sentinel.
// This is FS-independent (does not depend on whether the dst
// filesystem reports holes after a partial write).
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
dst.write_all_at(&vec![0xFE; 4096 * 16], 0).unwrap();
let used = write_region_sparse(&src, 0, &dst, 0, 4096 * 16).unwrap();
assert!(used);
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096 * 3].iter().all(|&b| b == 0xFE));
assert!(buf[4096 * 3..4096 * 5].iter().all(|&b| b == 0x42));
assert!(buf[4096 * 5..].iter().all(|&b| b == 0xFE));
}
#[test]
fn two_regions_in_same_destination_file_at_dst_offset() {
let src_a = make_memfd(0);
sparse_layout(&src_a, 4096 * 16, &[(4096, 4096 * 2, 0xAA)]);
let src_b = make_memfd(0);
sparse_layout(&src_b, 4096 * 16, &[(4096 * 5, 4096 * 3, 0xBB)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
sparse_layout(&dst, 4096 * 32, &[]);
let _ = write_region_sparse(&src_a, 0, &dst, 0, 4096 * 16).unwrap();
let _ = write_region_sparse(&src_b, 0, &dst, 4096 * 16, 4096 * 16).unwrap();
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096].iter().all(|&b| b == 0));
assert!(buf[4096..4096 * 3].iter().all(|&b| b == 0xAA));
assert!(buf[4096 * 3..4096 * 16].iter().all(|&b| b == 0));
assert!(buf[4096 * 16..4096 * 21].iter().all(|&b| b == 0));
assert!(buf[4096 * 21..4096 * 24].iter().all(|&b| b == 0xBB));
assert!(buf[4096 * 24..].iter().all(|&b| b == 0));
}
#[test]
fn extent_at_non_zero_src_offset() {
let src = make_memfd(0);
sparse_layout(&src, 4096 * 32, &[(4096 * 20, 4096 * 2, 0x77)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
sparse_layout(&dst, 4096 * 16, &[]);
let used = write_region_sparse(&src, 4096 * 16, &dst, 0, 4096 * 16).unwrap();
assert!(used);
let buf = std::fs::read(tmp.path()).unwrap();
assert!(buf[..4096 * 4].iter().all(|&b| b == 0));
assert!(buf[4096 * 4..4096 * 6].iter().all(|&b| b == 0x77));
assert!(buf[4096 * 6..].iter().all(|&b| b == 0));
}
/// Round-trip: write two regions sparsely into a snapshot file, then
/// read them back using the same next_data_extent + read_at pattern
/// that fill_saved_regions uses. Verifies the restore path recovers
/// the original content including holes.
#[test]
fn round_trip_sparse_write_then_read() {
let src_a = make_memfd(0);
sparse_layout(&src_a, 4096 * 16, &[(4096 * 2, 4096 * 3, 0xAA)]);
let src_b = make_memfd(0);
sparse_layout(&src_b, 4096 * 16, &[(4096 * 10, 4096 * 4, 0xBB)]);
let tmp = tempfile::NamedTempFile::new().unwrap();
let dst = tmp.reopen().unwrap();
let total = 4096u64 * 32;
sparse_layout(&dst, total, &[]);
write_region_sparse(&src_a, 0, &dst, 0, 4096 * 16).unwrap();
write_region_sparse(&src_b, 0, &dst, 4096 * 16, 4096 * 16).unwrap();
// Read back using next_data_extent + read_at, mirroring
// fill_saved_regions's sparse restore path.
let snap = tmp.reopen().unwrap();
let regions: Vec<(u64, u64)> = vec![(0, 4096 * 16), (4096 * 16, 4096 * 16)];
let mut restored = vec![0u8; total as usize];
for &(file_cursor, region_len) in &regions {
let end = file_cursor + region_len;
let mut cursor = file_cursor;
while let Some((data_off, ext_len)) =
next_data_extent(snap.as_fd(), cursor, end).unwrap()
{
let in_region = (data_off - file_cursor) as usize;
let dst_start = file_cursor as usize + in_region;
snap.read_at(
&mut restored[dst_start..dst_start + ext_len as usize],
data_off,
)
.unwrap();
cursor = data_off + ext_len;
}
}
// Verify content matches a dense read.
let dense = std::fs::read(tmp.path()).unwrap();
assert_eq!(restored, dense);
// Verify the actual data landed in the right places.
assert!(restored[..4096 * 2].iter().all(|&b| b == 0));
assert!(restored[4096 * 2..4096 * 5].iter().all(|&b| b == 0xAA));
assert!(restored[4096 * 5..4096 * 26].iter().all(|&b| b == 0));
assert!(restored[4096 * 26..4096 * 30].iter().all(|&b| b == 0xBB));
assert!(restored[4096 * 30..].iter().all(|&b| b == 0));
}
}