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;