use super::parser::{parse_for_security, ParseResult, Redirect, RedirectKind, SimpleCommand};
use std::collections::HashSet;
fn is_file_write_redirect(r: &Redirect) -> bool {
let target = r.target.trim().trim_start_matches('&');
if matches!(
target,
"1" | "2" | "/dev/null" | "/dev/stdout" | "/dev/stderr"
) {
return false;
}
!matches!(r.kind, RedirectKind::Stdin)
}
fn has_file_write_redirect(cmd: &SimpleCommand) -> bool {
cmd.redirects.iter().any(is_file_write_redirect)
}
fn builtin_readonly_commands() -> HashSet<&'static str> {
let mut set = HashSet::new();
for c in &[
"ls", "tree", "pwd", "du", "df", "stat", "file", "realpath", "basename", "dirname",
] {
set.insert(*c);
}
for c in &[
"cat", "head", "tail", "less", "more", "wc", "nl", "strings", "xxd", "od",
] {
set.insert(*c);
}
for c in &[
"find", "grep", "rg", "ag", "ack", "locate", "which", "whereis",
] {
set.insert(*c);
}
for c in &[
"sort", "uniq", "cut", "tr", "paste", "column", "jq", "yq", "awk", "sed",
] {
set.insert(*c);
}
for c in &["diff", "comm", "cmp"] {
set.insert(*c);
}
for c in &[
"uname", "whoami", "id", "hostname", "date", "uptime", "printenv", "tty", "arch",
] {
set.insert(*c);
}
for c in &["echo", "printf", "true", "false", ":", "test", "seq", "yes"] {
set.insert(*c);
}
for c in &["cargo", "rustc", "rustup"] {
set.insert(*c);
}
set
}
fn neutral_commands() -> HashSet<&'static str> {
let mut set = HashSet::new();
for c in &["echo", "printf", "true", "false", ":", "seq", "yes"] {
set.insert(*c);
}
set
}
fn is_command_readonly(cmd: &SimpleCommand) -> bool {
if has_file_write_redirect(cmd) {
return false;
}
let argv = match super::semantics::strip_safe_wrappers(&cmd.argv) {
Some(argv) => argv,
None => return false, };
let first = match argv.first() {
Some(s) => s.as_str(),
None => return true, };
if first == "git" {
return is_git_readonly(&argv);
}
if first == "sed" {
return !argv
.iter()
.skip(1)
.any(|a| a == "-i" || a.starts_with("-i") || a.starts_with("--in-place"));
}
if first == "find" {
return !argv.iter().skip(1).any(|a| {
a == "-delete"
|| a == "-exec"
|| a == "-execdir"
|| a == "-ok"
|| a == "-okdir"
|| a.starts_with("-fprint")
|| a.starts_with("-fls")
});
}
if first == "awk" || first == "gawk" {
return !argv
.iter()
.skip(1)
.any(|a| a.contains("system(") || a.contains("system (") || a.contains("| getline"));
}
if first == "sort" {
return !argv.iter().any(|a| a == "-o" || a.starts_with("-o"));
}
if first == "tee" {
return false;
}
if matches!(
first,
"cp" | "mv"
| "rm"
| "mkdir"
| "rmdir"
| "chmod"
| "chown"
| "ln"
| "touch"
| "dd"
| "mkfs"
| "mount"
| "umount"
| "kill"
| "killall"
| "pkill"
| "install"
| "strip"
| "patch"
| "rsync"
| "scp"
| "sftp"
) {
return false;
}
if first == "cargo" {
if let Some(subcmd) = argv.get(1) {
return matches!(
subcmd.as_str(),
"check" | "tree" | "metadata" | "search" | "locate-project"
);
}
return false;
}
if first == "rustc" {
return argv
.iter()
.any(|a| a == "--version" || a == "-V" || a == "--print");
}
builtin_readonly_commands().contains(first)
}
fn is_git_readonly(argv: &[String]) -> bool {
let Some(subcmd) = argv.get(1) else {
return false; };
let sub = subcmd.as_str();
let rest = &argv[2..];
if rest.iter().any(|a| a.starts_with("--output")) {
return false;
}
if matches!(
sub,
"status"
| "log"
| "diff"
| "show"
| "blame"
| "shortlog"
| "describe"
| "rev-parse"
| "ls-files"
| "ls-tree"
| "name-rev"
| "grep"
) {
return true;
}
if sub == "config" {
return !rest.iter().any(|a| {
a == "--global"
|| a == "--local"
|| a == "--system"
|| a == "--unset"
|| a == "--replace-all"
}) && rest
.iter()
.any(|a| a == "--get" || a == "--get-all" || a == "--list" || a == "-l");
}
match sub {
"branch" => {
rest.is_empty()
|| rest.iter().all(|a| {
matches!(
a.as_str(),
"-a" | "-r"
| "-v"
| "-vv"
| "--all"
| "--remotes"
| "--list"
| "--show-current"
)
})
}
"tag" => rest.is_empty() || rest.iter().all(|a| matches!(a.as_str(), "-l" | "--list")),
"remote" => {
rest.is_empty()
|| matches!(
rest.first().map(|s| s.as_str()),
Some("-v") | Some("--verbose") | Some("get-url") | Some("show")
)
}
"stash" => {
matches!(
rest.first().map(|s| s.as_str()),
Some("list") | Some("show")
)
}
"reflog" => {
rest.is_empty() || matches!(rest.first().map(|s| s.as_str()), Some("show"))
}
_ => false, }
}
pub fn is_readonly(command: &str) -> bool {
if command.trim().is_empty() {
return true;
}
let parsed = parse_for_security(command);
match parsed {
ParseResult::Simple { commands } => {
if commands.is_empty() {
return false;
}
commands.iter().all(|cmd| {
let is_ro = is_command_readonly(cmd);
let is_neutral = cmd
.argv
.first()
.map(|s| neutral_commands().contains(s.as_str()))
.unwrap_or(false)
&& !has_file_write_redirect(cmd);
is_ro || is_neutral
})
}
ParseResult::TooComplex { .. } => false, ParseResult::ParseUnavailable => false, ParseResult::ParseAborted { .. } => false, }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ls_is_readonly() {
assert!(is_readonly("ls"));
}
#[test]
fn ls_la_is_readonly() {
assert!(is_readonly("ls -la"));
}
#[test]
fn cat_is_readonly() {
assert!(is_readonly("cat file"));
}
#[test]
fn grep_is_readonly() {
assert!(is_readonly("grep pattern file"));
}
#[test]
fn find_name_is_readonly() {
assert!(is_readonly("find . -name '*.rs'"));
}
#[test]
fn git_status_is_readonly() {
assert!(is_readonly("git status"));
}
#[test]
fn git_log_is_readonly() {
assert!(is_readonly("git log --oneline -5"));
}
#[test]
fn git_diff_is_readonly() {
assert!(is_readonly("git diff"));
}
#[test]
fn rm_not_readonly() {
assert!(!is_readonly("rm file"));
}
#[test]
fn mv_not_readonly() {
assert!(!is_readonly("mv a b"));
}
#[test]
fn mkdir_not_readonly() {
assert!(!is_readonly("mkdir dir"));
}
#[test]
fn chmod_not_readonly() {
assert!(!is_readonly("chmod 755 file"));
}
#[test]
fn git_push_not_readonly() {
assert!(!is_readonly("git push"));
}
#[test]
fn git_commit_not_readonly() {
assert!(!is_readonly("git commit -m 'test'"));
}
#[test]
fn find_delete_not_readonly() {
assert!(!is_readonly("find . -delete"));
}
#[test]
fn find_exec_not_readonly() {
assert!(!is_readonly("find . -exec rm {} \\;"));
}
#[test]
fn sed_inplace_not_readonly() {
assert!(!is_readonly("sed -i 's/old/new/g' file"));
}
#[test]
fn sed_readonly() {
assert!(is_readonly("sed 's/old/new/g' file"));
}
#[test]
fn sort_output_not_readonly() {
assert!(!is_readonly("sort -o output.txt input.txt"));
}
#[test]
fn compound_readonly() {
assert!(is_readonly("ls && echo done"));
}
#[test]
fn compound_not_readonly() {
assert!(!is_readonly("ls && rm file"));
}
#[test]
fn pipeline_readonly() {
assert!(is_readonly("cat file | grep pattern | head -10"));
}
#[test]
fn command_substitution_not_readonly() {
assert!(!is_readonly("echo $(whoami)"));
}
#[test]
fn empty_is_readonly() {
assert!(is_readonly(""));
}
#[test]
fn env_var_prefix_readonly() {
assert!(is_readonly("FOO=bar ls"));
}
#[test]
fn stdout_redirect_not_readonly() {
assert!(!is_readonly("echo hacked > ~/.ssh/authorized_keys"));
}
#[test]
fn append_redirect_not_readonly() {
assert!(!is_readonly("echo alias x >> ~/.bashrc"));
}
#[test]
fn stderr_redirect_not_readonly() {
assert!(!is_readonly("ls 2> err.txt"));
}
#[test]
fn both_redirect_to_file_not_readonly() {
assert!(!is_readonly("ls &> out.txt"));
}
#[test]
fn bare_redirect_not_readonly() {
assert!(!is_readonly("> file"));
}
#[test]
fn stdin_redirect_still_readonly() {
assert!(is_readonly("cat < input.txt"));
}
#[test]
fn stream_merge_still_readonly() {
assert!(is_readonly("cat file 2>&1 | grep pattern"));
}
#[test]
fn devnull_redirect_still_readonly() {
assert!(is_readonly("ls > /dev/null 2>&1"));
}
#[test]
fn compound_with_redirect_not_readonly() {
assert!(!is_readonly("ls && echo hacked > file"));
}
}