Skip to main content

uv_fs/
space.rs

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/// An error encountered while measuring a file's physical storage.
21#[derive(Debug, Error)]
22pub enum PhysicalSpaceError {
23    /// The filesystem cannot report exclusively owned physical storage.
24    #[error("the filesystem does not support physical space accounting")]
25    UnsupportedFilesystem,
26    /// The file's physical storage could not be measured.
27    #[error(transparent)]
28    UnmeasurableFile(#[from] io::Error),
29}
30
31/// Return whether the current platform supports fine-grained space accounting.
32pub 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
40/// Return the physical file data that would be reclaimed by deleting `path`.
41///
42/// The result excludes data retained by another hardlink, copy-on-write clone, or snapshot.
43/// Filesystem metadata is not included.
44pub 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    // SAFETY: `path` is null-terminated and valid for the duration of the call. `attributes` is a
99    // valid attribute request, and `response` has enough space for the length, returned attribute
100    // set, and requested private-size value.
101    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        // SAFETY: `FS_IOC_FIEMAP` is the Linux fiemap ioctl opcode, and `request` begins with the
178        // expected fiemap header followed by enough initialized storage for all requested extents.
179        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            // Linux returns `ENOTTY` when the ioctl is unsupported for this file.
189            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}