mermaid_runtime/git.rs
1//! Hardened `git` invocation.
2//!
3//! Every `git` call Mermaid makes on a user's behalf runs through this
4//! builder, so the hardening is uniform instead of re-derived per call site:
5//!
6//! - **No repo-provided hooks** (`core.hooksPath` pointed at a nonexistent
7//! path). A checkpoint, a worktree, or a plugin fetch must never execute
8//! code the repo happens to carry. A missing hooks dir means git runs no
9//! hooks — including on Windows git, where `/dev/null` is not a device but
10//! is still an absent path.
11//! - **No external transports** (`protocol.ext.allow=never`). `ext::` URLs
12//! hand git a shell command to run; a submodule or remote carrying one is
13//! remote code execution.
14//! - **No credential prompts** (`GIT_TERMINAL_PROMPT=0`). A fetch against a
15//! private remote fails fast instead of blocking a background task on a
16//! terminal read nobody is watching.
17//! - **A fixed committer identity**, so a commit works on a machine with no
18//! `user.email` configured and never attributes Mermaid's bookkeeping to
19//! the user.
20//!
21//! Callers pick how much output they need: [`GitCommand::run`] discards it,
22//! [`GitCommand::success`] reports the exit status as a bool (for the
23//! `--quiet` predicates), [`GitCommand::output`] returns trimmed stdout, and
24//! [`GitCommand::output_bytes`] returns it raw — `git diff --binary` emits
25//! base85 payloads and diff context lifted verbatim out of files that need
26//! not be UTF-8.
27
28use std::ffi::OsStr;
29use std::io::Write;
30use std::path::Path;
31use std::process::{Command, Stdio};
32
33use anyhow::{Context, Result};
34
35/// Config flags forced on every invocation. See the module docs.
36const HARDENING: [&str; 4] = [
37 "-c",
38 "core.hooksPath=/dev/null",
39 "-c",
40 "protocol.ext.allow=never",
41];
42
43/// Identity used for commits Mermaid makes on its own behalf (checkpoint
44/// snapshots, subagent worktree bases). Never the user's.
45const AUTHOR_NAME: &str = "Mermaid";
46const AUTHOR_EMAIL: &str = "mermaid@localhost";
47
48/// A `git` invocation with Mermaid's hardening already applied.
49pub struct GitCommand {
50 cmd: Command,
51 /// Echoed into error messages — `Command` won't give the args back.
52 display: Vec<String>,
53 /// Likewise. A spawn failure is most often a missing working directory
54 /// rather than a missing git, and naming the directory is the difference
55 /// between a legible error and a wrong guess.
56 cwd: Option<std::path::PathBuf>,
57 stdin: Option<Vec<u8>>,
58}
59
60impl GitCommand {
61 /// Start a hardened `git` invocation. Add a working directory with
62 /// [`Self::cwd`]; without one the command inherits the process's.
63 pub fn new() -> Self {
64 let mut cmd = Command::new("git");
65 cmd.args(HARDENING)
66 .env("GIT_TERMINAL_PROMPT", "0")
67 .env("GIT_AUTHOR_NAME", AUTHOR_NAME)
68 .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
69 .env("GIT_COMMITTER_NAME", AUTHOR_NAME)
70 .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL);
71 Self {
72 cmd,
73 display: Vec::new(),
74 cwd: None,
75 stdin: None,
76 }
77 }
78
79 /// Run in `dir`.
80 pub fn cwd(mut self, dir: &Path) -> Self {
81 self.cmd.current_dir(dir);
82 self.cwd = Some(dir.to_path_buf());
83 self
84 }
85
86 /// Append one argument.
87 pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
88 let arg = arg.as_ref();
89 self.display.push(arg.to_string_lossy().into_owned());
90 self.cmd.arg(arg);
91 self
92 }
93
94 /// Append several arguments.
95 pub fn args<I, S>(mut self, args: I) -> Self
96 where
97 I: IntoIterator<Item = S>,
98 S: AsRef<OsStr>,
99 {
100 for arg in args {
101 self = self.arg(arg);
102 }
103 self
104 }
105
106 /// Feed `data` to the command's stdin. Lets `git apply` take a patch
107 /// without staging it through a temp file whose lifetime we'd have to
108 /// manage (and whose contents would briefly sit on disk unredacted).
109 pub fn stdin_bytes(mut self, data: Vec<u8>) -> Self {
110 self.stdin = Some(data);
111 self
112 }
113
114 /// Run and require success, discarding output.
115 pub fn run(self) -> Result<()> {
116 let display = self.display.join(" ");
117 let (ok, _, stderr) = self.capture()?;
118 anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
119 Ok(())
120 }
121
122 /// Run and report whether it exited zero, discarding output. For the
123 /// predicate forms (`diff --quiet`, `rev-parse`) where a nonzero exit is
124 /// an answer rather than a failure.
125 pub fn success(self) -> Result<bool> {
126 let (ok, _, _) = self.capture()?;
127 Ok(ok)
128 }
129
130 /// Run and return trimmed stdout, requiring success.
131 pub fn output(self) -> Result<String> {
132 let raw = self.output_bytes()?;
133 Ok(String::from_utf8_lossy(&raw).trim().to_string())
134 }
135
136 /// Run and return raw stdout, requiring success. Use for `diff --binary`
137 /// and anything else that need not be valid UTF-8.
138 pub fn output_bytes(self) -> Result<Vec<u8>> {
139 let display = self.display.join(" ");
140 let (ok, stdout, stderr) = self.capture()?;
141 anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
142 Ok(stdout)
143 }
144
145 /// Spawn, feed stdin when set, and collect `(success, stdout, stderr)`.
146 ///
147 /// stdin is written from this thread while the child runs. That is safe
148 /// only because every caller also drains stdout and stderr via
149 /// `wait_with_output` afterwards: a child that filled its stdout pipe
150 /// while we were still writing its stdin would otherwise deadlock, both
151 /// sides blocked on a full pipe.
152 fn capture(mut self) -> Result<(bool, Vec<u8>, String)> {
153 self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
154 self.cmd.stdin(if self.stdin.is_some() {
155 Stdio::piped()
156 } else {
157 Stdio::null()
158 });
159 let display = self.display.join(" ");
160 let where_ = match &self.cwd {
161 Some(dir) => format!(" in {}", dir.display()),
162 None => String::new(),
163 };
164 let mut child = self.cmd.spawn().with_context(|| {
165 format!(
166 "failed to run git {display}{where_} (missing directory, or git not installed?)"
167 )
168 })?;
169 if let Some(data) = self.stdin.take() {
170 let mut pipe = child
171 .stdin
172 .take()
173 .context("git stdin pipe missing after spawn")?;
174 // A `git apply` that rejects the patch early exits before reading
175 // all of it, breaking the pipe. That is a patch failure, reported
176 // through the exit status below — not an error in its own right.
177 let _ = pipe.write_all(&data);
178 drop(pipe);
179 }
180 let out = child
181 .wait_with_output()
182 .with_context(|| format!("git {display} was not reapable"))?;
183 Ok((
184 out.status.success(),
185 out.stdout,
186 String::from_utf8_lossy(&out.stderr).into_owned(),
187 ))
188 }
189}
190
191impl Default for GitCommand {
192 fn default() -> Self {
193 Self::new()
194 }
195}
196
197/// Start a hardened `git` invocation in `dir`. The common shape.
198pub fn git(dir: &Path) -> GitCommand {
199 GitCommand::new().cwd(dir)
200}
201
202/// Whether `dir` sits inside a git work tree. False when git is missing
203/// entirely, which is the same practical answer for every caller here.
204pub fn is_work_tree(dir: &Path) -> bool {
205 git(dir)
206 .args(["rev-parse", "--is-inside-work-tree"])
207 .output()
208 .is_ok_and(|out| out == "true")
209}
210
211#[cfg(test)]
212mod tests {
213 use super::*;
214 use std::path::PathBuf;
215
216 /// A throwaway directory unique to this test run + `tag` (tests share a PID).
217 fn unique_dir(tag: &str) -> PathBuf {
218 let dir = std::env::temp_dir().join(format!("mermaid_git_{tag}_{}", std::process::id()));
219 let _ = std::fs::remove_dir_all(&dir);
220 std::fs::create_dir_all(&dir).unwrap();
221 dir
222 }
223
224 /// File content with line endings normalized. A repo on a machine with
225 /// `core.autocrlf=true` checks out CRLF, which is correct and beside the
226 /// point of every assertion here.
227 fn read(path: &Path) -> String {
228 std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
229 }
230
231 /// A repo with one commit. `false` when git is absent — every test here
232 /// then no-ops rather than failing a machine that has no git at all.
233 fn init_repo(dir: &Path) -> bool {
234 if git(dir).args(["init", "-q"]).run().is_err() {
235 return false;
236 }
237 std::fs::write(dir.join("seed.txt"), "seed\n").unwrap();
238 git(dir).args(["add", "-A"]).run().unwrap();
239 git(dir).args(["commit", "-qm", "seed"]).run().unwrap();
240 true
241 }
242
243 #[test]
244 fn commits_without_a_configured_user_identity() {
245 let repo = unique_dir("identity");
246 if !init_repo(&repo) {
247 return;
248 }
249 // The point of the forced identity: the commit above succeeds on a
250 // machine where `git config user.email` is unset, as CI images are.
251 let author = git(&repo)
252 .args(["log", "-1", "--format=%an <%ae>"])
253 .output()
254 .unwrap();
255 assert_eq!(author, format!("{AUTHOR_NAME} <{AUTHOR_EMAIL}>"));
256 }
257
258 #[test]
259 fn success_reports_predicate_exits_without_erroring() {
260 let repo = unique_dir("predicate");
261 if !init_repo(&repo) {
262 return;
263 }
264 // Clean tree: `diff --quiet` exits 0.
265 assert!(git(&repo).args(["diff", "--quiet"]).success().unwrap());
266 std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
267 // Dirty tree: exits 1. `success` reports it; `run` would have errored.
268 assert!(!git(&repo).args(["diff", "--quiet"]).success().unwrap());
269 }
270
271 #[test]
272 fn stdin_feeds_a_patch_to_git_apply() {
273 let repo = unique_dir("stdin");
274 if !init_repo(&repo) {
275 return;
276 }
277 std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
278 let patch = git(&repo)
279 .args(["diff", "--binary"])
280 .output_bytes()
281 .unwrap();
282 assert!(!patch.is_empty());
283
284 // Revert, then replay the captured patch through stdin.
285 git(&repo)
286 .args(["checkout", "--", "seed.txt"])
287 .run()
288 .unwrap();
289 assert_eq!(read(&repo.join("seed.txt")), "seed\n");
290 git(&repo).args(["apply"]).stdin_bytes(patch).run().unwrap();
291 assert_eq!(read(&repo.join("seed.txt")), "changed\n");
292 }
293
294 #[test]
295 fn failure_surfaces_the_failing_command_in_the_error() {
296 let repo = unique_dir("failure");
297 if !init_repo(&repo) {
298 return;
299 }
300 let err = git(&repo)
301 .args(["rev-parse", "--verify", "definitely-not-a-ref"])
302 .output()
303 .unwrap_err()
304 .to_string();
305 assert!(err.contains("rev-parse"), "{err}");
306 }
307
308 #[test]
309 fn is_work_tree_distinguishes_a_repo_from_a_plain_directory() {
310 let repo = unique_dir("worktree_yes");
311 if !init_repo(&repo) {
312 return;
313 }
314 assert!(is_work_tree(&repo));
315 // A plain directory under the system temp dir is not in a work tree.
316 // (If temp itself were inside a repo this would be wrong, but no
317 // platform we support puts it there.)
318 assert!(!is_work_tree(&unique_dir("worktree_no")));
319 }
320}