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;