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}