Skip to main content

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;