git_policy_cli/lock/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 return scan_proc_fd_holders_until(proc_root, device, inode, self_pid, None);
212}
213
214/// What:
215/// `scan_proc_fd_holders`,
216/// stopping at `deadline` when one is given.
217/// Why:
218/// The commit service scans while commits queue behind it,
219/// and a host with many processes must not hold them up beyond a bound:
220/// the processes not reached are said in the evidence's partial reasons,
221/// and the holders found so far are kept.
222///
223/// In TS you'd write (pseudocode):
224/// ```ts
225/// await scanProcFdHolders({ device, inode, procRoot, selfPid, deadline });
226/// ```
227#[cfg(unix)]
228pub fn scan_proc_fd_holders_until(proc_root: &Path, device: u64, inode: u64, self_pid: u32, deadline: Option<std::time::Instant>) -> HolderEvidence {
229 let mut evidence: HolderEvidence = HolderEvidence {
230 method: "proc-fd",
231 holders: Vec::new(),
232 partial: Vec::new(),
233 };
234 let listing: std::fs::ReadDir = match std::fs::read_dir(proc_root) {
235 Ok(found) => found,
236 Err(error) => {
237 evidence.partial.push(format!("{}: {error}", proc_root.display()));
238 return evidence;
239 }
240 };
241 let own: String = self_pid.to_string();
242 let mut pids: Vec<String> = Vec::new();
243 for item in listing.flatten() {
244 let name: String = item.file_name().to_string_lossy().into_owned();
245 if is_pid_name(name.as_str()) && name != own {
246 pids.push(name);
247 }
248 }
249 for (reached, pid) in pids.iter().enumerate() {
250 if deadline.is_some_and(|limit| { return std::time::Instant::now() >= limit; }) {
251 evidence.partial.push(format!("stopped at the time bound after {reached} of {} processes", pids.len()));
252 break;
253 }
254 let (holds, partial) = scan_process(proc_root, pid.as_str(), device, inode);
255 if holds && let Ok(number) = pid.parse::<i64>() {
256 let command: Option<String> = std::fs::read_to_string(proc_root.join(pid).join("comm"))
257 .ok()
258 .map(trimmed);
259 evidence.holders.push(Holder { pid: number, command });
260 }
261 if let Some(reason) = partial {
262 evidence.partial.push(reason);
263 }
264 }
265 crate::diagnostic_log::debug(
266 "scanProcFdHolders",
267 format!(
268 "scanned {} processes: {} holders, {} unreadable",
269 pids.len(),
270 evidence.holders.len(),
271 evidence.partial.len()
272 )
273 .as_str(),
274 );
275 return evidence;
276}
277
278/// What:
279/// Text without surrounding whitespace.
280/// Why:
281/// A named function keeps `map` free of closures.
282///
283/// In TS you'd write (pseudocode):
284/// ```ts
285/// (text: string) => text.trim()
286/// ```
287#[cfg(unix)]
288fn trimmed(text: String) -> String {
289 return String::from(crate::js_text::trim_javascript(text.as_str()));
290}
291
292/// How long the commit service's scan for the processes that have a lock open may take.
293///
294/// Measured on 2026-11-03 on this host with 858 processes: listing every process's open descriptors (`ls -l /proc/[0-9]*/fd`,
295/// the same walk) took 0.10 s in each of three runs, so the budget is two and a half times a whole walk. A scan that runs
296/// out returns what it found; the holder then reads as one nothing proves alive, which is waited for the short backoff.
297pub const SERVICE_SCAN_BUDGET: std::time::Duration = std::time::Duration::from_millis(250);
298
299/// The scan for open holders that the commit service makes:
300/// the host's,
301/// bounded in time where the host's is `/proc`.
302pub fn scan_platform_holders_bounded(lock_path: &Path, device: u64, inode: u64) -> HolderEvidence {
303 #[cfg(target_os = "linux")]
304 {
305 let _ = lock_path;
306 return scan_proc_fd_holders_until(Path::new("/proc"), device, inode, std::process::id(), Some(std::time::Instant::now() + SERVICE_SCAN_BUDGET));
307 }
308 #[cfg(not(target_os = "linux"))]
309 {
310 return scan_platform_holders(lock_path, device, inode);
311 }
312}
313
314/// What:
315/// Scan for open holders with the host's mechanism.
316/// Why:
317/// `scanPlatformHolders`.
318///
319/// In TS you'd write (pseudocode):
320/// ```ts
321/// async function scanPlatformHolders({ lockPath, lock }): Promise<LockHolderEvidence>;
322/// ```
323pub fn scan_platform_holders(lock_path: &Path, device: u64, inode: u64) -> HolderEvidence {
324 #[cfg(target_os = "linux")]
325 {
326 let _ = lock_path;
327 return scan_proc_fd_holders(Path::new("/proc"), device, inode, std::process::id());
328 }
329 #[cfg(target_os = "macos")]
330 {
331 return scan_lsof_holders(lock_path, device, inode);
332 }
333 // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
334 #[cfg(windows)]
335 {
336 let _ = (device, inode);
337 return scan_restart_manager_holders(lock_path);
338 }
339 #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))]
340 {
341 let _ = (lock_path, device, inode);
342 return HolderEvidence {
343 method: "unsupported",
344 holders: Vec::new(),
345 partial: vec![String::from("no open-holder scan on this platform")],
346 };
347 }
348}
349
350/// What:
351/// Holders through `lsof -n -P -w -F pcDi -- <lock>`;
352/// exit 1 with output means "no match".
353/// Why:
354/// `scanLsofHolders`;
355/// an unprivileged scan always notes that macOS hides other users.
356///
357/// In TS you'd write (pseudocode):
358/// ```ts
359/// async function scanLsofHolders({ lockPath, device, inode }): Promise<LockHolderEvidence>;
360/// ```
361#[cfg(target_os = "macos")]
362fn scan_lsof_holders(lock_path: &Path, device: u64, inode: u64) -> HolderEvidence {
363 let output: std::io::Result<std::process::Output> = std::process::Command::new("lsof")
364 .args(["-n", "-P", "-w", "-F", "pcDi", "--"])
365 .arg(lock_path)
366 .env("LC_ALL", "C")
367 .stdin(std::process::Stdio::null())
368 .output();
369 let stdout: String = match output {
370 Ok(finished) if finished.status.success() || finished.status.code() == Some(1) => {
371 String::from_utf8_lossy(finished.stdout.as_slice()).into_owned()
372 }
373 Ok(finished) => {
374 return HolderEvidence {
375 method: "lsof",
376 holders: Vec::new(),
377 partial: vec![format!("lsof failed: exit {:?}", finished.status.code())],
378 };
379 }
380 Err(error) => {
381 return HolderEvidence {
382 method: "lsof",
383 holders: Vec::new(),
384 partial: vec![format!("lsof failed: {error}")],
385 };
386 }
387 };
388 // SAFETY: `getuid` has no preconditions.
389 let root: bool = unsafe { libc::getuid() } == 0;
390 return HolderEvidence {
391 method: "lsof",
392 holders: matching_holders(parse_lsof_fields(stdout.as_str()).as_slice(), device, inode),
393 partial: if root { Vec::new() } else { vec![String::from(SILENT_DENIAL_NOTE)] },
394 };
395}
396
397/// What:
398/// Holders through the Restart Manager script,
399/// the path passed in `CLI_GIT_RM_TARGET`.
400/// Why:
401/// `scanRestartManagerHolders`.
402///
403/// In TS you'd write (pseudocode):
404/// ```ts
405/// async function scanRestartManagerHolders(lockPath: string): Promise<LockHolderEvidence>;
406/// ```
407// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
408#[cfg(windows)]
409fn scan_restart_manager_holders(lock_path: &Path) -> HolderEvidence {
410 let output: std::io::Result<std::process::Output> = std::process::Command::new("powershell.exe")
411 .args(["-NoLogo", "-NoProfile", "-NonInteractive", "-Command", RESTART_MANAGER_SCRIPT])
412 .env("CLI_GIT_RM_TARGET", lock_path)
413 .stdin(std::process::Stdio::null())
414 .output();
415 match output {
416 Ok(finished) if finished.status.success() => {
417 return HolderEvidence {
418 method: "restart-manager",
419 holders: parse_restart_manager_output(String::from_utf8_lossy(finished.stdout.as_slice()).as_ref()),
420 partial: Vec::new(),
421 };
422 }
423 Ok(finished) => {
424 return HolderEvidence {
425 method: "restart-manager",
426 holders: Vec::new(),
427 partial: vec![format!("Restart Manager query failed: exit {:?}", finished.status.code())],
428 };
429 }
430 Err(error) => {
431 return HolderEvidence {
432 method: "restart-manager",
433 holders: Vec::new(),
434 partial: vec![format!("Restart Manager query failed: {error}")],
435 };
436 }
437 }
438}
439
440/// Holders found by the parsers,
441/// re-exported for the scans and their controls.
442pub use crate::index_lock_holder_parsers::{LsofProcess, matching_holders, parse_lsof_fields, parse_lsof_number, parse_restart_manager_output};
443
444/// Holder-scan controls stay out of the release executable.
445#[cfg(test)]
446#[path = "index_lock_holders_tests.rs"]
447mod tests;