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