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    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}