io_harness/tools/git.rs
1//! Governed `git` spawns: a fixed argv, built here, run under the policy.
2//!
3//! This is the foundation the git built-ins sit on. It deliberately does *not*
4//! repeat the weakness of [`ExecGuard`](crate::verify::ExecGuard), which checks
5//! only the program name and formats the rest of the argv into a trace string:
6//! for `rustc` that is survivable, because the argv is the gate's own; for
7//! `git` it is not, because the argv would carry model-supplied text and `git`
8//! has subcommands that fetch code and options that execute it.
9//!
10//! ## Why a caller cannot pass a free-form argument
11//!
12//! There is no `&str`, no `Vec<String>`, and no `impl IntoIterator<Item = ...>`
13//! anywhere on the way in that reaches the argv as an *option*. A caller names a
14//! `GitCmd` variant, and every field of every variant is one of two things:
15//!
16//! * a typed non-string value (`bool`, `u32`) that this module renders into a
17//! flag itself — a `bool` cannot spell `--upload-pack=…`; or
18//! * a `paths` vector, which is model-supplied data and is therefore emitted
19//! *only* after the `--` separator, and only after each element passes
20//! `check_path`.
21//!
22//! Adding a git capability means adding a variant here and getting it reviewed.
23//! That is the whole security property: the set of argvs this module can emit is
24//! finite, enumerable, and enumerated by the tests below.
25//!
26//! No shell is ever involved — no `sh -c`, no joining. Each path is one argv
27//! element, so a path containing spaces, quotes, newlines or shell
28//! metacharacters reaches `git` as that exact path and nothing interprets it.
29
30// The git built-ins that call this land in a follow-up task; until they do, the
31// default lib build sees the module as unused. Remove this once they exist.
32
33use std::path::PathBuf;
34use std::process::Stdio;
35
36use tokio::process::Command;
37
38use super::cap_result;
39use crate::error::{Error, Result};
40use crate::policy::{Act, Effect, Policy};
41
42/// The binary. A constant rather than a parameter so a policy rule has a stable
43/// `Act::Exec` target to name.
44const GIT: &str = "git";
45
46/// The platform's bit bucket, used both as the "global config" file and as the
47/// hooks directory.
48const NULL_DEVICE: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
49
50/// The child's environment, fixed on every spawn:
51///
52/// * `GIT_PAGER`/`PAGER` — a pager would block forever on a pipe that never
53/// becomes a tty, and its output is terminal escapes, not data.
54/// * `LC_ALL`/`LANG` — porcelain and error text must not change with the
55/// operator's locale, or parsing and tests drift per machine.
56/// * `GIT_CONFIG_NOSYSTEM`/`GIT_CONFIG_GLOBAL` — `/etc/gitconfig` and
57/// `~/.gitconfig` can define aliases and `core.*` settings that change what a
58/// subcommand does. Nothing in this crate's permission model covers them.
59const FIXED_ENV: [(&str, &str); 6] = [
60 ("GIT_PAGER", "cat"),
61 ("PAGER", "cat"),
62 ("LC_ALL", "C"),
63 ("LANG", "C"),
64 ("GIT_CONFIG_NOSYSTEM", "1"),
65 ("GIT_CONFIG_GLOBAL", NULL_DEVICE),
66];
67
68/// Repository hooks, disabled by pointing `core.hooksPath` at a path that cannot
69/// be a directory.
70///
71/// This matters more than the rest of the environment put together. `.git/hooks/*`
72/// is arbitrary executable code carried by the repository the agent was pointed
73/// at — cloned, or supplied by whoever opened the pull request — and `git`
74/// runs it on ordinary commands. Nothing in this crate's permission model covers
75/// it: the policy authorises spawning `git`, not spawning whatever a checked-out
76/// tree left in its hooks directory. `-c` is the reliable way to suppress it,
77/// because it wins over every config file, including the repository's own.
78const NO_HOOKS: &str = "core.hooksPath";
79
80/// One git invocation, as a closed set of shapes.
81///
82/// Every variant renders to a *read-only* subcommand. See the module docs for
83/// why the fields are typed the way they are, and
84/// [`no_shape_can_write_or_reach_the_network`] for the test that holds the line.
85//
86// ponytail: three shapes, the ones the first built-ins need. More git
87// capability = one more variant plus its case in `render`, not a new
88// mechanism.
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub(crate) enum GitCmd {
91 /// `git status --porcelain=v1 --untracked-files=normal -- <paths>`
92 Status {
93 /// Model-supplied paths to limit the report to. Data, never options.
94 paths: Vec<String>,
95 },
96 /// `git diff --no-color --no-ext-diff [--cached] -- <paths>`
97 Diff {
98 /// Diff the index rather than the working tree (`--cached`).
99 staged: bool,
100 /// Model-supplied paths to limit the diff to.
101 paths: Vec<String>,
102 },
103 /// `git log --no-color --oneline --no-decorate --max-count=N -- <paths>`
104 Log {
105 /// How many commits at most. A number, so it cannot spell a flag.
106 max_count: u32,
107 /// Model-supplied paths to limit the history to.
108 paths: Vec<String>,
109 },
110 /// `git add -- <paths>`
111 ///
112 /// No `-f`: staging honours `.gitignore`, and the flag that overrides it is
113 /// not reachable from here. An ignored path comes back as git's own message,
114 /// which is what lets the model tell "ignored" from "no such path".
115 Add {
116 /// Model-supplied paths to stage.
117 paths: Vec<String>,
118 },
119 /// `git -c user.name=… -c user.email=… commit --no-verify -m <message>`
120 ///
121 /// Commits what is staged, on the branch that is checked out. No paths: a
122 /// commit is of the index, and `git_add` is what decides the index.
123 Commit {
124 /// The model's commit message. Safe as data because it is the operand of
125 /// `-m`: git takes the next argv element literally, so a message
126 /// beginning with `-` is a message and not an option.
127 message: String,
128 /// Who the commit is attributed to.
129 identity: Identity,
130 },
131}
132
133/// Who a commit is attributed to.
134///
135/// `git commit` fails outright with no `user.email` configured, so this cannot
136/// be left to the machine. Inheriting the repository's identity would attribute
137/// the agent's commit to whichever human configured that checkout, which is the
138/// wrong default; requiring configuration would fail on a fresh machine, which
139/// is the wrong other one. So the harness supplies one and the caller may
140/// replace it.
141///
142/// This is what `git log` shows for every commit a run makes, and it is passed
143/// as `-c user.name=… -c user.email=…` on the commit itself rather than written
144/// into the repository's config, so a run leaves the checkout's own identity
145/// untouched.
146///
147/// ```
148/// use io_harness::{Identity, TaskContract, Verification};
149///
150/// // The default: a name at a domain that can never exist. RFC 2606 reserves
151/// // `.invalid` precisely so a synthetic address cannot accidentally be
152/// // someone's real one.
153/// assert_eq!(Identity::default().email, "agent@io-harness.invalid");
154///
155/// // Replace it when the commits should be attributable to your service, so
156/// // a human reading `git log` a month later can tell which system made
157/// // them and where to ask about it.
158/// let contract = TaskContract::workspace(
159/// "fix the failing test and commit the fix",
160/// "/path/to/repo",
161/// Verification::WorkspaceFileContains { file: "src/lib.rs".into(), needle: "fn".into() },
162/// )
163/// .with_commit_identity("nightly-agent", "nightly-agent@example.com");
164/// # let _ = contract;
165/// ```
166///
167/// Neither field may be empty or hold a control character: both reach the
168/// commit object and the reflog, and a newline in a name has nowhere useful to
169/// go. A bad one is an [`Error::Config`].
170/// `Serialize`/`Deserialize` since 0.19.0, so a team's commit identity can live
171/// in the project's config file rather than in each program that embeds the
172/// crate. Both fields are `#[serde(default)]`, and the validation below still
173/// runs when the identity is used — a deserialized identity is not a checked one.
174///
175/// ```
176/// use io_harness::Identity;
177///
178/// let team: Identity = serde_json::from_str(r#"{"name": "release bot"}"#).unwrap();
179/// assert_eq!(team.name, "release bot");
180/// assert_eq!(team.email, Identity::default().email);
181/// ```
182#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
183#[serde(default, deny_unknown_fields)]
184pub struct Identity {
185 /// The committer name.
186 pub name: String,
187 /// The committer email.
188 pub email: String,
189}
190
191impl Default for Identity {
192 /// An agent identity at a domain that can never exist. `.invalid` is
193 /// reserved by RFC 2606 precisely so that a synthetic address cannot
194 /// accidentally be someone's.
195 fn default() -> Self {
196 Self {
197 name: "io-harness agent".into(),
198 email: "agent@io-harness.invalid".into(),
199 }
200 }
201}
202
203impl Identity {
204 /// Refuse an identity that could split into more than one `-c` setting.
205 ///
206 /// `-c key=value` is a single argv element, so a value cannot introduce a
207 /// second setting — but a newline in a name reaches the commit object and
208 /// the reflog, and there is no reason to carry one.
209 fn check(&self) -> Result<()> {
210 for (what, v) in [("name", &self.name), ("email", &self.email)] {
211 if v.is_empty() || v.chars().any(char::is_control) {
212 return Err(Error::Config(format!(
213 "commit identity {what} must be non-empty and free of control characters"
214 )));
215 }
216 }
217 Ok(())
218 }
219}
220
221impl GitCmd {
222 /// The subcommand and its fixed options — everything this module chose, with
223 /// nothing model-supplied in it.
224 fn options(&self) -> Vec<String> {
225 let mut v: Vec<String> = Vec::new();
226 match self {
227 Self::Status { .. } => {
228 v.push("status".into());
229 v.push("--porcelain=v1".into());
230 v.push("--untracked-files=normal".into());
231 }
232 Self::Diff { staged, .. } => {
233 v.push("diff".into());
234 v.push("--no-color".into());
235 v.push("--no-ext-diff".into());
236 if *staged {
237 v.push("--cached".into());
238 }
239 }
240 Self::Log { max_count, .. } => {
241 v.push("log".into());
242 v.push("--no-color".into());
243 v.push("--oneline".into());
244 v.push("--no-decorate".into());
245 v.push(format!("--max-count={max_count}"));
246 }
247 Self::Add { .. } => v.push("add".into()),
248 Self::Commit { message, .. } => {
249 v.push("commit".into());
250 // Belt and braces with `core.hooksPath`: that stops the hook
251 // being found, this stops it being asked for.
252 v.push("--no-verify".into());
253 v.push("-m".into());
254 v.push(message.clone());
255 }
256 }
257 v
258 }
259
260 /// The model-supplied half.
261 fn paths(&self) -> &[String] {
262 match self {
263 Self::Status { paths }
264 | Self::Diff { paths, .. }
265 | Self::Log { paths, .. }
266 | Self::Add { paths } => paths,
267 // A commit takes the index, not a pathspec.
268 Self::Commit { .. } => &[],
269 }
270 }
271
272 /// The `-c` settings this command needs before its subcommand.
273 fn config(&self) -> Result<Vec<String>> {
274 match self {
275 Self::Commit { identity, .. } => {
276 identity.check()?;
277 Ok(vec![
278 "-c".into(),
279 format!("user.name={}", identity.name),
280 "-c".into(),
281 format!("user.email={}", identity.email),
282 ])
283 }
284 _ => Ok(Vec::new()),
285 }
286 }
287}
288
289/// What a spawn produced.
290#[derive(Debug, Clone, PartialEq, Eq)]
291pub(crate) enum GitOutcome {
292 /// The child ran. `code` is `None` when a signal killed it.
293 Ran {
294 /// The exit status code.
295 code: Option<i32>,
296 /// Standard output, bounded by the run's per-observation cap.
297 stdout: String,
298 /// Standard error, bounded by the same cap.
299 stderr: String,
300 },
301 /// There is no `git` on this machine. A capability the host does not have is
302 /// not a failed run: the caller turns this into an observation and the agent
303 /// carries on without it, exactly as it would with a tool it was never given.
304 Unavailable {
305 /// What was looked for, for the observation text.
306 reason: String,
307 },
308}
309
310impl GitOutcome {
311 /// Whether git ran and reported success.
312 pub(crate) fn ok(&self) -> bool {
313 matches!(self, Self::Ran { code: Some(0), .. })
314 }
315}
316
317/// Spawns `GitCmd`s in one directory under one policy.
318pub(crate) struct Git<'a> {
319 policy: &'a Policy,
320 workdir: PathBuf,
321 program: String,
322 cap: usize,
323}
324
325impl<'a> Git<'a> {
326 /// Spawn in `workdir` under `policy`, bounding captured output at `cap`
327 /// chars — the run's per-observation cap from
328 /// [`entry_cap_chars`](crate::context::entry_cap_chars), so a git command
329 /// obeys the same ceiling as every other tool result rather than one of its
330 /// own.
331 pub(crate) fn new(policy: &'a Policy, workdir: impl Into<PathBuf>, cap: usize) -> Self {
332 Self {
333 policy,
334 workdir: workdir.into(),
335 program: GIT.into(),
336 cap,
337 }
338 }
339
340 /// Use a `git` that is not on `PATH` under that name.
341 ///
342 /// The policy check targets this exact string; `Act::Exec` patterns match by
343 /// full text or by basename, so a rule naming `git` still covers
344 /// `/usr/bin/git`.
345 // Only the tests need this today: production always runs the `git` on PATH,
346 // and a caller-configurable git binary is a capability nobody has asked for.
347 #[cfg(test)]
348 pub(crate) fn program(mut self, program: impl Into<String>) -> Self {
349 self.program = program.into();
350 self
351 }
352
353 /// The complete argv, program at index 0, or a refusal if any path is not
354 /// one.
355 ///
356 /// Order is load-bearing: the `-c` overrides and `--no-pager` must precede
357 /// the subcommand (git only accepts them there), and the `--` separator must
358 /// precede every model-supplied byte.
359 pub(crate) fn argv(&self, cmd: &GitCmd) -> Result<Vec<String>> {
360 let mut argv = vec![
361 self.program.clone(),
362 "--no-pager".into(),
363 "-c".into(),
364 format!("{NO_HOOKS}={NULL_DEVICE}"),
365 ];
366 argv.extend(cmd.config()?);
367 argv.extend(cmd.options());
368 argv.push("--".into());
369 for p in cmd.paths() {
370 check_path(p)?;
371 argv.push(p.clone());
372 }
373 Ok(argv)
374 }
375
376 /// Check the policy and run `cmd`, capturing a bounded result.
377 pub(crate) async fn run(&self, cmd: &GitCmd) -> Result<GitOutcome> {
378 // Build first: a refusable argument is refused before the policy is even
379 // consulted, so a permissive policy is not a way to smuggle one through.
380 let argv = self.argv(cmd)?;
381 let verdict = self.policy.check(Act::Exec, &self.program);
382 if verdict.effect != Effect::Allow {
383 return Err(Error::Refused {
384 act: "exec".into(),
385 target: self.program.clone(),
386 rule: verdict.rule,
387 layer: verdict.layer,
388 });
389 }
390 match self.command(&argv).output().await {
391 Ok(out) => Ok(GitOutcome::Ran {
392 code: out.status.code(),
393 stdout: cap_result(String::from_utf8_lossy(&out.stdout).into_owned(), self.cap).0,
394 stderr: cap_result(String::from_utf8_lossy(&out.stderr).into_owned(), self.cap).0,
395 }),
396 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GitOutcome::Unavailable {
397 reason: format!("no `{}` on PATH", self.program),
398 }),
399 Err(e) => Err(Error::Io(e)),
400 }
401 }
402
403 /// The configured child. Separate from [`Git::run`] so the environment it
404 /// pins is testable without a `git` on the machine.
405 fn command(&self, argv: &[String]) -> Command {
406 let mut c = Command::new(&argv[0]);
407 c.args(&argv[1..])
408 .current_dir(&self.workdir)
409 // No inherited stdin: git must never be able to sit waiting on a
410 // terminal that will never answer.
411 .stdin(Stdio::null())
412 .stdout(Stdio::piped())
413 .stderr(Stdio::piped());
414 for (k, v) in FIXED_ENV {
415 c.env(k, v);
416 }
417 c
418 }
419}
420
421/// Accept one model-supplied path, or refuse it.
422///
423/// A leading `-` is refused outright rather than escaped or quoted. There is no
424/// escaping that helps: `git` parses its own argv, so the only two states are
425/// "this is an option" and "this is not present". The `--` separator already
426/// covers the well-behaved case; this covers the case where a future variant
427/// forgets it, and it is cheap. A path genuinely named `-foo` is still reachable
428/// as `./-foo`.
429fn check_path(p: &str) -> Result<()> {
430 let bad = if p.is_empty() {
431 Some("<empty path>")
432 } else if p.starts_with('-') {
433 Some("<path may not begin with `-`>")
434 } else {
435 None
436 };
437 match bad {
438 None => Ok(()),
439 Some(rule) => Err(Error::Refused {
440 act: "exec".into(),
441 target: p.to_string(),
442 rule: Some(rule.into()),
443 layer: None,
444 }),
445 }
446}
447
448#[cfg(test)]
449mod tests {
450 use super::*;
451
452 /// A generous cap; the bound itself is [`cap_result`]'s and tested there.
453 const CAP: usize = 100_000;
454
455 fn git<'a>(policy: &'a Policy, dir: &std::path::Path) -> Git<'a> {
456 Git::new(policy, dir, CAP)
457 }
458
459 /// Every shape, with one benign path each, for the sweeps below.
460 fn every_shape() -> Vec<GitCmd> {
461 vec![
462 GitCmd::Status {
463 paths: vec!["src".into()],
464 },
465 GitCmd::Diff {
466 staged: false,
467 paths: vec!["src".into()],
468 },
469 GitCmd::Diff {
470 staged: true,
471 paths: vec![],
472 },
473 GitCmd::Log {
474 max_count: 20,
475 paths: vec!["src/main.rs".into()],
476 },
477 GitCmd::Add {
478 paths: vec!["src/main.rs".into()],
479 },
480 GitCmd::Commit {
481 message: "a message".into(),
482 identity: Identity::default(),
483 },
484 ]
485 }
486
487 /// Fails to compile if a variant is added without adding it to
488 /// [`every_shape`]. Without this the surface tests below would silently stop
489 /// covering the new capability while continuing to pass, which is the exact
490 /// way a closed surface quietly opens.
491 #[test]
492 fn every_shape_covers_every_variant() {
493 for cmd in every_shape() {
494 match cmd {
495 GitCmd::Status { .. }
496 | GitCmd::Diff { .. }
497 | GitCmd::Log { .. }
498 | GitCmd::Add { .. }
499 | GitCmd::Commit { .. } => {}
500 }
501 }
502 let mut kinds: Vec<_> = every_shape().iter().map(std::mem::discriminant).collect();
503 let before = kinds.len();
504 kinds.dedup_by(|a, b| a == b);
505 assert_eq!(before, 6, "every_shape lists six commands");
506 assert_eq!(
507 kinds.len(),
508 5,
509 "every_shape must contain one of each variant; add the new one"
510 );
511 }
512
513 /// The subcommand is the invariant, and it is checked directly rather than
514 /// by scanning the whole argv — the whole argv contains model-supplied data,
515 /// and a path named `push` is a path.
516 #[test]
517 fn the_subcommand_is_always_one_of_the_five_this_crate_ships() {
518 let p = Policy::permissive();
519 let dir = tempfile::tempdir().unwrap();
520 let g = git(&p, dir.path());
521 for cmd in every_shape() {
522 let argv = g.argv(&cmd).unwrap();
523 // The fixed prefix is program, --no-pager, -c, hooksPath, then any
524 // -c config pairs, then the subcommand.
525 let sub = argv
526 .iter()
527 .skip(1)
528 .find(|a| !a.starts_with('-') && !a.contains('='))
529 .expect("every argv has a subcommand");
530 assert!(
531 ["status", "diff", "log", "add", "commit"].contains(&sub.as_str()),
532 "{cmd:?} produced subcommand {sub:?}"
533 );
534 }
535 }
536
537 #[test]
538 fn a_path_beginning_with_a_dash_is_refused_and_the_same_path_without_it_is_not() {
539 let p = Policy::permissive();
540 let dir = tempfile::tempdir().unwrap();
541 let g = git(&p, dir.path());
542
543 let refused = g.argv(&GitCmd::Status {
544 paths: vec!["--exec=rm -rf /".into()],
545 });
546 assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
547
548 // Negative control: the identical path with the leading `-` removed.
549 let argv = g
550 .argv(&GitCmd::Status {
551 paths: vec!["exec=rm -rf /".into()],
552 })
553 .unwrap();
554 assert_eq!(argv.last().unwrap(), "exec=rm -rf /");
555 }
556
557 #[test]
558 fn a_path_with_a_space_a_quote_and_a_newline_survives_as_one_argv_element() {
559 let p = Policy::permissive();
560 let dir = tempfile::tempdir().unwrap();
561 let nasty = "a dir/it's \"quoted\"\nand $(whoami) `id` ;rm -rf *";
562
563 let argv = git(&p, dir.path())
564 .argv(&GitCmd::Diff {
565 staged: false,
566 paths: vec![nasty.into()],
567 })
568 .unwrap();
569
570 // One element, byte-identical, and the last one — nothing split it.
571 assert_eq!(argv.iter().filter(|a| a.contains("whoami")).count(), 1);
572 assert_eq!(argv.last().unwrap(), nasty);
573 }
574
575 #[test]
576 fn a_path_named_like_a_flag_is_refused_and_its_relative_form_stays_data() {
577 let p = Policy::permissive();
578 let dir = tempfile::tempdir().unwrap();
579 let g = git(&p, dir.path());
580
581 // The classic git argument injection. Refused, not escaped.
582 let refused = g.argv(&GitCmd::Log {
583 max_count: 1,
584 paths: vec!["--upload-pack=x".into()],
585 });
586 assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
587
588 // Negative control: a file genuinely called that, named the way a shell
589 // user names it. It is a path, it lands after `--`, and it is one element.
590 let argv = g
591 .argv(&GitCmd::Log {
592 max_count: 1,
593 paths: vec!["./--upload-pack=x".into()],
594 })
595 .unwrap();
596 let sep = argv.iter().position(|a| a == "--").unwrap();
597 assert_eq!(argv[sep + 1], "./--upload-pack=x");
598 assert_eq!(argv.len(), sep + 2);
599 }
600
601 #[test]
602 fn every_model_supplied_path_lands_after_the_separator() {
603 let p = Policy::permissive();
604 let dir = tempfile::tempdir().unwrap();
605 let g = git(&p, dir.path());
606 for cmd in every_shape() {
607 let argv = g.argv(&cmd).unwrap();
608 let sep = argv.iter().position(|a| a == "--").unwrap();
609 assert_eq!(&argv[sep + 1..], cmd.paths(), "{cmd:?}");
610 }
611 }
612
613 #[test]
614 fn no_shape_can_write_or_reach_the_network() {
615 let p = Policy::permissive();
616 let dir = tempfile::tempdir().unwrap();
617 let g = git(&p, dir.path());
618 const FORBIDDEN: &[&str] = &[
619 "push",
620 "fetch",
621 "clone",
622 "remote",
623 "reset",
624 "checkout",
625 "rebase",
626 "stash",
627 "filter-branch",
628 "--force",
629 ];
630 for cmd in every_shape() {
631 let argv = g.argv(&cmd).unwrap();
632 for word in FORBIDDEN {
633 assert!(
634 !argv.iter().any(|a| a.contains(word)),
635 "{cmd:?} produced {argv:?} containing {word}"
636 );
637 }
638 }
639 }
640
641 #[test]
642 fn the_child_pins_the_pager_the_locale_the_config_and_the_hooks() {
643 let p = Policy::permissive();
644 let dir = tempfile::tempdir().unwrap();
645 let g = git(&p, dir.path());
646 let argv = g.argv(&GitCmd::Status { paths: vec![] }).unwrap();
647
648 // Hooks are suppressed in the argv, because `-c` is the only place that
649 // beats the repository's own config.
650 assert_eq!(argv[1], "--no-pager");
651 assert_eq!(argv[2], "-c");
652 assert_eq!(argv[3], format!("core.hooksPath={NULL_DEVICE}"));
653
654 let cmd = g.command(&argv);
655 let env: Vec<(String, Option<String>)> = cmd
656 .as_std()
657 .get_envs()
658 .map(|(k, v)| {
659 (
660 k.to_string_lossy().into_owned(),
661 v.map(|v| v.to_string_lossy().into_owned()),
662 )
663 })
664 .collect();
665 for (k, v) in FIXED_ENV {
666 assert!(
667 env.contains(&(k.to_string(), Some(v.to_string()))),
668 "{k}={v} missing from {env:?}"
669 );
670 }
671 assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
672 }
673
674 #[tokio::test]
675 async fn a_missing_git_binary_is_an_unavailable_outcome_not_a_run_failure() {
676 let p = Policy::permissive();
677 let dir = tempfile::tempdir().unwrap();
678 let out = git(&p, dir.path())
679 .program("io-harness-no-such-git")
680 .run(&GitCmd::Status { paths: vec![] })
681 .await
682 .unwrap();
683 assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
684 assert!(!out.ok());
685 }
686
687 #[tokio::test]
688 async fn an_exec_deny_refuses_the_spawn_and_a_permissive_policy_does_not() {
689 let dir = tempfile::tempdir().unwrap();
690 let cmd = GitCmd::Status { paths: vec![] };
691
692 let denied = Policy::default()
693 .layer("l")
694 .deny_exec("io-harness-fake-git");
695 let err = git(&denied, dir.path())
696 .program("io-harness-fake-git")
697 .run(&cmd)
698 .await
699 .unwrap_err();
700 assert!(
701 matches!(&err, Error::Refused { act, target, .. }
702 if act == "exec" && target == "io-harness-fake-git"),
703 "{err:?}"
704 );
705
706 // Negative control: the same spawn under a policy that allows exec gets
707 // past the gate — it reaches the spawn and reports the binary missing.
708 let allowed = Policy::permissive();
709 let out = git(&allowed, dir.path())
710 .program("io-harness-fake-git")
711 .run(&cmd)
712 .await
713 .unwrap();
714 assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
715 }
716
717 /// End to end, when the machine has a git. Deliberately outside a repository:
718 /// it proves capture and exit status without needing one, and needs no
719 /// network. A machine without git skips, and the test above covers that path.
720 #[tokio::test]
721 async fn a_real_git_reports_its_own_failure_rather_than_erroring_the_run() {
722 let p = Policy::permissive();
723 let dir = tempfile::tempdir().unwrap();
724 let out = git(&p, dir.path())
725 .run(&GitCmd::Status { paths: vec![] })
726 .await
727 .unwrap();
728 let GitOutcome::Ran { code, stderr, .. } = out else {
729 return; // no git on this machine
730 };
731 // 128 outside a repository; 0 on the rare box whose TMPDIR sits inside
732 // one. Either way git ran, and its failure came back as a result.
733 match code {
734 Some(128) => assert!(stderr.contains("not a git repository"), "{stderr}"),
735 other => assert_eq!(other, Some(0), "{stderr}"),
736 }
737 }
738}