Skip to main content

safe_chains/handlers/
mod.rs

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