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