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 ruby;
10pub mod shell;
11pub mod system;
12pub mod vcs;
13pub mod wrappers;
14pub mod xcode;
15
16use std::collections::HashMap;
17
18use crate::parse::Token;
19use crate::verdict::Verdict;
20
21type HandlerFn = fn(&[Token]) -> Verdict;
22
23pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
24    HashMap::from([
25        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
26    ])
27}
28
29pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
30    HashMap::from([
31        ("bun_x", node::bun::check_bun_x as HandlerFn),
32    ])
33}
34
35pub fn dispatch(tokens: &[Token]) -> Verdict {
36    let cmd = tokens[0].command_name();
37    None
38        .or_else(|| shell::dispatch(cmd, tokens))
39        .or_else(|| wrappers::dispatch(cmd, tokens))
40        .or_else(|| vcs::dispatch(cmd, tokens))
41        .or_else(|| forges::dispatch(cmd, tokens))
42        .or_else(|| node::dispatch(cmd, tokens))
43        .or_else(|| ruby::dispatch(cmd, tokens))
44        .or_else(|| jvm::dispatch(cmd, tokens))
45        .or_else(|| android::dispatch(cmd, tokens))
46        .or_else(|| network::dispatch(cmd, tokens))
47        .or_else(|| system::dispatch(cmd, tokens))
48        .or_else(|| xcode::dispatch(cmd, tokens))
49        .or_else(|| perl::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(coreutils::command_docs());
125    docs.extend(fuzzy::command_docs());
126    docs.extend(shell::command_docs());
127    docs.extend(wrappers::command_docs());
128    docs.extend(crate::registry::toml_command_docs());
129    docs
130}
131
132#[cfg(test)]
133#[derive(Debug)]
134pub(crate) enum CommandEntry {
135    Positional { cmd: &'static str },
136    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
137    Subcommand { cmd: &'static str, subs: &'static [SubEntry], bare_ok: bool },
138    Delegation { cmd: &'static str },
139}
140
141#[cfg(test)]
142#[derive(Debug)]
143pub(crate) enum SubEntry {
144    Policy { name: &'static str },
145    Nested { name: &'static str, subs: &'static [SubEntry] },
146    Custom { name: &'static str, valid_suffix: Option<&'static str> },
147    Positional,
148    Guarded { name: &'static str, valid_suffix: &'static str },
149}
150
151use crate::command::CommandDef;
152
153const COMMAND_DEFS: &[&CommandDef] = &[
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(coreutils::full_registry());
198    entries.extend(fuzzy::full_registry());
199    entries
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205    use std::collections::HashSet;
206
207    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
208    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
209
210    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
211        match entry {
212            CommandEntry::Positional { .. } | CommandEntry::Delegation { .. } => {}
213            CommandEntry::Custom { cmd, valid_prefix } => {
214                let base = valid_prefix.unwrap_or(cmd);
215                let test = format!("{base} {UNKNOWN_FLAG}");
216                if crate::is_safe_command(&test) {
217                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
218                }
219            }
220            CommandEntry::Subcommand { cmd, subs, bare_ok } => {
221                if !bare_ok && crate::is_safe_command(cmd) {
222                    failures.push(format!("{cmd}: accepted bare invocation"));
223                }
224                let test = format!("{cmd} {UNKNOWN_SUB}");
225                if crate::is_safe_command(&test) {
226                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
227                }
228                for sub in *subs {
229                    check_sub(cmd, sub, failures);
230                }
231            }
232        }
233    }
234
235    fn check_sub(prefix: &str, entry: &SubEntry, failures: &mut Vec<String>) {
236        match entry {
237            SubEntry::Policy { name } => {
238                let test = format!("{prefix} {name} {UNKNOWN_FLAG}");
239                if crate::is_safe_command(&test) {
240                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
241                }
242            }
243            SubEntry::Nested { name, subs } => {
244                let path = format!("{prefix} {name}");
245                let test = format!("{path} {UNKNOWN_SUB}");
246                if crate::is_safe_command(&test) {
247                    failures.push(format!("{path}: accepted unknown subcommand: {test}"));
248                }
249                for sub in *subs {
250                    check_sub(&path, sub, failures);
251                }
252            }
253            SubEntry::Custom { name, valid_suffix } => {
254                let base = match valid_suffix {
255                    Some(s) => format!("{prefix} {name} {s}"),
256                    None => format!("{prefix} {name}"),
257                };
258                let test = format!("{base} {UNKNOWN_FLAG}");
259                if crate::is_safe_command(&test) {
260                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
261                }
262            }
263            SubEntry::Positional => {}
264            SubEntry::Guarded { name, valid_suffix } => {
265                let test = format!("{prefix} {name} {valid_suffix} {UNKNOWN_FLAG}");
266                if crate::is_safe_command(&test) {
267                    failures.push(format!("{prefix} {name}: accepted unknown flag: {test}"));
268                }
269            }
270        }
271    }
272
273    #[test]
274    fn all_commands_reject_unknown() {
275        let registry = full_registry();
276        let mut failures = Vec::new();
277        for entry in &registry {
278            check_entry(entry, &mut failures);
279        }
280        assert!(
281            failures.is_empty(),
282            "unknown flags/subcommands accepted:\n{}",
283            failures.join("\n")
284        );
285    }
286
287    #[test]
288    fn command_defs_reject_unknown() {
289        for def in COMMAND_DEFS {
290            def.auto_test_reject_unknown();
291        }
292    }
293
294    #[test]
295    fn flat_defs_reject_unknown() {
296        for def in coreutils::all_flat_defs() {
297            def.auto_test_reject_unknown();
298        }
299        for def in xcode::xcbeautify_flat_defs() {
300            def.auto_test_reject_unknown();
301        }
302        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
303            def.auto_test_reject_unknown();
304        }
305    }
306
307
308    #[test]
309    fn bare_false_rejects_bare_invocation() {
310        let check_def = |def: &crate::command::FlatDef| {
311            if !def.policy.bare {
312                assert!(
313                    !crate::is_safe_command(def.name),
314                    "{}: bare=false but bare invocation accepted",
315                    def.name,
316                );
317            }
318        };
319        for def in coreutils::all_flat_defs()
320            .into_iter()
321            .chain(xcode::xcbeautify_flat_defs())
322        {
323            check_def(def);
324        }
325        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
326            check_def(def);
327        }
328    }
329
330    fn visit_subs(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::command::SubDef)) {
331        for sub in subs {
332            visitor(prefix, sub);
333            if let crate::command::SubDef::Nested { name, subs: inner } = sub {
334                visit_subs(&format!("{prefix} {name}"), inner, visitor);
335            }
336        }
337    }
338
339    #[test]
340    fn guarded_subs_require_guard() {
341        let mut failures = Vec::new();
342        for def in COMMAND_DEFS {
343            visit_subs(def.name, def.subs, &mut |prefix, sub| {
344                if let crate::command::SubDef::Guarded { name, guard_long, .. } = sub {
345                    let without = format!("{prefix} {name}");
346                    if crate::is_safe_command(&without) {
347                        failures.push(format!("{without}: accepted without guard {guard_long}"));
348                    }
349                    let with = format!("{prefix} {name} {guard_long}");
350                    if !crate::is_safe_command(&with) {
351                        failures.push(format!("{with}: rejected with guard {guard_long}"));
352                    }
353                }
354            });
355        }
356        assert!(failures.is_empty(), "guarded sub issues:\n{}", failures.join("\n"));
357    }
358
359    #[test]
360    fn guarded_subs_accept_guard_short() {
361        let mut failures = Vec::new();
362        for def in COMMAND_DEFS {
363            visit_subs(def.name, def.subs, &mut |prefix, sub| {
364                if let crate::command::SubDef::Guarded { name, guard_short: Some(short), .. } = sub {
365                    let with_short = format!("{prefix} {name} {short}");
366                    if !crate::is_safe_command(&with_short) {
367                        failures.push(format!("{with_short}: rejected with guard_short"));
368                    }
369                }
370            });
371        }
372        assert!(failures.is_empty(), "guard_short issues:\n{}", failures.join("\n"));
373    }
374
375    #[test]
376    fn nested_subs_reject_bare() {
377        let mut failures = Vec::new();
378        for def in COMMAND_DEFS {
379            visit_subs(def.name, def.subs, &mut |prefix, sub| {
380                if let crate::command::SubDef::Nested { name, .. } = sub {
381                    let bare = format!("{prefix} {name}");
382                    if crate::is_safe_command(&bare) {
383                        failures.push(format!("{bare}: nested sub accepted bare invocation"));
384                    }
385                }
386            });
387        }
388        assert!(failures.is_empty(), "nested bare issues:\n{}", failures.join("\n"));
389    }
390
391    #[test]
392    fn process_substitution_blocked() {
393        let cmds = ["echo <(cat /etc/passwd)", "echo >(rm -rf /)", "grep pattern <(ls)"];
394        for cmd in &cmds {
395            assert!(
396                !crate::is_safe_command(cmd),
397                "process substitution not blocked: {cmd}",
398            );
399        }
400    }
401
402    #[test]
403    fn positional_style_accepts_unknown_args() {
404        use crate::policy::FlagStyle;
405        for def in coreutils::all_flat_defs() {
406            if def.policy.flag_style == FlagStyle::Positional {
407                let test = format!("{} --unknown-xyz", def.name);
408                assert!(
409                    crate::is_safe_command(&test),
410                    "{}: FlagStyle::Positional but rejected unknown arg",
411                    def.name,
412                );
413            }
414        }
415    }
416
417    fn visit_policies(prefix: &str, subs: &[crate::command::SubDef], visitor: &mut dyn FnMut(&str, &crate::policy::FlagPolicy)) {
418        for sub in subs {
419            match sub {
420                crate::command::SubDef::Policy { name, policy, .. } => {
421                    visitor(&format!("{prefix} {name}"), policy);
422                }
423                crate::command::SubDef::Guarded { name, guard_long, policy, .. } => {
424                    visitor(&format!("{prefix} {name} {guard_long}"), policy);
425                }
426                crate::command::SubDef::Nested { name, subs: inner } => {
427                    visit_policies(&format!("{prefix} {name}"), inner, visitor);
428                }
429                _ => {}
430            }
431        }
432    }
433
434    #[test]
435    fn valued_flags_accept_eq_syntax() {
436        let mut failures = Vec::new();
437
438        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
439            for flag in def.policy.valued.iter() {
440                let cmd = format!("{} {flag}=test_value", def.name);
441                if !crate::is_safe_command(&cmd) {
442                    failures.push(format!("{cmd}: valued flag rejected with = syntax"));
443                }
444            }
445        };
446        for def in coreutils::all_flat_defs()
447            .into_iter()
448            .chain(xcode::xcbeautify_flat_defs())
449        {
450            check_flat(def, &mut failures);
451        }
452        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
453            check_flat(def, &mut failures);
454        }
455
456        for def in COMMAND_DEFS {
457            visit_policies(def.name, def.subs, &mut |prefix, policy| {
458                for flag in policy.valued.iter() {
459                    let cmd = format!("{prefix} {flag}=test_value");
460                    if !crate::is_safe_command(&cmd) {
461                        failures.push(format!("{cmd}: valued flag rejected with = syntax"));
462                    }
463                }
464            });
465        }
466
467        assert!(failures.is_empty(), "valued = syntax issues:\n{}", failures.join("\n"));
468    }
469
470    #[test]
471    fn max_positional_enforced() {
472        let mut failures = Vec::new();
473
474        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
475            if let Some(max) = def.policy.max_positional {
476                let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
477                let cmd = format!("{} {}", def.name, args.join(" "));
478                if crate::is_safe_command(&cmd) {
479                    failures.push(format!(
480                        "{}: max_positional={max} but accepted {} positional args",
481                        def.name,
482                        max + 1,
483                    ));
484                }
485            }
486        };
487        for def in coreutils::all_flat_defs()
488            .into_iter()
489            .chain(xcode::xcbeautify_flat_defs())
490        {
491            check_flat(def, &mut failures);
492        }
493        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
494            check_flat(def, &mut failures);
495        }
496
497        for def in COMMAND_DEFS {
498            visit_policies(def.name, def.subs, &mut |prefix, policy| {
499                if let Some(max) = policy.max_positional {
500                    let args: Vec<&str> = (0..=max).map(|_| "testarg").collect();
501                    let cmd = format!("{prefix} {}", args.join(" "));
502                    if crate::is_safe_command(&cmd) {
503                        failures.push(format!(
504                            "{prefix}: max_positional={max} but accepted {} positional args",
505                            max + 1,
506                        ));
507                    }
508                }
509            });
510        }
511
512        assert!(failures.is_empty(), "max_positional issues:\n{}", failures.join("\n"));
513    }
514
515    #[test]
516    fn doc_generation_non_empty() {
517        let mut failures = Vec::new();
518
519        for def in COMMAND_DEFS {
520            let doc = def.to_doc();
521            if doc.description.trim().is_empty() {
522                failures.push(format!("{}: CommandDef produced empty doc", def.name));
523            }
524            if doc.url.is_empty() {
525                failures.push(format!("{}: CommandDef has empty URL", def.name));
526            }
527        }
528
529        let check_flat = |def: &crate::command::FlatDef, failures: &mut Vec<String>| {
530            let doc = def.to_doc();
531            if doc.description.trim().is_empty() && !def.policy.bare {
532                failures.push(format!("{}: FlatDef produced empty doc", def.name));
533            }
534            if doc.url.is_empty() {
535                failures.push(format!("{}: FlatDef has empty URL", def.name));
536            }
537        };
538        for def in coreutils::all_flat_defs()
539            .into_iter()
540            .chain(xcode::xcbeautify_flat_defs())
541        {
542            check_flat(def, &mut failures);
543        }
544        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
545            check_flat(def, &mut failures);
546        }
547
548        assert!(failures.is_empty(), "doc generation issues:\n{}", failures.join("\n"));
549    }
550
551    #[test]
552    fn registry_covers_handled_commands() {
553        let registry = full_registry();
554        let mut all_cmds: HashSet<&str> = registry
555            .iter()
556            .map(|e| match e {
557                CommandEntry::Positional { cmd }
558                | CommandEntry::Custom { cmd, .. }
559                | CommandEntry::Subcommand { cmd, .. }
560                | CommandEntry::Delegation { cmd } => *cmd,
561            })
562            .collect();
563        for def in COMMAND_DEFS {
564            all_cmds.insert(def.name);
565        }
566        for def in coreutils::all_flat_defs() {
567            all_cmds.insert(def.name);
568        }
569        for def in xcode::xcbeautify_flat_defs() {
570            all_cmds.insert(def.name);
571        }
572        for def in jvm::jvm_flat_defs().into_iter().chain(android::android_flat_defs()).chain(fuzzy::fuzzy_flat_defs()) {
573            all_cmds.insert(def.name);
574        }
575        for name in crate::registry::toml_command_names() {
576            all_cmds.insert(name);
577        }
578        let handled: HashSet<&str> = HANDLED_CMDS.iter().copied().collect();
579
580        let missing: Vec<_> = handled.difference(&all_cmds).collect();
581        assert!(missing.is_empty(), "not in registry or COMMAND_DEFS: {missing:?}");
582
583        let extra: Vec<_> = all_cmds.difference(&handled).collect();
584        assert!(extra.is_empty(), "not in HANDLED_CMDS: {extra:?}");
585    }
586
587}