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", "rvm", "brakeman", "rspec",
92 "standardrb", "erb_lint", "erblint", "herb",
93 "reek", "flay", "flog", "fasterer", "haml-lint", "slim-lint",
94 "bundler-audit", "bundle-audit", "ruby-audit", "rdoc", "yard", "yardoc", "rubycritic",
95 "annotaterb", "annotate", "jekyll", "bridgetown", "middleman", "foreman", "guard",
96 "spring", "overcommit", "pry", "byebug", "thor", "m", "rake", "sdoc", "license_finder",
97 "danger", "kamal", "mutant", "whenever", "haml", "slimrb", "railroady", "erd",
98 "parallel_test", "parallel_rspec", "parallel_cucumber", "parallel_spinach",
99 "racc", "rex",
100 "steep", "srb", "rbs", "typeprof", "stree", "rufo", "packwerk", "debride",
101 "i18n-tasks", "asciidoctor", "kramdown", "dawn", "fpm", "stackprof",
102 "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
103 "cargo", "rustup",
104 "go",
105 "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
106 "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
107 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
108 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
109 "fastlane", "firebase",
110 "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
111 "swift",
112 "dotnet",
113 "curl",
114 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
115 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
116 "ddev", "dcli",
117 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
118 "pg_dump", "bazel", "meson", "ninja",
119 "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
120 "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
121 "cx", "hey", "wrangler", "cf", "newrelic",
122 "aws", "gcloud", "az",
123 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
124 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
125 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
126 "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
127 "monolith",
128 "cloudflared", "ngrok", "ssh",
129 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
130 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
131 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
132 "perl",
133 "R", "Rscript",
134 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
135 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
136 "diff", "comm", "paste", "tac", "rev", "nl",
137 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
138 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
139 "arch", "command", "hostname",
140 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
141 "magick", "convert", "frames",
142 "fd", "eza", "exa", "ls", "delta", "colordiff",
143 "dirname", "basename", "realpath", "readlink",
144 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
145 "true", "false", ":", "shopt",
146 "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
147 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
148 "who", "w", "last", "lastlog",
149 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
150 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
151 "base64", "xxd", "getconf", "uuidgen",
152 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
153 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
154 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
155 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
156 "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
157 "xv",
158 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
159 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
160 "tldr", "ldd", "objdump", "readelf", "just",
161 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
162 "rubocop", "eslint", "biome", "stylelint", "zoxide",
163 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
164 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
165 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
166 "@astrojs/check", "@changesets/cli",
167 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
168 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
169 "@microsoft/api-extractor", "@asyncapi/cli",
170 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
171 "depcheck", "madge", "license-checker",
172 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
173 "direnv", "make", "packer", "vagrant",
174 "node", "python3", "python", "rustc", "java", "php",
175 "gcc", "g++", "cc", "c++", "clang", "clang++",
176 "elixir", "erl", "mix", "zig", "lua", "tsc",
177 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
178 "git-cliff", "git-lfs", "tig",
179 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
180];
181
182pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
183 let mut docs = Vec::new();
184 docs.extend(forges::command_docs());
185 docs.extend(node::command_docs());
186 docs.extend(jvm::command_docs());
187 docs.extend(android::command_docs());
188 docs.extend(network::command_docs());
189 docs.extend(system::command_docs());
190 docs.extend(perl::command_docs());
191 docs.extend(coreutils::command_docs());
192 docs.extend(fuzzy::command_docs());
193 docs.extend(shell::command_docs());
194 docs.extend(wrappers::command_docs());
195 docs.extend(vcs::command_docs());
196 docs.extend(crate::registry::toml_command_docs());
197 docs
198}
199
200#[cfg(test)]
201#[derive(Debug)]
202pub(crate) enum CommandEntry {
203 Positional { cmd: &'static str },
204 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
205 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
206 Delegation { cmd: &'static str },
207}
208
209pub fn all_opencode_patterns() -> Vec<String> {
210 let mut patterns = Vec::new();
211 patterns.sort();
212 patterns.dedup();
213 patterns
214}
215
216#[cfg(test)]
217fn full_registry() -> Vec<&'static CommandEntry> {
218 let mut entries = Vec::new();
219 entries.extend(shell::REGISTRY);
220 entries.extend(wrappers::REGISTRY);
221 entries.extend(forges::full_registry());
222 entries.extend(node::full_registry());
223 entries.extend(jvm::full_registry());
224 entries.extend(android::full_registry());
225 entries.extend(network::REGISTRY);
226 entries.extend(system::full_registry());
227 entries.extend(perl::REGISTRY);
228 entries.extend(coreutils::full_registry());
229 entries.extend(fuzzy::full_registry());
230 entries
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use std::collections::HashSet;
237
238 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
239 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
240
241 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
242 match entry {
243 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
244 CommandEntry::Custom { cmd, valid_prefix } => {
245 let base = valid_prefix.unwrap_or(cmd);
246 let test = format!("{base} {UNKNOWN_FLAG}");
247 if crate::is_safe_command(&test) {
248 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
249 }
250 }
251 CommandEntry::Paths { cmd, bare_ok, paths } => {
252 if !bare_ok && crate::is_safe_command(cmd) {
253 failures.push(format!("{cmd}: accepted bare invocation"));
254 }
255 let test = format!("{cmd} {UNKNOWN_SUB}");
256 if crate::is_safe_command(&test) {
257 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
258 }
259 for path in *paths {
260 let test = format!("{path} {UNKNOWN_FLAG}");
261 if crate::is_safe_command(&test) {
262 failures.push(format!("{path}: accepted unknown flag: {test}"));
263 }
264 }
265 }
266 }
267 }
268
269 #[test]
270 fn all_commands_reject_unknown() {
271 let registry = full_registry();
272 let mut failures = Vec::new();
273 for entry in ®istry {
274 check_entry(entry, &mut failures);
275 }
276 assert!(
277 failures.is_empty(),
278 "unknown flags/subcommands accepted:\n{}",
279 failures.join("\n")
280 );
281 }
282
283 #[test]
284 fn process_substitution_safe_inner() {
285 let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
286 for cmd in &safe {
287 assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
288 }
289 }
290
291 #[test]
292 fn process_substitution_unsafe_inner() {
293 let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
294 for cmd in &unsafe_cmds {
295 assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
296 }
297 }
298
299 #[test]
300 fn registry_covers_handled_commands() {
301 let registry = full_registry();
302 let mut all_cmds: HashSet<&str> = registry
303 .iter()
304 .map(|e| match e {
305 CommandEntry::Positional { cmd }
306 | CommandEntry::Custom { cmd, .. }
307 | CommandEntry::Paths { cmd, .. }
308 | CommandEntry::Delegation { cmd } => *cmd,
309 })
310 .collect();
311 for name in crate::registry::toml_command_names() {
312 all_cmds.insert(name);
313 }
314 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
315
316 let missing: Vec<_> = handled.difference(&all_cmds).collect();
317 assert!(missing.is_empty(), "not in registry: {missing:?}");
318
319 let extra: Vec<_> = all_cmds.difference(&handled).collect();
320 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
321 }
322
323}