fslock_guard/lib.rs
1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![doc = include_str!("../README.md")]
3// @@ begin lint list maintained by maint/add_warning @@
4#![allow(renamed_and_removed_lints)] // @@REMOVE_WHEN(ci_arti_stable)
5#![allow(unknown_lints)] // @@REMOVE_WHEN(ci_arti_nightly)
6#![warn(missing_docs)]
7#![warn(noop_method_call)]
8#![warn(unreachable_pub)]
9#![warn(clippy::all)]
10#![deny(clippy::await_holding_lock)]
11#![deny(clippy::cargo_common_metadata)]
12#![deny(clippy::cast_lossless)]
13#![deny(clippy::checked_conversions)]
14#![warn(clippy::cognitive_complexity)]
15#![deny(clippy::debug_assert_with_mut_call)]
16#![deny(clippy::exhaustive_enums)]
17#![deny(clippy::exhaustive_structs)]
18#![deny(clippy::expl_impl_clone_on_copy)]
19#![deny(clippy::fallible_impl_from)]
20#![deny(clippy::implicit_clone)]
21#![deny(clippy::large_stack_arrays)]
22#![warn(clippy::manual_ok_or)]
23#![deny(clippy::missing_docs_in_private_items)]
24#![warn(clippy::needless_borrow)]
25#![warn(clippy::needless_pass_by_value)]
26#![warn(clippy::option_option)]
27#![deny(clippy::print_stderr)]
28#![deny(clippy::print_stdout)]
29#![warn(clippy::rc_buffer)]
30#![deny(clippy::ref_option_ref)]
31#![warn(clippy::semicolon_if_nothing_returned)]
32#![warn(clippy::trait_duplication_in_bounds)]
33#![deny(clippy::unchecked_time_subtraction)]
34#![deny(clippy::unnecessary_wraps)]
35#![warn(clippy::unseparated_literal_suffix)]
36#![deny(clippy::unwrap_used)]
37#![deny(clippy::mod_module_files)]
38#![allow(clippy::let_unit_value)] // This can reasonably be done for explicitness
39#![allow(clippy::uninlined_format_args)]
40#![allow(clippy::significant_drop_in_scrutinee)] // arti/-/merge_requests/588/#note_2812945
41#![allow(clippy::result_large_err)] // temporary workaround for arti#587
42#![allow(clippy::needless_raw_string_hashes)] // complained-about code is fine, often best
43#![allow(clippy::needless_lifetimes)] // See arti#1765
44#![allow(mismatched_lifetime_syntaxes)] // temporary workaround for arti#2060
45#![allow(clippy::collapsible_if)] // See arti#2342
46#![deny(clippy::unused_async)]
47#![deny(clippy::string_slice)] // See arti#2571
48//! <!-- @@ end lint list maintained by maint/add_warning @@ -->
49
50use std::{fs, path::Path};
51
52/// A lock-file for which we hold the lock.
53///
54/// So long as this object exists, we hold the lock on this file.
55/// When it is dropped, we will release the lock.
56///
57/// # Semantics
58///
59/// * Only one `LockFileGuard` can exist at one time
60/// for any particular `path`.
61/// * This applies across all tasks and threads in all programs;
62/// other acquisitions of the lock in the same process are prevented.
63/// * This applies across even separate machines, if `path` is on a shared filesystem.
64///
65/// # Restrictions
66///
67/// * **`path` must only be deleted (or renamed) via the APIs in this module**
68/// * This restriction applies to all programs on the computer,
69/// so for example automatic file cleaning with `find` and `rm` is forbidden.
70/// * Cross-filesystem locking is broken on Linux before 2.6.12.
71#[derive(Debug)]
72pub struct LockFileGuard {
73 /// A [`File`](fs::File) with its exclusive lock held.
74 ///
75 /// This `File` instance will remain locked for as long as this
76 /// LockFileGuard exists.
77 locked_file: fs::File,
78}
79
80impl LockFileGuard {
81 /// Try to open `path` with options suitable for using it as a lockfile,
82 /// creating it as necessary.
83 fn open<P>(path: P) -> Result<fs::File, std::io::Error>
84 where
85 P: AsRef<Path>,
86 {
87 fs::OpenOptions::new()
88 .read(true)
89 .write(true)
90 .create(true)
91 .truncate(false)
92 .open(&path)
93 }
94
95 /// Try to construct a new [`LockFileGuard`] representing a lock we hold on
96 /// the file `path`.
97 ///
98 /// Blocks until we can get the lock.
99 pub fn lock<P>(path: P) -> Result<Self, std::io::Error>
100 where
101 P: AsRef<Path>,
102 {
103 let path = path.as_ref();
104 loop {
105 let file = Self::open(path)?;
106 do_lock(&file)?;
107
108 if os::lockfile_has_path(&file, path)? {
109 return Ok(Self { locked_file: file });
110 }
111 }
112 }
113
114 /// Try to construct a new [`LockFileGuard`] representing a lock we hold on
115 /// the file `path`.
116 ///
117 /// Does not block; returns Ok(None) if somebody else holds the lock.
118 pub fn try_lock<P>(path: P) -> Result<Option<Self>, std::io::Error>
119 where
120 P: AsRef<Path>,
121 {
122 let path = path.as_ref();
123 let file = Self::open(path)?;
124 match do_try_lock(&file) {
125 Ok(()) => {
126 if os::lockfile_has_path(&file, path)? {
127 Ok(Some(Self { locked_file: file }))
128 } else {
129 Ok(None)
130 }
131 }
132 Err(fs::TryLockError::WouldBlock) => Ok(None),
133 Err(fs::TryLockError::Error(e)) => Err(e),
134 }
135 }
136
137 /// Try to delete the lock file that we hold.
138 ///
139 /// The provided `path` must be the same as was passed to `lock`.
140 pub fn delete_lock_file<P>(self, path: P) -> Result<(), std::io::Error>
141 where
142 P: AsRef<Path>,
143 {
144 let path = path.as_ref();
145 if os::lockfile_has_path(&self.locked_file, path)? {
146 std::fs::remove_file(path)
147 } else {
148 Err(std::io::Error::other(MismatchedPathError {}))
149 }
150 }
151}
152
153/// Try to lock `f`, blocking if need be.
154///
155/// On non-android, this just calls [`fs::File::lock`].
156#[cfg(not(target_os = "android"))]
157fn do_lock(f: &fs::File) -> std::io::Result<()> {
158 f.lock()
159}
160
161/// Try to lock `f`, without blocking.
162///
163/// On non-android, this just calls [`fs::File::try_lock`].
164#[cfg(not(target_os = "android"))]
165fn do_try_lock(f: &fs::File) -> Result<(), std::fs::TryLockError> {
166 f.try_lock()
167}
168
169/// Try to lock `f`, blocking if need be.
170///
171/// On android, we need to use flock manually, since Rust (as of May 2026)
172/// always returns "not implemented" for `lock()` and `try_lock()`.
173///
174/// See <https://github.com/rust-lang/rust/issues/148325>.
175/// Apparently,
176/// although there are filesystems (specifically FUSE filesystems)
177/// where flock won't work, it will correctly report ENOSYS
178/// on those filesystems.
179//
180// TODO MSRV ????: we can remove this once Rust supports file locking on Android
181// at our MSRV. As of May 2026, https://github.com/rust-lang/rust/pull/157038/
182// seems like the likeliest MR for that, but it has not been merged.
183#[cfg(target_os = "android")]
184fn do_lock(f: &fs::File) -> std::io::Result<()> {
185 use std::os::fd::AsRawFd;
186
187 let fd = f.as_raw_fd();
188 // SAFETY: Since `f` is a file, it has a valid fd.
189 let success = unsafe { libc::flock(fd, libc::LOCK_EX) } == 0;
190
191 if success {
192 Ok(())
193 } else {
194 Err(std::io::Error::last_os_error())
195 }
196}
197
198/// Try to lock `f`, without blocking.
199///
200/// On android, we need to use flock manually, since Rust (as of May 2026)
201/// always returns "not implemented" for `lock()` and `try_lock()`.
202///
203/// See <https://github.com/rust-lang/rust/issues/148325>.
204/// Apparently,
205/// although there are filesystems (specifically FUSE filesystems)
206/// where flock won't work, it will correctly report ENOSYS
207/// on those filesystems.
208//
209// TODO MSRV ????: See 'TODO MSRV' on do_lock above.
210#[cfg(target_os = "android")]
211fn do_try_lock(f: &fs::File) -> Result<(), std::fs::TryLockError> {
212 use std::os::fd::AsRawFd;
213
214 let fd = f.as_raw_fd();
215 // SAFETY: Since `f` is a file, it has a valid fd.
216 let success = unsafe { libc::flock(fd, libc::LOCK_EX | libc::LOCK_NB) } == 0;
217
218 if success {
219 Ok(())
220 } else {
221 let err = std::io::Error::last_os_error();
222 if err.kind() == std::io::ErrorKind::WouldBlock {
223 Err(std::fs::TryLockError::WouldBlock)
224 } else {
225 Err(std::fs::TryLockError::Error(err))
226 }
227 }
228}
229
230/// An error that we return when the path given to `delete_lock_file` does not
231/// match the file we have.
232///
233/// Since we wrap this in an `io::Error`, it doesn't need to be public or fancy.
234#[derive(thiserror::Error, Debug, Clone)]
235#[error("Called delete_lock_file with a mismatched path.")]
236struct MismatchedPathError {}
237
238/// Platform module for locking protocol on Unix.
239///
240/// ### Locking protocol on Unix
241///
242/// The lock is held by an open-file iff:
243///
244/// * that open-file holds an `flock` `LOCK_EX` lock; and
245/// * the directory entry for `path` refers to the same file as the open-file
246///
247/// `path` may only refer to a plain file, or `ENOENT`.
248/// If `path` refers to a file,
249/// only the lockholder may cause it to no longer refer to that file.
250///
251/// In principle the open-file might be shared with subprocesses.
252/// Even a naive program can safely and correctly inherit and hold the lock,
253/// since the lockholder only needs to not close an fd.
254/// However uncontrolled leaking of the fd into other processes is undesirable,
255/// as it might cause delays or even deadlocks, if those processes' inheritors live too long.
256/// In our Rust implementation we don't support sharing the held lock
257/// with subprocesses or different process images (ie across exec);
258/// we use `O_CLOEXEC`.
259///
260/// #### Locking algorithm
261///
262/// 1. open the file with `O_CREAT|O_RDWR`
263/// 2. `flock LOCK_EX`
264/// 3. `fstat` the open-file and `lstat` the path
265/// 4. If the inode and device numbers don't match,
266/// close the fd and go back to the start.
267/// 5. Now we hold the lock.
268///
269/// Proof sketch:
270///
271/// If we get to point 5, we see that at point 3, we had the lock.
272/// No-one else could cause the conditions to become false
273/// in the meantime:
274/// no-one else ~~can~~ may make `path` refer to a different file
275/// since they don't hold the lock.
276/// And, no-one else can `flock` it since the kernel prevents
277/// a conflicting lock.
278/// So at step 5 we must still hold the lock.
279///
280/// #### Unlocking algorithm
281///
282/// 1. Close the fd.
283/// 2. Now we no longer hold the lock and others can acquire it.
284///
285/// This drops the open-file and
286/// leaves the lock available for another caller.
287///
288/// #### Deletion algorithm
289///
290/// 0. The lock must already be held
291/// 1. `unlink` the file
292/// 2. close the fd
293/// 3. Now we no longer hold the lock and others can acquire it.
294///
295/// Step 1 atomically falsifies the lock-holding condition.
296/// We are allowed to perform it because we hold the lock.
297///
298/// Concurrent lockers might open the old file,
299/// which we are about to delete.
300/// They will acquire their `flock` (locking step 2)
301/// after we close (deletion step 2)
302/// and then see that they have a stale file.
303#[cfg(unix)]
304mod os {
305 use std::{fs::File, os::unix::fs::MetadataExt as _, path::Path};
306
307 /// Return true if `lf` currently exists with the given `path`, and false otherwise.
308 pub(crate) fn lockfile_has_path(lf: &File, path: &Path) -> std::io::Result<bool> {
309 let m1 = std::fs::metadata(path)?;
310 let m2 = lf.metadata()?;
311
312 Ok(m1.ino() == m2.ino() && m1.dev() == m2.dev())
313 }
314}
315
316/// Platform module for locking protocol on Windows.
317///
318/// The argument for correctness on Windows proceeds as for Unix, but with a
319/// higher degree of uncertainty, since we are not sufficient Windows experts to
320/// determine if our assumptions hold.
321///
322/// Here we assume as follows:
323/// * When `File::open` calls `CreateFileW`, it gets a `HANDLE` to an open file.
324/// As we use them, the `HANDLE` behaves
325/// similarly to the "fd" in the Unix argument above,
326/// and the open file behaves similarly to the "open-file".
327/// * We assume that any differences that exist in their behavior do not
328/// affect our correctness above.
329/// * When `File::lock` calls `LockFileEx`, and it completes successfully,
330/// we now have a lock on the file.
331/// Only one lock can exist on a file at a time.
332/// * When we compare members of `handle.metadata()` and `path.metadata()`,
333/// the comparison will return equal if ~~and only if~~
334/// the two files are truly the same.
335/// * We rely on the property that a file cannot change its file_index while it is
336/// open.
337/// * Deleting the lock file will actually work, since `File::open` opened it with
338/// FILE_SHARE_DELETE. (This is the default according to the documentation
339/// for `OpenOptionsExt::share_mode`.)
340/// * When we delete the lock file, possibly-asynchronous ("deferred") deletion
341/// definitely won't mean that the OS kernel violates our rule that no-one but the lockholder
342/// is allowed to delete the file.
343/// * The above is true even if someone with read
344/// access to the file - eg the human user - opens it without the FILE_SHARE options.
345/// * The same is true even if there is a virus scanner.
346/// * The same is true even on a remote filesystem.
347/// * If someone with read access to the file - eg the human user - opens it for reading
348/// without FILE_SHARE options, the algorithm will still work and not fail
349/// with a file sharing violation io error.
350/// (Or, every program the user might use to randomly peer at files in arti's
351/// state directory, including the equivalents of `grep -R` and backup programs,
352/// will use suitable FILE_SHARE options.)
353/// (If this assumption is false, the consequence is not data loss;
354/// rather, arti would fall over. So that would be tolerable if we don't
355/// know how to do better, or if doing better is hard.)
356#[cfg(windows)]
357mod os {
358 use std::{fs::File, mem::MaybeUninit, os::windows::io::AsRawHandle, path::Path};
359 use windows_sys::Win32::{
360 Foundation::HANDLE,
361 Storage::FileSystem::{FILE_ID_INFO, FileIdInfo, GetFileInformationByHandleEx},
362 };
363
364 /// Use `GetFileInformationByHandleEx` to return a FILE_ID_INFO data for `f`.
365 ///
366 /// `GetFileInformationByHandleEx` is supported in Vista and later, so it
367 /// should be fine here. Unlike GetFileInformationByHandle, it gives
368 /// 128-bit identifiers which are supposedly even more unique.
369 fn get_id_info(f: &File) -> std::io::Result<FILE_ID_INFO> {
370 let handle = f.as_raw_handle() as HANDLE;
371 let mut info: MaybeUninit<FILE_ID_INFO> = MaybeUninit::uninit();
372 let buffersize: u32 = std::mem::size_of::<FILE_ID_INFO>()
373 .try_into()
374 .expect("sizeof(FILE_ID_INFO) is ridiculously large");
375
376 let info = unsafe {
377 // SAFETY: Since `size` is the size of info, this will not write to
378 // uninitialized memory.
379 let rv = GetFileInformationByHandleEx(
380 handle,
381 FileIdInfo,
382 info.as_mut_ptr() as _,
383 buffersize,
384 );
385
386 if rv == 0 {
387 return Err(std::io::Error::last_os_error());
388 }
389
390 // SAFETY: since rv was nonzero, this value is initialized.
391 info.assume_init()
392 };
393 Ok(info)
394 }
395
396 /// Return true if `lf` currently exists with the given `path`, and false otherwise.
397 pub(crate) fn lockfile_has_path(lf: &File, path: &Path) -> std::io::Result<bool> {
398 let f2 = File::open(path)?;
399
400 // Note: we would like to just use the MetadataExt methods for index and
401 // volume serial number, but they are currently available only on
402 // nightly: https://github.com/rust-lang/rust/issues/63010
403 //
404 // If they stabilize at our MSRV, _and_ the file ID is expanded to the
405 // 128-bit version, we can use them here instead.
406
407 let i1 = get_id_info(lf)?;
408 let i2 = get_id_info(&f2)?;
409
410 // This comparison is about the best we can do on Windows,
411 // though there are caveats.
412 //
413 // See Raymond Chen's writeup at
414 // https://devblogs.microsoft.com/oldnewthing/20220128-00/?p=106201
415 // and also see BurntSushi's caveats at
416 // https://github.com/BurntSushi/same-file/blob/master/src/win.rs
417 Ok(i1.VolumeSerialNumber == i2.VolumeSerialNumber
418 && i1.FileId.Identifier == i2.FileId.Identifier)
419 }
420}
421
422/// Non-windows, non-unix implementation for lockfile_has_path.
423///
424/// For now, this implementation always reports an error.
425/// It exists so that we can build (but not run) on wasm.
426#[cfg(all(not(windows), not(unix)))]
427mod os {
428 use std::path::Path;
429
430 /// Return true if `lf` currently exists with the given `path`, and false otherwise.
431 pub(crate) fn lockfile_has_path(_lf: &std::fs::File, _path: &Path) -> std::io::Result<bool> {
432 Err(std::io::Error::other(
433 "fslock-guard does not support this operating system".to_string(),
434 ))
435 }
436}
437
438#[cfg(test)]
439mod tests {
440 // @@ begin test lint list maintained by maint/add_warning @@
441 #![allow(clippy::bool_assert_comparison)]
442 #![allow(clippy::clone_on_copy)]
443 #![allow(clippy::dbg_macro)]
444 #![allow(clippy::mixed_attributes_style)]
445 #![allow(clippy::print_stderr)]
446 #![allow(clippy::print_stdout)]
447 #![allow(clippy::single_char_pattern)]
448 #![allow(clippy::unwrap_used)]
449 #![allow(clippy::unchecked_time_subtraction)]
450 #![allow(clippy::useless_vec)]
451 #![allow(clippy::needless_pass_by_value)]
452 #![allow(clippy::string_slice)] // See arti#2571
453 //! <!-- @@ end test lint list maintained by maint/add_warning @@ -->
454
455 use crate::LockFileGuard;
456 use test_temp_dir::test_temp_dir;
457
458 #[test]
459 fn keep_lock_file_after_drop() {
460 test_temp_dir!().used_by(|dir| {
461 let file = dir.join("file");
462 let flock_guard = LockFileGuard::lock(&file).unwrap();
463 assert!(file.try_exists().unwrap());
464 drop(flock_guard);
465 assert!(file.try_exists().unwrap());
466 });
467 }
468
469 #[test]
470 fn delete_lock_file_if_requested() {
471 test_temp_dir!().used_by(|dir| {
472 let file = dir.join("file");
473 let flock_guard = LockFileGuard::lock(&file).unwrap();
474 assert!(file.try_exists().unwrap());
475 assert!(flock_guard.delete_lock_file(&file).is_ok());
476 assert!(!file.try_exists().unwrap());
477 });
478 }
479}