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 "7z", "7zz", "7za", "zstd", "unzstd", "zstdcat", "zstdmt", "xz", "unxz", "xzcat",
150 "lzma", "unlzma", "lzcat", "bzip2", "bunzip2", "bzcat", "bzip2recover", "brotli",
151 "lz4", "unlz4", "lz4cat", "lz4c", "pigz", "unpigz", "pbzip2", "lzip", "lunzip", "plzip", "ar",
152 "openssl", "mkcert", "certbot",
153 "ssh-keygen", "ssh-keyscan", "ssh-copy-id", "ssh-add", "ssh-agent",
154 "scp", "sftp", "sshfs", "autossh", "mosh",
155 "caddy", "nginx", "haproxy", "envoy", "traefik",
156 "hadolint", "dive", "nerdctl", "ctr", "copa",
157 "wasmtime", "wasmer", "wasm-pack", "wat2wasm", "wasm2wat",
158 "wasm-validate", "wasm-objdump", "wasm-strip", "wasm-tools", "spin",
159 "hg", "svn", "fossil", "pijul",
160 "newman", "bru", "inso", "ab", "wrk", "iperf3", "iperf",
161 "vegeta", "k6", "locust", "bombardier", "siege", "artillery",
162 "huggingface-cli", "sentry-cli", "promtool", "grafana-cli", "amtool", "otel-cli", "oc",
163 "arduino-cli", "pio", "platformio", "esptool", "esptool.py",
164 "avrdude", "openocd", "west", "idf.py",
165 "coqc", "coqtop", "lean", "lake", "elan", "agda", "idris2", "swipl",
166 "code", "code-insiders", "codium", "hx", "subl",
167 "earthly", "buck2", "pants", "waf", "xmake",
168 "shfmt", "yamllint", "jsonlint", "editorconfig-checker", "ec",
169 "pkg-config", "envsubst",
170 "ansible-vault", "molecule",
171 "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
172 "cargo", "rustup",
173 "go",
174 "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
175 "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
176 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
177 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
178 "fastlane", "firebase",
179 "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
180 "swift",
181 "dotnet",
182 "curl",
183 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
184 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
185 "ddev", "dcli",
186 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
187 "pg_dump", "bazel", "meson", "ninja",
188 "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
189 "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
190 "cx", "hey", "wrangler", "cf", "newrelic",
191 "aws", "gcloud", "az",
192 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
193 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
194 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
195 "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
196 "monolith",
197 "cloudflared", "ngrok", "ssh",
198 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
199 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
200 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
201 "perl",
202 "R", "Rscript",
203 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
204 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
205 "diff", "comm", "paste", "tac", "rev", "nl",
206 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
207 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
208 "arch", "command", "hostname",
209 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
210 "magick", "convert", "frames",
211 "fd", "eza", "exa", "ls", "delta", "colordiff",
212 "dirname", "basename", "realpath", "readlink",
213 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
214 "true", "false", ":", "shopt",
215 "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
216 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
217 "who", "w", "last", "lastlog",
218 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
219 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
220 "base64", "xxd", "getconf", "uuidgen",
221 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
222 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
223 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
224 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
225 "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
226 "xv",
227 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
228 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
229 "tldr", "ldd", "objdump", "readelf", "just",
230 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
231 "rubocop", "eslint", "biome", "stylelint", "zoxide",
232 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
233 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
234 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
235 "@astrojs/check", "@changesets/cli",
236 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
237 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
238 "@microsoft/api-extractor", "@asyncapi/cli",
239 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
240 "depcheck", "madge", "license-checker",
241 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
242 "direnv", "make", "packer", "vagrant",
243 "node", "python3", "python", "rustc", "java", "php",
244 "gcc", "g++", "cc", "c++", "clang", "clang++",
245 "elixir", "erl", "mix", "zig", "lua", "tsc",
246 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
247 "git-cliff", "git-lfs", "tig",
248 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
249];
250
251pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
252 let mut docs = Vec::new();
253 docs.extend(forges::command_docs());
254 docs.extend(node::command_docs());
255 docs.extend(jvm::command_docs());
256 docs.extend(android::command_docs());
257 docs.extend(network::command_docs());
258 docs.extend(system::command_docs());
259 docs.extend(perl::command_docs());
260 docs.extend(coreutils::command_docs());
261 docs.extend(fuzzy::command_docs());
262 docs.extend(shell::command_docs());
263 docs.extend(wrappers::command_docs());
264 docs.extend(vcs::command_docs());
265 docs.extend(crate::registry::toml_command_docs());
266 docs
267}
268
269#[cfg(test)]
270#[derive(Debug)]
271pub(crate) enum CommandEntry {
272 Positional { cmd: &'static str },
273 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
274 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
275 Delegation { cmd: &'static str },
276}
277
278pub fn all_opencode_patterns() -> Vec<String> {
279 let mut patterns = Vec::new();
280 patterns.sort();
281 patterns.dedup();
282 patterns
283}
284
285#[cfg(test)]
286fn full_registry() -> Vec<&'static CommandEntry> {
287 let mut entries = Vec::new();
288 entries.extend(shell::REGISTRY);
289 entries.extend(wrappers::REGISTRY);
290 entries.extend(forges::full_registry());
291 entries.extend(node::full_registry());
292 entries.extend(jvm::full_registry());
293 entries.extend(android::full_registry());
294 entries.extend(network::REGISTRY);
295 entries.extend(system::full_registry());
296 entries.extend(perl::REGISTRY);
297 entries.extend(coreutils::full_registry());
298 entries.extend(fuzzy::full_registry());
299 entries
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305 use std::collections::HashSet;
306
307 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
308 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
309
310 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
311 match entry {
312 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
313 CommandEntry::Custom { cmd, valid_prefix } => {
314 let base = valid_prefix.unwrap_or(cmd);
315 let test = format!("{base} {UNKNOWN_FLAG}");
316 if crate::is_safe_command(&test) {
317 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
318 }
319 }
320 CommandEntry::Paths { cmd, bare_ok, paths } => {
321 if !bare_ok && crate::is_safe_command(cmd) {
322 failures.push(format!("{cmd}: accepted bare invocation"));
323 }
324 let test = format!("{cmd} {UNKNOWN_SUB}");
325 if crate::is_safe_command(&test) {
326 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
327 }
328 for path in *paths {
329 let test = format!("{path} {UNKNOWN_FLAG}");
330 if crate::is_safe_command(&test) {
331 failures.push(format!("{path}: accepted unknown flag: {test}"));
332 }
333 }
334 }
335 }
336 }
337
338 #[test]
339 fn all_commands_reject_unknown() {
340 let registry = full_registry();
341 let mut failures = Vec::new();
342 for entry in ®istry {
343 check_entry(entry, &mut failures);
344 }
345 assert!(
346 failures.is_empty(),
347 "unknown flags/subcommands accepted:\n{}",
348 failures.join("\n")
349 );
350 }
351
352 #[test]
353 fn process_substitution_safe_inner() {
354 let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
355 for cmd in &safe {
356 assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
357 }
358 }
359
360 #[test]
361 fn process_substitution_unsafe_inner() {
362 let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
363 for cmd in &unsafe_cmds {
364 assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
365 }
366 }
367
368 #[test]
369 fn registry_covers_handled_commands() {
370 let registry = full_registry();
371 let mut all_cmds: HashSet<&str> = registry
372 .iter()
373 .map(|e| match e {
374 CommandEntry::Positional { cmd }
375 | CommandEntry::Custom { cmd, .. }
376 | CommandEntry::Paths { cmd, .. }
377 | CommandEntry::Delegation { cmd } => *cmd,
378 })
379 .collect();
380 for name in crate::registry::toml_command_names() {
381 all_cmds.insert(name);
382 }
383 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
384
385 let missing: Vec<_> = handled.difference(&all_cmds).collect();
386 assert!(missing.is_empty(), "not in registry: {missing:?}");
387
388 let extra: Vec<_> = all_cmds.difference(&handled).collect();
389 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
390 }
391
392}