1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
//! Provides facilities for file locks on unix and windows

use crate::{Error, Path, PathBuf};
use std::{fs, time::Duration};

#[cfg_attr(unix, path = "flock/unix.rs")]
#[cfg_attr(windows, path = "flock/windows.rs")]
mod sys;

/// An error pertaining to a failed file lock
#[derive(Debug, thiserror::Error)]
#[error("failed to obtain lock file '{path}'")]
pub struct FileLockError {
    /// The path of the file lock
    pub path: PathBuf,
    /// The underlying failure reason
    pub source: LockError,
}

/// Errors that can occur when attempting to acquire a [`FileLock`]
#[derive(Debug, thiserror::Error)]
pub enum LockError {
    /// An I/O error occurred attempting to open the lock file
    #[error(transparent)]
    Open(std::io::Error),
    /// Exclusive locks cannot be take on read-only file systems
    #[error("attempted to take an exclusive lock on a read-only path")]
    Readonly,
    /// Failed to create parents directories to lock file
    #[error("failed to create parent directories for lock path")]
    CreateDir(std::io::Error),
    /// Locking is not supported if the lock file is on an NFS, though note this
    /// is a bit more nuanced as `NFSv4` _does_ support file locking, but is out
    /// of scope, at least for now
    #[error("NFS do not support locking")]
    Nfs,
    /// This could happen on eg. _extremely_ old and outdated OSes or some filesystems
    /// and is only present for completeness
    #[error("locking is not supported on the filesystem and/or in the kernel")]
    NotSupported,
    /// An I/O error occurred attempting to un/lock the file
    #[error("failed to acquire or release file lock")]
    Lock(std::io::Error),
    /// The lock could not be acquired within the caller provided timeout
    #[error("failed to acquire lock within the specified duration")]
    TimedOut,
    /// The lock is currently held by another
    #[error("the lock is currently held by another")]
    Contested,
}

/// Provides options for creating a [`FileLock`]
pub struct LockOptions<'pb> {
    path: std::borrow::Cow<'pb, Path>,
    exclusive: bool,
    shared_fallback: bool,
}

impl<'pb> LockOptions<'pb> {
    /// Creates a new [`Self`] for the specified path
    #[inline]
    pub fn new(path: &'pb Path) -> Self {
        Self {
            path: path.into(),
            exclusive: false,
            shared_fallback: false,
        }
    }

    /// Creates a new [`Self`] for locking cargo's global package lock
    ///
    /// If specified, the path is used as the root, otherwise it is rooted at
    /// the path determined by `$CARGO_HOME`
    #[inline]
    pub fn cargo_package_lock(root: Option<PathBuf>) -> Result<Self, Error> {
        let mut path = if let Some(root) = root {
            root
        } else {
            crate::utils::cargo_home()?
        };
        path.push(".package-cache");

        Ok(Self {
            path: path.into(),
            exclusive: true,
            shared_fallback: false,
        })
    }

    /// Will attempt to acquire a shared lock rather than an exclusive one
    #[inline]
    pub fn shared(mut self) -> Self {
        self.exclusive = false;
        self
    }

    /// Will attempt to acquire an exclusive lock, which can optionally fallback
    /// to a shared lock if the lock file is for a read only filesystem
    #[inline]
    pub fn exclusive(mut self, shared_fallback: bool) -> Self {
        self.exclusive = true;
        self.shared_fallback = shared_fallback;
        self
    }

    /// Attempts to acquire a lock, but fails immediately if the lock is currently
    /// held
    #[inline]
    pub fn try_lock(&self) -> Result<FileLock, Error> {
        self.open_and_lock(Option::<fn(&Path) -> Option<Duration>>::None)
    }

    /// Attempts to acquire a lock, waiting if the lock is currently held.
    ///
    /// Unlike [`Self::try_lock`], if the lock is currently held, the specified
    /// callback is called to inform the caller that a wait is about to
    /// be performed, then waits for the amount of time specified by the return
    /// of the callback, or infinitely in the case of `None`.
    #[inline]
    pub fn lock(&self, wait: impl Fn(&Path) -> Option<Duration>) -> Result<FileLock, Error> {
        self.open_and_lock(Some(wait))
    }

    fn open(&self, opts: &fs::OpenOptions) -> Result<fs::File, FileLockError> {
        opts.open(self.path.as_std_path()).or_else(|err| {
            if err.kind() == std::io::ErrorKind::NotFound && self.exclusive {
                fs::create_dir_all(self.path.parent().unwrap()).map_err(|e| FileLockError {
                    path: self.path.parent().unwrap().to_owned(),
                    source: LockError::CreateDir(e),
                })?;
                self.open(opts)
            } else {
                // Note we just use the 30 EROFS constant here, which won't work on WASI, Haiku, or some other
                // niche targets, but none of them are intended targets for this crate, but can be fixed later
                // if someone actually uses them
                let source = if err.kind() == std::io::ErrorKind::PermissionDenied
                    || cfg!(unix) && err.raw_os_error() == Some(30 /* EROFS */)
                {
                    LockError::Readonly
                } else {
                    LockError::Open(err)
                };

                Err(FileLockError {
                    path: self.path.as_ref().to_owned(),
                    source,
                })
            }
        })
    }

