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