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