Skip to main content

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;