use super::read_only_programs::Arg;
type Verdict = Result<(), String>;
const READ_VERBS: &[(&str, &[&str])] = &[
("issue", &["view", "list"]),
("pr", &["view", "list", "diff", "checks"]),
("run", &["view", "list"]),
];
const API_FLAGS: &[&str] = &[
"--paginate",
"--slurp",
"-i",
"--include",
"--silent",
"--verbose",
];
const API_VALUED: &[&str] = &[
"-X",
"--method",
"-H",
"--header",
"-q",
"--jq",
"-t",
"--template",
"-p",
"--preview",
];
pub(super) fn check_gh(rest: &[Arg]) -> Verdict {
let resource = rest
.first()
.and_then(Arg::text)
.ok_or("`gh` without a literal command")?;
if resource == "api" {
return api(&rest[1..]);
}
let verb = rest.get(1).and_then(Arg::text).unwrap_or_default();
let reads = READ_VERBS
.iter()
.any(|(r, verbs)| *r == resource && verbs.contains(&verb));
if !reads {
return Err(format!(
"`gh {resource} {verb}`, which is not a read verb (a read-only agent runs gh issue \
view/list, pr view/list/diff/checks, run view/list and api GET only)"
));
}
for t in rest[2..].iter().filter_map(Arg::text) {
let name = t.split('=').next().unwrap_or(t);
let short_web = t
.strip_prefix('-')
.is_some_and(|c| !c.starts_with('-') && c.contains('w'));
if matches!(name, "--web" | "--watch") || short_web {
return Err(format!(
"`gh {resource} {verb} {t}`, which opens a browser or blocks on CI"
));
}
}
Ok(())
}
fn api(rest: &[Arg]) -> Verdict {
let mut endpoint = None;
let mut i = 0;
while let Some(arg) = rest.get(i) {
let t = arg
.text()
.ok_or("a `for` variable in `gh api`, whose endpoint or method it could be")?;
i += 1;
if !t.starts_with('-') {
if endpoint.replace(t).is_some() {
return Err("`gh api` with more than one endpoint".into());
}
continue;
}
if API_FLAGS.contains(&t) {
continue;
}
let (name, joined) = match t.split_once('=') {
Some((n, v)) if t.starts_with("--") => (n, Some(v)),
_ if !t.starts_with("--") && t.len() > 2 => (&t[..2], Some(&t[2..])),
_ => (t, None),
};
if !API_VALUED.contains(&name) {
return Err(format!(
"`gh api {name}`, which is not a GET-only option (a field or `--input` sends a \
request body)"
));
}
let value = match joined {
Some(v) => v,
None => {
i += 1;
rest.get(i - 1)
.and_then(Arg::text)
.ok_or_else(|| format!("`gh api {name}` without a literal value"))?
}
};
api_value(name, value)?;
}
let endpoint = endpoint.ok_or("`gh api` without an endpoint")?;
if endpoint.contains("://") {
return Err("a `gh api` endpoint that is a full URL".into());
}
let path = endpoint.split(['?', '#']).next().unwrap_or(endpoint);
let mut segments = path.split('/').filter(|s| !s.is_empty() && *s != ".");
if segments.any(|s| s == ".." || s.eq_ignore_ascii_case("graphql")) {
return Err("a `gh api` graphql endpoint or `..` segment".into());
}
Ok(())
}
fn api_value(name: &str, value: &str) -> Verdict {
match name {
"-X" | "--method" if !value.eq_ignore_ascii_case("GET") => {
Err(format!("`gh api {name} {value}`, a method other than GET"))
}
"-H" | "--header" => {
let header = value.to_ascii_lowercase();
if header.starts_with("accept:") || header.starts_with("x-github-api-version:") {
return Ok(());
}
Err("a `gh api` header other than `Accept:` or `X-GitHub-Api-Version:`".into())
}
_ => Ok(()),
}
}