cli_testing_specialist/utils/
validator.rs

1use crate::error::{CliTestError, Result};
2use std::path::{Path, PathBuf};
3use std::process::{Command, Stdio};
4use std::time::Duration;
5
6/// Validate binary path with comprehensive security checks
7///
8/// Performs the following validations:
9/// 1. File existence check
10/// 2. Executable permissions check (Unix)
11/// 3. Canonicalization (prevents path traversal)
12pub fn validate_binary_path(path: &Path) -> Result<PathBuf> {
13    // Check existence
14    if !path.exists() {
15        return Err(CliTestError::BinaryNotFound(path.to_path_buf()));
16    }
17
18    // Check if it's a file (not a directory)
19    if !path.is_file() {
20        return Err(CliTestError::BinaryNotFound(path.to_path_buf()));
21    }
22
23    // Check executable permissions (Unix only)
24    #[cfg(unix)]
25    {
26        use std::os::unix::fs::PermissionsExt;
27        let metadata = path.metadata()?;
28        let permissions = metadata.permissions();
29
30        // Check if any execute bit is set (user, group, or other)
31        if permissions.mode() & 0o111 == 0 {
32            return Err(CliTestError::BinaryNotExecutable(path.to_path_buf()));
33        }
34    }
35
36    // Resolve to canonical path (prevents path traversal attacks)
37    let canonical = path.canonicalize()?;
38
39    Ok(canonical)
40}
41
42/// Execute binary with timeout and resource limits
43///
44/// This function provides safe execution with the following guarantees:
45/// - Timeout enforcement (prevents infinite loops)
46/// - Output capture (stdout and stderr)
47/// - Graceful cleanup on timeout
48pub fn execute_with_timeout(binary: &Path, args: &[&str], timeout: Duration) -> Result<String> {
49    use std::io::Read;
50
51    log::debug!(
52        "Executing: {} {} (timeout: {:?})",
53        binary.display(),
54        args.join(" "),
55        timeout
56    );
57
58    // Spawn child process
59    let mut child = Command::new(binary)
60        .args(args)
61        .stdout(Stdio::piped())
62        .stderr(Stdio::piped())
63        .spawn()?;
64
65    // Wait with timeout
66    let start = std::time::Instant::now();
67
68    loop {
69        // Check if process has finished
70        match child.try_wait()? {
71            Some(_status) => {
72                // Process finished - collect output
73                let mut stdout = String::new();
74                if let Some(mut pipe) = child.stdout.take() {
75                    pipe.read_to_string(&mut stdout)?;
76                }
77
78                // Also capture stderr (some CLIs output help to stderr)
79                let mut stderr = String::new();
80                if let Some(mut pipe) = child.stderr.take() {
81                    pipe.read_to_string(&mut stderr)?;
82                }
83
84                // Prefer stdout, fallback to stderr
85                let output = if !stdout.is_empty() { stdout } else { stderr };
86
87                log::debug!("Execution completed in {:?}", start.elapsed());
88                return Ok(output);
89            }
90            None => {
91                // Process still running - check timeout
92                if start.elapsed() >= timeout {
93                    // Timeout exceeded - kill process
94                    log::warn!("Execution timeout exceeded, killing process");
95                    child.kill()?;
96                    child.wait()?;
97
98                    return Err(CliTestError::ExecutionFailed(format!(
99                        "Timeout after {:?}",
100                        timeout
101                    )));
102                }
103
104                // Sleep briefly before checking again
105                std::thread::sleep(Duration::from_millis(50));
106            }
107        }
108    }
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use std::fs::File;
115    use tempfile::TempDir;
116
117    #[test]
118    fn test_validate_nonexistent_binary() {
119        let path = Path::new("/nonexistent/binary");
120        let result = validate_binary_path(path);
121
122        assert!(result.is_err());
123        assert!(matches!(
124            result.unwrap_err(),
125            CliTestError::BinaryNotFound(_)
126        ));
127    }
128
129    #[test]
130    fn test_validate_directory() {
131        let temp_dir = TempDir::new().unwrap();
132        let result = validate_binary_path(temp_dir.path());
133
134        assert!(result.is_err());
135        assert!(matches!(
136            result.unwrap_err(),
137            CliTestError::BinaryNotFound(_)
138        ));
139    }
140
141    #[cfg(unix)]
142    #[test]
143    fn test_validate_non_executable_file() {
144        use std::os::unix::fs::PermissionsExt;
145
146        let temp_dir = TempDir::new().unwrap();
147        let file_path = temp_dir.path().join("non_executable");
148
149        // Create file without execute permissions
150        File::create(&file_path).unwrap();
151        let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
152        perms.set_mode(0o644); // rw-r--r--
153        std::fs::set_permissions(&file_path, perms).unwrap();
154
155        let result = validate_binary_path(&file_path);
156
157        assert!(result.is_err());
158        assert!(matches!(
159            result.unwrap_err(),
160            CliTestError::BinaryNotExecutable(_)
161        ));
162    }
163
164    #[test]
165    fn test_execute_with_timeout_echo() {
166        // Test with echo command (should be available on all Unix systems)
167        #[cfg(unix)]
168        {
169            let echo_path = Path::new("/bin/echo");
170            if echo_path.exists() {
171                let result =
172                    execute_with_timeout(echo_path, &["hello", "world"], Duration::from_secs(5));
173
174                assert!(result.is_ok());
175                let output = result.unwrap();
176                assert!(output.contains("hello"));
177            }
178        }
179    }
180
181    #[test]
182    fn test_execute_with_timeout_sleep() {
183        // Test timeout enforcement
184        #[cfg(unix)]
185        {
186            let sleep_path = Path::new("/bin/sleep");
187            if sleep_path.exists() {
188                let result = execute_with_timeout(
189                    sleep_path,
190                    &["10"],                    // Sleep for 10 seconds
191                    Duration::from_millis(500), // But timeout after 500ms
192                );
193
194                assert!(result.is_err());
195                if let Err(CliTestError::ExecutionFailed(msg)) = result {
196                    assert!(msg.contains("Timeout"));
197                }
198            }
199        }
200    }
201
202    #[test]
203    fn test_canonicalization() {
204        // Test that canonicalization works with valid binary
205        #[cfg(unix)]
206        {
207            let ls_path = Path::new("/bin/ls");
208            if ls_path.exists() {
209                let result = validate_binary_path(ls_path);
210                assert!(result.is_ok());
211
212                let canonical = result.unwrap();
213                assert!(canonical.is_absolute());
214            }
215        }
216    }
217}