perl_subprocess_runtime/
os_runtime.rs1use crate::{SubprocessError, SubprocessOutput, SubprocessRuntime};
2use std::io::Write;
3#[cfg(windows)]
4use std::path::Path;
5use std::process::{Command, Stdio};
6
7pub struct OsSubprocessRuntime {
9 timeout_secs: Option<u64>,
10}
11
12impl OsSubprocessRuntime {
13 pub fn new() -> Self {
15 Self { timeout_secs: None }
16 }
17
18 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 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 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)]
187pub(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}