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    #[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;