1use std::io;
2use std::path::Path;
3
4#[cfg(any(target_os = "macos", target_os = "ios"))]
5use std::ffi::CString;
6#[cfg(any(target_os = "macos", target_os = "ios"))]
7use std::os::unix::ffi::OsStrExt;
8#[cfg(unix)]
9use std::os::unix::fs::MetadataExt;
10
11#[cfg(target_os = "linux")]
12use linux_raw_sys::ioctl::{
13 FIEMAP_EXTENT_DATA_INLINE, FIEMAP_EXTENT_DELALLOC, FIEMAP_EXTENT_ENCODED, FIEMAP_EXTENT_LAST,
14 FIEMAP_EXTENT_NOT_ALIGNED, FIEMAP_EXTENT_SHARED, FIEMAP_EXTENT_UNKNOWN, FS_IOC_FIEMAP,
15};
16#[cfg(any(target_os = "linux", target_os = "macos", target_os = "ios"))]
17use rustix::io::Errno;
18use thiserror::Error;
19
20#[derive(Debug, Error)]
22pub enum PhysicalSpaceError {
23 #[error("the filesystem does not support physical space accounting")]
25 UnsupportedFilesystem,
26 #[error(transparent)]
28 UnmeasurableFile(#[from] io::Error),
29}
30
31pub const fn supports_fine_grained_accounting() -> bool {
33 cfg!(any(
34 target_os = "linux",
35 target_os = "macos",
36 target_os = "ios"
37 ))
38}
39
40pub fn physical_space(
45 path: &Path,
46 metadata: &std::fs::Metadata,
47) -> Result<u64, PhysicalSpaceError> {
48 if !metadata.is_file() {
49 #[cfg(unix)]
50 {
51 return Ok(metadata.blocks().saturating_mul(512));
52 }
53
54 #[cfg(not(unix))]
55 {
56 return Ok(0);
57 }
58 }
59
60 #[cfg(unix)]
61 if metadata.nlink() > 1 {
62 return Ok(0);
63 }
64
65 #[cfg(any(target_os = "macos", target_os = "ios"))]
66 {
67 apple_physical_space(path)
68 }
69
70 #[cfg(target_os = "linux")]
71 {
72 linux_physical_space(path)
73 }
74
75 #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "ios")))]
76 {
77 let _ = path;
78 Err(PhysicalSpaceError::UnsupportedFilesystem)
79 }
80}
81
82#[cfg(any(target_os = "macos", target_os = "ios"))]
83#[expect(unsafe_code)]
84fn apple_physical_space(path: &Path) -> Result<u64, PhysicalSpaceError> {
85 let path = CString::new(path.as_os_str().as_bytes())
86 .map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error))?;
87 let mut attributes = libc::attrlist {
88 bitmapcount: libc::ATTR_BIT_MAP_COUNT,
89 reserved: 0,
90 commonattr: libc::ATTR_CMN_RETURNED_ATTRS,
91 volattr: 0,
92 dirattr: 0,
93 fileattr: 0,
94 forkattr: libc::ATTR_CMNEXT_PRIVATESIZE,
95 };
96 let mut response = [0_u8; 32];
97
98 let result = unsafe {
102 libc::getattrlist(
103 path.as_ptr(),
104 (&raw mut attributes).cast(),
105 response.as_mut_ptr().cast(),
106 response.len(),
107 libc::FSOPT_ATTR_CMN_EXTENDED,
108 )
109 };
110 if result != 0 {
111 let error = io::Error::last_os_error();
112 if Errno::from_io_error(&error) == Some(Errno::NOTSUP) {
113 return Err(PhysicalSpaceError::UnsupportedFilesystem);
114 }
115 return Err(error.into());
116 }
117
118 let returned_fork_attributes =
119 u32::from_ne_bytes(response[20..24].try_into().map_err(io::Error::other)?);
120 if returned_fork_attributes & libc::ATTR_CMNEXT_PRIVATESIZE == 0 {
121 return Err(PhysicalSpaceError::UnsupportedFilesystem);
122 }
123
124 let private_size = i64::from_ne_bytes(response[24..32].try_into().map_err(io::Error::other)?);
125 Ok(u64::try_from(private_size).map_err(io::Error::other)?)
126}
127
128#[cfg(target_os = "linux")]
129#[expect(unsafe_code)]
130fn linux_physical_space(path: &Path) -> Result<u64, PhysicalSpaceError> {
131 const MAX_EXTENTS: usize = 32;
132
133 #[derive(Default)]
134 #[repr(C)]
135 struct Fiemap {
136 start: u64,
137 length: u64,
138 flags: u32,
139 mapped_extents: u32,
140 extent_count: u32,
141 reserved: u32,
142 }
143
144 #[derive(Clone, Copy, Default)]
145 #[repr(C)]
146 struct FiemapExtent {
147 logical: u64,
148 physical: u64,
149 length: u64,
150 reserved64: [u64; 2],
151 flags: u32,
152 reserved: [u32; 3],
153 }
154
155 #[derive(Default)]
156 #[repr(C)]
157 struct FiemapBuffer {
158 header: Fiemap,
159 extents: [FiemapExtent; MAX_EXTENTS],
160 }
161
162 let file = fs_err::File::open(path)?;
163 let mut physical = 0_u64;
164 let mut start = 0_u64;
165
166 loop {
167 let mut request = FiemapBuffer {
168 header: Fiemap {
169 start,
170 length: u64::MAX.saturating_sub(start),
171 extent_count: u32::try_from(MAX_EXTENTS).map_err(io::Error::other)?,
172 ..Fiemap::default()
173 },
174 ..FiemapBuffer::default()
175 };
176
177 unsafe {
180 rustix::ioctl::ioctl(
181 &file,
182 rustix::ioctl::Updater::<{ FS_IOC_FIEMAP as rustix::ioctl::Opcode }, _>::new(
183 &mut request,
184 ),
185 )
186 }
187 .map_err(|error| match error {
188 Errno::NOTSUP | Errno::NOTTY => PhysicalSpaceError::UnsupportedFilesystem,
190 error => PhysicalSpaceError::UnmeasurableFile(error.into()),
191 })?;
192
193 let mapped_extents =
194 usize::try_from(request.header.mapped_extents).map_err(io::Error::other)?;
195 if mapped_extents > request.extents.len() {
196 return Err(io::Error::new(
197 io::ErrorKind::InvalidData,
198 "the filesystem returned more extents than requested",
199 )
200 .into());
201 }
202 if mapped_extents == 0 {
203 return Ok(physical);
204 }
205
206 for extent in &request.extents[..mapped_extents] {
207 if extent.flags
208 & (FIEMAP_EXTENT_DELALLOC | FIEMAP_EXTENT_DATA_INLINE | FIEMAP_EXTENT_SHARED)
209 != 0
210 {
211 continue;
212 }
213
214 if extent.flags
215 & (FIEMAP_EXTENT_UNKNOWN | FIEMAP_EXTENT_ENCODED | FIEMAP_EXTENT_NOT_ALIGNED)
216 != 0
217 {
218 return Err(io::Error::new(
219 io::ErrorKind::Unsupported,
220 "the filesystem cannot report the physical size of an extent",
221 )
222 .into());
223 }
224
225 physical = physical.saturating_add(extent.length);
226 }
227
228 let last_extent = &request.extents[mapped_extents - 1];
229 if last_extent.flags & FIEMAP_EXTENT_LAST != 0 {
230 return Ok(physical);
231 }
232
233 let next = last_extent.logical.saturating_add(last_extent.length);
234 if next <= start {
235 return Err(io::Error::new(
236 io::ErrorKind::InvalidData,
237 "the filesystem returned a non-advancing extent",
238 )
239 .into());
240 }
241 start = next;
242 }
243}