Skip to main content

release_kit/plan/
lock.rs

1//! One target held by one apply at a time.
2//!
3//! An apply reads the target, decides against what it read, stages, and
4//! renames. Two of them interleaved can each pass their own validation
5//! and then commit over each other, leaving one plan's files beside
6//! another's record: a target describing a landing that never happened.
7//! The transaction cannot see that, because it renames files it staged
8//! before the other run existed.
9//!
10//! So an apply takes the target first and holds it until its
11//! postconditions have run. The lock is one file under the state root,
12//! named for the canonical target path, and what holds the target is the
13//! advisory lock the operating system puts on the open file, not the
14//! file's existence. The kernel owns that lock: it releases when the
15//! holder exits, however it exits, so a run killed outright frees the
16//! target rather than stranding it. The file itself is left in place,
17//! because removing one another run has already opened would leave two
18//! runs holding locks on two different inodes under one name.
19//!
20//! It lives outside the target because a target's cleanliness is judged
21//! byte by byte, and a lock file inside it would be drift.
22//!
23//! Where the lock cannot be taken, the apply refuses. A guard that
24//! silently does nothing is worse than none, because the call site still
25//! reads as guarded.
26//!
27//! It is advisory, and it bounds this engine's own runs rather than
28//! every writer: a hand edit during an apply is what the before-digests
29//! and the postconditions are for.
30
31use std::fs::{File, OpenOptions, TryLockError};
32use std::io::{self, Write};
33use std::path::{Path, PathBuf};
34
35use camino::Utf8Path;
36
37use crate::applog;
38use crate::diagnostic::{Diagnostic, Reason};
39use crate::digest::Digest;
40use crate::error::RkError;
41
42/// The directory holding the locks, under the state root.
43pub const LOCKS_DIR: &str = "locks";
44
45/// One target held for the life of this value.
46///
47/// The held file stays open for as long as this value lives, and the
48/// operating system releases its lock when the file closes: on a drop,
49/// on a refusal, on an error, and on a process that dies without
50/// unwinding. A held lock always names its file, and the only
51/// constructors either take the lock or refuse, so there is no such
52/// thing as a lock that holds nothing.
53#[derive(Debug)]
54pub struct TargetLock {
55    /// Held open, because closing it is what releases the lock. Dropped
56    /// with this value.
57    _file: File,
58    path: PathBuf,
59}
60
61impl TargetLock {
62    /// The lock file's path, while it is held.
63    #[must_use]
64    pub fn path(&self) -> &Path {
65        &self.path
66    }
67}
68
69/// Take `target` for this run, or refuse.
70///
71/// Exclusive ownership or a refusal, never a silent pass. Where no state
72/// root resolves there is nowhere to put the lock, and an apply that
73/// proceeds anyway is the unguarded apply this module exists to stop, so
74/// it refuses before it stages. A second location is no answer either: a
75/// run that locked elsewhere would not exclude a run that locked here,
76/// and two processes disagreeing about where the lock lives hold no lock
77/// at all.
78///
79/// # Errors
80///
81/// Returns a `target-busy` refusal where another live run holds the
82/// target, a `prerequisite-unmet` refusal where no state root resolves,
83/// and [`RkError::Io`] where the lock file cannot be opened.
84pub fn acquire(target: &Utf8Path) -> Result<TargetLock, RkError> {
85    let Some(root) = applog::state_root() else {
86        return Err(rootless());
87    };
88    acquire_in(&root.join(LOCKS_DIR), target)
89}
90
91/// The refusal for a host where the lock has nowhere to live.
92fn rootless() -> RkError {
93    RkError::refusal(
94        Diagnostic::new(
95            Reason::PrerequisiteUnmet,
96            "no state root resolves, so this run cannot take the target, and nothing was written",
97        )
98        .expected("one apply against a target at a time, held by a lock under the state root")
99        .action("set XDG_STATE_HOME, or HOME, and run it again")
100        .target_state("unchanged"),
101    )
102}
103
104/// The acquisition against one locks directory, which is what the tests
105/// drive so no test has to move the state root out from under itself.
106///
107/// The target path is digested rather than flattened, so a path carrying
108/// a separator cannot name another target's lock.
109///
110/// # Errors
111///
112/// As [`acquire`].
113pub fn acquire_in(dir: &Path, target: &Utf8Path) -> Result<TargetLock, RkError> {
114    let canonical = std::fs::canonicalize(target)
115        .map_or_else(|_| target.to_string(), |path| path.display().to_string());
116    std::fs::create_dir_all(dir)?;
117    restrict_lock_dir(dir)?;
118    let path = dir.join(format!("{}.lock", Digest::of(canonical.as_bytes())));
119    // Opened rather than created exclusively: the file outlives every
120    // run that took it, so its existence says a target was locked once,
121    // never that it is locked now. Only the lock below says that.
122    let mut file = open_lock_file(&path)?;
123    match file.try_lock() {
124        Ok(()) => {
125            // Best effort: the body is for the operator reading a
126            // refusal, and a lock that cannot be described still holds.
127            // Truncated first, because the previous holder's line is
128            // still there and a short write would leave its tail.
129            let _ = file.set_len(0);
130            let _ = writeln!(file, "{}", std::process::id());
131            let _ = writeln!(file, "{canonical}");
132            let _ = file.flush();
133            Ok(TargetLock { _file: file, path })
134        }
135        Err(TryLockError::WouldBlock) => Err(busy(&canonical, &path)),
136        Err(TryLockError::Error(error)) => Err(RkError::Io(error)),
137    }
138}
139
140/// Make the lock namespace private and refuse a link or another file type.
141fn restrict_lock_dir(dir: &Path) -> io::Result<()> {
142    let metadata = std::fs::symlink_metadata(dir)?;
143    if metadata.file_type().is_symlink() || !metadata.is_dir() {
144        return Err(invalid_lock_entry(dir, "lock namespace is not a directory"));
145    }
146    #[cfg(unix)]
147    {
148        use std::os::unix::fs::{OpenOptionsExt as _, PermissionsExt as _};
149
150        let directory = OpenOptions::new()
151            .read(true)
152            .custom_flags(libc::O_DIRECTORY | libc::O_NOFOLLOW)
153            .open(dir)?;
154        directory.set_permissions(std::fs::Permissions::from_mode(0o700))?;
155    }
156    Ok(())
157}
158
159/// Open the persistent lock entry without following its final component.
160fn open_lock_file(path: &Path) -> io::Result<File> {
161    match std::fs::symlink_metadata(path) {
162        Ok(metadata) if metadata.file_type().is_symlink() || !metadata.is_file() => {
163            return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
164        }
165        Ok(_) => {}
166        Err(error) if error.kind() == io::ErrorKind::NotFound => {}
167        Err(error) => return Err(error),
168    }
169    let mut options = OpenOptions::new();
170    options.read(true).write(true).create(true).truncate(false);
171    configure_lock_open(&mut options);
172    let file = options.open(path)?;
173    if !file.metadata()?.is_file() {
174        return Err(invalid_lock_entry(path, "lock entry is not a regular file"));
175    }
176    #[cfg(unix)]
177    {
178        use std::os::unix::fs::PermissionsExt as _;
179
180        file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
181    }
182    Ok(file)
183}
184
185#[cfg(unix)]
186fn configure_lock_open(options: &mut OpenOptions) {
187    use std::os::unix::fs::OpenOptionsExt as _;
188
189    options
190        .mode(0o600)
191        .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK);
192}
193
194#[cfg(windows)]
195fn configure_lock_open(options: &mut OpenOptions) {
196    use std::os::windows::fs::OpenOptionsExt as _;
197
198    // Win32 FILE_FLAG_OPEN_REPARSE_POINT makes CreateFileW open the link
199    // itself, so the regular-file check below refuses it rather than opening
200    // its destination.
201    const FILE_FLAG_OPEN_REPARSE_POINT: u32 = 0x0020_0000;
202    options.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT);
203}
204
205#[cfg(not(any(unix, windows)))]
206fn configure_lock_open(_options: &mut OpenOptions) {}
207
208fn invalid_lock_entry(path: &Path, detail: &str) -> io::Error {
209    io::Error::new(
210        io::ErrorKind::InvalidData,
211        format!("{detail}: {}", path.display()),
212    )
213}
214
215/// The refusal for a target another run holds.
216fn busy(target: &str, path: &Path) -> RkError {
217    let holder = std::fs::read_to_string(path)
218        .ok()
219        .and_then(|text| text.lines().next().map(str::to_owned))
220        .filter(|line| !line.is_empty())
221        .map_or_else(
222            || "another run".to_owned(),
223            |pid| format!("the run at process {pid}"),
224        );
225    RkError::refusal(
226        Diagnostic::new(
227            Reason::TargetBusy,
228            format!("{holder} holds {target}, and nothing was written"),
229        )
230        .expected("one apply against a target at a time")
231        .action("wait for that run to finish, then run it again")
232        .target_state("unchanged"),
233    )
234}
235
236#[cfg(test)]
237mod tests {
238    use super::{acquire_in, busy, rootless};
239    use crate::diagnostic::Reason;
240    use crate::digest::Digest;
241
242    fn utf8(dir: &tempfile::TempDir) -> camino::Utf8PathBuf {
243        camino::Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("a utf-8 path")
244    }
245
246    fn lock_path(locks: &tempfile::TempDir, target: &camino::Utf8Path) -> std::path::PathBuf {
247        let canonical = std::fs::canonicalize(target).expect("the target canonicalizes");
248        locks.path().join(format!(
249            "{}.lock",
250            Digest::of(canonical.display().to_string().as_bytes())
251        ))
252    }
253
254    /// The second acquisition refuses while the first is held, and the
255    /// drop frees the target for the next one.
256    #[test]
257    fn one_run_holds_a_target_at_a_time() {
258        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
259        let target = tempfile::tempdir().expect("a scratch target exists");
260        let path = utf8(&target);
261        let first = acquire_in(locks.path(), &path).expect("the first run takes the target");
262        let second = acquire_in(locks.path(), &path);
263        assert_eq!(
264            second.expect_err("the second refuses").reason(),
265            Reason::TargetBusy
266        );
267        drop(first);
268        acquire_in(locks.path(), &path).expect("the target is free again");
269    }
270
271    /// Two targets are two locks, so one apply does not block another.
272    #[test]
273    fn two_targets_are_two_locks() {
274        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
275        let a = tempfile::tempdir().expect("a scratch target exists");
276        let b = tempfile::tempdir().expect("a second scratch target exists");
277        let _first = acquire_in(locks.path(), &utf8(&a)).expect("the first target is taken");
278        acquire_in(locks.path(), &utf8(&b)).expect("the second target is free");
279    }
280
281    /// A lock file a dead run left behind holds nothing, so the next
282    /// apply takes the target rather than refusing until somebody
283    /// removes the file by hand.
284    ///
285    /// The file is what a killed process leaves: the operating system
286    /// released its lock when the process died, and the bytes stayed.
287    #[test]
288    fn a_lock_file_without_a_live_holder_is_taken_over() {
289        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
290        let target = tempfile::tempdir().expect("a scratch target exists");
291        let path = utf8(&target);
292        let held = acquire_in(locks.path(), &path)
293            .expect("the first run takes the target")
294            .path()
295            .to_path_buf();
296
297        // The holder gone the way a kill leaves it: the file and its
298        // line survive, the lock does not.
299        drop(acquire_in(locks.path(), &path));
300        std::fs::write(&held, "4242\n/some/target\n").expect("the corpse's line writes");
301        assert!(held.exists(), "a killed run leaves its lock file");
302
303        let taken = acquire_in(locks.path(), &path).expect("the next run takes the target");
304        assert_eq!(taken.path(), held, "it is the same lock file");
305        let body = std::fs::read_to_string(&held).expect("the lock file reads");
306        assert!(
307            body.starts_with(&format!("{}\n", std::process::id())),
308            "the taking run names itself, and no tail of the corpse survives: {body:?}"
309        );
310    }
311
312    /// A lock pathname cannot redirect the holder description writes to
313    /// another file.
314    #[cfg(unix)]
315    #[test]
316    fn a_symlink_lock_file_is_refused_without_touching_its_target() {
317        use std::os::unix::fs::symlink;
318
319        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
320        let target = tempfile::tempdir().expect("a scratch target exists");
321        let path = utf8(&target);
322        let victim = locks.path().join("victim");
323        std::fs::write(&victim, "untouched\n").expect("the victim exists");
324        symlink(&victim, lock_path(&locks, &path)).expect("the crafted lock link exists");
325
326        acquire_in(locks.path(), &path).expect_err("a lock link is refused");
327        assert_eq!(
328            std::fs::read_to_string(victim).expect("the victim reads"),
329            "untouched\n",
330            "acquisition must not truncate or write through the link"
331        );
332    }
333
334    /// A persistent lock entry is a regular file, never another kind of
335    /// filesystem object.
336    #[test]
337    fn a_non_regular_lock_entry_is_refused() {
338        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
339        let target = tempfile::tempdir().expect("a scratch target exists");
340        let path = utf8(&target);
341        let entry = lock_path(&locks, &path);
342        std::fs::create_dir(&entry).expect("a non-regular entry exists");
343
344        acquire_in(locks.path(), &path).expect_err("a non-regular lock is refused");
345        assert!(entry.is_dir(), "the refused entry stays unchanged");
346    }
347
348    /// The lock namespace and its operator-readable entries belong only
349    /// to the user running rk.
350    #[cfg(unix)]
351    #[test]
352    fn the_lock_namespace_and_file_are_owner_only() {
353        use std::os::unix::fs::PermissionsExt as _;
354
355        let locks = tempfile::tempdir().expect("a scratch locks directory exists");
356        let target = tempfile::tempdir().expect("a scratch target exists");
357        let taken = acquire_in(locks.path(), &utf8(&target)).expect("the target is taken");
358
359        let dir_mode = std::fs::metadata(locks.path())
360            .expect("the namespace has metadata")
361            .permissions()
362            .mode()
363            & 0o777;
364        let file_mode = std::fs::metadata(taken.path())
365            .expect("the lock has metadata")
366            .permissions()
367            .mode()
368            & 0o777;
369        assert_eq!(dir_mode, 0o700);
370        assert_eq!(file_mode, 0o600);
371    }
372
373    /// The namespace itself cannot redirect every target lock into
374    /// another directory.
375    #[cfg(unix)]
376    #[test]
377    fn a_symlink_lock_namespace_is_refused() {
378        use std::os::unix::fs::symlink;
379
380        let parent = tempfile::tempdir().expect("a scratch parent exists");
381        let destination = tempfile::tempdir().expect("a scratch destination exists");
382        let locks = parent.path().join("locks");
383        symlink(destination.path(), &locks).expect("the crafted namespace link exists");
384        let target = tempfile::tempdir().expect("a scratch target exists");
385
386        acquire_in(&locks, &utf8(&target)).expect_err("a linked namespace is refused");
387        assert_eq!(
388            std::fs::read_dir(destination.path())
389                .expect("the destination reads")
390                .count(),
391            0,
392            "no lock is written through the namespace link"
393        );
394    }
395
396    /// A host where no state root resolves refuses rather than applying
397    /// unguarded, and names what the operator can set.
398    #[test]
399    fn a_host_with_no_state_root_refuses() {
400        let error = rootless();
401        assert_eq!(error.reason(), Reason::PrerequisiteUnmet);
402        let diagnostic = error.diagnostic();
403        assert!(
404            diagnostic
405                .action
406                .unwrap_or_default()
407                .contains("XDG_STATE_HOME"),
408            "{:?}",
409            diagnostic.message
410        );
411        assert_eq!(diagnostic.target_state.as_deref(), Some("unchanged"));
412    }
413
414    /// The refusal names the holder, so an operator meeting it knows
415    /// which run to wait for.
416    #[test]
417    fn the_refusal_names_the_holder() {
418        let dir = tempfile::tempdir().expect("a scratch directory exists");
419        let path = dir.path().join("held.lock");
420        std::fs::write(&path, "4242\n/some/target\n").expect("the lock file is written");
421        let diagnostic = busy("/some/target", &path).diagnostic();
422        assert!(diagnostic.message.contains("4242"), "{diagnostic:?}");
423        assert!(
424            diagnostic.message.contains("/some/target"),
425            "{diagnostic:?}"
426        );
427    }
428}