aede_core/
store_lock.rs

1//! Process-wide coordination for the JSON documents in one Aède data folder.
2//!
3//! The lock is separate from the atomically replaced JSON files. Never remove
4//! it: unlinking a locked inode would let another process lock a new inode.
5//! Symbolic links and special lock files are refused. New lock files are
6//! owner-only on Unix, while existing permissions are preserved.
7
8use std::fs::{File, OpenOptions};
9use std::io;
10use std::path::Path;
11
12const LOCK_FILE: &str = ".aede.lock";
13
14/// Holds the exclusive data-folder lock until dropped.
15///
16/// Drop explicitly unlocks before closing the file. Closing alone may retain
17/// the lock while a helper process briefly holds an inherited descriptor.
18pub struct StoreLock {
19    _file: File,
20}
21
22impl StoreLock {
23    /// Waits for another Aède operation to finish, then locks this data folder.
24    pub fn acquire(data_dir: &Path) -> io::Result<Self> {
25        let file = open_lock(data_dir)?;
26        file.lock()?;
27        Ok(Self { _file: file })
28    }
29
30    /// Fails with [`io::ErrorKind::WouldBlock`] if another operation owns it.
31    pub fn try_acquire(data_dir: &Path) -> io::Result<Self> {
32        let file = open_lock(data_dir)?;
33        file.try_lock()?;
34        Ok(Self { _file: file })
35    }
36}
37
38impl Drop for StoreLock {
39    fn drop(&mut self) {
40        // File locks otherwise live until every duplicated/inherited handle
41        // closes. Drop cannot return an unlock error; closing our File remains
42        // the fallback, as with File's own destructor.
43        let _ = self._file.unlock();
44    }
45}
46
47fn open_lock(data_dir: &Path) -> io::Result<File> {
48    std::fs::create_dir_all(data_dir)?;
49    let path = data_dir.join(LOCK_FILE);
50    let inspected = match std::fs::symlink_metadata(&path) {
51        Ok(metadata) => {
52            check_regular_lock(&metadata)?;
53            Some(metadata)
54        }
55        Err(error) if error.kind() == io::ErrorKind::NotFound => None,
56        Err(error) => return Err(error),
57    };
58    let mut options = OpenOptions::new();
59    options.read(true).write(true).create(true).truncate(false);
60    #[cfg(unix)]
61    {
62        use std::os::unix::fs::OpenOptionsExt;
63        options.mode(0o600);
64    }
65    let file = options.open(&path)?;
66    let opened = file.metadata()?;
67    check_regular_lock(&opened)?;
68    let published = std::fs::symlink_metadata(&path)?;
69    check_regular_lock(&published)?;
70    #[cfg(unix)]
71    {
72        use std::os::unix::fs::MetadataExt;
73        let same = |metadata: &std::fs::Metadata| {
74            metadata.dev() == opened.dev() && metadata.ino() == opened.ino()
75        };
76        if !same(&published) || inspected.as_ref().is_some_and(|metadata| !same(metadata)) {
77            return Err(io::Error::other("writer lock changed while being opened"));
78        }
79    }
80    #[cfg(not(unix))]
81    let _ = inspected;
82    Ok(file)
83}
84
85fn check_regular_lock(metadata: &std::fs::Metadata) -> io::Result<()> {
86    if !metadata.file_type().is_file() {
87        return Err(io::Error::new(
88            io::ErrorKind::InvalidInput,
89            "writer lock must be an ordinary non-symlink file",
90        ));
91    }
92    Ok(())
93}
94
95#[cfg(test)]
96#[path = "store_lock_tests.rs"]
97mod tests;
98
99#[cfg(test)]
100#[path = "persistence_test_support.rs"]
101pub(crate) mod test_support;