use std::fs::File;
use std::io;
use std::path::Path;
#[cfg(unix)]
use std::os::unix::io::AsRawFd;
#[cfg(windows)]
use std::os::windows::io::AsRawHandle;
#[cfg(windows)]
use std::ptr;
#[cfg(windows)]
use windows_sys::Win32::Foundation::{ERROR_MORE_DATA, HANDLE};
#[cfg(windows)]
use windows_sys::Win32::System::IO::DeviceIoControl;
#[cfg(windows)]
use windows_sys::Win32::System::Ioctl::{
FILE_ALLOCATED_RANGE_BUFFER, FSCTL_QUERY_ALLOCATED_RANGES, FSCTL_SET_SPARSE,
};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExtentMap {
pub len: u64,
pub extents: Vec<(u64, u64)>,
}
impl ExtentMap {
pub fn scan(path: &Path) -> io::Result<Option<ExtentMap>> {
let file = File::open(path)?;
Self::scan_file(&file)
}
pub fn scan_file(file: &File) -> io::Result<Option<ExtentMap>> {
let len = file.metadata()?.len();
if len == 0 {
return Ok(Some(ExtentMap {
len,
extents: Vec::new(),
}));
}
scan_impl(file, len)
}
pub fn data_bytes(&self) -> u64 {
self.extents.iter().map(|(_, len)| len).sum()
}
pub fn has_holes(&self) -> bool {
self.data_bytes() < self.len
}
}
#[cfg(windows)]
pub fn mark_sparse(file: &File) -> io::Result<()> {
let mut bytes_returned = 0;
let ok = unsafe {
DeviceIoControl(
file.as_raw_handle() as HANDLE,
FSCTL_SET_SPARSE,
ptr::null(),
0,
ptr::null_mut(),
0,
&mut bytes_returned,
ptr::null_mut(),
)
};
if ok == 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(unix)]
pub fn mark_sparse(_file: &File) -> io::Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
pub fn punch_hole_aligned(file: &File, offset: u64, len: u64) -> io::Result<()> {
let block = allocation_block_size(file)?;
let start = offset.div_ceil(block).saturating_mul(block);
let end = (offset.saturating_add(len) / block).saturating_mul(block);
if end <= start {
return Ok(());
}
let args = libc::fpunchhole_t {
fp_flags: 0,
reserved: 0,
fp_offset: start as libc::off_t,
fp_length: (end - start) as libc::off_t,
};
let rc = unsafe { libc::fcntl(file.as_raw_fd(), libc::F_PUNCHHOLE, &args) };
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok(())
}
#[cfg(not(target_os = "macos"))]
pub fn punch_hole_aligned(_file: &File, _offset: u64, _len: u64) -> io::Result<()> {
Ok(())
}
#[cfg(unix)]
fn scan_impl(file: &File, len: u64) -> io::Result<Option<ExtentMap>> {
let fd = file.as_raw_fd();
let mut extents: Vec<(u64, u64)> = Vec::new();
let mut off: i64 = 0;
while (off as u64) < len {
let data_start = unsafe { libc::lseek(fd, off, libc::SEEK_DATA) };
if data_start < 0 {
let err = io::Error::last_os_error();
match err.raw_os_error() {
Some(libc::ENXIO) => break,
Some(libc::EINVAL) | Some(libc::ENOTSUP) => return Ok(None),
#[cfg(not(target_os = "linux"))]
Some(libc::EOPNOTSUPP) => return Ok(None),
_ => return Err(err),
}
}
let data_end = unsafe { libc::lseek(fd, data_start, libc::SEEK_HOLE) };
if data_end < 0 {
return Err(io::Error::last_os_error());
}
let data_end = (data_end as u64).min(len);
let data_start = data_start as u64;
if data_end <= data_start {
break;
}
extents.push((data_start, data_end - data_start));
off = data_end as i64;
}
Ok(Some(ExtentMap { len, extents }))
}
#[cfg(windows)]
fn scan_impl(file: &File, len: u64) -> io::Result<Option<ExtentMap>> {
const BATCH: usize = 64;
let handle = file.as_raw_handle() as HANDLE;
let mut extents: Vec<(u64, u64)> = Vec::new();
let mut next_offset: u64 = 0;
while next_offset < len {
let query = FILE_ALLOCATED_RANGE_BUFFER {
FileOffset: next_offset as i64,
Length: (len - next_offset) as i64,
};
let mut out = [FILE_ALLOCATED_RANGE_BUFFER {
FileOffset: 0,
Length: 0,
}; BATCH];
let mut bytes_returned: u32 = 0;
let ok = unsafe {
DeviceIoControl(
handle,
FSCTL_QUERY_ALLOCATED_RANGES,
&query as *const _ as *const _,
size_of::<FILE_ALLOCATED_RANGE_BUFFER>() as u32,
out.as_mut_ptr() as *mut _,
(size_of::<FILE_ALLOCATED_RANGE_BUFFER>() * BATCH) as u32,
&mut bytes_returned,
ptr::null_mut(),
)
};
let more = if ok == 0 {
let err = io::Error::last_os_error();
if err.raw_os_error() == Some(ERROR_MORE_DATA as i32) {
true
} else {
return Ok(None);
}
} else {
false
};
let count = bytes_returned as usize / size_of::<FILE_ALLOCATED_RANGE_BUFFER>();
if count == 0 {
break;
}
for range in &out[..count] {
let start = range.FileOffset as u64;
let end = (start + range.Length as u64).min(len);
if end > start {
extents.push((start, end - start));
}
}
let (last_off, last_len) = extents[extents.len() - 1];
next_offset = last_off + last_len;
if !more {
break;
}
}
Ok(Some(ExtentMap { len, extents }))
}
#[cfg(target_os = "macos")]
fn allocation_block_size(file: &File) -> io::Result<u64> {
let mut stat: libc::statfs = unsafe { std::mem::zeroed() };
let rc = unsafe { libc::fstatfs(file.as_raw_fd(), &mut stat) };
if rc != 0 {
return Err(io::Error::last_os_error());
}
Ok((stat.f_bsize as u64).max(512))
}
#[cfg(test)]
mod tests {
use std::io::{Seek, SeekFrom, Write};
use super::*;
#[test]
fn dense_file_scans_as_single_extent_or_unsupported() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("dense.bin");
std::fs::write(&path, vec![0xAB; 8192]).unwrap();
match ExtentMap::scan(&path).unwrap() {
None => {} Some(map) => {
assert_eq!(map.len, 8192);
assert_eq!(map.data_bytes(), 8192);
assert!(!map.has_holes());
}
}
}
#[test]
fn empty_file_scans_as_empty_map() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.bin");
std::fs::write(&path, b"").unwrap();
let map = ExtentMap::scan(&path).unwrap().unwrap();
assert_eq!(map.len, 0);
assert!(map.extents.is_empty());
assert!(!map.has_holes());
}
#[test]
fn sparse_file_scan_covers_all_data() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sparse.bin");
let len: u64 = 8 * 1024 * 1024;
let mut f = std::fs::OpenOptions::new()
.read(true)
.write(true)
.create(true)
.truncate(true)
.open(&path)
.unwrap();
mark_sparse(&f).unwrap();
f.set_len(len).unwrap();
f.seek(SeekFrom::Start(0)).unwrap();
f.write_all(&[0x11; 4096]).unwrap();
f.seek(SeekFrom::Start(4 * 1024 * 1024)).unwrap();
f.write_all(&[0x22; 4096]).unwrap();
f.sync_all().unwrap();
punch_hole_aligned(&f, 4096, 4 * 1024 * 1024 - 4096).unwrap();
punch_hole_aligned(&f, 4 * 1024 * 1024 + 4096, len - (4 * 1024 * 1024 + 4096)).unwrap();
drop(f);
let Some(map) = ExtentMap::scan(&path).unwrap() else {
eprintln!("filesystem can't enumerate extents; scan not exercised");
return;
};
assert_eq!(map.len, len);
let covers = |target: u64| {
map.extents
.iter()
.any(|(off, l)| *off <= target && target < off + l)
};
assert!(covers(0), "extent map misses data at 0: {:?}", map.extents);
assert!(
covers(4 * 1024 * 1024),
"extent map misses data at 4 MiB: {:?}",
map.extents
);
}
}