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 #[must_use]
64 pub fn new() -> Self {
65 let mut cmd = Command::new("git");
66 cmd.args(HARDENING)
67 .env("GIT_TERMINAL_PROMPT", "0")
68 .env("GIT_AUTHOR_NAME", AUTHOR_NAME)
69 .env("GIT_AUTHOR_EMAIL", AUTHOR_EMAIL)
70 .env("GIT_COMMITTER_NAME", AUTHOR_NAME)
71 .env("GIT_COMMITTER_EMAIL", AUTHOR_EMAIL);
72 Self {
73 cmd,
74 display: Vec::new(),
75 cwd: None,
76 stdin: None,
77 }
78 }
79
80 /// Run in `dir`.
81 #[must_use]
82 pub fn cwd(mut self, dir: &Path) -> Self {
83 self.cmd.current_dir(dir);
84 self.cwd = Some(dir.to_path_buf());
85 self
86 }
87
88 /// Append one argument.
89 pub fn arg<S: AsRef<OsStr>>(mut self, arg: S) -> Self {
90 let arg = arg.as_ref();
91 self.display.push(arg.to_string_lossy().into_owned());
92 self.cmd.arg(arg);
93 self
94 }
95
96 /// Append several arguments.
97 pub fn args<I, S>(mut self, args: I) -> Self
98 where
99 I: IntoIterator<Item = S>,
100 S: AsRef<OsStr>,
101 {
102 for arg in args {
103 self = self.arg(arg);
104 }
105 self
106 }
107
108 /// Feed `data` to the command's stdin. Lets `git apply` take a patch
109 /// without staging it through a temp file whose lifetime we'd have to
110 /// manage (and whose contents would briefly sit on disk unredacted).
111 #[must_use]
112 pub fn stdin_bytes(mut self, data: Vec<u8>) -> Self {
113 self.stdin = Some(data);
114 self
115 }
116
117 /// Run and require success, discarding output.
118 ///
119 /// # Errors
120 ///
121 /// `git` not being spawnable (not installed, not on `PATH`), a failure
122 /// while feeding stdin or collecting output, and a nonzero exit — the
123 /// message carries the command line and git's trimmed stderr. Use
124 /// [`Self::success`] where a nonzero exit is an answer rather than a
125 /// failure.
126 pub fn run(self) -> Result<()> {
127 let display = self.display.join(" ");
128 let (ok, _, stderr) = self.capture()?;
129 anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
130 Ok(())
131 }
132
133 /// Run and report whether it exited zero, discarding output. For the
134 /// predicate forms (`diff --quiet`, `rev-parse`) where a nonzero exit is
135 /// an answer rather than a failure.
136 ///
137 /// # Errors
138 ///
139 /// Only failures to *run* git: not spawnable, or stdin/output handling
140 /// failed. The exit status never produces an `Err` here — that is the
141 /// `bool`.
142 pub fn success(self) -> Result<bool> {
143 let (ok, _, _) = self.capture()?;
144 Ok(ok)
145 }
146
147 /// Run and return trimmed stdout, requiring success.
148 ///
149 /// # Errors
150 ///
151 /// Exactly [`Self::output_bytes`]'s. Invalid UTF-8 is not among them: it
152 /// is replaced lossily, so use `output_bytes` when the exact bytes matter.
153 pub fn output(self) -> Result<String> {
154 let raw = self.output_bytes()?;
155 Ok(String::from_utf8_lossy(&raw).trim().to_string())
156 }
157
158 /// Run and return raw stdout, requiring success. Use for `diff --binary`
159 /// and anything else that need not be valid UTF-8.
160 ///
161 /// # Errors
162 ///
163 /// `git` not being spawnable, a failure while feeding stdin or collecting
164 /// output, and a nonzero exit — the message carries the command line and
165 /// git's trimmed stderr. Stdout written before a nonzero exit is
166 /// discarded.
167 pub fn output_bytes(self) -> Result<Vec<u8>> {
168 let display = self.display.join(" ");
169 let (ok, stdout, stderr) = self.capture()?;
170 anyhow::ensure!(ok, "git {display} failed: {}", stderr.trim());
171 Ok(stdout)
172 }
173
174 /// Spawn, feed stdin when set, and collect `(success, stdout, stderr)`.
175 ///
176 /// stdin is written from this thread while the child runs. That is safe
177 /// only because every caller also drains stdout and stderr via
178 /// `wait_with_output` afterwards: a child that filled its stdout pipe
179 /// while we were still writing its stdin would otherwise deadlock, both
180 /// sides blocked on a full pipe.
181 fn capture(mut self) -> Result<(bool, Vec<u8>, String)> {
182 self.cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
183 self.cmd.stdin(if self.stdin.is_some() {
184 Stdio::piped()
185 } else {
186 Stdio::null()
187 });
188 let display = self.display.join(" ");
189 let where_ = match &self.cwd {
190 Some(dir) => format!(" in {}", dir.display()),
191 None => String::new(),
192 };
193 let mut child = self.cmd.spawn().with_context(|| {
194 format!(
195 "failed to run git {display}{where_} (missing directory, or git not installed?)"
196 )
197 })?;
198 if let Some(data) = self.stdin.take() {
199 let mut pipe = child
200 .stdin
201 .take()
202 .context("git stdin pipe missing after spawn")?;
203 // A `git apply` that rejects the patch early exits before reading
204 // all of it, breaking the pipe. That is a patch failure, reported
205 // through the exit status below — not an error in its own right.
206 let _ = pipe.write_all(&data);
207 drop(pipe);
208 }
209 let out = child
210 .wait_with_output()
211 .with_context(|| format!("git {display} was not reapable"))?;
212 Ok((
213 out.status.success(),
214 out.stdout,
215 String::from_utf8_lossy(&out.stderr).into_owned(),
216 ))
217 }
218}
219
220impl Default for GitCommand {
221 fn default() -> Self {
222 Self::new()
223 }
224}
225
226/// Start a hardened `git` invocation in `dir`. The common shape.
227#[must_use]
228pub fn git(dir: &Path) -> GitCommand {
229 GitCommand::new().cwd(dir)
230}
231
232/// Whether `dir` sits inside a git work tree. False when git is missing
233/// entirely, which is the same practical answer for every caller here.
234#[must_use]
235pub fn is_work_tree(dir: &Path) -> bool {
236 git(dir)
237 .args(["rev-parse", "--is-inside-work-tree"])
238 .output()
239 .is_ok_and(|out| out == "true")
240}
241
242#[cfg(test)]
243mod tests {
244 use super::*;
245 use std::path::PathBuf;
246
247 /// A throwaway directory unique to this test run + `tag` (tests share a PID).
248 fn unique_dir(tag: &str) -> PathBuf {
249 let dir = std::env::temp_dir().join(format!("mermaid_git_{tag}_{}", std::process::id()));
250 let _ = std::fs::remove_dir_all(&dir);
251 std::fs::create_dir_all(&dir).unwrap();
252 dir
253 }
254
255 /// File content with line endings normalized. A repo on a machine with
256 /// `core.autocrlf=true` checks out CRLF, which is correct and beside the
257 /// point of every assertion here.
258 fn read(path: &Path) -> String {
259 std::fs::read_to_string(path).unwrap().replace("\r\n", "\n")
260 }
261
262 /// A repo with one commit. `false` when git is absent — every test here
263 /// then no-ops rather than failing a machine that has no git at all.
264 fn init_repo(dir: &Path) -> bool {
265 if git(dir).args(["init", "-q"]).run().is_err() {
266 return false;
267 }
268 std::fs::write(dir.join("seed.txt"), "seed\n").unwrap();
269 git(dir).args(["add", "-A"]).run().unwrap();
270 git(dir).args(["commit", "-qm", "seed"]).run().unwrap();
271 true
272 }
273
274 #[test]
275 fn commits_without_a_configured_user_identity() {
276 let repo = unique_dir("identity");
277 if !init_repo(&repo) {
278 return;
279 }
280 // The point of the forced identity: the commit above succeeds on a
281 // machine where `git config user.email` is unset, as CI images are.
282 let author = git(&repo)
283 .args(["log", "-1", "--format=%an <%ae>"])
284 .output()
285 .unwrap();
286 assert_eq!(author, format!("{AUTHOR_NAME} <{AUTHOR_EMAIL}>"));
287 }
288
289 #[test]
290 fn success_reports_predicate_exits_without_erroring() {
291 let repo = unique_dir("predicate");
292 if !init_repo(&repo) {
293 return;
294 }
295 // Clean tree: `diff --quiet` exits 0.
296 assert!(git(&repo).args(["diff", "--quiet"]).success().unwrap());
297 std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
298 // Dirty tree: exits 1. `success` reports it; `run` would have errored.
299 assert!(!git(&repo).args(["diff", "--quiet"]).success().unwrap());
300 }
301
302 #[test]
303 fn stdin_feeds_a_patch_to_git_apply() {
304 let repo = unique_dir("stdin");
305 if !init_repo(&repo) {
306 return;
307 }
308 std::fs::write(repo.join("seed.txt"), "changed\n").unwrap();
309 let patch = git(&repo)
310 .args(["diff", "--binary"])
311 .output_bytes()
312 .unwrap();
313 assert!(!patch.is_empty());
314
315 // Revert, then replay the captured patch through stdin.
316 git(&repo)
317 .args(["checkout", "--", "seed.txt"])
318 .run()
319 .unwrap();
320 assert_eq!(read(&repo.join("seed.txt")), "seed\n");
321 git(&repo).args(["apply"]).stdin_bytes(patch).run().unwrap();
322 assert_eq!(read(&repo.join("seed.txt")), "changed\n");
323 }
324
325 #[test]
326 fn failure_surfaces_the_failing_command_in_the_error() {
327 let repo = unique_dir("failure");
328 if !init_repo(&repo) {
329 return;
330 }
331 let err = git(&repo)
332 .args(["rev-parse", "--verify", "definitely-not-a-ref"])
333 .output()
334 .unwrap_err()
335 .to_string();
336 assert!(err.contains("rev-parse"), "{err}");
337 }
338
339 #[test]
340 fn is_work_tree_distinguishes_a_repo_from_a_plain_directory() {
341 let repo = unique_dir("worktree_yes");
342 if !init_repo(&repo) {
343 return;
344 }
345 assert!(is_work_tree(&repo));
346 // A plain directory under the system temp dir is not in a work tree.
347 // (If temp itself were inside a repo this would be wrong, but no
348 // platform we support puts it there.)
349 assert!(!is_work_tree(&unique_dir("worktree_no")));
350 }
351}