git_policy_cli/invocation/started_as_git.rs
1//! What:
2//! The marker that every start of the real Git carries,
3//! and the check that stops a cli-git executable which finds it was
4//! started as that Git.
5//! Why:
6//! Which file the wrapper selected as Git can be a launcher,
7//! a link,
8//! a copy or another release of the wrapper,
9//! and no test
10//! of that file can recognise every one of them.
11//! What no spelling hides is the start itself:
12//! the wrapper that starts
13//! "Git" puts its own process number,
14//! that process's birth identity and the way it starts the program (replacing itself or
15//! as a child) in the environment of the start.
16//! A cli-git executable that finds itself in the very process,
17//! or the child,
18//! that a wrapper started as Git has been started as Git,
19//! whatever stood in between,
20//! and stops at once.
21//! Anything started further down (a hook,
22//! an alias,
23//! an editor a real Git started) has another process in between and
24//! passes:
25//! running `git` there through an installed wrapper stays legitimate.
26//!
27//! In TS you'd write (pseudocode):
28//! ```ts
29//! // child_process.spawn(realGit, args, { env: { ...env, CLI_GIT_STARTED_AS_GIT: `${process.pid}\x1f${identity}\x1fchild` } });
30//! // if (marker && marker.pid === process.ppid && sameBirth(marker)) { stop('started as Git by a cli-git process'); }
31//! ```
32
33/// The one lookup of a named variable in an injected environment.
34use crate::child_environment::{FORWARD_TARGET_VARIABLE, environment_value};
35/// What:
36/// The birth identity of a process,
37/// which changes when its number is reused.
38/// Why:
39/// A process number alone could name a later process after the first one ended;
40/// the identity cannot.
41use crate::process_identity::{current_birth_identity, process_birth_identity};
42/// What:
43/// `OsStr`/`OsString` are borrowed/owned operating-system text of raw bytes (siblings `&str`/`String` must be UTF-8).
44/// Why:
45/// Environment values and paths need not be UTF-8.
46use std::ffi::{OsStr, OsString};
47/// `Path` is a borrowed filesystem path of raw bytes (owned sibling `PathBuf`).
48use std::path::Path;
49/// The child-process builder that receives the marker.
50use std::process::Command;
51
52/// What:
53/// The name of the marker variable.
54/// `&str` is a borrowed text baked into the program (owned sibling:
55/// `String`).
56/// Why:
57/// Every start of the real Git sets it,
58/// and every wrapper start reads it.
59///
60/// In TS you'd write (pseudocode):
61/// ```ts
62/// const STARTED_AS_GIT_VARIABLE = 'CLI_GIT_STARTED_AS_GIT';
63/// ```
64pub const STARTED_AS_GIT_VARIABLE: &str = "CLI_GIT_STARTED_AS_GIT";
65
66/// The byte between the three fields of the marker:
67/// the unit separator,
68/// which a process number,
69/// a birth identity or a mode
70/// word never contains.
71const FIELD_SEPARATOR: char = '\u{1f}';
72
73/// The mode word of a start that replaces the starting process.
74const REPLACE_WORD: &str = "replace";
75
76/// The mode word of a start that runs beside the starting process,
77/// which waits for it.
78const CHILD_WORD: &str = "child";
79
80/// What:
81/// How the process that starts "Git" starts it.
82/// `#[derive(...)]` generates copying,
83/// debug printing and `==`.
84/// Why:
85/// The two ways leave different traces.
86/// A replaced process keeps its number,
87/// so the started program is the very process
88/// the marker names;
89/// a child has the starter as its parent.
90/// The check looks at the trace its mode leaves and nowhere else.
91///
92/// In TS you'd write (pseudocode):
93/// ```ts
94/// type StartMode = 'replace' | 'child';
95/// ```
96#[derive(Clone, Copy, Debug, Eq, PartialEq)]
97pub enum StartMode {
98 /// The starter replaces its own program with the one it starts (`exec`);
99 /// the process number stays.
100 Replace,
101 /// The starter runs the program as a child and waits for it.
102 Child,
103}
104
105/// What:
106/// What a marker says,
107/// taken apart.
108/// `u32` is an unsigned 32-bit integer (siblings `u64`,
109/// `i32`):
110/// the type of a Linux process number.
111/// `String` is owned text (sibling `&str`,
112/// a borrowed view).
113/// Why:
114/// The check compares these three facts with the live process;
115/// keeping them in one value lets a test hand the check
116/// a marker without touching the real environment.
117///
118/// In TS you'd write (pseudocode):
119/// ```ts
120/// type StartMarker = { starterPid: number; starterIdentity: string; mode: StartMode };
121/// ```
122#[derive(Clone, Debug, Eq, PartialEq)]
123pub struct StartMarker {
124 /// The process number of the wrapper that started the program as Git.
125 pub starter_pid: u32,
126 /// The birth identity of that process;
127 /// empty where this platform has none.
128 pub starter_identity: String,
129 /// How the program was started.
130 pub mode: StartMode,
131}
132
133/// What:
134/// Build the marker value for a start by the process `own_pid` with birth identity `own_identity`.
135/// `&str` borrows the identity text;
136/// `OsString` is the owned value an environment holds.
137/// Why:
138/// One function writes the format and `parse_marker` reads it,
139/// so the two cannot drift apart.
140///
141/// In TS you'd write (pseudocode):
142/// ```ts
143/// const value = [pid, identity, mode === 'replace' ? 'replace' : 'child'].join('\x1f');
144/// ```
145pub fn marker_value(mode: StartMode, own_pid: u32, own_identity: &str) -> OsString {
146 let word: &str = if mode == StartMode::Replace { REPLACE_WORD } else { CHILD_WORD };
147 // `format!` builds owned text; `OsString::from` turns it into environment text.
148 return OsString::from(format!("{own_pid}{FIELD_SEPARATOR}{own_identity}{FIELD_SEPARATOR}{word}"));
149}
150
151/// What:
152/// Take a marker value apart.
153/// `Option<StartMarker>` is "the marker,
154/// or nothing when the text is not one".
155/// Why:
156/// A variable some other program set to other text must not stop a wrapper,
157/// so anything that is not exactly three
158/// fields of the right kinds is no marker.
159///
160/// In TS you'd write (pseudocode):
161/// ```ts
162/// function parseMarker(value: string): StartMarker | undefined;
163/// ```
164pub fn parse_marker(value: &OsStr) -> Option<StartMarker> {
165 // `?` on an `Option` returns `None` from this function when the value is absent; here the text must be UTF-8.
166 let text: &str = value.to_str()?;
167 let mut fields = text.split(FIELD_SEPARATOR);
168 let pid_text: &str = fields.next()?;
169 let identity: &str = fields.next()?;
170 let word: &str = fields.next()?;
171 if fields.next().is_some() {
172 return None;
173 }
174 // `.parse::<u32>().ok()?` reads the digits, turning a failure into `None`.
175 let starter_pid: u32 = pid_text.parse::<u32>().ok()?;
176 let mode: StartMode = match word {
177 REPLACE_WORD => StartMode::Replace,
178 CHILD_WORD => StartMode::Child,
179 _ => return None,
180 };
181 // `Some(...)` is the "present" case of an `Option`.
182 return Some(StartMarker { starter_pid, starter_identity: identity.to_string(), mode });
183}
184
185/// What:
186/// The birth identity of this process,
187/// or empty text where none can be read.
188/// Why:
189/// A start is marked even where the platform cannot name a birth;
190/// the check then compares process numbers alone.
191/// Only Linux reads it here,
192/// from one small file that stays cached for the life of the process;
193/// the other platforms
194/// would start a program to ask,
195/// which every real-Git start must not pay.
196///
197/// In TS you'd write (pseudocode):
198/// ```ts
199/// const identity = process.platform === 'linux' ? readBirth(process.pid) ?? '' : '';
200/// ```
201fn own_identity() -> String {
202 #[cfg(target_os = "linux")]
203 {
204 // `.unwrap_or_default()` substitutes empty text when the identity cannot be read.
205 return current_birth_identity().unwrap_or_default();
206 }
207 #[cfg(not(target_os = "linux"))]
208 {
209 return String::new();
210 }
211}
212
213/// What:
214/// Put the marker in the environment of a real-Git start.
215/// `&mut Command` lends the builder for changing.
216/// Why:
217/// Every place that starts the real Git calls this,
218/// so a wrapper that arrives as Git can tell.
219/// `Command::env` overrides
220/// an earlier value of the same name,
221/// so a start that replaces the process can override the child marker that the shared
222/// builder put there.
223///
224/// In TS you'd write (pseudocode):
225/// ```ts
226/// command.env.CLI_GIT_STARTED_AS_GIT = markerValue(mode, process.pid, ownIdentity());
227/// ```
228pub fn mark_real_git_start(command: &mut Command, mode: StartMode) {
229 command.env(STARTED_AS_GIT_VARIABLE, marker_value(mode, std::process::id(), own_identity().as_str()));
230}
231
232/// What:
233/// What the check asks of the live process.
234/// `&dyn Fn(u32) -> Option<String>` borrows any function from a process number to its birth identity.
235/// Why:
236/// A control hands the check made-up numbers and identities;
237/// production hands it the process's own facts.
238///
239/// In TS you'd write (pseudocode):
240/// ```ts
241/// type ProcessFacts = { ownPid: number; parentPid: number; identityOf: (pid: number) => string | undefined };
242/// ```
243pub struct ProcessFacts<'a> {
244 /// This process's number.
245 pub own_pid: u32,
246 /// The number of this process's parent.
247 pub parent_pid: u32,
248 /// The birth identity of the process with a number;
249 /// `None` when it cannot be read.
250 pub identity_of: &'a dyn Fn(u32) -> Option<String>,
251}
252
253/// What:
254/// Whether the marker names the process this one was started by,
255/// in the way its mode says.
256/// Why:
257/// `Replace`:
258/// the starter replaced itself,
259/// so this is that very process (same number,
260/// same birth).
261/// `Child`:
262/// the starter
263/// ran this program as its child,
264/// so the starter is this process's parent.
265/// In both a identity the platform can read must
266/// agree,
267/// so a later process that reuses the number is not taken for the starter.
268/// A process further down has another
269/// process in between and is not the start.
270///
271/// In TS you'd write (pseudocode):
272/// ```ts
273/// function startedAsGit(marker: StartMarker, facts: ProcessFacts): boolean;
274/// ```
275pub fn started_as_git(marker: &StartMarker, facts: &ProcessFacts<'_>) -> bool {
276 let candidate: u32 = if marker.mode == StartMode::Replace { facts.own_pid } else { facts.parent_pid };
277 if candidate != marker.starter_pid {
278 return false;
279 }
280 if marker.starter_identity.is_empty() {
281 return true;
282 }
283 // What: `match` on the identity read: a readable identity must be the recorded one; an unreadable one cannot
284 // contradict it, so the numbers decide.
285 // Why: Stopping one start too many is repaired by the person who reads the message; missing a start is not.
286 //
287 // In TS you'd write (pseudocode):
288 // ```ts
289 // const found = facts.identityOf(candidate); return found === undefined || found === marker.starterIdentity;
290 // ```
291 match (facts.identity_of)(candidate) {
292 Some(found) => return found == marker.starter_identity,
293 None => return true,
294 }
295}
296
297/// What:
298/// The marker of an environment,
299/// if it carries one.
300/// Why:
301/// The caller's environment is passed in,
302/// never read from the process,
303/// so a control sets any value it likes.
304///
305/// In TS you'd write (pseudocode):
306/// ```ts
307/// const marker = parseMarker(environment.CLI_GIT_STARTED_AS_GIT);
308/// ```
309pub fn marker_of(environment: &[(OsString, OsString)]) -> Option<StartMarker> {
310 let value: OsString = environment_value(environment, STARTED_AS_GIT_VARIABLE)?;
311 return parse_marker(value.as_os_str());
312}
313
314/// What:
315/// The words a person reads when a cli-git executable stops because it was started as Git.
316/// `&Path` borrows this executable's path for the message.
317/// Why:
318/// The message names the executable,
319/// the process that started it and how,
320/// the Git that process chose when the
321/// environment says so,
322/// the `PATH` this process saw,
323/// why stopping is right,
324/// and every way out.
325///
326/// In TS you'd write (pseudocode):
327/// ```ts
328/// function refusalText(marker, ownExecutable, environment): string;
329/// ```
330pub fn refusal_text(marker: &StartMarker, own_executable: &Path, environment: &[(OsString, OsString)]) -> String {
331 let how: String = if marker.mode == StartMode::Replace {
332 format!("this is process {}, which a cli-git wrapper replaced with this program", marker.starter_pid)
333 } else {
334 format!("this is a child of the cli-git wrapper that runs as process {} and waits for its answer", marker.starter_pid)
335 };
336 let chosen: String = match environment_value(environment, FORWARD_TARGET_VARIABLE) {
337 Some(target) => format!(" The file that process chose as Git is {}.", Path::new(&target).display()),
338 None => String::new(),
339 };
340 let search_path: String = match environment_value(environment, "PATH") {
341 Some(value) => format!(" PATH here is {}.", Path::new(&value).display()),
342 None => String::from(" PATH is not set here."),
343 };
344 return format!(
345 "this cli-git executable ({}) was started as Git by a cli-git wrapper: {how}.{chosen}{search_path} \
346 A cli-git wrapper never runs itself as Git, so it stops here instead of starting one more wrapper, which would \
347 repeat without end. This happens when the first `git` that PATH offers after the wrapper leads back to cli-git: \
348 a link to it, a copy of it, its toolchain launcher, or another release of it. To fix it, put the directory of the \
349 real Git (for example /usr/bin) on the PATH of the program that started the first cli-git, or remove the entry that \
350 leads back to cli-git from that PATH.",
351 own_executable.display()
352 );
353}
354
355/// What:
356/// The birth identity of the process with a number, read from the operating system now.
357/// `.ok().flatten()` turns a read failure and an absent process into the same `None`.
358/// Why:
359/// The check treats an identity it cannot read as no contradiction, so both cases need no distinction here.
360///
361/// In TS you'd write (pseudocode):
362/// ```ts
363/// function liveIdentityOf(pid: number): string | undefined { try { return readBirth(pid); } catch { return undefined; } }
364/// ```
365fn live_identity_of(pid: u32) -> Option<String> {
366 return process_birth_identity(i64::from(pid)).ok().flatten();
367}
368
369/// What:
370/// Whether this process was started as Git by a cli-git wrapper,
371/// read from the live process,
372/// and if so the words to
373/// say so.
374/// `Option<String>` is "the message,
375/// or nothing when this process was not started as Git".
376/// Why:
377/// The executable calls it once,
378/// before it plans anything.
379/// It reads the parent's number and,
380/// only when the marker's
381/// number matches,
382/// one small process file for the birth identity,
383/// so an ordinary start costs a variable lookup.
384///
385/// In TS you'd write (pseudocode):
386/// ```ts
387/// function refusalWhenStartedAsGit(environment, ownExecutable): string | undefined;
388/// ```
389pub fn refusal_when_started_as_git(environment: &[(OsString, OsString)], own_executable: &Path) -> Option<String> {
390 let marker: StartMarker = marker_of(environment)?;
391 let facts: ProcessFacts<'_> = ProcessFacts { own_pid: std::process::id(), parent_pid: parent_pid(), identity_of: &live_identity_of };
392 if !started_as_git(&marker, &facts) {
393 return None;
394 }
395 return Some(refusal_text(&marker, own_executable, environment));
396}
397
398/// What:
399/// The process number of this process's parent.
400/// Why:
401/// A child start leaves the starter as the parent;
402/// the standard library does not expose it.
403///
404/// In TS you'd write (pseudocode):
405/// ```ts
406/// const parentPid = process.ppid;
407/// ```
408#[cfg(unix)]
409fn parent_pid() -> u32 {
410 // SAFETY: `getppid` takes no argument, reads no memory of this process and cannot fail.
411 let parent: libc::pid_t = unsafe { libc::getppid() };
412 // `u32::try_from(..)` refuses a negative number, which `getppid` never returns; `.unwrap_or(0)` then means no parent.
413 return u32::try_from(parent).unwrap_or(0);
414}
415
416/// Without a parent number the child check matches nothing (this crate runs on Linux only).
417#[cfg(not(unix))]
418fn parent_pid() -> u32 {
419 return 0;
420}
421
422/// Disposable-fact controls of the marker and the check stay out of the release executable.
423#[cfg(test)]
424#[path = "started_as_git_tests.rs"]
425mod tests;