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