Skip to main content

git_policy_cli/
real_git.rs

1//! What:
2//!  Find the real Git executable on PATH without ever selecting this wrapper.
3//! Why:
4//!  The wrapper is installed as `git` ahead of real Git;
5//!  forwarding needs the
6//!      first usable candidate that is not a cli-git wrapper.
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // const gitPath = resolveRealGit(processResolutionInputs()); // '/usr/bin/git'
11//! ```
12
13/// What:
14///  `use super::...` imports sibling modules of this crate.
15/// Why:
16///   Inputs are read with getenv semantics and each candidate is classified by
17///       the self-exclusion rules.
18///
19/// In TS you'd write (pseudocode):
20/// ```ts
21/// import { classifyCandidate } from './real-git-candidate.ts';
22/// ```
23use super::child_environment::environment_value;
24use super::real_git_candidate::{CandidateKind, classify_candidate};
25/// What:
26///  `OsStr`/`OsString` are borrowed/owned operating-system text of raw OS bytes
27///       (siblings `&str`/`String` must be UTF-8).
28/// Why:
29///   PATH entries and executable paths need not be UTF-8.
30///
31/// In TS you'd write (pseudocode):
32/// ```ts
33/// // Node strings; no byte-preserving equivalent.
34/// ```
35use std::ffi::{OsStr, OsString};
36/// `Path`/`PathBuf` are borrowed/owned filesystem paths made of the same raw bytes.
37use std::path::{Path, PathBuf};
38
39/// Windows executable extensions in shell lookup order when `PATHEXT` is unset.
40pub const DEFAULT_WINDOWS_PATH_EXTENSIONS: &str = ".COM;.EXE;.BAT;.CMD";
41
42/// What:
43///  Which operating-system family's lookup rules apply.
44///       `#[derive(...)]` generates copying,
45///  debug printing and `==`.
46/// Why:
47///   Executable names,
48///  conventional install locations and path identity differ by
49///       family;
50///  passing the family as a value keeps every branch testable on one host.
51///
52/// In TS you'd write (pseudocode):
53/// ```ts
54/// type Platform = 'linux' | 'darwin' | 'win32';
55/// ```
56#[derive(Clone, Copy, Debug, Eq, PartialEq)]
57pub enum Platform {
58    /// Linux and other Unix-like systems:
59    ///  `git`,
60    ///  case-sensitive paths.
61    Unix,
62    /// macOS:
63    ///  Unix rules plus Homebrew and MacPorts locations.
64    MacOs,
65    /// Windows:
66    ///  `git` plus `PATHEXT` extensions,
67    ///  case-insensitive paths.
68    Windows,
69}
70
71/// What:
72///  Everything resolution reads from the process,
73///  gathered into one value.
74///       `Vec<PathBuf>` is an owned list of owned paths.
75/// Why:
76///   Tests inject a disposable PATH and executable identity instead of mutating
77///       the real process environment.
78///
79/// In TS you'd write (pseudocode):
80/// ```ts
81/// type ResolutionInputs = { platform: Platform; path: string; pathExtensions: string;
82///   currentDirectory: string; commonPaths: string[]; ownExecutable: string };
83/// ```
84#[derive(Clone, Debug, Eq, PartialEq)]
85pub struct ResolutionInputs {
86    /// Lookup rules to apply.
87    pub platform: Platform,
88    /// The `PATH` value;
89    ///  empty when unset.
90    pub path: OsString,
91    /// The `PATHEXT` value;
92    ///  only read on Windows.
93    pub path_extensions: OsString,
94    /// Directory that relative PATH entries are resolved against.
95    pub current_directory: PathBuf,
96    /// Conventional Git locations promoted to the front when PATH exposes them.
97    pub common_paths: Vec<PathBuf>,
98    /// This wrapper's own executable,
99    ///  excluded from selection.
100    pub own_executable: PathBuf,
101}
102
103/// What:
104///  The failure when no PATH candidate can act as real Git.
105///       `usize` counts items;
106///  it is the length type of every Rust list.
107/// Why:
108///   The counts tell the user whether Git is missing or only wrappers were found.
109///
110/// In TS you'd write (pseudocode):
111/// ```ts
112/// class RealGitNotFoundError extends Error { candidateCount: number; skippedWrapperCount: number }
113/// ```
114#[derive(Clone, Copy, Debug, Eq, PartialEq)]
115pub struct RealGitNotFound {
116    /// PATH-derived candidates examined before failure.
117    pub candidate_count: usize,
118    /// Candidates rejected because they are cli-git wrappers.
119    pub skipped_wrapper_count: usize,
120}
121
122/// What:
123///  `impl std::fmt::Display for ...` supplies Rust's "print me" interface.
124/// Why:
125///   The executable prints the same guidance the TypeScript resolver gave.
126///
127/// In TS you'd write (pseudocode):
128/// ```ts
129/// toString(): string { return `Could not find a real Git executable ...`; }
130/// ```
131impl std::fmt::Display for RealGitNotFound {
132    /// `&self` borrows the failure;
133    ///  `&mut` lends the formatter for writing;
134    ///  `'_` is an unnamed lifetime.
135    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
136        // `write!` formats into the borrowed formatter and returns its result.
137        return write!(
138            formatter,
139            "Could not find a real Git executable after examining {} PATH candidates and \
140             skipping {} cli-git wrappers. Ensure Git is installed and PATH/PATHEXT expose \
141             its executable.",
142            self.candidate_count, self.skipped_wrapper_count
143        );
144    }
145}
146
147/// An empty `impl` marks the type as a standard error value.
148impl std::error::Error for RealGitNotFound {}
149
150/// What:
151///  List the file names PATH lookup tries,
152///  in order.
153/// Why:
154///   Unix runs `git`;
155///  Windows tries `git` plus each `PATHEXT` extension,
156///  adding a
157///       missing leading dot.
158///  An extension that is not UTF-8 cannot name a file
159///       portably and is skipped.
160///
161/// In TS you'd write (pseudocode):
162/// ```ts
163/// function executableNames(platform: Platform, pathExtensions: string): string[];
164/// ```
165pub fn executable_names(platform: Platform, path_extensions: &OsStr) -> Vec<OsString> {
166    if platform != Platform::Windows {
167        // `vec![...]` builds an owned list; `OsString::from` copies text into OS form.
168        return vec![OsString::from("git")];
169    }
170    // `Vec::<OsString>::new()` is an empty owned list; `mut` allows pushing.
171    let mut names: Vec<OsString> = Vec::<OsString>::new();
172    // `.split(...)` with a named predicate cuts the raw bytes at each semicolon.
173    for piece in path_extensions.as_encoded_bytes().split(is_semicolon) {
174        if piece.is_empty() {
175            continue;
176        }
177        // What: `let Ok(text) = ... else { continue; }` keeps valid UTF-8, skips the rest.
178        // Why:  Bytes can only become an OS string safely through checked text.
179        //
180        // In TS you'd write (pseudocode):
181        // ```ts
182        // const text = decodeUtf8OrSkip(piece);
183        // ```
184        let Ok(extension) = std::str::from_utf8(piece) else {
185            continue;
186        };
187        if extension.starts_with('.') {
188            names.push(OsString::from(format!("git{extension}")));
189        } else {
190            names.push(OsString::from(format!("git.{extension}")));
191        }
192    }
193    return names;
194}
195
196/// Named predicate for splitting `PATHEXT`;
197///  `&u8` borrows one byte.
198fn is_semicolon(byte: &u8) -> bool {
199    return *byte == b';';
200}
201
202/// What:
203///  List conventional Git install locations for a platform.
204///       `&[(OsString, OsString)]` borrows the environment as name/value pairs.
205/// Why:
206///   A conventional location wins over an earlier PATH entry,
207///  so a stray `git`
208///       script early on PATH cannot displace the system Git.
209///  Windows roots come from
210///       the environment;
211///  a trailing separator on a root is dropped before joining.
212///
213/// In TS you'd write (pseudocode):
214/// ```ts
215/// function commonGitPaths(platform: Platform, environment: [string, string][]): string[];
216/// ```
217pub fn common_git_paths(platform: Platform, environment: &[(OsString, OsString)]) -> Vec<PathBuf> {
218    if platform == Platform::Unix {
219        return vec![
220            PathBuf::from("/usr/bin/git"),
221            PathBuf::from("/usr/local/bin/git"),
222        ];
223    }
224    if platform == Platform::MacOs {
225        return vec![
226            PathBuf::from("/usr/bin/git"),
227            PathBuf::from("/usr/local/bin/git"),
228            PathBuf::from("/opt/homebrew/bin/git"),
229            PathBuf::from("/opt/local/bin/git"),
230        ];
231    }
232    let mut roots: Vec<OsString> = Vec::<OsString>::new();
233    for name in ["ProgramFiles", "ProgramW6432", "ProgramFiles(x86)"] {
234        // `if let Some(x) = ...` runs only when the variable is set.
235        if let Some(root) = environment_value(environment, name) {
236            roots.push(root);
237        }
238    }
239    roots.push(OsString::from("C:\\Program Files"));
240    if let Some(local) = environment_value(environment, "LOCALAPPDATA") {
241        let mut programs: OsString = trimmed_root(local.as_os_str());
242        programs.push("\\Programs");
243        roots.push(programs);
244    }
245    let mut paths: Vec<PathBuf> = Vec::<PathBuf>::new();
246    // `&roots` lends the list so the loop does not consume it.
247    for root in &roots {
248        for tail in ["\\Git\\cmd\\git.exe", "\\Git\\bin\\git.exe"] {
249            let mut path: OsString = trimmed_root(root.as_os_str());
250            path.push(tail);
251            let candidate: PathBuf = PathBuf::from(path);
252            // `.contains(&candidate)` borrows the new path to skip repeated roots.
253            if !paths.contains(&candidate) {
254                paths.push(candidate);
255            }
256        }
257    }
258    return paths;
259}
260
261/// What:
262///  Copy a Windows root without trailing `\` or `/` separators.
263/// Why:
264///   Joining `C:\Program Files\` and `\Git` must not produce a doubled separator
265///       that no PATH candidate would equal.
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// function trimmedRoot(root: string): string { return root.replace(/[\\/]+$/, ''); }
270/// ```
271fn trimmed_root(root: &OsStr) -> OsString {
272    let bytes: &[u8] = root.as_encoded_bytes();
273    let mut end: usize = bytes.len();
274    while end > 0 && (bytes[end - 1] == b'\\' || bytes[end - 1] == b'/') {
275        end -= 1;
276    }
277    // What: `std::str::from_utf8` checks the kept bytes; `match` picks by outcome.
278    // Why:  Cutting ASCII separators off valid text leaves valid text; a root that is
279    //       not UTF-8 is returned whole rather than re-encoded.
280    //
281    // In TS you'd write (pseudocode):
282    // ```ts
283    // return root.slice(0, end);
284    // ```
285    match std::str::from_utf8(&bytes[..end]) {
286        Ok(text) => return OsString::from(text),
287        Err(_) => return root.to_os_string(),
288    }
289}
290
291/// What:
292///  The form of a path used to compare candidates for equality.
293/// Why:
294///   Windows paths are case-insensitive;
295///  ASCII case is folded there.
296///  Other
297///       platforms compare the exact bytes.
298///
299/// In TS you'd write (pseudocode):
300/// ```ts
301/// function candidateIdentity(path: string, platform: Platform): string;
302/// ```
303fn candidate_identity(path: &Path, platform: Platform) -> OsString {
304    if platform == Platform::Windows {
305        return path.as_os_str().to_ascii_lowercase();
306    }
307    return path.as_os_str().to_os_string();
308}
309
310/// What:
311///  Rebuild a path from its components.
312/// Why:
313///   This removes `.` segments and repeated separators,
314///  so `/usr/./bin//git` and
315///       `/usr/bin/git` compare equal.
316///  `..` is kept:
317///  resolving it without the
318///       filesystem would be wrong across symbolic links.
319///
320/// In TS you'd write (pseudocode):
321/// ```ts
322/// function lexicallyTidied(path: string): string;
323/// ```
324fn lexically_tidied(path: &Path) -> PathBuf {
325    let mut result: PathBuf = PathBuf::new();
326    // `.components()` yields each path segment in order.
327    for component in path.components() {
328        result.push(component.as_os_str());
329    }
330    return result;
331}
332
333/// What:
334///  Build the ordered,
335///  de-duplicated list of absolute candidates.
336/// Why:
337///   Every PATH directory contributes each executable name in order;
338///  conventional
339///       locations that PATH exposes are promoted to the front in their PATH spelling.
340///       Relative PATH entries are resolved against the current directory,
341///  as a shell does.
342///
343/// In TS you'd write (pseudocode):
344/// ```ts
345/// function candidateSequence(inputs: ResolutionInputs): string[];
346/// ```
347pub fn candidate_sequence(inputs: &ResolutionInputs) -> Vec<PathBuf> {
348    let names: Vec<OsString> =
349        executable_names(inputs.platform, inputs.path_extensions.as_os_str());
350    let mut path_candidates: Vec<PathBuf> = Vec::<PathBuf>::new();
351    // `std::env::split_paths` cuts PATH by the host platform's separator.
352    for directory in std::env::split_paths(inputs.path.as_os_str()) {
353        for name in &names {
354            // `.join` appends a segment; an absolute `directory` replaces the base.
355            let candidate: PathBuf = inputs.current_directory.join(&directory).join(name);
356            path_candidates.push(lexically_tidied(candidate.as_path()));
357        }
358    }
359    let mut sequence: Vec<PathBuf> = Vec::<PathBuf>::new();
360    for common in &inputs.common_paths {
361        let wanted: OsString = candidate_identity(common.as_path(), inputs.platform);
362        for candidate in &path_candidates {
363            if candidate_identity(candidate.as_path(), inputs.platform) == wanted
364                && !sequence.contains(candidate)
365            {
366                // `.clone()` copies the path so both lists own their entries.
367                sequence.push(candidate.clone());
368            }
369        }
370    }
371    for candidate in path_candidates {
372        if !sequence.contains(&candidate) {
373            sequence.push(candidate);
374        }
375    }
376    return sequence;
377}
378
379/// What:
380///  Select the first candidate that is real Git.
381///       `Result<PathBuf, RealGitNotFound>` is the selected path or the typed failure.
382/// Why:
383///   Wrappers are skipped and counted,
384///  unusable entries are skipped,
385///  and the
386///       first remaining candidate in priority order is forwarded to.
387///
388/// In TS you'd write (pseudocode):
389/// ```ts
390/// function resolveRealGit(inputs: ResolutionInputs): string;
391/// ```
392pub fn resolve_real_git(inputs: &ResolutionInputs) -> Result<PathBuf, RealGitNotFound> {
393    let candidates: Vec<PathBuf> = candidate_sequence(inputs);
394    let mut skipped_wrapper_count: usize = 0;
395    for candidate in &candidates {
396        let kind: CandidateKind =
397            classify_candidate(candidate.as_path(), inputs.own_executable.as_path());
398        if kind == CandidateKind::RealGit {
399            // `Ok(...)` is the success variant carrying an owned copy of the path.
400            return Ok(candidate.clone());
401        }
402        if kind == CandidateKind::Wrapper {
403            skipped_wrapper_count += 1;
404        }
405    }
406    // `Err(...)` is the failure variant carrying the evidence counts.
407    return Err(RealGitNotFound {
408        candidate_count: candidates.len(),
409        skipped_wrapper_count,
410    });
411}
412
413/// What:
414///  The lookup rules of the platform this executable was compiled for.
415///       `cfg!(...)` is a compile-time `true`/`false` about the target platform.
416/// Why:
417///   Production uses the host's rules;
418///  tests pass a platform explicitly.
419///
420/// In TS you'd write (pseudocode):
421/// ```ts
422/// function hostPlatform(): Platform { return process.platform; }
423/// ```
424pub fn host_platform() -> Platform {
425    if cfg!(windows) {
426        return Platform::Windows;
427    }
428    if cfg!(target_os = "macos") {
429        return Platform::MacOs;
430    }
431    return Platform::Unix;
432}
433
434/// What:
435///  Gather resolution inputs from the running process.
436///       `std::io::Result<T>` is `Result<T, std::io::Error>`.
437/// Why:
438///   The wrapper must know its own executable to exclude it;
439///  if the operating
440///       system cannot report it (or the current directory),
441///  resolution fails rather
442///       than risk selecting itself.
443///
444/// In TS you'd write (pseudocode):
445/// ```ts
446/// function processResolutionInputs(environment: [string, string][]): ResolutionInputs;
447/// ```
448pub fn process_resolution_inputs(
449    environment: &[(OsString, OsString)],
450) -> std::io::Result<ResolutionInputs> {
451    let platform: Platform = host_platform();
452    // A trailing `?` returns the OS error to our caller, or unwraps the value.
453    let own_executable: PathBuf = std::env::current_exe()?;
454    let current_directory: PathBuf = std::env::current_dir()?;
455    // `.unwrap_or_default()` substitutes an empty value when the variable is unset.
456    let path: OsString = environment_value(environment, "PATH").unwrap_or_default();
457    let path_extensions: OsString = environment_value(environment, "PATHEXT")
458        .unwrap_or(OsString::from(DEFAULT_WINDOWS_PATH_EXTENSIONS));
459    return Ok(ResolutionInputs {
460        platform,
461        path,
462        path_extensions,
463        current_directory,
464        common_paths: common_git_paths(platform, environment),
465        own_executable,
466    });
467}
468
469/// Disposable-PATH controls stay out of the release executable.
470#[cfg(test)]
471#[path = "real_git_tests.rs"]
472mod tests;