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