use std::cell::OnceCell;
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, Output, Stdio};
use serde_json::Value;
const GIT_VALUED: &[&str] = &[
"-C",
"-c",
"--attr-source",
"--git-dir",
"--work-tree",
"--namespace",
"--config-env",
];
const CURRENT_BRANCH: &[&str] = &[
"$(git branch --show-current)",
"$(git rev-parse --abbrev-ref HEAD)",
"$(git symbolic-ref --short HEAD)",
"`git branch --show-current`",
"`git rev-parse --abbrev-ref HEAD`",
"`git symbolic-ref --short HEAD`",
];
const UNREAD: &str = "this push goes where the checkout says, and the checkout could not be read";
const ALLOW_RULE: &str =
"a cloud seat may push only its named branches and develop, and may not force or delete a ref";
pub(crate) fn seat_allowlist() -> Result<HashSet<String>, String> {
let mut allow = HashSet::new();
allow.insert("develop".to_string());
let Some(path) = seat_branch_file() else {
return Ok(allow);
};
if !path.is_file() {
return Ok(allow);
}
let text = std::fs::read_to_string(&path).map_err(|_| {
format!(
"the seat config {} could not be read; {ALLOW_RULE}",
path.display()
)
})?;
if text.trim().is_empty() {
return Ok(allow);
}
let value: toml::Value = toml::from_str(&text).map_err(|_| {
format!(
"the seat config {} does not parse; {ALLOW_RULE}",
path.display()
)
})?;
let Some(root) = value.as_table() else {
return Err(format!(
"the seat config {} is not a table; {ALLOW_RULE}",
path.display()
));
};
let branches = root
.get("cloud")
.and_then(toml::Value::as_table)
.and_then(|cloud| cloud.get("branches"))
.or_else(|| root.get("branches"));
let Some(branches) = branches else {
return Ok(allow);
};
let Some(items) = branches.as_array() else {
return Err(format!(
"the seat config {} key `branches` must be a list of strings; {ALLOW_RULE}",
path.display()
));
};
for item in items {
let Some(name) = item.as_str() else {
return Err(format!(
"the seat config {} key `branches` must be a list of strings; {ALLOW_RULE}",
path.display()
));
};
let name = short_branch(name);
if !name.is_empty() {
allow.insert(name);
}
}
Ok(allow)
}
fn seat_branch_file() -> Option<PathBuf> {
let base = crate::config_base()?;
let seat = std::env::var("LJOS_SEAT")
.ok()
.filter(|name| !name.trim().is_empty())
.or_else(|| seat_from_env_file(&base))?;
let seat = seat.trim();
if seat.is_empty() || seat.contains('/') || seat.contains('\\') || seat.contains("..") {
return None;
}
Some(base.join("ljos").join("seats").join(seat).join("ljos.toml"))
}
fn seat_from_env_file(base: &Path) -> Option<String> {
let text = std::fs::read_to_string(base.join("ljos").join("env")).ok()?;
for line in text.lines() {
let line = line.trim();
let Some((key, value)) = line.split_once('=') else {
continue;
};
if key.trim() == "LJOS_SEAT" {
let value = value.trim();
if !value.is_empty() {
return Some(value.to_string());
}
}
}
None
}
fn short_branch(name: &str) -> String {
let name = name.trim();
name.strip_prefix("refs/heads/")
.or_else(|| name.strip_prefix("heads/"))
.unwrap_or(name)
.to_string()
}
fn branch_permitted(name: &str) -> Result<bool, String> {
let allow = seat_allowlist()?;
if is_tag_ref(name) || is_other_ref(name) {
return Ok(false);
}
Ok(allow.contains(&short_branch(name)))
}
fn is_tag_ref(name: &str) -> bool {
let name = name.trim();
name.starts_with("refs/tags/") || name.starts_with("tags/")
}
fn is_other_ref(name: &str) -> bool {
let name = name.trim();
name.starts_with("refs/") && !name.starts_with("refs/heads/")
}
fn dest_refused(name: &str) -> Option<String> {
match branch_permitted(name) {
Err(why) => Some(why),
Ok(true) => None,
Ok(false) => Some(format!("this push updates {name}; {ALLOW_RULE}")),
}
}
pub(crate) fn cursor_push(line: &str, cwd: Option<&Path>) -> Option<String> {
judge(line, cwd).push
}
fn bound_names(names: &mut Vec<(String, String)>, seg: &str) {
let words = crate::shell_words(seg);
let bare = if words.first().is_some_and(|w| w == "export") {
&words[1..]
} else {
&words[..]
};
for w in bare {
if let Some((k, v)) = w.split_once('=') {
names.push((k.to_string(), v.to_string()));
}
}
}
#[derive(Clone, Copy, Default)]
pub(crate) struct GhActs {
pub open: bool,
pub merge: bool,
}
impl GhActs {
fn either(self, other: Self) -> Self {
Self {
open: self.open || other.open,
merge: self.merge || other.merge,
}
}
fn any(self) -> bool {
self.open || self.merge
}
}
pub(crate) fn gh_acts(line: &str, cwd: Option<&Path>) -> GhActs {
judge(line, cwd).acts
}
#[derive(Clone, Default)]
struct Verdict {
push: Option<String>,
acts: GhActs,
saw_ref: bool,
}
impl Verdict {
fn either(mut self, other: Self) -> Self {
if self.push.is_none() {
self.push = other.push;
}
self.acts = self.acts.either(other.acts);
self.saw_ref |= other.saw_ref;
self
}
fn closed(why: impl Into<String>) -> Self {
Self {
push: Some(why.into()),
saw_ref: true,
..Self::default()
}
}
}
#[derive(Clone, Default)]
struct WalkIn {
depth: usize,
tainted: bool,
gh_unreadable: bool,
git_aliases: Vec<(String, String)>,
gh_aliases: Vec<(String, String)>,
}
fn judge(line: &str, cwd: Option<&Path>) -> Verdict {
judge_depth(line, cwd, &WalkIn::default())
}
fn judge_depth(line: &str, cwd: Option<&Path>, incoming: &WalkIn) -> Verdict {
if incoming.depth > ALIAS_DEPTH {
return Verdict {
push: Some(format!("alias expansion could not be read; {ALLOW_RULE}")),
acts: GhActs {
open: true,
merge: true,
},
saw_ref: true,
};
}
let mut here = cwd
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok());
let mut git_aliases = incoming.git_aliases.clone();
let mut gh_aliases = incoming.gh_aliases.clone();
let mut tainted = incoming.tainted;
let mut gh_unreadable = incoming.gh_unreadable;
let mut verdict = Verdict::default();
for l in crate::nested_lines(line, 0) {
let mut names: Vec<(String, String)> = Vec::new();
for seg in crate::raw_segments(&l) {
if let Some(why) = hook_disarm(&seg, here.as_deref()) {
verdict.saw_ref = true;
if verdict.push.is_none() {
verdict.push = Some(why);
}
}
if crate::assignment_segment(&seg) {
bound_names(&mut names, &seg);
continue;
}
let ifs = names
.iter()
.rev()
.find(|(k, _)| k == "IFS")
.map(|(_, v)| v.clone());
let expanded = crate::expand_names(&seg, &names, ifs.as_deref());
let seg = expanded.as_deref().unwrap_or(&seg);
let words = argv(seg);
let Some(cmd) = words.first().map(String::as_str) else {
continue;
};
let run = prefix_context(seg, here.clone());
if cmd == "bash" {
if let Some(path) = env_value(&run.env, &names, "BASH_ENV") {
let child = walk_child(
incoming.depth,
tainted,
gh_unreadable,
&git_aliases,
&gh_aliases,
);
verdict = verdict.either(script_verdict(&path, here.as_deref(), &child));
}
}
match cmd {
"cd" | "pushd" => here = moved(here, &words[1..]),
"popd" => here = None,
"git" => {
if let Some(alias) = git_alias_write(&words[1..]) {
git_aliases.push(alias);
}
if config_affects_push(&words[1..]) {
tainted = true;
}
let Some(outcome) =
git_push(&words[1..], run.dir, &git_aliases, &run.env, run.via_xargs)
else {
continue;
};
match outcome {
GitOutcome::Shell(script) => {
let child = walk_child(
incoming.depth,
tainted,
gh_unreadable,
&git_aliases,
&gh_aliases,
);
verdict = verdict.either(judge_depth(&script, here.as_deref(), &child));
}
GitOutcome::Push(push) => {
verdict.saw_ref = true;
if push.dry_run {
continue;
}
let why = if tainted {
Some(format!(
"a git config on this line changes where a push writes; {ALLOW_RULE}"
))
} else {
push.cursor_ref()
};
if let Some(why) = why {
if verdict.push.is_none() {
verdict.push = Some(why);
}
}
}
}
}
"gh" => {
if let Some(set) = gh_alias_set(&words) {
if unreadable_token(&set.0) || unreadable_token(&set.1) || set.1.is_empty()
{
gh_unreadable = true;
} else {
let exp = if set.2 { format!("!{}", set.1) } else { set.1 };
gh_aliases.push((set.0, exp));
}
continue;
}
let child = walk_child(
incoming.depth,
tainted,
gh_unreadable,
&git_aliases,
&gh_aliases,
);
verdict = verdict.either(gh_command(
&words,
&prefix_env(seg),
here.as_deref(),
&child,
run.via_xargs,
));
}
"source" | "." | "bash" | "sh" | "dash" | "zsh" | "ksh" => {
if let Some(file) = shell_script_file(&words) {
let child = walk_child(
incoming.depth,
tainted,
gh_unreadable,
&git_aliases,
&gh_aliases,
);
verdict = verdict.either(script_verdict(&file, here.as_deref(), &child));
}
}
"curl" | "wget" | "http" | "https" => {
if http_targets_github(&words) {
verdict.saw_ref = true;
}
if let Some(why) = http_ref_write(&words, &run.env, here.as_deref()) {
if verdict.push.is_none() {
verdict.push = Some(why);
}
}
}
_ => {
if cloud_repoint(cmd, &words) {
verdict.saw_ref = true;
if verdict.push.is_none() {
verdict.push = Some(format!(
"`ljos cloud up --repoint` would retarget the repository this seat may push; {ALLOW_RULE}"
));
}
}
}
}
}
}
verdict
}
fn cloud_repoint(cmd: &str, words: &[String]) -> bool {
if cmd.rsplit('/').next().unwrap_or(cmd) != "ljos" {
return false;
}
let mut cloud = false;
let mut up = false;
let mut repoint = false;
for word in words.iter().skip(1) {
match word.as_str() {
"cloud" => cloud = true,
"up" => up = true,
"--repoint" => repoint = true,
_ => {}
}
}
cloud && up && repoint
}
fn hook_disarm(seg: &str, cwd: Option<&Path>) -> Option<String> {
let words = crate::shell_words(seg);
if words.is_empty() {
return None;
}
if let Some(why) = redirect_disarm(&words, cwd) {
return Some(why);
}
let (at, _) = crate::prefix_end(&words);
let cmd = words
.get(at)
.map(|word| word.rsplit('/').next().unwrap_or(word));
if let Some(why) = file_tool_disarm(cmd, &words[at..], cwd) {
return Some(why);
}
if let Some(why) = protected_path_write(cmd, &words[at..], cwd) {
return Some(why);
}
let shell_wide = cmd == Some("export") || at == words.len();
let git = cmd == Some("git");
if shell_wide || git {
if let Some(why) = config_env_disarm(&words[..at]) {
return Some(why);
}
}
if cmd == Some("export") {
if let Some(why) = config_env_disarm(&words[at + 1..]) {
return Some(why);
}
}
if git {
return git_hook_disarm(&words[at..], cwd);
}
None
}
fn protected_path_write(cmd: Option<&str>, words: &[String], cwd: Option<&Path>) -> Option<String> {
let cmd = cmd?;
if command_is_pure_read(cmd, words) || (cmd == "git" && git_invocation_is_read(words)) {
return None;
}
let hit = words
.iter()
.find(|word| crate::cloud_hook::word_hits_protected(word, cwd))?;
Some(disarm_why(&format!(
"`{cmd}` writes {hit}, which can replace the pre-push hook or the config that names it"
)))
}
fn command_is_pure_read(cmd: &str, words: &[String]) -> bool {
let cmd = cmd.rsplit('/').next().unwrap_or(cmd);
if matches!(
cmd,
"cat"
| "head"
| "tail"
| "less"
| "more"
| "wc"
| "stat"
| "ls"
| "file"
| "grep"
| "rg"
| "diff"
| "cmp"
| "od"
| "hexdump"
| "strings"
| "echo"
| "printf"
| "true"
| "false"
) {
return true;
}
if matches!(cmd, "sed" | "perl" | "awk" | "ruby") {
return !inplace_edit(cmd, words);
}
false
}
fn inplace_edit(cmd: &str, words: &[String]) -> bool {
words.iter().any(|word| {
if word == "--in-place" || word.starts_with("--in-place=") {
return true;
}
if cmd == "perl" {
if let Some(cluster) = word.strip_prefix('-') {
if !cluster.is_empty() && !word.starts_with("--") && cluster.contains('i') {
return true;
}
}
return false;
}
word == "-i" || (word.starts_with("-i") && !word.starts_with("--"))
})
}
fn git_invocation_is_read(words: &[String]) -> bool {
let mut i = 1usize;
while i < words.len() {
let word = words[i].as_str();
if GIT_VALUED.contains(&word) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
let sub = words.get(i).map(String::as_str).unwrap_or("");
if matches!(
sub,
"status"
| "log"
| "diff"
| "show"
| "rev-parse"
| "grep"
| "blame"
| "ls-files"
| "cat-file"
| "rev-list"
| "merge-base"
| "symbolic-ref"
| "describe"
| "fetch"
| "version"
| "help"
) {
return true;
}
sub == "config" && git_config_is_read(&words[i + 1..])
}
fn git_config_is_read(args: &[String]) -> bool {
let mut positional = 0usize;
let mut i = 0usize;
while i < args.len() {
let word = args[i].as_str();
if matches!(
word,
"--get"
| "--get-all"
| "--get-regexp"
| "--get-urlmatch"
| "--list"
| "-l"
| "--show-origin"
| "--show-scope"
| "--get-color"
| "--get-colorbool"
| "--global"
| "--system"
| "--local"
) {
i += 1;
continue;
}
if matches!(
word,
"--file" | "-f" | "--blob" | "--default" | "--comment" | "--type" | "--name" | "--id"
) {
i += 2;
continue;
}
if matches!(
word,
"--unset"
| "--unset-all"
| "--replace-all"
| "--add"
| "--edit"
| "--remove-section"
| "--rename-section"
) {
return false;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
if word.contains('=') {
return false;
}
positional += 1;
i += 1;
}
positional < 2
}
fn disarm_why(what: &str) -> String {
format!("{what}; {ALLOW_RULE}")
}
fn redirect_disarm(words: &[String], cwd: Option<&Path>) -> Option<String> {
for (i, word) in words.iter().enumerate() {
if word != ">" {
continue;
}
let Some(target) = words.get(i + 1) else {
continue;
};
if crate::cloud_hook::names_protected(target, cwd) {
return Some(disarm_why(&format!(
"a redirect writes {target}, which can replace the pre-push hook or the config that names it"
)));
}
}
None
}
fn file_tool_disarm(cmd: Option<&str>, words: &[String], cwd: Option<&Path>) -> Option<String> {
let cmd = cmd?;
let each = matches!(cmd, "rm" | "unlink" | "truncate" | "chmod" | "tee");
let dest = matches!(cmd, "mv" | "cp" | "install" | "ln");
if !each && !dest {
return None;
}
let mut positionals: Vec<String> = Vec::new();
let mut i = 1;
while i < words.len() {
let word = words[i].as_str();
if word == "--" {
positionals.extend(words[i + 1..].iter().cloned());
break;
}
if matches!(word, "-t" | "--target-directory" | "--target") {
if let Some(dir) = words.get(i + 1) {
if crate::cloud_hook::names_protected(dir, cwd) {
return Some(disarm_why(&format!(
"`{cmd}` writes {dir}, which can replace the pre-push hook or the config that names it"
)));
}
}
i += 2;
continue;
}
if let Some(dir) = word
.strip_prefix("--target-directory=")
.or_else(|| word.strip_prefix("--target="))
{
if crate::cloud_hook::names_protected(dir, cwd) {
return Some(disarm_why(&format!(
"`{cmd}` writes {dir}, which can replace the pre-push hook or the config that names it"
)));
}
i += 1;
continue;
}
if valued_file_flag(cmd, word) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
positionals.push(word.to_string());
i += 1;
}
let hit = if dest {
positionals.last().cloned()
} else {
positionals
.iter()
.find(|path| crate::cloud_hook::names_protected(path, cwd))
.cloned()
};
let hit = hit.filter(|path| crate::cloud_hook::names_protected(path, cwd))?;
Some(disarm_why(&format!(
"`{cmd}` writes {hit}, which can replace the pre-push hook or the config that names it"
)))
}
fn valued_file_flag(cmd: &str, word: &str) -> bool {
match cmd {
"install" => matches!(
word,
"-m" | "-o"
| "-g"
| "-S"
| "--mode"
| "--owner"
| "--group"
| "--suffix"
| "--strip-program"
| "--compare"
),
"chmod" => matches!(word, "--reference"),
"cp" | "mv" | "ln" => matches!(
word,
"--suffix" | "-S" | "--backup" | "--context" | "--no-target-directory"
),
"truncate" => matches!(word, "-s" | "--size" | "-r" | "--reference"),
"tee" => false,
_ => false,
}
}
fn config_env_disarm(words: &[String]) -> Option<String> {
for word in words {
let Some((key, value)) = word.split_once('=') else {
continue;
};
if !assign_key(key) {
continue;
}
if key == "GIT_CONFIG_GLOBAL" || key == "GIT_CONFIG_SYSTEM" {
return Some(disarm_why(&format!(
"`{key}` replaces the config that names the pre-push hook"
)));
}
if key.starts_with("GIT_CONFIG_KEY_") && value.eq_ignore_ascii_case("core.hooksPath") {
return Some(disarm_why(
"`GIT_CONFIG_KEY_*` sets core.hooksPath and skips the pre-push hook",
));
}
if key == "GIT_CONFIG_PARAMETERS" && value.to_ascii_lowercase().contains("core.hookspath") {
return Some(disarm_why(
"`GIT_CONFIG_PARAMETERS` sets core.hooksPath and skips the pre-push hook",
));
}
}
None
}
fn git_hook_disarm(words: &[String], cwd: Option<&Path>) -> Option<String> {
if let Some(why) = git_hook_switches(words) {
return Some(why);
}
let mut i = 1;
while i < words.len() {
let word = words[i].as_str();
if word == "--no-verify" || word.starts_with("--no-verify=") {
return Some(disarm_why("`git --no-verify` skips the pre-push hook"));
}
if word == "-c" || word == "--config-env" {
let value = words.get(i + 1).map(String::as_str).unwrap_or("");
if hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
i += 2;
continue;
}
if let Some(value) = word.strip_prefix("--config-env=") {
if hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
i += 1;
continue;
}
if let Some(value) = word.strip_prefix("-c") {
if !value.is_empty() && !word.starts_with("--") && hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
}
if GIT_VALUED.contains(&word) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
if words.get(i).map(String::as_str) == Some("config") {
return git_config_hooks_disarm(words, &words[i + 1..], cwd);
}
None
}
fn git_hook_switches(words: &[String]) -> Option<String> {
let mut i = 1;
while i < words.len() {
let word = words[i].as_str();
if word == "--no-verify" || word.starts_with("--no-verify=") {
return Some(disarm_why("`git --no-verify` skips the pre-push hook"));
}
if word == "-c" || word == "--config-env" || word == "--config" {
let value = words.get(i + 1).map(String::as_str).unwrap_or("");
if hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
i += 2;
continue;
}
if let Some(value) = word.strip_prefix("--config=") {
if hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
}
if let Some(value) = word.strip_prefix("--config-env=") {
if hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
}
if let Some(value) = word.strip_prefix("-c") {
if !value.is_empty() && !word.starts_with("--") && hooks_path_setting(value) {
return Some(disarm_why(
"`git -c` names core.hooksPath and skips the pre-push hook",
));
}
}
i += 1;
}
None
}
fn hooks_path_setting(value: &str) -> bool {
let key = value.split_once('=').map(|(key, _)| key).unwrap_or(value);
key.eq_ignore_ascii_case("core.hooksPath")
}
fn git_config_hooks_disarm(
git_words: &[String],
args: &[String],
cwd: Option<&Path>,
) -> Option<String> {
let mut i = 0;
let mut read = false;
let mut write = false;
let mut edit = false;
let mut global = false;
let mut file: Option<String> = None;
let mut section: Option<String> = None;
let mut positional: Vec<&str> = Vec::new();
while i < args.len() {
let word = args[i].as_str();
if matches!(word, "--global" | "--system") {
global = true;
i += 1;
continue;
}
if word == "--local" {
i += 1;
continue;
}
if matches!(
word,
"--get"
| "--get-all"
| "--get-regexp"
| "--get-urlmatch"
| "--list"
| "-l"
| "--show-origin"
| "--show-scope"
| "--get-color"
| "--get-colorbool"
) {
read = true;
i += 1;
continue;
}
if matches!(word, "--unset" | "--unset-all" | "--replace-all" | "--add") {
write = true;
i += 1;
continue;
}
if word == "--edit" {
edit = true;
i += 1;
continue;
}
if word == "--remove-section" || word == "--rename-section" {
section = args.get(i + 1).cloned();
i += 2;
continue;
}
if word == "--file" || word == "-f" {
file = args.get(i + 1).cloned();
i += 2;
continue;
}
if let Some(rest) = word.strip_prefix("--file=") {
file = Some(rest.to_string());
i += 1;
continue;
}
if matches!(
word,
"--blob" | "--default" | "--comment" | "--type" | "--name" | "--id"
) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
positional.push(word);
i += 1;
}
if !config_scope_guarded(global, file.as_deref(), git_words, cwd) {
return None;
}
if edit {
return Some(disarm_why(
"`git config --edit` can set core.hooksPath and skip the pre-push hook",
));
}
if let Some(section) = section.as_deref() {
if section_disarms(section) {
return Some(disarm_why(
"`git config --remove-section` can drop core.hooksPath and skip the pre-push hook",
));
}
}
let key = positional.first().copied().unwrap_or("");
let key = key.split_once('=').map(|(key, _)| key).unwrap_or(key);
if !key_disarms(key) {
return None;
}
let sets =
write || positional.len() >= 2 || positional.first().is_some_and(|w| w.contains('='));
if read && !sets {
return None;
}
if !sets {
return None;
}
Some(disarm_why(
"`git config` writes a key that skips the pre-push hook",
))
}
fn config_scope_guarded(
global: bool,
file: Option<&str>,
git_words: &[String],
cwd: Option<&Path>,
) -> bool {
if global {
return true;
}
if let Some(file) = file {
return crate::cloud_hook::names_protected(file, cwd);
}
let dir = git_dash_c(git_words).unwrap_or_else(|| PathBuf::from("."));
let dir = if dir.is_absolute() {
dir
} else {
match cwd {
Some(cwd) => cwd.join(dir),
None => dir,
}
};
crate::cloud_hook::dir_is_recorded_seat(&dir)
}
fn git_dash_c(words: &[String]) -> Option<PathBuf> {
let mut i = 1usize;
while i < words.len() {
let word = words.get(i).map(String::as_str)?;
if word == "-C" {
return words.get(i + 1).map(PathBuf::from);
}
if let Some(rest) = word.strip_prefix("-C") {
if !rest.is_empty() && !word.starts_with("--") {
return Some(PathBuf::from(rest));
}
}
if GIT_VALUED.contains(&word) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
None
}
fn key_disarms(key: &str) -> bool {
if key.eq_ignore_ascii_case("core.hooksPath") {
return true;
}
let lower = key.to_ascii_lowercase();
lower == "include.path" || lower.starts_with("include.") || lower.starts_with("includeif.")
}
fn section_disarms(section: &str) -> bool {
let lower = section.to_ascii_lowercase();
lower == "core" || lower == "include" || lower.starts_with("includeif")
}
fn walk_child(
depth: usize,
tainted: bool,
gh_unreadable: bool,
git_aliases: &[(String, String)],
gh_aliases: &[(String, String)],
) -> WalkIn {
WalkIn {
depth: depth + 1,
tainted,
gh_unreadable,
git_aliases: git_aliases.to_vec(),
gh_aliases: gh_aliases.to_vec(),
}
}
fn env_value(env: &[(String, String)], names: &[(String, String)], key: &str) -> Option<String> {
env.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.or_else(|| {
names
.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
})
}
fn unreadable_token(word: &str) -> bool {
word.contains(['$', '`'])
}
const ALIAS_DEPTH: usize = 8;
fn gh_command(
words: &[String],
env: &[(String, String)],
cwd: Option<&Path>,
incoming: &WalkIn,
via_xargs: bool,
) -> Verdict {
if incoming.depth > ALIAS_DEPTH {
return Verdict {
push: Some(format!(
"gh alias expansion could not be read; {ALLOW_RULE}"
)),
acts: GhActs {
open: true,
merge: true,
},
saw_ref: true,
};
}
let mut rest = words.iter().map(String::as_str).skip(1);
let Some(noun) = gh_word(&mut rest) else {
return Verdict::default();
};
if unreadable_token(noun) || (via_xargs && noun == "api") {
return Verdict::closed(format!(
"`gh {noun}` takes arguments the line does not show; {ALLOW_RULE}"
));
}
match noun {
"help" | "completion" | "status" | "search" | "browse" | "alias" | "config" => {
Verdict::default()
}
"auth" => gh_read_verb(noun, gh_word(&mut rest), &["status", "token"]),
"pr" => gh_pr(rest),
"issue" => gh_read_verb(noun, gh_word(&mut rest), &["view", "list", "status"]),
"repo" => gh_read_verb(noun, gh_word(&mut rest), &["view", "list", "clone"]),
"release" => gh_release(rest),
"run" => gh_read_verb(
noun,
gh_word(&mut rest),
&["view", "list", "watch", "download"],
),
"label" => gh_read_verb(noun, gh_word(&mut rest), &["list"]),
"cache" => gh_read_verb(noun, gh_word(&mut rest), &["list"]),
"workflow" => gh_read_verb(noun, gh_word(&mut rest), &["view", "list"]),
"gist" => gh_read_verb(noun, gh_word(&mut rest), &["view", "list"]),
"api" => gh_api(rest, cwd),
other => {
let exp = incoming
.gh_aliases
.iter()
.rev()
.find(|(name, _)| name == other)
.map(|(_, exp)| exp.clone())
.or_else(|| alias_expansion(env, other));
let Some(exp) = exp else {
if incoming.gh_unreadable {
return Verdict::closed(format!(
"a gh alias set on this line could not be read; {ALLOW_RULE}"
));
}
return gh_write(other, "");
};
let extra: Vec<&str> = rest.collect();
expand_alias(&exp, &extra, env, cwd, incoming)
}
}
}
const GH_WRITES_LJOS_NEEDS: &[&str] = &[];
fn ljos_needs_write(noun: &str, verb: &str) -> bool {
GH_WRITES_LJOS_NEEDS
.iter()
.any(|need| *need == noun || *need == verb)
}
fn gh_write(noun: &str, verb: &str) -> Verdict {
if ljos_needs_write(noun, verb) {
return Verdict::default();
}
let what = if verb.is_empty() {
format!("`gh {noun}`")
} else {
format!("`gh {noun} {verb}`")
};
Verdict::closed(format!(
"{what} is not a GitHub read this seat runs; {ALLOW_RULE}"
))
}
fn gh_read_verb(noun: &str, verb: Option<&str>, reads: &[&str]) -> Verdict {
match verb {
Some(verb) if reads.contains(&verb) => Verdict::default(),
None => Verdict::default(),
Some(verb) => gh_write(noun, verb),
}
}
fn gh_pr<'a>(mut words: impl Iterator<Item = &'a str>) -> Verdict {
let verb = gh_word(&mut words);
let acts = pr_verb(verb);
if acts.any() {
return Verdict {
acts,
..Verdict::default()
};
}
gh_read_verb(
"pr",
verb,
&["view", "list", "status", "checks", "diff", "checkout"],
)
}
fn pr_verb(verb: Option<&str>) -> GhActs {
match verb {
Some("create" | "new" | "ready" | "reopen") => GhActs {
open: true,
merge: false,
},
Some("merge") => GhActs {
open: false,
merge: true,
},
_ => GhActs::default(),
}
}
fn expand_alias(
exp: &str,
extra: &[&str],
env: &[(String, String)],
cwd: Option<&Path>,
incoming: &WalkIn,
) -> Verdict {
if let Some(script) = exp.strip_prefix('!') {
let mut line = script.trim().to_string();
if !extra.is_empty() {
line.push(' ');
line.push_str(&extra.join(" "));
}
let child = walk_child(
incoming.depth,
incoming.tainted,
incoming.gh_unreadable,
&incoming.git_aliases,
&incoming.gh_aliases,
);
return judge_depth(&line, cwd, &child);
}
let mut words = vec!["gh".to_string()];
words.extend(crate::shell_words(exp));
words.extend(extra.iter().map(|word| (*word).to_string()));
let child = walk_child(
incoming.depth,
incoming.tainted,
incoming.gh_unreadable,
&incoming.git_aliases,
&incoming.gh_aliases,
);
gh_command(&words, env, cwd, &child, false)
}
fn gh_alias_set(words: &[String]) -> Option<(String, String, bool)> {
let strs: Vec<&str> = words.iter().skip(1).map(String::as_str).collect();
let mut i = 0;
let mut shell = false;
let mut noun = None;
while i < strs.len() {
let word = strs[i];
if word == "-R" || word == "--repo" {
i += 2;
continue;
}
if word == "-s" || word == "--shell" {
shell = true;
i += 1;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
noun = Some(word);
i += 1;
break;
}
if noun != Some("alias") {
return None;
}
while i < strs.len() {
let word = strs[i];
if word == "-s" || word == "--shell" {
shell = true;
i += 1;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
if strs.get(i) != Some(&"set") {
return None;
}
i += 1;
while i < strs.len() {
let word = strs[i];
if word == "-s" || word == "--shell" {
shell = true;
i += 1;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
let name = strs.get(i)?.to_string();
i += 1;
if i >= strs.len() {
return Some((name, String::new(), shell));
}
Some((name, strs[i..].join(" "), shell))
}
fn gh_release<'a>(mut words: impl Iterator<Item = &'a str>) -> Verdict {
let verb = gh_word(&mut words);
if verb == Some("create") {
return Verdict::closed(format!("`gh release create` writes a tag; {ALLOW_RULE}"));
}
gh_read_verb("release", verb, &["view", "list", "download"])
}
fn prefix_env(segment: &str) -> Vec<(String, String)> {
let mut env = Vec::new();
for word in crate::shell_words(segment) {
if word == "gh" || word.rsplit('/').next() == Some("gh") {
break;
}
let Some((key, value)) = word.split_once('=') else {
continue;
};
if assign_key(key) && !value.contains('\0') {
env.push((key.to_string(), value.to_string()));
}
}
env
}
fn assign_key(key: &str) -> bool {
!key.is_empty()
&& !key.starts_with(|c: char| c.is_ascii_digit())
&& key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
}
fn alias_expansion(env: &[(String, String)], name: &str) -> Option<String> {
let mut cmd = Command::new("gh");
cmd.args(["alias", "list"])
.stdin(Stdio::null())
.stderr(Stdio::null());
for (key, value) in env {
cmd.env(key, value);
}
let out = cmd.output().ok()?;
if !out.status.success() {
return None;
}
let text = String::from_utf8_lossy(&out.stdout);
for line in text.lines() {
let Some((alias, exp)) = line.split_once(':') else {
continue;
};
if alias.trim() != name {
continue;
}
let exp = exp.trim();
let exp = exp
.strip_prefix('\'')
.and_then(|inner| inner.strip_suffix('\''))
.unwrap_or(exp);
return Some(exp.to_string());
}
None
}
const API_VALUED: &[&str] = &[
"-X",
"--method",
"-f",
"--raw-field",
"-F",
"--field",
"-H",
"--header",
"--hostname",
"--input",
"-q",
"--jq",
"-p",
"--preview",
"-t",
"--template",
"--cache",
];
const OPEN_MUTATIONS: &[&str] = &[
"createPullRequest",
"reopenPullRequest",
"markPullRequestReadyForReview",
];
const MERGE_MUTATIONS: &[&str] = &["mergePullRequest", "enablePullRequestAutoMerge"];
struct ApiCall {
method: Option<String>,
endpoint: String,
fields: Vec<(String, String)>,
input: Option<String>,
opaque: bool,
method_unreadable: bool,
endpoint_unreadable: bool,
}
fn gh_api<'a>(words: impl Iterator<Item = &'a str>, cwd: Option<&Path>) -> Verdict {
let Some(call) = parse_api_call(words, cwd) else {
return Verdict::closed(format!("`gh api` endpoint could not be read; {ALLOW_RULE}"));
};
if call.method_unreadable || call.endpoint_unreadable || unreadable_token(&call.endpoint) {
return Verdict::closed(format!(
"`gh api` method or endpoint could not be read; {ALLOW_RULE}"
));
}
let acts = classify_api(
&call.endpoint,
call.method.as_deref(),
&call.fields,
call.input.as_deref(),
cwd,
);
if acts.any() {
return Verdict {
acts,
saw_ref: true,
..Verdict::default()
};
}
if api_is_safe_read(&call, cwd) {
return Verdict::default();
}
if let Some(why) = classify_api_cursor_ref(
&call.endpoint,
call.method.as_deref(),
&call.fields,
call.input.as_deref(),
cwd,
call.opaque,
) {
return Verdict {
push: Some(why),
saw_ref: true,
..Verdict::default()
};
}
Verdict::closed(format!(
"`gh api` is not a GET or HEAD with no request body; {ALLOW_RULE}"
))
}
fn api_is_safe_read(call: &ApiCall, cwd: Option<&Path>) -> bool {
if call.opaque || call.input.is_some() {
return false;
}
if graphql_path(&api_path(&call.endpoint)) {
return graphql_is_query(call, cwd);
}
match call.method.as_deref() {
Some(method)
if method.eq_ignore_ascii_case("GET") || method.eq_ignore_ascii_case("HEAD") =>
{
true
}
Some(_) => false,
None => call.fields.is_empty(),
}
}
fn graphql_is_query(call: &ApiCall, cwd: Option<&Path>) -> bool {
if let Some(method) = call.method.as_deref() {
let upper = method.to_ascii_uppercase();
if upper != "GET" && upper != "HEAD" && upper != "POST" {
return false;
}
}
let (texts, opaque) = graphql_texts(&call.fields, call.input.as_deref(), cwd);
if opaque || texts.is_empty() {
return false;
}
texts
.iter()
.all(|text| !has_ident(&without_strings(text), "mutation"))
}
fn parse_api_call<'a>(words: impl Iterator<Item = &'a str>, cwd: Option<&Path>) -> Option<ApiCall> {
let mut method: Option<String> = None;
let mut endpoint: Option<String> = None;
let mut fields: Vec<(String, String)> = Vec::new();
let mut input: Option<String> = None;
let mut opaque = false;
let mut method_unreadable = false;
let mut endpoint_unreadable = false;
let mut it = words;
let note_method = |value: &str, method: &mut Option<String>, bad: &mut bool| {
if unreadable_token(value) {
*bad = true;
}
*method = Some(value.to_string());
};
while let Some(word) = it.next() {
if word == "--" {
if endpoint.is_none() {
if let Some(value) = it.next() {
if unreadable_token(value) {
endpoint_unreadable = true;
}
endpoint = Some(value.to_string());
}
}
continue;
}
if let Some(rest) = word.strip_prefix("--method=") {
note_method(rest, &mut method, &mut method_unreadable);
continue;
}
if let Some(rest) = word.strip_prefix("--raw-field=") {
fields.push(field_pair(rest));
continue;
}
if let Some(rest) = word.strip_prefix("--field=") {
match resolve_typed_field(rest, cwd) {
Ok(pair) => fields.push(pair),
Err(()) => opaque = true,
}
continue;
}
if let Some(rest) = word.strip_prefix("--input=") {
input = Some(rest.to_string());
continue;
}
if let Some(inline) = word.strip_prefix("-X") {
if !word.starts_with("--") && !inline.is_empty() {
note_method(inline, &mut method, &mut method_unreadable);
continue;
}
}
if let Some(cluster) = word.strip_prefix('-') {
if !word.starts_with("--") && !cluster.is_empty() && !API_VALUED.contains(&word) {
take_api_cluster(
cluster,
&mut it,
cwd,
&mut method,
&mut method_unreadable,
&mut fields,
&mut opaque,
);
continue;
}
}
if API_VALUED.contains(&word) {
let Some(value) = it.next() else {
continue;
};
match word {
"-X" | "--method" => note_method(value, &mut method, &mut method_unreadable),
"-f" | "--raw-field" => fields.push(field_pair(value)),
"-F" | "--field" => match resolve_typed_field(value, cwd) {
Ok(pair) => fields.push(pair),
Err(()) => opaque = true,
},
"--input" => input = Some(value.to_string()),
_ => {}
}
continue;
}
if word.starts_with('-') && word.len() > 1 {
continue;
}
if endpoint.is_none() {
if unreadable_token(word) {
endpoint_unreadable = true;
}
endpoint = Some(word.to_string());
}
}
let endpoint = endpoint?;
Some(ApiCall {
method,
endpoint,
fields,
input,
opaque,
method_unreadable,
endpoint_unreadable,
})
}
fn take_api_cluster<'a>(
cluster: &str,
it: &mut impl Iterator<Item = &'a str>,
cwd: Option<&Path>,
method: &mut Option<String>,
method_unreadable: &mut bool,
fields: &mut Vec<(String, String)>,
opaque: &mut bool,
) {
const VALUE: &str = "fFXHqpt";
const BOOL: &str = "i";
let chars: Vec<char> = cluster.chars().collect();
let mut k = 0usize;
while k < chars.len() {
let c = chars[k];
if BOOL.contains(c) {
k += 1;
continue;
}
if VALUE.contains(c) {
let attached: String = chars[k + 1..].iter().collect();
let value = if attached.is_empty() {
it.next().unwrap_or("").to_string()
} else {
attached
};
match c {
'X' => {
if unreadable_token(&value) {
*method_unreadable = true;
}
*method = Some(value);
}
'f' => fields.push(field_pair(&value)),
'F' => match resolve_typed_field(&value, cwd) {
Ok(pair) => fields.push(pair),
Err(()) => *opaque = true,
},
_ => {}
}
return;
}
k += 1;
}
}
fn resolve_typed_field(raw: &str, cwd: Option<&Path>) -> Result<(String, String), ()> {
let (key, value) = field_pair(raw);
let Some(path) = value.strip_prefix('@') else {
return Ok((key, value));
};
if path.is_empty() || path == "-" || unreadable_token(path) {
return Err(());
}
let text = read_query(cwd, path).ok_or(())?;
Ok((key, text.trim_end_matches('\n').to_string()))
}
fn field_pair(raw: &str) -> (String, String) {
match raw.split_once('=') {
Some((key, value)) => (key.to_string(), value.to_string()),
None => (raw.to_string(), String::new()),
}
}
fn classify_api(
endpoint: &str,
method: Option<&str>,
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
) -> GhActs {
let path = api_path(endpoint);
if graphql_path(&path) {
return graphql_acts(fields, input, cwd);
}
if !api_writes(method, !fields.is_empty() || input.is_some()) {
return GhActs::default();
}
rest_acts(&path)
}
fn classify_api_cursor_ref(
endpoint: &str,
method: Option<&str>,
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
opaque: bool,
) -> Option<String> {
let has_body = !fields.is_empty() || input.is_some() || opaque;
if !api_writes(method, has_body) {
return None;
}
let path = api_path(endpoint);
if graphql_path(&path) {
return graphql_cursor_ref(fields, input, cwd, opaque);
}
rest_ref_write(endpoint, &path, method, fields, input, cwd, opaque)
}
fn api_writes(method: Option<&str>, has_body: bool) -> bool {
match method {
Some(method) => matches!(
method.to_ascii_uppercase().as_str(),
"POST" | "PUT" | "PATCH" | "DELETE"
),
None => has_body,
}
}
fn api_path(endpoint: &str) -> String {
let endpoint = endpoint.split(['?', '#']).next().unwrap_or(endpoint);
let path = if let Some(rest) = endpoint
.strip_prefix("https://")
.or_else(|| endpoint.strip_prefix("http://"))
{
rest.split_once('/').map(|(_, path)| path).unwrap_or("")
} else {
endpoint
};
path.trim_matches('/').to_string()
}
fn graphql_path(path: &str) -> bool {
path == "graphql" || path.ends_with("/graphql")
}
fn rest_acts(path: &str) -> GhActs {
let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
let n = parts.len();
if n >= 3 && parts[n - 1] == "merge" && parts[n - 3] == "pulls" {
return GhActs {
open: false,
merge: true,
};
}
if parts.last() == Some(&"pulls") {
return GhActs {
open: true,
merge: false,
};
}
GhActs::default()
}
#[derive(Clone, Copy, Default, Eq, PartialEq)]
enum ForceFlag {
#[default]
Absent,
False,
True,
Unknown,
}
fn merge_force(left: ForceFlag, right: ForceFlag) -> ForceFlag {
use ForceFlag::{Absent, False, True, Unknown};
match (left, right) {
(True, _) | (_, True) => True,
(Unknown, _) | (_, Unknown) => Unknown,
(False, _) | (_, False) => False,
(Absent, Absent) => Absent,
}
}
fn force_literal(value: &str) -> ForceFlag {
let value = value.trim();
if value.eq_ignore_ascii_case("true")
|| value.eq_ignore_ascii_case("yes")
|| value.eq_ignore_ascii_case("on")
|| value == "1"
{
ForceFlag::True
} else if value.eq_ignore_ascii_case("false")
|| value.eq_ignore_ascii_case("no")
|| value.eq_ignore_ascii_case("off")
|| value == "0"
{
ForceFlag::False
} else if value.is_empty() {
ForceFlag::Absent
} else {
ForceFlag::Unknown
}
}
#[derive(Default)]
struct RefBody {
refs: Vec<String>,
force: ForceFlag,
opaque: bool,
}
fn collect_ref_body_from_json(value: &Value, body: &mut RefBody) {
match value {
Value::Object(map) => {
for (key, value) in map {
if key == "ref" {
if let Some(name) = value.as_str() {
body.refs.push(name.to_string());
} else {
body.opaque = true;
}
} else if key == "force" {
let next = match value {
Value::Bool(v) => {
if *v {
ForceFlag::True
} else {
ForceFlag::False
}
}
Value::String(v) => force_literal(v),
_ => ForceFlag::Unknown,
};
body.force = merge_force(body.force, next);
}
collect_ref_body_from_json(value, body);
}
}
Value::Array(items) => {
for item in items {
collect_ref_body_from_json(item, body);
}
}
_ => {}
}
}
fn read_ref_body(fields: &[(String, String)], input: Option<&str>, cwd: Option<&Path>) -> RefBody {
let mut body = RefBody::default();
for (key, value) in fields {
if key == "ref" {
body.refs.push(value.clone());
} else if key == "force" {
body.force = merge_force(body.force, force_literal(value));
}
}
let Some(path) = input else {
return body;
};
if path == "-" {
body.opaque = true;
return body;
}
let Some(text) = read_query(cwd, path) else {
body.opaque = true;
return body;
};
match serde_json::from_str::<Value>(&text) {
Ok(value) => collect_ref_body_from_json(&value, &mut body),
Err(_) => body.opaque = true,
}
body
}
fn git_refs_tail(path: &str) -> Option<Vec<&str>> {
let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
let at = parts
.windows(2)
.position(|window| window[0] == "git" && window[1] == "refs")?;
Some(parts[at + 2..].to_vec())
}
fn rest_ref_write(
endpoint: &str,
path: &str,
method: Option<&str>,
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
opaque: bool,
) -> Option<String> {
let has_body = !fields.is_empty() || input.is_some() || opaque;
let method = method.map(|m| m.to_ascii_uppercase()).unwrap_or_else(|| {
if has_body {
"POST".into()
} else {
"GET".into()
}
});
if let Some(why) = rest_other_ref(endpoint, path, &method, fields, input, cwd, opaque) {
return Some(why);
}
let tail = git_refs_tail(path)?;
let mut body = read_ref_body(fields, input, cwd);
if opaque {
body.opaque = true;
}
match method.as_str() {
"DELETE" => Some("`gh api` can delete refs through /git/refs".into()),
"POST" if tail.is_empty() => {
if body.opaque || body.refs.is_empty() {
return Some(format!(
"`gh api` can create refs through /git/refs and the destination could not be read; {ALLOW_RULE}"
));
}
body.refs
.into_iter()
.find_map(|name| match branch_permitted(&name) {
Err(why) => Some(why),
Ok(false) => Some(format!("`gh api` can create {name} through /git/refs")),
Ok(true) => None,
})
}
"PATCH" if !tail.is_empty() => {
if body.opaque {
return Some(format!(
"`gh api` can force-update refs through /git/refs with an unread request body; {ALLOW_RULE}"
));
}
match body.force {
ForceFlag::True => Some("`gh api` can force-update refs through /git/refs".into()),
ForceFlag::Unknown => Some(
"`gh api` can force-update refs through /git/refs with an unread force value"
.into(),
),
_ => {
let name = tail.join("/");
match branch_permitted(&name) {
Err(why) => Some(why),
Ok(false) => Some(format!(
"`gh api` can update {name} through /git/refs; {ALLOW_RULE}"
)),
Ok(true) => None,
}
}
}
}
_ => None,
}
}
fn graphql_acts(fields: &[(String, String)], input: Option<&str>, cwd: Option<&Path>) -> GhActs {
let (texts, opaque) = graphql_texts(fields, input, cwd);
let mut acts = GhActs::default();
for text in &texts {
acts = acts.either(mutation_acts(text));
}
if opaque && !acts.any() {
acts.open = true;
acts.merge = true;
}
acts
}
fn graphql_texts(
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
) -> (Vec<String>, bool) {
let mut texts = Vec::new();
let mut opaque = false;
for (key, value) in fields {
if key != "query" {
continue;
}
match value.strip_prefix('@') {
Some("-" | "") => opaque = true,
Some(path) => match read_query(cwd, path) {
Some(body) => texts.push(body),
None => opaque = true,
},
None => texts.push(value.clone()),
}
}
if let Some(path) = input {
if path == "-" {
opaque = true;
} else {
match read_query(cwd, path) {
Some(body) => texts.push(body),
None => opaque = true,
}
}
}
(texts, opaque)
}
fn quoted_strings(text: &str) -> Vec<String> {
let mut out = Vec::new();
let chars: Vec<char> = text.chars().collect();
let mut i = 0usize;
while i < chars.len() {
if chars[i] != '"' {
i += 1;
continue;
}
i += 1;
let mut value = String::new();
while i < chars.len() {
let c = chars[i];
if c == '\\' {
i += 1;
if i < chars.len() {
value.push(chars[i]);
i += 1;
}
continue;
}
if c == '"' {
i += 1;
break;
}
value.push(c);
i += 1;
}
out.push(value);
}
out
}
fn force_in_text(text: &str) -> ForceFlag {
let mut force = ForceFlag::Absent;
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut at = 0usize;
while let Some(found) = text[at..].find("force") {
let start = at + found;
let before = text[..start].chars().next_back();
let end = start + "force".len();
let after = text[end..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
at = end;
continue;
}
let mut rest = text[end..].chars().peekable();
while rest.peek().is_some_and(|c| c.is_whitespace()) {
rest.next();
}
if rest.peek() != Some(&':') {
at = end;
continue;
}
rest.next();
while rest.peek().is_some_and(|c| c.is_whitespace()) {
rest.next();
}
if rest.peek() == Some(&'$') {
force = merge_force(force, ForceFlag::Unknown);
at = end;
continue;
}
let mut literal = String::new();
while rest
.peek()
.is_some_and(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-')
{
literal.push(rest.next().unwrap_or_default());
}
let next = force_literal(&literal);
force = merge_force(force, next);
at = end;
}
force
}
fn rest_other_ref(
endpoint: &str,
path: &str,
method: &str,
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
opaque: bool,
) -> Option<String> {
let parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect();
let ends = |name: &str| parts.last() == Some(&name);
if method == "POST" && ends("releases") {
return Some(format!("`gh api` can create a release tag; {ALLOW_RULE}"));
}
if method == "POST" && ends("rename") && parts.contains(&"branches") {
return Some(format!(
"`gh api` can rename a branch, which deletes the old ref; {ALLOW_RULE}"
));
}
if method == "POST" && ends("merges") {
return named_branch_field(fields, input, cwd, opaque, "base", "`gh api` merge");
}
if method == "PUT" && parts.contains(&"contents") {
if let Some(branch) =
query_param(endpoint, "branch").or_else(|| query_param(endpoint, "ref"))
{
return judge_named_branch(&branch, "`gh api` contents");
}
return named_branch_field(fields, input, cwd, opaque, "branch", "`gh api` contents");
}
None
}
fn named_branch_field(
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
opaque: bool,
key: &str,
what: &str,
) -> Option<String> {
match body_string(fields, input, cwd, key) {
Some(name) if !unreadable_token(&name) => judge_named_branch(&name, what),
_ if opaque => Some(format!(
"{what} does not show the branch it writes; {ALLOW_RULE}"
)),
Some(name) => Some(format!(
"{what} names `{name}`, which is only known once the shell expands it; {ALLOW_RULE}"
)),
None => Some(format!(
"{what} does not show the branch it writes; {ALLOW_RULE}"
)),
}
}
fn judge_named_branch(name: &str, what: &str) -> Option<String> {
match branch_permitted(name) {
Err(why) => Some(why),
Ok(true) => None,
Ok(false) => Some(format!("{what} updates {name}; {ALLOW_RULE}")),
}
}
fn body_string(
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
key: &str,
) -> Option<String> {
if let Some((_, value)) = fields.iter().rev().find(|(k, _)| k == key) {
if !value.is_empty() {
return Some(value.clone());
}
}
let path = input?;
if path == "-" {
return None;
}
let text = read_query(cwd, path)?;
let value: Value = serde_json::from_str(&text).ok()?;
value.get(key).and_then(Value::as_str).map(str::to_string)
}
fn query_param(endpoint: &str, key: &str) -> Option<String> {
let query = endpoint.split_once('?')?.1.split('#').next()?;
for pair in query.split('&') {
let (name, value) = pair.split_once('=')?;
if name == key && !value.is_empty() {
return Some(value.to_string());
}
}
None
}
fn graphql_cursor_ref(
fields: &[(String, String)],
input: Option<&str>,
cwd: Option<&Path>,
extra_opaque: bool,
) -> Option<String> {
let (texts, opaque) = graphql_texts(fields, input, cwd);
if extra_opaque || (opaque && texts.is_empty()) {
return Some(format!(
"`gh api graphql` body could write refs and could not be read; {ALLOW_RULE}"
));
}
for text in &texts {
let code = without_strings(text);
if has_ident(&code, "deleteRef") {
return Some("`gh api graphql` can delete refs with deleteRef".into());
}
if has_ident(&code, "createRef") {
if let Some(why) = graphql_destinations(text, "name", "createRef") {
return Some(why);
}
}
if has_ident(&code, "createCommitOnBranch") {
if let Some(why) = graphql_destinations(text, "branchName", "createCommitOnBranch") {
return Some(why);
}
}
if has_ident(&code, "mergeBranch") {
if let Some(why) = graphql_destinations(text, "base", "mergeBranch") {
return Some(why);
}
}
if has_ident(&code, "updateRef") || has_ident(&code, "updateRefs") {
match force_in_text(&code) {
ForceFlag::True => {
return Some("`gh api graphql` can force-update refs with force: true".into());
}
ForceFlag::Unknown => {
return Some(
"`gh api graphql` can force-update refs with an unread force value".into(),
);
}
_ => {}
}
if !has_literal_oid(&code) {
return Some(format!(
"`gh api graphql` updateRefs shows no oid, so the ref it writes cannot be read; {ALLOW_RULE}"
));
}
if let Some(why) = graphql_destinations(text, "name", "updateRef") {
return Some(why);
}
}
}
if opaque {
return Some(format!(
"`gh api graphql` body could write refs and could not be read; {ALLOW_RULE}"
));
}
None
}
fn graphql_destinations(text: &str, key: &str, what: &str) -> Option<String> {
let fields = string_fields(text, key);
if fields.is_empty() {
return Some(format!(
"`gh api graphql` {what} does not name a branch; {ALLOW_RULE}"
));
}
for field in fields {
match field {
FieldRead::Unknown => {
return Some(format!(
"`gh api graphql` {what} names a branch the line does not show; {ALLOW_RULE}"
));
}
FieldRead::Value(name) => match branch_permitted(&name) {
Err(why) => return Some(why),
Ok(false) => {
return Some(format!(
"`gh api graphql` {what} updates {name}; {ALLOW_RULE}"
));
}
Ok(true) => {}
},
}
}
None
}
enum FieldRead {
Unknown,
Value(String),
}
fn string_fields(text: &str, key: &str) -> Vec<FieldRead> {
let mut out = Vec::new();
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut at = 0usize;
while let Some(found) = text[at..].find(key) {
let start = at + found;
let before = text[..start].chars().next_back();
let end = start + key.len();
let after = text[end..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
at = end;
continue;
}
let rest = text[end..].trim_start();
if !rest.starts_with(':') {
at = end;
continue;
}
let rest = rest[1..].trim_start();
if rest.starts_with('$') || rest.starts_with('`') {
out.push(FieldRead::Unknown);
} else if rest.starts_with('"') {
let value = quoted_strings(rest).into_iter().next().unwrap_or_default();
out.push(FieldRead::Value(value));
} else {
out.push(FieldRead::Unknown);
}
at = end;
}
out
}
fn without_strings(text: &str) -> String {
let chars: Vec<char> = text.chars().collect();
let mut out = String::new();
let mut i = 0usize;
while i < chars.len() {
if chars[i] != '"' {
out.push(chars[i]);
i += 1;
continue;
}
out.push('"');
out.push('"');
i += 1;
while i < chars.len() {
if chars[i] == '\\' {
i += 1;
if i < chars.len() {
i += 1;
}
continue;
}
if chars[i] == '"' {
i += 1;
break;
}
i += 1;
}
}
out
}
fn has_literal_oid(text: &str) -> bool {
let ident = |c: char| c.is_ascii_alphanumeric() || c == '_';
let mut at = 0usize;
while let Some(found) = text[at..].find("oid") {
let start = at + found;
let before = text[..start].chars().next_back();
let end = start + "oid".len();
let after = text[end..].chars().next();
if before.is_some_and(ident) || after.is_some_and(ident) {
at = end;
continue;
}
let rest = text[end..].trim_start();
if let Some(rest) = rest.strip_prefix(':') {
if rest.trim_start().starts_with('"') {
return true;
}
}
at = end;
}
false
}
fn read_query(cwd: Option<&Path>, path: &str) -> Option<String> {
if path.is_empty() || path.contains('\0') {
return None;
}
let path = Path::new(path);
let full = if path.is_absolute() {
path.to_path_buf()
} else {
let base = cwd
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())?;
base.join(path)
};
std::fs::read_to_string(full).ok()
}
fn mutation_acts(text: &str) -> GhActs {
let code = without_strings(text);
GhActs {
open: OPEN_MUTATIONS.iter().any(|name| has_ident(&code, name)),
merge: MERGE_MUTATIONS.iter().any(|name| has_ident(&code, name)),
}
}
fn has_ident(hay: &str, ident: &str) -> bool {
hay.match_indices(ident).any(|(at, _)| {
let before = hay[..at].chars().next_back();
let after = hay[at + ident.len()..].chars().next();
let ident_char = |c: char| c.is_ascii_alphanumeric() || c == '_';
!before.is_some_and(ident_char) && !after.is_some_and(ident_char)
})
}
fn gh_word<'a>(words: &mut impl Iterator<Item = &'a str>) -> Option<&'a str> {
while let Some(word) = words.next() {
if word == "-R" || word == "--repo" {
words.next();
} else if !(word.starts_with('-') && word.len() > 1) {
return Some(word);
}
}
None
}
fn argv(segment: &str) -> Vec<String> {
let mut out = Vec::new();
let mut target = false;
for word in crate::plain_words(segment) {
if std::mem::take(&mut target) {
continue;
}
if word.starts_with('>') || word.starts_with('<') {
target = word == ">" || word.chars().all(|c| c == '<');
continue;
}
out.push(word);
}
out
}
enum GitOutcome {
Push(Box<Push>),
Shell(String),
}
enum Expanded {
Argv(Vec<String>),
Shell(String),
}
fn moved(here: Option<PathBuf>, args: &[String]) -> Option<PathBuf> {
let home = || crate::home().ok();
let Some(to) = args.iter().find(|a| !a.starts_with('-') || *a == "-") else {
return home();
};
if to == "-" {
return None;
}
if to.contains(['$', '`']) {
return toplevel_cd(here, to);
}
if to == "~" {
return home();
}
if let Some(rest) = to.strip_prefix("~/") {
return home().map(|h| h.join(rest));
}
if to.starts_with('~') {
return None;
}
Some(here?.join(to))
}
fn toplevel_cd(here: Option<PathBuf>, to: &str) -> Option<PathBuf> {
if to != "$(git rev-parse --show-toplevel)" && to != "`git rev-parse --show-toplevel`" {
return None;
}
let here = here?;
let out = Command::new("git")
.current_dir(here)
.args(["rev-parse", "--show-toplevel"])
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()?;
if !out.status.success() {
return None;
}
let path = String::from_utf8(out.stdout).ok()?;
let path = path.trim();
(!path.is_empty()).then(|| PathBuf::from(path))
}
#[derive(Default)]
struct PrefixContext {
dir: Option<PathBuf>,
env: Vec<(String, String)>,
via_xargs: bool,
}
fn assignment(word: &str) -> Option<(&str, &str)> {
let (name, value) = word.split_once('=')?;
let valid = !name.is_empty()
&& !name.starts_with(|c: char| c.is_ascii_digit())
&& name.chars().all(|c| c.is_ascii_alphanumeric() || c == '_');
valid.then_some((name, value))
}
fn prefix_context(segment: &str, here: Option<PathBuf>) -> PrefixContext {
let words = crate::shell_words(segment);
let mut ctx = PrefixContext {
dir: here,
..PrefixContext::default()
};
let mut i = 0;
while let Some(word) = words.get(i).map(String::as_str) {
if let Some((name, value)) = assignment(word) {
ctx.env.push((name.to_string(), value.to_string()));
i += 1;
continue;
}
if word == "xargs" {
ctx.via_xargs = true;
break;
}
if word != "env" {
break;
}
i += 1;
while let Some(arg) = words.get(i).map(String::as_str) {
if arg == "--" {
i += 1;
break;
}
if arg == "-C" || arg == "--chdir" {
let next = words.get(i + 1).cloned();
ctx.dir = next.and_then(|value| moved(ctx.dir, &[value]));
i += 2;
continue;
}
if let Some(path) = arg.strip_prefix("--chdir=") {
ctx.dir = moved(ctx.dir, &[path.to_string()]);
i += 1;
continue;
}
if matches!(arg, "-u" | "--unset") {
i += 2;
continue;
}
if let Some((name, value)) = assignment(arg) {
ctx.env.push((name.to_string(), value.to_string()));
i += 1;
continue;
}
if arg.starts_with('-') && arg.len() > 1 {
i += 1;
continue;
}
break;
}
}
ctx
}
fn git_push(
args: &[String],
dir: Option<PathBuf>,
line_aliases: &[(String, String)],
env: &[(String, String)],
via_xargs: bool,
) -> Option<GitOutcome> {
let mut at = Checkout {
dir,
env: env.to_vec(),
..Checkout::default()
};
let mut i = 0;
while let Some(word) = args.get(i).map(String::as_str) {
if GIT_VALUED.contains(&word) {
let value = args.get(i + 1)?;
match word {
"-c" => {
at.config.extend(["-c".to_string(), value.clone()]);
}
"--config-env" => at.config.extend([word.to_string(), value.clone()]),
"-C" | "--git-dir" | "--work-tree" => {
at.place.extend([word.to_string(), value.clone()]);
}
_ => {}
}
i += 2;
} else if word.starts_with("--config-env=") {
at.config.push(word.to_string());
i += 1;
} else if word.starts_with("--git-dir=") || word.starts_with("--work-tree=") {
at.place.push(word.to_string());
i += 1;
} else if word.starts_with('-') {
i += 1;
} else {
break;
}
}
let sub = args.get(i)?;
let mut words: Vec<String> = vec![sub.clone()];
words.extend(args[i + 1..].iter().cloned());
words = match at.expand_aliases(words, line_aliases) {
Ok(Expanded::Argv(words)) => words,
Ok(Expanded::Shell(script)) => return Some(GitOutcome::Shell(script)),
Err(why) => return Some(GitOutcome::Push(Box::new(Push::refuse(at, why)))),
};
let sub = words.first().map(String::as_str)?;
if unresolved_subcommand(sub) {
return Some(GitOutcome::Push(Box::new(Push::refuse(
at,
format!("`{sub}` is only known once the shell expands it; name the git subcommand"),
))));
}
match sub {
"push" => {
if via_xargs {
return Some(GitOutcome::Push(Box::new(Push::refuse(
at,
"xargs fills git push arguments at run time; name each refspec directly"
.to_string(),
))));
}
Some(GitOutcome::Push(Box::new(Push::read(&words[1..], at))))
}
"send-pack" => Some(GitOutcome::Push(Box::new(send_pack_push(&words, at)))),
"subtree" => subtree_push_cursor_ref(&words, &at)
.map(|why| GitOutcome::Push(Box::new(Push::refuse(at, why)))),
"submodule" => submodule_foreach_cursor_ref(&words, &at)
.map(|why| GitOutcome::Push(Box::new(Push::refuse(at, why)))),
_ => None,
}
}
fn config_affects_push(args: &[String]) -> bool {
let mut i = 0;
while let Some(word) = args.get(i).map(String::as_str) {
if GIT_VALUED.contains(&word) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
break;
}
if args.get(i).map(String::as_str) != Some("config") {
return false;
}
i += 1;
let mut read = false;
let mut positional: Vec<&str> = Vec::new();
while let Some(word) = args.get(i).map(String::as_str) {
if matches!(
word,
"--get"
| "--get-all"
| "--get-regexp"
| "--get-urlmatch"
| "--list"
| "-l"
| "--show-origin"
| "--show-scope"
| "--get-color"
) {
read = true;
i += 1;
continue;
}
if matches!(
word,
"--file" | "-f" | "--blob" | "--default" | "--comment" | "--type"
) {
i += 2;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
positional.push(word);
i += 1;
}
if read || positional.is_empty() {
return false;
}
let key = positional[0];
let key = key.split_once('=').map(|(key, _)| key).unwrap_or(key);
if unreadable_token(key) {
return true;
}
affects_push_key(key)
}
fn affects_push_key(key: &str) -> bool {
if key == "push.default" || key == "remote.pushdefault" {
return true;
}
let Some((head, tail)) = key.split_once('.') else {
return false;
};
match head {
"remote" => tail.ends_with(".push") || tail.ends_with(".mirror"),
"branch" => {
tail.ends_with(".merge")
|| tail.ends_with(".remote")
|| tail.ends_with(".pushRemote")
|| tail.ends_with(".pushremote")
}
_ => false,
}
}
fn git_alias_write(args: &[String]) -> Option<(String, String)> {
let mut i = 0;
while let Some(word) = args.get(i).map(String::as_str) {
if GIT_VALUED.contains(&word) {
i += 2;
} else if word.starts_with('-') {
i += 1;
} else {
break;
}
}
if args.get(i).map(String::as_str) != Some("config") {
return None;
}
let mut key_at = i + 1;
while args
.get(key_at)
.is_some_and(|word| word.starts_with('-') && word.len() > 1)
{
key_at += 1;
}
let key = args.get(key_at)?;
let (name, value) = match key.split_once('=') {
Some((name, value)) => (name, value.to_string()),
None => (key.as_str(), args.get(key_at + 1)?.clone()),
};
let name = name.strip_prefix("alias.")?;
Some((name.to_string(), value))
}
fn unresolved_subcommand(sub: &str) -> bool {
sub.contains(['$', '`', '(', ')', '{', '}', '[', ']', '*', '?'])
}
fn send_pack_push(words: &[String], at: Checkout) -> Push {
let mut remote: Option<String> = None;
let mut specs: Vec<String> = Vec::new();
let mut bulk: Vec<String> = Vec::new();
let mut dry = false;
let mut force = false;
let mut stdin = false;
let mut i = 1usize;
while i < words.len() {
let word = words[i].as_str();
if word == "--" {
let rest = &words[i + 1..];
if remote.is_none() {
if let Some(name) = rest.first() {
remote = Some(name.clone());
specs.extend(rest.iter().skip(1).cloned());
}
} else {
specs.extend(rest.iter().cloned());
}
break;
}
if matches!(word, "--receive-pack" | "--exec") {
i += 2;
continue;
}
if word.starts_with("--receive-pack=") || word.starts_with("--exec=") {
i += 1;
continue;
}
if matches!(
word,
"--all" | "--branches" | "--mirror" | "--tags" | "--stdin" | "--dry-run"
) || is_force_flag(word)
{
match word {
"--all" | "--branches" | "--mirror" | "--tags" => bulk.push(word.to_string()),
"--stdin" => stdin = true,
"--dry-run" => dry = true,
_ => force = true,
}
i += 1;
continue;
}
if word.starts_with('-') && word.len() > 1 {
i += 1;
continue;
}
if remote.is_none() {
remote = Some(word.to_string());
} else {
specs.push(word.to_string());
}
i += 1;
}
if stdin {
return Push::refuse(
at,
format!("git send-pack --stdin reads refspecs the line does not show; {ALLOW_RULE}"),
);
}
let mut pushed = Vec::new();
let repo_why = remote.as_deref().map(|name| send_pack_repo_why(&at, name));
if let Some(name) = remote {
pushed.push(name);
}
pushed.extend(specs);
let mut push = Push {
reason: None,
repo_why: None,
at,
words: pushed,
repo: None,
bulk,
prune: false,
dry_run: dry,
force,
delete: false,
};
if let Some(Err(why)) = repo_why {
push.repo_why = Some(why);
}
push
}
fn send_pack_repo_why(at: &Checkout, remote: &str) -> Result<(), String> {
let url = if remote_is_explicit(remote) {
remote.to_string()
} else {
let out = at.git(&[], &["remote", "get-url", remote]).ok_or_else(|| {
format!("git send-pack destination {remote} could not be read; {ALLOW_RULE}")
})?;
if !out.status.success() {
return Err(format!(
"git send-pack destination {remote} could not be read; {ALLOW_RULE}"
));
}
let url = String::from_utf8_lossy(&out.stdout).trim().to_string();
if url.is_empty() {
return Err(format!(
"git send-pack destination {remote} could not be read; {ALLOW_RULE}"
));
}
url
};
match crate::cloud_hook::repo_on_allowlist(&url) {
Ok(true) => Ok(()),
Ok(false) => Err(format!(
"git send-pack destination is not a repository this seat may push; {ALLOW_RULE}"
)),
Err(why) => Err(format!("git send-pack {why}; {ALLOW_RULE}")),
}
}
fn remote_is_explicit(remote: &str) -> bool {
remote.contains("://")
|| remote.starts_with('/')
|| remote.starts_with('.')
|| remote.starts_with('~')
|| remote.contains(':')
}
fn subtree_push_cursor_ref(words: &[String], at: &Checkout) -> Option<String> {
if words.first().map(String::as_str) != Some("subtree")
|| words.get(1).map(String::as_str) != Some("push")
{
return None;
}
let mut positional: Vec<&str> = Vec::new();
let mut i = 2;
while let Some(word) = words.get(i).map(String::as_str) {
if word == "--" {
positional.extend(words[i + 1..].iter().map(String::as_str));
break;
}
if matches!(word, "--prefix" | "-P") {
i += 2;
continue;
}
if word.starts_with("--prefix=") || (word.starts_with('-') && word.len() > 1) {
i += 1;
continue;
}
positional.push(word);
i += 1;
}
let remote = positional.first().copied()?;
let spec = positional.get(1).copied()?;
at.spec(spec, Some(remote))
}
fn submodule_foreach_cursor_ref(words: &[String], at: &Checkout) -> Option<String> {
if words.first().map(String::as_str) != Some("submodule")
|| words.get(1).map(String::as_str) != Some("foreach")
{
return None;
}
let mut i = 2;
while words
.get(i)
.is_some_and(|word| word.starts_with('-') && word.len() > 1)
{
i += 1;
}
let script = words.get(i..)?.join(" ");
let why = cursor_push(&script, at.dir.as_deref())?;
Some(format!(
"`git submodule foreach` runs a command where {why}"
))
}
struct Push {
reason: Option<String>,
repo_why: Option<String>,
at: Checkout,
words: Vec<String>,
repo: Option<String>,
bulk: Vec<String>,
prune: bool,
dry_run: bool,
force: bool,
delete: bool,
}
impl Push {
fn read(args: &[String], at: Checkout) -> Self {
let mut push = Push {
reason: None,
repo_why: None,
at,
words: Vec::new(),
repo: None,
bulk: Vec::new(),
prune: false,
dry_run: false,
force: false,
delete: false,
};
let mut it = args.iter();
while let Some(word) = it.next() {
let word = word.as_str();
if word == "--" {
push.words.extend(it.by_ref().cloned());
break;
}
if word == "--repo" {
push.repo = it.next().cloned();
} else if let Some(value) = word.strip_prefix("--repo=") {
push.repo = Some(value.to_string());
} else if word == "--prune" {
push.prune = true;
} else if word == "--dry-run" || short_dry_run(word) {
push.dry_run = true;
} else if word == "--delete" || short_delete(word) {
push.delete = true;
} else if is_force_flag(word) {
push.force = true;
} else if crate::PUSH_VALUED.contains(&word) || short_cluster_takes_value(word) {
it.next();
} else if matches!(
word,
"--all" | "--branches" | "--mirror" | "--tags" | "--follow-tags"
) {
push.bulk.push(word.to_string());
} else if !(word.starts_with('-') && word.len() > 1) {
push.words.push(word.to_string());
}
}
push
}
fn refuse(at: Checkout, reason: String) -> Self {
Self {
reason: Some(reason),
repo_why: None,
at,
words: Vec::new(),
repo: None,
bulk: Vec::new(),
prune: false,
dry_run: false,
force: false,
delete: false,
}
}
fn cursor_ref(&self) -> Option<String> {
if self.dry_run {
return None;
}
if let Some(why) = &self.repo_why {
return Some(why.clone());
}
if let Some(reason) = &self.reason {
return Some(reason.clone());
}
if self.delete {
return Some("this push deletes a ref; a cloud seat may not delete a ref".to_string());
}
if self.prune {
return Some(
"`--prune` can delete remote refs; a cloud seat may not delete a ref".to_string(),
);
}
let follow_tags = self.bulk.iter().any(|f| f == "--follow-tags");
for flag in &self.bulk {
if flag == "--follow-tags" {
continue;
}
let under: &[&str] = match flag.as_str() {
"--all" | "--branches" => &["refs/heads/"],
"--mirror" => &["refs/heads/", "refs/tags/"],
_ => &["refs/tags/"],
};
if let Some(why) = self.at.local(under, &format!("`{flag}`")) {
return Some(why);
}
}
if self.bulk.iter().any(|f| f == "--mirror") {
return Some(
"`--mirror` can delete remote branches and tags; a cloud seat may not delete a ref"
.to_string(),
);
}
let (remote, specs) = if self.repo.is_some() {
(self.words.first().or(self.repo.as_ref()), &self.words[..])
} else {
(self.words.first(), self.words.get(1..).unwrap_or(&[]))
};
let mut words = specs.iter();
while let Some(word) = words.next() {
let spec = match (word.as_str(), words.clone().next()) {
("tag", Some(name)) => {
words.next();
format!("refs/tags/{name}")
}
_ => word.clone(),
};
if let Some(why) = self.at.spec(&spec, remote.map(String::as_str)) {
return Some(why);
}
}
let covers_heads = self
.bulk
.iter()
.any(|f| matches!(f.as_str(), "--all" | "--branches" | "--mirror"));
let tags_only = self.bulk.iter().any(|f| f == "--tags") && !covers_heads && !follow_tags;
if self.words.len() <= 1 && !covers_heads && !tags_only {
if let Some(why) = self.at.implicit(remote.map(String::as_str)) {
return Some(why);
}
}
if follow_tags {
if let Some(why) = self.at.local(&["refs/tags/"], "`--follow-tags`") {
return Some(why);
}
}
if self.force {
return Some(format!("a cloud seat may not force a ref; {ALLOW_RULE}"));
}
None
}
}
fn short_cluster(word: &str) -> Option<&str> {
let flags = word.strip_prefix('-')?;
if flags.is_empty() || flags.starts_with('-') {
return None;
}
flags
.chars()
.all(|c| c.is_ascii_alphabetic())
.then_some(flags)
}
fn short_dry_run(word: &str) -> bool {
short_cluster(word).is_some_and(|flags| flags.contains('n'))
}
fn short_delete(word: &str) -> bool {
short_cluster(word).is_some_and(|flags| flags.contains('d'))
}
fn is_force_flag(word: &str) -> bool {
word == "--force"
|| word == "--force-with-lease"
|| word.starts_with("--force-with-lease=")
|| word == "--force-if-includes"
|| short_cluster(word).is_some_and(|flags| flags.contains('f'))
}
fn short_cluster_takes_value(word: &str) -> bool {
word.strip_prefix('-').is_some_and(|flags| {
flags.len() > 1 && flags.chars().all(|c| c.is_ascii_alphabetic()) && flags.ends_with('o')
})
}
fn refspec_sides(spec: &str) -> Option<(&str, &str)> {
let mut paren = 0i32;
let mut brace = 0i32;
let mut tick = false;
let mut colon = None;
let mut chars = spec.char_indices().peekable();
while let Some((i, c)) = chars.next() {
match c {
'`' => tick = !tick,
'$' if !tick => match chars.peek().map(|(_, n)| *n) {
Some('(') => {
paren += 1;
chars.next();
}
Some('{') => {
brace += 1;
chars.next();
}
_ => {}
},
')' if !tick && paren > 0 => paren -= 1,
'}' if !tick && brace > 0 => brace -= 1,
':' if !tick && paren == 0 && brace == 0 => colon = Some(i),
_ => {}
}
}
let at = colon?;
Some((&spec[..at], &spec[at + 1..]))
}
fn local_ref_allowed(allow: &HashSet<String>, full: &str) -> bool {
full.strip_prefix("refs/heads/")
.is_some_and(|name| allow.contains(name))
}
fn land(src: &str, dst: &str, name: &str) -> Option<String> {
let (src_head, src_tail) = src.split_once('*')?;
let (dst_head, dst_tail) = dst.split_once('*')?;
let middle = name.strip_prefix(src_head)?.strip_suffix(src_tail)?;
Some(format!("{dst_head}{middle}{dst_tail}"))
}
#[derive(Default)]
struct Checkout {
dir: Option<PathBuf>,
env: Vec<(String, String)>,
place: Vec<String>,
config: Vec<String>,
head: OnceCell<Option<Option<String>>>,
settings: OnceCell<Option<Vec<(String, String)>>>,
refs: OnceCell<Option<Vec<String>>>,
}
impl Checkout {
fn git(&self, extra: &[String], args: &[&str]) -> Option<Output> {
Command::new("git")
.current_dir(self.dir.as_ref()?)
.envs(self.env.iter().map(|(k, v)| (k, v)))
.args(&self.place)
.args(extra)
.args(args)
.stdin(Stdio::null())
.stderr(Stdio::null())
.output()
.ok()
}
fn read(&self, extra: &[String], args: &[&str]) -> Option<String> {
let out = self.git(extra, args)?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).into_owned())
}
fn branch(&self) -> Option<Option<&str>> {
self.head
.get_or_init(|| {
let out = self.git(&[], &["symbolic-ref", "-q", "HEAD"])?;
match out.status.code() {
Some(0) => Some(
String::from_utf8_lossy(&out.stdout)
.trim()
.strip_prefix("refs/heads/")
.map(str::to_string),
),
Some(1) => Some(None),
_ => None,
}
})
.as_ref()
.map(Option::as_deref)
}
fn settings(&self) -> Option<&[(String, String)]> {
self.settings
.get_or_init(|| {
let text = self.read(&self.config, &["config", "-z", "--list"])?;
Some(
text.split('\0')
.filter(|entry| !entry.is_empty())
.map(|entry| match entry.split_once('\n') {
Some((key, value)) => (key.to_string(), value.to_string()),
None => (entry.to_string(), "true".to_string()),
})
.collect(),
)
})
.as_deref()
}
fn alias_setting(&self, sub: &str) -> Option<String> {
let key = format!("alias.{sub}");
self.settings()?
.iter()
.rev()
.find(|(k, _)| *k == key)
.map(|(_, v)| v.clone())
}
fn expand_aliases(
&self,
words: Vec<String>,
line_aliases: &[(String, String)],
) -> Result<Expanded, String> {
let mut words = words;
let mut seen = HashSet::new();
while let Some(sub) = words.first().cloned() {
if !seen.insert(sub.clone()) {
break;
}
let value = line_aliases
.iter()
.rev()
.find(|(name, _)| *name == sub)
.map(|(_, value)| value.clone())
.or_else(|| self.alias_setting(&sub));
let Some(value) = value else { break };
if let Some(script) = value.strip_prefix('!') {
let mut line = script.trim().to_string();
let extra: Vec<String> = words.iter().skip(1).cloned().collect();
if !extra.is_empty() {
if !line.is_empty() {
line.push(' ');
}
line.push_str(&extra.join(" "));
}
return Ok(Expanded::Shell(line));
}
let mut expanded: Vec<String> = value.split_whitespace().map(str::to_string).collect();
if expanded.is_empty() {
break;
}
expanded.extend(words.into_iter().skip(1));
words = expanded;
}
Ok(Expanded::Argv(words))
}
fn refs(&self) -> Option<&[String]> {
self.refs
.get_or_init(|| {
let text = self.read(&[], &["for-each-ref", "--format=%(refname)"])?;
Some(text.lines().map(str::to_string).collect())
})
.as_deref()
}
fn local(&self, under: &[&str], what: &str) -> Option<String> {
let Some(refs) = self.refs() else {
return Some(UNREAD.into());
};
let allow = match seat_allowlist() {
Ok(allow) => allow,
Err(why) => return Some(why),
};
refs.iter()
.find(|r| under.iter().any(|p| r.starts_with(p)) && !local_ref_allowed(&allow, r))
.map(|r| format!("{what} pushes {r}"))
}
fn spec(&self, spec: &str, remote: Option<&str>) -> Option<String> {
let forced = spec.starts_with('+');
let spec = spec.strip_prefix('+').unwrap_or(spec);
if spec == ":" {
return Some(format!("a matching push can delete refs; {ALLOW_RULE}"));
}
let (src, dst, bare) = match refspec_sides(spec) {
Some((src, dst)) => (src, dst, false),
None => (spec, spec, true),
};
if src.is_empty() {
return Some(format!(
"this push deletes {dst}; a cloud seat may not delete a ref"
));
}
if bare {
if let Some(landed) = self.map_bare_destination(src, remote) {
if landed != dst {
if dest_refused(&landed).is_some() {
return Some(format!("`{spec}` pushes {src} to {landed}"));
}
return forced.then(|| {
format!("this push updates {landed}; a cloud seat may not force a ref")
});
}
}
}
if CURRENT_BRANCH.contains(&dst) || (bare && matches!(dst, "HEAD" | "@")) {
return match self.branch() {
None => Some(UNREAD.into()),
Some(Some(branch)) => match branch_permitted(branch) {
Err(why) => Some(why),
Ok(false) => Some(format!(
"this push updates {branch}, the branch checked out"
)),
Ok(true) if forced => Some(format!(
"this push updates {branch}; a cloud seat may not force a ref"
)),
Ok(true) => None,
},
Some(None) if forced => {
Some(format!("a cloud seat may not force a ref; {ALLOW_RULE}"))
}
Some(None) => None,
};
}
if dst.contains(['$', '`']) {
return Some(format!(
"`{dst}` is only known once the shell expands it; push a named branch"
));
}
if dst.contains('*') {
let Some(refs) = self.refs() else {
return Some(UNREAD.into());
};
let hit = refs.iter().find_map(|r| {
let landed = land(src, dst, r).or_else(|| {
(!src.starts_with("refs/"))
.then(|| land(&format!("refs/heads/{src}"), dst, r))
.flatten()
})?;
dest_refused(&landed).map(|_| format!("`{spec}` pushes {r} to {landed}"))
});
if hit.is_some() {
return hit;
}
return forced.then(|| format!("a wildcard push may not be forced; {ALLOW_RULE}"));
}
if let Some(why) = dest_refused(dst) {
return Some(why);
}
forced.then(|| format!("this push updates {dst}; a cloud seat may not force a ref"))
}
fn implicit(&self, remote: Option<&str>) -> Option<String> {
let Some(branch) = self.branch() else {
return Some(UNREAD.into());
};
let Some(settings) = self.settings() else {
return Some(UNREAD.into());
};
let get = |key: &str| {
settings
.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
};
let of_branch = |name: &str| branch.and_then(|b| get(&format!("branch.{b}.{name}")));
let remote = remote
.or_else(|| of_branch("pushremote"))
.or_else(|| get("remote.pushdefault"))
.or_else(|| of_branch("remote"))
.unwrap_or("origin");
if get(&format!("remote.{remote}.mirror"))
.is_some_and(|v| matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "on" | "1"))
{
return self.local(&["refs/heads/", "refs/tags/"], "a mirror remote");
}
let key = format!("remote.{remote}.push");
let pushes: Vec<&str> = settings
.iter()
.filter(|(k, _)| *k == key)
.map(|(_, v)| v.as_str())
.collect();
if !pushes.is_empty() {
return pushes.iter().find_map(|spec| self.spec(spec, Some(remote)));
}
match get("push.default").unwrap_or("simple") {
"nothing" => None,
"matching" => self.local(&["refs/heads/"], "a matching push"),
"upstream" | "tracking" => {
of_branch("merge").and_then(|merge| match branch_permitted(merge) {
Err(why) => Some(why),
Ok(false) => Some(format!(
"this push updates {merge}, the upstream of the branch checked out"
)),
Ok(true) => None,
})
}
_ => match branch {
Some(b) => match branch_permitted(b) {
Err(why) => Some(why),
Ok(false) => Some(format!("this push updates {b}, the branch checked out")),
Ok(true) => None,
},
None => None,
},
}
}
fn map_bare_destination(&self, src: &str, remote: Option<&str>) -> Option<String> {
let settings = self.settings()?;
let get = |key: &str| {
settings
.iter()
.rev()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
};
let branch = src.strip_prefix("refs/heads/").unwrap_or(src);
let remote = remote
.or_else(|| get(&format!("branch.{branch}.pushremote")))
.or_else(|| get("remote.pushdefault"))
.or_else(|| get(&format!("branch.{branch}.remote")))
.unwrap_or("origin");
let key = format!("remote.{remote}.push");
for push in settings.iter().filter(|(k, _)| *k == key) {
if let Some(dst) = map_push_spec(&push.1, src) {
return Some(dst);
}
}
match get("push.default").unwrap_or("simple") {
"upstream" | "tracking" => {
let branch_remote = get(&format!("branch.{branch}.remote"));
if branch_remote.is_none_or(|r| r == remote) {
return get(&format!("branch.{branch}.merge")).map(str::to_string);
}
}
_ => {}
}
None
}
}
fn map_push_spec(spec: &str, src: &str) -> Option<String> {
let spec = spec.strip_prefix('+').unwrap_or(spec);
let (from, to) = spec.split_once(':')?;
let matched = land(from, to, src).or_else(|| {
(!src.starts_with("refs/")).then(|| land(from, to, &format!("refs/heads/{src}")))?
});
if matched.is_some() {
return matched;
}
let from = from.strip_prefix("refs/heads/").unwrap_or(from);
(from == src).then(|| to.to_string())
}
fn shell_script_file(words: &[String]) -> Option<String> {
let cmd = words.first()?.rsplit('/').next()?;
match cmd {
"source" | "." => words
.iter()
.skip(1)
.find(|word| !word.starts_with('-'))
.cloned(),
"bash" | "sh" | "dash" | "zsh" | "ksh" => {
if words.iter().any(|word| word == "-c" || word == "--command") {
return None;
}
let mut it = words.iter().skip(1);
while let Some(word) = it.next() {
if word == "--" {
return it.next().cloned();
}
if matches!(word.as_str(), "-o" | "--rcfile" | "--init-file") {
it.next();
continue;
}
if word.starts_with('-') && word.len() > 1 {
continue;
}
return Some(word.clone());
}
None
}
_ => None,
}
}
fn script_verdict(file: &str, cwd: Option<&Path>, state: &WalkIn) -> Verdict {
if unreadable_token(file) {
return Verdict::closed(format!(
"`{file}` is only known once the shell expands it; a script it names can write refs; {ALLOW_RULE}"
));
}
match read_script(cwd, file) {
ScriptBody::Missing => Verdict::default(),
ScriptBody::Unreadable => {
Verdict::closed(format!("the script {file} could not be read; {ALLOW_RULE}"))
}
ScriptBody::Text(text) => {
let inner = judge_depth(&text, cwd, state);
if inner.push.is_some() || inner.acts.any() {
return inner;
}
if !inner.saw_ref && script_mentions_ref_write(&text) {
return Verdict::closed(format!(
"the script {file} mentions a ref write the guard could not resolve; {ALLOW_RULE}"
));
}
inner
}
}
}
enum ScriptBody {
Missing,
Unreadable,
Text(String),
}
fn read_script(cwd: Option<&Path>, file: &str) -> ScriptBody {
if file.is_empty() || file.contains('\0') {
return ScriptBody::Unreadable;
}
let path = Path::new(file);
let full = if path.is_absolute() {
path.to_path_buf()
} else {
let Some(base) = cwd
.map(Path::to_path_buf)
.or_else(|| std::env::current_dir().ok())
else {
return ScriptBody::Unreadable;
};
base.join(path)
};
match std::fs::metadata(&full) {
Ok(meta) if meta.is_file() => match std::fs::read_to_string(&full) {
Ok(text) => ScriptBody::Text(text),
Err(_) => ScriptBody::Unreadable,
},
Ok(_) => ScriptBody::Unreadable,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => ScriptBody::Missing,
Err(_) => ScriptBody::Unreadable,
}
}
fn script_mentions_ref_write(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("git push")
|| lower.contains("git send-pack")
|| lower.contains("gh api")
|| lower.contains("gh release create")
|| lower.contains("gh repo sync")
|| ((lower.contains("curl") || lower.contains("wget") || lower.contains("http"))
&& lower.contains("github.com"))
}
fn http_targets_github(words: &[String]) -> bool {
http_hosts(words).0
}
fn http_hosts(words: &[String]) -> (bool, bool) {
let cmd = words
.first()
.map(|word| word.rsplit('/').next().unwrap_or(word.as_str()))
.unwrap_or("");
let proto = if cmd == "curl" {
curl_proto_default(words)
} else {
None
};
let httpie = cmd == "http" || cmd == "https";
let mut github = false;
let mut other = false;
let mut hidden = false;
for word in words.iter().skip(1) {
if word.starts_with('-') {
continue;
}
if let Some(host) = literal_http_host(word) {
if is_github_host(&host) {
github = true;
} else {
other = true;
}
continue;
}
if httpie || proto.is_some() {
if let Some(host) = schemeless_host(word) {
if is_github_host(&host) {
github = true;
} else {
other = true;
}
continue;
}
}
if unreadable_token(word) && (word.contains("://") || !word.contains([' ', ':', '='])) {
hidden = true;
}
}
if github {
return (true, other);
}
if other {
return (false, true);
}
(hidden, false)
}
fn curl_proto_default(words: &[String]) -> Option<String> {
let mut i = 1usize;
while i < words.len() {
let word = words[i].as_str();
if let Some(value) = word.strip_prefix("--proto-default=") {
return (!value.is_empty()).then(|| value.to_string());
}
if word == "--proto-default" {
return words.get(i + 1).cloned();
}
i += 1;
}
None
}
fn literal_http_host(word: &str) -> Option<String> {
host_in_url(word)
}
fn schemeless_host(word: &str) -> Option<String> {
if word.contains("://") || word.contains('=') || word.contains([' ', '\t']) {
return None;
}
let host = host_in_url(&format!("https://{word}"))?;
host.contains('.').then_some(host)
}
fn host_in_url(word: &str) -> Option<String> {
let word = word
.trim()
.trim_matches(|c| c == '"' || c == '\'' || c == '<' || c == '>');
let idx = word.find("://")?;
let scheme = &word[..idx];
if scheme.is_empty() || !scheme.chars().all(|c| c.is_ascii_alphabetic()) {
return None;
}
let rest = &word[idx + 3..];
let auth = rest.split(['/', '?', '#']).next().unwrap_or("");
if auth.is_empty() {
return None;
}
let hostport = auth.rsplit_once('@').map(|(_, host)| host).unwrap_or(auth);
if hostport.is_empty() || unreadable_token(hostport) {
return None;
}
let host = strip_host_port(hostport).trim_end_matches('.');
if host.is_empty() || unreadable_token(host) {
return None;
}
Some(host.to_ascii_lowercase())
}
fn strip_host_port(hostport: &str) -> &str {
if let Some(inner) = hostport.strip_prefix('[') {
return inner.split(']').next().unwrap_or(inner);
}
if let Some((host, port)) = hostport.rsplit_once(':') {
if !port.is_empty() && port.chars().all(|c| c.is_ascii_digit()) {
return host;
}
}
hostport
}
fn is_github_host(host: &str) -> bool {
host == "github.com"
|| host == "github.ghe.com"
|| host == "ghcr.io"
|| host == "githubusercontent.com"
|| host.ends_with(".github.com")
|| host.ends_with(".github.ghe.com")
|| host.ends_with(".ghcr.io")
|| host.ends_with(".githubusercontent.com")
|| host.ends_with(".pkg.github.com")
}
fn http_ref_write(
words: &[String],
env: &[(String, String)],
cwd: Option<&Path>,
) -> Option<String> {
let cmd = words.first()?.rsplit('/').next()?;
if !matches!(cmd, "curl" | "wget" | "http" | "https") {
return None;
}
let (shape, config_github) = http_shape(cmd, words, env, cwd);
if !http_targets_github(words) && !config_github {
return None;
}
match shape {
HttpShape::Get => None,
HttpShape::Write | HttpShape::Opaque => Some(format!(
"an HTTP client can write refs on GitHub and the destination is not limited to this seat; {ALLOW_RULE}"
)),
}
}
enum HttpShape {
Get,
Write,
Opaque,
}
fn http_shape(
cmd: &str,
words: &[String],
env: &[(String, String)],
cwd: Option<&Path>,
) -> (HttpShape, bool) {
match cmd {
"curl" => curl_shape(words, env, cwd),
"wget" => wget_shape(words, env, cwd),
"http" | "https" => (httpie_shape(words), false),
_ => (HttpShape::Opaque, false),
}
}
const CURL_BOOL: &str = "sSfkLvgGIOJiq4";
const CURL_VALUE: &str = "XdHuKoTFAbcemwExUrPQDyYztC";
#[derive(Default)]
struct Tune {
method: Option<String>,
body: bool,
upload: bool,
query: bool,
github: bool,
unreadable: bool,
}
fn curl_shape(words: &[String], env: &[(String, String)], cwd: Option<&Path>) -> (HttpShape, bool) {
let mut tune = Tune::default();
let mut seen = HashSet::new();
if !curl_rc_skipped(words) {
if let Some(shape) = curl_default_files(env, cwd, &mut tune, &mut seen) {
return (shape, curl_config_github(words, &tune));
}
}
let mut i = 1usize;
while i < words.len() {
let word = words[i].as_str();
if word == "--" {
break;
}
if word.starts_with("--") {
match curl_long(words, &mut i, &mut tune, cwd, &mut seen) {
Some(shape) => return (shape, curl_config_github(words, &tune)),
None => continue,
}
}
if let Some(cluster) = word.strip_prefix('-') {
if cluster.is_empty() {
i += 1;
continue;
}
if let Some(shape) = curl_cluster(cluster, words, &mut i, &mut tune, cwd, &mut seen) {
return (shape, curl_config_github(words, &tune));
}
continue;
}
i += 1;
}
let shape = finish_http(tune.method.as_deref(), tune.body, tune.query, tune.upload);
(shape, curl_config_github(words, &tune))
}
fn curl_config_github(words: &[String], tune: &Tune) -> bool {
if tune.github {
return true;
}
tune.unreadable && !http_hosts(words).1
}
fn curl_rc_skipped(words: &[String]) -> bool {
match words.get(1).map(String::as_str) {
Some("--disable") => true,
Some(word) => word.starts_with("-q"),
None => false,
}
}
fn finish_http(method: Option<&str>, body: bool, query: bool, upload: bool) -> HttpShape {
if upload {
return HttpShape::Write;
}
match method {
Some("GET" | "HEAD") if !body || query => HttpShape::Get,
Some("GET" | "HEAD") => HttpShape::Write,
Some(_) => HttpShape::Write,
None if body && !query => HttpShape::Write,
None => HttpShape::Get,
}
}
fn prefixed_or_process(env: &[(String, String)], key: &str) -> Option<String> {
if let Some((_, value)) = env.iter().rev().find(|(name, _)| name == key) {
return Some(value.clone());
}
std::env::var(key).ok()
}
fn curl_long(
words: &[String],
i: &mut usize,
tune: &mut Tune,
cwd: Option<&Path>,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
let word = words[*i].as_str();
let (name, attached) = word.split_once('=').unwrap_or((word, ""));
let lower = name.to_ascii_lowercase();
if matches!(
lower.as_str(),
"--netrc" | "--netrc-optional" | "--netrc-file"
) {
return Some(HttpShape::Opaque);
}
if lower == "--config" {
let value = flag_value(words, i, attached);
return curl_named_file(&value, cwd, tune, seen);
}
if lower == "--variable" || lower.starts_with("--expand-") {
tune.github = true;
return Some(HttpShape::Opaque);
}
if matches!(
lower.as_str(),
"--data"
| "--data-ascii"
| "--data-binary"
| "--data-raw"
| "--data-urlencode"
| "--json"
| "--upload-file"
| "--form"
| "--form-string"
) {
let value = flag_value(words, i, attached);
let uploading = matches!(lower.as_str(), "--upload-file" | "--form" | "--form-string");
return if value.starts_with('@') {
Some(HttpShape::Opaque)
} else if uploading {
tune.upload = true;
None
} else {
tune.body = true;
None
};
}
if lower == "--request" {
let value = flag_value(words, i, attached);
tune.method = Some(value.to_ascii_uppercase());
return None;
}
if lower == "--head" {
tune.method = Some("HEAD".to_string());
*i += 1;
return None;
}
if lower == "--get" {
tune.query = true;
*i += 1;
return None;
}
if matches!(
lower.as_str(),
"--header" | "--oauth2-bearer" | "--user" | "--url"
) {
let value = flag_value(words, i, attached);
if lower == "--url" {
note_cfg_url(&value, tune);
}
return if lower == "--header" && value.starts_with('@') {
Some(HttpShape::Opaque)
} else {
None
};
}
if matches!(
lower.as_str(),
"--silent"
| "--show-error"
| "--fail"
| "--fail-with-body"
| "--insecure"
| "--location"
| "--location-trusted"
| "--verbose"
| "--globoff"
| "--compressed"
| "--progress-bar"
| "--remote-name"
| "--http1.1"
| "--http2"
| "--path-as-is"
| "--no-progress-meter"
| "--create-dirs"
| "--ipv4"
| "--ipv6"
| "--retry-all-errors"
| "--remote-header-name"
) {
*i += 1;
return None;
}
if matches!(
lower.as_str(),
"--output"
| "--retry"
| "--retry-delay"
| "--retry-max-time"
| "--connect-timeout"
| "--max-time"
| "--max-redirs"
| "--user-agent"
| "--referer"
| "--cookie"
| "--cookie-jar"
| "--proxy"
| "--proxy-user"
| "--cacert"
| "--capath"
| "--cert"
| "--key"
| "--speed-limit"
| "--speed-time"
| "--limit-rate"
| "--output-dir"
| "--write-out"
) {
let _value = flag_value(words, i, attached);
return None;
}
*i += 1;
None
}
fn flag_value(words: &[String], i: &mut usize, attached: &str) -> String {
if !attached.is_empty() {
*i += 1;
return attached.to_string();
}
*i += 1;
let value = words.get(*i).cloned().unwrap_or_default();
*i += 1;
value
}
fn curl_cluster(
cluster: &str,
words: &[String],
i: &mut usize,
tune: &mut Tune,
cwd: Option<&Path>,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
let chars: Vec<char> = cluster.chars().collect();
let mut k = 0;
while k < chars.len() {
let c = chars[k];
if CURL_BOOL.contains(c) {
if c == 'G' {
tune.query = true;
}
if c == 'I' {
tune.method = Some("HEAD".to_string());
}
k += 1;
continue;
}
if c == 'n' || !CURL_VALUE.contains(c) {
return Some(HttpShape::Opaque);
}
let attached: String = chars[k + 1..].iter().collect();
let value = if attached.is_empty() {
*i += 1;
let value = words.get(*i).cloned().unwrap_or_default();
*i += 1;
value
} else {
*i += 1;
attached
};
return match c {
'K' => curl_named_file(&value, cwd, tune, seen),
'X' => {
tune.method = Some(value.to_ascii_uppercase());
None
}
'd' | 'T' | 'F' => {
if value.starts_with('@') {
Some(HttpShape::Opaque)
} else if c == 'd' {
tune.body = true;
None
} else {
tune.upload = true;
None
}
}
'H' if value.starts_with('@') => Some(HttpShape::Opaque),
_ => None,
};
}
*i += 1;
None
}
fn curl_named_file(
value: &str,
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
if value.is_empty() || value == "-" {
tune.unreadable = true;
tune.github = true;
return Some(HttpShape::Opaque);
}
let shape = read_curl_file(&resolve_cfg(value, cwd), cwd, tune, seen, true);
if tune.unreadable {
tune.github = true;
}
shape
}
fn curl_default_files(
env: &[(String, String)],
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
let dir_of = |key: &str| {
prefixed_or_process(env, key)
.filter(|dir| !dir.is_empty())
.map(PathBuf::from)
};
if let Some(dir) = dir_of("CURL_HOME") {
let found = first_curl_file(&curl_rc_names(&dir, cfg!(windows)), cwd, tune, seen);
return found.unwrap_or_else(|| {
tune.unreadable = true;
Some(HttpShape::Opaque)
});
}
let mut paths = Vec::new();
if let Some(xdg) = dir_of("XDG_CONFIG_HOME") {
paths.push(xdg.join("curlrc"));
}
if let Some(home) = dir_of("HOME") {
paths.extend(curl_rc_names(&home, cfg!(windows)));
}
if let Some(profile) = dir_of("USERPROFILE") {
paths.extend(curl_rc_names(&profile, true));
}
if let Some(appdata) = dir_of("APPDATA") {
paths.extend(curl_rc_names(&appdata, true));
}
if let Some(profile) = dir_of("USERPROFILE") {
paths.extend(curl_rc_names(&profile.join("Application Data"), true));
}
first_curl_file(&paths, cwd, tune, seen).flatten()
}
fn curl_rc_names(dir: &Path, windows: bool) -> Vec<PathBuf> {
let mut names = vec![dir.join(".curlrc")];
if windows {
names.push(dir.join("_curlrc"));
}
names
}
fn first_curl_file(
paths: &[PathBuf],
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
) -> Option<Option<HttpShape>> {
for path in paths {
if let Some(shape) = read_curl_file(path, cwd, tune, seen, false) {
return Some(Some(shape));
}
if tune.unreadable || seen.contains(&cfg_key(path)) {
return Some(None);
}
}
None
}
fn read_curl_file(
path: &Path,
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
required: bool,
) -> Option<HttpShape> {
if seen.len() >= 8 {
tune.unreadable = true;
return Some(HttpShape::Opaque);
}
let key = cfg_key(path);
if !seen.insert(key.clone()) {
return None;
}
match open_cfg(&key) {
Ok(None) if !required => {
seen.remove(&key);
None
}
Ok(None) | Err(()) => {
tune.unreadable = true;
Some(HttpShape::Opaque)
}
Ok(Some(text)) => apply_curl_text(&text, cwd, tune, seen),
}
}
fn apply_curl_text(
text: &str,
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
let mut opaque = false;
for line in text.lines() {
match curl_cfg_line(line) {
CfgLine::Blank => {}
CfgLine::Bad => {
opaque = true;
tune.github = true;
}
CfgLine::Item { name, value } => {
if apply_curl_opt(&name, value.as_deref(), cwd, tune, seen).is_some() {
opaque = true;
}
}
}
}
opaque.then_some(HttpShape::Opaque)
}
enum CfgLine {
Blank,
Bad,
Item { name: String, value: Option<String> },
}
fn curl_cfg_line(line: &str) -> CfgLine {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
return CfgLine::Blank;
}
if line.starts_with('-') {
return curl_dashed_line(line);
}
let mut name_end = line.len();
for (i, c) in line.char_indices() {
if c == '=' || c == ':' || c.is_whitespace() {
name_end = i;
break;
}
}
let name = &line[..name_end];
if name.is_empty() {
return CfgLine::Bad;
}
let rest = line[name_end..].trim_start();
let rest = if let Some(rest) = rest.strip_prefix('=').or_else(|| rest.strip_prefix(':')) {
rest.trim()
} else {
rest
};
if rest.is_empty() {
return CfgLine::Item {
name: name.to_ascii_lowercase(),
value: None,
};
}
match unquote(rest) {
Some(value) => CfgLine::Item {
name: name.to_ascii_lowercase(),
value: Some(value),
},
None => CfgLine::Bad,
}
}
fn curl_dashed_line(line: &str) -> CfgLine {
let long = line.starts_with("--");
let body = if long { &line[2..] } else { &line[1..] };
if body.is_empty() {
return CfgLine::Bad;
}
if !long {
let mut chars = body.chars();
let Some(flag) = chars.next() else {
return CfgLine::Bad;
};
let attached = chars.as_str().trim();
let value = if attached.is_empty() {
None
} else {
match unquote(attached) {
Some(value) => Some(value),
None => return CfgLine::Bad,
}
};
return CfgLine::Item {
name: flag.to_string(),
value,
};
}
let (name, raw) = if let Some((name, value)) = body.split_once('=') {
(name, Some(value))
} else if let Some((name, value)) = body.split_once(char::is_whitespace) {
(name, Some(value.trim()))
} else {
(body, None)
};
if name.is_empty() || name.chars().any(char::is_whitespace) {
return CfgLine::Bad;
}
let value = match raw {
None | Some("") => None,
Some(value) => match unquote(value.trim()) {
Some(value) => Some(value),
None => return CfgLine::Bad,
},
};
CfgLine::Item {
name: name.to_ascii_lowercase(),
value,
}
}
fn unquote(raw: &str) -> Option<String> {
let raw = raw.trim();
if !raw.starts_with('"') {
return Some(raw.to_string());
}
let mut out = String::new();
let mut chars = raw[1..].chars();
while let Some(c) = chars.next() {
if c == '\\' {
match chars.next() {
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some('t') => out.push('\t'),
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('v') => out.push('\u{0b}'),
Some(other) => out.push(other),
None => return None,
}
continue;
}
if c == '"' {
return Some(out);
}
out.push(c);
}
None
}
fn apply_curl_opt(
name: &str,
value: Option<&str>,
cwd: Option<&Path>,
tune: &mut Tune,
seen: &mut HashSet<PathBuf>,
) -> Option<HttpShape> {
let short = name.chars().count() == 1;
match name {
"request" | "X" => {
let value = value.unwrap_or("");
if value.is_empty() {
return Some(HttpShape::Opaque);
}
tune.method = Some(value.to_ascii_uppercase());
None
}
"head" | "I" => {
tune.method = Some("HEAD".to_string());
None
}
"get" | "G" => {
tune.query = true;
None
}
"data" | "data-ascii" | "data-binary" | "data-raw" | "data-urlencode" | "json" | "d" => {
let value = value.unwrap_or("");
if value.starts_with('@') {
return Some(HttpShape::Opaque);
}
tune.body = true;
None
}
"upload-file" | "form" | "form-string" | "T" | "F" => {
let value = value.unwrap_or("");
if value.starts_with('@') {
return Some(HttpShape::Opaque);
}
tune.upload = true;
None
}
"header" | "H" => {
let value = value.unwrap_or("");
if value.starts_with('@') {
Some(HttpShape::Opaque)
} else {
None
}
}
"config" | "K" => {
let value = value.unwrap_or("");
curl_named_file(value, cwd, tune, seen)
}
"url" | "proxy" | "x" => {
if let Some(value) = value {
note_cfg_url(value, tune);
}
None
}
"netrc" | "netrc-optional" | "netrc-file" | "n" => Some(HttpShape::Opaque),
_ if short && curl_short_benign(name) => None,
_ if !short && curl_long_benign(name) => None,
_ => {
tune.github = true;
Some(HttpShape::Opaque)
}
}
}
fn curl_short_benign(name: &str) -> bool {
matches!(
name,
"s" | "S"
| "f"
| "k"
| "L"
| "v"
| "g"
| "O"
| "J"
| "i"
| "4"
| "6"
| "q"
| "o"
| "A"
| "b"
| "c"
| "e"
| "m"
| "u"
| "U"
| "r"
| "w"
| "x"
| "y"
| "Y"
| "z"
| "C"
| "E"
| "P"
| "Q"
| "D"
)
}
fn curl_long_benign(name: &str) -> bool {
matches!(
name,
"silent"
| "show-error"
| "fail"
| "fail-with-body"
| "insecure"
| "location"
| "location-trusted"
| "verbose"
| "globoff"
| "compressed"
| "progress-bar"
| "remote-name"
| "http1.1"
| "http2"
| "path-as-is"
| "no-progress-meter"
| "create-dirs"
| "ipv4"
| "ipv6"
| "retry-all-errors"
| "remote-header-name"
| "output"
| "retry"
| "retry-delay"
| "retry-max-time"
| "connect-timeout"
| "max-time"
| "max-redirs"
| "user-agent"
| "referer"
| "cookie"
| "cookie-jar"
| "proxy"
| "proxy-user"
| "cacert"
| "capath"
| "cert"
| "key"
| "speed-limit"
| "speed-time"
| "limit-rate"
| "output-dir"
| "write-out"
| "user"
| "oauth2-bearer"
| "disable"
| "include"
| "dump-header"
)
}
fn note_cfg_url(value: &str, tune: &mut Tune) {
if value.contains('$') || value.contains('`') || value.contains("{{") {
tune.github = true;
return;
}
if let Some(host) = host_in_url(value).or_else(|| schemeless_host(value)) {
if is_github_host(&host) {
tune.github = true;
}
}
}
fn resolve_cfg(value: &str, cwd: Option<&Path>) -> PathBuf {
let path = PathBuf::from(value);
if path.is_absolute() {
path
} else {
cwd.map(|dir| dir.join(path))
.unwrap_or_else(|| PathBuf::from(value))
}
}
fn cfg_key(path: &Path) -> PathBuf {
let mut out = PathBuf::new();
for part in path.components() {
match part {
std::path::Component::CurDir => {}
std::path::Component::ParentDir => {
out.pop();
}
other => out.push(other),
}
}
out
}
fn open_cfg(path: &Path) -> Result<Option<String>, ()> {
use std::io::Read;
use std::os::unix::fs::OpenOptionsExt;
let mut file = match std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK | libc::O_CLOEXEC)
.open(path)
{
Ok(file) => file,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(_) => return Err(()),
};
if !file.metadata().is_ok_and(|meta| meta.is_file()) {
return Err(());
}
let mut text = String::new();
file.read_to_string(&mut text).map_err(|_| ())?;
Ok(Some(text))
}
fn wget_shape(words: &[String], env: &[(String, String)], cwd: Option<&Path>) -> (HttpShape, bool) {
let mut tune = Tune::default();
if let Some(shape) = wget_files(words, env, cwd, &mut tune) {
return (shape, curl_config_github(words, &tune));
}
let mut method = tune.method.clone();
let mut body = tune.body;
let mut i = 1usize;
while i < words.len() {
let lower = words[i].to_ascii_lowercase();
if lower == "--method" {
method = words.get(i + 1).map(|value| value.to_ascii_uppercase());
i += 2;
continue;
}
if let Some(value) = lower.strip_prefix("--method=") {
method = Some(value.to_ascii_uppercase());
i += 1;
continue;
}
if lower == "-e" || lower == "--execute" {
if let Some(value) = words.get(i + 1) {
note_wget_exec(value, &mut method, &mut body, &mut tune.github);
}
i += 2;
continue;
}
if let Some(value) = lower.strip_prefix("--execute=") {
note_wget_exec(value, &mut method, &mut body, &mut tune.github);
i += 1;
continue;
}
if let Some(value) = lower.strip_prefix("-e") {
if !value.is_empty() && !lower.starts_with("--") {
note_wget_exec(value, &mut method, &mut body, &mut tune.github);
i += 1;
continue;
}
}
if lower.starts_with("--post-") || lower.starts_with("--body-") {
body = true;
}
i += 1;
}
let shape = finish_http(method.as_deref(), body, false, false);
(shape, curl_config_github(words, &tune))
}
enum WgetFiles {
Off,
Defaults,
Named(Vec<PathBuf>),
}
fn wget_files_plan(words: &[String]) -> WgetFiles {
let mut named = Vec::new();
let mut i = 1usize;
while i < words.len() {
let lower = words[i].to_ascii_lowercase();
if lower == "--no-config" && named.is_empty() {
return WgetFiles::Off;
}
if lower == "--config" {
let value = words.get(i + 1).cloned().unwrap_or_default();
named.push(PathBuf::from(value));
i += 2;
continue;
}
if let Some(value) = lower.strip_prefix("--config=") {
named.push(PathBuf::from(value));
i += 1;
continue;
}
i += 1;
}
if named.is_empty() {
WgetFiles::Defaults
} else {
WgetFiles::Named(named)
}
}
fn wget_files(
words: &[String],
env: &[(String, String)],
cwd: Option<&Path>,
tune: &mut Tune,
) -> Option<HttpShape> {
match wget_files_plan(words) {
WgetFiles::Off => None,
WgetFiles::Named(paths) => {
let mut opaque = false;
for path in paths {
let path = resolve_cfg(&path.to_string_lossy(), cwd);
if read_wget_file(&path, tune, true).is_some() {
opaque = true;
}
}
opaque.then_some(HttpShape::Opaque)
}
WgetFiles::Defaults => {
if let Some(shape) = wget_system(env, tune) {
return Some(shape);
}
wget_user(env, cwd, tune)
}
}
}
fn wget_system(env: &[(String, String)], tune: &mut Tune) -> Option<HttpShape> {
if let Some(path) = prefixed_or_process(env, "SYSTEM_WGETRC") {
if !path.is_empty() {
let path = PathBuf::from(path);
match open_cfg(&path) {
Ok(None) => {}
Err(()) => {
tune.unreadable = true;
return Some(HttpShape::Opaque);
}
Ok(Some(_)) => return read_wget_file(&path, tune, true),
}
}
}
read_wget_file(Path::new("/etc/wgetrc"), tune, false)
}
fn wget_user(env: &[(String, String)], cwd: Option<&Path>, tune: &mut Tune) -> Option<HttpShape> {
if let Some(path) = prefixed_or_process(env, "WGETRC") {
if !path.is_empty() {
return read_wget_file(&resolve_cfg(&path, cwd), tune, true);
}
}
if let Some(home) = prefixed_or_process(env, "HOME") {
if !home.is_empty() {
return read_wget_file(&PathBuf::from(home).join(".wgetrc"), tune, false);
}
}
None
}
fn read_wget_file(path: &Path, tune: &mut Tune, required: bool) -> Option<HttpShape> {
match open_cfg(path) {
Ok(None) if !required => None,
Ok(None) | Err(()) => {
tune.unreadable = true;
Some(HttpShape::Opaque)
}
Ok(Some(text)) => apply_wget_text(&text, tune),
}
}
fn apply_wget_text(text: &str, tune: &mut Tune) -> Option<HttpShape> {
let mut opaque = false;
for line in text.lines() {
match curl_cfg_line(line) {
CfgLine::Blank => {}
CfgLine::Bad => opaque = true,
CfgLine::Item { name, value } => {
let key = name.replace(['_', '-'], "");
if wget_key_moves_target(&key, value.as_deref().unwrap_or("")) {
tune.github = true;
}
match key.as_str() {
"method" => {
tune.method = Some(value.unwrap_or_default().trim().to_ascii_uppercase());
}
"postdata" | "postfile" | "bodydata" | "bodyfile" => tune.body = true,
"header" if value.as_deref().unwrap_or("").trim_start().starts_with('@') => {
opaque = true;
}
_ => {}
}
}
}
}
opaque.then_some(HttpShape::Opaque)
}
fn wget_key_moves_target(key: &str, value: &str) -> bool {
match key {
"input" | "inputmetalink" | "base" | "httpproxy" | "httpsproxy" | "ftpproxy"
| "useproxy" => true,
"header" => value
.trim_start()
.get(..5)
.is_some_and(|name| name.eq_ignore_ascii_case("host:")),
_ => false,
}
}
fn note_wget_exec(value: &str, method: &mut Option<String>, body: &mut bool, github: &mut bool) {
let lower = value.to_ascii_lowercase();
let (key, val) = lower.split_once('=').unwrap_or((lower.as_str(), ""));
let key = key.trim();
if wget_key_moves_target(&key.replace(['_', '-'], ""), val) {
*github = true;
}
match key {
"method" => *method = Some(val.trim().to_ascii_uppercase()),
"body_data" | "body_file" | "post_data" | "post_file" | "header" => *body = true,
_ => {}
}
}
fn httpie_shape(words: &[String]) -> HttpShape {
let mut method: Option<String> = None;
let mut saw_method = false;
let mut body = false;
for word in words.iter().skip(1) {
if word.starts_with('@') {
return HttpShape::Opaque;
}
if word.starts_with('-') {
continue;
}
let upper = word.to_ascii_uppercase();
if !saw_method
&& matches!(
upper.as_str(),
"GET" | "HEAD" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"
)
{
saw_method = true;
method = Some(upper);
continue;
}
if word.contains('=') && !word.starts_with("http") {
body = true;
}
}
finish_http(method.as_deref(), body, false, false)
}