1macro_rules! handler_module {
2 ($($sub:ident),+ $(,)?) => {
3 $(mod $sub;)+
4
5 pub(crate) fn dispatch(cmd: &str, tokens: &[crate::parse::Token]) -> Option<crate::verdict::Verdict> {
6 None$(.or_else(|| $sub::dispatch(cmd, tokens)))+
7 }
8
9 pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
10 let mut docs = Vec::new();
11 $(docs.extend($sub::command_docs());)+
12 docs
13 }
14
15 #[cfg(test)]
16 pub(super) fn full_registry() -> Vec<&'static super::CommandEntry> {
17 let mut v = Vec::new();
18 $(v.extend($sub::REGISTRY);)+
19 v
20 }
21 };
22}
23
24pub mod android;
25pub mod coreutils;
26pub mod forges;
27pub mod fuzzy;
28pub mod jvm;
29pub mod magick;
30pub mod network;
31pub mod node;
32pub mod perl;
33pub mod php;
34pub mod ruby;
35pub mod shell;
36pub mod system;
37pub mod vcs;
38pub mod wrappers;
39
40use std::collections::HashMap;
41
42use crate::parse::Token;
43use crate::verdict::Verdict;
44
45type HandlerFn = fn(&[Token]) -> Verdict;
46
47pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
48 HashMap::from([
49 ("magick", magick::is_safe_magick as HandlerFn),
50 ("php", php::is_safe_php as HandlerFn),
51 ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
52 ])
53}
54
55pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
56 HashMap::from([
57 ("bun_x", node::bun::check_bun_x as HandlerFn),
58 ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
59 ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
60 ("git_remote", vcs::git::check_git_remote as HandlerFn),
61 ("laravel_cache_clear", php::check_laravel_cache_clear as HandlerFn),
62 ("plutil_convert", system::plutil::check_plutil_convert as HandlerFn),
63 ])
64}
65
66pub fn dispatch(tokens: &[Token]) -> Verdict {
67 let cmd = tokens[0].command_name();
68 None
69 .or_else(|| shell::dispatch(cmd, tokens))
70 .or_else(|| wrappers::dispatch(cmd, tokens))
71 .or_else(|| forges::dispatch(cmd, tokens))
72 .or_else(|| node::dispatch(cmd, tokens))
73 .or_else(|| jvm::dispatch(cmd, tokens))
74 .or_else(|| android::dispatch(cmd, tokens))
75 .or_else(|| network::dispatch(cmd, tokens))
76 .or_else(|| system::dispatch(cmd, tokens))
77 .or_else(|| perl::dispatch(cmd, tokens))
78 .or_else(|| coreutils::dispatch(cmd, tokens))
79 .or_else(|| fuzzy::dispatch(cmd, tokens))
80 .or_else(|| vcs::dispatch(cmd, tokens))
81 .or_else(|| crate::registry::toml_dispatch(tokens))
82 .unwrap_or(Verdict::Denied)
83}
84
85#[cfg(test)]
86const HANDLED_CMDS: &[&str] = &[
87 "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv", "jai",
88 "git", "jj", "gh", "glab", "jjpr", "tea", "basecamp",
89 "jira", "linear", "notion", "td", "todoist", "trello",
90 "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta", "mocha",
91 "ruby", "ri", "bundle", "gem", "importmap", "rails", "rbenv", "rvm", "brakeman", "rspec",
92 "standardrb", "erb_lint", "erblint", "herb",
93 "reek", "flay", "flog", "fasterer", "haml-lint", "slim-lint",
94 "bundler-audit", "bundle-audit", "ruby-audit", "rdoc", "yard", "yardoc", "rubycritic",
95 "annotaterb", "annotate", "jekyll", "bridgetown", "middleman", "foreman", "guard",
96 "spring", "overcommit", "pry", "byebug", "thor", "m", "rake", "sdoc", "license_finder",
97 "danger", "kamal", "mutant", "whenever", "haml", "slimrb", "railroady", "erd",
98 "parallel_test", "parallel_rspec", "parallel_cucumber", "parallel_spinach",
99 "racc", "rex",
100 "steep", "srb", "rbs", "typeprof", "stree", "rufo", "packwerk", "debride",
101 "i18n-tasks", "asciidoctor", "kramdown", "dawn", "fpm", "stackprof",
102 "pipx", "pip-compile", "pip-sync", "pre-commit", "sphinx-build", "sphinx-quickstart", "sphinx-apidoc",
103 "mkdocs", "twine", "yapf", "autopep8", "autoflake", "pyupgrade", "vulture", "pyflakes",
104 "pycodestyle", "pydocstyle", "cookiecutter", "copier", "deptry", "safety",
105 "http", "https", "ipython", "scalene", "py-spy", "kernprof", "mprof", "dvc", "alembic", "hatch",
106 "husky", "lint-staged", "markdownlint-cli2", "markdownlint", "typedoc", "nodemon", "pm2",
107 "ncu", "depcruise", "dependency-cruise",
108 "rollup", "vite", "esbuild", "swc", "webpack", "parcel", "tsup",
109 "prisma", "drizzle-kit", "sequelize", "knex",
110 "ava", "tap", "c8", "nyc", "jasmine",
111 "http-server", "serve", "concurrently",
112 "npm-run-all", "run-p", "run-s",
113 "tsx", "ts-node", "cucumber-js", "@cucumber/cucumber",
114 "bacon", "sccache", "sqlx", "diesel", "starship", "atuin",
115 "gofmt", "goimports", "gofumpt", "gci", "revive", "errcheck",
116 "gotestsum", "goreleaser", "mage", "task", "buf", "gosec", "gomodifytags", "dlv",
117 "scala", "scalac", "sbt", "mill", "groovy", "lein", "clj", "clojure",
118 "kotlinc", "scalafmt", "scalafix", "jdeps", "jcmd", "jstack",
119 "ghc", "cabal", "stack", "hlint", "ormolu", "fourmolu",
120 "opam", "dune", "ocamlformat",
121 "credo", "iex",
122 "clang-format", "clang-tidy", "cppcheck", "doxygen", "autoconf", "automake", "cmake-format",
123 "crystal", "shards", "ameba",
124 "nim", "nimble",
125 "luarocks", "selene",
126 "julia",
127 "dart", "flutter",
128 "hugo", "zola", "eleventy", "@11ty/eleventy", "gatsby", "astro", "vitepress", "hexo",
129 "op", "bw", "pass", "vault", "gpg", "age", "sops",
130 "terraform-docs", "tflint", "tfsec", "terragrunt", "ansible-lint", "helmfile",
131 "argocd", "skaffold", "tilt", "consul", "nomad",
132 "jupyter", "jupytext", "nbqa", "jupyter-nbconvert", "nbconvert", "nbstripout",
133 "mlflow", "wandb", "papermill", "dbt",
134 "rebar3", "fantomas", "cpan", "cpanm", "plenv", "carton",
135 "latexmk", "pdflatex", "xelatex", "lualatex", "latex", "biber",
136 "dub", "sbcl", "ros", "raco", "gleam", "roc",
137 "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
138 "cargo", "rustup",
139 "go",
140 "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
141 "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
142 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
143 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
144 "fastlane", "firebase",
145 "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
146 "swift",
147 "dotnet",
148 "curl",
149 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
150 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
151 "ddev", "dcli",
152 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
153 "pg_dump", "bazel", "meson", "ninja",
154 "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
155 "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
156 "cx", "hey", "wrangler", "cf", "newrelic",
157 "aws", "gcloud", "az",
158 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
159 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
160 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
161 "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
162 "monolith",
163 "cloudflared", "ngrok", "ssh",
164 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
165 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
166 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
167 "perl",
168 "R", "Rscript",
169 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
170 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
171 "diff", "comm", "paste", "tac", "rev", "nl",
172 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
173 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
174 "arch", "command", "hostname",
175 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
176 "magick", "convert", "frames",
177 "fd", "eza", "exa", "ls", "delta", "colordiff",
178 "dirname", "basename", "realpath", "readlink",
179 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
180 "true", "false", ":", "shopt",
181 "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
182 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
183 "who", "w", "last", "lastlog",
184 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
185 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
186 "base64", "xxd", "getconf", "uuidgen",
187 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
188 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
189 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
190 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
191 "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
192 "xv",
193 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
194 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
195 "tldr", "ldd", "objdump", "readelf", "just",
196 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
197 "rubocop", "eslint", "biome", "stylelint", "zoxide",
198 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
199 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
200 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
201 "@astrojs/check", "@changesets/cli",
202 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
203 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
204 "@microsoft/api-extractor", "@asyncapi/cli",
205 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
206 "depcheck", "madge", "license-checker",
207 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
208 "direnv", "make", "packer", "vagrant",
209 "node", "python3", "python", "rustc", "java", "php",
210 "gcc", "g++", "cc", "c++", "clang", "clang++",
211 "elixir", "erl", "mix", "zig", "lua", "tsc",
212 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
213 "git-cliff", "git-lfs", "tig",
214 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
215];
216
217pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
218 let mut docs = Vec::new();
219 docs.extend(forges::command_docs());
220 docs.extend(node::command_docs());
221 docs.extend(jvm::command_docs());
222 docs.extend(android::command_docs());
223 docs.extend(network::command_docs());
224 docs.extend(system::command_docs());
225 docs.extend(perl::command_docs());
226 docs.extend(coreutils::command_docs());
227 docs.extend(fuzzy::command_docs());
228 docs.extend(shell::command_docs());
229 docs.extend(wrappers::command_docs());
230 docs.extend(vcs::command_docs());
231 docs.extend(crate::registry::toml_command_docs());
232 docs
233}
234
235#[cfg(test)]
236#[derive(Debug)]
237pub(crate) enum CommandEntry {
238 Positional { cmd: &'static str },
239 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
240 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
241 Delegation { cmd: &'static str },
242}
243
244pub fn all_opencode_patterns() -> Vec<String> {
245 let mut patterns = Vec::new();
246 patterns.sort();
247 patterns.dedup();
248 patterns
249}
250
251#[cfg(test)]
252fn full_registry() -> Vec<&'static CommandEntry> {
253 let mut entries = Vec::new();
254 entries.extend(shell::REGISTRY);
255 entries.extend(wrappers::REGISTRY);
256 entries.extend(forges::full_registry());
257 entries.extend(node::full_registry());
258 entries.extend(jvm::full_registry());
259 entries.extend(android::full_registry());
260 entries.extend(network::REGISTRY);
261 entries.extend(system::full_registry());
262 entries.extend(perl::REGISTRY);
263 entries.extend(coreutils::full_registry());
264 entries.extend(fuzzy::full_registry());
265 entries
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use std::collections::HashSet;
272
273 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
274 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
275
276 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
277 match entry {
278 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
279 CommandEntry::Custom { cmd, valid_prefix } => {
280 let base = valid_prefix.unwrap_or(cmd);
281 let test = format!("{base} {UNKNOWN_FLAG}");
282 if crate::is_safe_command(&test) {
283 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
284 }
285 }
286 CommandEntry::Paths { cmd, bare_ok, paths } => {
287 if !bare_ok && crate::is_safe_command(cmd) {
288 failures.push(format!("{cmd}: accepted bare invocation"));
289 }
290 let test = format!("{cmd} {UNKNOWN_SUB}");
291 if crate::is_safe_command(&test) {
292 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
293 }
294 for path in *paths {
295 let test = format!("{path} {UNKNOWN_FLAG}");
296 if crate::is_safe_command(&test) {
297 failures.push(format!("{path}: accepted unknown flag: {test}"));
298 }
299 }
300 }
301 }
302 }
303
304 #[test]
305 fn all_commands_reject_unknown() {
306 let registry = full_registry();
307 let mut failures = Vec::new();
308 for entry in ®istry {
309 check_entry(entry, &mut failures);
310 }
311 assert!(
312 failures.is_empty(),
313 "unknown flags/subcommands accepted:\n{}",
314 failures.join("\n")
315 );
316 }
317
318 #[test]
319 fn process_substitution_safe_inner() {
320 let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
321 for cmd in &safe {
322 assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
323 }
324 }
325
326 #[test]
327 fn process_substitution_unsafe_inner() {
328 let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
329 for cmd in &unsafe_cmds {
330 assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
331 }
332 }
333
334 #[test]
335 fn registry_covers_handled_commands() {
336 let registry = full_registry();
337 let mut all_cmds: HashSet<&str> = registry
338 .iter()
339 .map(|e| match e {
340 CommandEntry::Positional { cmd }
341 | CommandEntry::Custom { cmd, .. }
342 | CommandEntry::Paths { cmd, .. }
343 | CommandEntry::Delegation { cmd } => *cmd,
344 })
345 .collect();
346 for name in crate::registry::toml_command_names() {
347 all_cmds.insert(name);
348 }
349 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
350
351 let missing: Vec<_> = handled.difference(&all_cmds).collect();
352 assert!(missing.is_empty(), "not in registry: {missing:?}");
353
354 let extra: Vec<_> = all_cmds.difference(&handled).collect();
355 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
356 }
357
358}