Skip to main content

canic_backup/persistence/journal_lock/
mod.rs

1//! Module: persistence::journal_lock
2//!
3//! Responsibility: serialize mutation of a persisted journal with a sidecar lock.
4//! Does not own: journal validation, workflow policy, or domain error projection.
5
6use std::{fs, io, path::Path, path::PathBuf};
7
8use super::file_lock::{self, FileLockError};
9use thiserror::Error as ThisError;
10
11///
12/// JournalLockError
13///
14/// Typed failure to acquire exclusive access to a journal's regular sidecar file.
15///
16
17#[derive(Debug, ThisError)]
18pub enum JournalLockError {
19    #[error("filesystem authority is locked: {lock_path}")]
20    Locked { lock_path: String },
21
22    #[error("unsafe lock entry at {lock_path}: {kind}")]
23    UnsafeEntry { lock_path: String, kind: String },
24
25    #[error(transparent)]
26    Io(#[from] io::Error),
27}
28
29///
30/// JournalLock
31///
32/// Exclusive journal mutation guard. Dropping it releases the host file lock.
33///
34
35#[derive(Debug)]
36pub struct JournalLock {
37    #[cfg(unix)]
38    file: fs::File,
39}
40
41impl JournalLock {
42    pub(crate) fn acquire(journal_path: &Path) -> Result<Self, JournalLockError> {
43        let path = journal_lock_path(journal_path);
44
45        #[cfg(unix)]
46        {
47            acquire_supported(path)
48        }
49
50        #[cfg(not(unix))]
51        {
52            Err(io::Error::new(
53                io::ErrorKind::Unsupported,
54                format!(
55                    "journal locking is unsupported on this host: {}",
56                    path.display()
57                ),
58            )
59            .into())
60        }
61    }
62}
63
64#[cfg(unix)]
65impl Drop for JournalLock {
66    fn drop(&mut self) {
67        file_lock::unlock(&self.file);
68    }
69}
70
71#[cfg(unix)]
72fn acquire_supported(path: PathBuf) -> Result<JournalLock, JournalLockError> {
73    match file_lock::acquire(&path) {
74        Ok(file) => Ok(JournalLock { file }),
75        Err(FileLockError::Locked) => Err(JournalLockError::Locked {
76            lock_path: path.to_string_lossy().to_string(),
77        }),
78        Err(FileLockError::UnsafeEntry { kind }) => Err(JournalLockError::UnsafeEntry {
79            lock_path: path.to_string_lossy().to_string(),
80            kind,
81        }),
82        Err(FileLockError::Io(error)) => Err(JournalLockError::Io(error)),
83    }
84}
85
86fn journal_lock_path(path: &Path) -> PathBuf {
87    let mut lock_path = path.as_os_str().to_os_string();
88    lock_path.push(".lock");
89    PathBuf::from(lock_path)
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use crate::test_support::temp_path;
96
97    #[cfg(unix)]
98    use rustix::io::{FdFlags, fcntl_getfd};
99    #[cfg(unix)]
100    use std::{os::unix::fs::symlink, process::Command, thread, time::Duration};
101
102    const CHILD_JOURNAL_ENV: &str = "CANIC_TEST_JOURNAL_LOCK_CHILD_PATH";
103    const CHILD_READY_ENV: &str = "CANIC_TEST_JOURNAL_LOCK_CHILD_READY";
104
105    #[cfg(unix)]
106    #[test]
107    fn lock_excludes_a_second_owner_and_reacquires_after_drop() {
108        let journal_path = temp_path("canic-backup-journal-lock");
109        let lock_path = journal_lock_path(&journal_path);
110        let lock = JournalLock::acquire(&journal_path).expect("acquire lock");
111
112        assert!(lock_path.is_file());
113        assert!(
114            fcntl_getfd(&lock.file)
115                .expect("read lock descriptor flags")
116                .contains(FdFlags::CLOEXEC)
117        );
118        std::assert_matches!(
119            JournalLock::acquire(&journal_path),
120            Err(JournalLockError::Locked { lock_path: actual })
121                if actual == lock_path.to_string_lossy()
122        );
123
124        drop(lock);
125        assert!(lock_path.is_file());
126        let reacquired = JournalLock::acquire(&journal_path).expect("reacquire lock");
127        drop(reacquired);
128        fs::remove_file(lock_path).expect("remove lock path");
129    }
130
131    #[cfg(unix)]
132    #[test]
133    fn lock_rejects_symlink_and_special_entry_substitution() {
134        let journal_path = temp_path("canic-backup-journal-lock-unsafe");
135        let lock_path = journal_lock_path(&journal_path);
136        let target = temp_path("canic-backup-journal-lock-target");
137        symlink(&target, &lock_path).expect("create lock symlink");
138
139        std::assert_matches!(
140            JournalLock::acquire(&journal_path),
141            Err(JournalLockError::UnsafeEntry { lock_path: actual, kind })
142                if actual == lock_path.to_string_lossy() && kind == "Symlink"
143        );
144        fs::remove_file(&lock_path).expect("remove lock symlink");
145        fs::create_dir(&lock_path).expect("create lock directory");
146        std::assert_matches!(
147            JournalLock::acquire(&journal_path),
148            Err(JournalLockError::UnsafeEntry { lock_path: actual, kind })
149                if actual == lock_path.to_string_lossy() && kind == "Directory"
150        );
151        fs::remove_dir(lock_path).expect("remove lock directory");
152    }
153
154    #[cfg(unix)]
155    #[test]
156    fn process_death_releases_lock_without_sidecar_removal() {
157        let journal_path = temp_path("canic-backup-journal-lock-process-death");
158        let ready_path = temp_path("canic-backup-journal-lock-ready");
159        let mut child = Command::new(std::env::current_exe().expect("resolve test executable"))
160            .args([
161                "--exact",
162                "persistence::journal_lock::tests::process_death_child_holds_lock",
163                "--nocapture",
164            ])
165            .env(CHILD_JOURNAL_ENV, &journal_path)
166            .env(CHILD_READY_ENV, &ready_path)
167            .spawn()
168            .expect("spawn lock owner");
169
170        for _ in 0..500 {
171            if ready_path.is_file() {
172                break;
173            }
174            assert!(
175                child.try_wait().expect("inspect child").is_none(),
176                "lock owner exited before signaling readiness"
177            );
178            thread::sleep(Duration::from_millis(10));
179        }
180        assert!(ready_path.is_file(), "lock owner did not become ready");
181        std::assert_matches!(
182            JournalLock::acquire(&journal_path),
183            Err(JournalLockError::Locked { .. })
184        );
185
186        child.kill().expect("kill lock owner without unwinding");
187        child.wait().expect("reap lock owner");
188        let lock = JournalLock::acquire(&journal_path).expect("reacquire after process death");
189
190        drop(lock);
191        fs::remove_file(journal_lock_path(&journal_path)).expect("remove lock path");
192        fs::remove_file(ready_path).expect("remove ready marker");
193    }
194
195    #[cfg(unix)]
196    #[test]
197    fn process_death_child_holds_lock() {
198        let Some(journal_path) = std::env::var_os(CHILD_JOURNAL_ENV) else {
199            return;
200        };
201        let ready_path = std::env::var_os(CHILD_READY_ENV).expect("child ready path");
202        let _lock = JournalLock::acquire(Path::new(&journal_path)).expect("child acquire lock");
203        fs::write(ready_path, b"ready\n").expect("signal child readiness");
204        loop {
205            thread::sleep(Duration::from_secs(1));
206        }
207    }
208}