1use 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
21pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(600);
23
24const PROTOCOLS: &str = "file:https:ssh";
26
27const PASSED: &[&str] = &["PATH", "SSH_AUTH_SOCK", "LANG", "LC_ALL", "TMPDIR"];
31
32#[derive(Clone, Debug)]
33pub struct Git {
34 pub bin: String,
36 pub home: PathBuf,
38 pub timeout: Duration,
39}
40
41#[derive(Debug, PartialEq, Eq)]
43pub enum Failure {
44 Start(String),
46 Timeout(Duration),
48 Exit(String),
50}
51
52impl Git {
53 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 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 .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
129pub 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 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}