tcrm_task/tasks/validator.rs
1use std::collections::HashMap;
2use std::path::Path;
3
4use crate::tasks::error::TaskError;
5const MAX_COMMAND_LEN: usize = 4096;
6const MAX_ARG_LEN: usize = 4096;
7const MAX_WORKING_DIR_LEN: usize = 4096;
8const MAX_ENV_KEY_LEN: usize = 1024;
9const MAX_ENV_VALUE_LEN: usize = 4096;
10/// Security validation utilities for task configuration
11pub struct ConfigValidator;
12
13impl ConfigValidator {
14 /// Validates command name.
15 ///
16 /// # Arguments
17 ///
18 /// * `command` - The command string to validate.
19 ///
20 /// # Returns
21 ///
22 /// - `Ok(())` if the command is valid.
23 /// - `Err(TaskError::InvalidConfiguration)` if the command is invalid.
24 ///
25 /// # Errors
26 ///
27 /// Returns a [`TaskError::InvalidConfiguration`] if:
28 /// - Command is empty or contains only whitespace
29 /// - Command contains null bytes or obvious injection patterns
30 /// - Command has leading or trailing whitespace
31 /// - Command length exceeds maximum allowed length
32 ///
33 /// # Examples
34 /// ```rust
35 /// use tcrm_task::tasks::validator::ConfigValidator;
36 ///
37 /// let valid_command = "echo Hello";
38 /// assert!(ConfigValidator::validate_command(valid_command).is_ok());
39 ///
40 /// let invalid_command = "\0";
41 /// assert!(ConfigValidator::validate_command(invalid_command).is_err());
42 /// ```
43 pub fn validate_command(command: &str) -> Result<(), TaskError> {
44 // Check for empty or whitespace-only commands
45 if command.trim().is_empty() {
46 return Err(TaskError::InvalidConfiguration(
47 "Command cannot be empty".to_string(),
48 ));
49 }
50
51 // Only check for obvious injection attempts, not shell features
52 if Self::contains_obvious_injection(command) {
53 return Err(TaskError::InvalidConfiguration(
54 "Command contains potentially dangerous injection patterns".to_string(),
55 ));
56 }
57
58 if command.trim() != command {
59 return Err(TaskError::InvalidConfiguration(
60 "Command cannot have leading or trailing whitespace".to_string(),
61 ));
62 }
63 if command.len() > MAX_COMMAND_LEN {
64 return Err(TaskError::InvalidConfiguration(
65 "Command length exceeds maximum allowed length".to_string(),
66 ));
67 }
68
69 Ok(())
70 }
71
72 /// Validates arguments.
73 ///
74 /// # Arguments
75 ///
76 /// * `args` - A slice of argument strings to validate.
77 ///
78 /// # Returns
79 ///
80 /// - `Ok(())` if all arguments are valid.
81 /// - `Err(TaskError::InvalidConfiguration)` if any argument is invalid.
82 ///
83 /// # Errors
84 ///
85 /// Returns a [`TaskError::InvalidConfiguration`] if:
86 /// - any argument contains null bytes
87 /// - any argument is an empty string
88 /// - any argument has leading or trailing whitespace
89 /// - any argument exceeds maximum length
90 ///
91 /// # Examples
92 /// ```rust
93 /// use tcrm_task::tasks::validator::ConfigValidator;
94 ///
95 /// let valid_args = vec!["arg1".to_string(), "arg2".to_string()];
96 /// assert!(ConfigValidator::validate_args(&valid_args).is_ok());
97 ///
98 /// let invalid_args = vec!["arg1".to_string(), "\0".to_string()];
99 /// assert!(ConfigValidator::validate_args(&invalid_args).is_err());
100 /// ```
101 pub fn validate_args(args: &[String]) -> Result<(), TaskError> {
102 for arg in args {
103 // Only check for null bytes
104 if arg.contains('\0') {
105 return Err(TaskError::InvalidConfiguration(
106 "Argument contains null characters".to_string(),
107 ));
108 }
109 if arg.is_empty() {
110 return Err(TaskError::InvalidConfiguration(
111 "Arguments cannot be empty".to_string(),
112 ));
113 }
114 if arg.trim() != arg {
115 return Err(TaskError::InvalidConfiguration(format!(
116 "Argument '{arg}' cannot have leading/trailing whitespace"
117 )));
118 }
119 if arg.len() > MAX_ARG_LEN {
120 return Err(TaskError::InvalidConfiguration(format!(
121 "Argument '{arg}' exceeds maximum length"
122 )));
123 }
124 }
125 Ok(())
126 }
127
128 /// Validates the working directory path.
129 ///
130 /// # Arguments
131 ///
132 /// * `dir` - The directory path to validate.
133 ///
134 /// # Returns
135 ///
136 /// - `Ok(())` if the directory is valid.
137 /// - `Err(TaskError::InvalidConfiguration)` if the directory is invalid.
138 ///
139 /// # Errors
140 ///
141 /// Returns a [`TaskError::InvalidConfiguration`] if:
142 /// - Directory does not exist
143 /// - Path exists but is not a directory
144 /// - Directory has leading or trailing whitespace
145 /// - Directory path exceeds maximum length
146 ///
147 /// # Examples
148 /// ```rust
149 /// use tcrm_task::tasks::validator::ConfigValidator;
150 /// use std::env;
151 ///
152 /// // Test with current directory (should exist)
153 /// let current_dir = env::current_dir().unwrap();
154 /// let valid_dir = current_dir.to_str().unwrap();
155 /// assert!(ConfigValidator::validate_working_dir(valid_dir).is_ok());
156 ///
157 /// // Test with nonexistent directory
158 /// let invalid_dir = "nonexistent_dir_12345";
159 /// assert!(ConfigValidator::validate_working_dir(invalid_dir).is_err());
160 /// ```
161 pub fn validate_working_dir(dir: &str) -> Result<(), TaskError> {
162 let path = Path::new(dir);
163
164 // Check if path exists
165 if !path.exists() {
166 return Err(TaskError::InvalidConfiguration(format!(
167 "Working directory does not exist: {dir}"
168 )));
169 }
170
171 // Check if it's actually a directory
172 if !path.is_dir() {
173 return Err(TaskError::InvalidConfiguration(format!(
174 "Working directory is not a directory: {dir}"
175 )));
176 }
177
178 if dir.trim() != dir {
179 return Err(TaskError::InvalidConfiguration(
180 "Working directory cannot have leading/trailing whitespace".to_string(),
181 ));
182 }
183 if dir.len() > MAX_WORKING_DIR_LEN {
184 return Err(TaskError::InvalidConfiguration(
185 "Working directory path exceeds maximum length".to_string(),
186 ));
187 }
188
189 Ok(())
190 }
191
192 /// Validates environment variables.
193 ///
194 /// # Arguments
195 ///
196 /// * `env` - A hashmap of environment variable key-value pairs to validate.
197 ///
198 /// # Returns
199 ///
200 /// - `Ok(())` if all environment variables are valid.
201 /// - `Err(TaskError::InvalidConfiguration)` if any environment variable is invalid.
202 ///
203 /// # Errors
204 ///
205 /// Returns a [`TaskError::InvalidConfiguration`] if:
206 /// - Any environment variable key contains spaces, '=', or null bytes
207 /// - Any environment variable value contains null bytes
208 /// - Any environment variable key/value exceeds maximum length
209 ///
210 /// # Examples
211 /// ```rust
212 /// use tcrm_task::tasks::validator::ConfigValidator;
213 /// use std::collections::HashMap;
214 ///
215 /// let mut valid_env = HashMap::new();
216 /// valid_env.insert("KEY".to_string(), "VALUE".to_string());
217 /// assert!(ConfigValidator::validate_env_vars(&valid_env).is_ok());
218 ///
219 /// let mut invalid_env = HashMap::new();
220 /// invalid_env.insert("KEY\0".to_string(), "VALUE".to_string());
221 /// assert!(ConfigValidator::validate_env_vars(&invalid_env).is_err());
222 /// ```
223 pub fn validate_env_vars(env: &HashMap<String, String>) -> Result<(), TaskError> {
224 for (key, value) in env {
225 // Validate key
226 if key.trim().is_empty() {
227 return Err(TaskError::InvalidConfiguration(
228 "Environment variable key cannot be empty".to_string(),
229 ));
230 }
231 if key.contains('=') || key.contains('\0') {
232 return Err(TaskError::InvalidConfiguration(
233 "Environment variable key contains invalid characters".to_string(),
234 ));
235 }
236
237 if key.contains(' ') {
238 return Err(TaskError::InvalidConfiguration(format!(
239 "Environment variable key '{key}' cannot contain spaces"
240 )));
241 }
242
243 if key.len() > MAX_ENV_KEY_LEN {
244 return Err(TaskError::InvalidConfiguration(format!(
245 "Environment variable key '{key}' exceeds maximum length"
246 )));
247 }
248
249 // Validate value
250 if value.contains('\0') {
251 return Err(TaskError::InvalidConfiguration(
252 "Environment variable value contains null characters".to_string(),
253 ));
254 }
255
256 if value.trim() != value {
257 return Err(TaskError::InvalidConfiguration(format!(
258 "Environment variable '{key}' value cannot have leading/trailing whitespace"
259 )));
260 }
261 if value.len() > MAX_ENV_VALUE_LEN {
262 return Err(TaskError::InvalidConfiguration(format!(
263 "Environment variable '{key}' value exceeds maximum length"
264 )));
265 }
266 }
267 Ok(())
268 }
269
270 /// Checks for obvious injection attempts while allowing normal shell features.
271 ///
272 /// This internal method identifies clearly malicious patterns without blocking
273 /// legitimate shell functionality. It focuses on patterns that are rarely used
274 /// in normal command execution.
275 ///
276 /// # Arguments
277 ///
278 /// * `input` - The command string to check for injection patterns.
279 ///
280 /// # Returns
281 ///
282 /// `true` if obvious injection patterns are detected, `false` otherwise.
283 fn contains_obvious_injection(input: &str) -> bool {
284 // Only block patterns that are clearly malicious, not functional shell features
285 let obvious_injection_patterns = [
286 "\0", // Null bytes
287 "\x00", // Null bytes (hex)
288 "\r\n", // CRLF injection
289 "eval(", // Direct eval calls
290 "exec(", // Direct exec calls
291 ];
292
293 obvious_injection_patterns
294 .iter()
295 .any(|pattern| input.contains(pattern))
296 }
297
298 /// Validates command with strict security rules for untrusted input sources.
299 ///
300 /// This is an alternative to `validate_command` that blocks all shell features
301 ///
302 /// # Arguments
303 ///
304 /// * `command` - The command string to validate strictly.
305 ///
306 /// # Returns
307 ///
308 /// - `Ok(())` if the command passes strict validation.
309 /// - `Err(TaskError::InvalidConfiguration)` if the command contains potentially dangerous patterns.
310 ///
311 /// # Errors
312 ///
313 /// Returns a [`TaskError::InvalidConfiguration`] if:
314 /// - Command is empty or contains only whitespace
315 /// - Command contains shell metacharacters or redirection operators
316 ///
317 /// # Examples
318 /// ```rust
319 /// use tcrm_task::tasks::validator::ConfigValidator;
320 ///
321 /// // Simple command should pass
322 /// let simple_command = "echo";
323 /// assert!(ConfigValidator::validate_command_strict(simple_command).is_ok());
324 ///
325 /// // Command with shell features should fail
326 /// let shell_command = "echo hello; rm -rf /";
327 /// assert!(ConfigValidator::validate_command_strict(shell_command).is_err());
328 /// ```
329 #[allow(dead_code)]
330 pub fn validate_command_strict(command: &str) -> Result<(), TaskError> {
331 // Check for empty or whitespace-only commands
332 if command.trim().is_empty() {
333 return Err(TaskError::InvalidConfiguration(
334 "Command cannot be empty".to_string(),
335 ));
336 }
337
338 // Strict validation - blocks shell features
339 let dangerous_patterns = [
340 ";", "&", "|", "`", "$", "(", ")", "{", "}", "[", "]", "<", ">", "&&", "||", ">>",
341 "<<", "\n", "\r",
342 ];
343
344 if dangerous_patterns
345 .iter()
346 .any(|pattern| command.contains(pattern))
347 {
348 return Err(TaskError::InvalidConfiguration(
349 "Command contains shell metacharacters (use validate_command for developer tools)"
350 .to_string(),
351 ));
352 }
353
354 Ok(())
355 }
356}
357
358#[cfg(test)]
359mod tests {
360 use super::*;
361
362 #[test]
363 fn validate_command_rejects_empty() {
364 assert!(ConfigValidator::validate_command("").is_err());
365 assert!(ConfigValidator::validate_command(" ").is_err());
366 }
367
368 #[test]
369 fn validate_command_accepts_shell_features() {
370 // These should be allowed for developer tools
371 let shell_commands = [
372 "ls | grep pattern",
373 "echo hello > output.txt",
374 "make && npm test",
375 "command1; command2",
376 "echo $PATH",
377 "ls $(pwd)",
378 "cat file.txt | head -n 10",
379 ];
380
381 for cmd in &shell_commands {
382 assert!(
383 ConfigValidator::validate_command(cmd).is_ok(),
384 "Should accept shell feature: {}",
385 cmd
386 );
387 }
388 }
389
390 #[test]
391 fn validate_command_rejects_obvious_injection() {
392 let dangerous_commands = [
393 "command\0with\0nulls",
394 "eval(malicious_code)",
395 "exec(rm -rf /)",
396 "command\r\necho injected",
397 ];
398
399 for cmd in &dangerous_commands {
400 assert!(
401 ConfigValidator::validate_command(cmd).is_err(),
402 "Should reject obvious injection: {}",
403 cmd
404 );
405 }
406 }
407
408 #[test]
409 fn validate_command_strict_blocks_shell_features() {
410 let shell_commands = [
411 "ls | grep pattern",
412 "echo hello > output.txt",
413 "make && npm test",
414 ];
415
416 for cmd in &shell_commands {
417 assert!(
418 ConfigValidator::validate_command_strict(cmd).is_err(),
419 "Strict validation should reject: {}",
420 cmd
421 );
422 }
423 }
424
425 #[test]
426 fn validate_command_accepts_safe_commands() {
427 let safe_commands = [
428 "echo",
429 "ls",
430 "cat",
431 "grep",
432 "node",
433 "python",
434 "ls -la",
435 "grep pattern file.txt",
436 "node script.js",
437 ];
438
439 for cmd in &safe_commands {
440 assert!(
441 ConfigValidator::validate_command(cmd).is_ok(),
442 "Should accept: {}",
443 cmd
444 );
445 }
446 }
447
448 #[test]
449 fn validate_args_accepts_normal_args() {
450 let normal_args = vec![
451 "arg1".to_string(),
452 "--flag".to_string(),
453 "file.txt".to_string(),
454 "path/to/file".to_string(),
455 ];
456
457 assert!(ConfigValidator::validate_args(&normal_args).is_ok());
458 }
459
460 #[test]
461 fn validate_args_rejects_null_bytes() {
462 let dangerous_args = vec!["arg\0with\0nulls".to_string()];
463
464 assert!(ConfigValidator::validate_args(&dangerous_args).is_err());
465 }
466
467 #[test]
468 fn validate_working_dir_accepts_relative_paths() {
469 // Should accept relative paths including .. for developer use
470 let current_dir = std::env::current_dir().unwrap();
471 assert!(ConfigValidator::validate_working_dir(current_dir.to_str().unwrap()).is_ok());
472 }
473
474 #[test]
475 fn validate_working_dir_rejects_nonexistent() {
476 assert!(ConfigValidator::validate_working_dir("/nonexistent/path").is_err());
477 }
478
479 #[test]
480 fn validate_env_vars_rejects_spaces_in_keys() {
481 // Environment variable keys should not contain spaces
482 let mut env = HashMap::new();
483 env.insert("KEY WITH SPACE".to_string(), "value".to_string());
484 assert!(ConfigValidator::validate_env_vars(&env).is_err());
485 }
486
487 #[test]
488 fn validate_env_vars_accepts_normal_vars() {
489 // Normal environment variables should be accepted
490 let mut env = HashMap::new();
491 env.insert("PATH".to_string(), "/usr/bin:/bin".to_string());
492 env.insert(
493 "CUSTOM_VAR".to_string(),
494 "some value with spaces".to_string(),
495 );
496 assert!(ConfigValidator::validate_env_vars(&env).is_ok());
497 }
498
499 #[test]
500 fn validate_env_vars_rejects_invalid_keys() {
501 let mut env = HashMap::new();
502 env.insert("KEY=BAD".to_string(), "value".to_string());
503 assert!(ConfigValidator::validate_env_vars(&env).is_err());
504 }
505
506 #[test]
507 fn validate_env_vars_rejects_null_chars() {
508 let mut env = HashMap::new();
509 env.insert("KEY".to_string(), "value\0with\0nulls".to_string());
510 assert!(ConfigValidator::validate_env_vars(&env).is_err());
511 }
512}