Skip to main content

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;