forge_agent/env/git_command.rs
1//! The shared constructor every production `git` / `gh` spawn goes
2//! through.
3//!
4//! `GIT_DIR` / `GIT_WORK_TREE` / `GIT_COMMON_DIR` override `-C` and
5//! `current_dir` outright, so without the scrub a forge launched from
6//! a git hook, a `git rebase --exec`, or any shell that exported them
7//! answers every question about every repo from one foreign repo -
8//! at exit 0, with nothing in the output to say so. Positioning the
9//! process (`-C` for git, `current_dir` for gh) and args stay with
10//! the call site; nothing there may set a repo-location variable.
11
12use std::collections::HashMap;
13use std::process::Command;
14
15/// A `git` / `gh` spawn with the ambient repo-location env scrubbed,
16/// for synchronous callers anywhere in the crate (the plugin
17/// rollback's marketplace-clone git steps included).
18pub(crate) fn command(program: &str) -> Command {
19 let mut command = Command::new(program);
20 command.env_remove("GIT_DIR").env_remove("GIT_WORK_TREE").env_remove("GIT_COMMON_DIR");
21 command
22}
23
24/// Whether forge builds the ssh command for a child, or the checkout does.
25///
26/// **`GIT_SSH_COMMAND` outranks both `core.sshCommand` and `GIT_SSH`**, so a
27/// command forge sets is the one that runs: it may not be set while either of
28/// the others is the user's own way to reach their remote.
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
30pub(super) enum SshCommand {
31 /// The environment and the checkout name none, so forge's bounded one
32 /// runs.
33 Forge,
34 /// One of the user's spellings is in force, and it is left alone.
35 User,
36}
37
38/// A `git` child that may reach a remote.
39///
40/// **No prompt path, and no unbounded one.** A child on a dead link ends in an
41/// error rather than holding a read open for the process's life: git's
42/// terminal prompt is off, the environment's askpass hooks are removed, http
43/// aborts below a trickle, and when the effective configuration names no ssh
44/// command of its own (the global file included), forge's bounds ssh's connect
45/// and keeps the session alive.
46///
47/// **What the bounds cost, said plainly.** `http.lowSpeedLimit=1000` with
48/// `lowSpeedTime=20` ends a transfer that stays below one kilobyte a second
49/// for twenty seconds: a cap on a *working* link, chosen where only a dead one
50/// would notice, and git's own words ("Operation too slow") reach the page
51/// when it bites. `ServerAliveInterval=10` with `ServerAliveCountMax=3` turns
52/// a 45-second silence into an error, and because a command-line `-o` outranks
53/// `~/.ssh/config`, those two win over a user's own interval while forge's
54/// command runs; they are set only where the user named no command. A checkout
55/// command is left alone, and then its timeouts are its own. `core.askPass` is
56/// a checkout's own helper and is not touched: it answers or fails, it does
57/// not open a terminal.
58///
59/// This is the network children's alone, not the shared constructor's (#2668):
60/// a local scan has nothing to prompt for, and a plugin's clone still belongs
61/// to whoever asked for it.
62pub(super) fn network_tokio_git_command(
63 cwd: &std::path::Path,
64 ssh: SshCommand,
65) -> tokio::process::Command {
66 let mut command = tokio_git_command(cwd);
67 command.env("GIT_TERMINAL_PROMPT", "0");
68 command.env_remove("GIT_ASKPASS").env_remove("SSH_ASKPASS");
69 command.args(["-c", "http.lowSpeedLimit=1000", "-c", "http.lowSpeedTime=20"]);
70 if ssh == SshCommand::Forge {
71 command.env(
72 "GIT_SSH_COMMAND",
73 "ssh -o BatchMode=yes -o ConnectTimeout=15 -o ServerAliveInterval=10 \
74 -o ServerAliveCountMax=3",
75 );
76 }
77 command
78}
79
80/// The [`command`] variant async callers await; the scrub rides the
81/// std -> tokio conversion.
82pub(super) fn tokio_command(program: &str) -> tokio::process::Command {
83 command(program).into()
84}
85
86/// A `git` spawn positioned at `cwd`.
87///
88/// **The machine's own `git`, unless a test pointed that directory
89/// elsewhere** (`point_git_at`): a warm `git` can run to completion
90/// before a caller's first poll, so a case that needs a child which
91/// cannot - a zero-budget refusal - has to drive a wrapper of its own.
92pub(super) fn tokio_git_command(cwd: &std::path::Path) -> tokio::process::Command {
93 #[cfg(any(test, feature = "testing"))]
94 if let Some(program) = test_git_program(cwd) {
95 return tokio_command(&program.to_string_lossy());
96 }
97 #[cfg(not(any(test, feature = "testing")))]
98 let _ = cwd;
99 tokio_command("git")
100}
101
102/// Point every `git` spawn positioned at `cwd` at `program`, for this
103/// process's tests and for a crate whose fixtures build on the `testing`
104/// feature. Keyed by directory so parallel cases never run the machine's `git`
105/// through another case's wrapper.
106#[cfg(any(test, feature = "testing"))]
107pub(super) fn point_git_at(cwd: &std::path::Path, program: &std::path::Path) {
108 git_programs()
109 .lock()
110 .unwrap_or_else(std::sync::PoisonError::into_inner)
111 .push((cwd.to_path_buf(), program.to_path_buf()));
112}
113
114/// Drop a directory's pointer, back to the machine's `git`.
115#[cfg(any(test, feature = "testing"))]
116pub(super) fn clear_git_point(cwd: &std::path::Path) {
117 git_programs()
118 .lock()
119 .unwrap_or_else(std::sync::PoisonError::into_inner)
120 .retain(|(pointed, _)| pointed != cwd);
121}
122
123#[cfg(any(test, feature = "testing"))]
124fn test_git_program(cwd: &std::path::Path) -> Option<std::path::PathBuf> {
125 git_programs()
126 .lock()
127 .unwrap_or_else(std::sync::PoisonError::into_inner)
128 .iter()
129 .rev()
130 .find(|(pointed, _)| pointed == cwd)
131 .map(|(_, program)| program.clone())
132}
133
134#[cfg(any(test, feature = "testing"))]
135fn git_programs() -> &'static std::sync::Mutex<Vec<(std::path::PathBuf, std::path::PathBuf)>> {
136 static POINTS: std::sync::OnceLock<
137 std::sync::Mutex<Vec<(std::path::PathBuf, std::path::PathBuf)>>,
138 > = std::sync::OnceLock::new();
139 POINTS.get_or_init(|| std::sync::Mutex::new(Vec::new()))
140}
141
142/// The environment variables `gh` reads for auth, in its own precedence
143/// order; GITHUB_PAT_TOKEN is not one of them, but the environment that
144/// carries the hazard declares it beside the two that are.
145const AMBIENT_IDENTITY_VARS: [&str; 3] = ["GH_TOKEN", "GITHUB_TOKEN", "GITHUB_PAT_TOKEN"];
146
147/// A `gh` spawn for one project's own work: [`command`]'s scrub, the
148/// ambient identity removed, then the project's declared `env` applied -
149/// so a project that declares its own token acts as that project, and
150/// one that declares none falls back to gh's own login rather than
151/// silently to the server's environment.
152pub(super) fn gh_tokio_command<S: std::hash::BuildHasher>(
153 env: &HashMap<String, String, S>,
154) -> tokio::process::Command {
155 let mut command = tokio_command("gh");
156 for key in AMBIENT_IDENTITY_VARS {
157 command.env_remove(key);
158 }
159 command.envs(env);
160 command
161}
162
163#[cfg(test)]
164mod tests {
165 use super::*;
166
167 fn removed_keys(command: &Command) -> Vec<String> {
168 command
169 .get_envs()
170 .filter(|(_, value)| value.is_none())
171 .filter_map(|(key, _)| key.to_str().map(str::to_owned))
172 .collect()
173 }
174
175 fn tokio_removed_keys(command: &tokio::process::Command) -> Vec<String> {
176 // The conversion keeps the inner std Command verbatim, so the
177 // scrub is readable through `as_std`.
178 removed_keys(command.as_std())
179 }
180
181 /// One constructor, so one pin covers every production git/gh
182 /// spawn: deleting any `env_remove` here fails this test, and no
183 /// call site can lose the scrub without leaving this function.
184 #[test]
185 fn the_shared_constructor_scrubs_every_repo_location_variable() {
186 for program in ["git", "gh"] {
187 let std_removed = removed_keys(&command(program));
188 let tokio_removed = tokio_removed_keys(&tokio_command(program));
189 for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] {
190 assert!(
191 std_removed.iter().any(|k| k == key),
192 "{program} (std) is missing the {key} scrub",
193 );
194 assert!(
195 tokio_removed.iter().any(|k| k == key),
196 "{program} (tokio) is missing the {key} scrub",
197 );
198 }
199 }
200 // Production gh spawns leave `command` for `gh_tokio_command`, so
201 // the repo-location scrub is pinned on that path too.
202 let gh_removed = tokio_removed_keys(&gh_tokio_command(&HashMap::new()));
203 for key in ["GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR"] {
204 let scrubbed = gh_removed.iter().any(|k| k == key);
205 assert!(scrubbed, "the gh spawn is missing the {key} scrub");
206 }
207 }
208
209 /// The project's own identity reaches the child: a gh spawn built
210 /// for a project that declares its own GH_TOKEN carries it, so that
211 /// project's PR read answers as the project rather than as whoever
212 /// the server's environment says.
213 #[test]
214 fn the_gh_constructor_carries_the_projects_own_identity() {
215 let env = HashMap::from([("GH_TOKEN".to_owned(), "project-token".to_owned())]);
216 let command = gh_tokio_command(&env);
217 let carried = command
218 .as_std()
219 .get_envs()
220 .find(|(key, _)| *key == "GH_TOKEN")
221 .and_then(|(_, value)| value)
222 .and_then(|value| value.to_str());
223 assert_eq!(carried, Some("project-token"), "the project's GH_TOKEN is on the spawn");
224 }
225
226 /// **A network git child can never wait on a person, nor on a dead link.**
227 /// The review page's read waits for its base fetch to finish, so a prompt
228 /// inherited from the shell forge was launched in would hold the page open
229 /// for as long as nobody answers it, and a blackholed remote would hold it
230 /// open for ever. Every such child ends in a git error instead: the
231 /// terminal prompt is off, the environment's askpass hooks are removed,
232 /// http aborts below a trickle, and ssh is bounded when the checkout names
233 /// no command of its own.
234 #[test]
235 fn a_network_git_child_can_wait_on_neither_a_person_nor_a_dead_link() {
236 let cwd = std::path::Path::new("/tmp/nowhere");
237 let ours = network_tokio_git_command(cwd, SshCommand::Forge).into_std();
238 let set = |command: &Command, key: &str| {
239 command
240 .get_envs()
241 .find(|(k, _)| *k == key)
242 .and_then(|(_, value)| value)
243 .and_then(|value| value.to_str())
244 .map(str::to_owned)
245 };
246 assert_eq!(
247 set(&ours, "GIT_TERMINAL_PROMPT").as_deref(),
248 Some("0"),
249 "git's terminal prompt is disabled whatever the shell exported",
250 );
251 for key in ["GIT_ASKPASS", "SSH_ASKPASS"] {
252 assert!(
253 removed_keys(&ours).iter().any(|k| k == key),
254 "{key} reaches the child from this process's environment",
255 );
256 }
257 let args: Vec<String> =
258 ours.get_args().filter_map(|arg| arg.to_str().map(str::to_owned)).collect();
259 for pinned in ["http.lowSpeedLimit=1000", "http.lowSpeedTime=20"] {
260 assert!(args.iter().any(|arg| arg == pinned), "http is bounded: {args:?}");
261 }
262 let ssh = set(&ours, "GIT_SSH_COMMAND").unwrap_or_default();
263 for pinned in ["BatchMode=yes", "ConnectTimeout=15", "ServerAliveInterval=10"] {
264 assert!(ssh.contains(pinned), "forge's ssh is bounded: {ssh:?}");
265 }
266
267 // **A checkout's own command is left alone**, whichever spelling holds
268 // it: GIT_SSH_COMMAND outranks both, so forge must not set it.
269 let theirs = network_tokio_git_command(cwd, SshCommand::User).into_std();
270 assert!(
271 set(&theirs, "GIT_SSH_COMMAND").is_none(),
272 "the user's own ssh command is not overridden",
273 );
274
275 // And the shared constructor carries none of it: this is the network
276 // children's, so a local scan and a plugin's clone are untouched.
277 let local = command("git");
278 assert!(set(&local, "GIT_TERMINAL_PROMPT").is_none(), "a local git is not reshaped");
279 assert!(
280 !removed_keys(&local).iter().any(|k| k == "GIT_ASKPASS"),
281 "a local git keeps the environment's askpass hooks",
282 );
283 }
284
285 /// And the server's identity never reaches it: a project that
286 /// declares no token of its own gets gh's own login, not the
287 /// environment forge itself was launched with. `gh` reads GH_TOKEN
288 /// then GITHUB_TOKEN; GITHUB_PAT_TOKEN rides along because the
289 /// environment that carries the hazard declares it beside them.
290 #[test]
291 fn the_gh_constructor_scrubs_the_ambient_identity_variables() {
292 let removed = tokio_removed_keys(&gh_tokio_command(&HashMap::new()));
293 for key in ["GH_TOKEN", "GITHUB_TOKEN", "GITHUB_PAT_TOKEN"] {
294 assert!(
295 removed.iter().any(|k| k == key),
296 "{key} is not removed from a gh spawn: {removed:?}",
297 );
298 }
299 }
300}