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