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