Skip to main content

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}