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