Skip to main content

coven_foundation/files/
open_files.rs

1//! Which files under a directory this process still holds open.
2//!
3//! A closed store must hold no file in its directory. POSIX lets a directory
4//! with open files be deleted, so a leftover handle goes unseen on macOS and
5//! Linux and fails the delete only on Windows. Listing the process's open file
6//! descriptors, or handles on Windows, catches it on every platform.
7
8use std::path::{Path, PathBuf};
9
10/// Every file under `dir` this process has open, once per open descriptor or
11/// handle. Directories are not files: a handle on one, the kind resolving a
12/// path takes for a moment on Windows, holds no file open.
13/// Panics if the directory cannot be resolved or enumeration fails. Descriptors
14/// closed while being inspected may disappear from the result.
15pub fn open_files_under(dir: &Path) -> Vec<PathBuf> {
16    let dir = dir
17        .canonicalize()
18        .unwrap_or_else(|error| panic!("canonicalize {}: {error}", dir.display()));
19    #[cfg(not(windows))]
20    let open = open_file_paths().into_iter().filter(|path| path.starts_with(&dir)).collect();
21    #[cfg(windows)]
22    let open = open_file_paths_under(&dir);
23    open
24}
25
26/// Panic naming every file under `dir` this process has open.
27pub fn assert_no_open_files_under(dir: &Path) {
28    let open = open_files_under(dir);
29    assert!(open.is_empty(), "files still open under {}: {open:#?}", dir.display());
30}
31
32#[cfg(any(target_os = "macos", target_os = "ios"))]
33fn open_file_paths() -> Vec<PathBuf> {
34    use std::os::fd::BorrowedFd;
35    use std::os::unix::ffi::OsStringExt;
36
37    let descriptors: Vec<std::os::fd::RawFd> = std::fs::read_dir("/dev/fd")
38        .expect("list /dev/fd")
39        .map(|entry| {
40            entry
41                .expect("read /dev/fd entry")
42                .file_name()
43                .to_str()
44                .expect("descriptor name is UTF-8")
45                .parse()
46                .expect("descriptor name is a number")
47        })
48        .collect();
49    descriptors
50        .into_iter()
51        .filter_map(|fd| {
52            // SAFETY: the descriptor is only asked for its type and path. One
53            // closed since the listing (the listing's own among them) fails the
54            // call.
55            let fd = unsafe { BorrowedFd::borrow_raw(fd) };
56            let stat = match rustix::fs::fstat(fd) {
57                Ok(stat) => stat,
58                Err(rustix::io::Errno::BADF) => {
59                    tracing::debug!(?fd, "descriptor closed during open-file enumeration");
60                    return None;
61                }
62                Err(error) => panic!("inspect descriptor {fd:?}: {error}"),
63            };
64            if rustix::fs::FileType::from_raw_mode(stat.st_mode) == rustix::fs::FileType::Directory
65            {
66                return None;
67            }
68            let path = match rustix::fs::getpath(fd) {
69                Ok(path) => path,
70                Err(rustix::io::Errno::BADF | rustix::io::Errno::NOENT) => {
71                    // Closed descriptors and anonymous pipes, sockets or
72                    // event descriptors have no filesystem path. Named pipes do.
73                    tracing::debug!(?fd, "descriptor has no current file path");
74                    return None;
75                }
76                Err(error) => panic!("resolve descriptor {fd:?}: {error}"),
77            };
78            Some(PathBuf::from(std::ffi::OsString::from_vec(path.into_bytes())))
79        })
80        .collect()
81}
82
83#[cfg(any(target_os = "linux", target_os = "android"))]
84fn open_file_paths() -> Vec<PathBuf> {
85    std::fs::read_dir("/proc/self/fd")
86        .expect("list /proc/self/fd")
87        .filter_map(|entry| {
88            let link = entry.expect("read /proc/self/fd entry").path();
89            // The link's own metadata is the descriptor's, followed through.
90            let metadata = match std::fs::metadata(&link) {
91                Ok(metadata) => metadata,
92                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
93                    tracing::debug!(path = %link.display(), "descriptor closed during open-file enumeration");
94                    return None;
95                }
96                Err(error) => panic!("inspect descriptor {}: {error}", link.display()),
97            };
98            if metadata.is_dir() {
99                return None;
100            }
101            match std::fs::read_link(&link) {
102                Ok(path) => Some(path),
103                Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
104                    tracing::debug!(path = %link.display(), "descriptor closed during open-file enumeration");
105                    None
106                }
107                Err(error) => panic!("resolve descriptor {}: {error}", link.display()),
108            }
109        })
110        .collect()
111}
112
113/// The disk files under `dir` this process holds open for their data, read
114/// from one snapshot of its handles that the kernel takes at once.
115///
116/// Paths are named after the snapshot, one handle at a time, and a handle
117/// value freed meanwhile can be taken by a new handle. So only values that
118/// were file handles in the snapshot are named: a file opened since then under
119/// a value that held something else is never counted beside the one it
120/// replaced, and the list never names more files than were open at the
121/// snapshot. A handle opened only to read attributes, as `std::fs::metadata`
122/// and `canonicalize` do here and no Unix descriptor stands for, holds no file
123/// open, and neither does one on a directory.
124///
125/// A value can also be freed and taken again part-way through reading it, on
126/// any thread: the reads after that are of the new object, a pipe or a socket
127/// as often as a file, and fail as that object does. So a read that fails
128/// with the handle still open is made again from the start: a failure the
129/// value's own object gives repeats, and is a failure of the enumeration.
130///
131/// Handles are named by their NT device path (`\Device\HarddiskVolume3\...`),
132/// which needs no drive-letter lookup; CI runners hold handles whose drive-letter
133/// name is refused. The directory gets its NT path the same way, and matches
134/// are reported under `dir`'s own spelling.
135#[cfg(windows)]
136fn open_file_paths_under(dir: &Path) -> Vec<PathBuf> {
137    use windows_sys::Win32::Storage::FileSystem::{
138        FILE_APPEND_DATA, FILE_READ_DATA, FILE_WRITE_DATA,
139    };
140
141    const DATA_ACCESS: u32 = FILE_READ_DATA | FILE_WRITE_DATA | FILE_APPEND_DATA;
142    let nt_dir = nt_directory_name(dir);
143    let file_type = windows_handles::file_object_type();
144    let mut name = vec![0u16; 32_768];
145    windows_handles::snapshot()
146        .into_iter()
147        .filter(|entry| {
148            entry.object_type_index == file_type && entry.granted_access & DATA_ACCESS != 0
149        })
150        .filter_map(|entry| {
151            let handle = entry.handle_value;
152            let read = |name: &mut [u16]| match file_under(handle, dir, &nt_dir, name) {
153                Err(unread) if unread.closed() => {
154                    tracing::debug!(
155                        ?handle,
156                        operation = unread.operation,
157                        "handle closed during open-file enumeration"
158                    );
159                    Ok(None)
160                }
161                read => read,
162            };
163            match read(&mut name).or_else(|first| read(&mut name).map_err(|again| (first, again))) {
164                Ok(path) => path,
165                Err((first, again)) => panic!(
166                    "{} {handle:?} (granted access {:#x}): {}; read again, {}: {}",
167                    first.operation,
168                    entry.granted_access,
169                    first.error,
170                    again.operation,
171                    again.error
172                ),
173            }
174        })
175        .collect()
176}
177
178/// A read of a handle that failed: which read, and how.
179#[cfg(windows)]
180struct UnreadHandle {
181    operation: &'static str,
182    error: std::io::Error,
183}
184
185#[cfg(windows)]
186impl UnreadHandle {
187    fn last(operation: &'static str) -> Self {
188        Self { operation, error: std::io::Error::last_os_error() }
189    }
190
191    /// The handle was closed before the read.
192    fn closed(&self) -> bool {
193        self.error.raw_os_error()
194            == Some(windows_sys::Win32::Foundation::ERROR_INVALID_HANDLE as i32)
195    }
196}
197
198/// The disk file under `dir`, whose NT path is `nt_dir`, that `handle` holds
199/// open, or None for a handle on anything else.
200#[cfg(windows)]
201fn file_under(
202    handle: windows_sys::Win32::Foundation::HANDLE,
203    dir: &Path,
204    nt_dir: &str,
205    name: &mut [u16],
206) -> Result<Option<PathBuf>, UnreadHandle> {
207    use windows_sys::Win32::Foundation::{ERROR_ACCESS_DENIED, NO_ERROR};
208    use windows_sys::Win32::Storage::FileSystem::{
209        GetFileInformationByHandle, GetFileType, BY_HANDLE_FILE_INFORMATION,
210        FILE_ATTRIBUTE_DIRECTORY, FILE_TYPE_DISK, FILE_TYPE_UNKNOWN,
211    };
212
213    // SAFETY: none of these reads or writes through the handle or closes it;
214    // one closed since the snapshot only fails them.
215    unsafe {
216        let kind = GetFileType(handle);
217        if kind == FILE_TYPE_UNKNOWN {
218            let unread = UnreadHandle::last("inspect file type");
219            // UNKNOWN with NO_ERROR is a successful report of an unknown
220            // type, which is not a disk file to enumerate.
221            if unread.error.raw_os_error() == Some(NO_ERROR as i32) {
222                return Ok(None);
223            }
224            return Err(unread);
225        }
226        if kind != FILE_TYPE_DISK {
227            return Ok(None);
228        }
229        let nt_name = match nt_handle_name(handle, name) {
230            Ok(nt_name) => nt_name,
231            Err(error) => {
232                // A file deleted while open has no name left under any
233                // directory, so it is skipped.
234                if error.raw_os_error() == Some(ERROR_ACCESS_DENIED as i32)
235                    && delete_pending(handle)
236                {
237                    tracing::debug!(?handle, "skipping a deleted file still open");
238                    return Ok(None);
239                }
240                return Err(UnreadHandle { operation: "resolve file handle", error });
241            }
242        };
243        let Some(suffix) = nt_suffix_under(&nt_name, nt_dir) else {
244            return Ok(None);
245        };
246        let path = dir.join(suffix);
247        let mut information: BY_HANDLE_FILE_INFORMATION = std::mem::zeroed();
248        if GetFileInformationByHandle(handle, &mut information) != 0 {
249            if information.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY != 0 {
250                return Ok(None);
251            }
252        } else {
253            let unread = UnreadHandle::last("inspect file handle");
254            // A handle granted data access without FILE_READ_ATTRIBUTES
255            // can't report its attributes; its path can.
256            if unread.error.raw_os_error() != Some(ERROR_ACCESS_DENIED as i32) {
257                return Err(unread);
258            }
259            if std::fs::metadata(&path).is_ok_and(|metadata| metadata.is_dir()) {
260                return Ok(None);
261            }
262        }
263        Ok(Some(path))
264    }
265}
266
267/// The NT device path of an open handle: normalized when the system allows it,
268/// otherwise the name the handle was opened with.
269#[cfg(windows)]
270fn nt_handle_name(
271    handle: windows_sys::Win32::Foundation::HANDLE,
272    buffer: &mut [u16],
273) -> std::io::Result<String> {
274    use windows_sys::Win32::Foundation::ERROR_ACCESS_DENIED;
275    use windows_sys::Win32::Storage::FileSystem::{
276        GetFinalPathNameByHandleW, FILE_NAME_NORMALIZED, FILE_NAME_OPENED, VOLUME_NAME_NT,
277    };
278
279    let mut last = None;
280    for flags in [VOLUME_NAME_NT | FILE_NAME_NORMALIZED, VOLUME_NAME_NT | FILE_NAME_OPENED] {
281        // SAFETY: the call writes at most `buffer.len()` units into `buffer`
282        // and only reads the handle's name; a closed handle only fails it.
283        let length = unsafe {
284            GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
285        } as usize;
286        if length != 0 {
287            assert!(length < buffer.len(), "open file path exceeds its buffer");
288            return Ok(String::from_utf16_lossy(&buffer[..length]));
289        }
290        let error = std::io::Error::last_os_error();
291        if error.raw_os_error() != Some(ERROR_ACCESS_DENIED as i32) {
292            return Err(error);
293        }
294        tracing::debug!(?handle, flags, "file name refused; trying the opened name");
295        last = Some(error);
296    }
297    Err(last.expect("at least one naming attempt ran"))
298}
299
300/// The NT device path of `dir`, through a handle that opens no file data.
301#[cfg(windows)]
302fn nt_directory_name(dir: &Path) -> String {
303    use std::os::windows::fs::OpenOptionsExt;
304    use std::os::windows::io::AsRawHandle;
305    use windows_sys::Win32::Storage::FileSystem::FILE_FLAG_BACKUP_SEMANTICS;
306
307    let directory = std::fs::OpenOptions::new()
308        .access_mode(0)
309        .custom_flags(FILE_FLAG_BACKUP_SEMANTICS)
310        .open(dir)
311        .unwrap_or_else(|error| panic!("open {}: {error}", dir.display()));
312    let mut buffer = vec![0u16; 32_768];
313    nt_handle_name(directory.as_raw_handle() as _, &mut buffer)
314        .unwrap_or_else(|error| panic!("resolve {}: {error}", dir.display()))
315}
316
317/// The part of `nt_name` below `nt_dir`, compared without case as Windows
318/// names are; None for a name outside it.
319#[cfg(windows)]
320fn nt_suffix_under(nt_name: &str, nt_dir: &str) -> Option<PathBuf> {
321    let dir = nt_dir.trim_end_matches('\\');
322    let head = nt_name.get(..dir.len())?;
323    let rest = &nt_name[dir.len()..];
324    (head.eq_ignore_ascii_case(dir) && rest.starts_with('\\')).then(|| PathBuf::from(&rest[1..]))
325}
326
327/// Whether the file behind `handle` is deleted and waiting for its last
328/// handle to close. A failed query answers no, so the caller stays loud.
329#[cfg(windows)]
330fn delete_pending(handle: windows_sys::Win32::Foundation::HANDLE) -> bool {
331    use windows_sys::Win32::Storage::FileSystem::{
332        FileStandardInfo, GetFileInformationByHandleEx, FILE_STANDARD_INFO,
333    };
334    // SAFETY: the call only reads the handle's standard information into a
335    // buffer of the size it is given; a closed handle only fails it.
336    unsafe {
337        let mut information: FILE_STANDARD_INFO = std::mem::zeroed();
338        GetFileInformationByHandleEx(
339            handle,
340            FileStandardInfo,
341            (&mut information as *mut FILE_STANDARD_INFO).cast(),
342            std::mem::size_of::<FILE_STANDARD_INFO>() as u32,
343        ) != 0
344            && information.DeletePending
345    }
346}
347
348#[cfg(windows)]
349mod windows_handles {
350    use windows_sys::Wdk::System::Threading::{
351        NtQueryInformationProcess, ProcessHandleInformation,
352    };
353    use windows_sys::Win32::Foundation::{HANDLE, STATUS_INFO_LENGTH_MISMATCH};
354    use windows_sys::Win32::System::Threading::GetCurrentProcess;
355
356    /// `PROCESS_HANDLE_TABLE_ENTRY_INFO`, one handle of a snapshot.
357    #[repr(C)]
358    #[derive(Clone, Copy)]
359    pub(super) struct HandleEntry {
360        pub(super) handle_value: HANDLE,
361        handle_count: usize,
362        pointer_count: usize,
363        pub(super) granted_access: u32,
364        pub(super) object_type_index: u32,
365        handle_attributes: u32,
366        reserved: u32,
367    }
368
369    /// Every handle of this process at one instant.
370    pub(super) fn snapshot() -> Vec<HandleEntry> {
371        // `PROCESS_HANDLE_SNAPSHOT_INFORMATION`: a handle count and a reserved
372        // word, then the entries. Grown until the snapshot fits; usize words
373        // keep the entries aligned.
374        let header = 2 * std::mem::size_of::<usize>();
375        let mut buffer: Vec<usize> = vec![0; 4096];
376        loop {
377            let bytes = std::mem::size_of_val(buffer.as_slice()) as u32;
378            let mut needed = 0u32;
379            // SAFETY: the pseudo-handle for this process needs no closing, and
380            // the call writes at most `bytes` into the buffer it is given.
381            let status = unsafe {
382                NtQueryInformationProcess(
383                    GetCurrentProcess(),
384                    ProcessHandleInformation,
385                    buffer.as_mut_ptr().cast(),
386                    bytes,
387                    &mut needed,
388                )
389            };
390            if status == STATUS_INFO_LENGTH_MISMATCH {
391                let words =
392                    (needed as usize).max(bytes as usize * 2) / std::mem::size_of::<usize>() + 1;
393                buffer = vec![0; words];
394                continue;
395            }
396            assert!(status >= 0, "snapshot this process's handles: status {status:#x}");
397            break;
398        }
399        let count = buffer[0];
400        // SAFETY: the snapshot holds `count` entries right after its header,
401        // inside the buffer the call filled.
402        unsafe {
403            std::slice::from_raw_parts(
404                buffer.as_ptr().cast::<u8>().add(header).cast::<HandleEntry>(),
405                count,
406            )
407        }
408        .to_vec()
409    }
410
411    /// The kernel's type number for file objects, read once off a file this
412    /// opens for the purpose: it names the type in every later snapshot.
413    pub(super) fn file_object_type() -> u32 {
414        use std::os::windows::io::AsRawHandle;
415        static FILE_TYPE: std::sync::OnceLock<u32> = std::sync::OnceLock::new();
416        *FILE_TYPE.get_or_init(|| {
417            let probe = std::fs::File::open(std::env::current_exe().expect("this executable"))
418                .expect("open this executable");
419            let value = probe.as_raw_handle() as HANDLE;
420            snapshot()
421                .into_iter()
422                .find(|entry| entry.handle_value == value)
423                .expect("an open file is in its process's snapshot")
424                .object_type_index
425        })
426    }
427}
428
429#[cfg(test)]
430#[path = "open_files_tests.rs"]
431mod tests;