Skip to main content

git_policy_cli/invocation/
real_git_candidate.rs

1//! What:
2//!  Decide whether one PATH candidate is real Git,
3//!  a cli-git wrapper,
4//!  or unusable.
5//! Why:
6//!  The wrapper shadows `git` on PATH and must never select itself,
7//!  a copy of
8//!      itself,
9//!  a toolchain launcher that replaces itself with the wrapper,
10//!  or a script that starts the TypeScript wrapper.
11//!
12//! In TS you'd write (pseudocode):
13//! ```ts
14//! // classifyCandidate('/usr/bin/git', ownExecutable) === 'real-git'
15//! ```
16
17/// What:
18///  Import the trait that gives files `.read(..)`.
19///       A trait is an interface;
20///  its methods exist only while it is in scope.
21/// Why:
22///   Candidates are inspected through bounded reads,
23///  never loaded whole by default.
24///
25/// In TS you'd write (pseudocode):
26/// ```ts
27/// import { open } from 'node:fs/promises';
28/// ```
29use std::io::Read;
30/// `Path` is a borrowed filesystem path of raw OS bytes (owned sibling:
31///  `PathBuf`).
32use std::path::Path;
33
34/// What:
35///  Largest script inspected for wrapper markers,
36///  64 kibibytes.
37///       `usize` is the platform's index and length type (siblings `u32`,
38///  `u64`).
39/// Why:
40///   Generated command shims are tiny launchers;
41///  a fixed bound keeps an arbitrary
42///       PATH file from controlling how much the resolver reads.
43///  `usize` matches the
44///       buffer lengths it is compared with.
45///
46/// In TS you'd write (pseudocode):
47/// ```ts
48/// const MAX_SCRIPT_INSPECTION_BYTES = 64 * 1024;
49/// ```
50pub const MAX_SCRIPT_INSPECTION_BYTES: usize = 64 * 1024;
51
52/// What:
53///  Leading bytes of ELF,
54///  PE,
55///  Mach-O and universal Mach-O executables.
56///       `&[&[u8]]` is a borrowed list of borrowed byte strings compiled into the program.
57/// Why:
58///   A native executable that is not this wrapper is treated as Git after one
59///       four-byte read;
60///  only scripts are searched for wrapper markers.
61///
62/// In TS you'd write (pseudocode):
63/// ```ts
64/// const NATIVE_EXECUTABLE_PREFIXES = ['7f454c46', '4d5a', 'feedface', ...];
65/// ```
66const NATIVE_EXECUTABLE_PREFIXES: &[&[u8]] = &[
67    b"\x7fELF",
68    b"MZ",
69    b"\xfe\xed\xfa\xce",
70    b"\xfe\xed\xfa\xcf",
71    b"\xce\xfa\xed\xfe",
72    b"\xcf\xfa\xed\xfe",
73    b"\xca\xfe\xba\xbe",
74    b"\xbe\xba\xfe\xca",
75    b"\xca\xfe\xba\xbf",
76    b"\xbf\xba\xfe\xca",
77];
78
79/// What:
80///  Text that identifies a script delegating to the TypeScript cli-git wrapper:
81///       its package name and bundled entry path,
82///  in POSIX and Windows spellings.
83/// Why:
84///   Such a script is the other wrapper;
85///  selecting it as real Git would make the
86///       two wrappers call each other.
87///
88/// In TS you'd write (pseudocode):
89/// ```ts
90/// const SELF_SHIM_MARKERS = ['@monochromatic-dev/git-policy-cli', ...];
91/// ```
92const WRAPPER_SCRIPT_MARKERS: &[&[u8]] = &[
93    b"@monochromatic-dev/git-policy-cli",
94    b"package/git-policy/cli/dist/final/node/index.mjs",
95    b"@monochromatic-dev\\git-policy-cli",
96    b"package\\git-policy\\cli\\dist\\final\\node\\index.mjs",
97];
98
99/// What:
100///  The three verdicts for one candidate path.
101///       `#[derive(...)]` generates copying,
102///  debug printing and `==`.
103/// Why:
104///   The resolver counts skipped wrappers separately from unusable entries so its
105///       failure message can say why nothing was selected.
106///
107/// In TS you'd write (pseudocode):
108/// ```ts
109/// type CandidateKind = 'real-git' | 'wrapper' | 'unusable';
110/// ```
111#[derive(Clone, Copy, Debug, Eq, PartialEq)]
112pub enum CandidateKind {
113    /// An executable regular file that is not a cli-git wrapper.
114    RealGit,
115    /// This executable,
116    ///  a byte-identical copy of it,
117    ///  a toolchain launcher with a record naming this wrapper,
118    ///  or a script starting the TypeScript wrapper.
119    Wrapper,
120    /// Missing,
121    ///  not a regular file,
122    ///  not executable,
123    ///  unreadable,
124    ///  or an oversized script.
125    Unusable,
126}
127
128/// What:
129///  Report whether the bytes begin with a native executable signature.
130/// Why:
131///   Native candidates skip the script-marker search entirely.
132///
133/// In TS you'd write (pseudocode):
134/// ```ts
135/// function isNativeHeader(header: Uint8Array): boolean;
136/// ```
137pub fn is_native_header(header: &[u8]) -> bool {
138    // `for prefix in LIST` borrows each compiled-in signature in turn.
139    for prefix in NATIVE_EXECUTABLE_PREFIXES {
140        if header.starts_with(prefix) {
141            return true;
142        }
143    }
144    return false;
145}
146
147/// What:
148///  Report whether `needle` occurs anywhere inside `haystack`.
149/// Why:
150///   Byte slices have no built-in substring search,
151///  and scripts are searched as
152///       bytes so a non-UTF-8 script cannot hide a marker.
153///
154/// In TS you'd write (pseudocode):
155/// ```ts
156/// function containsBytes(haystack: Buffer, needle: Buffer): boolean { return haystack.includes(needle); }
157/// ```
158fn contains_bytes(haystack: &[u8], needle: &[u8]) -> bool {
159    if needle.len() > haystack.len() {
160        return false;
161    }
162    // `usize` start offsets; `mut` allows advancing.
163    let mut start: usize = 0;
164    while start + needle.len() <= haystack.len() {
165        // `&haystack[a..b]` borrows the bytes from `a` up to, not including, `b`.
166        if &haystack[start..start + needle.len()] == needle {
167            return true;
168        }
169        start += 1;
170    }
171    return false;
172}
173
174/// What:
175///  Report whether script bytes contain a TypeScript-wrapper marker.
176/// Why:
177///   This is the version-independent recognition of the other wrapper's launchers.
178///
179/// In TS you'd write (pseudocode):
180/// ```ts
181/// function hasWrapperMarker(content: Buffer): boolean;
182/// ```
183pub fn has_wrapper_marker(content: &[u8]) -> bool {
184    for marker in WRAPPER_SCRIPT_MARKERS {
185        if contains_bytes(content, marker) {
186            return true;
187        }
188    }
189    return false;
190}
191
192/// What:
193///  Report whether file metadata says the file can be run.
194///       `#[cfg(unix)]` and `#[cfg(not(unix))]` each compile one of the two inner blocks.
195/// Why:
196///   PATH lookup only runs executable files;
197///  a non-executable `git` is skipped.
198///       On Unix this checks the mode bits,
199///  not the calling user's access as `access(2)`
200///       would.
201///  Other systems have no execute bit:
202///  every regular file named by PATHEXT
203///       can run.
204///  The Markdown policy's linter lookup applies the same test.
205///
206/// In TS you'd write (pseudocode):
207/// ```ts
208/// function isExecutable(stats: Stats): boolean { return process.platform === 'win32' || (stats.mode & 0o111) !== 0; }
209/// ```
210pub fn is_executable(metadata: &std::fs::Metadata) -> bool {
211    #[cfg(unix)]
212    {
213        // The trait adds `.mode()`; `use` inside a block scopes it to this block.
214        use std::os::unix::fs::PermissionsExt;
215        // `&` here is bitwise AND; `0o111` is octal for the three execute bits.
216        return metadata.permissions().mode() & 0o111 != 0;
217    }
218    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
219    #[cfg(not(unix))]
220    {
221        // `let _ = ...` states that the metadata is deliberately not consulted here.
222        let _ = metadata;
223        return true;
224    }
225}
226
227/// What:
228///  Report whether two metadata records name the same file on the same device.
229/// Why:
230///   On Unix,
231///  device and inode numbers identify a file through any symbolic link,
232///       hard link or repeated PATH entry.
233///  Other systems expose no stable inode here,
234///       so the answer is "not proven the same" and canonical paths decide instead.
235///
236/// In TS you'd write (pseudocode):
237/// ```ts
238/// function sameInode(a: Stats, b: Stats): boolean { return process.platform !== 'win32' && a.dev === b.dev && a.ino === b.ino; }
239/// ```
240fn same_inode(first: &std::fs::Metadata, second: &std::fs::Metadata) -> bool {
241    #[cfg(unix)]
242    {
243        // The trait adds `.dev()` and `.ino()` to metadata on Unix.
244        use std::os::unix::fs::MetadataExt;
245        return first.dev() == second.dev() && first.ino() == second.ino();
246    }
247    // UNTESTED ON WINDOWS: no gate compiles or runs this branch (the crate does not build for Windows; README, Requirements).
248    #[cfg(not(unix))]
249    {
250        // Neither record is consulted where no inode is available.
251        let _ = (first, second);
252        return false;
253    }
254}
255
256/// What:
257///  Report whether two paths name the same existing file.
258/// Why:
259///   Self-exclusion must hold through symbolic links,
260///  hard links,
261///  relative
262///       spellings and repeated PATH directories.
263///  Either check alone suffices;
264///  an
265///       inspection failure means "not proven the same".
266///
267/// In TS you'd write (pseudocode):
268/// ```ts
269/// async function sameFile(first: string, second: string): Promise<boolean>;
270/// ```
271pub fn same_file(first: &Path, second: &Path) -> bool {
272    // What: `if let (Ok(a), Ok(b)) = (x, y) && cond` runs only when both results
273    //       succeeded and the extra condition holds.
274    //       `std::fs::metadata` follows symbolic links to the final file.
275    // Why:  A missing or unreadable path cannot be proven identical to anything.
276    //
277    // In TS you'd write (pseudocode):
278    // ```ts
279    // const [a, b] = await Promise.all([stat(first), stat(second)]);
280    // ```
281    if let (Ok(first_metadata), Ok(second_metadata)) =
282        (std::fs::metadata(first), std::fs::metadata(second))
283        && same_inode(&first_metadata, &second_metadata)
284    {
285        return true;
286    }
287    // `canonicalize` resolves every symbolic link and relative segment to one absolute path.
288    if let (Ok(first_canonical), Ok(second_canonical)) =
289        (std::fs::canonicalize(first), std::fs::canonicalize(second))
290    {
291        return first_canonical == second_canonical;
292    }
293    return false;
294}
295
296/// What:
297///  Report whether two files have the same length and the same bytes.
298/// Why:
299///   A copy of this executable under another name or directory is still this
300///       wrapper,
301///  although its inode differs.
302///  Lengths are compared first,
303///  so real Git
304///       is never read in full for this check.
305///
306/// In TS you'd write (pseudocode):
307/// ```ts
308/// async function identicalContent(first: string, second: string): Promise<boolean>;
309/// ```
310pub fn identical_content(first: &Path, second: &Path) -> bool {
311    let (Ok(first_metadata), Ok(second_metadata)) =
312        (std::fs::metadata(first), std::fs::metadata(second))
313    else {
314        return false;
315    };
316    if first_metadata.len() != second_metadata.len() {
317        return false;
318    }
319    // `std::fs::read` returns the whole file as an owned byte list, or an error.
320    let (Ok(first_bytes), Ok(second_bytes)) = (std::fs::read(first), std::fs::read(second)) else {
321        return false;
322    };
323    return first_bytes == second_bytes;
324}
325
326/// What:
327///  Read from a file until `limit` bytes are collected or the file ends.
328///       `&mut std::fs::File` lends the open file for reading (reading moves its position).
329///       `Option<Vec<u8>>` is the owned bytes,
330///  or `None` when the read failed.
331/// Why:
332///   One `read` call may return fewer bytes than requested.
333///  The standard library's
334///       bounded reader repeats the read until the limit or the end of the file,
335///  so the
336///       bound is exact and there is no loop here to keep in step.
337///
338/// In TS you'd write (pseudocode):
339/// ```ts
340/// async function readUpTo(file: FileHandle, limit: number): Promise<Buffer | undefined>;
341/// ```
342fn read_up_to(file: &mut std::fs::File, limit: usize) -> Option<Vec<u8>> {
343    // `Vec::<u8>::new()` is an empty owned byte list that grows as bytes arrive.
344    let mut buffer: Vec<u8> = Vec::<u8>::new();
345    // What: `.take(n)` wraps the lent file in a reader that ends after `n` bytes;
346    //       `limit as u64` widens the count to the 64-bit type that reader takes, which
347    //       cannot lose a value. `.read_to_end(&mut buffer)` appends everything the
348    //       reader yields to the list. `match` unpacks the `Result`: `Ok(_)` ignores
349    //       the byte count.
350    // Why:  A read error makes the candidate unusable.
351    //
352    // In TS you'd write (pseudocode):
353    // ```ts
354    // try { return (await file.read({ length: limit })).buffer; } catch { return undefined; }
355    // ```
356    match file.take(limit as u64).read_to_end(&mut buffer) {
357        // `Some(buffer)` is the "present" variant carrying the bytes read.
358        Ok(_) => return Some(buffer),
359        Err(_) => return None,
360    }
361}
362
363/// What:
364///  The suffix of the record a toolchain launcher keeps beside it, `<launcher>.launch`.
365///       `&str` is a borrowed text baked into the program.
366/// Why:
367///   The repository's `mpm-shadow` writes the record when it installs a launcher
368///  (`package/mpm-shadow/cli/src/toolchain/launcher.rs`, `RECORD_SUFFIX`); the wrapper reads the same file to learn what
369///  a launcher on PATH leads to.
370///
371/// In TS you'd write (pseudocode):
372/// ```ts
373/// const LAUNCH_RECORD_SUFFIX = '.launch';
374/// ```
375pub const LAUNCH_RECORD_SUFFIX: &str = ".launch";
376
377/// What:
378///  Report whether a launch record's text names this wrapper: its tool is this crate, or the file it replaces itself
379///  with is this executable.
380///       `Option<&Path>` is "this executable's path, or nothing when the caller has none".
381///       `serde_json::Value` is a parsed JSON document (siblings: a typed struct, which would need a derive macro).
382/// Why:
383///   The launcher of another release of the wrapper has a record naming a different file, so the file the record names
384///  cannot decide alone; the tool id is the same for every release. Both are read from the record; neither starts
385///  anything.
386///
387/// In TS you'd write (pseudocode):
388/// ```ts
389/// function recordNamesThisWrapper(record: string, own?: string): boolean {
390///   const { toolId, real } = JSON.parse(record);
391///   return toolId.split(':').at(-1) === 'monochromatic-git-policy-cli' || (own !== undefined && sameFile(real, own));
392/// }
393/// ```
394pub fn record_names_this_wrapper(record: &[u8], own_executable: Option<&Path>) -> bool {
395    // `let Ok(x) = ... else { return ...; };` unwraps a success or leaves the function; a record that is not JSON is no record.
396    let Ok(parsed) = serde_json::from_slice::<serde_json::Value>(record) else {
397        return false;
398    };
399    // `.get("toolId")` looks a key up; `.and_then(serde_json::Value::as_str)` turns a JSON string into a borrowed `&str`.
400    if let Some(tool_id) = parsed.get("toolId").and_then(serde_json::Value::as_str)
401        && tool_id.rsplit(':').next() == Some(env!("CARGO_PKG_NAME"))
402    {
403        return true;
404    }
405    if let Some(own) = own_executable
406        && let Some(real) = parsed.get("real").and_then(serde_json::Value::as_str)
407    {
408        return same_file(Path::new(real), own);
409    }
410    return false;
411}
412
413/// What:
414///  Read the launch record at `record` if there is one, bounded.
415///       `Option<Vec<u8>>` is the owned bytes, or nothing when there is no readable record.
416/// Why:
417///   Most candidates (the real Git among them) have no record, and the open of a file that is not there is the whole
418///  cost. A record is a few hundred bytes; the same bound as a script's keeps an odd file from controlling the read.
419///
420/// In TS you'd write (pseudocode):
421/// ```ts
422/// async function readRecord(path: string): Promise<Buffer | undefined>;
423/// ```
424fn read_launch_record(record: &Path) -> Option<Vec<u8>> {
425    // `std::fs::File::open(..).ok()?` opens the file, turning a failure (usually "not there") into `None`.
426    let mut file: std::fs::File = std::fs::File::open(record).ok()?;
427    return read_up_to(&mut file, MAX_SCRIPT_INSPECTION_BYTES);
428}
429
430/// What:
431///  Report whether the candidate is a toolchain launcher of this wrapper: a file with a launch record beside it that
432///  names this wrapper.
433///       The record is looked for beside the candidate's own name and beside the file its links finally lead to.
434/// Why:
435///   A launcher replaces itself with the wrapper's executable, which is another file with other bytes, so the file
436///  checks of the identity test miss it; the record beside it says what it leads to. A symlink named `git` that points
437///  at a launcher has no record of its own, so the file it leads to is checked as well.
438///
439/// In TS you'd write (pseudocode):
440/// ```ts
441/// function isWrapperLauncher(candidate: string, own?: string): boolean {
442///   return [candidate, realpathOrUndefined(candidate)].some((p) => p && recordNamesThisWrapper(readOrUndefined(p + '.launch'), own));
443/// }
444/// ```
445pub fn is_wrapper_launcher(candidate: &Path, own_executable: Option<&Path>) -> bool {
446    let mut spellings: Vec<std::path::PathBuf> = vec![candidate.to_path_buf()];
447    // `if let Ok(x) = ...` runs only when the links could be resolved; a dangling link has no record to read.
448    if let Ok(canonical) = std::fs::canonicalize(candidate)
449        && canonical != candidate
450    {
451        spellings.push(canonical);
452    }
453    for spelling in &spellings {
454        let mut record_name: std::ffi::OsString = spelling.as_os_str().to_os_string();
455        record_name.push(LAUNCH_RECORD_SUFFIX);
456        if let Some(record) = read_launch_record(Path::new(&record_name))
457            && record_names_this_wrapper(record.as_slice(), own_executable)
458        {
459            return true;
460        }
461    }
462    return false;
463}
464
465/// What:
466///  Classify one candidate against this wrapper's own executable.
467/// Why:
468///   This is the whole self-exclusion decision:
469///  the same file (any link or
470///       spelling),
471///  a toolchain launcher of this wrapper,
472///  an identical copy,
473///  or a TypeScript-wrapper launcher is a wrapper;
474///       any other executable regular file is Git.
475///  Another release of the wrapper behind no record and no marker string cannot be told here; the marker every
476///  real-Git start carries (`started_as_git.rs`) stops it when it arrives.
477///
478/// In TS you'd write (pseudocode):
479/// ```ts
480/// async function classifyCandidate(candidate: string, ownExecutable: string): Promise<CandidateKind>;
481/// ```
482pub fn classify_candidate(candidate: &Path, own_executable: &Path) -> CandidateKind {
483    // What: `let Ok(x) = ... else { return ... };` unwraps success or exits.
484    //       `std::fs::metadata` inspects the file a symbolic link finally points at.
485    // Why:  The file type is checked before opening, because opening a named pipe
486    //       would wait forever for a writer. A missing candidate is simply skipped.
487    //
488    // In TS you'd write (pseudocode):
489    // ```ts
490    // let stats; try { stats = await stat(candidate); } catch { return "unusable"; }
491    // ```
492    let Ok(metadata) = std::fs::metadata(candidate) else {
493        return CandidateKind::Unusable;
494    };
495    if !metadata.is_file() || !is_executable(&metadata) {
496        return CandidateKind::Unusable;
497    }
498    if same_file(candidate, own_executable)
499        || is_wrapper_launcher(candidate, Some(own_executable))
500        || identical_content(candidate, own_executable)
501    {
502        return CandidateKind::Wrapper;
503    }
504    // `mut file`: reading advances the open file, which counts as modifying it.
505    let Ok(mut file) = std::fs::File::open(candidate) else {
506        return CandidateKind::Unusable;
507    };
508    // Four bytes cover every native signature in the table.
509    let Some(header) = read_up_to(&mut file, 4) else {
510        return CandidateKind::Unusable;
511    };
512    if is_native_header(header.as_slice()) {
513        return CandidateKind::RealGit;
514    }
515    // The header is at least one byte of a file that has any, so reading the whole bound
516    // after it reaches past the bound exactly when the script is too large to inspect.
517    let Some(rest) = read_up_to(&mut file, MAX_SCRIPT_INSPECTION_BYTES) else {
518        return CandidateKind::Unusable;
519    };
520    // Join header and rest so a marker spanning the first four bytes is still found.
521    let mut content: Vec<u8> = header;
522    content.extend_from_slice(rest.as_slice());
523    if content.len() > MAX_SCRIPT_INSPECTION_BYTES {
524        return CandidateKind::Unusable;
525    }
526    if has_wrapper_marker(content.as_slice()) {
527        return CandidateKind::Wrapper;
528    }
529    return CandidateKind::RealGit;
530}
531
532/// Controls for the header,
533///  marker,
534///  identity and content primitives.
535#[cfg(test)]
536#[path = "real_git_candidate_primitive_tests.rs"]
537mod primitive_tests;
538
539/// Disposable-directory classification controls stay out of the release executable.
540#[cfg(test)]
541#[path = "real_git_candidate_tests.rs"]
542mod tests;