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