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