git_policy_cli/process_use.rs
1//! What:
2//! The processes of this host that use a directory or a file:
3//! a working directory or an open descriptor inside the directory,
4//! or an open descriptor on the file.
5//! Why:
6//! Recovery may treat a directory or a lock as abandoned only when no process can still be using it.
7//! A process that holds a path open is the one proof of use that survives a lost record,
8//! so the scan reads it from `/proc` and says what it could not read instead of guessing.
9//! Only processes of the scanning account are reported as unreadable:
10//! another account's process cannot have opened a private file this account created,
11//! and every unprivileged scan meets some of them.
12//!
13//! In TS you'd write (pseudocode):
14//! ```ts
15//! const users = await processesUsing({ target: { inside: directory }, selfPid: process.pid });
16//! ```
17
18/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
19use std::path::{Path, PathBuf};
20
21/// The root of the process table on Linux.
22pub const PROC_ROOT: &str = "/proc";
23
24/// What:
25/// What a scan looks for.
26/// Why:
27/// A directory is matched by path because a record inside it may be opened under any name;
28/// a file is matched by device and inode because a lock has more than one name.
29///
30/// In TS you'd write (pseudocode):
31/// ```ts
32/// type UseTarget = { inside: string } | { file: { device: bigint; inode: bigint } };
33/// ```
34#[derive(Clone, Copy, Debug, Eq, PartialEq)]
35pub enum UseTarget<'a> {
36 /// A working directory or open descriptor whose resolved path lies under this canonical directory.
37 Inside(&'a Path),
38 /// An open descriptor that resolves to this device and inode.
39 File {
40 /// Device number of the file.
41 device: u64,
42 /// Inode number of the file.
43 inode: u64,
44 },
45}
46
47/// One process found using the target.
48#[derive(Clone, Debug, Eq, PartialEq)]
49pub struct ProcessUser {
50 /// The process number.
51 pub pid: i64,
52 /// The command name when readable.
53 pub command: Option<String>,
54}
55
56/// What:
57/// The processes found using a target and the reasons the scan could not see everything.
58/// Why:
59/// A caller that cannot rely on a scan with no process table refuses when `unavailable` is set;
60/// `unreadable` lists single processes the scan could not see into, which a caller may or may not tolerate.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// type UseScan = { users: ProcessUser[]; unreadable: string[]; unavailable?: string };
65/// ```
66#[derive(Clone, Debug, Default, Eq, PartialEq)]
67pub struct UseScan {
68 /// Processes using the target.
69 pub users: Vec<ProcessUser>,
70 /// Reasons some process of the scanning account could not be read.
71 pub unreadable: Vec<String>,
72 /// Why the process table itself could not be listed, so that no process was scanned.
73 pub unavailable: Option<String>,
74}
75
76/// What:
77/// Whether an error means the process exited during the scan.
78/// Why:
79/// `ENOENT` and `ESRCH` are expected races, never unreadable evidence.
80///
81/// In TS you'd write (pseudocode):
82/// ```ts
83/// ['ENOENT', 'ESRCH'].includes(error.code)
84/// ```
85fn exited(error: &std::io::Error) -> bool {
86 return error.kind() == std::io::ErrorKind::NotFound || error.raw_os_error() == Some(libc::ESRCH);
87}
88
89/// What:
90/// The account this process runs as.
91/// Why:
92/// Unreadable processes of other accounts are not evidence against a private file.
93///
94/// In TS you'd write (pseudocode):
95/// ```ts
96/// process.geteuid()
97/// ```
98#[cfg(unix)]
99fn effective_user() -> u32 {
100 // SAFETY: `geteuid` reads the process credentials and cannot fail.
101 return unsafe { libc::geteuid() };
102}
103
104/// What:
105/// Whether a path belongs to the account that runs this process.
106/// Why:
107/// A process directory in `/proc` belongs to the account of the process.
108///
109/// In TS you'd write (pseudocode):
110/// ```ts
111/// (await stat(path)).uid === process.geteuid()
112/// ```
113#[cfg(unix)]
114fn belongs_to_this_account(path: &Path) -> bool {
115 /// The Unix metadata accessors, for the owning account.
116 use std::os::unix::fs::MetadataExt;
117 return std::fs::metadata(path).is_ok_and(|metadata| { return metadata.uid() == effective_user(); });
118}
119
120/// What:
121/// Without accounts, no process is reported as unreadable evidence.
122/// Why:
123/// Without `/proc` the scan has already reported that it could not read the process table.
124///
125/// In TS you'd write (pseudocode):
126/// ```ts
127/// false
128/// ```
129// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
130#[cfg(not(unix))]
131fn belongs_to_this_account(_path: &Path) -> bool {
132 return false;
133}
134
135/// What:
136/// Whether a descriptor link resolves to the file with this device and inode.
137/// Why:
138/// A lock has more than one name, so only the identity of the open file says whether it is the lock.
139///
140/// In TS you'd write (pseudocode):
141/// ```ts
142/// const { dev, ino } = await stat(link, { bigint: true });
143/// ```
144#[cfg(unix)]
145fn same_file(link: &Path, device: u64, inode: u64) -> std::io::Result<bool> {
146 /// The Unix metadata accessors, for the device and inode.
147 use std::os::unix::fs::MetadataExt;
148 let metadata: std::fs::Metadata = std::fs::metadata(link)?;
149 return Ok(metadata.dev() == device && metadata.ino() == inode);
150}
151
152/// What:
153/// Without device and inode numbers, no descriptor is recognized as the file.
154/// Why:
155/// The caller then finds no user and relies on its other proofs; the crate does not build for Windows.
156///
157/// In TS you'd write (pseudocode):
158/// ```ts
159/// false
160/// ```
161// UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
162#[cfg(not(unix))]
163fn same_file(_link: &Path, _device: u64, _inode: u64) -> std::io::Result<bool> {
164 return Ok(false);
165}
166
167/// What:
168/// Whether a resolved link or descriptor matches the target.
169/// Why:
170/// One place decides a match for the working directory and for each descriptor.
171///
172/// In TS you'd write (pseudocode):
173/// ```ts
174/// target.inside ? resolved.startsWith(target.inside) : sameFile(resolved, target.file)
175/// ```
176fn matches_target(link: &Path, target: &UseTarget<'_>) -> std::io::Result<bool> {
177 match target {
178 UseTarget::Inside(directory) => {
179 let resolved: PathBuf = std::fs::read_link(link)?;
180 return Ok(resolved.starts_with(directory));
181 }
182 UseTarget::File { device, inode } => return same_file(link, *device, *inode),
183 }
184}
185
186/// What:
187/// Whether one process uses the target,
188/// and why its links were unreadable.
189/// Why:
190/// The first unreadable link is kept as the reason.
191///
192/// In TS you'd write (pseudocode):
193/// ```ts
194/// async function scanProcess({ procRoot, pid, target }): Promise<{ uses: boolean; unreadable?: string }>;
195/// ```
196fn scan_process(proc_root: &Path, pid: &str, target: &UseTarget<'_>) -> (bool, Option<String>) {
197 let process: PathBuf = proc_root.join(pid);
198 let mut uses: bool = false;
199 let mut unreadable: Option<String> = None;
200 if matches!(target, UseTarget::Inside(_)) {
201 match matches_target(process.join("cwd").as_path(), target) {
202 Ok(found) => uses = found,
203 Err(error) if exited(&error) => {}
204 Err(error) => unreadable = Some(format!("PID {pid} cwd: {error}")),
205 }
206 }
207 let listing: std::fs::ReadDir = match std::fs::read_dir(process.join("fd")) {
208 Ok(found) => found,
209 Err(error) if exited(&error) => return (uses, unreadable),
210 Err(error) => return (uses, unreadable.or(Some(format!("PID {pid} fd: {error}")))),
211 };
212 for item in listing.flatten() {
213 match matches_target(item.path().as_path(), target) {
214 Ok(true) => uses = true,
215 Ok(false) => {}
216 Err(error) if exited(&error) => {}
217 Err(error) => {
218 if unreadable.is_none() {
219 unreadable = Some(format!("PID {pid} fd {}: {error}", item.file_name().to_string_lossy()));
220 }
221 }
222 }
223 }
224 return (uses, unreadable);
225}
226
227/// What:
228/// Every process other than `self_pid` that uses the target, through a proc root.
229/// Why:
230/// Recovery runs this only after a record is missing or a lock is unproven,
231/// so the scan costs nothing on a commit that finds its state whole.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// async function processesUsing({ procRoot, target, selfPid }): Promise<UseScan>;
236/// ```
237pub fn processes_using_through(proc_root: &Path, target: &UseTarget<'_>, self_pid: u32) -> UseScan {
238 let mut scan: UseScan = UseScan::default();
239 let listing: std::fs::ReadDir = match std::fs::read_dir(proc_root) {
240 Ok(found) => found,
241 Err(error) => {
242 scan.unavailable = Some(format!("{}: {error}", proc_root.display()));
243 return scan;
244 }
245 };
246 let own: String = self_pid.to_string();
247 for item in listing.flatten() {
248 let name: String = item.file_name().to_string_lossy().into_owned();
249 let Ok(number) = name.parse::<i64>() else {
250 continue;
251 };
252 if name == own {
253 continue;
254 }
255 let (uses, unreadable) = scan_process(proc_root, name.as_str(), target);
256 if uses {
257 let command: Option<String> = std::fs::read_to_string(item.path().join("comm"))
258 .ok()
259 .map(|text| { return String::from(text.trim()); });
260 scan.users.push(ProcessUser { pid: number, command });
261 }
262 if let Some(reason) = unreadable
263 && belongs_to_this_account(item.path().as_path())
264 {
265 scan.unreadable.push(reason);
266 }
267 }
268 return scan;
269}
270
271/// What:
272/// Every other process that uses the target on this host.
273/// Why:
274/// The production scan of `processes_using_through`.
275///
276/// In TS you'd write (pseudocode):
277/// ```ts
278/// await processesUsing({ target })
279/// ```
280pub fn processes_using(target: &UseTarget<'_>) -> UseScan {
281 return processes_using_through(Path::new(PROC_ROOT), target, std::process::id());
282}
283
284/// Process-use controls stay out of the release executable.
285#[cfg(test)]
286#[path = "process_use_tests.rs"]
287mod tests;