Skip to main content

rippy_cli/handlers/
mod.rs

1mod ansible;
2mod cd;
3mod cloud;
4mod curl;
5mod database;
6mod docker;
7mod env_xargs;
8mod find;
9mod gh;
10mod git;
11mod helm;
12mod mkdir;
13mod node;
14mod npm;
15mod perl;
16mod python;
17mod python_tools;
18mod ruby;
19mod shell;
20mod system;
21mod text_tools;
22mod unix_utils;
23
24use std::collections::HashMap;
25use std::path::Path;
26use std::sync::LazyLock;
27
28use crate::verdict::Decision;
29
30/// Context passed to handlers for classification.
31pub struct HandlerContext<'a> {
32    pub command_name: &'a str,
33    pub args: &'a [String],
34    pub working_directory: &'a Path,
35    pub remote: bool,
36    pub receives_piped_input: bool,
37    /// Extra directories that `cd` is allowed to navigate to (from config).
38    pub cd_allowed_dirs: &'a [std::path::PathBuf],
39}
40
41/// Maximum file size (64 KB) for `read_file` — prevents reading huge files.
42const MAX_FILE_SIZE: u64 = 65_536;
43
44impl HandlerContext<'_> {
45    /// Get the first argument (typically a subcommand).
46    pub fn subcommand(&self) -> &str {
47        self.args.first().map_or("", String::as_str)
48    }
49
50    /// Get the Nth argument.
51    pub fn arg(&self, n: usize) -> &str {
52        self.args.get(n).map_or("", String::as_str)
53    }
54
55    /// Read a file's contents for informed classification.
56    ///
57    /// Returns `None` if the file can't be read (remote mode, missing,
58    /// too large, binary, or outside the working directory).
59    pub fn read_file(&self, path: &str) -> Option<String> {
60        if self.remote {
61            return None;
62        }
63        let file_path = self.working_directory.join(path);
64        let canonical = file_path.canonicalize().ok()?;
65        let cwd_canonical = self.working_directory.canonicalize().ok()?;
66        if !canonical.starts_with(&cwd_canonical) {
67            return None;
68        }
69        let metadata = std::fs::metadata(&canonical).ok()?;
70        if metadata.len() > MAX_FILE_SIZE {
71            return None;
72        }
73        std::fs::read_to_string(&canonical).ok()
74    }
75}
76
77/// The result of classifying a command.
78#[derive(Debug, Clone)]
79pub enum Classification {
80    /// Auto-approve with description.
81    Allow(String),
82    /// Needs user confirmation with description.
83    Ask(String),
84    /// Block with description.
85    Deny(String),
86    /// Re-parse and analyze this inner command string.
87    Recurse(String),
88    /// Re-parse inner command with remote=true (for docker exec, kubectl exec).
89    RecurseRemote(String),
90    /// Decision with redirect targets that need config rule checking.
91    WithRedirects(Decision, String, Vec<String>),
92}
93
94/// Trait for command handlers.
95pub trait Handler: Send + Sync {
96    fn commands(&self) -> &[&str];
97    fn classify(&self, ctx: &HandlerContext) -> Classification;
98}
99
100/// A data-driven handler for commands with simple subcommand-based classification.
101pub struct SubcommandHandler {
102    cmds: &'static [&'static str],
103    safe: &'static [&'static str],
104    ask: &'static [&'static str],
105    desc_prefix: &'static str,
106}
107
108impl SubcommandHandler {
109    #[must_use]
110    pub const fn new(
111        cmds: &'static [&'static str],
112        safe: &'static [&'static str],
113        ask: &'static [&'static str],
114        desc_prefix: &'static str,
115    ) -> Self {
116        Self {
117            cmds,
118            safe,
119            ask,
120            desc_prefix,
121        }
122    }
123}
124
125impl Handler for SubcommandHandler {
126    fn commands(&self) -> &[&str] {
127        self.cmds
128    }
129
130    fn classify(&self, ctx: &HandlerContext) -> Classification {
131        let sub = ctx.args.first().map_or("", String::as_str);
132        let desc = format!("{} {sub}", self.desc_prefix);
133
134        // Check --help/--version first
135        if ctx
136            .args
137            .iter()
138            .any(|a| a == "--help" || a == "-h" || a == "--version" || a == "-V")
139        {
140            return Classification::Allow(format!("{} help/version", self.desc_prefix));
141        }
142
143        if self.safe.contains(&sub) {
144            Classification::Allow(desc)
145        } else if self.ask.contains(&sub) {
146            Classification::Ask(desc)
147        } else if sub.is_empty() {
148            Classification::Ask(format!("{} (no subcommand)", self.desc_prefix))
149        } else {
150            Classification::Ask(desc)
151        }
152    }
153}
154
155/// Look up a handler by command name.
156#[must_use]
157pub fn get_handler(command_name: &str) -> Option<&'static dyn Handler> {
158    HANDLER_REGISTRY.get(command_name).copied()
159}
160
161/// Return the number of registered handler command names.
162#[must_use]
163pub fn handler_count() -> usize {
164    HANDLER_REGISTRY.len()
165}
166
167/// Return all handler-registered command names, sorted alphabetically.
168#[must_use]
169pub fn all_handler_commands() -> Vec<&'static str> {
170    let mut cmds: Vec<_> = HANDLER_REGISTRY.keys().copied().collect();
171    cmds.sort_unstable();
172    cmds
173}
174
175static HANDLER_REGISTRY: LazyLock<HashMap<&'static str, &'static dyn Handler>> =
176    LazyLock::new(build_registry);
177
178fn build_registry() -> HashMap<&'static str, &'static dyn Handler> {
179    // NOTE: Pure classification handlers (simple.rs, file_ops.rs, dangerous.rs) have been
180    // migrated to stdlib config rules. Only behavioral handlers remain here.
181    let handlers: Vec<&'static dyn Handler> = vec![
182        &cd::CD_HANDLER,
183        &mkdir::MKDIR_HANDLER,
184        &git::GIT_HANDLER,
185        &docker::DOCKER_HANDLER,
186        &node::NODE_HANDLER,
187        &perl::PERL_HANDLER,
188        &python::PYTHON_HANDLER,
189        &ruby::RUBY_HANDLER,
190        &shell::SHELL_HANDLER,
191        &find::FIND_HANDLER,
192        &curl::CURL_HANDLER,
193        &npm::NPM_HANDLER,
194        &helm::HELM_HANDLER,
195        &gh::GH_HANDLER,
196        &cloud::KUBECTL_HANDLER,
197        &cloud::AWS_HANDLER,
198        &cloud::GCLOUD_HANDLER,
199        &cloud::AZ_HANDLER,
200        &database::PSQL_HANDLER,
201        &database::MYSQL_HANDLER,
202        &database::SQLITE3_HANDLER,
203        &text_tools::SED_HANDLER,
204        &text_tools::AWK_HANDLER,
205        &env_xargs::ENV_HANDLER,
206        &env_xargs::XARGS_HANDLER,
207        &unix_utils::TAR_HANDLER,
208        &unix_utils::WGET_HANDLER,
209        &python_tools::UV_HANDLER,
210        &unix_utils::GZIP_HANDLER,
211        &unix_utils::UNZIP_HANDLER,
212        &unix_utils::MKTEMP_HANDLER,
213        &unix_utils::TEE_HANDLER,
214        &unix_utils::SORT_HANDLER,
215        &unix_utils::OPEN_HANDLER,
216        &unix_utils::YQ_HANDLER,
217        &python_tools::RUFF_HANDLER,
218        &python_tools::BLACK_HANDLER,
219        &system::FD_HANDLER,
220        &system::DMESG_HANDLER,
221        &system::IP_HANDLER,
222        &system::IFCONFIG_HANDLER,
223        &ansible::ANSIBLE_HANDLER,
224    ];
225
226    let mut map = HashMap::new();
227    for handler in handlers {
228        for cmd in handler.commands() {
229            map.insert(*cmd, handler);
230        }
231    }
232    map
233}
234
235/// Helper: check if any arg matches a set of flags.
236pub fn has_flag(args: &[String], flags: &[&str]) -> bool {
237    args.iter().any(|a| flags.contains(&a.as_str()))
238}
239
240/// Helper: get the first positional argument (non-flag).
241pub fn first_positional(args: &[String]) -> Option<&str> {
242    args.iter()
243        .find(|a| !a.starts_with('-'))
244        .map(String::as_str)
245}
246
247/// Helper: collect all positional (non-flag) arguments.
248pub fn positional_args(args: &[String]) -> Vec<&str> {
249    args.iter()
250        .filter(|a| !a.starts_with('-'))
251        .map(String::as_str)
252        .collect()
253}
254
255/// Helper: get the value following a flag (e.g., `-o output.txt` → `Some("output.txt")`).
256pub fn get_flag_value(args: &[String], flags: &[&str]) -> Option<String> {
257    for (i, arg) in args.iter().enumerate() {
258        if flags.contains(&arg.as_str()) {
259            return args.get(i + 1).cloned();
260        }
261    }
262    None
263}
264
265/// Default directories that are always considered safe for path-based handlers.
266pub const SAFE_DIRECTORIES: &[&str] = &["/tmp", "/var/tmp"];
267
268/// Logical path normalization: resolve `.` and `..` components without
269/// filesystem access (the target directory may not exist yet).
270pub fn normalize_path(path: &Path) -> std::path::PathBuf {
271    let mut result = std::path::PathBuf::new();
272    for component in path.components() {
273        match component {
274            std::path::Component::CurDir => {}
275            std::path::Component::ParentDir => {
276                result.pop();
277            }
278            other => result.push(other),
279        }
280    }
281    result
282}
283
284/// Check if a resolved, normalized path is within the working directory,
285/// a config-allowed directory, or a default safe directory.
286///
287/// Both `path` and `normalized_cwd` must already be normalized.
288/// `allowed_dirs` are normalized at config load time.
289pub fn is_within_scope(
290    path: &Path,
291    normalized_cwd: &Path,
292    allowed_dirs: &[std::path::PathBuf],
293) -> bool {
294    if path.starts_with(normalized_cwd) {
295        return true;
296    }
297
298    if allowed_dirs.iter().any(|d| path.starts_with(d)) {
299        return true;
300    }
301
302    SAFE_DIRECTORIES.iter().any(|safe| path.starts_with(safe))
303}
304
305#[cfg(test)]
306#[allow(clippy::unwrap_used)]
307mod tests {
308    use super::*;
309
310    fn ctx_with_dir(dir: &Path, remote: bool) -> HandlerContext<'_> {
311        HandlerContext {
312            command_name: "test",
313            args: &[],
314            working_directory: dir,
315            remote,
316            receives_piped_input: false,
317            cd_allowed_dirs: &[],
318        }
319    }
320
321    #[test]
322    fn read_file_returns_none_when_remote() {
323        let dir = tempfile::tempdir().unwrap();
324        let file = dir.path().join("test.txt");
325        std::fs::write(&file, "hello").unwrap();
326        let ctx = ctx_with_dir(dir.path(), true);
327        assert!(ctx.read_file("test.txt").is_none());
328    }
329
330    #[test]
331    fn read_file_returns_none_for_missing_file() {
332        let dir = tempfile::tempdir().unwrap();
333        let ctx = ctx_with_dir(dir.path(), false);
334        assert!(ctx.read_file("nonexistent.txt").is_none());
335    }
336
337    #[test]
338    fn read_file_reads_existing_file() {
339        let dir = tempfile::tempdir().unwrap();
340        let file = dir.path().join("test.txt");
341        std::fs::write(&file, "hello world").unwrap();
342        let ctx = ctx_with_dir(dir.path(), false);
343        assert_eq!(ctx.read_file("test.txt").unwrap(), "hello world");
344    }
345
346    #[test]
347    fn read_file_rejects_path_outside_working_dir() {
348        let dir = tempfile::tempdir().unwrap();
349        let ctx = ctx_with_dir(dir.path(), false);
350        assert!(ctx.read_file("../../etc/passwd").is_none());
351    }
352
353    #[test]
354    fn read_file_rejects_oversized_file() {
355        let dir = tempfile::tempdir().unwrap();
356        let file = dir.path().join("big.txt");
357        #[allow(clippy::cast_possible_truncation)]
358        let content = "x".repeat(MAX_FILE_SIZE as usize + 1);
359        std::fs::write(&file, content).unwrap();
360        let ctx = ctx_with_dir(dir.path(), false);
361        assert!(ctx.read_file("big.txt").is_none());
362    }
363}