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