use super::read_only_programs::Arg;
type Verdict = Result<(), String>;
const GLOBAL_FLAGS: &[&str] = &[
"--no-pager",
"-P",
"--no-optional-locks",
"--literal-pathspecs",
];
const READS: &[&str] = &[
"status",
"log",
"diff",
"show",
"grep",
"rev-parse",
"ls-files",
"merge-base",
"ls-remote",
];
const WRITING_LONG: &[&str] = &[
"output",
"open-files-in-pager",
"upload-pack",
"exec",
"ext-diff",
"textconv",
"show-signature",
];
const BRANCH_FLAGS: &[&str] = &[
"--list",
"-l",
"-a",
"--all",
"-r",
"--remotes",
"-v",
"-vv",
"--verbose",
"--show-current",
"--no-color",
"--no-column",
"-i",
"--ignore-case",
];
const BRANCH_VALUED: &[&str] = &[
"--contains",
"--no-contains",
"--merged",
"--no-merged",
"--points-at",
"--sort",
"--format",
];
pub(super) fn check_git(rest: &[Arg]) -> Verdict {
let (sub, tail) = subcommand(rest)?;
match sub {
s if READS.contains(&s) => read(s, tail),
"branch" => branch(tail),
"worktree" => worktree(tail),
_ => Err(format!(
"`git {sub}`, which is not a read (a read-only agent never moves a ref or restores a file)"
)),
}
}
pub(super) fn check_git_ls_remote(rest: &[Arg]) -> Verdict {
match subcommand(rest)? {
("ls-remote", tail) => read("ls-remote", tail),
(sub, _) => Err(format!(
"a `GH_CONFIG_DIR=<dir>` prefix before `git {sub}` (only `git ls-remote` may carry it)"
)),
}
}
fn subcommand(rest: &[Arg]) -> Result<(&str, &[Arg]), String> {
let mut i = 0;
let sub = loop {
let Some(arg) = rest.get(i) else {
return Err("`git` with no subcommand".into());
};
let t = arg
.text()
.ok_or("a `git` subcommand that is not a literal")?;
if GLOBAL_FLAGS.contains(&t) {
i += 1;
} else if t == "-C" {
if !rest
.get(i + 1)
.is_some_and(|a| a.text().is_some() && a.is_operand())
{
return Err("`git -C` without a literal directory".into());
}
i += 2;
} else if t.starts_with('-') {
return Err(format!(
"the git global option `{t}`, which is not allowlisted"
));
} else {
break t;
}
};
Ok((sub, &rest[i + 1..]))
}
fn read(sub: &str, tail: &[Arg]) -> Verdict {
for (k, arg) in tail.iter().enumerate() {
if arg.text().is_some() {
continue;
}
let after_option = k
.checked_sub(1)
.and_then(|p| tail[p].text())
.is_some_and(|prev| prev.starts_with('-') && prev != "--");
if sub == "ls-remote" || after_option {
return Err(format!(
"a `for` variable as the value of a `git {sub}` option or remote (to pass a \
loop path to git, put it after `--`: `git {sub} <options> -- \"$f\"`)"
));
}
}
for t in tail.iter().filter_map(Arg::text) {
if sub == "ls-remote" && t.contains("::") {
return Err("a `git ls-remote` remote-helper URL, which runs a program".into());
}
if signature_placeholder(t) {
return Err("a `%G` format placeholder, which runs the signature verifier".into());
}
let long = t
.strip_prefix("--")
.map(|n| n.split('=').next().unwrap_or(n));
let writes_long =
long.is_some_and(|n| !n.is_empty() && WRITING_LONG.iter().any(|w| w.starts_with(n)));
let cluster = t.strip_prefix('-').filter(|c| !c.starts_with('-'));
let writes_short = cluster.is_some_and(|c| {
(sub == "grep" && c.contains('O')) || (sub == "ls-remote" && c.contains('u'))
});
if writes_long || writes_short {
return Err(format!(
"`git {sub} {t}`, which writes a file or runs a program"
));
}
}
Ok(())
}
fn branch(tail: &[Arg]) -> Verdict {
let listing = tail
.iter()
.any(|a| matches!(a.text(), Some("--list" | "-l")));
let mut i = 0;
if tail
.iter()
.filter_map(Arg::text)
.any(|t| t.contains("signature"))
{
return Err("a `%(signature)` format atom, which runs the signature verifier".into());
}
while let Some(arg) = tail.get(i) {
let t = arg.text().unwrap_or_default();
let name = t.split('=').next().unwrap_or(t);
let joined = t.contains('=') && BRANCH_VALUED.contains(&name);
if BRANCH_VALUED.contains(&t) {
if tail.get(i + 1).and_then(Arg::text).is_none() {
return Err(format!("`git branch {t}` without a literal value"));
}
i += 2;
} else if BRANCH_FLAGS.contains(&t) || joined || (listing && arg.is_operand()) {
i += 1;
} else {
return Err("`git branch` in a form that creates, moves or deletes a branch".into());
}
}
Ok(())
}
fn worktree(tail: &[Arg]) -> Verdict {
let listed = tail.first().and_then(Arg::text) == Some("list");
let flags_ok = tail
.iter()
.skip(1)
.all(|a| matches!(a.text(), Some("--porcelain" | "-v" | "--verbose" | "-z")));
if !listed || !flags_ok {
return Err("`git worktree` in a form other than `list`".into());
}
Ok(())
}
fn signature_placeholder(t: &str) -> bool {
t.match_indices('%').any(|(k, _)| {
t[k + 1..]
.trim_start_matches(['+', '-', ' '])
.starts_with('G')
})
}