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