Skip to main content

git_policy_cli/
index_lock_holders.rs

1//! What:
2//!  Processes holding a lock file open,
3//!  found by device and inode:
4//!  `/proc/<pid>/fd` on
5//!       Linux,
6//!  `lsof` on macOS,
7//!  the Restart Manager on Windows.
8//! Why:
9//!  An open descriptor proves a live owner.
10//!  Matching by device and inode,
11//!  never by path text,
12//!      means bind mounts and renamed paths cannot fake or hide a holder.
13//!  Processes the scan
14//!      cannot read are partial evidence (`src/index-lock/index-lock-holders-linux.ts`,
15//!      `-darwin.ts`,
16//!  `-win32.ts`).
17//!
18//! In TS you'd write (pseudocode):
19//! ```ts
20//! const evidence = await scanPlatformHolders({ lockPath, lock });
21//! ```
22
23/// `Path`/`PathBuf` are borrowed/owned filesystem paths.
24use std::path::Path;
25/// `PathBuf` is an owned filesystem path,
26///  used by the Unix proc scan.
27#[cfg(unix)]
28use std::path::PathBuf;
29
30/// The incumbent's Restart Manager script,
31///  byte for byte.
32pub const RESTART_MANAGER_SCRIPT: &str = include_str!("index_lock_restart_manager.ps1");
33
34/// Partial-evidence note every unprivileged macOS scan carries.
35pub const SILENT_DENIAL_NOTE: &str = "lsof omits other users' processes on macOS without reporting it";
36
37/// What:
38///  One process holding the lock open.
39/// Why:
40///   `LockHolderProcess`.
41///
42/// In TS you'd write (pseudocode):
43/// ```ts
44/// type LockHolderProcess = { pid: number; command?: string };
45/// ```
46#[derive(Clone, Debug, Eq, PartialEq)]
47pub struct Holder {
48    /// Holder process ID.
49    pub pid: i64,
50    /// Holder command name when readable.
51    pub command: Option<String>,
52}
53
54/// What:
55///  The scan mechanism,
56///  its holders,
57///  and what it could not see.
58/// Why:
59///   `LockHolderEvidence`.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// type LockHolderEvidence = { method: 'proc-fd' | 'lsof' | 'restart-manager' | 'unsupported'; holders; partial };
64/// ```
65#[derive(Clone, Debug, Eq, PartialEq)]
66pub struct HolderEvidence {
67    /// Scan mechanism name.
68    pub method: &'static str,
69    /// Processes holding the lock open.
70    pub holders: Vec<Holder>,
71    /// Reasons the scan could not see everything.
72    pub partial: Vec<String>,
73}
74
75/// What:
76///  Whether a directory name is a decimal PID.
77/// Why:
78///   `/proc` also holds `self`,
79///  `sys` and others.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// name.length > 0 && isAsciiDigits(name)
84/// ```
85#[cfg(unix)]
86fn is_pid_name(name: &str) -> bool {
87    return !name.is_empty() && name.bytes().all(is_digit);
88}
89
90/// What:
91///  Whether a byte is an ASCII digit.
92/// Why:
93///   A named predicate keeps the scans free of closures.
94///
95/// In TS you'd write (pseudocode):
96/// ```ts
97/// (byte: number) => byte >= 0x30 && byte <= 0x39
98/// ```
99#[cfg(unix)]
100fn is_digit(byte: u8) -> bool {
101    return byte.is_ascii_digit();
102}
103
104/// What:
105///  Whether an error means the process exited during the scan.
106/// Why:
107///   `ENOENT` and `ESRCH` are expected races,
108///  never partial evidence.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// ['ENOENT', 'ESRCH'].includes(error.code)
113/// ```
114#[cfg(unix)]
115fn exited(error: &std::io::Error) -> bool {
116    if error.kind() == std::io::ErrorKind::NotFound {
117        return true;
118    }
119    #[cfg(unix)]
120    {
121        return error.raw_os_error() == Some(libc::ESRCH);
122    }
123    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
124    #[cfg(not(unix))]
125    {
126        return false;
127    }
128}
129
130/// What:
131///  The device and inode a path names after following links,
132///  as numbers.
133/// Why:
134///   A descriptor link resolves to the open file itself.
135///
136/// In TS you'd write (pseudocode):
137/// ```ts
138/// const { dev, ino } = await stat(path, { bigint: true });
139/// ```
140#[cfg(unix)]
141fn followed_identity(path: &Path) -> std::io::Result<(u64, u64)> {
142    /// The Unix metadata accessors,
143    ///  for the device and inode.
144    use std::os::unix::fs::MetadataExt;
145    let metadata: std::fs::Metadata = std::fs::metadata(path)?;
146    return Ok((metadata.dev(), metadata.ino()));
147}
148
149/// What:
150///  Whether one process holds the file open,
151///  and why its descriptors were unreadable.
152/// Why:
153///   `scanProcess`:
154///  the first unreadable descriptor's reason is kept.
155///
156/// In TS you'd write (pseudocode):
157/// ```ts
158/// async function scanProcess({ procRoot, pid, device, inode }): Promise<{ holds: boolean; partial?: string }>;
159/// ```
160#[cfg(unix)]
161fn scan_process(proc_root: &Path, pid: &str, device: u64, inode: u64) -> (bool, Option<String>) {
162    let fd_directory: PathBuf = proc_root.join(pid).join("fd");
163    let listing: std::fs::ReadDir = match std::fs::read_dir(&fd_directory) {
164        Ok(found) => found,
165        Err(error) if exited(&error) => return (false, None),
166        Err(error) => return (false, Some(format!("PID {pid}: {error}"))),
167    };
168    let mut holds: bool = false;
169    let mut partial: Option<String> = None;
170    for item in listing {
171        let Ok(entry) = item else {
172            continue;
173        };
174        match followed_identity(entry.path().as_path()) {
175            Ok((found_device, found_inode)) => {
176                if found_device == device && found_inode == inode {
177                    holds = true;
178                }
179            }
180            Err(error) if exited(&error) => {}
181            Err(error) => {
182                if partial.is_none() {
183                    partial = Some(format!(
184                        "PID {pid} fd {}: {error}",
185                        entry.file_name().to_string_lossy()
186                    ));
187                }
188            }
189        }
190    }
191    return (holds, partial);
192}
193
194/// What:
195///  Every process other than this one holding a file open,
196///  by device and inode,
197///  through a
198///       proc root.
199/// Why:
200///   `scanProcFdHolders`;
201///  processes are scanned one after another (the incumbent runs 16 at
202///       once;
203///  the result is the same set).
204///
205/// In TS you'd write (pseudocode):
206/// ```ts
207/// async function scanProcFdHolders({ device, inode, procRoot, selfPid }): Promise<LockHolderEvidence>;
208/// ```
209#[cfg(unix)]
210pub fn scan_proc_fd_holders(proc_root: &Path, device: u64, inode: u64, self_pid: u32) -> HolderEvidence {
211    let mut evidence: HolderEvidence = HolderEvidence {
212        method: "proc-fd",
213        holders: Vec::new(),
214        partial: Vec::new(),
215    };
216    let listing: std::fs::ReadDir = match std::fs::read_dir(proc_root) {
217        Ok(found) => found,
218        Err(error) => {
219            evidence.partial.push(format!("{}: {error}", proc_root.display()));
220            return evidence;
221        }
222    };
223    let own: String = self_pid.to_string();
224    let mut pids: Vec<String> = Vec::new();
225    for item in listing.flatten() {
226        let name: String = item.file_name().to_string_lossy().into_owned();
227        if is_pid_name(name.as_str()) && name != own {
228            pids.push(name);
229        }
230    }
231    for pid in &pids {
232        let (holds, partial) = scan_process(proc_root, pid.as_str(), device, inode);
233        if holds && let Ok(number) = pid.parse::<i64>() {
234            let command: Option<String> = std::fs::read_to_string(proc_root.join(pid).join("comm"))
235                .ok()
236                .map(trimmed);
237            evidence.holders.push(Holder { pid: number, command });
238        }
239        if let Some(reason) = partial {
240            evidence.partial.push(reason);
241        }
242    }
243    super::diagnostic_log::debug(
244        "scanProcFdHolders",
245        format!(
246            "scanned {} processes: {} holders, {} unreadable",
247            pids.len(),
248            evidence.holders.len(),
249            evidence.partial.len()
250        )
251        .as_str(),
252    );
253    return evidence;
254}
255
256/// What:
257///  Text without surrounding whitespace.
258/// Why:
259///   A named function keeps `map` free of closures.
260///
261/// In TS you'd write (pseudocode):
262/// ```ts
263/// (text: string) => text.trim()
264/// ```
265#[cfg(unix)]
266fn trimmed(text: String) -> String {
267    return String::from(super::js_text::trim_javascript(text.as_str()));
268}
269
270/// What:
271///  Scan for open holders with the host's mechanism.
272/// Why:
273///   `scanPlatformHolders`.
274///
275/// In TS you'd write (pseudocode):
276/// ```ts
277/// async function scanPlatformHolders({ lockPath, lock }): Promise<LockHolderEvidence>;
278/// ```
279pub fn scan_platform_holders(lock_path: &Path, device: u64, inode: u64) -> HolderEvidence {
280    #[cfg(target_os = "linux")]
281    {
282        let _ = lock_path;
283        return scan_proc_fd_holders(Path::new("/proc"), device, inode, std::process::id());
284    }
285    #[cfg(target_os = "macos")]
286    {
287        return scan_lsof_holders(lock_path, device, inode);
288    }
289    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
290    #[cfg(windows)]
291    {
292        let _ = (device, inode);
293        return scan_restart_manager_holders(lock_path);
294    }
295    #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
296    {
297        let _ = (lock_path, device, inode);
298        return HolderEvidence {
299            method: "unsupported",
300            holders: Vec::new(),
301            partial: vec![String::from("no open-holder scan on this platform")],
302        };
303    }
304}
305
306/// What:
307///  Holders through `lsof -n -P -w -F pcDi -- <lock>`;
308///  exit 1 with output means "no match".
309/// Why:
310///   `scanLsofHolders`;
311///  an unprivileged scan always notes that macOS hides other users.
312///
313/// In TS you'd write (pseudocode):
314/// ```ts
315/// async function scanLsofHolders({ lockPath, device, inode }): Promise<LockHolderEvidence>;
316/// ```
317#[cfg(target_os = "macos")]
318fn scan_lsof_holders(lock_path: &Path, device: u64, inode: u64) -> HolderEvidence {
319    let output: std::io::Result<std::process::Output> = std::process::Command::new("lsof")
320        .args(["-n", "-P", "-w", "-F", "pcDi", "--"])
321        .arg(lock_path)
322        .env("LC_ALL", "C")
323        .stdin(std::process::Stdio::null())
324        .output();
325    let stdout: String = match output {
326        Ok(finished) if finished.status.success() || finished.status.code() == Some(1) => {
327            String::from_utf8_lossy(finished.stdout.as_slice()).into_owned()
328        }
329        Ok(finished) => {
330            return HolderEvidence {
331                method: "lsof",
332                holders: Vec::new(),
333                partial: vec![format!("lsof failed: exit {:?}", finished.status.code())],
334            };
335        }
336        Err(error) => {
337            return HolderEvidence {
338                method: "lsof",
339                holders: Vec::new(),
340                partial: vec![format!("lsof failed: {error}")],
341            };
342        }
343    };
344    // SAFETY: `getuid` has no preconditions.
345    let root: bool = unsafe { libc::getuid() } == 0;
346    return HolderEvidence {
347        method: "lsof",
348        holders: matching_holders(parse_lsof_fields(stdout.as_str()).as_slice(), device, inode),
349        partial: if root { Vec::new() } else { vec![String::from(SILENT_DENIAL_NOTE)] },
350    };
351}
352
353/// What:
354///  Holders through the Restart Manager script,
355///  the path passed in `CLI_GIT_RM_TARGET`.
356/// Why:
357///   `scanRestartManagerHolders`.
358///
359/// In TS you'd write (pseudocode):
360/// ```ts
361/// async function scanRestartManagerHolders(lockPath: string): Promise<LockHolderEvidence>;
362/// ```
363// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
364#[cfg(windows)]
365fn scan_restart_manager_holders(lock_path: &Path) -> HolderEvidence {
366    let output: std::io::Result<std::process::Output> = std::process::Command::new("powershell.exe")
367        .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", RESTART_MANAGER_SCRIPT])
368        .env("CLI_GIT_RM_TARGET", lock_path)
369        .stdin(std::process::Stdio::null())
370        .output();
371    match output {
372        Ok(finished) if finished.status.success() => {
373            return HolderEvidence {
374                method: "restart-manager",
375                holders: parse_restart_manager_output(String::from_utf8_lossy(finished.stdout.as_slice()).as_ref()),
376                partial: Vec::new(),
377            };
378        }
379        Ok(finished) => {
380            return HolderEvidence {
381                method: "restart-manager",
382                holders: Vec::new(),
383                partial: vec![format!("Restart Manager query failed: exit {:?}", finished.status.code())],
384            };
385        }
386        Err(error) => {
387            return HolderEvidence {
388                method: "restart-manager",
389                holders: Vec::new(),
390                partial: vec![format!("Restart Manager query failed: {error}")],
391            };
392        }
393    }
394}
395
396/// Holders found by the parsers,
397///  re-exported for the scans and their controls.
398pub use super::index_lock_holder_parsers::{LsofProcess, matching_holders, parse_lsof_fields, parse_lsof_number, parse_restart_manager_output};
399
400/// Holder-scan controls stay out of the release executable.
401#[cfg(test)]
402#[path = "index_lock_holders_tests.rs"]
403mod tests;