git_policy_cli/ownerless_directory.rs
1//! What:
2//! Whether a published transaction directory without an owner record is abandoned,
3//! and the message for one that is not.
4//! Why:
5//! A power loss can persist the rename that published a transaction directory and the bytes of `owner.json`
6//! but not the name `owner.json` in it.
7//! Nothing then says which process runs the transaction,
8//! so the PID and birth identity that decide every other directory are gone.
9//! The wrapper already judges an owner it cannot probe by age:
10//! an owner of another PID namespace counts as alive until its record outlives `FOREIGN_OWNER_PATIENCE`
11//! (`pid_namespace.rs`).
12//! The same rule serves here:
13//! a directory is abandoned once nothing in it has changed for the patience
14//! and no process has a file or working directory in it.
15//! A live wrapper writes its records within minutes,
16//! and a wrapper cannot lose its own `owner.json` name while it runs,
17//! so the age bound can fail only for a directory whose record someone removed by hand and whose wrapper then sat idle
18//! for longer than the patience.
19//!
20//! In TS you'd write (pseudocode):
21//! ```ts
22//! const standing = await ownerlessStanding(directory); // 'abandoned' | { unproven: reason }
23//! ```
24
25/// The patience an owner that cannot be probed is given.
26use super::pid_namespace::{FOREIGN_OWNER_PATIENCE, age_outlives_patience};
27/// Processes that use a path.
28use super::process_use::{UseScan, UseTarget, processes_using};
29/// The fail-closed recovery failure.
30use super::recovery_error::{RecoveryError, io_failure};
31/// `Path` and `PathBuf` are borrowed and owned filesystem paths.
32use std::path::{Path, PathBuf};
33/// Wall-clock times.
34use std::time::SystemTime;
35
36/// Most entries the age walk reads before it gives up on proving the directory old.
37/// A transaction directory holds a few dozen records at most.
38const ENTRY_LIMIT: usize = 4096;
39
40/// What:
41/// What a directory without an owner record is.
42/// Why:
43/// Recovery treats `Abandoned` like a dead owner's directory and fails closed on `Unproven`, naming the reason.
44///
45/// In TS you'd write (pseudocode):
46/// ```ts
47/// type OwnerlessStanding = 'abandoned' | { unproven: string };
48/// ```
49#[derive(Clone, Debug, Eq, PartialEq)]
50pub enum OwnerlessStanding {
51 /// Nothing in the directory changed for the patience and no process uses it.
52 Abandoned,
53 /// The directory may still be in use; the text says what is not proven.
54 Unproven(String),
55}
56
57/// What:
58/// The newest modification time of a directory and everything in it,
59/// or why the walk stopped.
60/// Why:
61/// A live wrapper writes records anywhere in its directory,
62/// so the age of the directory is the age of its newest entry.
63///
64/// In TS you'd write (pseudocode):
65/// ```ts
66/// Math.max(...(await walk(directory)).map((entry) => entry.mtimeMs))
67/// ```
68fn newest_change(directory: &Path) -> Result<Result<SystemTime, String>, RecoveryError> {
69 let mut newest: SystemTime = SystemTime::UNIX_EPOCH;
70 let mut pending: Vec<PathBuf> = vec![directory.to_path_buf()];
71 let mut seen: usize = 0;
72 while let Some(path) = pending.pop() {
73 seen += 1;
74 if seen > ENTRY_LIMIT {
75 return Ok(Err(format!("It holds more than {ENTRY_LIMIT} entries, too many to date")));
76 }
77 let metadata: std::fs::Metadata = match std::fs::symlink_metadata(&path) {
78 Ok(found) => found,
79 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
80 Err(error) => return Err(io_failure("inspecting", path.as_path(), &error)),
81 };
82 let modified: SystemTime = match metadata.modified() {
83 Ok(found) => found,
84 Err(error) => return Ok(Err(format!("The change time of {} is unreadable ({error})", path.display()))),
85 };
86 newest = newest.max(modified);
87 if metadata.is_dir() {
88 let listing: std::fs::ReadDir = match std::fs::read_dir(&path) {
89 Ok(found) => found,
90 Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue,
91 Err(error) => return Err(io_failure("listing", path.as_path(), &error)),
92 };
93 for item in listing.flatten() {
94 pending.push(item.path());
95 }
96 }
97 }
98 return Ok(Ok(newest));
99}
100
101/// What:
102/// The description of the processes found using a directory.
103/// Why:
104/// The message names the process the person has to deal with.
105///
106/// In TS you'd write (pseudocode):
107/// ```ts
108/// scan.users.map((user) => `PID ${user.pid} (${user.command})`).join(', ')
109/// ```
110fn describe_users(scan: &UseScan) -> String {
111 let mut named: Vec<String> = Vec::new();
112 for user in &scan.users {
113 match &user.command {
114 Some(command) => named.push(format!("PID {} ({command})", user.pid)),
115 None => named.push(format!("PID {}", user.pid)),
116 }
117 }
118 return named.join(", ");
119}
120
121/// What:
122/// Judge a published transaction directory that has no owner record,
123/// with the age of its newest entry against `now` and the processes found using it.
124/// Why:
125/// The two proofs are separate so a control can set the clock and the scan.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// ownerlessStandingAt({ directory, now, scan })
130/// ```
131pub fn ownerless_standing_with(
132 directory: &Path,
133 now: SystemTime,
134 scan: fn(&Path) -> UseScan,
135) -> Result<OwnerlessStanding, RecoveryError> {
136 let newest: SystemTime = match newest_change(directory)? {
137 Ok(found) => found,
138 Err(reason) => return Ok(OwnerlessStanding::Unproven(reason)),
139 };
140 let Ok(age) = now.duration_since(newest) else {
141 return Ok(OwnerlessStanding::Unproven(String::from("Its newest entry is dated in the future")));
142 };
143 if !age_outlives_patience(age) {
144 return Ok(OwnerlessStanding::Unproven(format!(
145 "Its newest entry changed {} seconds ago",
146 age.as_secs()
147 )));
148 }
149 let found: UseScan = scan(directory);
150 if !found.users.is_empty() {
151 return Ok(OwnerlessStanding::Unproven(format!(
152 "{} has a file or working directory in it",
153 describe_users(&found)
154 )));
155 }
156 return Ok(OwnerlessStanding::Abandoned);
157}
158
159/// What:
160/// The processes using the canonical form of a directory.
161/// Why:
162/// Links in `/proc` resolve to canonical paths, so the directory is compared in that form.
163///
164/// In TS you'd write (pseudocode):
165/// ```ts
166/// processesUsing({ inside: await realpath(directory) })
167/// ```
168fn scan_canonical(directory: &Path) -> UseScan {
169 let canonical: PathBuf = std::fs::canonicalize(directory).unwrap_or_else(|_| { return directory.to_path_buf(); });
170 return processes_using(&UseTarget::Inside(canonical.as_path()));
171}
172
173/// What:
174/// Judge a published transaction directory that has no owner record,
175/// by the clock and the host's process table.
176/// Why:
177/// The production judgement.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// await ownerlessStanding(directory)
182/// ```
183pub fn ownerless_standing(directory: &Path) -> Result<OwnerlessStanding, RecoveryError> {
184 return ownerless_standing_with(directory, SystemTime::now(), scan_canonical);
185}
186
187/// What:
188/// The failure for a directory whose owner is not proven gone.
189/// Why:
190/// The diagnostic names the directory, says why nothing is recovered, when cli-git recovers it by itself,
191/// and the one manual way out with the condition under which it is safe.
192///
193/// In TS you'd write (pseudocode):
194/// ```ts
195/// new CommitTransactionRecoveryError(`Transaction owner record is missing: ${directory}. ...`)
196/// ```
197pub fn ownerless_refusal(directory: &Path, reason: &str) -> RecoveryError {
198 let minutes: u64 = FOREIGN_OWNER_PATIENCE.as_secs() / 60;
199 return RecoveryError(format!(
200 "Transaction owner record is missing: {dir}. A published transaction directory names the cli-git process that \
201 runs it in `owner.json`, so cli-git cannot tell whether that process is still running, and every guarded \
202 command stops (exit 2) until this directory is dealt with. {reason}. Once nothing in it has changed for \
203 {minutes} minutes and no process has a file or working directory in it, the next guarded command recovers \
204 the directory by itself, with no action from you. To continue sooner, first confirm that no cli-git or git command is running \
205 in this repository (for example with `ps`), then remove the directory with `rm -r {dir}`. Remove it by hand \
206 only when it holds no `landing-<n>.json` record: with one, the commit may already have moved the branch, and \
207 removing the directory skips the index installation that cli-git's own recovery performs, so wait for the \
208 automatic recovery instead. The usual cause is a power loss that kept the file `owner.json` but not its name \
209 in the directory.",
210 dir = directory.display()
211 ));
212}
213
214/// Ownerless-directory controls stay out of the release executable.
215#[cfg(test)]
216#[path = "ownerless_directory_tests.rs"]
217mod tests;