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}