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 "pipx", "pip-compile", "pip-sync", "pre-commit", "sphinx-build", "sphinx-quickstart", "sphinx-apidoc",
103 "mkdocs", "twine", "yapf", "autopep8", "autoflake", "pyupgrade", "vulture", "pyflakes",
104 "pycodestyle", "pydocstyle", "cookiecutter", "copier", "deptry", "safety",
105 "http", "https", "ipython", "scalene", "py-spy", "kernprof", "mprof", "dvc", "alembic", "hatch",
106 "husky", "lint-staged", "markdownlint-cli2", "markdownlint", "typedoc", "nodemon", "pm2",
107 "ncu", "depcruise", "dependency-cruise",
108 "rollup", "vite", "esbuild", "swc", "webpack", "parcel", "tsup",
109 "prisma", "drizzle-kit", "sequelize", "knex",
110 "ava", "tap", "c8", "nyc", "jasmine",
111 "http-server", "serve", "concurrently",
112 "npm-run-all", "run-p", "run-s",
113 "tsx", "ts-node", "cucumber-js", "@cucumber/cucumber",
114 "bacon", "sccache", "sqlx", "diesel", "starship", "atuin",
115 "gofmt", "goimports", "gofumpt", "gci", "revive", "errcheck",
116 "gotestsum", "goreleaser", "mage", "task", "buf", "gosec", "gomodifytags", "dlv",
117 "scala", "scalac", "sbt", "mill", "groovy", "lein", "clj", "clojure",
118 "kotlinc", "scalafmt", "scalafix", "jdeps", "jcmd", "jstack",
119 "ghc", "cabal", "stack", "hlint", "ormolu", "fourmolu",
120 "opam", "dune", "ocamlformat",
121 "credo", "iex",
122 "clang-format", "clang-tidy", "cppcheck", "doxygen", "autoconf", "automake", "cmake-format",
123 "crystal", "shards", "ameba",
124 "nim", "nimble",
125 "luarocks", "selene",
126 "julia",
127 "dart", "flutter",
128 "hugo", "zola", "eleventy", "@11ty/eleventy", "gatsby", "astro", "vitepress", "hexo",
129 "op", "bw", "pass", "vault", "gpg", "age", "sops",
130 "terraform-docs", "tflint", "tfsec", "terragrunt", "ansible-lint", "helmfile",
131 "argocd", "skaffold", "tilt", "consul", "nomad",
132 "jupyter", "jupytext", "nbqa", "jupyter-nbconvert", "nbconvert", "nbstripout",
133 "mlflow", "wandb", "papermill", "dbt",
134 "rebar3", "fantomas", "cpan", "cpanm", "plenv", "carton",
135 "latexmk", "pdflatex", "xelatex", "lualatex", "latex", "biber",
136 "dub", "sbcl", "ros", "raco", "gleam", "roc",
137 "ffmpeg", "ffprobe", "exiftool", "mediainfo",
138 "jpegoptim", "optipng", "pngquant", "gifsicle", "cwebp", "sox",
139 "pandoc", "marp",
140 "rsync", "rclone", "restic", "borg", "mc",
141 "mysql", "mariadb", "mongosh", "redis-cli", "sqlite3", "duckdb", "usql", "pg_restore",
142 "entr", "parallel", "ts", "sponge", "vipe", "vidir", "chronic", "ifne", "errno", "isutf8", "pee",
143 "buildah", "velero", "flux", "linkerd", "istioctl", "kapp", "ytt",
144 "nu", "pscale", "supabase", "neon", "neonctl",
145 "nuget", "paket", "msbuild", "cake", "nuke", "docfx",
146 "mono", "mcs", "fsi", "dotnet-fsi", "csi",
147 "dotnet-ef", "dotnet-script", "dotnet-counters", "dotnet-dump",
148 "dotnet-trace", "dotnet-stack", "dotnet-gcdump", "dotnet-sos", "dotnet-symbol",
149 "7z", "7zz", "7za", "zstd", "unzstd", "zstdcat", "zstdmt", "xz", "unxz", "xzcat",
150 "lzma", "unlzma", "lzcat", "bzip2", "bunzip2", "bzcat", "bzip2recover", "brotli",
151 "lz4", "unlz4", "lz4cat", "lz4c", "pigz", "unpigz", "pbzip2", "lzip", "lunzip", "plzip", "ar",
152 "openssl", "mkcert", "certbot",
153 "ssh-keygen", "ssh-keyscan", "ssh-copy-id", "ssh-add", "ssh-agent",
154 "scp", "sftp", "sshfs", "autossh", "mosh",
155 "caddy", "nginx", "haproxy", "envoy", "traefik",
156 "hadolint", "dive", "nerdctl", "ctr", "copa",
157 "wasmtime", "wasmer", "wasm-pack", "wat2wasm", "wasm2wat",
158 "wasm-validate", "wasm-objdump", "wasm-strip", "wasm-tools", "spin",
159 "hg", "svn", "fossil", "pijul",
160 "newman", "bru", "inso", "ab", "wrk", "iperf3", "iperf",
161 "vegeta", "k6", "locust", "bombardier", "siege", "artillery",
162 "huggingface-cli", "sentry-cli", "promtool", "grafana-cli", "amtool", "otel-cli", "oc",
163 "arduino-cli", "pio", "platformio", "esptool", "esptool.py",
164 "avrdude", "openocd", "west", "idf.py",
165 "coqc", "coqtop", "lean", "lake", "elan", "agda", "idris2", "swipl",
166 "code", "code-insiders", "codium", "hx", "subl",
167 "earthly", "buck2", "pants", "waf", "xmake",
168 "shfmt", "yamllint", "jsonlint", "editorconfig-checker", "ec",
169 "pkg-config", "envsubst",
170 "ansible-vault", "molecule",
171 "nix", "nix-shell", "nix-build", "nix-env", "nix-store", "nix-collect-garbage",
172 "nix-channel", "nix-instantiate", "nix-prefetch-url",
173 "nixos-rebuild", "home-manager", "devenv",
174 "sam", "cdk", "amplify", "eb", "sls", "serverless", "copilot", "chalice",
175 "expo", "react-native", "ionic", "cap", "capacitor", "tns", "ns", "nativescript",
176 "create-vite", "create-next-app", "create-react-app",
177 "degit", "tiged", "yo", "hygen", "plop",
178 "pg_dumpall", "pg_basebackup", "pg_ctl", "pg_config", "pg_controldata",
179 "createdb", "dropdb", "createuser", "dropuser",
180 "vacuumdb", "reindexdb", "clusterdb", "pgbench",
181 "mysqldump", "mysqladmin", "mysqlcheck",
182 "mongodump", "mongorestore", "mongoimport", "mongoexport", "mongostat", "mongotop",
183 "redis-server", "redis-benchmark", "cqlsh", "nodetool", "cockroach", "influx", "influxd",
184 "atlas", "flyway", "liquibase", "dbmate", "migrate", "goose",
185 "pgcli", "mycli", "litecli", "iredis",
186 "wget", "aria2c",
187 "gs", "qpdf", "pdftk", "pdftotext", "pdftohtml", "pdftocairo",
188 "pdfimages", "pdfinfo", "pdffonts", "pdfdetach", "pdfseparate", "pdfunite",
189 "weasyprint", "wkhtmltopdf",
190 "emcc", "em++", "emar", "emconfigure", "emmake", "emranlib", "emstrip", "emrun",
191 "llc", "opt", "lli", "llvm-cov", "llvm-objdump", "llvm-readobj", "llvm-readelf",
192 "llvm-strip", "llvm-mc", "llvm-link", "llvm-as", "llvm-dis",
193 "llvm-symbolizer", "llvm-profdata", "llvm-nm", "llvm-ar", "llvm-ranlib",
194 "llvm-config", "llvm-cxxfilt",
195 "gfortran", "flang", "flang-new", "gnatmake", "gnatls", "gnatchop", "gnatpp",
196 "gnatkr", "gnatxref", "gnatfind", "gnatprep", "tcc",
197 "godot", "godot4",
198 "tclsh", "wish", "expect", "unbuffer",
199 "buildctl", "docker-compose", "docker-buildx",
200 "chef-client", "chef", "knife", "chef-solo", "chef-shell",
201 "puppet", "puppet-agent", "puppet-master",
202 "salt", "salt-call", "salt-master", "salt-key", "salt-run", "salt-cloud", "salt-ssh",
203 "kn", "tkn", "kpt", "dapr", "oras", "krew",
204 "conan", "vcpkg", "spack",
205 "tcpdump", "tshark", "arp", "arping", "masscan", "grpcurl", "protoc",
206 "ipfs", "xh", "oha", "hurl", "httpyac",
207 "lnav", "btop", "glances", "nvtop", "gpustat",
208 "pbcopy", "pbpaste", "xclip", "xsel",
209 "cast", "forge", "anvil", "chisel", "foundryup",
210 "hardhat", "truffle", "solc", "vyper",
211 "geth", "solana", "anchor", "spl-token", "near", "near-cli",
212 "bitcoin-cli", "lncli", "slither", "mythril", "myth", "ignite", "polkadot",
213 "pip", "pip3", "uv", "poetry", "pyenv", "conda", "coverage", "tox", "nox", "bandit", "pip-audit", "pdm",
214 "cargo", "rustup",
215 "go",
216 "gradle", "gradlew", "mvn", "mvnw", "ktlint", "detekt",
217 "javap", "jar", "keytool", "jarsigner", "jenv", "sdk",
218 "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
219 "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
220 "fastlane", "firebase",
221 "artisan", "composer", "craft", "pest", "phpstan", "phpunit", "please", "valet",
222 "swift",
223 "dotnet",
224 "curl",
225 "docker", "podman", "kubectl", "orbctl", "orb", "qemu-img", "helm", "skopeo", "crane", "cosign", "kustomize", "stern", "kubectx", "kubens", "kind", "minikube",
226 "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
227 "ddev", "dcli",
228 "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
229 "pg_dump", "bazel", "meson", "ninja",
230 "terraform", "heroku", "vercel", "fly", "flyctl", "pulumi", "netlify", "railway", "render",
231 "northflank", "porter", "platform", "upsun", "koyeb", "scalingo", "clever",
232 "cx", "hey", "wrangler", "cf", "newrelic",
233 "aws", "gcloud", "az",
234 "doctl", "hcloud", "vultr-cli", "exo", "scw", "linode-cli",
235 "ansible-playbook", "ansible-inventory", "ansible-doc", "ansible-config", "ansible-galaxy",
236 "overmind", "tailscale", "tmux", "wg", "systemctl", "journalctl", "zellij",
237 "kafka-topics", "kafka-console-consumer", "kafka-consumer-groups",
238 "monolith",
239 "cloudflared", "ngrok", "ssh",
240 "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
241 "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
242 "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
243 "perl",
244 "R", "Rscript",
245 "grep", "egrep", "fgrep", "rg", "ag", "ack", "zgrep", "zegrep", "zfgrep", "locate", "mlocate", "plocate",
246 "cat", "gzcat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
247 "diff", "comm", "paste", "tac", "rev", "nl",
248 "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
249 "echo", "printf", "seq", "test", "[", "expr", "bc", "factor", "bat", "glow",
250 "arch", "command", "hostname",
251 "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
252 "magick", "convert", "frames",
253 "fd", "eza", "exa", "ls", "delta", "colordiff",
254 "dirname", "basename", "realpath", "readlink",
255 "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
256 "true", "false", ":", "shopt",
257 "alias", "break", "continue", "declare", "exit", "export", "hash", "printenv", "read", "type", "typeset", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
258 "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
259 "who", "w", "last", "lastlog",
260 "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "pstree", "lsblk", "free", "sample", "kill",
261 "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
262 "base64", "xxd", "getconf", "uuidgen",
263 "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
264 "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
265 "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
266 "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
267 "traceroute", "traceroute6", "mtr", "nc", "ncat", "nmap",
268 "xv",
269 "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
270 "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "specdiff", "workon", "safe-chains", "snyk", "mdbook", "devbox", "pup",
271 "tldr", "ldd", "objdump", "readelf", "just",
272 "prettier", "black", "ruff", "mypy", "pyright", "pylint", "flake8", "isort",
273 "rubocop", "eslint", "biome", "stylelint", "zoxide",
274 "@herb-tools/linter", "@biomejs/biome", "@commitlint/cli", "@redocly/cli",
275 "@axe-core/cli", "@arethetypeswrong/cli", "@taplo/cli", "@johnnymorganz/stylua-bin",
276 "@shopify/theme-check", "@graphql-inspector/cli", "@apidevtools/swagger-cli",
277 "@astrojs/check", "@changesets/cli",
278 "@stoplight/spectral-cli", "@ibm/openapi-validator", "@openapitools/openapi-generator-cli",
279 "@ls-lint/ls-lint", "@htmlhint/htmlhint", "@manypkg/cli",
280 "@microsoft/api-extractor", "@asyncapi/cli",
281 "svelte-check", "secretlint", "oxlint", "knip", "size-limit",
282 "depcheck", "madge", "license-checker",
283 "pytest", "jest", "vitest", "golangci-lint", "staticcheck", "govulncheck", "semgrep", "next", "turbo", "nx",
284 "direnv", "make", "packer", "vagrant",
285 "node", "python3", "python", "rustc", "java", "php",
286 "gcc", "g++", "cc", "c++", "clang", "clang++",
287 "elixir", "erl", "mix", "zig", "lua", "tsc",
288 "jc", "gron", "difft", "difftastic", "duf", "xsv", "qsv",
289 "git-cliff", "git-lfs", "tig",
290 "trivy", "gitleaks", "grype", "syft", "watchexec", "act",
291];
292
293pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
294 let mut docs = Vec::new();
295 docs.extend(forges::command_docs());
296 docs.extend(node::command_docs());
297 docs.extend(jvm::command_docs());
298 docs.extend(android::command_docs());
299 docs.extend(network::command_docs());
300 docs.extend(system::command_docs());
301 docs.extend(perl::command_docs());
302 docs.extend(coreutils::command_docs());
303 docs.extend(fuzzy::command_docs());
304 docs.extend(shell::command_docs());
305 docs.extend(wrappers::command_docs());
306 docs.extend(vcs::command_docs());
307 docs.extend(crate::registry::toml_command_docs());
308 docs
309}
310
311#[cfg(test)]
312#[derive(Debug)]
313pub(crate) enum CommandEntry {
314 Positional { cmd: &'static str },
315 Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
316 Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
317 Delegation { cmd: &'static str },
318}
319
320pub fn all_opencode_patterns() -> Vec<String> {
321 let mut patterns = Vec::new();
322 patterns.sort();
323 patterns.dedup();
324 patterns
325}
326
327#[cfg(test)]
328fn full_registry() -> Vec<&'static CommandEntry> {
329 let mut entries = Vec::new();
330 entries.extend(shell::REGISTRY);
331 entries.extend(wrappers::REGISTRY);
332 entries.extend(forges::full_registry());
333 entries.extend(node::full_registry());
334 entries.extend(jvm::full_registry());
335 entries.extend(android::full_registry());
336 entries.extend(network::REGISTRY);
337 entries.extend(system::full_registry());
338 entries.extend(perl::REGISTRY);
339 entries.extend(coreutils::full_registry());
340 entries.extend(fuzzy::full_registry());
341 entries
342}
343
344#[cfg(test)]
345mod tests {
346 use super::*;
347 use std::collections::HashSet;
348
349 const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
350 const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
351
352 fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
353 match entry {
354 CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
355 CommandEntry::Custom { cmd, valid_prefix } => {
356 let base = valid_prefix.unwrap_or(cmd);
357 let test = format!("{base} {UNKNOWN_FLAG}");
358 if crate::is_safe_command(&test) {
359 failures.push(format!("{cmd}: accepted unknown flag: {test}"));
360 }
361 }
362 CommandEntry::Paths { cmd, bare_ok, paths } => {
363 if !bare_ok && crate::is_safe_command(cmd) {
364 failures.push(format!("{cmd}: accepted bare invocation"));
365 }
366 let test = format!("{cmd} {UNKNOWN_SUB}");
367 if crate::is_safe_command(&test) {
368 failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
369 }
370 for path in *paths {
371 let test = format!("{path} {UNKNOWN_FLAG}");
372 if crate::is_safe_command(&test) {
373 failures.push(format!("{path}: accepted unknown flag: {test}"));
374 }
375 }
376 }
377 }
378 }
379
380 #[test]
381 fn all_commands_reject_unknown() {
382 let registry = full_registry();
383 let mut failures = Vec::new();
384 for entry in ®istry {
385 check_entry(entry, &mut failures);
386 }
387 assert!(
388 failures.is_empty(),
389 "unknown flags/subcommands accepted:\n{}",
390 failures.join("\n")
391 );
392 }
393
394 #[test]
395 fn process_substitution_safe_inner() {
396 let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
397 for cmd in &safe {
398 assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
399 }
400 }
401
402 #[test]
403 fn process_substitution_unsafe_inner() {
404 let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
405 for cmd in &unsafe_cmds {
406 assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
407 }
408 }
409
410 #[test]
411 fn registry_covers_handled_commands() {
412 let registry = full_registry();
413 let mut all_cmds: HashSet<&str> = registry
414 .iter()
415 .map(|e| match e {
416 CommandEntry::Positional { cmd }
417 | CommandEntry::Custom { cmd, .. }
418 | CommandEntry::Paths { cmd, .. }
419 | CommandEntry::Delegation { cmd } => *cmd,
420 })
421 .collect();
422 for name in crate::registry::toml_command_names() {
423 all_cmds.insert(name);
424 }
425 let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
426
427 let missing: Vec<_> = handled.difference(&all_cmds).collect();
428 assert!(missing.is_empty(), "not in registry: {missing:?}");
429
430 let extra: Vec<_> = all_cmds.difference(&handled).collect();
431 assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
432 }
433
434}