Skip to main content

safe_chains/handlers/
mod.rs

1macro_rules! handler_module {
2    ($($sub:ident),+ $(,)?) => {
3        $(mod $sub;)+
4
5        pub(crate) fn dispatch(cmd: &str, tokens: &[crate::parse::Token]) -> Option<crate::verdict::Verdict> {
6            None$(.or_else(|| $sub::dispatch(cmd, tokens)))+
7        }
8
9        pub fn command_docs() -> Vec<crate::docs::CommandDoc> {
10            let mut docs = Vec::new();
11            $(docs.extend($sub::command_docs());)+
12            docs
13        }
14
15        #[cfg(test)]
16        pub(super) fn full_registry() -> Vec<&'static super::CommandEntry> {
17            let mut v = Vec::new();
18            $(v.extend($sub::REGISTRY);)+
19            v
20        }
21    };
22}
23
24pub mod android;
25pub mod coreutils;
26pub mod forges;
27pub mod fuzzy;
28pub mod jvm;
29pub mod magick;
30pub mod network;
31pub mod node;
32pub mod perl;
33pub mod php;
34pub mod ruby;
35pub mod shell;
36pub mod system;
37pub mod tilt;
38pub mod vcs;
39pub mod wrappers;
40
41use std::collections::HashMap;
42
43use crate::parse::Token;
44use crate::verdict::Verdict;
45
46type HandlerFn = fn(&[Token]) -> Verdict;
47
48pub fn custom_cmd_handlers() -> HashMap<&'static str, HandlerFn> {
49    HashMap::from([
50        ("gh", forges::gh::is_safe_gh as HandlerFn),
51        ("glab", forges::glab::is_safe_glab as HandlerFn),
52        ("magick", magick::is_safe_magick as HandlerFn),
53        ("php", php::is_safe_php as HandlerFn),
54        ("sysctl", system::sysctl::is_safe_sysctl as HandlerFn),
55        ("tilt", tilt::check_tilt as HandlerFn),
56    ])
57}
58
59pub fn custom_sub_handlers() -> HashMap<&'static str, HandlerFn> {
60    HashMap::from([
61        ("bun_x", node::bun::check_bun_x as HandlerFn),
62        ("bundle_config", ruby::bundle::check_bundle_config as HandlerFn),
63        ("bundle_exec", ruby::bundle::check_bundle_exec as HandlerFn),
64        ("gh_api", forges::gh::is_safe_gh_api as HandlerFn),
65        ("git_remote", vcs::git::check_git_remote as HandlerFn),
66        ("laravel_cache_clear", php::check_laravel_cache_clear as HandlerFn),
67        ("plutil_convert", system::plutil::check_plutil_convert as HandlerFn),
68    ])
69}
70
71pub fn dispatch(tokens: &[Token]) -> Verdict {
72    let cmd = tokens[0].command_name();
73    None
74        .or_else(|| crate::registry::custom_dispatch(tokens))
75        .or_else(|| shell::dispatch(cmd, tokens))
76        .or_else(|| wrappers::dispatch(cmd, tokens))
77        .or_else(|| forges::dispatch(cmd, tokens))
78        .or_else(|| node::dispatch(cmd, tokens))
79        .or_else(|| jvm::dispatch(cmd, tokens))
80        .or_else(|| android::dispatch(cmd, tokens))
81        .or_else(|| network::dispatch(cmd, tokens))
82        .or_else(|| system::dispatch(cmd, tokens))
83        .or_else(|| perl::dispatch(cmd, tokens))
84        .or_else(|| coreutils::dispatch(cmd, tokens))
85        .or_else(|| fuzzy::dispatch(cmd, tokens))
86        .or_else(|| vcs::dispatch(cmd, tokens))
87        .or_else(|| crate::registry::toml_dispatch(tokens))
88        .unwrap_or(Verdict::Denied)
89}
90
91pub fn handler_docs() -> Vec<crate::docs::CommandDoc> {
92    let mut docs = Vec::new();
93    docs.extend(forges::command_docs());
94    docs.extend(node::command_docs());
95    docs.extend(jvm::command_docs());
96    docs.extend(android::command_docs());
97    docs.extend(network::command_docs());
98    docs.extend(system::command_docs());
99    docs.extend(perl::command_docs());
100    docs.extend(coreutils::command_docs());
101    docs.extend(fuzzy::command_docs());
102    docs.extend(shell::command_docs());
103    docs.extend(wrappers::command_docs());
104    docs.extend(vcs::command_docs());
105    docs.extend(crate::registry::toml_command_docs());
106    docs
107}
108
109#[cfg(test)]
110#[derive(Debug)]
111pub(crate) enum CommandEntry {
112    Custom { cmd: &'static str, valid_prefix: Option<&'static str> },
113    Paths { cmd: &'static str, bare_ok: bool, paths: &'static [&'static str] },
114}
115
116pub fn all_opencode_patterns() -> Vec<String> {
117    let mut patterns = Vec::new();
118    patterns.sort();
119    patterns.dedup();
120    patterns
121}
122
123#[cfg(test)]
124fn full_registry() -> Vec<&'static CommandEntry> {
125    let mut entries = Vec::new();
126    entries.extend(forges::full_registry());
127    entries.extend(jvm::full_registry());
128    entries.extend(android::full_registry());
129    entries.extend(network::REGISTRY);
130    entries.extend(coreutils::full_registry());
131    entries.extend(fuzzy::full_registry());
132    entries
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    const UNKNOWN_FLAG: &str = "--xyzzy-unknown-42";
140    const UNKNOWN_SUB: &str = "xyzzy-unknown-42";
141
142    fn check_entry(entry: &CommandEntry, failures: &mut Vec<String>) {
143        match entry {
144            CommandEntry::Custom { cmd, valid_prefix } => {
145                let base = valid_prefix.unwrap_or(cmd);
146                let test = format!("{base} {UNKNOWN_FLAG}");
147                if crate::is_safe_command(&test) {
148                    failures.push(format!("{cmd}: accepted unknown flag: {test}"));
149                }
150            }
151            CommandEntry::Paths { cmd, bare_ok, paths } => {
152                if !bare_ok && crate::is_safe_command(cmd) {
153                    failures.push(format!("{cmd}: accepted bare invocation"));
154                }
155                let test = format!("{cmd} {UNKNOWN_SUB}");
156                if crate::is_safe_command(&test) {
157                    failures.push(format!("{cmd}: accepted unknown subcommand: {test}"));
158                }
159                for path in *paths {
160                    let test = format!("{path} {UNKNOWN_FLAG}");
161                    if crate::is_safe_command(&test) {
162                        failures.push(format!("{path}: accepted unknown flag: {test}"));
163                    }
164                }
165            }
166        }
167    }
168
169    #[test]
170    fn all_commands_reject_unknown() {
171        let registry = full_registry();
172        let mut failures = Vec::new();
173        for entry in &registry {
174            check_entry(entry, &mut failures);
175        }
176        assert!(
177            failures.is_empty(),
178            "unknown flags/subcommands accepted:\n{}",
179            failures.join("\n")
180        );
181    }
182
183    #[test]
184    fn process_substitution_safe_inner() {
185        let safe = ["echo <(cat /etc/passwd)", "grep pattern <(ls)", "diff <(sort a.txt) <(sort b.txt)", "comm -23 file.txt <(sort other.txt)"];
186        for cmd in &safe {
187            assert!(crate::is_safe_command(cmd), "safe process substitution rejected: {cmd}");
188        }
189    }
190
191    #[test]
192    fn process_substitution_unsafe_inner() {
193        let unsafe_cmds = ["echo >(rm -rf /)", "diff <(sort a.txt) <(rm -rf /)"];
194        for cmd in &unsafe_cmds {
195            assert!(!crate::is_safe_command(cmd), "unsafe process substitution approved: {cmd}");
196        }
197    }
198
199}