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