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;