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;