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
30pub 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 pub cd_allowed_dirs: &'a [std::path::PathBuf],
39}
40
41const MAX_FILE_SIZE: u64 = 65_536;
43
44impl HandlerContext<'_> {
45 pub fn subcommand(&self) -> &str {
47 self.args.first().map_or("", String::as_str)
48 }
49
50 pub fn arg(&self, n: usize) -> &str {
52 self.args.get(n).map_or("", String::as_str)
53 }
54
55 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#[derive(Debug, Clone)]
79pub enum Classification {
80 Allow(String),
82 Ask(String),
84 Deny(String),
86 Recurse(String),
88 RecurseRemote(String),
90 WithRedirects(Decision, String, Vec<String>),
92}
93
94pub trait Handler: Send + Sync {
96 fn commands(&self) -> &[&str];
97 fn classify(&self, ctx: &HandlerContext) -> Classification;
98}
99
100pub 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 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#[must_use]
157pub fn get_handler(command_name: &str) -> Option<&'static dyn Handler> {
158 HANDLER_REGISTRY.get(command_name).copied()
159}
160
161#[must_use]
163pub fn handler_count() -> usize {
164 HANDLER_REGISTRY.len()
165}
166
167#[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 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
235pub fn has_flag(args: &[String], flags: &[&str]) -> bool {
237 args.iter().any(|a| flags.contains(&a.as_str()))
238}
239
240pub fn first_positional(args: &[String]) -> Option<&str> {
242 args.iter()
243 .find(|a| !a.starts_with('-'))
244 .map(String::as_str)
245}
246
247pub 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
255pub 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
265pub const SAFE_DIRECTORIES: &[&str] = &["/tmp", "/var/tmp"];
267
268pub 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
284pub 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}