git_policy_cli/child_environment.rs
1//! What:
2//! The environment variables cli-git adds for every real Git it starts.
3//! Why:
4//! Native Git must write lock-owner PID files,
5//! and a wrapper must be able to
6//! notice that another wrapper selected it as "real Git".
7//!
8//! In TS you'd write (pseudocode):
9//! ```ts
10//! // spawn(gitPath, args, { env: { ...process.env, ...childEnvironmentOverlay(process.env, gitPath) } });
11//! ```
12
13/// What:
14/// `OsStr` is borrowed operating-system text;
15/// `OsString` is its owned form.
16/// They hold raw OS bytes (siblings `&str`/`String` must be valid UTF-8).
17/// Why:
18/// Environment names and values need not be UTF-8 and are passed through unchanged.
19///
20/// In TS you'd write (pseudocode):
21/// ```ts
22/// // Node exposes process.env as strings; there is no byte-preserving equivalent.
23/// ```
24use std::ffi::{OsStr, OsString};
25/// `Path` is a borrowed filesystem path made of the same raw OS bytes.
26use std::path::Path;
27
28/// Git's count variable for environment-supplied configuration.
29pub const COUNT_VARIABLE: &str = "GIT_CONFIG_COUNT";
30
31/// What:
32/// The variable naming the executable this wrapper selected as real Git.
33/// Why:
34/// If the selected executable is itself a cli-git wrapper (for example another
35/// build that file identity cannot recognise),
36/// it finds its own path here and
37/// stops instead of forwarding back and forth forever.
38///
39/// In TS you'd write (pseudocode):
40/// ```ts
41/// const FORWARD_TARGET_VARIABLE = 'CLI_GIT_NATIVE_FORWARD_TARGET';
42/// ```
43pub const FORWARD_TARGET_VARIABLE: &str = "CLI_GIT_NATIVE_FORWARD_TARGET";
44
45/// Key spelling written into the environment;
46/// Git compares it case-insensitively.
47const LOCKFILE_PID_KEY: &str = "core.lockfilePid";
48
49/// What:
50/// Largest count Git accepts (`INT_MAX`),
51/// as an unsigned 64-bit integer.
52/// Siblings:
53/// `u32`,
54/// `usize`,
55/// `i64`.
56/// Why:
57/// `u64` holds every value C's `strtoul` can return on a 64-bit platform,
58/// so
59/// the comparison Git makes can be repeated without wrapping.
60///
61/// In TS you'd write (pseudocode):
62/// ```ts
63/// const GIT_MAX_CONFIG_COUNT = 2_147_483_647;
64/// ```
65const GIT_MAX_CONFIG_COUNT: u64 = 2_147_483_647;
66
67/// What:
68/// Look one variable up the way C's `getenv` does:
69/// the first entry wins.
70/// `&[(OsString, OsString)]` borrows a list of name/value pairs.
71/// `Option<OsString>` is "an owned value or nothing".
72/// Why:
73/// The overlay must read exactly what the Git child will read.
74/// Returning an
75/// owned copy keeps the function free of borrow bookkeeping for the caller.
76///
77/// In TS you'd write (pseudocode):
78/// ```ts
79/// function environmentValue(environment: [string, string][], name: string): string | undefined;
80/// ```
81pub fn environment_value(environment: &[(OsString, OsString)], name: &str) -> Option<OsString> {
82 // What: `for (key, value) in environment` destructures each borrowed pair.
83 // Why: The list is small and scanned in its original order.
84 //
85 // In TS you'd write (pseudocode):
86 // ```ts
87 // for (const [key, value] of environment) { ... }
88 // ```
89 for (key, value) in environment {
90 // `key == name` compares OS text with UTF-8 text without converting either.
91 if key == name {
92 // What: `Some(value.clone())` wraps an owned copy in the "present" variant.
93 // Why: The caller may keep the value after the environment list is gone.
94 //
95 // In TS you'd write (pseudocode):
96 // ```ts
97 // return value;
98 // ```
99 return Some(value.clone());
100 }
101 }
102 // `None` is the "absent" variant: the variable is not set.
103 return None;
104}
105
106/// What:
107/// Parse `GIT_CONFIG_COUNT` exactly as Git 2.56.0 `config.c` does with
108/// `strtoul(env, &endp, 10)` followed by its `*endp` and `INT_MAX` checks.
109/// `Option<u64>` is `Some(count)` when Git accepts the value,
110/// `None` when Git
111/// would report "bogus count" or "too many entries".
112/// Why:
113/// The overlay must append after the entries Git will really read,
114/// and must
115/// leave a value Git rejects untouched so Git reports the caller's own mistake.
116/// `strtoul` skips leading whitespace and accepts one sign;
117/// a negated nonzero
118/// value wraps above `INT_MAX`,
119/// so only `-0` survives with a minus sign.
120///
121/// In TS you'd write (pseudocode):
122/// ```ts
123/// function parseConfigCount(value: string): number | undefined;
124/// ```
125pub fn parse_config_count(value: &OsStr) -> Option<u64> {
126 // What: `&[u8]` borrows the value's raw bytes (`u8` is one byte, 0 to 255).
127 // Why: Only ASCII whitespace, signs and digits are meaningful; anything else rejects.
128 //
129 // In TS you'd write (pseudocode):
130 // ```ts
131 // const bytes = Buffer.from(value);
132 // ```
133 let bytes: &[u8] = value.as_encoded_bytes();
134 // An empty string converts nothing and ends at once: Git reads it as zero entries.
135 if bytes.is_empty() {
136 return Some(0);
137 }
138 // `usize` is the index type of every Rust list; `mut` allows advancing it.
139 let mut index: usize = 0;
140 // C `isspace`: space, tab, line feed, vertical tab, form feed, carriage return.
141 while index < bytes.len() && [b' ', b'\t', b'\n', 0x0b, 0x0c, b'\r'].contains(&bytes[index]) {
142 index += 1;
143 }
144 let mut negative: bool = false;
145 if index < bytes.len() && (bytes[index] == b'+' || bytes[index] == b'-') {
146 negative = bytes[index] == b'-';
147 index += 1;
148 }
149 let first_digit: usize = index;
150 let mut count: u64 = 0;
151 while index < bytes.len() && bytes[index].is_ascii_digit() {
152 // What: `checked_mul`/`checked_add` return `None` on overflow; a trailing `?`
153 // returns that `None` to our caller, or unwraps the number.
154 // Why: A value too large for `strtoul` saturates far above `INT_MAX`, which
155 // Git rejects; overflow therefore means "rejected".
156 //
157 // In TS you'd write (pseudocode):
158 // ```ts
159 // count = count * 10n + BigInt(digit); // BigInt cannot overflow
160 // ```
161 count = count
162 .checked_mul(10)?
163 .checked_add(u64::from(bytes[index] - b'0'))?;
164 index += 1;
165 }
166 // No digit converted, or bytes remain after the digits: Git's `*endp` check fails.
167 if index == first_digit || index != bytes.len() {
168 return None;
169 }
170 if negative && count != 0 {
171 return None;
172 }
173 if count > GIT_MAX_CONFIG_COUNT {
174 return None;
175 }
176 return Some(count);
177}
178
179/// What:
180/// Report whether a configuration value is one of the true spellings cli-git
181/// itself writes or recognises (`true`,
182/// `yes`,
183/// `on`,
184/// `1`,
185/// any ASCII case).
186/// Why:
187/// When the last numbered `core.lockfilePid` entry already reads as true,
188/// a
189/// nested cli-git must not grow the list again.
190///
191/// In TS you'd write (pseudocode):
192/// ```ts
193/// function isTrueSpelling(value: string): boolean;
194/// ```
195fn is_true_spelling(value: &OsStr) -> bool {
196 let bytes: &[u8] = value.as_encoded_bytes();
197 // `b"true"` is a byte-string literal; `.as_slice()` makes the array a borrowed list.
198 for spelling in [b"true".as_slice(), b"yes", b"on", b"1"] {
199 if bytes.eq_ignore_ascii_case(spelling) {
200 return true;
201 }
202 }
203 return false;
204}
205
206/// What:
207/// Compute the variables that append `core.lockfilePid=true` to an environment's
208/// numbered Git configuration.
209/// `Vec<(OsString, OsString)>` is an owned list of
210/// name/value pairs;
211/// it is empty when nothing must change.
212/// Why:
213/// Native Git then writes an owner PID file beside each lock it takes,
214/// which
215/// lock-ownership checks rely on.
216/// Numbered entries are read before
217/// `GIT_CONFIG_PARAMETERS`,
218/// so a caller's explicit `-c core.lockfilePid=false`
219/// still wins.
220/// A count Git would reject,
221/// or a numbered entry Git would report
222/// as missing,
223/// is left untouched so Git reports the caller's own mistake.
224///
225/// In TS you'd write (pseudocode):
226/// ```ts
227/// function lockfilePidOverlay(environment: [string, string][]): [string, string][];
228/// ```
229pub fn lockfile_pid_overlay(environment: &[(OsString, OsString)]) -> Vec<(OsString, OsString)> {
230 // What: `match` picks by variant: an unset variable is zero entries; a set one is parsed.
231 // Why: Only a count Git accepts tells us where the next free index is.
232 //
233 // In TS you'd write (pseudocode):
234 // ```ts
235 // const count = raw === undefined ? 0 : parseConfigCount(raw);
236 // ```
237 let count: u64 = match environment_value(environment, COUNT_VARIABLE) {
238 None => 0,
239 Some(raw) => {
240 // `let Some(x) = ... else { return ... };` unwraps the accepted count or exits.
241 let Some(parsed) = parse_config_count(raw.as_os_str()) else {
242 // `Vec::new()` is the empty owned list: nothing to add.
243 return Vec::<(OsString, OsString)>::new();
244 };
245 parsed
246 }
247 };
248 // The value of the last numbered `core.lockfilePid` entry, which is the one Git applies.
249 let mut effective: Option<OsString> = None;
250 let mut index: u64 = 0;
251 while index < count {
252 let key: Option<OsString> =
253 environment_value(environment, format!("GIT_CONFIG_KEY_{index}").as_str());
254 let value: Option<OsString> =
255 environment_value(environment, format!("GIT_CONFIG_VALUE_{index}").as_str());
256 // A missing numbered entry makes Git fail; stopping here also bounds this loop
257 // by the real environment size instead of by an arbitrary declared count.
258 let (Some(present_key), Some(present_value)) = (key, value) else {
259 return Vec::<(OsString, OsString)>::new();
260 };
261 if present_key
262 .as_encoded_bytes()
263 .eq_ignore_ascii_case(b"core.lockfilepid")
264 {
265 effective = Some(present_value);
266 }
267 index += 1;
268 }
269 // `if let Some(value) = &effective && ...` borrows the stored value when one exists
270 // and then also requires the second condition, like `a !== undefined && check(a)`.
271 if let Some(value) = &effective
272 && is_true_spelling(value.as_os_str())
273 {
274 return Vec::<(OsString, OsString)>::new();
275 }
276 // `vec![...]` builds an owned list; `OsString::from` copies text into OS form.
277 return vec![
278 (
279 OsString::from(COUNT_VARIABLE),
280 OsString::from((count + 1).to_string()),
281 ),
282 (
283 OsString::from(format!("GIT_CONFIG_KEY_{count}")),
284 OsString::from(LOCKFILE_PID_KEY),
285 ),
286 (
287 OsString::from(format!("GIT_CONFIG_VALUE_{count}")),
288 OsString::from("true"),
289 ),
290 ];
291}
292
293/// What:
294/// Compute every variable cli-git sets for a real Git child:
295/// the lock PID
296/// injection plus the forward-target marker naming `real_git`.
297/// Why:
298/// Every Git the wrapper starts,
299/// forwarded or queried,
300/// gets the same additions;
301/// the rest of the caller's environment is inherited unchanged.
302///
303/// In TS you'd write (pseudocode):
304/// ```ts
305/// function childEnvironmentOverlay(environment: [string, string][], realGit: string): [string, string][];
306/// ```
307pub fn child_environment_overlay(
308 environment: &[(OsString, OsString)],
309 real_git: &Path,
310) -> Vec<(OsString, OsString)> {
311 let mut overlay: Vec<(OsString, OsString)> = lockfile_pid_overlay(environment);
312 // `.as_os_str().to_os_string()` copies the path's raw bytes without any decoding.
313 overlay.push((
314 OsString::from(FORWARD_TARGET_VARIABLE),
315 real_git.as_os_str().to_os_string(),
316 ));
317 return overlay;
318}
319
320/// What:
321/// The complete environment of a Git child that is started with a cleared environment:
322/// the caller's variables, with every name the overlay sets replaced by the overlay's value.
323/// Why:
324/// The incumbent's push coordination, push and manual-push probe inherit the whole process environment
325/// (`HOME`, `PATH`, `SSH_AUTH_SOCK`, the user's `GIT_*` variables) plus the overlay,
326/// and a credential helper or transport needs them;
327/// the overlay alone would start Git with an almost empty environment.
328///
329/// In TS you'd write (pseudocode):
330/// ```ts
331/// ({ ...process.env, ...Object.fromEntries(overlay) })
332/// ```
333pub fn environment_with_overlay(
334 environment: &[(OsString, OsString)],
335 overlay: &[(OsString, OsString)],
336) -> Vec<(OsString, OsString)> {
337 // `mut` lets the overlay entries be appended after the surviving caller entries.
338 let mut result: Vec<(OsString, OsString)> = Vec::with_capacity(environment.len() + overlay.len());
339 for (name, value) in environment {
340 let replaced: bool = overlay.iter().any(|(overlaid, _value)| {
341 return overlaid == name;
342 });
343 if !replaced {
344 result.push((name.clone(), value.clone()));
345 }
346 }
347 result.extend(overlay.iter().cloned());
348 return result;
349}
350
351/// Lookup and count-parsing controls,
352/// checked against real Git.
353#[cfg(test)]
354#[path = "child_environment_count_tests.rs"]
355mod count_tests;
356
357/// Overlay controls and the shared environment builder stay out of the release executable.
358#[cfg(test)]
359#[path = "child_environment_tests.rs"]
360mod tests;