Skip to main content

gitwig/
git_cmd.rs

1//! Hardened `git` subprocess construction and bounded execution.
2//!
3//! Every `git` invocation that can touch a remote **must** be built here. A bare
4//! `Command::new("git")` inherits the controlling terminal, so an unreachable or
5//! permission-denied remote lets `ssh` or a credential helper write its prompt
6//! straight into the alternate screen — corrupting the TUI — or block forever on
7//! `/dev/tty` with no way for the user to cancel.
8//!
9//! [`git_command`] removes that class of failure by disabling every interactive
10//! prompt path, and [`run_git_with_timeout`] guarantees the child is reaped even
11//! when the remote simply never answers.
12
13use std::io::Read;
14use std::process::{Command, Output, Stdio};
15use std::time::{Duration, Instant};
16
17/// How often the supervising thread checks whether the child has exited.
18const POLL_INTERVAL: Duration = Duration::from_millis(50);
19
20/// Builds a `git` command that can never block on an interactive prompt.
21///
22/// - `GIT_TERMINAL_PROMPT=0` stops git asking for a username/password on the tty.
23/// - `GIT_SSH_COMMAND` pins host-key handling so ssh never asks to confirm a
24///   fingerprint (see [`crate::config::ssh_command_val`]).
25/// - `GIT_ASKPASS`/`SSH_ASKPASS` are neutralised so no GUI or console credential
26///   helper can be spawned behind the alternate screen.
27/// - The protocol allowlist blocks `ext::`-style transports from a hostile
28///   `.gitmodules` or remote URL.
29/// - stdin is `/dev/null`, so anything that still tries to read gets EOF instead
30///   of stealing the user's keystrokes.
31pub fn git_command() -> Command {
32    let mut cmd = Command::new("git");
33    cmd.env("GIT_TERMINAL_PROMPT", "0");
34    cmd.env("GIT_SSH_COMMAND", crate::config::ssh_command_val());
35    cmd.env("GIT_ALLOW_PROTOCOL", "https:ssh:git:file");
36    cmd.env("GIT_PROTOCOL_FROM_USER", "0");
37    // An askpass helper would pop a prompt *outside* our alternate screen.
38    cmd.env("GIT_ASKPASS", "");
39    cmd.env("SSH_ASKPASS", "");
40    cmd.env("GCM_INTERACTIVE", "Never");
41    cmd.stdin(Stdio::null());
42    cmd
43}
44
45/// Why a bounded `git` run did not produce a normal exit status.
46#[derive(Debug, Clone, PartialEq, Eq)]
47pub enum GitRunError {
48    /// The child outlived its budget and was killed.
49    Timeout(Duration),
50    /// The child could not be spawned or supervised.
51    Spawn(String),
52}
53
54impl std::fmt::Display for GitRunError {
55    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
56        match self {
57            GitRunError::Timeout(d) => {
58                write!(f, "git did not respond within {}s and was cancelled", d.as_secs())
59            }
60            GitRunError::Spawn(e) => write!(f, "could not run git: {}", e),
61        }
62    }
63}
64
65impl std::error::Error for GitRunError {}
66
67/// Runs `cmd` to completion, killing it if it exceeds `timeout`.
68///
69/// `Command::output()` blocks forever on a remote that accepts the connection but
70/// never replies, which is exactly what a firewalled or blackholed host does. This
71/// drains stdout and stderr on dedicated threads (a bare `try_wait` poll loop
72/// deadlocks once a pipe buffer fills) while a timer supervises the child.
73///
74/// A zero timeout means "no limit" and simply defers to `output()`.
75pub fn run_git_with_timeout(mut cmd: Command, timeout: Duration) -> Result<Output, GitRunError> {
76    if timeout.is_zero() {
77        return cmd.output().map_err(|e| GitRunError::Spawn(e.to_string()));
78    }
79
80    cmd.stdin(Stdio::null()).stdout(Stdio::piped()).stderr(Stdio::piped());
81
82    let mut child = cmd.spawn().map_err(|e| GitRunError::Spawn(e.to_string()))?;
83
84    // Drain both pipes concurrently; otherwise a chatty `git fetch` fills the
85    // stderr buffer, blocks the child, and the timeout fires on a process that
86    // was only ever waiting on us.
87    let mut out_pipe = child.stdout.take();
88    let mut err_pipe = child.stderr.take();
89    let out_reader = std::thread::spawn(move || {
90        let mut buf = Vec::new();
91        if let Some(p) = out_pipe.as_mut() {
92            let _ = p.read_to_end(&mut buf);
93        }
94        buf
95    });
96    let err_reader = std::thread::spawn(move || {
97        let mut buf = Vec::new();
98        if let Some(p) = err_pipe.as_mut() {
99            let _ = p.read_to_end(&mut buf);
100        }
101        buf
102    });
103
104    let started = Instant::now();
105    let status = loop {
106        match child.try_wait() {
107            Ok(Some(status)) => break Some(status),
108            Ok(None) => {
109                if started.elapsed() >= timeout {
110                    let _ = child.kill();
111                    let _ = child.wait();
112                    break None;
113                }
114                std::thread::sleep(POLL_INTERVAL);
115            }
116            Err(e) => {
117                let _ = child.kill();
118                let _ = child.wait();
119                return Err(GitRunError::Spawn(e.to_string()));
120            }
121        }
122    };
123
124    let stdout = out_reader.join().unwrap_or_default();
125    let stderr = err_reader.join().unwrap_or_default();
126
127    match status {
128        Some(status) => Ok(Output { status, stdout, stderr }),
129        None => Err(GitRunError::Timeout(timeout)),
130    }
131}