Skip to main content

perl_subprocess_runtime/
os_runtime.rs

1use crate::{SubprocessError, SubprocessOutput, SubprocessRuntime};
2use std::io::Write;
3#[cfg(windows)]
4use std::path::Path;
5use std::process::{Command, Stdio};
6
7/// Default implementation using `std::process::Command`.
8pub struct OsSubprocessRuntime {
9    timeout_secs: Option<u64>,
10}
11
12impl OsSubprocessRuntime {
13    /// Create a new OS subprocess runtime with no timeout.
14    pub fn new() -> Self {
15        Self { timeout_secs: None }
16    }
17
18    /// Create a new OS subprocess runtime with the given wall-clock timeout.
19    ///
20    /// If the subprocess does not complete within `timeout_secs` seconds the
21    /// call returns a `SubprocessError` with a "timed out" message and attempts
22    /// to terminate the spawned process before returning.
23    ///
24    /// # Stdin size caveat
25    ///
26    /// Stdin data is written synchronously before the timeout poll loop begins.
27    /// If the subprocess hangs before consuming stdin and the data exceeds the
28    /// OS pipe buffer (~64 KiB on Linux), `run_command` will block in the write
29    /// phase and the timeout will not fire. For typical Perl source files this
30    /// is not a concern.
31    ///
32    /// # Panics
33    ///
34    /// Panics if `timeout_secs` is zero (a zero-second timeout would time out
35    /// every command immediately and is almost certainly a caller bug).
36    pub fn with_timeout(timeout_secs: u64) -> Self {
37        assert!(timeout_secs > 0, "timeout_secs must be greater than zero");
38        Self { timeout_secs: Some(timeout_secs) }
39    }
40}
41
42impl Default for OsSubprocessRuntime {
43    fn default() -> Self {
44        Self::new()
45    }
46}
47
48impl SubprocessRuntime for OsSubprocessRuntime {
49    fn run_command(
50        &self,
51        program: &str,
52        args: &[&str],
53        stdin: Option<&[u8]>,
54    ) -> Result<SubprocessOutput, SubprocessError> {
55        validate_command_input(program, args)?;
56        let (resolved_program, resolved_args) = resolve_command_invocation(program, args);
57        let mut cmd = Command::new(&resolved_program);
58        cmd.args(resolved_args.iter().map(String::as_str));
59        if stdin.is_some() {
60            cmd.stdin(Stdio::piped());
61        }
62        cmd.stdout(Stdio::piped());
63        cmd.stderr(Stdio::piped());
64        let mut child = cmd
65            .spawn()
66            .map_err(|e| SubprocessError::new(format!("Failed to start {}: {}", program, e)))?;
67        if let Some(input) = stdin
68            && let Some(mut child_stdin) = child.stdin.take()
69        {
70            child_stdin.write_all(input).map_err(|e| {
71                SubprocessError::new(format!("Failed to write to {} stdin: {}", program, e))
72            })?;
73        }
74        match self.timeout_secs {
75            None => {
76                let output = child.wait_with_output().map_err(|e| {
77                    SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
78                })?;
79                Ok(SubprocessOutput {
80                    stdout: output.stdout,
81                    stderr: output.stderr,
82                    status_code: output.status.code().unwrap_or(-1),
83                })
84            }
85            Some(secs) => {
86                use std::time::{Duration, Instant};
87                let deadline = Instant::now() + Duration::from_secs(secs);
88                loop {
89                    if child
90                        .try_wait()
91                        .map_err(|e| {
92                            SubprocessError::new(format!("Failed to poll {}: {}", program, e))
93                        })?
94                        .is_some()
95                    {
96                        let output = child.wait_with_output().map_err(|e| {
97                            SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
98                        })?;
99                        return Ok(SubprocessOutput {
100                            stdout: output.stdout,
101                            stderr: output.stderr,
102                            status_code: output.status.code().unwrap_or(-1),
103                        });
104                    }
105                    if Instant::now() >= deadline {
106                        if let Err(kill_err) = child.kill() {
107                            // Best effort: process may have already exited between `try_wait`
108                            // and `kill`.
109                            let already_exited = child
110                                .try_wait()
111                                .map_err(|e| {
112                                    SubprocessError::new(format!(
113                                        "Failed to poll {}: {}",
114                                        program, e
115                                    ))
116                                })?
117                                .is_some();
118                            if !already_exited {
119                                return Err(SubprocessError::new(format!(
120                                    "subprocess timed out after {} seconds and failed to terminate {}: {}",
121                                    secs, program, kill_err
122                                )));
123                            }
124                        }
125                        let _ = child.wait();
126                        return Err(SubprocessError::new(format!(
127                            "subprocess timed out after {} seconds",
128                            secs
129                        )));
130                    }
131                    std::thread::sleep(Duration::from_millis(50));
132                }
133            }
134        }
135    }
136}
137
138fn validate_command_input(program: &str, args: &[&str]) -> Result<(), SubprocessError> {
139    if program.trim().is_empty() {
140        return Err(SubprocessError::new("program name must not be empty"));
141    }
142    if program.contains('\0') {
143        return Err(SubprocessError::new("program name must not contain NUL bytes"));
144    }
145    if args.iter().any(|arg| arg.contains('\0')) {
146        return Err(SubprocessError::new("arguments must not contain NUL bytes"));
147    }
148    Ok(())
149}
150
151pub(crate) fn resolve_command_invocation(program: &str, args: &[&str]) -> (String, Vec<String>) {
152    #[cfg(windows)]
153    {
154        let resolved_program =
155            resolve_windows_program(program).unwrap_or_else(|| program.to_string());
156        if windows_requires_cmd_shell(&resolved_program) {
157            let command_line = std::iter::once(resolved_program.as_str())
158                .chain(args.iter().copied())
159                .map(windows_quote_for_cmd)
160                .collect::<Vec<_>>()
161                .join(" ");
162            // /D  - disable AutoRun registry commands.
163            // /V:OFF - disable delayed expansion so that !VAR! patterns in
164            //          arguments are not expanded even when the caller's
165            //          environment has delayed expansion enabled.
166            // /S  - strip the outer quotes from the /C argument and re-parse
167            //       the remainder, which lets each individual token retain its
168            //       own double-quoting.
169            let shell_args = vec![
170                "/D".to_string(),
171                "/V:OFF".to_string(),
172                "/S".to_string(),
173                "/C".to_string(),
174                command_line,
175            ];
176            return ("cmd.exe".to_string(), shell_args);
177        }
178        (resolved_program, args.iter().map(|arg| (*arg).to_string()).collect())
179    }
180    #[cfg(not(windows))]
181    {
182        (program.to_string(), args.iter().map(|arg| (*arg).to_string()).collect())
183    }
184}
185
186#[cfg(windows)]
187/// Quote a single argument for use inside a `cmd.exe /V:OFF /S /C "..."` command line.
188///
189/// ## cmd.exe quoting rules inside double-quoted regions
190///
191/// Once cmd.exe sees an opening `"` it enters a quoted region. Inside that region:
192///
193/// - Characters like `&`, `|`, `<`, `>`, `(`, and `)` are literal; they do not
194///   need `^` escaping.
195/// - `^` is also literal in a quoted region, so doubling it would change the
196///   argument seen by the child process.
197/// - `%` is still processed by the variable-substitution pass, which runs before
198///   the shell-metachar pass and is not suppressed by quoting. Double it (`%%`)
199///   to produce a literal `%`.
200/// - `!` would be processed by the delayed-expansion pass when `/V:ON` is in
201///   effect. We invoke cmd.exe with `/V:OFF` to suppress this entirely, so `!`
202///   needs no escaping here.
203/// - To embed a literal `"` inside a double-quoted cmd.exe token, use `""` (the
204///   cmd.exe shell convention). The `\"` form is for `CommandLineToArgvW` (the
205///   Win32 C-runtime argv parser), which is a different parser from the cmd.exe
206///   shell command-line parser.
207pub(crate) fn windows_quote_for_cmd(arg: &str) -> String {
208    let mut escaped = String::with_capacity(arg.len() + 2);
209    escaped.push('"');
210    for ch in arg.chars() {
211        match ch {
212            '%' => escaped.push_str("%%"),
213            '"' => escaped.push_str("\"\""),
214            _ => escaped.push(ch),
215        }
216    }
217    escaped.push('"');
218    escaped
219}
220
221#[cfg(windows)]
222fn resolve_windows_program(program: &str) -> Option<String> {
223    let program_path = Path::new(program);
224    let has_separator = program.contains('\\') || program.contains('/');
225    let has_extension = program_path.extension().is_some();
226    if has_separator || has_extension {
227        return Some(program.to_string());
228    }
229    let output = Command::new("where")
230        .arg(program)
231        .stdout(Stdio::piped())
232        .stderr(Stdio::null())
233        .output()
234        .ok()?;
235    if !output.status.success() {
236        return None;
237    }
238    String::from_utf8(output.stdout)
239        .ok()?
240        .lines()
241        .map(str::trim)
242        .filter(|line| !line.is_empty())
243        .max_by_key(|candidate| windows_program_priority(candidate))
244        .map(String::from)
245}
246
247#[cfg(windows)]
248pub(crate) fn windows_program_priority(candidate: &str) -> u8 {
249    match Path::new(candidate)
250        .extension()
251        .and_then(|ext| ext.to_str())
252        .map(|ext| ext.to_ascii_lowercase())
253    {
254        Some(ext) if ext == "exe" => 5,
255        Some(ext) if ext == "com" => 4,
256        Some(ext) if ext == "cmd" => 3,
257        Some(ext) if ext == "bat" => 2,
258        Some(_) => 1,
259        None => 0,
260    }
261}
262
263#[cfg(windows)]
264fn windows_requires_cmd_shell(program: &str) -> bool {
265    Path::new(program)
266        .extension()
267        .and_then(|ext| ext.to_str())
268        .map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd"))
269        .unwrap_or(false)
270}