use super::read_only_git::check_git;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) enum Arg {
Lit { text: String, bare: bool },
Operand,
}
impl Arg {
pub(super) fn text(&self) -> Option<&str> {
match self {
Arg::Lit { text, .. } => Some(text),
Arg::Operand => None,
}
}
pub(super) fn is_operand(&self) -> bool {
self.text().is_none_or(|t| !t.starts_with('-'))
}
}
type Verdict = Result<(), String>;
const PIPE_READERS: &[&str] = &["cat", "head", "tail", "wc", "grep", "rg", "sed"];
const PLAIN_READERS: &[&str] = &["cat", "head", "wc", "ls", "grep"];
const FIND_FLAGS: &[&str] = &[
"-print",
"-print0",
"-prune",
"-o",
"-a",
"-or",
"-and",
"-not",
"-true",
"-false",
"-empty",
"-depth",
"-d",
"-L",
"-H",
"-P",
"-E",
"-x",
"-xdev",
"-mount",
"-follow",
"-ls",
"-quit",
"-nouser",
"-nogroup",
"-readable",
"-executable",
];
const FIND_VALUED: &[&str] = &[
"-name",
"-iname",
"-path",
"-ipath",
"-wholename",
"-iwholename",
"-regex",
"-iregex",
"-type",
"-xtype",
"-maxdepth",
"-mindepth",
"-mtime",
"-mmin",
"-atime",
"-amin",
"-ctime",
"-cmin",
"-newer",
"-size",
"-perm",
"-user",
"-group",
"-uid",
"-gid",
"-links",
"-inum",
"-lname",
"-ilname",
"-printf",
"-regextype",
];
pub(super) fn check_command(args: &[Arg], piped: bool) -> Verdict {
let program = match args.first() {
Some(Arg::Lit { text, bare: true }) => text.as_str(),
_ => return Err("a program named by anything but a bare word".into()),
};
if piped && !PIPE_READERS.contains(&program) {
return Err(format!("a pipe into `{program}`, which is not a reader"));
}
let rest = &args[1..];
match program {
p if PLAIN_READERS.contains(&p) => Ok(()),
"tail" => tail(rest),
"rg" => rg(rest),
"find" => find(rest),
"sed" => sed(rest),
"plutil" => plutil(rest),
"defaults" => defaults(rest),
"launchctl" => launchctl(rest),
"tmux" => tmux(rest),
"cargo" => cargo(rest),
"git" => check_git(rest),
"echo" => Ok(()),
"pwd" if rest.is_empty() => Ok(()),
_ => Err(format!(
"`{program}`, which is not on the read-only allowlist"
)),
}
}
fn tail(rest: &[Arg]) -> Verdict {
let follows = rest
.iter()
.filter_map(Arg::text)
.any(|t| t.starts_with("--") || (t.starts_with('-') && t.contains(['f', 'F'])));
if follows {
return Err("`tail` with a long option or `-f`/`-F`".into());
}
Ok(())
}
fn rg(rest: &[Arg]) -> Verdict {
let runs = rest
.iter()
.filter_map(Arg::text)
.any(|t| t.starts_with("--pre") || t.starts_with("--hostname-bin"));
if runs {
return Err("an `rg` option that runs a program".into());
}
Ok(())
}
fn find(rest: &[Arg]) -> Verdict {
let mut i = 0;
while let Some(arg) = rest.get(i) {
match arg.text() {
Some(t) if FIND_VALUED.contains(&t) => {
if rest.get(i + 1).and_then(Arg::text).is_none() {
return Err(format!("`find {t}` without a literal value"));
}
i += 2;
}
Some(t) if t.starts_with('-') && !FIND_FLAGS.contains(&t) => {
return Err(format!(
"`find {t}`, which is not on the read-only allowlist"
));
}
_ => i += 1,
}
}
Ok(())
}
fn sed(rest: &[Arg]) -> Verdict {
let mut scripts = Vec::new();
let mut i = 0;
while let Some(t) = rest.get(i).and_then(Arg::text) {
if t == "-e" {
let script = rest.get(i + 1).and_then(Arg::text);
scripts.push(script.ok_or("`sed -e` without a literal script")?);
i += 2;
} else if t.starts_with('-') && t.len() > 1 {
if t.starts_with("--") || !t[1..].chars().all(|c| matches!(c, 'n' | 'E' | 'r')) {
return Err(format!(
"`sed {t}`, which is not on the read-only allowlist"
));
}
i += 1;
} else {
break;
}
}
if scripts.is_empty() {
let script = rest.get(i).and_then(Arg::text);
scripts.push(script.ok_or("a `sed` script that is not a literal")?);
i += 1;
}
if !rest[i.min(rest.len())..].iter().all(Arg::is_operand) {
return Err("a `sed` option after its first operand".into());
}
if !scripts.iter().all(|s| sed_script_prints_only(s)) {
return Err("a `sed` script that does more than print lines".into());
}
Ok(())
}
fn sed_script_prints_only(script: &str) -> bool {
script.split(';').all(|part| {
let Some(addrs) = part.trim().strip_suffix('p') else {
return false;
};
let mut addrs = addrs.splitn(2, ',');
addrs.all(sed_address)
})
}
fn sed_address(a: &str) -> bool {
if a.is_empty() || a == "$" || a.chars().all(|c| c.is_ascii_digit()) {
return true;
}
a.strip_prefix('/')
.and_then(|r| r.strip_suffix('/'))
.is_some_and(|re| !re.contains('/') && !re.contains('\\'))
}
fn plutil(rest: &[Arg]) -> Verdict {
let files = match rest.first().and_then(Arg::text) {
Some("-p") => &rest[1..],
Some("-lint") => {
let quiet = rest.get(1).and_then(Arg::text) == Some("-s");
&rest[if quiet { 2 } else { 1 }..]
}
_ => return Err("`plutil` in a form other than `-p` or `-lint`".into()),
};
if files.is_empty() || !files.iter().all(Arg::is_operand) {
return Err("`plutil` with an option after its read verb".into());
}
Ok(())
}
fn defaults(rest: &[Arg]) -> Verdict {
let tail = &rest[rest.len().min(1)..];
let domain_ok = tail
.iter()
.all(|a| a.is_operand() || a.text() == Some("-g"));
if rest.first().and_then(Arg::text) != Some("read") || tail.len() > 2 || !domain_ok {
return Err("`defaults` in a form other than `defaults read`".into());
}
Ok(())
}
fn launchctl(rest: &[Arg]) -> Verdict {
let tail = &rest[rest.len().min(1)..];
let fits = match rest.first().and_then(Arg::text) {
Some("print") => tail.len() == 1,
Some("list") => tail.len() <= 1,
_ => false,
};
if !fits || !tail.iter().all(Arg::is_operand) {
return Err("`launchctl` in a form other than `print` or `list`".into());
}
Ok(())
}
fn tmux(rest: &[Arg]) -> Verdict {
if rest.first().and_then(Arg::text) != Some("capture-pane") {
return Err("`tmux` in a form other than `capture-pane -p`".into());
}
let mut printed = false;
let mut i = 1;
while let Some(arg) = rest.get(i) {
let t = arg
.text()
.ok_or("a `tmux` argument that is not a literal")?;
let Some(cluster) = t.strip_prefix('-').filter(|c| !c.is_empty()) else {
return Err("a `tmux capture-pane` operand".into());
};
i += 1;
for (k, c) in cluster.char_indices() {
match c {
'p' => printed = true,
'a' | 'e' | 'C' | 'J' | 'N' | 'P' | 'q' | 'T' | 'M' => {}
't' | 'S' | 'E' => {
let value = if k + 1 == cluster.len() {
i += 1;
rest.get(i - 1).and_then(Arg::text)
} else {
Some(&cluster[k + 1..])
};
let value = value.ok_or("`tmux -t/-S/-E` without a literal value")?;
if !tmux_value_ok(c, value) {
return Err(format!(
"`tmux capture-pane -{c} {value}`, which tmux may expand"
));
}
break;
}
_ => return Err(format!("`tmux capture-pane -{c}`, which is not allowed")),
}
}
}
if !printed {
return Err("`tmux capture-pane` without `-p`, which fills a paste buffer".into());
}
Ok(())
}
fn tmux_value_ok(flag: char, value: &str) -> bool {
if flag == 't' {
return !value.is_empty()
&& value
.chars()
.all(|c| c.is_ascii_alphanumeric() || "_:.%$@-".contains(c));
}
let digits = value.strip_prefix('-').unwrap_or(value);
value == "-" || (!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit()))
}
fn cargo(rest: &[Arg]) -> Verdict {
let sub = rest.first().and_then(Arg::text);
if !matches!(sub, Some("metadata" | "tree")) {
return Err("`cargo` in a form other than `metadata` or `tree`".into());
}
let overrides = rest.iter().any(|a| {
a.text()
.is_none_or(|t| t.starts_with("--config") || t.starts_with("-Z"))
});
if overrides {
return Err("a `cargo` config override or non-literal argument".into());
}
Ok(())
}