Skip to main content

lonca_daemon/
gitcmd.rs

1//! How the daemon runs `git`, the one program it starts for network
2//! transport (fetch, push) and for a few plumbing reads.
3//!
4//! Git reads configuration from the environment, the system, the user's
5//! home and the repository, and any of them can change which remote it talks
6//! to (`url.<x>.insteadOf`), what it runs (`core.sshCommand`, a credential
7//! helper, hooks) or where it connects (`http.proxy`). In cooperative mode
8//! that is the developer's configuration, which agents running as the
9//! developer can write. So the child gets an environment built here and
10//! nothing else: no system configuration, a home of the daemon's own
11//! (`<daemon home>/git-home`, where an administrator may put credentials), no
12//! hooks, and a time limit after which its whole process group is killed.
13
14use std::ffi::OsString;
15use std::io::Read;
16use std::os::unix::process::CommandExt;
17use std::path::{Path, PathBuf};
18use std::process::{Command, Stdio};
19use std::time::{Duration, Instant};
20
21/// How long a git command may run before it is killed (network included).
22pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600);
23
24/// Transports git may use: no `ext::`, no `fd::`, no plain `git://`.
25const PROTOCOLS: &str = "file:https:ssh";
26
27/// What the environment of a git child may carry from the daemon's.
28/// `SSH_AUTH_SOCK` lets a cooperative daemon push with the developer's
29/// agent, as the developer's own `git push` would; nothing else passes.
30const PASSED: &[&str] = &["PATH", "SSH_AUTH_SOCK", "LANG", "LC_ALL", "TMPDIR"];
31
32#[derive(Clone, Debug)]
33pub struct Git {
34    /// The program (`git`, or a path).
35    pub bin: String,
36    /// `HOME` of the child: the daemon's own git home.
37    pub home: PathBuf,
38    pub timeout: Duration,
39}
40
41/// Why a git command did not succeed.
42#[derive(Debug, PartialEq, Eq)]
43pub enum Failure {
44    /// It could not be started.
45    Start(String),
46    /// It ran past its time and was killed with its group.
47    Timeout(Duration),
48    /// It exited unsuccessfully; what it said on stderr.
49    Exit(String),
50}
51
52impl Git {
53    /// The environment of every git child, from the daemon's own `env`.
54    pub fn env(
55        &self,
56        env: impl Iterator<Item = (OsString, OsString)>,
57    ) -> Vec<(OsString, OsString)> {
58        let mut out: Vec<(OsString, OsString)> =
59            env.filter(|(k, _)| PASSED.iter().any(|p| k == p)).collect();
60        let fixed = [
61            ("HOME", self.home.as_os_str().to_owned()),
62            ("GIT_CONFIG_NOSYSTEM", "1".into()),
63            ("GIT_TERMINAL_PROMPT", "0".into()),
64            ("GIT_ALLOW_PROTOCOL", PROTOCOLS.into()),
65        ];
66        out.extend(fixed.into_iter().map(|(k, v)| (OsString::from(k), v)));
67        out
68    }
69
70    /// Runs `git --git-dir <dir> <args>` and returns its stdout.
71    pub fn run(&self, dir: &Path, args: &[&str]) -> Result<String, Failure> {
72        std::fs::create_dir_all(&self.home).map_err(|e| Failure::Start(e.to_string()))?;
73        let mut cmd = Command::new(&self.bin);
74        cmd.env_clear()
75            .envs(self.env(std::env::vars_os()))
76            .current_dir(&self.home)
77            .args([
78                "-c",
79                "core.hooksPath=/dev/null",
80                "-c",
81                "credential.interactive=never",
82            ])
83            .arg("--git-dir")
84            .arg(dir)
85            .args(args)
86            .stdin(Stdio::null())
87            .stdout(Stdio::piped())
88            .stderr(Stdio::piped())
89            // Its own group, so a timeout reaches ssh and the remote
90            // helpers it started too.
91            .process_group(0);
92        let mut child = cmd.spawn().map_err(|e| Failure::Start(e.to_string()))?;
93        let read = |mut r: Box<dyn Read + Send>| {
94            std::thread::spawn(move || {
95                let mut buf = Vec::new();
96                let _ = r.read_to_end(&mut buf);
97                buf
98            })
99        };
100        let out = child.stdout.take().map(|o| read(Box::new(o)));
101        let err = child.stderr.take().map(|e| read(Box::new(e)));
102        let started = Instant::now();
103        let status = loop {
104            match child.try_wait() {
105                Ok(Some(status)) => break status,
106                Ok(None) if started.elapsed() >= self.timeout => {
107                    let group = child.id() as libc::pid_t;
108                    crate::sys::kill_group(group, libc::SIGKILL);
109                    let _ = child.wait();
110                    return Err(Failure::Timeout(self.timeout));
111                }
112                Ok(None) => std::thread::sleep(Duration::from_millis(20)),
113                Err(e) => return Err(Failure::Start(e.to_string())),
114            }
115        };
116        let join = |t: Option<std::thread::JoinHandle<Vec<u8>>>| {
117            t.and_then(|t| t.join().ok()).unwrap_or_default()
118        };
119        let (out, err) = (join(out), join(err));
120        if !status.success() {
121            return Err(Failure::Exit(
122                String::from_utf8_lossy(&err).trim().to_string(),
123            ));
124        }
125        Ok(String::from_utf8_lossy(&out).into_owned())
126    }
127}
128
129/// A branch name as git takes one: `git check-ref-format --branch`, after
130/// Lonca's own narrower test. Run with the same clean environment.
131pub fn branch_ok(git: &Git, name: &str) -> bool {
132    let mut cmd = Command::new(&git.bin);
133    cmd.env_clear()
134        .envs(git.env(std::env::vars_os()))
135        .args(["check-ref-format", "--branch", name])
136        .stdin(Stdio::null())
137        .stdout(Stdio::null())
138        .stderr(Stdio::null());
139    cmd.status().is_ok_and(|s| s.success())
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    fn git(dir: &Path, timeout: Duration) -> Git {
147        Git {
148            bin: "git".into(),
149            home: dir.join("git-home"),
150            timeout,
151        }
152    }
153
154    #[test]
155    fn the_environment_passes_little_and_fixes_the_rest() {
156        let dir = tempfile::tempdir().unwrap();
157        let g = git(dir.path(), DEFAULT_TIMEOUT);
158        let from = [
159            ("PATH", "/usr/bin"),
160            ("HOME", "/Users/dev"),
161            ("GIT_SSH_COMMAND", "evil"),
162            ("GIT_CONFIG_GLOBAL", "/tmp/evil"),
163            ("GIT_CONFIG_COUNT", "1"),
164            ("SSH_AUTH_SOCK", "/tmp/agent"),
165            ("GIT_DIR", "/elsewhere"),
166        ];
167        let env = g.env(from.iter().map(|(k, v)| (k.into(), v.into())));
168        let get = |k: &str| {
169            env.iter()
170                .filter(|(n, _)| n == k)
171                .map(|(_, v)| v.to_string_lossy().into_owned())
172                .collect::<Vec<_>>()
173        };
174        assert_eq!(get("PATH"), ["/usr/bin"]);
175        assert_eq!(get("SSH_AUTH_SOCK"), ["/tmp/agent"]);
176        assert_eq!(get("HOME"), [g.home.to_string_lossy().into_owned()]);
177        assert_eq!(get("GIT_CONFIG_NOSYSTEM"), ["1"]);
178        assert_eq!(get("GIT_ALLOW_PROTOCOL"), ["file:https:ssh"]);
179        for gone in [
180            "GIT_SSH_COMMAND",
181            "GIT_CONFIG_GLOBAL",
182            "GIT_CONFIG_COUNT",
183            "GIT_DIR",
184        ] {
185            assert!(get(gone).is_empty(), "{gone}");
186        }
187    }
188
189    #[test]
190    fn a_command_past_its_time_is_killed_with_its_group() {
191        let dir = tempfile::tempdir().unwrap();
192        // A "git" that never ends, and leaves a child that would not either.
193        let fake = dir.path().join("slow-git");
194        std::fs::write(&fake, "#!/bin/sh\nsleep 30 &\nsleep 30\n").unwrap();
195        use std::os::unix::fs::PermissionsExt;
196        std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
197        let mut g = git(dir.path(), Duration::from_millis(300));
198        g.bin = fake.to_string_lossy().into_owned();
199        let started = Instant::now();
200        let r = g.run(dir.path(), &["fetch"]);
201        assert_eq!(r, Err(Failure::Timeout(Duration::from_millis(300))));
202        assert!(started.elapsed() < Duration::from_secs(5));
203    }
204
205    #[test]
206    fn what_git_says_comes_back() {
207        let dir = tempfile::tempdir().unwrap();
208        let g = git(dir.path(), DEFAULT_TIMEOUT);
209        let repo = dir.path().join("r.git");
210        let init = Command::new("git")
211            .args(["init", "-q", "--bare"])
212            .arg(&repo)
213            .status()
214            .unwrap();
215        assert!(init.success());
216        assert_eq!(
217            g.run(&repo, &["rev-parse", "--is-bare-repository"])
218                .unwrap(),
219            "true\n"
220        );
221        let Err(Failure::Exit(said)) = g.run(&repo, &["no-such-command"]) else {
222            panic!("an unknown command fails");
223        };
224        assert!(said.contains("no-such-command"), "{said}");
225        assert!(branch_ok(&g, "main") && branch_ok(&g, "feature/x"));
226        assert!(!branch_ok(&g, "a..b") && !branch_ok(&g, "-x") && !branch_ok(&g, "x.lock"));
227    }
228}