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