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;