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