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 "ffmpeg", "ffprobe", "exiftool", "mediainfo",
138 "jpegoptim", "optipng", "pngquant", "gifsicle", "cwebp", "sox",
139 "pandoc", "marp",
140 "rsync", "rclone", "restic", "borg", "mc",
141 "mysql", "mariadb", "mongosh", "redis-cli", "sqlite3", "duckdb", "usql", "pg_restore",
142 "entr", "parallel", "ts", "sponge", "vipe", "vidir", "chronic", "ifne", "errno", "isutf8", "pee",
143 "buildah", "velero", "flux", "linkerd", "istioctl", "kapp", "ytt",
144 "nu", "pscale", "supabase", "neon", "neonctl",
145 "nuget", "paket", "msbuild", "cake", "nuke", "docfx",
146 "mono", "mcs", "fsi", "dotnet-fsi", "csi",
147 "dotnet-ef", "dotnet-script", "dotnet-counters", "dotnet-dump",
148 "dotnet-trace", "dotnet-stack", "dotnet-gcdump", "dotnet-sos", "dotnet-symbol",
149 "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
150 "cargo", "rustup",
151 "go",
152 "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
153 "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
154 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
155 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
156 "fastlane", "firebase",
157 "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
158 "swift",
159 "dotnet",
160 "curl",
161 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
162 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
163 "ddev", "dcli",
164 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
165 "pg_dump", "bazel", "meson", "ninja",
166 "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
167 "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
168 "cx", "hey", "wrangler", "cf", "newrelic",
169 "aws", "gcloud", "az",
170 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
171 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
172 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
173 "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
174 "monolith",
175 "cloudflared", "ngrok", "ssh",
176 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
177 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
178 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
179 "perl",
180 "R", "Rscript",
181 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
182 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
183 "diff", "comm", "paste", "tac", "rev", "nl",
184 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
185 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
186 "arch", "command", "hostname",
187 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
188 "magick", "convert", "frames",
189 "fd", "eza", "exa", "ls", "delta", "colordiff",
190 "dirname", "basename", "realpath", "readlink",
191 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
192 "true", "false", ":", "shopt",
193 "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
194 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
195 "who", "w", "last", "lastlog",
196 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
197 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
198 "base64", "xxd", "getconf", "uuidgen",
199 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
200 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
201 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
202 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
203 "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
204 "xv",
205 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
206 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
207 "tldr", "ldd", "objdump", "readelf", "just",
208 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
209 "rubocop", "eslint", "biome", "stylelint", "zoxide",
210 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
211 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
212 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
213 "@astrojs/check", "@changesets/cli",
214 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
215 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
216 "@microsoft/api-extractor", "@asyncapi/cli",
217 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
218 "depcheck", "madge", "license-checker",
219 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
220 "direnv", "make", "packer", "vagrant",
221 "node", "python3", "python", "rustc", "java", "php",
222 "gcc", "g++", "cc", "c++", "clang", "clang++",
223 "elixir", "erl", "mix", "zig", "lua", "tsc",
224 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
225 "git-cliff", "git-lfs", "tig",
226 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
227];
228
229pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
230 let mut docs = Vec::new();
231 docs.extend(forges::command_docs());
232 docs.extend(node::command_docs());
233 docs.extend(jvm::command_docs());
234 docs.extend(android::command_docs());
235 docs.extend(network::command_docs());
236 docs.extend(system::command_docs());
237 docs.extend(perl::command_docs());
238 docs.extend(coreutils::command_docs());
239 docs.extend(fuzzy::command_docs());
240 docs.extend(shell::command_docs());
241 docs.extend(wrappers::command_docs());
242 docs.extend(vcs::command_docs());
243 docs.extend(crate::registry::toml_command_docs());
244 docs
245}
246
247#[cfg(test)]
248#[derive(Debug)]
249pub(crate) enum CommandEntry {
250 Positional { cmd: &'static str },
251 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
252 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
253 Delegation { cmd: &'static str },
254}
255
256pub fn all_opencode_patterns() -> Vec<String> {
257 let mut patterns = Vec::new();
258 patterns.sort();
259 patterns.dedup();
260 patterns
261}
262
263#[cfg(test)]
264fn full_registry() -> Vec<&'static CommandEntry> {
265 let mut entries = Vec::new();
266 entries.extend(shell::REGISTRY);
267 entries.extend(wrappers::REGISTRY);
268 entries.extend(forges::full_registry());
269 entries.extend(node::full_registry());
270 entries.extend(jvm::full_registry());
271 entries.extend(android::full_registry());
272 entries.extend(network::REGISTRY);
273 entries.extend(system::full_registry());
274 entries.extend(perl::REGISTRY);
275 entries.extend(coreutils::full_registry());
276 entries.extend(fuzzy::full_registry());
277 entries
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283 use std::collections::HashSet;
284
285 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
286 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
287
288 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
289 match entry {
290 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
291 CommandEntry::Custom { cmd, valid_prefix } => {
292 let base = valid_prefix.unwrap_or(cmd);
293 let test = format!("{base} {UNKNOWN_FLAG}");
294 if crate::is_safe_command(&test) {
295 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
296 }
297 }
298 CommandEntry::Paths { cmd, bare_ok, paths } => {
299 if !bare_ok && crate::is_safe_command(cmd) {
300 failures.push(format!("{cmd}: accepted bare invocation"));
301 }
302 let test = format!("{cmd} {UNKNOWN_SUB}");
303 if crate::is_safe_command(&test) {
304 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
305 }
306 for path in *paths {
307 let test = format!("{path} {UNKNOWN_FLAG}");
308 if crate::is_safe_command(&test) {
309 failures.push(format!("{path}: accepted unknown flag: {test}"));
310 }
311 }
312 }
313 }
314 }
315
316 #[test]
317 fn all_commands_reject_unknown() {
318 let registry = full_registry();
319 let mut failures = Vec::new();
320 for entry in ®istry {
321 check_entry(entry, &mut failures);
322 }
323 assert!(
324 failures.is_empty(),
325 "unknown flags/subcommands accepted:\n{}",
326 failures.join("\n")
327 );
328 }
329
330 #[test]
331 fn process_substitution_safe_inner() {
332 let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
333 for cmd in &safe {
334 assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
335 }
336 }
337
338 #[test]
339 fn process_substitution_unsafe_inner() {
340 let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
341 for cmd in &unsafe_cmds {
342 assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
343 }
344 }
345
346 #[test]
347 fn registry_covers_handled_commands() {
348 let registry = full_registry();
349 let mut all_cmds: HashSet<&str> = registry
350 .iter()
351 .map(|e| match e {
352 CommandEntry::Positional { cmd }
353 | CommandEntry::Custom { cmd, .. }
354 | CommandEntry::Paths { cmd, .. }
355 | CommandEntry::Delegation { cmd } => *cmd,
356 })
357 .collect();
358 for name in crate::registry::toml_command_names() {
359 all_cmds.insert(name);
360 }
361 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
362
363 let missing: Vec<_> = handled.difference(&all_cmds).collect();
364 assert!(missing.is_empty(), "not in registry: {missing:?}");
365
366 let extra: Vec<_> = all_cmds.difference(&handled).collect();
367 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
368 }
369
370}