Skip to main content

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;