    fn open_and_lock(
        &self,
        wait: Option<impl Fn(&Path) -> Option<Duration>>,
    ) -> Result<FileLock, Error> {
        let (state, file) = if self.exclusive {
            match self.open(&sys::open_opts(true)) {
                Ok(file) => (LockState::Exclusive, file),
                Err(err) => {
                    // If the user requested it, check if the error is due to a read only error,
                    // and if so, fallback to a shared lock instead of an exclusive lock, just
                    // as cargo does
                    //
                    // https://github.com/rust-lang/cargo/blob/0b6cc3c75f1813df857fb54421edf7f8fee548e3/src/cargo/util/config/mod.rs#L1907-L1935
                    if self.shared_fallback && matches!(err.source, LockError::Readonly) {
                        (LockState::Shared, self.open(&sys::open_opts(false))?)
                    } else {
                        return Err(err.into());
                    }
                }
            }
        } else {
            (LockState::Shared, self.open(&sys::open_opts(false))?)
        };

        self.do_lock(state, &file, wait)
            .map_err(|source| FileLockError {
                path: self.path.as_ref().to_owned(),
                source,
            })?;

        Ok(FileLock {
            file: Some(file),
            state,
        })
    }

    fn do_lock(
        &self,
        state: LockState,
        file: &fs::File,
        wait: Option<impl Fn(&Path) -> Option<std::time::Duration>>,
    ) -> Result<(), LockError> {
        #[cfg(all(target_os = "linux", not(target_env = "musl")))]
        fn is_on_nfs_mount(path: &crate::Path) -> bool {
            use std::os::unix::prelude::*;

            let path = match std::ffi::CString::new(path.as_os_str().as_bytes()) {
                Ok(path) => path,
                Err(_) => return false,
            };

            #[allow(unsafe_code)]
            unsafe {
                let mut buf: libc::statfs = std::mem::zeroed();
                let r = libc::statfs(path.as_ptr(), &mut buf);

                r == 0 && buf.f_type as u32 == libc::NFS_SUPER_MAGIC as u32
            }
        }

        #[cfg(any(not(target_os = "linux"), target_env = "musl"))]
        fn is_on_nfs_mount(_path: &crate::Path) -> bool {
            false
        }

        // File locking on Unix is currently implemented via `flock`, which is known
        // to be broken on NFS. We could in theory just ignore errors that happen on
        // NFS, but apparently the failure mode [1] for `flock` on NFS is **blocking
        // forever**, even if the "non-blocking" flag is passed!
        //
        // As a result, we just skip all file locks entirely on NFS mounts. That
        // should avoid calling any `flock` functions at all, and it wouldn't work
        // there anyway.
        //
        // [1]: https://github.com/rust-lang/cargo/issues/2615
        if is_on_nfs_mount(&self.path) {
            return Err(LockError::Nfs);
        }

        match sys::try_lock(file, state) {
            Ok(()) => return Ok(()),

            // In addition to ignoring NFS which is commonly not working we also
            // just ignore locking on filesystems that look like they don't
            // implement file locking.
            Err(e) if sys::is_unsupported(&e) => return Err(LockError::NotSupported),

            Err(e) => {
                if !sys::is_contended(&e) {
                    return Err(LockError::Lock(e));
                }
            }
        }

        // Signal to the caller that we are about to enter a blocking operation
        // and whether they want to assign a timeout to it
        if let Some(wait) = wait {
            let timeout = wait(&self.path);

            sys::lock(file, state, timeout).map_err(|e| {
                if sys::is_timed_out(&e) {
                    LockError::TimedOut
                } else {
                    LockError::Lock(e)
                }
            })
        } else {
            Err(LockError::Contested)
        }
    }
}

#[derive(PartialEq, Copy, Clone, Debug)]
enum LockState {
    Exclusive,
    Shared,
    Unlocked,
}

/// A currently held file lock.
///
/// The lock is released when this is dropped, or the program exits for any reason,
/// including `SIGKILL` or power loss
pub struct FileLock {
    file: Option<std::fs::File>,
    state: LockState,
}

impl FileLock {
    /// Creates a [`Self`] in an unlocked state.
    ///
    /// This allows for easy testing or use in situations where you don't care
    /// about file locking, or have other ways to ensure something is uncontested
    pub fn unlocked() -> Self {
        Self {
            file: None,
            state: LockState::Unlocked,
        }
    }
}

impl Drop for FileLock {
    fn drop(&mut self) {
        if self.state != LockState::Unlocked {
            if let Some(f) = self.file.take() {
                let _ = sys::unlock(&f);
            }
        }
    }
}