Skip to main content

prikk_store/
unlock.rs

1//! Stale-lock recovery (RFC 102 Stage 6 Step 2, design-v1.md §15.7 decision 3, handoff §4):
2//! `lock.rs::lock_body`'s own text says it outright -- *"note=PR-007 lock has no stale-lock stealing
3//! yet"* -- and a lock file surviving a crash wedges that lock permanently. `doctor.rs:405`'s own
4//! repair path acquires `ActiveLock`, so the tool meant to repair the repository was blocked by the
5//! very thing needing repair, with no recovery until this module.
6//!
7//! **Rejected: PID-based auto-stealing.** The tempting design is "if the recorded `pid=` isn't
8//! running, the lock is safe to steal automatically." The two failure directions are not symmetric:
9//!
10//! - **False negative** (stale lock, liveness check says "still running"): no worse than today's
11//!   permanent wedge. Annoying, not dangerous.
12//! - **False positive** (lock genuinely held, liveness check wrongly says "not running", auto-steals
13//!   it): **two writers now believe they hold exclusive access to the same container simultaneously**
14//!   -- the exact race Step 2 exists to close, reintroduced by the mechanism meant to keep the
15//!   repository usable. PID reuse after a reboot, and PID-namespace isolation across containers (a
16//!   process id meaningful inside one container's namespace is not the same number space the host or
17//!   a different container sees), both make this a real, not theoretical, failure path for a tool
18//!   whose deployment context includes CI containers.
19//!
20//! An auto-stealing mechanism whose failure mode is silent data corruption, built to fix a failure
21//! mode that is merely inconvenient, is the wrong trade.
22//!
23//! **So this module never removes a lock on its own.** `list_held_locks` only enumerates and reports;
24//! `clear_lock` only removes the exact path it is given, once. Prompting for confirmation and deciding
25//! whether the `--yes`/`--force` scripting escape applies are `prikk unlock`'s own job (`prikk-cli`),
26//! not this module's -- keeping the decision in the caller that can actually see a terminal keeps this
27//! module a pure, easily-tested primitive.
28//!
29//! **The liveness check is advisory, and the asymmetry in how it is trusted is the whole point:** a
30//! *positive* result (`AppearsRunning`) is reliable evidence to refuse -- `kill(pid, 0)` succeeding, or
31//! failing with `EPERM`, both mean the process genuinely exists. A *negative* result
32//! (`DoesNotAppearRunning`) is **not** evidence the lock is safe to clear, for the PID-reuse/namespace
33//! reasons above -- it is information for the operator, never authorization for the tool.
34
35use std::path::{Path, PathBuf};
36
37use prikk_error::Result;
38
39use crate::fsutil::{EntryKind, list_directory, read_file_if_exists, remove_file_required};
40use crate::layout::{LockableContainer, RepositoryLayout};
41
42/// Best-effort, advisory-only liveness of a lock's recorded `pid=`. See the module doc for why a
43/// negative or unknown result must never be treated as authorization to clear the lock.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum PidLiveness {
46    /// The recorded PID genuinely exists on this host right now -- reliable: refuse to clear.
47    AppearsRunning,
48    /// The recorded PID does not appear to exist on this host -- **not proof it is safe to clear**
49    /// (PID reuse, container namespace isolation both make "not found here" compatible with "still
50    /// running somewhere that matters").
51    DoesNotAppearRunning,
52    /// The check could not be performed (unparseable `pid=` value, or no liveness primitive on this
53    /// platform).
54    Unknown,
55}
56
57/// One lock file found on disk, parsed from its own body (`lock.rs::lock_body`'s format:
58/// `pid=<n>\nkind=<k>\nnote=...\n`).
59#[derive(Debug, Clone, PartialEq, Eq)]
60pub struct HeldLock {
61    /// The lock file's own path -- pass this to `clear_lock` to remove it.
62    pub path: PathBuf,
63    /// The `kind=` field recorded in the lock body (`"active"`, `"ref"`, or one of
64    /// `lock::container_lock_kind`'s strings).
65    pub kind: String,
66    /// The `pid=` field, if the body parsed cleanly.
67    pub recorded_pid: Option<u32>,
68    /// Best-effort, advisory liveness of `recorded_pid` -- see `PidLiveness`'s own doc.
69    pub liveness: PidLiveness,
70}
71
72#[cfg(any(target_os = "linux", target_os = "macos"))]
73fn check_pid_liveness(pid: u32) -> PidLiveness {
74    let Ok(raw) = i32::try_from(pid) else {
75        return PidLiveness::Unknown;
76    };
77    let Some(rustix_pid) = rustix::process::Pid::from_raw(raw) else {
78        return PidLiveness::Unknown;
79    };
80    match rustix::process::test_kill_process(rustix_pid) {
81        Ok(()) => PidLiveness::AppearsRunning,
82        // `EPERM` means the kernel found a process to check permissions against -- it exists, this
83        // caller simply cannot signal it. That is still existence, not absence.
84        Err(rustix::io::Errno::PERM) => PidLiveness::AppearsRunning,
85        Err(rustix::io::Errno::SRCH) => PidLiveness::DoesNotAppearRunning,
86        Err(_) => PidLiveness::Unknown,
87    }
88}
89
90/// DC-99: mirrors the Unix implementation's own reasoning, including its subtlety --
91/// `prikk_ffi::ProcessLiveness::Exists` covers both a confirmed-running handle and the
92/// access-denied case (`ERROR_ACCESS_DENIED`, the kernel found a process to check permissions
93/// against), the same two situations Linux/macOS's `Ok(())`/`EPERM` arms both map to
94/// `AppearsRunning`.
95#[cfg(target_os = "windows")]
96fn check_pid_liveness(pid: u32) -> PidLiveness {
97    match prikk_ffi::process_liveness(pid) {
98        prikk_ffi::ProcessLiveness::Exists => PidLiveness::AppearsRunning,
99        prikk_ffi::ProcessLiveness::DoesNotExist => PidLiveness::DoesNotAppearRunning,
100        prikk_ffi::ProcessLiveness::Indeterminate => PidLiveness::Unknown,
101    }
102}
103
104#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
105fn check_pid_liveness(_pid: u32) -> PidLiveness {
106    PidLiveness::Unknown
107}
108
109fn parse_lock_body(bytes: &[u8]) -> (String, Option<u32>) {
110    let body = String::from_utf8_lossy(bytes);
111    let mut kind = String::new();
112    let mut recorded_pid = None;
113    for line in body.lines() {
114        if let Some(value) = line.strip_prefix("kind=") {
115            kind = value.to_string();
116        } else if let Some(value) = line.strip_prefix("pid=") {
117            recorded_pid = value.parse::<u32>().ok();
118        }
119    }
120    (kind, recorded_pid)
121}
122
123fn read_lock_if_present(layout: &RepositoryLayout, path: &Path) -> Result<Option<HeldLock>> {
124    let relative = layout.repository_relative(path)?;
125    let Some(bytes) = read_file_if_exists(layout.repository_mutation_root(), &relative)? else {
126        return Ok(None);
127    };
128    let (kind, recorded_pid) = parse_lock_body(&bytes);
129    let liveness = recorded_pid.map_or(PidLiveness::Unknown, check_pid_liveness);
130    Ok(Some(HeldLock {
131        path: path.to_path_buf(),
132        kind,
133        recorded_pid,
134        liveness,
135    }))
136}
137
138/// Enumerate every lock file currently present: the active-session lock, every per-ref lock, and
139/// every one of the four container locks. Read-only -- never clears anything, matching the module's
140/// own "enumerate and report, never decide" split.
141pub fn list_held_locks(layout: &RepositoryLayout) -> Result<Vec<HeldLock>> {
142    let mut locks = Vec::new();
143
144    if let Some(lock) = read_lock_if_present(layout, &layout.default_active_lock_path())? {
145        locks.push(lock);
146    }
147
148    let ref_locks_dir = layout.refs_dir().join("locks");
149    let ref_locks_relative = layout.repository_relative(&ref_locks_dir)?;
150    for entry in list_directory(layout.repository_mutation_root(), &ref_locks_relative)? {
151        if entry.kind != EntryKind::Regular {
152            continue;
153        }
154        let path = ref_locks_dir.join(&entry.name);
155        if let Some(lock) = read_lock_if_present(layout, &path)? {
156            locks.push(lock);
157        }
158    }
159
160    for container in LockableContainer::ALL {
161        if let Some(lock) =
162            read_lock_if_present(layout, &layout.lockable_container_lock_path(container))?
163        {
164            locks.push(lock);
165        }
166    }
167
168    Ok(locks)
169}
170
171/// Find the held lock naming the same file as `target`, resolving both sides through the filesystem
172/// before comparing. A path reached through a different-but-equivalent route -- a symlinked temp
173/// directory (every macOS `/tmp`/`/var` path), a symlinked home, a symlinked mount -- must still match
174/// the lock `list_held_locks` itself reports; exact string equality alone silently misses these,
175/// telling an operator with a genuinely wedged repository and a genuinely correct path that "no held
176/// lock" exists (the CI run that found this: a real lock, a real matching path, reported absent,
177/// because `HeldLock::path` is built from an OS-resolved root while an independently-typed `--lock`
178/// argument is not).
179///
180/// Falls back to plain path equality if either side fails to resolve (`std::fs::canonicalize` errors
181/// on a path that does not exist): a target that names nothing real is exactly the "not currently
182/// held" case this function must still express as `None`, not an I/O error -- the no-match branch is
183/// precisely where the target may be bogus. `lock.path` is resolved defensively here too, even though
184/// it is already well-formed by construction today (`list_held_locks` builds it from an
185/// OS-resolved root) -- that is an invariant of the current call path, not a guarantee this function
186/// should assume holds forever.
187///
188/// `print_locks` (`prikk-cli`) already emits every `HeldLock::path` in its resolved form, so an
189/// operator who copies a path straight from a `prikk unlock` listing always gets a working `--lock`
190/// argument -- this defect only ever bit a path an operator (or a test) constructed independently.
191#[must_use]
192pub fn find_held_lock<'a>(locks: &'a [HeldLock], target: &Path) -> Option<&'a HeldLock> {
193    locks
194        .iter()
195        .find(|lock| paths_name_the_same_file(&lock.path, target))
196}
197
198fn paths_name_the_same_file(a: &Path, b: &Path) -> bool {
199    match (std::fs::canonicalize(a), std::fs::canonicalize(b)) {
200        (Ok(a), Ok(b)) => a == b,
201        _ => a == b,
202    }
203}
204
205/// Clear one specific lock file by path. The caller (`prikk unlock`) is responsible for obtaining
206/// operator confirmation before calling this -- this function performs no confirmation, no liveness
207/// check, and no safety gate of its own: by the time it is called, the decision has already been made
208/// by a human who read `list_held_locks`'s own advisory. Removing a lock that is still genuinely held
209/// lets two writers race the container it names -- that is the whole risk this module exists to keep
210/// an operator, not a heuristic, deciding.
211pub fn clear_lock(layout: &RepositoryLayout, path: &Path) -> Result<()> {
212    let relative = layout.repository_relative(path)?;
213    remove_file_required(layout.repository_mutation_root(), &relative)
214}
215
216#[cfg(test)]
217mod tests;