canic_backup/persistence/journal_lock/
mod.rs1use std::{fs, io, path::Path, path::PathBuf};
7
8use super::file_lock::{self, FileLockError};
9use thiserror::Error as ThisError;
10
11#[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#[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}