Skip to main content

git_policy_cli/invocation/
real_git_record.rs

1//! What:
2//!  Read the real Git that the wrapper's commit service was set up with,
3//! from the unit file the wrapper wrote for it.
4//! Why:
5//!  Nothing the wrapper installs records the real Git it shadows.
6//! The one file that names a real Git is the unit of the
7//!  commit service (`service_provision.rs`):
8//! its `ExecStart` line holds `--git <path>`,
9//! the Git the wrapper resolved
10//!  when it last provisioned the service.
11//! When no real Git follows the wrapper on `PATH`,
12//! that path is the only
13//!  recorded one,
14//! so the resolver (`real_git.rs`) falls back to it,
15//! after the same checks a `PATH` candidate passes.
16//!  This is the coordinator's pick under the owner's delegation,
17//! open to the owner's veto
18//!  (`doc/planning/cli-git-no-self-invocation.md`,
19//! section "Falling back to the Git the commit service was set up
20//!  with").
21//! The file lives in the runtime directory,
22//! so it is gone after the account's last logout,
23//! and no record then
24//!  means no fallback.
25//!
26//! In TS you'd write (pseudocode):
27//! ```ts
28//! const unitText = readFileSync(join(runtimeDir, 'systemd/user/cli-git@.service'), 'utf8');
29//! const recordedGit = unitText.match(/^ExecStart=.*\s--git\s+(\S+)/m)?.[1]; // undefined: no record
30//! ```
31
32/// What:
33///  `use super::...` imports the names of the sibling modules that own the unit file's location and name,
34///  and the bound on how much of a record is read.
35/// Why:
36///   The unit's directory and file name are decided once,
37/// where the wrapper writes the unit;
38///   this module only reads what that writer produced.
39///
40/// In TS you'd write (pseudocode):
41/// ```ts
42/// import { runtimeUnitDir, SERVICE_UNIT_NAME } from './service-provision.ts';
43/// ```
44use crate::real_git_candidate::MAX_SCRIPT_INSPECTION_BYTES;
45use crate::service_provision::{SERVICE_UNIT_NAME, runtime_unit_dir};
46/// `Read` supplies `read_to_end` and `take`,
47/// the bounded read below.
48use std::io::Read;
49/// `Path` is a borrowed filesystem path;
50///  `PathBuf` is the owned sibling a function returns.
51use std::path::{Path, PathBuf};
52
53/// The option of the service program that names the Git it starts.
54const GIT_OPTION: &str = "--git";
55
56/// The key of the unit line that holds the service program and its options.
57const EXEC_START_KEY: &str = "ExecStart=";
58
59/// What:
60///  The unit file of the commit service below a runtime directory.
61///  `&Path` borrows a path (sibling:
62/// `PathBuf` owns one),
63/// and `PathBuf` is returned because the result is built here.
64/// Why:
65///  The wrapper writes the service unit there,
66/// and nowhere else is searched:
67///  a record found by looking through other places would be a guess.
68///
69/// In TS you'd write (pseudocode):
70/// ```ts
71/// function serviceUnitFile(runtimeDir: string): string { return join(runtimeDir, 'systemd/user/cli-git@.service'); }
72/// ```
73pub fn service_unit_file(runtime_dir: &Path) -> PathBuf {
74    return runtime_unit_dir(runtime_dir).join(SERVICE_UNIT_NAME);
75}
76
77/// What:
78///  Whether one word of a line is the Git option.
79///  `&&str` is a borrow of a borrowed word,
80/// which is the shape `.position(...)` hands its predicate.
81/// Why:
82///  A named predicate keeps the search below a plain call.
83///
84/// In TS you'd write (pseudocode):
85/// ```ts
86/// const isGitOption = (word: string) => word === '--git';
87/// ```
88fn is_git_option(word: &&str) -> bool {
89    return *word == GIT_OPTION;
90}
91
92/// What:
93///  The absolute path after `--git` on the `ExecStart` line of a unit's text.
94///  `Option<PathBuf>` is the path or nothing (`PathBuf | undefined`).
95/// Why:
96///  The wrapper writes the path unquoted,
97/// so whitespace cuts the line into words and the word after `--git` is the path.
98///  A relative path is no record,
99/// because it would mean a different file for each directory the wrapper runs in.
100///  A path with a space in it is cut short and then fails the checks of the resolver,
101/// which is the safe outcome.
102///
103/// In TS you'd write (pseudocode):
104/// ```ts
105/// function recordedGitPath(unitText: string): string | undefined;
106/// ```
107pub fn recorded_git_path(unit_text: &str) -> Option<PathBuf> {
108    for line in unit_text.lines() {
109        // What:
110        //  `let Some(command) = ... else { continue; };` takes the value out of an `Option` or skips to the next line.
111        //  `.strip_prefix(key)` removes the key from the start and gives nothing when the key is not there.
112        // Why:
113        //  Only a line that starts with `ExecStart=` holds the service's command line.
114        //
115        // In TS you'd write (pseudocode):
116        // ```ts
117        // const command = line.trimStart().startsWith('ExecStart=') ? line.trimStart().slice('ExecStart='.length) : undefined; if (command === undefined) continue;
118        // ```
119        let Some(command) = line.trim_start().strip_prefix(EXEC_START_KEY) else {
120            continue;
121        };
122        // What:
123        //  `.split_whitespace()` cuts the line into words;
124        //  `.collect()` gathers them into an owned list, `Vec<&str>` (siblings: `[&str; N]`, a fixed-size array, and `&[&str]`, a borrowed view).
125        //  `: Vec<&str>` is the type written out, since `.collect()` cannot guess the list kind.
126        // Why:
127        //  A list can be searched for the option and indexed for the word after it.
128        //
129        // In TS you'd write (pseudocode):
130        // ```ts
131        // const words = command.split(/\s+/).filter(Boolean);
132        // ```
133        let words: Vec<&str> = command.split_whitespace().collect();
134        // `words.iter().position(is_git_option)` is the index of the first word the predicate accepts, or nothing.
135        let Some(option_at) = words.iter().position(is_git_option) else {
136            continue;
137        };
138        // What:
139        //  `.get(n)` is a checked index that gives `Some(word)` or `None` where `words[n]` would panic.
140        // Why:
141        //  The option may be the last word of a damaged line, and a damaged record names nothing.
142        //
143        // In TS you'd write (pseudocode):
144        // ```ts
145        // const value = words[optionAt + 1]; if (value === undefined) continue;
146        // ```
147        let Some(value) = words.get(option_at + 1) else {
148            continue;
149        };
150        // `PathBuf::from(value)` copies the word into an owned path.
151        let path: PathBuf = PathBuf::from(value);
152        if path.is_absolute() {
153            // `Some(...)` wraps the found path; this is the function's result.
154            return Some(path);
155        }
156    }
157    // `None` says there is no record.
158    return None;
159}
160
161/// What:
162///  The Git a unit file records,
163/// read with the bound a launcher record is read with.
164/// Why:
165///  The unit is a few hundred bytes,
166/// so a file past the bound is not the wrapper's unit and names nothing.
167///  An unreadable or missing file names nothing either,
168/// and the caller keeps its stop.
169///
170/// In TS you'd write (pseudocode):
171/// ```ts
172/// function readRecordedGit(unitFile: string): string | undefined;
173/// ```
174pub fn read_recorded_git(unit_file: &Path) -> Option<PathBuf> {
175    // What:
176    //  `.ok()` turns a `Result` into an `Option` (an error becomes nothing);
177    //  the trailing `?` then returns that nothing from this function at once, or unwraps the value.
178    // Why:
179    //  A file that cannot be opened is no record, and no error text is owed for it.
180    //
181    // In TS you'd write (pseudocode):
182    // ```ts
183    // let file; try { file = openSync(unitFile); } catch { return undefined; }
184    // ```
185    let file: std::fs::File = std::fs::File::open(unit_file).ok()?;
186    // `Vec::<u8>::new()` is an empty owned list of bytes (sibling: `String`, which holds text); `mut` lets the read fill it.
187    let mut bytes: Vec<u8> = Vec::<u8>::new();
188    // What:
189    //  `as u64` converts the count to an unsigned 64-bit integer (siblings: `usize`, the size of a list, and `u32`).
190    //  One byte more than the limit is read, so that a file over the limit is told from one exactly at it.
191    // Why:
192    //  `.take(n)` takes `u64`, and a bounded read never lets an unexpected file fill memory.
193    //
194    // Gotcha:
195    //  `as` truncates silently when a value does not fit, which a constant this small never does.
196    //
197    // In TS you'd write (pseudocode):
198    // ```ts
199    // const bound = MAX_SCRIPT_INSPECTION_BYTES + 1;
200    // ```
201    let bound: u64 = MAX_SCRIPT_INSPECTION_BYTES as u64 + 1;
202    // `.take(bound)` stops the read after `bound` bytes; `&mut bytes` lends the list for writing.
203    file.take(bound).read_to_end(&mut bytes).ok()?;
204    if bytes.len() > MAX_SCRIPT_INSPECTION_BYTES {
205        return None;
206    }
207    // `String::from_utf8` checks that the bytes are text; `.ok()?` returns nothing for a file that is not.
208    let text: String = String::from_utf8(bytes).ok()?;
209    // `.as_str()` lends the owned text as a borrowed view.
210    return recorded_git_path(text.as_str());
211}
212
213/// Controls for the unit text,
214/// the file read and the bounds.
215#[cfg(test)]
216#[path = "real_git_record_tests.rs"]
217mod tests;