1use std::time::Duration;
4
5#[derive(Debug, Clone)]
7pub struct ValidationConfig {
8 pub max_command_length: usize,
10 pub max_output_size: usize,
12 pub max_timeout_secs: u64,
14 pub min_timeout_secs: u64,
16 pub block_dangerous: bool,
18 pub blocked_patterns: Vec<String>,
20}
21
22impl Default for ValidationConfig {
23 fn default() -> Self {
24 Self {
25 max_command_length: 4096,
26 max_output_size: 10 * 1024 * 1024, max_timeout_secs: 300, min_timeout_secs: 1,
29 block_dangerous: true,
30 blocked_patterns: Vec::new(),
31 }
32 }
33}
34
35impl ValidationConfig {
36 pub fn permissive() -> Self {
38 Self {
39 max_command_length: 65536,
40 max_output_size: 100 * 1024 * 1024, max_timeout_secs: 3600, min_timeout_secs: 1,
43 block_dangerous: false,
44 blocked_patterns: Vec::new(),
45 }
46 }
47
48 pub fn strict() -> Self {
50 Self {
51 max_command_length: 1024,
52 max_output_size: 1024 * 1024, max_timeout_secs: 60,
54 min_timeout_secs: 1,
55 block_dangerous: true,
56 blocked_patterns: vec![
57 "rm -rf".to_string(),
58 "mkfs".to_string(),
59 "dd if=".to_string(),
60 ":(){".to_string(), ">(w)".to_string(),
62 ],
63 }
64 }
65}
66
67#[derive(Debug)]
69pub struct CommandValidator {
70 config: ValidationConfig,
71}
72
73impl CommandValidator {
74 pub fn new(config: ValidationConfig) -> Self {
76 Self { config }
77 }
78
79 pub fn validate_command(&self, command: &str) -> Result<(), ValidationError> {
81 if command.len() > self.config.max_command_length {
83 return Err(ValidationError::CommandTooLong {
84 length: command.len(),
85 max: self.config.max_command_length,
86 });
87 }
88
89 if command.trim().is_empty() {
91 return Err(ValidationError::EmptyCommand);
92 }
93
94 if self.config.block_dangerous {
96 if let Some(pattern) = self.check_dangerous_patterns(command) {
97 return Err(ValidationError::DangerousCommand {
98 pattern: pattern.to_string(),
99 });
100 }
101 }
102
103 for pattern in &self.config.blocked_patterns {
105 if command.contains(pattern) {
106 return Err(ValidationError::BlockedPattern {
107 pattern: pattern.clone(),
108 });
109 }
110 }
111
112 if command.contains('\0') {
114 return Err(ValidationError::InvalidCharacter('\0'));
115 }
116
117 Ok(())
118 }
119
120 pub fn validate_timeout(&self, timeout_secs: u64) -> Result<Duration, ValidationError> {
122 if timeout_secs < self.config.min_timeout_secs {
123 return Err(ValidationError::TimeoutTooShort {
124 value: timeout_secs,
125 min: self.config.min_timeout_secs,
126 });
127 }
128
129 if timeout_secs > self.config.max_timeout_secs {
130 return Err(ValidationError::TimeoutTooLong {
131 value: timeout_secs,
132 max: self.config.max_timeout_secs,
133 });
134 }
135
136 Ok(Duration::from_secs(timeout_secs))
137 }
138
139 pub fn validate_working_dir(&self, path: &str) -> Result<(), ValidationError> {
141 if path.contains("..") {
143 return Err(ValidationError::PathTraversal);
144 }
145
146 if path.contains('\0') {
148 return Err(ValidationError::InvalidCharacter('\0'));
149 }
150
151 if path.len() > 4096 {
153 return Err(ValidationError::PathTooLong {
154 length: path.len(),
155 max: 4096,
156 });
157 }
158
159 Ok(())
160 }
161
162 fn check_dangerous_patterns(&self, command: &str) -> Option<&'static str> {
164 let lower = command.to_lowercase();
165
166 if lower.contains("rm -rf /") || lower.contains("rm -fr /") {
168 return Some("rm -rf /");
169 }
170
171 if lower.contains("mkfs") || lower.contains("fdisk") || lower.contains("parted") {
173 return Some("disk formatting");
174 }
175
176 if lower.contains("dd if=/dev") && lower.contains("of=/dev") {
178 return Some("raw disk write");
179 }
180
181 if lower.contains(":(){") || lower.contains(":(){ :|:& };:") {
183 return Some("fork bomb");
184 }
185
186 if lower.contains("shutdown") || lower.contains("reboot") || lower.contains("init 0") {
188 return Some("system shutdown");
189 }
190
191 if lower.contains("> /dev/sd") || lower.contains("> /dev/nvme") {
193 return Some("device overwrite");
194 }
195
196 None
197 }
198
199 pub fn max_output_size(&self) -> usize {
201 self.config.max_output_size
202 }
203}
204
205impl Default for CommandValidator {
206 fn default() -> Self {
207 Self::new(ValidationConfig::default())
208 }
209}
210
211#[derive(Debug, Clone, PartialEq, Eq)]
213pub enum ValidationError {
214 CommandTooLong { length: usize, max: usize },
216 EmptyCommand,
218 DangerousCommand { pattern: String },
220 BlockedPattern { pattern: String },
222 InvalidCharacter(char),
224 TimeoutTooShort { value: u64, min: u64 },
226 TimeoutTooLong { value: u64, max: u64 },
228 PathTraversal,
230 PathTooLong { length: usize, max: usize },
232}
233
234impl std::fmt::Display for ValidationError {
235 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
236 match self {
237 Self::CommandTooLong { length, max } => {
238 write!(f, "Command too long: {} chars (max: {})", length, max)
239 }
240 Self::EmptyCommand => write!(f, "Command cannot be empty"),
241 Self::DangerousCommand { pattern } => {
242 write!(f, "Dangerous command pattern detected: {}", pattern)
243 }
244 Self::BlockedPattern { pattern } => {
245 write!(f, "Command contains blocked pattern: {}", pattern)
246 }
247 Self::InvalidCharacter(c) => {
248 write!(f, "Command contains invalid character: {:?}", c)
249 }
250 Self::TimeoutTooShort { value, min } => {
251 write!(f, "Timeout too short: {}s (min: {}s)", value, min)
252 }
253 Self::TimeoutTooLong { value, max } => {
254 write!(f, "Timeout too long: {}s (max: {}s)", value, max)
255 }
256 Self::PathTraversal => write!(f, "Path traversal detected"),
257 Self::PathTooLong { length, max } => {
258 write!(f, "Path too long: {} chars (max: {})", length, max)
259 }
260 }
261 }
262}
263
264impl std::error::Error for ValidationError {}
265
266pub fn sanitize_for_display(command: &str) -> String {
271 command
272 .chars()
273 .filter(|c| !c.is_control() || *c == '\n' || *c == '\t')
274 .take(1000) .collect()
276}
277
278pub fn looks_like_injection(input: &str) -> bool {
280 let suspicious_patterns = [
281 "$(", "`", "${", "&&", "||", ";", "|", ">", "<", "\\n", "\\r",
282 ];
283
284 suspicious_patterns.iter().any(|p| input.contains(p))
285}
286
287#[cfg(test)]
288mod tests {
289 use super::*;
290
291 #[test]
292 fn test_validation_config_default() {
293 let config = ValidationConfig::default();
294 assert_eq!(config.max_command_length, 4096);
295 assert!(config.block_dangerous);
296 }
297
298 #[test]
299 fn test_validation_config_strict() {
300 let config = ValidationConfig::strict();
301 assert_eq!(config.max_command_length, 1024);
302 assert!(config.block_dangerous);
303 assert!(!config.blocked_patterns.is_empty());
304 }
305
306 #[test]
307 fn test_validate_command_ok() {
308 let validator = CommandValidator::default();
309
310 assert!(validator.validate_command("ls -la").is_ok());
311 assert!(validator.validate_command("echo hello world").is_ok());
312 assert!(validator.validate_command("cat /etc/passwd").is_ok());
313 }
314
315 #[test]
316 fn test_validate_command_empty() {
317 let validator = CommandValidator::default();
318
319 assert!(matches!(
320 validator.validate_command(""),
321 Err(ValidationError::EmptyCommand)
322 ));
323 assert!(matches!(
324 validator.validate_command(" "),
325 Err(ValidationError::EmptyCommand)
326 ));
327 }
328
329 #[test]
330 fn test_validate_command_too_long() {
331 let validator = CommandValidator::new(ValidationConfig {
332 max_command_length: 10,
333 ..Default::default()
334 });
335
336 let result = validator.validate_command("this is a very long command");
337 assert!(matches!(
338 result,
339 Err(ValidationError::CommandTooLong { .. })
340 ));
341 }
342
343 #[test]
344 fn test_validate_dangerous_rm() {
345 let validator = CommandValidator::default();
346
347 assert!(matches!(
348 validator.validate_command("rm -rf /"),
349 Err(ValidationError::DangerousCommand { .. })
350 ));
351 assert!(matches!(
352 validator.validate_command("sudo rm -rf /home"),
353 Err(ValidationError::DangerousCommand { .. })
354 ));
355 }
356
357 #[test]
358 fn test_validate_dangerous_fork_bomb() {
359 let validator = CommandValidator::default();
360
361 assert!(matches!(
362 validator.validate_command(":(){ :|:& };:"),
363 Err(ValidationError::DangerousCommand { .. })
364 ));
365 }
366
367 #[test]
368 fn test_validate_dangerous_shutdown() {
369 let validator = CommandValidator::default();
370
371 assert!(matches!(
372 validator.validate_command("shutdown -h now"),
373 Err(ValidationError::DangerousCommand { .. })
374 ));
375 assert!(matches!(
376 validator.validate_command("reboot"),
377 Err(ValidationError::DangerousCommand { .. })
378 ));
379 }
380
381 #[test]
382 fn test_validate_null_byte() {
383 let validator = CommandValidator::default();
384
385 assert!(matches!(
386 validator.validate_command("ls\0 -la"),
387 Err(ValidationError::InvalidCharacter('\0'))
388 ));
389 }
390
391 #[test]
392 fn test_validate_timeout() {
393 let validator = CommandValidator::default();
394
395 assert!(validator.validate_timeout(30).is_ok());
396 assert!(validator.validate_timeout(1).is_ok());
397 assert!(validator.validate_timeout(300).is_ok());
398
399 assert!(matches!(
400 validator.validate_timeout(0),
401 Err(ValidationError::TimeoutTooShort { .. })
402 ));
403 assert!(matches!(
404 validator.validate_timeout(1000),
405 Err(ValidationError::TimeoutTooLong { .. })
406 ));
407 }
408
409 #[test]
410 fn test_validate_working_dir() {
411 let validator = CommandValidator::default();
412
413 assert!(validator.validate_working_dir("/home/user").is_ok());
414 assert!(validator.validate_working_dir("/tmp").is_ok());
415
416 assert!(matches!(
417 validator.validate_working_dir("/home/../etc"),
418 Err(ValidationError::PathTraversal)
419 ));
420 }
421
422 #[test]
423 fn test_blocked_patterns() {
424 let validator = CommandValidator::new(ValidationConfig::strict());
425
426 assert!(matches!(
429 validator.validate_command("dd if=/dev/zero"),
430 Err(ValidationError::BlockedPattern { .. })
431 ));
432 }
433
434 #[test]
435 fn test_permissive_allows_dangerous() {
436 let validator = CommandValidator::new(ValidationConfig::permissive());
437
438 assert!(validator.validate_command("rm -rf /").is_ok());
440 }
441
442 #[test]
443 fn test_sanitize_for_display() {
444 assert_eq!(sanitize_for_display("hello"), "hello");
445 assert_eq!(sanitize_for_display("hello\nworld"), "hello\nworld");
446 assert_eq!(sanitize_for_display("hello\x00world"), "helloworld");
447
448 let long = "a".repeat(2000);
450 assert_eq!(sanitize_for_display(&long).len(), 1000);
451 }
452
453 #[test]
454 fn test_looks_like_injection() {
455 assert!(looks_like_injection("echo $(whoami)"));
456 assert!(looks_like_injection("echo `id`"));
457 assert!(looks_like_injection("cmd1 && cmd2"));
458 assert!(looks_like_injection("cmd1 | cmd2"));
459
460 assert!(!looks_like_injection("echo hello"));
461 assert!(!looks_like_injection("ls -la"));
462 }
463}