1pub mod android;
2pub mod coreutils;
3pub mod forges;
4pub mod fuzzy;
5pub mod jvm;
6pub mod network;
7pub mod node;
8pub mod perl;
9pub mod r;
10pub mod ruby;
11pub mod shell;
12pub mod system;
13pub mod vcs;
14pub mod wrappers;
15pub mod xcode;
16
17use std::collections::HashMap;
18
19use crate::parse::Token;
20use crate::verdict::Verdict;
21
22type HandlerFn = fn(&[Token]) -> Verdict;
23
24pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
25 HashMap::new()
26}
27
28pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
29 HashMap::new()
30}
31
32pub fn dispatch(tokens: &[Token]) -> Verdict {
33 let cmd = tokens[0].command_name();
34 None
35 .or_else(|| shell::dispatch(cmd, tokens))
36 .or_else(|| wrappers::dispatch(cmd, tokens))
37 .or_else(|| vcs::dispatch(cmd, tokens))
38 .or_else(|| forges::dispatch(cmd, tokens))
39 .or_else(|| node::dispatch(cmd, tokens))
40 .or_else(|| ruby::dispatch(cmd, tokens))
41 .or_else(|| jvm::dispatch(cmd, tokens))
42 .or_else(|| android::dispatch(cmd, tokens))
43 .or_else(|| network::dispatch(cmd, tokens))
44 .or_else(|| system::dispatch(cmd, tokens))
45 .or_else(|| xcode::dispatch(cmd, tokens))
46 .or_else(|| perl::dispatch(cmd, tokens))
47 .or_else(|| r::dispatch(cmd, tokens))
48 .or_else(|| coreutils::dispatch(cmd, tokens))
49 .or_else(|| fuzzy::dispatch(cmd, tokens))
50 .or_else(|| crate::registry::toml_dispatch(tokens))
51 .unwrap_or(Verdict::Denied)
52}
53
54#[cfg(test)]
55const HANDLED_CMDS: &[&str] = &[
56 "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
57 "git", "jj", "gh", "glab", "jjpr", "tea",
58 "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta",
59 "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
60 "pip", "uv", "poetry", "pyenv", "conda",
61 "cargo", "rustup",
62 "go",
63 "gradle", "mvn", "mvnw", "ktlint", "detekt",
64 "javap", "jar", "keytool", "jarsigner",
65 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
66 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
67 "fastlane", "firebase",
68 "composer", "craft",
69 "swift",
70 "dotnet",
71 "curl",
72 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img",
73 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
74 "ddev", "dcli",
75 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
76 "terraform", "heroku", "vercel", "flyctl",
77 "overmind", "tailscale", "tmux", "wg",
78 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
79 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
80 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
81 "perl",
82 "R", "Rscript",
83 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
84 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
85 "diff", "comm", "paste", "tac", "rev", "nl",
86 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
87 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat",
88 "arch", "command", "hostname",
89 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
90 "magick",
91 "fd", "eza", "exa", "ls", "delta", "colordiff",
92 "dirname", "basename", "realpath", "readlink",
93 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
94 "true", "false",
95 "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
96 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
97 "who", "w", "last", "lastlog",
98 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
99 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
100 "base64", "xxd", "getconf", "uuidgen",
101 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
102 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
103 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
104 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
105 "xv",
106 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
107 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
108];
109
110pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
111 let mut docs = Vec::new();
112 docs.extend(vcs::command_docs());
113 docs.extend(forges::command_docs());
114 docs.extend(node::command_docs());
115 docs.extend(ruby::command_docs());
116 docs.extend(jvm::command_docs());
117 docs.extend(android::command_docs());
118 docs.extend(network::command_docs());
119 docs.extend(system::command_docs());
120 docs.extend(xcode::command_docs());
121 docs.extend(perl::command_docs());
122 docs.extend(r::command_docs());
123 docs.extend(coreutils::command_docs());
124 docs.extend(fuzzy::command_docs());
125 docs.extend(shell::command_docs());
126 docs.extend(wrappers::command_docs());
127 docs.extend(crate::registry::toml_command_docs());
128 docs
129}
130
131#[cfg(test)]
132#[derive(Debug)]
133pub(crate) enum CommandEntry {
134 Positional { cmd: &'static str },
135 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
136 Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
137 Delegation { cmd: &'static str },
138}
139
140#[cfg(test)]
141#[derive(Debug)]
142pub(crate) enum SubEntry {
143 Policy { name: &'static str },
144 Nested { name: &'static str, subs: &'static [SubEntry] },
145 Custom { name: &'static str, valid_suffix: Option<&'static str> },
146 Positional,
147 Guarded { name: &'static str, valid_suffix: &'static str },
148}
149
150use crate::command::CommandDef;
151
152const COMMAND_DEFS: &[&CommandDef] = &[
153 &node::BUN,
154 &ruby::BUNDLE,
155 &vcs::GIT,
156];
157
158pub fn all_opencode_patterns() -> Vec<String> {
159 let mut patterns = Vec::new();
160 for def in COMMAND_DEFS {
161 patterns.extend(def.opencode_patterns());
162 }
163 for def in coreutils::all_flat_defs() {
164 patterns.extend(def.opencode_patterns());
165 }
166 for def in jvm::jvm_flat_defs() {
167 patterns.extend(def.opencode_patterns());
168 }
169 for def in android::android_flat_defs() {
170 patterns.extend(def.opencode_patterns());
171 }
172 for def in xcode::xcbeautify_flat_defs() {
173 patterns.extend(def.opencode_patterns());
174 }
175 for def in fuzzy::fuzzy_flat_defs() {
176 patterns.extend(def.opencode_patterns());
177 }
178 patterns.sort();
179 patterns.dedup();
180 patterns
181}
182
183#[cfg(test)]
184fn full_registry() -> Vec<&'static CommandEntry> {
185 let mut entries = Vec::new();
186 entries.extend(shell::REGISTRY);
187 entries.extend(wrappers::REGISTRY);
188 entries.extend(vcs::full_registry());
189 entries.extend(forges::full_registry());
190 entries.extend(node::full_registry());
191 entries.extend(jvm::full_registry());
192 entries.extend(android::full_registry());
193 entries.extend(network::REGISTRY);
194 entries.extend(system::full_registry());
195 entries.extend(xcode::full_registry());
196 entries.extend(perl::REGISTRY);
197 entries.extend(r::REGISTRY);
198 entries.extend(coreutils::full_registry());
199 entries.extend(fuzzy::full_registry());
200 entries
201}
202
203#[cfg(test)]
204mod tests {
205 use super::*;
206 use std::collections::HashSet;
207
208 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
209 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
210
211 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
212 match entry {
213 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
214 CommandEntry::Custom { cmd, valid_prefix } => {
215 let base = valid_prefix.unwrap_or(cmd);
216 let test = format!("{base} {UNKNOWN_FLAG}");
217 if crate::is_safe_command(&test) {
218 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
219 }
220 }
221 CommandEntry::Subcommand { cmd, subs, bare_ok } => {
222 if !bare_ok && crate::is_safe_command(cmd) {
223 failures.push(format!("{cmd}: accepted bare invocation"));
224 }
225 let test = format!("{cmd} {UNKNOWN_SUB}");
226 if crate::is_safe_command(&test) {
227 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
228 }
229 for sub in *subs {
230 check_sub(cmd, sub, failures);
231 }
232 }
233 }
234 }
235
236 fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
237 match entry {
238 SubEntry::Policy { name } => {
239 let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
240 if crate::is_safe_command(&test) {
241 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
242 }
243 }
244 SubEntry::Nested { name, subs } => {
245 let path = format!("{prefix} {name}");
246 let test = format!("{path} {UNKNOWN_SUB}");
247 if crate::is_safe_command(&test) {
248 failures.push(format!("{path}: accepted unknown subcommand: {test}"));
249 }
250 for sub in *subs {
251 check_sub(&path, sub, failures);
252 }
253 }
254 SubEntry::Custom { name, valid_suffix } => {
255 let base = match valid_suffix {
256 Some(s) => format!("{prefix} {name} {s}"),
257 None => format!("{prefix} {name}"),
258 };
259 let test = format!("{base} {UNKNOWN_FLAG}");
260 if crate::is_safe_command(&test) {
261 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
262 }
263 }
264 SubEntry::Positional => {}
265 SubEntry::Guarded { name, valid_suffix } => {
266 let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
267 if crate::is_safe_command(&test) {
268 failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
269 }
270 }
271 }
272 }
273
274 #[test]
275 fn all_commands_reject_unknown() {
276 let registry = full_registry();
277 let mut failures = Vec::new();
278 for entry in ®istry {
279 check_entry(entry, &mut failures);
280 }
281 assert!(
282 failures.is_empty(),
283 "unknown flags/subcommands accepted:\n{}",
284 failures.join("\n")
285 );
286 }
287
288 #[test]
289 fn command_defs_reject_unknown() {
290 for def in COMMAND_DEFS {
291 def.auto_test_reject_unknown();
292 }
293 }
294
295 #[test]
296 fn flat_defs_reject_unknown() {
297 for def in coreutils::all_flat_defs() {
298 def.auto_test_reject_unknown();
299 }
300 for def in xcode::xcbeautify_flat_defs() {
301 def.auto_test_reject_unknown();
302 }
303 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
304 def.auto_test_reject_unknown();
305 }
306 }
307
308
309 #[test]
310 fn bare_false_rejects_bare_invocation() {
311 let check_def = |def: &crate::command::FlatDef| {
312 if !def.policy.bare {
313 assert!(
314 !crate::is_safe_command(def.name),
315 "{}: bare=false but bare invocation accepted",
316 def.name,
317 );
318 }
319 };
320 for def in coreutils::all_flat_defs()
321 .into_iter()
322 .chain(xcode::xcbeautify_flat_defs())
323 {
324 check_def(def);
325 }
326 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
327 check_def(def);
328 }
329 }
330
331 fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
332 for sub in subs {
333 visitor(prefix, sub);
334 if let crate::command::SubDef::Nested { name, subs: inner } = sub {
335 visit_subs(&format!("{prefix} {name}"), inner, visitor);
336 }
337 }
338 }
339
340 #[test]
341 fn guarded_subs_require_guard() {
342 let mut failures = Vec::new();
343 for def in COMMAND_DEFS {
344 visit_subs(def.name, def.subs, &mut |prefix, sub| {
345 if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
346 let without = format!("{prefix} {name}");
347 if crate::is_safe_command(&without) {
348 failures.push(format!("{without}: accepted without guard {guard_long}"));
349 }
350 let with = format!("{prefix} {name} {guard_long}");
351 if !crate::is_safe_command(&with) {
352 failures.push(format!("{with}: rejected with guard {guard_long}"));
353 }
354 }
355 });
356 }
357 assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
358 }
359
360 #[test]
361 fn guarded_subs_accept_guard_short() {
362 let mut failures = Vec::new();
363 for def in COMMAND_DEFS {
364 visit_subs(def.name, def.subs, &mut |prefix, sub| {
365 if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
366 let with_short = format!("{prefix} {name} {short}");
367 if !crate::is_safe_command(&with_short) {
368 failures.push(format!("{with_short}: rejected with guard_short"));
369 }
370 }
371 });
372 }
373 assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
374 }
375
376 #[test]
377 fn nested_subs_reject_bare() {
378 let mut failures = Vec::new();
379 for def in COMMAND_DEFS {
380 visit_subs(def.name, def.subs, &mut |prefix, sub| {
381 if let crate::command::SubDef::Nested { name, .. } = sub {
382 let bare = format!("{prefix} {name}");
383 if crate::is_safe_command(&bare) {
384 failures.push(format!("{bare}: nested sub accepted bare invocation"));
385 }
386 }
387 });
388 }
389 assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
390 }
391
392 #[test]
393 fn process_substitution_blocked() {
394 let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
395 for cmd in &cmds {
396 assert!(
397 !crate::is_safe_command(cmd),
398 "process substitution not blocked: {cmd}",
399 );
400 }
401 }
402
403 #[test]
404 fn positional_style_accepts_unknown_args() {
405 use crate::policy::FlagStyle;
406 for def in coreutils::all_flat_defs() {
407 if def.policy.flag_style == FlagStyle::Positional {
408 let test = format!("{} --unknown-xyz", def.name);
409 assert!(
410 crate::is_safe_command(&test),
411 "{}: FlagStyle::Positional but rejected unknown arg",
412 def.name,
413 );
414 }
415 }
416 }
417
418 fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
419 for sub in subs {
420 match sub {
421 crate::command::SubDef::Policy { name, policy, .. } => {
422 visitor(&format!("{prefix} {name}"), policy);
423 }
424 crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
425 visitor(&format!("{prefix} {name} {guard_long}"), policy);
426 }
427 crate::command::SubDef::Nested { name, subs: inner } => {
428 visit_policies(&format!("{prefix} {name}"), inner, visitor);
429 }
430 _ => {}
431 }
432 }
433 }
434
435 #[test]
436 fn valued_flags_accept_eq_syntax() {
437 let mut failures = Vec::new();
438
439 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
440 for flag in def.policy.valued.iter() {
441 let cmd = format!("{} {flag}=test_value", def.name);
442 if !crate::is_safe_command(&cmd) {
443 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
444 }
445 }
446 };
447 for def in coreutils::all_flat_defs()
448 .into_iter()
449 .chain(xcode::xcbeautify_flat_defs())
450 {
451 check_flat(def, &mut failures);
452 }
453 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
454 check_flat(def, &mut failures);
455 }
456
457 for def in COMMAND_DEFS {
458 visit_policies(def.name, def.subs, &mut |prefix, policy| {
459 for flag in policy.valued.iter() {
460 let cmd = format!("{prefix} {flag}=test_value");
461 if !crate::is_safe_command(&cmd) {
462 failures.push(format!("{cmd}: valued flag rejected with = syntax"));
463 }
464 }
465 });
466 }
467
468 assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
469 }
470
471 #[test]
472 fn max_positional_enforced() {
473 let mut failures = Vec::new();
474
475 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
476 if let Some(max) = def.policy.max_positional {
477 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
478 let cmd = format!("{} {}", def.name, args.join(" "));
479 if crate::is_safe_command(&cmd) {
480 failures.push(format!(
481 "{}: max_positional={max} but accepted {} positional args",
482 def.name,
483 max + 1,
484 ));
485 }
486 }
487 };
488 for def in coreutils::all_flat_defs()
489 .into_iter()
490 .chain(xcode::xcbeautify_flat_defs())
491 {
492 check_flat(def, &mut failures);
493 }
494 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
495 check_flat(def, &mut failures);
496 }
497
498 for def in COMMAND_DEFS {
499 visit_policies(def.name, def.subs, &mut |prefix, policy| {
500 if let Some(max) = policy.max_positional {
501 let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
502 let cmd = format!("{prefix} {}", args.join(" "));
503 if crate::is_safe_command(&cmd) {
504 failures.push(format!(
505 "{prefix}: max_positional={max} but accepted {} positional args",
506 max + 1,
507 ));
508 }
509 }
510 });
511 }
512
513 assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
514 }
515
516 #[test]
517 fn doc_generation_non_empty() {
518 let mut failures = Vec::new();
519
520 for def in COMMAND_DEFS {
521 let doc = def.to_doc();
522 if doc.description.trim().is_empty() {
523 failures.push(format!("{}: CommandDef produced empty doc", def.name));
524 }
525 if doc.url.is_empty() {
526 failures.push(format!("{}: CommandDef has empty URL", def.name));
527 }
528 }
529
530 let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
531 let doc = def.to_doc();
532 if doc.description.trim().is_empty() && !def.policy.bare {
533 failures.push(format!("{}: FlatDef produced empty doc", def.name));
534 }
535 if doc.url.is_empty() {
536 failures.push(format!("{}: FlatDef has empty URL", def.name));
537 }
538 };
539 for def in coreutils::all_flat_defs()
540 .into_iter()
541 .chain(xcode::xcbeautify_flat_defs())
542 {
543 check_flat(def, &mut failures);
544 }
545 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
546 check_flat(def, &mut failures);
547 }
548
549 assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
550 }
551
552 #[test]
553 fn registry_covers_handled_commands() {
554 let registry = full_registry();
555 let mut all_cmds: HashSet<&str> = registry
556 .iter()
557 .map(|e| match e {
558 CommandEntry::Positional { cmd }
559 | CommandEntry::Custom { cmd, .. }
560 | CommandEntry::Subcommand { cmd, .. }
561 | CommandEntry::Delegation { cmd } => *cmd,
562 })
563 .collect();
564 for def in COMMAND_DEFS {
565 all_cmds.insert(def.name);
566 }
567 for def in coreutils::all_flat_defs() {
568 all_cmds.insert(def.name);
569 }
570 for def in xcode::xcbeautify_flat_defs() {
571 all_cmds.insert(def.name);
572 }
573 for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
574 all_cmds.insert(def.name);
575 }
576 for name in crate::registry::toml_command_names() {
577 all_cmds.insert(name);
578 }
579 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
580
581 let missing: Vec<_> = handled.difference(&all_cmds).collect();
582 assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
583
584 let extra: Vec<_> = all_cmds.difference(&handled).collect();
585 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
586 }
587
588}