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 #[cfg(not(unix))]
215 {
216 let changed: i64 = match metadata.modified() {
217 Ok(time) => match time.duration_since(std::time::UNIX_EPOCH) {
218 Ok(elapsed) => i64::try_from(elapsed.as_millis()).unwrap_or(i64::MAX),
219 Err(_) => 0,
220 },
221 Err(_) => 0,
222 };
223 return Ok(Some(LockMetadata { device: 0, inode: 0, ctime_ms: changed }));
224 }
225}
226
227/// What:
228/// Whether an error means the path does not exist.
229/// Why:
230/// `isMissing`:
231/// `ENOENT` and `ENOTDIR`.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// error.code === 'ENOENT' || error.code === 'ENOTDIR'
236/// ```
237fn is_missing(error: &std::io::Error) -> bool {
238 return matches!(error.kind(), std::io::ErrorKind::NotFound | std::io::ErrorKind::NotADirectory);
239}
240
241/// What:
242/// The PID in Git's `pid <n>` text,
243/// as `read_lock_pid` reads it:
244/// trailing whitespace
245/// trimmed,
246/// then `pid ` and decimal digits of a positive safe integer.
247/// Why:
248/// `parsePidFileText`.
249///
250/// In TS you'd write (pseudocode):
251/// ```ts
252/// parsePidFileText('pid 42\n') // 42
253/// ```
254pub fn parse_pid_file_text(text: &str) -> Option<i64> {
255 let trimmed: &str = super::js_text::trim_javascript_end(text);
256 let digits: &str = trimmed.strip_prefix("pid ")?;
257 return super::recovery_evidence::decimal_attempt(digits);
258}
259
260/// What:
261/// The owner evidence of a PID's start against the lock's change time.
262/// Why:
263/// `ownerEvidence`:
264/// started no later than the change plus the start's resolution.
265///
266/// In TS you'd write (pseudocode):
267/// ```ts
268/// function ownerEvidence({ start, lock }): PidOwnerEvidence;
269/// ```
270pub fn owner_evidence(start: ProcessStart, lock: &LockMetadata) -> PidOwner {
271 match start {
272 ProcessStart::Missing => return PidOwner::Missing,
273 ProcessStart::Unknown(reason) => return PidOwner::StartUnknown(reason),
274 ProcessStart::Running { started_at_ms, resolution_ms } => {
275 if started_at_ms <= (lock.ctime_ms + resolution_ms) as f64 {
276 return PidOwner::StartedBeforeLock(started_at_ms);
277 }
278 return PidOwner::StartedAfterLock(started_at_ms);
279 }
280 }
281}
282
283/// What:
284/// The first characters of a text,
285/// as `slice(0, 64)` keeps them.
286/// Why:
287/// A malformed PID file is quoted,
288/// not dumped whole.
289///
290/// In TS you'd write (pseudocode):
291/// ```ts
292/// text.slice(0, 64)
293/// ```
294fn quoted_prefix(text: &str) -> String {
295 let mut prefix: String = String::new();
296 let mut units: usize = 0;
297 for character in text.chars() {
298 units += character.len_utf16();
299 if units > QUOTED_TEXT_LIMIT {
300 break;
301 }
302 prefix.push(character);
303 }
304 return prefix;
305}
306
307/// What:
308/// The PID file beside a lock and the process it names.
309/// Why:
310/// `readPidFileEvidence`;
311/// the text is read with replacement characters as `'utf8'` does.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// async function readPidFileEvidence({ pidPath, lock, resolveStart }): Promise<PidFileEvidence>;
316/// ```
317pub fn read_pid_file_evidence(pid_path: &Path, lock: &LockMetadata, resolve: fn(i64) -> ProcessStart) -> PidFile {
318 let text: String = match std::fs::read(pid_path) {
319 Ok(bytes) => String::from_utf8_lossy(bytes.as_slice()).into_owned(),
320 Err(error) if is_missing(&error) => return PidFile::Absent,
321 Err(error) => return PidFile::Unreadable(error.to_string()),
322 };
323 let Some(pid) = parse_pid_file_text(text.as_str()) else {
324 return PidFile::Malformed(quoted_prefix(text.as_str()));
325 };
326 return PidFile::Owner(pid, owner_evidence(resolve(pid), lock));
327}
328
329/// What:
330/// Classify gathered evidence.
331/// Why:
332/// `classifyIndexLock`:
333/// an open holder first,
334/// then a PID file of a process that started
335/// before the lock,
336/// then one naming a gone process.
337///
338/// In TS you'd write (pseudocode):
339/// ```ts
340/// function classifyIndexLock(evidence: IndexLockEvidence): IndexLockVerdict;
341/// ```
342pub fn classify_index_lock(evidence: &Evidence) -> Verdict {
343 if let Some(scan) = &evidence.holders
344 && let Some(holder) = scan.holders.first()
345 {
346 return Verdict::ProvenAlive {
347 pid: holder.pid,
348 by_descriptor: true,
349 command: holder.command.clone(),
350 };
351 }
352 match &evidence.pid_file {
353 PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) => {
354 return Verdict::ProvenAlive {
355 pid: *pid,
356 by_descriptor: false,
357 command: None,
358 };
359 }
360 PidFile::Owner(pid, PidOwner::Missing) => return Verdict::Dead(*pid),
361 _ => return Verdict::EvidenceFree,
362 }
363}
364
365/// What:
366/// Gather one attempt's evidence with the given start-time source and holder scan.
367/// Why:
368/// `gatherIndexLockEvidence`;
369/// nothing when the lock is gone.
370///
371/// In TS you'd write (pseudocode):
372/// ```ts
373/// async function gatherIndexLockEvidence({ realIndexPath, sources }): Promise<IndexLockEvidence | typeof LOCK_ABSENT>;
374/// ```
375pub fn gather_with(
376 real_index: &Path,
377 resolve: fn(i64) -> ProcessStart,
378 scan: fn(&Path, u64, u64) -> HolderEvidence,
379) -> std::io::Result<Option<Evidence>> {
380 let mut name: std::ffi::OsString = real_index.as_os_str().to_owned();
381 name.push(".lock");
382 let lock_path: PathBuf = PathBuf::from(name);
383 let Some(lock) = read_lock_metadata(lock_path.as_path())? else {
384 return Ok(None);
385 };
386 let pid_file: PidFile = read_pid_file_evidence(lock_pid_path(real_index).as_path(), &lock, resolve);
387 if let PidFile::Owner(pid, PidOwner::StartedBeforeLock(_)) = &pid_file {
388 super::diagnostic_log::debug(
389 "gatherIndexLockEvidence",
390 format!("PID file proves live owner {pid}").as_str(),
391 );
392 return Ok(Some(Evidence { lock_path, lock, pid_file, holders: None }));
393 }
394 let holders: HolderEvidence = scan(lock_path.as_path(), lock.device, lock.inode);
395 return Ok(Some(Evidence {
396 lock_path,
397 lock,
398 pid_file,
399 holders: Some(holders),
400 }));
401}
402
403/// What:
404/// Gather one attempt's evidence with the host's sources.
405/// Why:
406/// The production gather.
407///
408/// In TS you'd write (pseudocode):
409/// ```ts
410/// await gatherIndexLockEvidence({ realIndexPath })
411/// ```
412pub fn gather_index_lock_evidence(real_index: &Path) -> std::io::Result<Option<Evidence>> {
413 return gather_with(real_index, resolve_process_start, scan_platform_holders);
414}
415
416/// What:
417/// The PID file evidence as prose.
418/// Why:
419/// `describePidFile`.
420///
421/// In TS you'd write (pseudocode):
422/// ```ts
423/// function describePidFile(pidFile: PidFileEvidence): string;
424/// ```
425pub fn describe_pid_file(pid_file: &PidFile) -> String {
426 match pid_file {
427 PidFile::Absent => return String::from("no PID file (the owner did not run with core.lockfilePid=true)"),
428 PidFile::Malformed(text) => return format!("a malformed PID file {}", quote(text.as_str())),
429 PidFile::Unreadable(reason) => return format!("an unreadable PID file ({reason})"),
430 PidFile::Owner(pid, owner) => {
431 let named: String = format!("PID file names PID {pid}");
432 match owner {
433 PidOwner::Missing => return format!("{named}, which no longer runs"),
434 PidOwner::StartedAfterLock(started) => {
435 return format!(
436 "{named}, now a process started at {}, after the lock changed, so the PID was reused",
437 format_iso_milliseconds(*started as i64)
438 );
439 }
440 PidOwner::StartUnknown(reason) => {
441 return format!("{named}, which runs but whose start time is unreadable ({reason})");
442 }
443 PidOwner::StartedBeforeLock(_) => return format!("{named}, which runs and started before the lock changed"),
444 }
445 }
446 }
447}
448
449/// What:
450/// The holder scan as prose.
451/// Why:
452/// `describeHolders`.
453///
454/// In TS you'd write (pseudocode):
455/// ```ts
456/// function describeHolders(evidence: IndexLockEvidence): string;
457/// ```
458pub fn describe_holders(evidence: &Evidence) -> String {
459 let Some(scan) = &evidence.holders else {
460 return String::from("no open-holder scan");
461 };
462 let found: String = if scan.holders.is_empty() {
463 String::from("no process holding it open")
464 } else {
465 let mut names: Vec<String> = Vec::new();
466 for holder in &scan.holders {
467 names.push(holder_name(holder.pid, holder.command.as_deref()));
468 }
469 names.join(", ")
470 };
471 let partial: String = if scan.partial.is_empty() {
472 String::new()
473 } else {
474 let quoted: &[String] = &scan.partial[..scan.partial.len().min(QUOTED_REASON_LIMIT)];
475 format!(
476 "; {} partial-evidence note(s): {}{}",
477 scan.partial.len(),
478 quoted.join("; "),
479 if scan.partial.len() > quoted.len() { "; ..." } else { "" }
480 )
481 };
482 return format!("{} scan found {found}{partial}", scan.method);
483}
484
485/// What:
486/// `PID <n>` with the command in parentheses when known.
487/// Why:
488/// Shared by the evidence and the waiting line.
489///
490/// In TS you'd write (pseudocode):
491/// ```ts
492/// `PID ${pid}${command === undefined ? '' : ` (${command})`}`
493/// ```
494fn holder_name(pid: i64, command: Option<&str>) -> String {
495 match command {
496 Some(name) => return format!("PID {pid} ({name})"),
497 None => return format!("PID {pid}"),
498 }
499}
500
501/// What:
502/// The evidence as one line of prose.
503/// Why:
504/// `describeIndexLockEvidence`,
505/// quoted in the unproven-owner failure.
506///
507/// In TS you'd write (pseudocode):
508/// ```ts
509/// function describeIndexLockEvidence(evidence: IndexLockEvidence): string;
510/// ```
511pub fn describe_index_lock_evidence(evidence: &Evidence) -> String {
512 return format!(
513 "{} (device {}, inode {}, changed {}); {}; {}",
514 evidence.lock_path.display(),
515 evidence.lock.device,
516 evidence.lock.inode,
517 format_iso_milliseconds(evidence.lock.ctime_ms),
518 describe_pid_file(&evidence.pid_file),
519 describe_holders(evidence)
520 );
521}
522
523/// What:
524/// The one standard-error line naming a proven-alive holder.
525/// Why:
526/// `provenHolderLine`:
527/// the wait is unbounded,
528/// so the person learns what it waits for.
529///
530/// In TS you'd write (pseudocode):
531/// ```ts
532/// function provenHolderLine({ verdict, lockPath }): string;
533/// ```
534pub fn proven_holder_line(pid: i64, by_descriptor: bool, command: Option<&str>, lock_path: &Path) -> String {
535 let proof: &str = if by_descriptor { "an open descriptor" } else { "its Git lock PID file" };
536 return format!(
537 "cli-git: waiting for {}, which holds {} (proven by {proof}); cli-git waits until it releases the lock.\n",
538 holder_name(pid, command),
539 lock_path.display()
540 );
541}
542
543/// Evidence controls stay out of the release executable.
544#[cfg(test)]
545#[path = "index_lock_evidence_tests.rs"]
546mod tests;