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)
12///
13/// # Examples
14///
15/// ```no_run
16/// use cli_testing_specialist::utils::validate_binary_path;
17/// use std::path::Path;
18///
19/// // Validate a system binary
20/// let binary = validate_binary_path(Path::new("/usr/bin/ls"))?;
21/// assert!(binary.is_absolute());
22/// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
23/// ```
24pub fn validate_binary_path(path: &Path) -> Result<PathBuf> {
25    // Check existence
26    if !path.exists() {
27        return Err(CliTestError::BinaryNotFound(path.to_path_buf()));
28    }
29
30    // Check if it's a file (not a directory)
31    if !path.is_file() {
32        return Err(CliTestError::BinaryNotFound(path.to_path_buf()));
33    }
34
35    // Check executable permissions (Unix only)
36    #[cfg(unix)]
37    {
38        use std::os::unix::fs::PermissionsExt;
39        let metadata = path.metadata()?;
40        let permissions = metadata.permissions();
41
42        // Check if any execute bit is set (user, group, or other)
43        if permissions.mode() & 0o111 == 0 {
44            return Err(CliTestError::BinaryNotExecutable(path.to_path_buf()));
45        }
46    }
47
48    // Resolve to canonical path (prevents path traversal attacks)
49    let canonical = path.canonicalize()?;
50
51    Ok(canonical)
52}
53
54/// Execute binary with timeout and default resource limits
55///
56/// This function provides safe execution with the following guarantees:
57/// - Timeout enforcement (prevents infinite loops)
58/// - Output capture (stdout and stderr)
59/// - Graceful cleanup on timeout
60/// - Resource limits applied (Unix only)
61///
62/// # Examples
63///
64/// ```no_run
65/// use cli_testing_specialist::utils::execute_with_timeout;
66/// use std::path::Path;
67/// use std::time::Duration;
68///
69/// // Execute echo with 5 second timeout
70/// let output = execute_with_timeout(
71///     Path::new("/bin/echo"),
72///     &["hello", "world"],
73///     Duration::from_secs(5)
74/// )?;
75/// assert!(output.contains("hello"));
76/// # Ok::<(), cli_testing_specialist::error::CliTestError>(())
77/// ```
78pub fn execute_with_timeout(binary: &Path, args: &[&str], timeout: Duration) -> Result<String> {
79    execute_with_timeout_and_limits(
80        binary,
81        args,
82        timeout,
83        Some(&crate::utils::ResourceLimits::default()),
84    )
85}
86
87/// Execute binary with custom resource limits
88///
89/// This function allows specifying custom resource limits for the child process.
90/// If limits are None, no resource limits are applied (unsafe for untrusted binaries).
91pub fn execute_with_timeout_and_limits(
92    binary: &Path,
93    args: &[&str],
94    timeout: Duration,
95    limits: Option<&crate::utils::ResourceLimits>,
96) -> Result<String> {
97    use std::io::Read;
98
99    log::debug!(
100        "Executing: {} {} (timeout: {:?})",
101        binary.display(),
102        args.join(" "),
103        timeout
104    );
105
106    // Build command
107    let mut command = Command::new(binary);
108    command
109        .args(args)
110        .stdout(Stdio::piped())
111        .stderr(Stdio::piped());
112
113    // Apply resource limits in child process (Unix only)
114    #[cfg(unix)]
115    if let Some(resource_limits) = limits {
116        use std::os::unix::process::CommandExt;
117
118        // Clone limits for use in pre_exec closure
119        let max_memory = resource_limits.max_memory_bytes;
120        let max_fds = resource_limits.max_file_descriptors;
121        let max_procs = resource_limits.max_processes;
122
123        unsafe {
124            command.pre_exec(move || {
125                use libc::{getrlimit, rlimit, setrlimit, RLIMIT_AS, RLIMIT_NOFILE, RLIMIT_NPROC};
126
127                // Set memory limit (only if lower than current)
128                let mut current_limit = rlimit {
129                    rlim_cur: 0,
130                    rlim_max: 0,
131                };
132
133                // Memory limit
134                if getrlimit(RLIMIT_AS, &mut current_limit) == 0 {
135                    // Only set if we're lowering the limit (or if unlimited)
136                    if current_limit.rlim_max == libc::RLIM_INFINITY
137                        || current_limit.rlim_max > max_memory
138                    {
139                        let mem_limit = rlimit {
140                            rlim_cur: max_memory,
141                            rlim_max: max_memory,
142                        };
143                        // Ignore error - some systems may not allow lowering limits
144                        let _ = setrlimit(RLIMIT_AS, &mem_limit);
145                    }
146                }
147
148                // File descriptor limit
149                if getrlimit(RLIMIT_NOFILE, &mut current_limit) == 0
150                    && (current_limit.rlim_max == libc::RLIM_INFINITY
151                        || current_limit.rlim_max > max_fds)
152                {
153                    let fd_limit = rlimit {
154                        rlim_cur: max_fds,
155                        rlim_max: max_fds,
156                    };
157                    let _ = setrlimit(RLIMIT_NOFILE, &fd_limit);
158                }
159
160                // Process limit
161                if getrlimit(RLIMIT_NPROC, &mut current_limit) == 0
162                    && (current_limit.rlim_max == libc::RLIM_INFINITY
163                        || current_limit.rlim_max > max_procs)
164                {
165                    let proc_limit = rlimit {
166                        rlim_cur: max_procs,
167                        rlim_max: max_procs,
168                    };
169                    let _ = setrlimit(RLIMIT_NPROC, &proc_limit);
170                }
171
172                Ok(())
173            });
174        }
175    }
176
177    // Spawn child process
178    let mut child = command.spawn()?;
179
180    // Apply resource limits on Windows (must be done after spawn)
181    #[cfg(windows)]
182    if let Some(resource_limits) = limits {
183        apply_windows_job_limits(&child, resource_limits)?;
184    }
185
186    // Wait with timeout
187    let start = std::time::Instant::now();
188
189    loop {
190        // Check if process has finished
191        match child.try_wait()? {
192            Some(_status) => {
193                // Process finished - collect output
194                let mut stdout = String::new();
195                if let Some(mut pipe) = child.stdout.take() {
196                    pipe.read_to_string(&mut stdout)?;
197                }
198
199                // Also capture stderr (some CLIs output help to stderr)
200                let mut stderr = String::new();
201                if let Some(mut pipe) = child.stderr.take() {
202                    pipe.read_to_string(&mut stderr)?;
203                }
204
205                // Prefer stdout, fallback to stderr
206                let output = if !stdout.is_empty() { stdout } else { stderr };
207
208                log::debug!("Execution completed in {:?}", start.elapsed());
209                return Ok(output);
210            }
211            None => {
212                // Process still running - check timeout
213                if start.elapsed() >= timeout {
214                    // Timeout exceeded - kill process
215                    log::warn!("Execution timeout exceeded, killing process");
216                    child.kill()?;
217                    child.wait()?;
218
219                    return Err(CliTestError::ExecutionFailed(format!(
220                        "Timeout after {:?}",
221                        timeout
222                    )));
223                }
224
225                // Sleep briefly before checking again
226                std::thread::sleep(Duration::from_millis(50));
227            }
228        }
229    }
230}
231
232/// Apply resource limits to a Windows child process using Job Objects
233#[cfg(windows)]
234fn apply_windows_job_limits(
235    child: &std::process::Child,
236    limits: &crate::utils::ResourceLimits,
237) -> Result<()> {
238    use std::os::windows::process::CommandExt;
239    use windows::Win32::Foundation::{CloseHandle, HANDLE};
240    use windows::Win32::System::JobObjects::{
241        AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
242        SetInformationJobObject, JOBOBJECT_BASIC_LIMIT_INFORMATION,
243        JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_LIMIT_ACTIVE_PROCESS,
244        JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_PROCESS_MEMORY,
245    };
246
247    unsafe {
248        // Create a job object
249        let job = CreateJobObjectW(None, None).map_err(|e| {
250            CliTestError::ExecutionFailed(format!("Failed to create job object: {}", e))
251        })?;
252
253        // Set job limits
254        let mut job_limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION {
255            BasicLimitInformation: JOBOBJECT_BASIC_LIMIT_INFORMATION {
256                LimitFlags: JOB_OBJECT_LIMIT_ACTIVE_PROCESS
257                    | JOB_OBJECT_LIMIT_PROCESS_MEMORY
258                    | JOB_OBJECT_LIMIT_JOB_MEMORY,
259                ActiveProcessLimit: limits.max_processes as u32,
260                ..Default::default()
261            },
262            ProcessMemoryLimit: limits.max_memory_bytes as usize,
263            JobMemoryLimit: limits.max_memory_bytes as usize,
264            ..Default::default()
265        };
266
267        // Apply limits to job object
268        SetInformationJobObject(
269            job,
270            JobObjectExtendedLimitInformation,
271            &mut job_limits as *mut _ as *mut _,
272            std::mem::size_of::<JOBOBJECT_EXTENDED_LIMIT_INFORMATION>() as u32,
273        )
274        .map_err(|e| {
275            CloseHandle(job);
276            CliTestError::ExecutionFailed(format!("Failed to set job limits: {}", e))
277        })?;
278
279        // Get child process handle and assign to job
280        let child_handle = HANDLE(child.id() as isize);
281        AssignProcessToJobObject(job, child_handle).map_err(|e| {
282            CloseHandle(job);
283            CliTestError::ExecutionFailed(format!("Failed to assign process to job: {}", e))
284        })?;
285
286        // Note: We intentionally don't close the job handle here
287        // The job will terminate when the child process exits
288        log::debug!("Resource limits applied to child process via Job Object");
289    }
290
291    Ok(())
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use std::fs::File;
298    use tempfile::TempDir;
299
300    #[test]
301    fn test_validate_nonexistent_binary() {
302        let path = Path::new("/nonexistent/binary");
303        let result = validate_binary_path(path);
304
305        assert!(result.is_err());
306        assert!(matches!(
307            result.unwrap_err(),
308            CliTestError::BinaryNotFound(_)
309        ));
310    }
311
312    #[test]
313    fn test_validate_directory() {
314        let temp_dir = TempDir::new().unwrap();
315        let result = validate_binary_path(temp_dir.path());
316
317        assert!(result.is_err());
318        assert!(matches!(
319            result.unwrap_err(),
320            CliTestError::BinaryNotFound(_)
321        ));
322    }
323
324    #[cfg(unix)]
325    #[test]
326    fn test_validate_non_executable_file() {
327        use std::os::unix::fs::PermissionsExt;
328
329        let temp_dir = TempDir::new().unwrap();
330        let file_path = temp_dir.path().join("non_executable");
331
332        // Create file without execute permissions
333        File::create(&file_path).unwrap();
334        let mut perms = std::fs::metadata(&file_path).unwrap().permissions();
335        perms.set_mode(0o644); // rw-r--r--
336        std::fs::set_permissions(&file_path, perms).unwrap();
337
338        let result = validate_binary_path(&file_path);
339
340        assert!(result.is_err());
341        assert!(matches!(
342            result.unwrap_err(),
343            CliTestError::BinaryNotExecutable(_)
344        ));
345    }
346
347    #[test]
348    fn test_execute_with_timeout_echo() {
349        // Test with echo command (should be available on all Unix systems)
350        #[cfg(unix)]
351        {
352            let echo_path = Path::new("/bin/echo");
353            if echo_path.exists() {
354                let result =
355                    execute_with_timeout(echo_path, &["hello", "world"], Duration::from_secs(5));
356
357                assert!(result.is_ok());
358                let output = result.unwrap();
359                assert!(output.contains("hello"));
360            }
361        }
362    }
363
364    #[test]
365    fn test_execute_with_timeout_sleep() {
366        // Test timeout enforcement
367        #[cfg(unix)]
368        {
369            let sleep_path = Path::new("/bin/sleep");
370            if sleep_path.exists() {
371                let result = execute_with_timeout(
372                    sleep_path,
373                    &["10"],                    // Sleep for 10 seconds
374                    Duration::from_millis(500), // But timeout after 500ms
375                );
376
377                assert!(result.is_err());
378                if let Err(CliTestError::ExecutionFailed(msg)) = result {
379                    assert!(msg.contains("Timeout"));
380                }
381            }
382        }
383    }
384
385    #[test]
386    fn test_canonicalization() {
387        // Test that canonicalization works with valid binary
388        #[cfg(unix)]
389        {
390            let ls_path = Path::new("/bin/ls");
391            if ls_path.exists() {
392                let result = validate_binary_path(ls_path);
393                assert!(result.is_ok());
394
395                let canonical = result.unwrap();
396                assert!(canonical.is_absolute());
397            }
398        }
399    }
400}