Skip to main content

safe_chains/handlers/
mod.rs

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 &registry {
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}