use std::path::PathBuf;
use std::process::Stdio;
use tokio::process::Command;
use super::cap_result;
use crate::error::{Error, Result};
use crate::policy::{Act, Effect, Policy};
const GIT: &str = "git";
const NULL_DEVICE: &str = if cfg!(windows) { "NUL" } else { "/dev/null" };
const FIXED_ENV: [(&str, &str); 6] = [
("GIT_PAGER", "cat"),
("PAGER", "cat"),
("LC_ALL", "C"),
("LANG", "C"),
("GIT_CONFIG_NOSYSTEM", "1"),
("GIT_CONFIG_GLOBAL", NULL_DEVICE),
];
const NO_HOOKS: &str = "core.hooksPath";
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GitCmd {
Status {
paths: Vec<String>,
},
Diff {
staged: bool,
paths: Vec<String>,
},
Log {
max_count: u32,
paths: Vec<String>,
},
Add {
paths: Vec<String>,
},
Commit {
message: String,
identity: Identity,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Identity {
pub name: String,
pub email: String,
}
impl Default for Identity {
fn default() -> Self {
Self {
name: "io-harness agent".into(),
email: "agent@io-harness.invalid".into(),
}
}
}
impl Identity {
fn check(&self) -> Result<()> {
for (what, v) in [("name", &self.name), ("email", &self.email)] {
if v.is_empty() || v.chars().any(char::is_control) {
return Err(Error::Config(format!(
"commit identity {what} must be non-empty and free of control characters"
)));
}
}
Ok(())
}
}
impl GitCmd {
fn options(&self) -> Vec<String> {
let mut v: Vec<String> = Vec::new();
match self {
Self::Status { .. } => {
v.push("status".into());
v.push("--porcelain=v1".into());
v.push("--untracked-files=normal".into());
}
Self::Diff { staged, .. } => {
v.push("diff".into());
v.push("--no-color".into());
v.push("--no-ext-diff".into());
if *staged {
v.push("--cached".into());
}
}
Self::Log { max_count, .. } => {
v.push("log".into());
v.push("--no-color".into());
v.push("--oneline".into());
v.push("--no-decorate".into());
v.push(format!("--max-count={max_count}"));
}
Self::Add { .. } => v.push("add".into()),
Self::Commit { message, .. } => {
v.push("commit".into());
v.push("--no-verify".into());
v.push("-m".into());
v.push(message.clone());
}
}
v
}
fn paths(&self) -> &[String] {
match self {
Self::Status { paths }
| Self::Diff { paths, .. }
| Self::Log { paths, .. }
| Self::Add { paths } => paths,
Self::Commit { .. } => &[],
}
}
fn config(&self) -> Result<Vec<String>> {
match self {
Self::Commit { identity, .. } => {
identity.check()?;
Ok(vec![
"-c".into(),
format!("user.name={}", identity.name),
"-c".into(),
format!("user.email={}", identity.email),
])
}
_ => Ok(Vec::new()),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum GitOutcome {
Ran {
code: Option<i32>,
stdout: String,
stderr: String,
},
Unavailable {
reason: String,
},
}
impl GitOutcome {
pub(crate) fn ok(&self) -> bool {
matches!(self, Self::Ran { code: Some(0), .. })
}
}
pub(crate) struct Git<'a> {
policy: &'a Policy,
workdir: PathBuf,
program: String,
cap: usize,
}
impl<'a> Git<'a> {
pub(crate) fn new(policy: &'a Policy, workdir: impl Into<PathBuf>, cap: usize) -> Self {
Self {
policy,
workdir: workdir.into(),
program: GIT.into(),
cap,
}
}
#[cfg(test)]
pub(crate) fn program(mut self, program: impl Into<String>) -> Self {
self.program = program.into();
self
}
pub(crate) fn argv(&self, cmd: &GitCmd) -> Result<Vec<String>> {
let mut argv = vec![
self.program.clone(),
"--no-pager".into(),
"-c".into(),
format!("{NO_HOOKS}={NULL_DEVICE}"),
];
argv.extend(cmd.config()?);
argv.extend(cmd.options());
argv.push("--".into());
for p in cmd.paths() {
check_path(p)?;
argv.push(p.clone());
}
Ok(argv)
}
pub(crate) async fn run(&self, cmd: &GitCmd) -> Result<GitOutcome> {
let argv = self.argv(cmd)?;
let verdict = self.policy.check(Act::Exec, &self.program);
if verdict.effect != Effect::Allow {
return Err(Error::Refused {
act: "exec".into(),
target: self.program.clone(),
rule: verdict.rule,
layer: verdict.layer,
});
}
match self.command(&argv).output().await {
Ok(out) => Ok(GitOutcome::Ran {
code: out.status.code(),
stdout: cap_result(String::from_utf8_lossy(&out.stdout).into_owned(), self.cap).0,
stderr: cap_result(String::from_utf8_lossy(&out.stderr).into_owned(), self.cap).0,
}),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(GitOutcome::Unavailable {
reason: format!("no `{}` on PATH", self.program),
}),
Err(e) => Err(Error::Io(e)),
}
}
fn command(&self, argv: &[String]) -> Command {
let mut c = Command::new(&argv[0]);
c.args(&argv[1..])
.current_dir(&self.workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
for (k, v) in FIXED_ENV {
c.env(k, v);
}
c
}
}
fn check_path(p: &str) -> Result<()> {
let bad = if p.is_empty() {
Some("<empty path>")
} else if p.starts_with('-') {
Some("<path may not begin with `-`>")
} else {
None
};
match bad {
None => Ok(()),
Some(rule) => Err(Error::Refused {
act: "exec".into(),
target: p.to_string(),
rule: Some(rule.into()),
layer: None,
}),
}
}
#[cfg(test)]
mod tests {
use super::*;
const CAP: usize = 100_000;
fn git<'a>(policy: &'a Policy, dir: &std::path::Path) -> Git<'a> {
Git::new(policy, dir, CAP)
}
fn every_shape() -> Vec<GitCmd> {
vec![
GitCmd::Status {
paths: vec!["src".into()],
},
GitCmd::Diff {
staged: false,
paths: vec!["src".into()],
},
GitCmd::Diff {
staged: true,
paths: vec![],
},
GitCmd::Log {
max_count: 20,
paths: vec!["src/main.rs".into()],
},
GitCmd::Add {
paths: vec!["src/main.rs".into()],
},
GitCmd::Commit {
message: "a message".into(),
identity: Identity::default(),
},
]
}
#[test]
fn every_shape_covers_every_variant() {
for cmd in every_shape() {
match cmd {
GitCmd::Status { .. }
| GitCmd::Diff { .. }
| GitCmd::Log { .. }
| GitCmd::Add { .. }
| GitCmd::Commit { .. } => {}
}
}
let mut kinds: Vec<_> = every_shape().iter().map(std::mem::discriminant).collect();
let before = kinds.len();
kinds.dedup_by(|a, b| a == b);
assert_eq!(before, 6, "every_shape lists six commands");
assert_eq!(
kinds.len(),
5,
"every_shape must contain one of each variant; add the new one"
);
}
#[test]
fn the_subcommand_is_always_one_of_the_five_this_crate_ships() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
for cmd in every_shape() {
let argv = g.argv(&cmd).unwrap();
let sub = argv
.iter()
.skip(1)
.find(|a| !a.starts_with('-') && !a.contains('='))
.expect("every argv has a subcommand");
assert!(
["status", "diff", "log", "add", "commit"].contains(&sub.as_str()),
"{cmd:?} produced subcommand {sub:?}"
);
}
}
#[test]
fn a_path_beginning_with_a_dash_is_refused_and_the_same_path_without_it_is_not() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
let refused = g.argv(&GitCmd::Status {
paths: vec!["--exec=rm -rf /".into()],
});
assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
let argv = g
.argv(&GitCmd::Status {
paths: vec!["exec=rm -rf /".into()],
})
.unwrap();
assert_eq!(argv.last().unwrap(), "exec=rm -rf /");
}
#[test]
fn a_path_with_a_space_a_quote_and_a_newline_survives_as_one_argv_element() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let nasty = "a dir/it's \"quoted\"\nand $(whoami) `id` ;rm -rf *";
let argv = git(&p, dir.path())
.argv(&GitCmd::Diff {
staged: false,
paths: vec![nasty.into()],
})
.unwrap();
assert_eq!(argv.iter().filter(|a| a.contains("whoami")).count(), 1);
assert_eq!(argv.last().unwrap(), nasty);
}
#[test]
fn a_path_named_like_a_flag_is_refused_and_its_relative_form_stays_data() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
let refused = g.argv(&GitCmd::Log {
max_count: 1,
paths: vec!["--upload-pack=x".into()],
});
assert!(matches!(refused, Err(Error::Refused { .. })), "{refused:?}");
let argv = g
.argv(&GitCmd::Log {
max_count: 1,
paths: vec!["./--upload-pack=x".into()],
})
.unwrap();
let sep = argv.iter().position(|a| a == "--").unwrap();
assert_eq!(argv[sep + 1], "./--upload-pack=x");
assert_eq!(argv.len(), sep + 2);
}
#[test]
fn every_model_supplied_path_lands_after_the_separator() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
for cmd in every_shape() {
let argv = g.argv(&cmd).unwrap();
let sep = argv.iter().position(|a| a == "--").unwrap();
assert_eq!(&argv[sep + 1..], cmd.paths(), "{cmd:?}");
}
}
#[test]
fn no_shape_can_write_or_reach_the_network() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
const FORBIDDEN: &[&str] = &[
"push",
"fetch",
"clone",
"remote",
"reset",
"checkout",
"rebase",
"stash",
"filter-branch",
"--force",
];
for cmd in every_shape() {
let argv = g.argv(&cmd).unwrap();
for word in FORBIDDEN {
assert!(
!argv.iter().any(|a| a.contains(word)),
"{cmd:?} produced {argv:?} containing {word}"
);
}
}
}
#[test]
fn the_child_pins_the_pager_the_locale_the_config_and_the_hooks() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let g = git(&p, dir.path());
let argv = g.argv(&GitCmd::Status { paths: vec![] }).unwrap();
assert_eq!(argv[1], "--no-pager");
assert_eq!(argv[2], "-c");
assert_eq!(argv[3], format!("core.hooksPath={NULL_DEVICE}"));
let cmd = g.command(&argv);
let env: Vec<(String, Option<String>)> = cmd
.as_std()
.get_envs()
.map(|(k, v)| {
(
k.to_string_lossy().into_owned(),
v.map(|v| v.to_string_lossy().into_owned()),
)
})
.collect();
for (k, v) in FIXED_ENV {
assert!(
env.contains(&(k.to_string(), Some(v.to_string()))),
"{k}={v} missing from {env:?}"
);
}
assert_eq!(cmd.as_std().get_current_dir(), Some(dir.path()));
}
#[tokio::test]
async fn a_missing_git_binary_is_an_unavailable_outcome_not_a_run_failure() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let out = git(&p, dir.path())
.program("io-harness-no-such-git")
.run(&GitCmd::Status { paths: vec![] })
.await
.unwrap();
assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
assert!(!out.ok());
}
#[tokio::test]
async fn an_exec_deny_refuses_the_spawn_and_a_permissive_policy_does_not() {
let dir = tempfile::tempdir().unwrap();
let cmd = GitCmd::Status { paths: vec![] };
let denied = Policy::default()
.layer("l")
.deny_exec("io-harness-fake-git");
let err = git(&denied, dir.path())
.program("io-harness-fake-git")
.run(&cmd)
.await
.unwrap_err();
assert!(
matches!(&err, Error::Refused { act, target, .. }
if act == "exec" && target == "io-harness-fake-git"),
"{err:?}"
);
let allowed = Policy::permissive();
let out = git(&allowed, dir.path())
.program("io-harness-fake-git")
.run(&cmd)
.await
.unwrap();
assert!(matches!(out, GitOutcome::Unavailable { .. }), "{out:?}");
}
#[tokio::test]
async fn a_real_git_reports_its_own_failure_rather_than_erroring_the_run() {
let p = Policy::permissive();
let dir = tempfile::tempdir().unwrap();
let out = git(&p, dir.path())
.run(&GitCmd::Status { paths: vec![] })
.await
.unwrap();
let GitOutcome::Ran { code, stderr, .. } = out else {
return; };
match code {
Some(128) => assert!(stderr.contains("not a git repository"), "{stderr}"),
other => assert_eq!(other, Some(0), "{stderr}"),
}
}
}