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 #[cfg(not(unix))]
217 {
218 // `let _ = ...` states that the metadata is deliberately not consulted here.
219 let _ = metadata;
220 return true;
221 }
222}
223
224/// What:
225/// Report whether two metadata records name the same file on the same device.
226/// Why:
227/// On Unix,
228/// device and inode numbers identify a file through any symbolic link,
229/// hard link or repeated PATH entry.
230/// Other systems expose no stable inode here,
231/// so the answer is "not proven the same" and canonical paths decide instead.
232///
233/// In TS you'd write (pseudocode):
234/// ```ts
235/// function sameInode(a: Stats, b: Stats): boolean { return process.platform !== 'win32' && a.dev === b.dev && a.ino === b.ino; }
236/// ```
237fn same_inode(first: &std::fs::Metadata, second: &std::fs::Metadata) -> bool {
238 #[cfg(unix)]
239 {
240 // The trait adds `.dev()` and `.ino()` to metadata on Unix.
241 use std::os::unix::fs::MetadataExt;
242 return first.dev() == second.dev() && first.ino() == second.ino();
243 }
244 #[cfg(not(unix))]
245 {
246 // Neither record is consulted where no inode is available.
247 let _ = (first, second);
248 return false;
249 }
250}
251
252/// What:
253/// Report whether two paths name the same existing file.
254/// Why:
255/// Self-exclusion must hold through symbolic links,
256/// hard links,
257/// relative
258/// spellings and repeated PATH directories.
259/// Either check alone suffices;
260/// an
261/// inspection failure means "not proven the same".
262///
263/// In TS you'd write (pseudocode):
264/// ```ts
265/// async function sameFile(first: string, second: string): Promise<boolean>;
266/// ```
267pub fn same_file(first: &Path, second: &Path) -> bool {
268 // What: `if let (Ok(a), Ok(b)) = (x, y) && cond` runs only when both results
269 // succeeded and the extra condition holds.
270 // `std::fs::metadata` follows symbolic links to the final file.
271 // Why: A missing or unreadable path cannot be proven identical to anything.
272 //
273 // In TS you'd write (pseudocode):
274 // ```ts
275 // const [a, b] = await Promise.all([stat(first), stat(second)]);
276 // ```
277 if let (Ok(first_metadata), Ok(second_metadata)) =
278 (std::fs::metadata(first), std::fs::metadata(second))
279 && same_inode(&first_metadata, &second_metadata)
280 {
281 return true;
282 }
283 // `canonicalize` resolves every symbolic link and relative segment to one absolute path.
284 if let (Ok(first_canonical), Ok(second_canonical)) =
285 (std::fs::canonicalize(first), std::fs::canonicalize(second))
286 {
287 return first_canonical == second_canonical;
288 }
289 return false;
290}
291
292/// What:
293/// Report whether two files have the same length and the same bytes.
294/// Why:
295/// A copy of this executable under another name or directory is still this
296/// wrapper,
297/// although its inode differs.
298/// Lengths are compared first,
299/// so real Git
300/// is never read in full for this check.
301///
302/// In TS you'd write (pseudocode):
303/// ```ts
304/// async function identicalContent(first: string, second: string): Promise<boolean>;
305/// ```
306pub fn identical_content(first: &Path, second: &Path) -> bool {
307 let (Ok(first_metadata), Ok(second_metadata)) =
308 (std::fs::metadata(first), std::fs::metadata(second))
309 else {
310 return false;
311 };
312 if first_metadata.len() != second_metadata.len() {
313 return false;
314 }
315 // `std::fs::read` returns the whole file as an owned byte list, or an error.
316 let (Ok(first_bytes), Ok(second_bytes)) = (std::fs::read(first), std::fs::read(second)) else {
317 return false;
318 };
319 return first_bytes == second_bytes;
320}
321
322/// What:
323/// Read from a file until `limit` bytes are collected or the file ends.
324/// `&mut std::fs::File` lends the open file for reading (reading moves its position).
325/// `Option<Vec<u8>>` is the owned bytes,
326/// or `None` when the read failed.
327/// Why:
328/// One `read` call may return fewer bytes than requested.
329/// The standard library's
330/// bounded reader repeats the read until the limit or the end of the file,
331/// so the
332/// bound is exact and there is no loop here to keep in step.
333///
334/// In TS you'd write (pseudocode):
335/// ```ts
336/// async function readUpTo(file: FileHandle, limit: number): Promise<Buffer | undefined>;
337/// ```
338fn read_up_to(file: &mut std::fs::File, limit: usize) -> Option<Vec<u8>> {
339 // `Vec::<u8>::new()` is an empty owned byte list that grows as bytes arrive.
340 let mut buffer: Vec<u8> = Vec::<u8>::new();
341 // What: `.take(n)` wraps the lent file in a reader that ends after `n` bytes;
342 // `limit as u64` widens the count to the 64-bit type that reader takes, which
343 // cannot lose a value. `.read_to_end(&mut buffer)` appends everything the
344 // reader yields to the list. `match` unpacks the `Result`: `Ok(_)` ignores
345 // the byte count.
346 // Why: A read error makes the candidate unusable.
347 //
348 // In TS you'd write (pseudocode):
349 // ```ts
350 // try { return (await file.read({ length: limit })).buffer; } catch { return undefined; }
351 // ```
352 match file.take(limit as u64).read_to_end(&mut buffer) {
353 // `Some(buffer)` is the "present" variant carrying the bytes read.
354 Ok(_) => return Some(buffer),
355 Err(_) => return None,
356 }
357}
358
359/// What:
360/// Classify one candidate against this wrapper's own executable.
361/// Why:
362/// This is the whole self-exclusion decision:
363/// the same file (any link or
364/// spelling),
365/// an identical copy,
366/// or a TypeScript-wrapper launcher is a wrapper;
367/// any other executable regular file is Git.
368///
369/// In TS you'd write (pseudocode):
370/// ```ts
371/// async function classifyCandidate(candidate: string, ownExecutable: string): Promise<CandidateKind>;
372/// ```
373pub fn classify_candidate(candidate: &Path, own_executable: &Path) -> CandidateKind {
374 // What: `let Ok(x) = ... else { return ... };` unwraps success or exits.
375 // `std::fs::metadata` inspects the file a symbolic link finally points at.
376 // Why: The file type is checked before opening, because opening a named pipe
377 // would wait forever for a writer. A missing candidate is simply skipped.
378 //
379 // In TS you'd write (pseudocode):
380 // ```ts
381 // let stats; try { stats = await stat(candidate); } catch { return "unusable"; }
382 // ```
383 let Ok(metadata) = std::fs::metadata(candidate) else {
384 return CandidateKind::Unusable;
385 };
386 if !metadata.is_file() || !is_executable(&metadata) {
387 return CandidateKind::Unusable;
388 }
389 if same_file(candidate, own_executable) || identical_content(candidate, own_executable) {
390 return CandidateKind::Wrapper;
391 }
392 // `mut file`: reading advances the open file, which counts as modifying it.
393 let Ok(mut file) = std::fs::File::open(candidate) else {
394 return CandidateKind::Unusable;
395 };
396 // Four bytes cover every native signature in the table.
397 let Some(header) = read_up_to(&mut file, 4) else {
398 return CandidateKind::Unusable;
399 };
400 if is_native_header(header.as_slice()) {
401 return CandidateKind::RealGit;
402 }
403 // The header is at least one byte of a file that has any, so reading the whole bound
404 // after it reaches past the bound exactly when the script is too large to inspect.
405 let Some(rest) = read_up_to(&mut file, MAX_SCRIPT_INSPECTION_BYTES) else {
406 return CandidateKind::Unusable;
407 };
408 // Join header and rest so a marker spanning the first four bytes is still found.
409 let mut content: Vec<u8> = header;
410 content.extend_from_slice(rest.as_slice());
411 if content.len() > MAX_SCRIPT_INSPECTION_BYTES {
412 return CandidateKind::Unusable;
413 }
414 if has_wrapper_marker(content.as_slice()) {
415 return CandidateKind::Wrapper;
416 }
417 return CandidateKind::RealGit;
418}
419
420/// Controls for the header,
421/// marker,
422/// identity and content primitives.
423#[cfg(test)]
424#[path = "real_git_candidate_primitive_tests.rs"]
425mod primitive_tests;
426
427/// Disposable-directory classification controls stay out of the release executable.
428#[cfg(test)]
429#[path = "real_git_candidate_tests.rs"]
430mod tests;