use std::path::Path;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Sharing {
pub shared: bool,
pub private_bytes: u64,
}
impl Sharing {
pub fn unknown_for(size: u64) -> Self {
Self {
shared: false,
private_bytes: size,
}
}
}
pub fn probe(path: &Path, size: u64) -> Sharing {
#[cfg(target_os = "macos")]
{
probe_macos(path, size)
}
#[cfg(target_os = "linux")]
{
probe_linux(path, size)
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
{
probe_unsupported(path, size)
}
}
#[cfg(target_os = "macos")]
fn probe_macos(path: &Path, size: u64) -> Sharing {
use std::ffi::CString;
use std::os::unix::ffi::OsStrExt;
const FSOPT_ATTR_CMN_EXTENDED: u32 = 0x0000_0020;
const ATTR_BIT_MAP_COUNT: u16 = 5;
const ATTR_CMN_RETURNED_ATTRS: u32 = 0x8000_0000;
const ATTR_CMNEXT_PRIVATESIZE: u32 = 0x0000_0008;
const ATTR_CMNEXT_EXT_FLAGS: u32 = 0x0000_0200;
const EF_MAY_SHARE_BLOCKS: u64 = 0x0000_0001;
const EF_SHARES_ALL_BLOCKS: u64 = 0x0000_0040;
#[repr(C)]
#[derive(Default)]
struct AttrList {
bitmapcount: u16,
reserved: u16,
commonattr: u32,
volattr: u32,
dirattr: u32,
fileattr: u32,
forkattr: u32,
}
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct AttributeSet {
commonattr: u32,
volattr: u32,
dirattr: u32,
fileattr: u32,
forkattr: u32,
}
#[repr(C, packed)]
struct Buf {
length: u32,
returned: AttributeSet,
private_size: i64,
ext_flags: u64,
}
let Ok(c_path) = CString::new(path.as_os_str().as_bytes()) else {
return Sharing::unknown_for(size);
};
let mut attrs = AttrList {
bitmapcount: ATTR_BIT_MAP_COUNT,
reserved: 0,
commonattr: ATTR_CMN_RETURNED_ATTRS,
volattr: 0,
dirattr: 0,
fileattr: 0,
forkattr: ATTR_CMNEXT_PRIVATESIZE | ATTR_CMNEXT_EXT_FLAGS,
};
let mut buf: Buf = unsafe { std::mem::zeroed() };
let rc = unsafe {
libc::getattrlist(
c_path.as_ptr(),
&mut attrs as *mut AttrList as *mut libc::c_void,
&mut buf as *mut Buf as *mut libc::c_void,
std::mem::size_of::<Buf>(),
FSOPT_ATTR_CMN_EXTENDED,
)
};
if rc != 0 {
return Sharing::unknown_for(size);
}
if (buf.length as usize) < std::mem::size_of::<Buf>() {
return Sharing::unknown_for(size);
}
let returned_fork = buf.returned.forkattr;
let got_flags = returned_fork & ATTR_CMNEXT_EXT_FLAGS != 0;
let got_private = returned_fork & ATTR_CMNEXT_PRIVATESIZE != 0;
if !got_flags && !got_private {
return Sharing::unknown_for(size);
}
let ext_flags = buf.ext_flags;
let private_size = buf.private_size;
let shared = got_flags && ext_flags & (EF_MAY_SHARE_BLOCKS | EF_SHARES_ALL_BLOCKS) != 0;
let private_bytes = if got_private && private_size >= 0 {
(private_size as u64).min(size)
} else {
size
};
Sharing {
shared,
private_bytes,
}
}
#[cfg(target_os = "linux")]
fn fiemap_window_length(offset: u64) -> u64 {
u64::MAX - offset
}
#[cfg(target_os = "linux")]
fn extent_is_shared(fe_flags: u32) -> bool {
const FIEMAP_EXTENT_SHARED: u32 = 0x0000_2000;
fe_flags & FIEMAP_EXTENT_SHARED != 0
}
#[cfg(target_os = "linux")]
fn extent_is_last(fe_flags: u32) -> bool {
const FIEMAP_EXTENT_LAST: u32 = 0x0000_0001;
fe_flags & FIEMAP_EXTENT_LAST != 0
}
#[cfg(target_os = "linux")]
fn batch_count_is_valid(mapped: usize, capacity: usize) -> bool {
mapped <= capacity
}
#[cfg(target_os = "linux")]
fn batch_made_progress(offset: u64, batch_start: u64) -> bool {
offset > batch_start
}
#[cfg(target_os = "linux")]
fn extent_is_usable(fe_logical: u64, fe_length: u64, offset: u64) -> bool {
fe_length != 0 && fe_logical >= offset && fe_logical.checked_add(fe_length).is_some()
}
#[cfg(target_os = "linux")]
fn mapped_nothing(shared_bytes: u64, private_bytes: u64) -> bool {
shared_bytes == 0 && private_bytes == 0
}
#[cfg(target_os = "linux")]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Extent {
logical: u64,
length: u64,
flags: u32,
}
#[cfg(target_os = "linux")]
fn walk_extent_map<F>(size: u64, mut next_batch: F) -> Sharing
where
F: FnMut(u64) -> Option<Vec<Extent>>,
{
let mut shared_bytes: u64 = 0;
let mut private_bytes: u64 = 0;
let mut any_shared = false;
let mut offset: u64 = 0;
let mut complete = false;
for _ in 0..64 {
let Some(batch) = next_batch(offset) else {
return Sharing::unknown_for(size);
};
if batch.is_empty() {
complete = true;
break;
}
let batch_start = offset;
let mut last = false;
for ext in &batch {
if !extent_is_usable(ext.logical, ext.length, offset) {
return Sharing::unknown_for(size);
}
let Some(next_offset) = ext.logical.checked_add(ext.length) else {
return Sharing::unknown_for(size);
};
if extent_is_shared(ext.flags) {
let Some(total) = shared_bytes.checked_add(ext.length) else {
return Sharing::unknown_for(size);
};
shared_bytes = total;
any_shared = true;
} else {
let Some(total) = private_bytes.checked_add(ext.length) else {
return Sharing::unknown_for(size);
};
private_bytes = total;
}
offset = next_offset;
if extent_is_last(ext.flags) {
last = true;
}
}
if last {
complete = true;
break;
}
if !batch_made_progress(offset, batch_start) {
return Sharing::unknown_for(size);
}
}
fiemap_verdict(complete, any_shared, shared_bytes, private_bytes, size)
}
#[cfg(target_os = "linux")]
fn fiemap_verdict(
complete: bool,
any_shared: bool,
shared_bytes: u64,
private_bytes: u64,
size: u64,
) -> Sharing {
if !complete {
return Sharing::unknown_for(size);
}
if mapped_nothing(shared_bytes, private_bytes) {
return Sharing::unknown_for(size);
}
Sharing {
shared: any_shared,
private_bytes: private_bytes.min(size),
}
}
#[cfg(target_os = "linux")]
fn probe_linux(path: &Path, size: u64) -> Sharing {
use std::os::fd::AsRawFd;
const FIEMAP_MAX_EXTENTS: usize = 32;
const FIEMAP_FLAG_SYNC: u32 = 0x0000_0001;
const FS_IOC_FIEMAP: u32 = 0xc020_660b;
#[repr(C)]
#[derive(Default, Clone, Copy)]
struct FiemapExtent {
fe_logical: u64,
fe_physical: u64,
fe_length: u64,
fe_reserved64: [u64; 2],
fe_flags: u32,
fe_reserved: [u32; 3],
}
#[repr(C)]
struct Fiemap {
fm_start: u64,
fm_length: u64,
fm_flags: u32,
fm_mapped_extents: u32,
fm_extent_count: u32,
fm_reserved: u32,
fm_extents: [FiemapExtent; FIEMAP_MAX_EXTENTS],
}
if size == 0 {
return Sharing {
shared: false,
private_bytes: 0,
};
}
let Ok(file) = std::fs::File::open(path) else {
return Sharing::unknown_for(size);
};
walk_extent_map(size, |offset| {
let mut fm: Fiemap = unsafe { std::mem::zeroed() };
fm.fm_start = offset;
fm.fm_length = fiemap_window_length(offset);
fm.fm_flags = FIEMAP_FLAG_SYNC;
fm.fm_extent_count = FIEMAP_MAX_EXTENTS as u32;
let rc = unsafe {
libc::ioctl(
file.as_raw_fd(),
FS_IOC_FIEMAP as libc::Ioctl,
&mut fm as *mut Fiemap as *mut libc::c_void,
)
};
if rc != 0 {
return None;
}
let mapped = fm.fm_mapped_extents as usize;
if !batch_count_is_valid(mapped, FIEMAP_MAX_EXTENTS) {
return None;
}
Some(
fm.fm_extents
.iter()
.take(mapped)
.map(|ext| Extent {
logical: ext.fe_logical,
length: ext.fe_length,
flags: ext.fe_flags,
})
.collect(),
)
})
}
#[cfg(not(any(target_os = "macos", target_os = "linux")))]
fn probe_unsupported(_path: &Path, size: u64) -> Sharing {
Sharing::unknown_for(size)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn unknown_reports_everything_private_and_unshared() {
let s = Sharing::unknown_for(4096);
assert!(!s.shared);
assert_eq!(s.private_bytes, 4096);
}
#[test]
fn an_ordinary_private_file_is_not_reported_as_shared() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("plain.bin");
let bytes = vec![0xABu8; 256 * 1024];
std::fs::write(&path, &bytes).unwrap();
let s = probe(&path, bytes.len() as u64);
assert!(
!s.shared,
"a freshly written file shares nothing: {s:?} — a false positive here \
would make `clean` tell users their build outputs are already cached"
);
assert_eq!(
s.private_bytes,
bytes.len() as u64,
"all of an unshared file's bytes are reclaimable: {s:?}"
);
}
#[test]
fn a_missing_file_falls_back_instead_of_failing() {
let dir = tempfile::tempdir().unwrap();
let s = probe(&dir.path().join("does-not-exist"), 1234);
assert_eq!(s, Sharing::unknown_for(1234));
}
#[test]
fn an_empty_file_reclaims_nothing() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("empty.bin");
std::fs::write(&path, b"").unwrap();
let s = probe(&path, 0);
assert_eq!(s.private_bytes, 0, "an empty file frees no bytes: {s:?}");
}
#[cfg(target_os = "linux")]
#[test]
fn the_fiemap_window_runs_from_the_offset_to_the_end() {
assert_eq!(fiemap_window_length(0), u64::MAX);
assert_eq!(fiemap_window_length(4096), u64::MAX - 4096);
}
#[cfg(target_os = "linux")]
#[test]
fn extent_flags_are_read_bit_by_bit() {
const SHARED: u32 = 0x0000_2000;
const LAST: u32 = 0x0000_0001;
const OTHER: u32 = 0x0000_0800;
assert!(extent_is_shared(SHARED));
assert!(extent_is_shared(SHARED | LAST | OTHER));
assert!(!extent_is_shared(0));
assert!(
!extent_is_shared(OTHER | LAST),
"only the SHARED bit means shared — a false positive here reports \
private storage as already-cached"
);
assert!(extent_is_last(LAST));
assert!(extent_is_last(LAST | SHARED));
assert!(!extent_is_last(0));
assert!(!extent_is_last(SHARED | OTHER));
}
#[cfg(target_os = "linux")]
#[test]
fn an_empty_map_is_not_evidence_of_anything() {
assert!(mapped_nothing(0, 0));
assert!(!mapped_nothing(0, 4096), "private bytes were mapped");
assert!(!mapped_nothing(4096, 0), "shared bytes were mapped");
assert!(!mapped_nothing(4096, 4096));
}
#[cfg(unix)]
#[test]
fn a_sparse_file_reports_only_its_allocated_bytes_as_private() {
use std::io::{Seek, SeekFrom, Write};
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("sparse.bin");
let size = 64 * 1024 * 1024;
let mut f = std::fs::File::create(&path).unwrap();
f.seek(SeekFrom::Start(size - 4096)).unwrap();
f.write_all(&[0x7Eu8; 4096]).unwrap();
f.sync_all().unwrap();
drop(f);
let allocated = {
use std::os::unix::fs::MetadataExt;
std::fs::metadata(&path).unwrap().blocks() * 512
};
if allocated >= size {
eprintln!("skipping: {path:?} was not stored sparsely ({allocated} of {size})");
return;
}
let s = probe(&path, size);
assert!(
s.private_bytes < size,
"a hole is not reclaimable storage: {s:?} for a {size}-byte file \
holding {allocated} allocated bytes"
);
}
#[cfg(any(target_os = "macos", target_os = "linux"))]
#[test]
fn a_reflinked_copy_is_detected_as_shared_despite_nlink_1() {
use std::os::unix::fs::MetadataExt;
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("orig.bin");
let dst = dir.path().join("clone.bin");
let bytes = vec![0x5Au8; 8 * 1024 * 1024];
std::fs::write(&src, &bytes).unwrap();
if crate::link::try_reflink(&src, &dst).is_err() {
eprintln!("skipping: no reflink support on this filesystem");
return;
}
let size = bytes.len() as u64;
let meta = std::fs::metadata(&dst).unwrap();
assert_eq!(
meta.nlink(),
1,
"a clone is a distinct inode — this is precisely why nlink was the \
wrong signal (#602)"
);
let s = probe(&dst, size);
assert!(
s.shared,
"a reflinked clone must be detected as sharing storage: {s:?}"
);
assert!(
s.private_bytes < size,
"a fully shared clone must not claim to free its whole apparent size \
({} of {size} bytes reported private)",
s.private_bytes
);
}
#[cfg(target_os = "linux")]
#[test]
fn only_forward_progressing_extents_are_usable() {
assert!(extent_is_usable(0, 4096, 0), "a plain first extent");
assert!(extent_is_usable(8192, 4096, 4096), "a later extent");
assert!(
extent_is_usable(4096, 4096, 4096),
"starting exactly where the walk is, is progress"
);
assert!(
!extent_is_usable(4096, 0, 4096),
"a zero-length extent moves the offset nowhere"
);
assert!(
!extent_is_usable(0, 4096, 8192),
"an extent behind the walk would be re-counted"
);
assert!(
!extent_is_usable(u64::MAX, 1, 0),
"logical + length must not wrap"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_batch_may_fill_the_buffer_but_not_overrun_it() {
assert!(batch_count_is_valid(0, 32), "an empty batch is valid");
assert!(batch_count_is_valid(31, 32), "under capacity");
assert!(
batch_count_is_valid(32, 32),
"exactly full is the buffer being used, not an overrun"
);
assert!(
!batch_count_is_valid(33, 32),
"more extents than room is a reply we do not understand"
);
}
#[cfg(target_os = "linux")]
#[test]
fn a_batch_that_ends_where_it_began_is_not_progress() {
assert!(batch_made_progress(4096, 0), "advanced");
assert!(batch_made_progress(1, 0), "advanced by one byte");
assert!(
!batch_made_progress(4096, 4096),
"ending where it started would re-read the region"
);
assert!(
!batch_made_progress(0, 4096),
"going backwards is not progress"
);
}
#[cfg(target_os = "linux")]
#[test]
fn an_unfinished_or_empty_map_is_not_an_answer() {
assert_eq!(
fiemap_verdict(false, true, 4096, 4096, 8192),
Sharing::unknown_for(8192),
"an unfinished map must not be reported as authoritative"
);
assert_eq!(
fiemap_verdict(true, false, 0, 0, 8192),
Sharing::unknown_for(8192),
"an empty map is not evidence of anything"
);
assert_eq!(
fiemap_verdict(true, true, 4096, 4096, 8192),
Sharing {
shared: true,
private_bytes: 4096,
}
);
assert_eq!(
fiemap_verdict(true, false, 0, 8192, 5000).private_bytes,
5000,
"a reclaim estimate never exceeds the file's own size"
);
}
#[cfg(target_os = "linux")]
fn extent(logical: u64, length: u64, flags: u32) -> Extent {
Extent {
logical,
length,
flags,
}
}
#[cfg(target_os = "linux")]
#[test]
fn the_walk_reads_a_finished_map() {
let got = walk_extent_map(8192, |_| Some(vec![extent(0, 8192, 0x0001)]));
assert_eq!(
got,
Sharing {
shared: false,
private_bytes: 8192,
}
);
let got = walk_extent_map(8192, |_| {
Some(vec![extent(0, 4096, 0x2000), extent(4096, 4096, 0x0001)])
});
assert_eq!(
got,
Sharing {
shared: true,
private_bytes: 4096,
}
);
}
#[cfg(target_os = "linux")]
#[test]
fn the_walk_continues_across_batches() {
let mut calls = 0;
let got = walk_extent_map(8192, |offset| {
calls += 1;
match offset {
0 => Some(vec![extent(0, 4096, 0)]),
_ => Some(vec![extent(4096, 4096, 0x0001)]),
}
});
assert_eq!(
calls, 2,
"the first batch carried no LAST, so it asked again"
);
assert_eq!(
got,
Sharing {
shared: false,
private_bytes: 8192,
}
);
}
#[cfg(target_os = "linux")]
#[test]
fn an_unusable_reply_is_never_reported_as_an_answer() {
let unknown = Sharing::unknown_for(8192);
assert_eq!(
walk_extent_map(8192, |_| None),
unknown,
"the kernel could not be asked"
);
assert_eq!(
walk_extent_map(8192, |_| Some(vec![extent(0, 0, 0)])),
unknown,
"a zero-length extent makes no progress"
);
assert_eq!(
walk_extent_map(8192, |_| Some(vec![extent(u64::MAX, 1, 0)])),
unknown,
"logical + length wraps"
);
assert_eq!(
walk_extent_map(8192, |_| Some(vec![extent(0, 4096, 0)])),
unknown,
"a batch that does not advance is refused"
);
}
#[cfg(target_os = "linux")]
#[test]
fn running_out_of_batches_is_not_an_answer() {
let mut calls = 0;
let got = walk_extent_map(1 << 30, |offset| {
calls += 1;
Some(vec![extent(offset, 4096, 0)])
});
assert_eq!(calls, 64, "the cap bounds the walk");
assert_eq!(
got,
Sharing::unknown_for(1 << 30),
"a map that never ended must not be reported as authoritative"
);
}
#[cfg(target_os = "linux")]
#[test]
fn an_empty_batch_ends_the_walk() {
assert_eq!(
walk_extent_map(8192, |_| Some(Vec::new())),
Sharing::unknown_for(8192),
"nothing mapped is not evidence of anything"
);
}
}