Skip to main content

safe_chains/handlers/
mod.rs

1pub mod ai;
2pub mod android;
3pub mod containers;
4pub mod coreutils;
5pub mod dotnet;
6pub mod forges;
7pub mod fuzzy;
8pub mod go;
9pub mod jvm;
10pub mod magick;
11pub mod network;
12pub mod node;
13pub mod perl;
14pub mod php;
15pub mod python;
16pub mod r;
17pub mod ruby;
18pub mod rust;
19pub mod shell;
20pub mod swift;
21pub mod system;
22pub mod vcs;
23pub mod wrappers;
24pub mod xcode;
25
26use crate::parse::Token;
27use crate::verdict::Verdict;
28
29pub fn dispatch(tokens: &[Token]) -> Verdict {
30    let cmd = tokens[0].command_name();
31    None
32        .or_else(|| shell::dispatch(cmd, tokens))
33        .or_else(|| wrappers::dispatch(cmd, tokens))
34        .or_else(|| vcs::dispatch(cmd, tokens))
35        .or_else(|| forges::dispatch(cmd, tokens))
36        .or_else(|| node::dispatch(cmd, tokens))
37        .or_else(|| ruby::dispatch(cmd, tokens))
38        .or_else(|| python::dispatch(cmd, tokens))
39        .or_else(|| rust::dispatch(cmd, tokens))
40        .or_else(|| go::dispatch(cmd, tokens))
41        .or_else(|| jvm::dispatch(cmd, tokens))
42        .or_else(|| android::dispatch(cmd, tokens))
43        .or_else(|| php::dispatch(cmd, tokens))
44        .or_else(|| swift::dispatch(cmd, tokens))
45        .or_else(|| dotnet::dispatch(cmd, tokens))
46        .or_else(|| containers::dispatch(cmd, tokens))
47        .or_else(|| network::dispatch(cmd, tokens))
48        .or_else(|| ai::dispatch(cmd, tokens))
49        .or_else(|| system::dispatch(cmd, tokens))
50        .or_else(|| xcode::dispatch(cmd, tokens))
51        .or_else(|| perl::dispatch(cmd, tokens))
52        .or_else(|| r::dispatch(cmd, tokens))
53        .or_else(|| coreutils::dispatch(cmd, tokens))
54        .or_else(|| fuzzy::dispatch(cmd, tokens))
55        .or_else(|| magick::dispatch(cmd, tokens))
56        .unwrap_or(Verdict::Denied)
57}
58
59#[cfg(test)]
60const HANDLED_CMDS: &[&str] = &[
61    "sh", "bash", "xargs", "timeout", "time", "env", "nice", "ionice", "hyperfine", "dotenv",
62    "git", "jj", "gh", "glab", "jjpr", "tea",
63    "npm", "yarn", "pnpm", "bun", "deno", "npx", "bunx", "nvm", "fnm", "volta",
64    "ruby", "ri", "bundle", "gem", "importmap", "rbenv",
65    "pip", "uv", "poetry", "pyenv", "conda",
66    "cargo", "rustup",
67    "go",
68    "gradle", "mvn", "mvnw", "ktlint", "detekt",
69    "javap", "jar", "keytool", "jarsigner",
70    "adb", "apkanalyzer", "apksigner", "bundletool", "aapt2",
71    "emulator", "avdmanager", "sdkmanager", "zipalign", "lint",
72    "fastlane", "firebase",
73    "composer", "craft",
74    "swift",
75    "dotnet",
76    "curl",
77    "docker", "podman", "kubectl", "orbctl", "qemu-img",
78    "ollama", "llm", "hf", "claude", "aider", "codex", "opencode", "vibe",
79    "ddev", "dcli",
80    "brew", "mise", "asdf", "crontab", "defaults", "pmset", "sysctl", "cmake", "psql", "pg_isready",
81    "terraform", "heroku", "vercel", "flyctl",
82    "overmind", "tailscale", "tmux", "wg",
83    "networksetup", "launchctl", "diskutil", "security", "csrutil", "log",
84    "xcodebuild", "plutil", "xcode-select", "xcrun", "pkgutil", "lipo", "codesign", "spctl",
85    "xcodegen", "tuist", "pod", "swiftlint", "swiftformat", "periphery", "xcbeautify", "agvtool", "simctl",
86    "perl",
87    "R", "Rscript",
88    "grep", "rg", "ag", "ack", "zgrep", "locate",
89    "cat", "head", "tail", "wc", "cut", "tr", "uniq", "less", "more", "zcat",
90    "diff", "comm", "paste", "tac", "rev", "nl",
91    "expand", "unexpand", "fold", "fmt", "col", "column", "iconv", "nroff",
92    "echo", "printf", "seq", "test", "expr", "bc", "factor", "bat",
93    "arch", "command", "hostname",
94    "find", "sed", "shuf", "sort", "yq", "xmllint", "awk", "gawk", "mawk", "nawk",
95    "magick",
96    "fd", "eza", "ls", "delta", "colordiff",
97    "dirname", "basename", "realpath", "readlink",
98    "file", "stat", "du", "df", "tree", "cmp", "zipinfo", "tar", "unzip", "gzip",
99    "true", "false",
100    "alias", "export", "printenv", "read", "type", "wait", "whereis", "which", "whoami", "date", "pwd", "cd", "unset",
101    "uname", "nproc", "uptime", "id", "groups", "tty", "locale", "cal", "sleep",
102    "who", "w", "last", "lastlog",
103    "ps", "top", "htop", "iotop", "procs", "dust", "lsof", "pgrep", "lsblk", "free",
104    "jq", "jaq", "gojq", "fx", "jless", "htmlq", "xq", "tomlq", "mlr", "dasel",
105    "base64", "xxd", "getconf", "uuidgen",
106    "md5sum", "md5", "sha256sum", "shasum", "sha1sum", "sha512sum",
107    "cksum", "b2sum", "sum", "strings", "hexdump", "od", "size", "sips",
108    "sw_vers", "mdls", "otool", "nm", "system_profiler", "ioreg", "vm_stat", "mdfind", "man",
109    "dig", "nslookup", "host", "whois", "netstat", "ss", "ifconfig", "route", "ping",
110    "xv",
111    "fzf", "fzy", "peco", "pick", "selecta", "sk", "zf",
112    "identify", "shellcheck", "cloc", "tokei", "cucumber", "branchdiff", "workon", "safe-chains",
113];
114
115pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
116    let mut docs = Vec::new();
117    docs.extend(vcs::command_docs());
118    docs.extend(forges::command_docs());
119    docs.extend(node::command_docs());
120    docs.extend(ruby::command_docs());
121    docs.extend(python::command_docs());
122    docs.extend(rust::command_docs());
123    docs.extend(go::command_docs());
124    docs.extend(jvm::command_docs());
125    docs.extend(android::command_docs());
126    docs.extend(php::command_docs());
127    docs.extend(swift::command_docs());
128    docs.extend(dotnet::command_docs());
129    docs.extend(containers::command_docs());
130    docs.extend(ai::command_docs());
131    docs.extend(network::command_docs());
132    docs.extend(system::command_docs());
133    docs.extend(xcode::command_docs());
134    docs.extend(perl::command_docs());
135    docs.extend(r::command_docs());
136    docs.extend(coreutils::command_docs());
137    docs.extend(fuzzy::command_docs());
138    docs.extend(shell::command_docs());
139    docs.extend(wrappers::command_docs());
140    docs.extend(magick::command_docs());
141    docs
142}
143
144#[cfg(test)]
145#[derive(Debug)]
146pub(crate) enum CommandEntry {
147    Positional { cmd: &'static str },
148    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
149    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
150    Delegation { cmd: &'static str },
151}
152
153#[cfg(test)]
154#[derive(Debug)]
155pub(crate) enum SubEntry {
156    Policy { name: &'static str },
157    Nested { name: &'static str, subs: &'static [SubEntry] },
158    Custom { name: &'static str, valid_suffix: Option<&'static str> },
159    Positional,
160    Guarded { name: &'static str, valid_suffix: &'static str },
161}
162
163use crate::command::CommandDef;
164
165const COMMAND_DEFS: &[&CommandDef] = &[
166    &ai::CODEX, &ai::OLLAMA, &ai::OPENCODE, &ai::LLM, &ai::HF,
167    &containers::DOCKER, &containers::PODMAN, &containers::KUBECTL, &containers::ORBCTL, &containers::QEMU_IMG,
168    &dotnet::DOTNET,
169    &go::GO,
170    &android::APKANALYZER, &android::APKSIGNER, &android::BUNDLETOOL, &android::AAPT2,
171    &android::AVDMANAGER,
172    &jvm::GRADLE, &jvm::KEYTOOL,
173    &magick::MAGICK,
174    &node::NPM, &node::PNPM, &node::BUN, &node::DENO,
175    &node::NVM, &node::FNM, &node::VOLTA,
176    &php::COMPOSER, &php::CRAFT,
177    &python::PIP, &python::UV, &python::POETRY,
178    &python::PYENV, &python::CONDA,
179    &ruby::BUNDLE, &ruby::GEM, &ruby::IMPORTMAP, &ruby::RBENV,
180    &rust::CARGO, &rust::RUSTUP,
181    &vcs::GIT,
182    &swift::SWIFT,
183    &system::BREW, &system::MISE, &system::ASDF, &system::DDEV, &system::DCLI, &system::CMAKE,
184    &system::DEFAULTS, &system::TERRAFORM, &system::HEROKU, &system::VERCEL,
185    &system::FLYCTL, &system::FASTLANE, &system::FIREBASE,
186    &system::OVERMIND, &system::TAILSCALE, &system::WG,
187    &system::SECURITY, &system::CSRUTIL, &system::DISKUTIL,
188    &system::LAUNCHCTL, &system::LOG,
189    &xcode::XCODEBUILD, &xcode::PLUTIL, &xcode::XCODE_SELECT,
190    &xcode::XCODEGEN, &xcode::TUIST, &xcode::POD, &xcode::SWIFTLINT,
191    &xcode::PERIPHERY, &xcode::AGVTOOL, &xcode::SIMCTL,
192];
193
194pub fn all_opencode_patterns() -> Vec<String> {
195    let mut patterns = Vec::new();
196    for def in COMMAND_DEFS {
197        patterns.extend(def.opencode_patterns());
198    }
199    for def in coreutils::all_flat_defs() {
200        patterns.extend(def.opencode_patterns());
201    }
202    for def in jvm::jvm_flat_defs() {
203        patterns.extend(def.opencode_patterns());
204    }
205    for def in android::android_flat_defs() {
206        patterns.extend(def.opencode_patterns());
207    }
208    for def in ai::ai_flat_defs() {
209        patterns.extend(def.opencode_patterns());
210    }
211    for def in ruby::ruby_flat_defs() {
212        patterns.extend(def.opencode_patterns());
213    }
214    for def in system::system_flat_defs() {
215        patterns.extend(def.opencode_patterns());
216    }
217    for def in xcode::xcbeautify_flat_defs() {
218        patterns.extend(def.opencode_patterns());
219    }
220    for def in fuzzy::fuzzy_flat_defs() {
221        patterns.extend(def.opencode_patterns());
222    }
223    patterns.sort();
224    patterns.dedup();
225    patterns
226}
227
228#[cfg(test)]
229fn full_registry() -> Vec<&'static CommandEntry> {
230    let mut entries = Vec::new();
231    entries.extend(shell::REGISTRY);
232    entries.extend(wrappers::REGISTRY);
233    entries.extend(vcs::full_registry());
234    entries.extend(forges::full_registry());
235    entries.extend(node::full_registry());
236    entries.extend(jvm::full_registry());
237    entries.extend(android::full_registry());
238    entries.extend(network::REGISTRY);
239    entries.extend(system::full_registry());
240    entries.extend(xcode::full_registry());
241    entries.extend(perl::REGISTRY);
242    entries.extend(r::REGISTRY);
243    entries.extend(coreutils::full_registry());
244    entries.extend(fuzzy::full_registry());
245    entries
246}
247
248#[cfg(test)]
249mod tests {
250    use super::*;
251    use std::collections::HashSet;
252
253    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
254    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
255
256    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
257        match entry {
258            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
259            CommandEntry::Custom { cmd, valid_prefix } => {
260                let base = valid_prefix.unwrap_or(cmd);
261                let test = format!("{base} {UNKNOWN_FLAG}");
262                if crate::is_safe_command(&test) {
263                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
264                }
265            }
266            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
267                if !bare_ok && crate::is_safe_command(cmd) {
268                    failures.push(format!("{cmd}: accepted bare invocation"));
269                }
270                let test = format!("{cmd} {UNKNOWN_SUB}");
271                if crate::is_safe_command(&test) {
272                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
273                }
274                for sub in *subs {
275                    check_sub(cmd, sub, failures);
276                }
277            }
278        }
279    }
280
281    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
282        match entry {
283            SubEntry::Policy { name } => {
284                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
285                if crate::is_safe_command(&test) {
286                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
287                }
288            }
289            SubEntry::Nested { name, subs } => {
290                let path = format!("{prefix} {name}");
291                let test = format!("{path} {UNKNOWN_SUB}");
292                if crate::is_safe_command(&test) {
293                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
294                }
295                for sub in *subs {
296                    check_sub(&path, sub, failures);
297                }
298            }
299            SubEntry::Custom { name, valid_suffix } => {
300                let base = match valid_suffix {
301                    Some(s) => format!("{prefix} {name} {s}"),
302                    None => format!("{prefix} {name}"),
303                };
304                let test = format!("{base} {UNKNOWN_FLAG}");
305                if crate::is_safe_command(&test) {
306                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
307                }
308            }
309            SubEntry::Positional => {}
310            SubEntry::Guarded { name, valid_suffix } => {
311                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
312                if crate::is_safe_command(&test) {
313                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
314                }
315            }
316        }
317    }
318
319    #[test]
320    fn all_commands_reject_unknown() {
321        let registry = full_registry();
322        let mut failures = Vec::new();
323        for entry in &registry {
324            check_entry(entry, &mut failures);
325        }
326        assert!(
327            failures.is_empty(),
328            "unknown flags/subcommands accepted:\n{}",
329            failures.join("\n")
330        );
331    }
332
333    #[test]
334    fn command_defs_reject_unknown() {
335        for def in COMMAND_DEFS {
336            def.auto_test_reject_unknown();
337        }
338    }
339
340    #[test]
341    fn flat_defs_reject_unknown() {
342        for def in coreutils::all_flat_defs() {
343            def.auto_test_reject_unknown();
344        }
345        for def in xcode::xcbeautify_flat_defs() {
346            def.auto_test_reject_unknown();
347        }
348        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
349            def.auto_test_reject_unknown();
350        }
351    }
352
353
354    #[test]
355    fn bare_false_rejects_bare_invocation() {
356        let check_def = |def: &crate::command::FlatDef| {
357            if !def.policy.bare {
358                assert!(
359                    !crate::is_safe_command(def.name),
360                    "{}: bare=false but bare invocation accepted",
361                    def.name,
362                );
363            }
364        };
365        for def in coreutils::all_flat_defs()
366            .into_iter()
367            .chain(xcode::xcbeautify_flat_defs())
368        {
369            check_def(def);
370        }
371        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
372            check_def(def);
373        }
374    }
375
376    fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
377        for sub in subs {
378            visitor(prefix, sub);
379            if let crate::command::SubDef::Nested { name, subs: inner } = sub {
380                visit_subs(&format!("{prefix} {name}"), inner, visitor);
381            }
382        }
383    }
384
385    #[test]
386    fn guarded_subs_require_guard() {
387        let mut failures = Vec::new();
388        for def in COMMAND_DEFS {
389            visit_subs(def.name, def.subs, &mut |prefix, sub| {
390                if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
391                    let without = format!("{prefix} {name}");
392                    if crate::is_safe_command(&without) {
393                        failures.push(format!("{without}: accepted without guard {guard_long}"));
394                    }
395                    let with = format!("{prefix} {name} {guard_long}");
396                    if !crate::is_safe_command(&with) {
397                        failures.push(format!("{with}: rejected with guard {guard_long}"));
398                    }
399                }
400            });
401        }
402        assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
403    }
404
405    #[test]
406    fn guarded_subs_accept_guard_short() {
407        let mut failures = Vec::new();
408        for def in COMMAND_DEFS {
409            visit_subs(def.name, def.subs, &mut |prefix, sub| {
410                if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
411                    let with_short = format!("{prefix} {name} {short}");
412                    if !crate::is_safe_command(&with_short) {
413                        failures.push(format!("{with_short}: rejected with guard_short"));
414                    }
415                }
416            });
417        }
418        assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
419    }
420
421    #[test]
422    fn nested_subs_reject_bare() {
423        let mut failures = Vec::new();
424        for def in COMMAND_DEFS {
425            visit_subs(def.name, def.subs, &mut |prefix, sub| {
426                if let crate::command::SubDef::Nested { name, .. } = sub {
427                    let bare = format!("{prefix} {name}");
428                    if crate::is_safe_command(&bare) {
429                        failures.push(format!("{bare}: nested sub accepted bare invocation"));
430                    }
431                }
432            });
433        }
434        assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
435    }
436
437    #[test]
438    fn process_substitution_blocked() {
439        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
440        for cmd in &cmds {
441            assert!(
442                !crate::is_safe_command(cmd),
443                "process substitution not blocked: {cmd}",
444            );
445        }
446    }
447
448    #[test]
449    fn positional_style_accepts_unknown_args() {
450        use crate::policy::FlagStyle;
451        for def in coreutils::all_flat_defs() {
452            if def.policy.flag_style == FlagStyle::Positional {
453                let test = format!("{} --unknown-xyz", def.name);
454                assert!(
455                    crate::is_safe_command(&test),
456                    "{}: FlagStyle::Positional but rejected unknown arg",
457                    def.name,
458                );
459            }
460        }
461    }
462
463    fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
464        for sub in subs {
465            match sub {
466                crate::command::SubDef::Policy { name, policy, .. } => {
467                    visitor(&format!("{prefix} {name}"), policy);
468                }
469                crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
470                    visitor(&format!("{prefix} {name} {guard_long}"), policy);
471                }
472                crate::command::SubDef::Nested { name, subs: inner } => {
473                    visit_policies(&format!("{prefix} {name}"), inner, visitor);
474                }
475                _ => {}
476            }
477        }
478    }
479
480    #[test]
481    fn valued_flags_accept_eq_syntax() {
482        let mut failures = Vec::new();
483
484        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
485            for flag in def.policy.valued.iter() {
486                let cmd = format!("{} {flag}=test_value", def.name);
487                if !crate::is_safe_command(&cmd) {
488                    failures.push(format!("{cmd}: valued flag rejected with = syntax"));
489                }
490            }
491        };
492        for def in coreutils::all_flat_defs()
493            .into_iter()
494            .chain(xcode::xcbeautify_flat_defs())
495        {
496            check_flat(def, &mut failures);
497        }
498        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
499            check_flat(def, &mut failures);
500        }
501
502        for def in COMMAND_DEFS {
503            visit_policies(def.name, def.subs, &mut |prefix, policy| {
504                for flag in policy.valued.iter() {
505                    let cmd = format!("{prefix} {flag}=test_value");
506                    if !crate::is_safe_command(&cmd) {
507                        failures.push(format!("{cmd}: valued flag rejected with = syntax"));
508                    }
509                }
510            });
511        }
512
513        assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
514    }
515
516    #[test]
517    fn max_positional_enforced() {
518        let mut failures = Vec::new();
519
520        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
521            if let Some(max) = def.policy.max_positional {
522                let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
523                let cmd = format!("{} {}", def.name, args.join(" "));
524                if crate::is_safe_command(&cmd) {
525                    failures.push(format!(
526                        "{}: max_positional={max} but accepted {} positional args",
527                        def.name,
528                        max + 1,
529                    ));
530                }
531            }
532        };
533        for def in coreutils::all_flat_defs()
534            .into_iter()
535            .chain(xcode::xcbeautify_flat_defs())
536        {
537            check_flat(def, &mut failures);
538        }
539        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
540            check_flat(def, &mut failures);
541        }
542
543        for def in COMMAND_DEFS {
544            visit_policies(def.name, def.subs, &mut |prefix, policy| {
545                if let Some(max) = policy.max_positional {
546                    let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
547                    let cmd = format!("{prefix} {}", args.join(" "));
548                    if crate::is_safe_command(&cmd) {
549                        failures.push(format!(
550                            "{prefix}: max_positional={max} but accepted {} positional args",
551                            max + 1,
552                        ));
553                    }
554                }
555            });
556        }
557
558        assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
559    }
560
561    #[test]
562    fn doc_generation_non_empty() {
563        let mut failures = Vec::new();
564
565        for def in COMMAND_DEFS {
566            let doc = def.to_doc();
567            if doc.description.trim().is_empty() {
568                failures.push(format!("{}: CommandDef produced empty doc", def.name));
569            }
570            if doc.url.is_empty() {
571                failures.push(format!("{}: CommandDef has empty URL", def.name));
572            }
573        }
574
575        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
576            let doc = def.to_doc();
577            if doc.description.trim().is_empty() && !def.policy.bare {
578                failures.push(format!("{}: FlatDef produced empty doc", def.name));
579            }
580            if doc.url.is_empty() {
581                failures.push(format!("{}: FlatDef has empty URL", def.name));
582            }
583        };
584        for def in coreutils::all_flat_defs()
585            .into_iter()
586            .chain(xcode::xcbeautify_flat_defs())
587        {
588            check_flat(def, &mut failures);
589        }
590        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
591            check_flat(def, &mut failures);
592        }
593
594        assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
595    }
596
597    #[test]
598    fn registry_covers_handled_commands() {
599        let registry = full_registry();
600        let mut all_cmds: HashSet<&str> = registry
601            .iter()
602            .map(|e| match e {
603                CommandEntry::Positional { cmd }
604                | CommandEntry::Custom { cmd, .. }
605                | CommandEntry::Subcommand { cmd, .. }
606                | CommandEntry::Delegation { cmd } => *cmd,
607            })
608            .collect();
609        for def in COMMAND_DEFS {
610            all_cmds.insert(def.name);
611        }
612        for def in coreutils::all_flat_defs() {
613            all_cmds.insert(def.name);
614        }
615        for def in xcode::xcbeautify_flat_defs() {
616            all_cmds.insert(def.name);
617        }
618        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(ai::ai_flat_defs()).chain(ruby::ruby_flat_defs()).chain(system::system_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
619            all_cmds.insert(def.name);
620        }
621        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
622
623        let missing: Vec<_> = handled.difference(&all_cmds).collect();
624        assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
625
626        let extra: Vec<_> = all_cmds.difference(&handled).collect();
627        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
628    }
629
630}