Skip to main content

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}