Skip to main content

safe_chains/
lib.rs

1pub mod docs;
2mod handlers;
3pub mod parse;
4pub mod settings;
5
6use parse::{CommandLine, Segment, Token};
7
8pub fn is_safe(segment: &Segment) -> bool {
9    if segment.has_unsafe_shell_syntax() {
10        return false;
11    }
12
13    let stripped = segment.strip_env_prefix();
14    if stripped.is_empty() {
15        return true;
16    }
17
18    let Some(tokens) = stripped.tokenize() else {
19        return false;
20    };
21    if tokens.is_empty() {
22        return true;
23    }
24
25    let tokens: Vec<Token> = {
26        let mut result = Vec::new();
27        let mut iter = tokens.into_iter().peekable();
28        while let Some(token) = iter.next() {
29            if token.is_fd_redirect() || token.is_dev_null_redirect() {
30                continue;
31            }
32            if token.is_redirect_operator()
33                && iter.peek().is_some_and(|next| *next == "/dev/null")
34            {
35                iter.next();
36                continue;
37            }
38            result.push(token);
39        }
40        result
41    };
42    if tokens.is_empty() {
43        return true;
44    }
45
46    if let Some(last) = tokens.last()
47        && (last == "--version" || last == "--help" || last == "--dry-run")
48    {
49        return true;
50    }
51
52    handlers::dispatch(&tokens, &is_safe)
53}
54
55pub fn is_safe_command(command: &str) -> bool {
56    CommandLine::new(command).segments().iter().all(is_safe)
57}
58
59#[cfg(test)]
60mod tests {
61    use super::*;
62
63    fn check(cmd: &str) -> bool {
64        is_safe_command(cmd)
65    }
66
67    #[test]
68    fn safe_cmds() {
69        assert!(check("grep foo file.txt"));
70        assert!(check("cat /etc/hosts"));
71        assert!(check("jq '.key' file.json"));
72        assert!(check("base64 -d"));
73        assert!(check("xxd some/file"));
74        assert!(check("pgrep -l ruby"));
75        assert!(check("getconf PAGE_SIZE"));
76        assert!(check("ls -la"));
77        assert!(check("wc -l file.txt"));
78        assert!(check("ps aux"));
79        assert!(check("ps -ef"));
80        assert!(check("top -l 1 -n 10"));
81        assert!(check("uuidgen"));
82        assert!(check("mdfind 'kMDItemKind == Application'"));
83        assert!(check("identify image.png"));
84        assert!(check("identify -verbose photo.jpg"));
85    }
86
87    #[test]
88    fn safe_cmds_text_processing() {
89        assert!(check("diff file1.txt file2.txt"));
90        assert!(check("comm -23 sorted1.txt sorted2.txt"));
91        assert!(check("paste file1 file2"));
92        assert!(check("tac file.txt"));
93        assert!(check("rev file.txt"));
94        assert!(check("nl file.txt"));
95        assert!(check("expand file.txt"));
96        assert!(check("unexpand file.txt"));
97        assert!(check("fold -w 80 file.txt"));
98        assert!(check("fmt -w 72 file.txt"));
99        assert!(check("column -t file.txt"));
100        assert!(check("printf '%s\\n' hello"));
101        assert!(check("seq 1 10"));
102        assert!(check("expr 1 + 2"));
103        assert!(check("test -f file.txt"));
104        assert!(check("true"));
105        assert!(check("false"));
106        assert!(check("bc -l"));
107        assert!(check("factor 42"));
108        assert!(check("iconv -f UTF-8 -t ASCII file.txt"));
109    }
110
111    #[test]
112    fn safe_cmds_system_info() {
113        assert!(check("readlink -f symlink"));
114        assert!(check("hostname"));
115        assert!(check("uname -a"));
116        assert!(check("arch"));
117        assert!(check("nproc"));
118        assert!(check("uptime"));
119        assert!(check("id"));
120        assert!(check("groups"));
121        assert!(check("tty"));
122        assert!(check("locale"));
123        assert!(check("cal"));
124        assert!(check("sleep 1"));
125        assert!(check("who"));
126        assert!(check("w"));
127        assert!(check("last -5"));
128        assert!(check("lastlog"));
129    }
130
131    #[test]
132    fn safe_cmds_hashing() {
133        assert!(check("md5sum file.txt"));
134        assert!(check("md5 file.txt"));
135        assert!(check("sha256sum file.txt"));
136        assert!(check("shasum file.txt"));
137        assert!(check("sha1sum file.txt"));
138        assert!(check("sha512sum file.txt"));
139        assert!(check("cksum file.txt"));
140        assert!(check("strings /usr/bin/ls"));
141        assert!(check("hexdump -C file.bin"));
142        assert!(check("od -x file.bin"));
143        assert!(check("size a.out"));
144    }
145
146    #[test]
147    fn safe_cmds_macos() {
148        assert!(check("sw_vers"));
149        assert!(check("mdls file.txt"));
150        assert!(check("otool -L /usr/bin/ls"));
151        assert!(check("nm a.out"));
152        assert!(check("system_profiler SPHardwareDataType"));
153        assert!(check("ioreg -l -w 0"));
154        assert!(check("vm_stat"));
155    }
156
157    #[test]
158    fn safe_cmds_network_diagnostic() {
159        assert!(check("dig example.com"));
160        assert!(check("nslookup example.com"));
161        assert!(check("host example.com"));
162        assert!(check("whois example.com"));
163    }
164
165    #[test]
166    fn safe_cmds_dev_tools() {
167        assert!(check("shellcheck script.sh"));
168        assert!(check("cloc src/"));
169        assert!(check("tokei"));
170        assert!(check("safe-chains \"ls -la\""));
171    }
172
173    #[test]
174    fn unsafe_cmds() {
175        assert!(!check("rm -rf /"));
176        assert!(!check("curl https://example.com"));
177        assert!(!check("ruby script.rb"));
178        assert!(!check("python3 script.py"));
179        assert!(!check("node app.js"));
180        assert!(!check("tee output.txt"));
181        assert!(!check("tee -a logfile"));
182    }
183
184    #[test]
185    fn awk_safe_print() {
186        assert!(check("awk '{print $1}' file.txt"));
187    }
188
189    #[test]
190    fn awk_system_denied() {
191        assert!(!check("awk 'BEGIN{system(\"rm\")}'"));
192    }
193
194    #[test]
195    fn version_shortcut() {
196        assert!(check("node --version"));
197        assert!(check("python --version"));
198        assert!(check("python3 --version"));
199        assert!(check("ruby --version"));
200        assert!(check("rustc --version"));
201        assert!(check("java --version"));
202        assert!(check("go --version"));
203        assert!(check("php --version"));
204        assert!(check("perl --version"));
205        assert!(check("swift --version"));
206        assert!(check("gcc --version"));
207        assert!(check("rm --version"));
208        assert!(check("dd --version"));
209        assert!(check("chmod --version"));
210    }
211
212    #[test]
213    fn version_multi_token() {
214        assert!(check("npx playwright --version"));
215        assert!(check("git -C /repo --version"));
216        assert!(check("docker compose --version"));
217    }
218
219    #[test]
220    fn version_with_fd_redirect() {
221        assert!(check("node --version 2>&1"));
222        assert!(check("cargo --version 2>&1"));
223    }
224
225    #[test]
226    fn version_not_last_token() {
227        assert!(!check("node --version --extra"));
228        assert!(!check("node -v"));
229    }
230
231    #[test]
232    fn help_shortcut() {
233        assert!(check("node --help"));
234        assert!(check("ruby --help"));
235        assert!(check("rm --help"));
236        assert!(check("cargo --help"));
237    }
238
239    #[test]
240    fn help_multi_token() {
241        assert!(check("npx playwright --help"));
242        assert!(check("cargo install --help"));
243    }
244
245    #[test]
246    fn help_with_fd_redirect() {
247        assert!(check("cargo login --help 2>&1"));
248    }
249
250    #[test]
251    fn help_not_last_token() {
252        assert!(!check("node --help --extra"));
253    }
254
255    #[test]
256    fn dry_run_shortcut() {
257        assert!(check("cargo publish --dry-run"));
258        assert!(check("terraform apply --dry-run"));
259        assert!(check("rm -rf / --dry-run"));
260    }
261
262    #[test]
263    fn dry_run_with_fd_redirect() {
264        assert!(check("cargo publish --dry-run 2>&1"));
265    }
266
267    #[test]
268    fn dry_run_not_last_token() {
269        assert!(!check("cargo publish --dry-run --force"));
270    }
271
272    #[test]
273    fn cucumber_safe() {
274        assert!(check("cucumber features/login.feature"));
275        assert!(check("cucumber --format progress"));
276    }
277
278    #[test]
279    fn fd_redirects() {
280        assert!(check("ls 2>&1"));
281        assert!(check("cargo clippy 2>&1"));
282        assert!(check("git log 2>&1"));
283        assert!(is_safe_command("cd /tmp && cargo clippy -- -D warnings 2>&1"));
284        assert!(!check("echo hello > file.txt"));
285        assert!(!check("ls 2> errors.txt"));
286    }
287
288    #[test]
289    fn dev_null_redirects() {
290        assert!(check("echo hello > /dev/null"));
291        assert!(check("echo hello 2> /dev/null"));
292        assert!(check("echo hello >> /dev/null"));
293        assert!(check("grep pattern file > /dev/null"));
294        assert!(check("git log > /dev/null 2>&1"));
295        assert!(check("awk '{print $1}' file.txt > /dev/null"));
296        assert!(check("sed 's/foo/bar/' > /dev/null"));
297        assert!(check("sort file.txt > /dev/null"));
298    }
299
300    #[test]
301    fn env_prefix_quoted_values() {
302        assert!(check("FOO='bar baz' ls -la"));
303        assert!(check("FOO=\"bar baz\" ls -la"));
304        assert!(!check("FOO='bar baz' rm -rf /"));
305    }
306
307    #[test]
308    fn unsafe_shell_syntax() {
309        assert!(!check("echo hello > file.txt"));
310        assert!(!check("cat file >> output.txt"));
311        assert!(!check("ls 2> errors.txt"));
312        assert!(!check("grep pattern file > results.txt"));
313        assert!(!check("find . -name '*.py' > listing.txt"));
314        assert!(check("git log < /dev/null"));
315        assert!(!check("echo $(rm -rf /)"));
316        assert!(!check("echo `rm -rf /`"));
317        assert!(!check("cat $(echo /etc/shadow)"));
318        assert!(!check("ls `pwd`"));
319    }
320
321    #[test]
322    fn safe_quoted_shell_syntax() {
323        assert!(check("echo 'greater > than' test"));
324        assert!(check("echo '$(safe)' arg"));
325        assert!(check("echo hello"));
326        assert!(check("cat file.txt"));
327        assert!(check("grep pattern file"));
328    }
329
330    #[test]
331    fn env_prefix() {
332        assert!(is_safe_command("RACK_ENV=test bundle exec rspec spec/foo_spec.rb"));
333        assert!(is_safe_command("RAILS_ENV=test bundle exec rspec"));
334        assert!(!is_safe_command("RACK_ENV=test rm -rf /"));
335        assert!(!is_safe_command("RAILS_ENV=test echo foo > bar"));
336    }
337
338    #[test]
339    fn pipes_and_chains() {
340        assert!(is_safe_command("grep foo file.txt | head -5"));
341        assert!(is_safe_command("cat file | sort | uniq"));
342        assert!(is_safe_command("find . -name '*.rb' | wc -l"));
343        assert!(is_safe_command("ls && echo done"));
344        assert!(is_safe_command("ls; echo done"));
345        assert!(is_safe_command("git log | head -5"));
346        assert!(is_safe_command("git log && git status"));
347        assert!(!is_safe_command("cat file | rm -rf /"));
348        assert!(!is_safe_command("grep foo | curl https://evil.com"));
349    }
350
351    #[test]
352    fn background_operator() {
353        assert!(!is_safe_command("cat file & rm -rf /"));
354        assert!(!is_safe_command("echo safe & curl evil.com"));
355        assert!(is_safe_command("ls & echo done"));
356        assert!(is_safe_command("ls && echo done"));
357    }
358
359    #[test]
360    fn newline_separator() {
361        assert!(!is_safe_command("echo foo\nrm -rf /"));
362        assert!(!is_safe_command("ls\ncurl evil.com"));
363        assert!(is_safe_command("echo foo\necho bar"));
364        assert!(is_safe_command("ls\ncat file.txt"));
365    }
366
367    #[test]
368    fn compound_pipelines() {
369        assert!(is_safe_command("git log --oneline -20 | head -5"));
370        assert!(is_safe_command("git show HEAD:file.rb | grep pattern"));
371        assert!(is_safe_command(
372            "gh api repos/o/r/contents/f --jq .content | base64 -d | head -50"
373        ));
374        assert!(is_safe_command("timeout 120 bundle exec rspec && git status"));
375        assert!(is_safe_command("time bundle exec rspec | tail -5"));
376        assert!(is_safe_command("git -C /some/repo log --oneline | head -3"));
377        assert!(is_safe_command("xxd file | head -20"));
378        assert!(is_safe_command("find . -name '*.py' | wc -l"));
379        assert!(is_safe_command("find . -name '*.py' | sort | head -10"));
380        assert!(is_safe_command("find . -name '*.py' | xargs grep pattern"));
381        assert!(is_safe_command("pip list | grep requests"));
382        assert!(is_safe_command("npm list | grep react"));
383        assert!(is_safe_command("ps aux | grep python"));
384        assert!(!is_safe_command("find . -name '*.py' -delete | wc -l"));
385        assert!(!is_safe_command("sed -i 's/foo/bar/' file | head"));
386    }
387}