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}