jevhooks.git / crates / jevhooks-mod / src / read_only.rs
1//! Which shell commands are plainly read-only, so they are never sent to the
2//! daemon: no round trip, no question, no cost.
3//!
4//! This errs one way only. A command it accepts must be unable to change
5//! anything; a command it rejects is merely judged by Jev instead. So it does
6//! not parse shell: any character that could chain, redirect, substitute,
7//! glob or quote makes the command "not plainly read-only", and what is left
8//! is a program name and plain words, checked against a short table.
9
10/// Characters that give a command line more than one meaning.
11const SHELL_SYNTAX: &[char] =
12    &[';', '|', '&', '<', '>', '$', '`', '(', ')', '{', '}', '*', '?', '~', '!', '#', '\\', '\'', '"', '\n', '\r'];
13
14/// A word that could point outside the project: absolute, or climbing out.
15fn leaves_project(word: &str) -> bool {
16    word.starts_with('/') || word.split('/').any(|part| part == "..") || word.contains("=/")
17}
18
19/// Whether `git <args>` only reads. The subcommand must come first: options
20/// 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}
32
33/// 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}
56
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}