Skip to main content

release_kit/landing/
lock.rs

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