Skip to main content

nb_api/
git_env.rs

1//! Environment hygiene for spawning git-aware subprocesses.
2//!
3//! When a process is invoked from inside a Git hook (pre-commit,
4//! pre-push, post-checkout, ...) or from a CI runner, Git exports a
5//! set of repository-routing environment variables into the hook's
6//! environment: `GIT_DIR`, `GIT_INDEX_FILE`, `GIT_COMMON_DIR`,
7//! `GIT_WORK_TREE`, `GIT_OBJECT_DIRECTORY`,
8//! `GIT_ALTERNATE_OBJECT_DIRECTORIES`. Every subprocess the hook
9//! spawns inherits them.
10//!
11//! Any of these variables redirect every Git call inside the
12//! subprocess away from the subprocess's expected repository.
13//! Downstream tools layered over Git — for our purposes, `nb`, which
14//! is a bash script wrapping Git — then act on the wrong repo:
15//! `nb notebooks add` writes a scratch notebook into the parent
16//! repo, the subsequent `nb notebooks` listing reads from a
17//! different root, and the test fails in ways that depend on the
18//! hook environment (CI vs. local vs. the same tests run outside
19//! any hook).
20//!
21//! The fix is mechanical: before invoking any Git-aware subprocess
22//! from inside a context that may be hooked or CI-driven, remove
23//! every `GIT_*` variable from the child's environment. The child
24//! then starts from a clean slate and resolves the repository from
25//! its own `cwd` / its own arguments.
26//!
27//! This module mirrors [`nbspec::git_env`](https://docs.rs/nbspec)
28//! (which had the original CI-failure-driven implementation). The
29//! tokio `Command` variant here is the form nb-api uses; consumers
30//! using `std::process::Command` can swap in `env_remove` directly
31//! without depending on this helper. See `nb-api:issues/2` for
32//! the related read-path hygiene finding (show line-wrap) and
33//! `nb-api:issues/3` for the original git-env finding reported by
34//! Nbspec Owner.
35
36use std::process::Command as StdCommand;
37use tokio::process::Command;
38
39/// Returns the names of every environment variable in the current
40/// process whose name starts with `GIT_`. Exposed so other call
41/// sites (a future `std::process::Command` variant in `git_env`, or
42/// any caller that wants the list without the scrub) share one
43/// enumeration policy.
44///
45/// **Blast vs. selective — deliberate decision.** The `GIT_` prefix
46/// blast also removes intent vars (`GIT_CONFIG_GLOBAL`,
47/// `GIT_SSH_COMMAND`, `GIT_TERMINAL_PROMPT`, ...). Today no nb-api
48/// code path consumes those; the only vars that redirect Git's
49/// view of the repository are `GIT_DIR`, `GIT_INDEX_FILE`,
50/// `GIT_COMMON_DIR`, `GIT_WORK_TREE`, `GIT_OBJECT_DIRECTORY`, and
51/// `GIT_ALTERNATE_OBJECT_DIRECTORIES`. A more selective policy
52/// could enumerate exactly those. The blast is chosen for two
53/// reasons: (1) any future `GIT_*` redirect that lands in this
54/// range gets caught by default rather than requiring a code
55/// change; (2) keeping the predicate to a prefix check is the
56/// minimum surface to audit. Revisit if a container identity
57/// mechanism ever routes through `GIT_CONFIG_GLOBAL` — at that
58/// point a selective enumeration belongs here.
59pub fn leaked_git_names() -> Vec<String> {
60    std::env::vars()
61        .filter_map(|(name, _)| {
62            if name.starts_with("GIT_") {
63                Some(name)
64            } else {
65                None
66            }
67        })
68        .collect()
69}
70
71/// Removes every environment variable whose name starts with `GIT_`
72/// from the given tokio `Command`'s environment. The spawned process
73/// inherits every other variable from the parent (PATH, HOME,
74/// LANG, ...), just not the ones that redirect Git's view of the
75/// repository.
76///
77/// Pass the command BEFORE chaining `.args(...)` or `.env(...)` so
78/// later `.env(name, value)` calls are not also removed.
79///
80/// # Example
81///
82/// ```no_run
83/// use tokio::process::Command;
84/// nb_api::scrub_git_env(&mut Command::new("nb"));
85/// ```
86pub fn scrub_git_env(command: &mut Command) {
87    for name in leaked_git_names() {
88        command.env_remove(&name);
89    }
90}
91
92/// Synchronous-`Command` variant of [`scrub_git_env`]. nb-api uses
93/// `tokio::process::Command` for every `nb` invocation, but
94/// `git_rev_parse` (the only direct `git` spawn) uses
95/// `std::process::Command`. Both spawn sites must be scrubbed; if a
96/// future helper spawns git synchronously, use this overload.
97///
98/// # Example
99///
100/// ```no_run
101/// use std::process::Command;
102/// nb_api::scrub_git_env_std(&mut Command::new("git"));
103/// ```
104pub fn scrub_git_env_std(command: &mut StdCommand) {
105    for name in leaked_git_names() {
106        command.env_remove(&name);
107    }
108}
109
110#[cfg(test)]
111mod tests {
112    use super::*;
113    use std::ffi::OsString;
114
115    #[test]
116    fn leaked_git_names_only_returns_git_prefixed() {
117        // Filter logic is decoupled from process env: build a synthetic
118        // iterator and assert the predicate. The process env may or may
119        // not have GIT_* vars; we don't depend on either case.
120        let pairs: Vec<(String, OsString)> = vec![
121            ("GIT_DIR".to_string(), OsString::from("/foo")),
122            ("PATH".to_string(), OsString::from("/usr/bin")),
123            ("GIT_INDEX_FILE".to_string(), OsString::from("/foo/index")),
124            ("HOME".to_string(), OsString::from("/home")),
125            ("git_dir".to_string(), OsString::from("lowercase")),
126            ("GIT_".to_string(), OsString::from("empty-suffix")),
127            ("FOO_GIT_BAR".to_string(), OsString::from("not-prefixed")),
128        ];
129        let filtered: Vec<String> = pairs
130            .into_iter()
131            .filter_map(|(name, _)| {
132                if name.starts_with("GIT_") {
133                    Some(name)
134                } else {
135                    None
136                }
137            })
138            .collect();
139        assert_eq!(
140            filtered,
141            vec![
142                "GIT_DIR".to_string(),
143                "GIT_INDEX_FILE".to_string(),
144                "GIT_".to_string(),
145            ]
146        );
147    }
148
149    #[test]
150    fn leaked_git_names_against_process_env_only_returns_git_vars() {
151        // Whatever GIT_* vars happen to be set in the test process, the
152        // function must only return names starting with "GIT_". This
153        // guards against accidental broadening of the predicate.
154        let leaked = leaked_git_names();
155        for name in &leaked {
156            assert!(
157                name.starts_with("GIT_"),
158                "leaked_git_names returned non-GIT_ var: {name}"
159            );
160        }
161    }
162
163    #[test]
164    fn scrub_git_env_removes_git_vars_via_env_remove() {
165        // `tokio::process::Command::env_remove` removes from the
166        // inherited environment; verify the call site wires through to
167        // it. We can't easily inspect a tokio Command's resulting env
168        // pre-spawn, so we exercise the std::process::Command form
169        // (same `env_remove` semantics) against the same helper list.
170        use std::process::Command as StdCommand;
171        let mut cmd = StdCommand::new("true");
172        // Inherited env may already contain GIT_* in this process; the
173        // scrub should remove them so the spawned `true` would see
174        // none. We can only assert the helper list is consumed.
175        for name in leaked_git_names() {
176            cmd.env_remove(&name);
177        }
178        // Smoke: the call did not panic; the command is still buildable.
179        let _ = cmd.get_program();
180    }
181}