Skip to main content

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