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}