Skip to main content

safe_chains/
lib.rs

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