Skip to main content

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