git_policy_cli/index_lock_evidence.rs
1//! What:
2//! Evidence about a foreign `index.lock` and the verdict it supports:
3//! proven alive,
4//! dead,
5//! or evidence-free.
6//! Why:
7//! A live owner is proven by a process holding the lock open (device and inode),
8//! or by Git's
9//! `core.lockfilePid` file naming a running process that started no later than the lock
10//! changed.
11//! A PID file naming a gone process proves a dead owner.
12//! Everything else is
13//! evidence-free;
14//! an absent open holder never proves abandonment,
15//! because native
16//! `git commit` keeps `index.lock` without a descriptor through its hooks and editor
17//! (`src/index-lock/index-lock-evidence.ts`).
18//!
19//! In TS you'd write (pseudocode):
20//! ```ts
21//! const evidence = await gatherIndexLockEvidence({ realIndexPath }); classifyIndexLock(evidence);
22//! ```
23
24/// Open-holder scans.
25use super::index_lock_holders::{HolderEvidence, scan_platform_holders};
26/// The variable that sets how long the wait for a live holder lasts.
27use super::wait_limit::LIVE_WAIT_VARIABLE;
28/// Process start times.
29use super::process_start::{ProcessStart, resolve_process_start};
30/// Git's PID file path.
31use super::recovery_files::lock_pid_path;
32/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
33use std::path::{Path, PathBuf};
34
35/// Longest PID file text quoted in evidence,
36/// in UTF-16 code units for ASCII text.
37const QUOTED_TEXT_LIMIT: usize = 64;
38
39/// What:
40/// A lock's identity and change time,
41/// read without following links.
42/// Why:
43/// `LockFileMetadata`;
44/// the change time is whole milliseconds,
45/// as `Number(stat.ctimeMs)`.
46///
47/// In TS you'd write (pseudocode):
48/// ```ts
49/// type LockFileMetadata = { device: bigint; inode: bigint; ctimeMs: number };
50/// ```
51#[derive(Clone, Copy, Debug, Eq, PartialEq)]
52pub struct LockMetadata {
53 /// Device number.
54 pub device: u64,
55 /// Inode number.
56 pub inode: u64,
57 /// Status change time in whole milliseconds since the epoch.
58 pub ctime_ms: i64,
59}
60
61/// What:
62/// The process a PID file names,
63/// now.
64/// Why:
65/// `PidOwnerEvidence`.
66///
67/// In TS you'd write (pseudocode):
68/// ```ts
69/// type PidOwnerEvidence = { state: 'missing' } | { state: 'started-before-lock'; startedAtMs } | ...;
70/// ```
71#[derive(Clone, Debug, PartialEq)]
72pub enum PidOwner {
73 /// No such process runs,
74 /// or it is a zombie.
75 Missing,
76 /// A process runs that started no later than the lock changed.
77 StartedBeforeLock(
78 /// Start time in milliseconds since the epoch.
79 f64,
80 ),
81 /// A process runs that started after the lock changed:
82 /// the PID was reused.
83 StartedAfterLock(
84 /// Start time in milliseconds since the epoch.
85 f64,
86 ),
87 /// A process runs whose start time is unreadable.
88 StartUnknown(
89 /// Why the start time could not be read.
90 String,
91 ),
92}
93
94/// What:
95/// Git's `core.lockfilePid` file beside the lock.
96/// Why:
97/// `PidFileEvidence`.
98///
99/// In TS you'd write (pseudocode):
100/// ```ts
101/// type PidFileEvidence = { kind: 'absent' } | { kind: 'malformed'; text } | { kind: 'unreadable'; reason } | { kind: 'owner'; pid; owner };
102/// ```
103#[derive(Clone, Debug, PartialEq)]
104pub enum PidFile {
105 /// No PID file exists.
106 Absent,
107 /// The file does not hold `pid <n>`;
108 /// the first characters of its text.
109 Malformed(
110 /// The first characters of the text.
111 String,
112 ),
113 /// The file exists but could not be read.
114 Unreadable(
115 /// The reason the file could not be read.
116 String,
117 ),
118 /// The file names a PID.
119 Owner(
120 /// The PID the file names.
121 i64,
122 /// What the process named by the PID is now.
123 PidOwner,
124 ),
125}
126
127/// What:
128/// The evidence of one attempt.
129/// Why:
130/// `IndexLockEvidence`;
131/// the holder scan is skipped once the PID file proves a live owner.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// type IndexLockEvidence = { lockPath; lock; pidFile; holders? };
136/// ```
137#[derive(Clone, Debug, PartialEq)]
138pub struct Evidence {
139 /// Lock path.
140 pub lock_path: PathBuf,
141 /// Lock identity and change time.
142 pub lock: LockMetadata,
143 /// PID file evidence.
144 pub pid_file: PidFile,
145 /// Open-holder scan,
146 /// absent when the PID file proved a live owner.
147 pub holders: Option<HolderEvidence>,
148}
149
150/// What:
151/// The classification of a foreign lock's owner.
152/// Why:
153/// `IndexLockVerdict`.
154///
155/// In TS you'd write (pseudocode):
156/// ```ts
157/// type IndexLockVerdict = { kind: 'proven-alive'; pid; source; command? } | { kind: 'dead'; pid } | { kind: 'evidence-free' };
158/// ```
159#[derive(Clone, Debug, Eq, PartialEq)]
160pub enum Verdict {
161 /// A live owner is proven;
162 /// `true` when by an open descriptor,
163 /// `false` by the PID file.
164 ProvenAlive {
165 /// Owner PID.
166 pid: i64,
167 /// Whether an open descriptor proved it (otherwise the PID file).
168 by_descriptor: bool,
169 /// Owner command name when readable.
170 command: Option<String>,
171 },
172 /// The PID file names a process that no longer runs.
173 Dead(
174 /// The PID of the process that no longer runs.
175 i64,
176 ),
177 /// Nothing proves a live owner or a dead one.
178 EvidenceFree,
179}
180
181/// What:
182/// A lock's metadata without following links,
183/// or nothing when it does not exist.
184/// Why:
185/// `readLockMetadata`.
186///
187/// In TS you'd write (pseudocode):
188/// ```ts
189/// async function readLockMetadata(lockPath: string): Promise<LockFileMetadata | typeof LOCK_ABSENT>;
190/// ```
191pub fn read_lock_metadata(lock_path: &Path) -> std::io::Result<Option<LockMetadata>> {
192 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(lock_path) {
193 Ok(found) => found,
194 Err(error) if is_missing(&error) => return Ok(None),
195 Err(error) => return Err(error),
196 };
197 #[cfg(unix)]
198 {
199 /// The Unix metadata accessors,
200 /// for the device,
201 /// inode and change time.
202 use std::os::unix::fs::MetadataExt;
203 return Ok(Some(LockMetadata {
204 device: metadata.dev(),
205 inode: metadata.ino(),
206 ctime_ms: metadata.ctime() * 1000 + metadata.ctime_nsec() / 1_000_000,
207 }));
208 }
209 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
210 #[cfg(not(unix))]
211 {
212 let changed: i64 = match metadata.modified() {
213 Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
214 Ok(elapsed) => i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX),
215 Err(_) => 0,
216 },
217 Err(_) => 0,
218 };
219 return Ok(Some(LockMetadata { device: 0, inode: 0, ctime_ms: changed }));
220 }
221}
222
223/// What:
224/// Whether an error means the path does not exist.
225/// Why:
226/// `isMissing`:
227/// `ENOENT` and `ENOTDIR`.
228///
229/// In TS you'd write (pseudocode):
230/// ```ts
231/// error.code === 'ENOENT' || error.code === 'ENOTDIR'
232/// ```
233fn is_missing(error: &std::io::Error) -> bool {
234 return matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory);
235}
236
237/// What:
238/// The PID in Git's `pid <n>` text,
239/// as `read_lock_pid` reads it:
240/// trailing whitespace
241/// trimmed,
242/// then `pid ` and decimal digits of a positive safe integer.
243/// Why:
244/// `parsePidFileText`.
245///
246/// In TS you'd write (pseudocode):
247/// ```ts
248/// parsePidFileText('pid 42\n') // 42
249/// ```
250pub fn parse_pid_file_text(text: &str) -> Option<i64> {
251 let trimmed: &str = super::js_text::trim_javascript_end(text);
252 let digits: &str = trimmed.strip_prefix("pid ")?;
253 return super::js_text::decimal_attempt(digits);
254}
255
256/// What:
257/// The owner evidence of a PID's start against the lock's change time.
258/// Why:
259/// `ownerEvidence`:
260/// started no later than the change plus the start's resolution.
261///
262/// In TS you'd write (pseudocode):
263/// ```ts
264/// function ownerEvidence({ start, lock }): PidOwnerEvidence;
265/// ```
266pub fn owner_evidence(start: ProcessStart, lock: &LockMetadata) -> PidOwner {
267 match start {
268 ProcessStart::Missing => return PidOwner::Missing,
269 ProcessStart::Unknown(reason) => return PidOwner::StartUnknown(reason),
270 ProcessStart::Running { started_at_ms, resolution_ms } => {
271 if started_at_ms <= (lock.ctime_ms + resolution_ms) as f64 {
272 return PidOwner::StartedBeforeLock(started_at_ms);
273 }
274 return PidOwner::StartedAfterLock(started_at_ms);
275 }
276 }
277}
278
279/// What:
280/// The first characters of a text,
281/// as `slice(0, 64)` keeps them.
282/// Why:
283/// A malformed PID file is quoted,
284/// not dumped whole.
285///
286/// In TS you'd write (pseudocode):
287/// ```ts
288/// text.slice(0, 64)
289/// ```
290fn quoted_prefix(text: &str) -> String {
291 let mut prefix: String = String::new();
292 let mut units: usize = 0;
293 for character in text.chars() {
294 units += character.len_utf16();
295 if units > QUOTED_TEXT_LIMIT {
296 break;
297 }
298 prefix.push(character);
299 }
300 return prefix;
301}
302
303/// What:
304/// The PID file beside a lock and the process it names.
305/// Why:
306/// `readPidFileEvidence`;
307/// the text is read with replacement characters as `'utf8'` does.
308///
309/// In TS you'd write (pseudocode):
310/// ```ts
311/// async function readPidFileEvidence({ pidPath, lock, resolveStart }): Promise<PidFileEvidence>;
312/// ```
313pub fn read_pid_file_evidence(pid_path: &Path, lock: &LockMetadata, resolve: fn(i64) -> ProcessStart) -> PidFile {
314 let text: String = match std::fs::read(pid_path) {
315 Ok(bytes) => String::from_utf8_lossy(bytes.as_slice()).into_owned(),
316 Err(error) if is_missing(&error) => return PidFile::Absent,
317 Err(error) => return PidFile::Unreadable(error.to_string()),
318 };
319 let Some(pid) = parse_pid_file_text(text.as_str()) else {
320 return PidFile::Malformed(quoted_prefix(text.as_str()));
321 };
322 return PidFile::Owner(pid, owner_evidence(resolve(pid), lock));
323}
324
325/// What:
326/// Classify gathered evidence.
327/// Why:
328/// `classifyIndexLock`:
329/// an open holder first,
330/// then a PID file of a process that started
331/// before the lock,
332/// then one naming a gone process.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// function classifyIndexLock(evidence: IndexLockEvidence): IndexLockVerdict;
337/// ```
338pub fn classify_index_lock(evidence: &Evidence) -> Verdict {
339 if let Some(scan) = &evidence.holders
340 && let Some(holder) = scan.holders.first()
341 {
342 return Verdict::ProvenAlive {
343 pid: holder.pid,
344 by_descriptor: true,
345 command: holder.command.clone(),
346 };
347 }
348 match &evidence.pid_file {
349 PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) => {
350 return Verdict::ProvenAlive {
351 pid: *pid,
352 by_descriptor: false,
353 command: None,
354 };
355 }
356 PidFile::Owner(pid, PidOwner::Missing) => return Verdict::Dead(*pid),
357 _ => return Verdict::EvidenceFree,
358 }
359}
360
361/// What:
362/// Gather one attempt's evidence with the given start-time source and holder scan.
363/// Why:
364/// `gatherIndexLockEvidence`;
365/// nothing when the lock is gone.
366///
367/// In TS you'd write (pseudocode):
368/// ```ts
369/// async function gatherIndexLockEvidence({ realIndexPath, sources }): Promise<IndexLockEvidence | typeof LOCK_ABSENT>;
370/// ```
371pub fn gather_with(
372 real_index: &Path,
373 resolve: fn(i64) -> ProcessStart,
374 scan: fn(&Path, u64, u64) -> HolderEvidence,
375) -> std::io::Result<Option<Evidence>> {
376 let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
377 name.push(".lock");
378 let lock_path: PathBuf = PathBuf::from(name);
379 let Some(lock) = read_lock_metadata(lock_path.as_path())? else {
380 return Ok(None);
381 };
382 let pid_file: PidFile = read_pid_file_evidence(lock_pid_path(real_index).as_path(), &lock, resolve);
383 if let PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) = &pid_file {
384 super::diagnostic_log::debug(
385 "gatherIndexLockEvidence",
386 format!("PID file proves live owner {pid}").as_str(),
387 );
388 return Ok(Some(Evidence { lock_path, lock, pid_file, holders: None }));
389 }
390 let holders: HolderEvidence = scan(lock_path.as_path(), lock.device, lock.inode);
391 return Ok(Some(Evidence {
392 lock_path,
393 lock,
394 pid_file,
395 holders: Some(holders),
396 }));
397}
398
399/// What:
400/// Gather one attempt's evidence with the host's sources.
401/// Why:
402/// The production gather.
403///
404/// In TS you'd write (pseudocode):
405/// ```ts
406/// await gatherIndexLockEvidence({ realIndexPath })
407/// ```
408pub fn gather_index_lock_evidence(real_index: &Path) -> std::io::Result<Option<Evidence>> {
409 return gather_with(real_index, resolve_process_start, scan_platform_holders);
410}
411
412/// What:
413/// `PID <n>` with the command in parentheses when known.
414/// Why:
415/// Shared by the evidence and the waiting line.
416///
417/// In TS you'd write (pseudocode):
418/// ```ts
419/// `PID ${pid}${command === undefined ? '' : ` (${command})`}`
420/// ```
421fn holder_name(pid: i64, command: Option<&str>) -> String {
422 match command {
423 Some(name) => return format!("PID {pid} ({name})"),
424 None => return format!("PID {pid}"),
425 }
426}
427
428/// What:
429/// The one standard-error line naming a proven-alive holder.
430/// Why:
431/// `provenHolderLine`:
432/// the person learns what the wait is for and where it gives up.
433///
434/// In TS you'd write (pseudocode):
435/// ```ts
436/// function provenHolderLine({ verdict, lockPath, limitSeconds }): string;
437/// ```
438pub fn proven_holder_line(
439 pid: i64,
440 by_descriptor: bool,
441 command: Option<&str>,
442 lock_path: &Path,
443 limit_seconds: u64,
444) -> String {
445 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
446 return format!(
447 "cli-git: waiting for {}, which holds {} (proven by {proof}); giving up after {limit_seconds} seconds \
448 ({LIVE_WAIT_VARIABLE} changes this), leaving the lock in place.\n",
449 holder_name(pid, command),
450 lock_path.display()
451 );
452}
453
454/// What:
455/// The line that repeats while a proven-alive holder keeps the lock.
456/// Why:
457/// Each repeat names the holder, the proof, how long the wait has lasted and where it gives up,
458/// so a reader of the last line alone knows what is going on.
459///
460/// In TS you'd write (pseudocode):
461/// ```ts
462/// function provenHolderProgressLine({ verdict, lockPath, waitedSeconds, limitSeconds }): string;
463/// ```
464pub fn proven_holder_progress_line(
465 pid: i64,
466 by_descriptor: bool,
467 command: Option<&str>,
468 lock_path: &Path,
469 waited_seconds: u64,
470 limit_seconds: u64,
471) -> String {
472 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
473 return format!(
474 "cli-git: still waiting for {}, which holds {} (proven by {proof}) after {waited_seconds} seconds; giving up \
475 after {limit_seconds} seconds ({LIVE_WAIT_VARIABLE} changes this), leaving the lock in place.\n",
476 holder_name(pid, command),
477 lock_path.display()
478 );
479}
480
481/// Evidence controls stay out of the release executable.
482#[cfg(test)]
483#[path = "index_lock_evidence_tests.rs"]
484mod tests;