Which shell commands are plainly read-only, so they are never sent to the daemon: no round trip, no question, no cost.
This errs one way only. A command it accepts must be unable to change anything; a command it rejects is merely judged by Jev instead. So it does not parse shell: any character that could chain, redirect, substitute, glob or quote makes the command "not plainly read-only", and what is left is a program name and plain words, checked against a short table.
Characters that give a command line more than one meaning.
A word that could point outside the project: absolute, or climbing out.
Whether git <args> only reads. The subcommand must come first: options
before it (-c, -C, --exec-path) can change what git runs or where.
21fn git_reads(args: &[&str]) -> bool { 22 let Some((subcommand, rest)) = args.split_first() else { return false }; 23 let no_output_file = !rest.iter().any(|arg| arg.starts_with("--output") || *arg == "-o"); 24 match *subcommand { 25 "status" | "log" | "diff" | "show" | "blame" | "rev-parse" | "describe" | "ls-files" => no_output_file, 26 // Anything else after `branch` or `remote` creates, renames or deletes. 27 "branch" => rest.iter().all(|arg| matches!(*arg, "-a" | "-r" | "-v" | "-vv" | "--all" | "--list" | "--show-current")), 28 "remote" => rest.iter().all(|arg| *arg == "-v"), 29 _ => false, 30 } 31}
Whether command can only read.
34pub fn is_read_only(command: &str) -> bool { 35 if command.contains(SHELL_SYNTAX) { 36 return false; 37 } 38 let words: Vec<&str> = command.split_whitespace().collect(); 39 let Some((program, args)) = words.split_first() else { return false }; 40 if args.iter().any(|arg| leaves_project(arg)) { 41 return false; 42 } 43 match *program { 44 "ls" | "pwd" | "cat" | "head" | "tail" | "wc" | "file" | "stat" | "du" | "df" | "whoami" | "which" | "echo" 45 | "grep" => true, 46 // `--pre` runs a command of the caller's choosing on every file. 47 "rg" => !args.iter().any(|arg| arg.starts_with("--pre")), 48 // `-o` writes a file. 49 "tree" => !args.iter().any(|arg| arg.starts_with("-o")), 50 // `-s` sets the clock. 51 "date" => !args.iter().any(|arg| arg.starts_with("-s") || arg.starts_with("--set")), 52 "git" => git_reads(args), 53 _ => false, 54 } 55}
57#[cfg(test)] 58mod tests { 59 use super::is_read_only; 60 61 #[test] 62 fn plain_reads_are_read_only() { 63 for command in ["ls", "ls -la src", "pwd", "cat README.md", "git status", "git log --oneline -5", "git diff HEAD", "rg -n TODO src", "wc -l src/lib.rs", "git branch -a", "git remote -v"] { 64 assert!(is_read_only(command), "{command}"); 65 } 66 } 67 68 #[test] 69 fn anything_that_could_write_is_not() { 70 for command in [ 71 "", 72 "rm -rf target", 73 "ls; rm -rf target", 74 "ls && rm x", 75 "cat a > b", 76 "echo hi >> ~/.bashrc", 77 "ls $(rm x)", 78 "ls `rm x`", 79 "cat 'a b'", 80 "git push", 81 "git branch -D main", 82 "git branch new", 83 "git remote add x y", 84 "git -c core.pager=sh log", 85 "git diff --output=x", 86 "git log -o x", 87 "rg --pre sh x", 88 "tree -o out", 89 "date -s tomorrow", 90 "sed -i s/a/b/ x", 91 "find . -delete", 92 "cargo test", 93 ] { 94 assert!(!is_read_only(command), "{command}"); 95 } 96 } 97 98 #[test] 99 fn reading_outside_the_project_is_left_to_jev() { 100 for command in ["cat /etc/shadow", "ls ../other", "cat src/../../x", "rg x --file=/etc/passwd"] { 101 assert!(!is_read_only(command), "{command}"); 102 } 103 } 104}