#![deny(unsafe_code)]
#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
#![warn(rust_2018_idioms)]
#![warn(missing_docs)]
#![warn(clippy::all)]
use std::fmt;
#[cfg(all(not(target_arch = "wasm32"), windows))]
use std::path::Path;
#[derive(Debug, Clone)]
pub struct SubprocessOutput {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub status_code: i32,
}
impl SubprocessOutput {
pub fn success(&self) -> bool {
self.status_code == 0
}
pub fn stdout_lossy(&self) -> String {
String::from_utf8_lossy(&self.stdout).into_owned()
}
pub fn stderr_lossy(&self) -> String {
String::from_utf8_lossy(&self.stderr).into_owned()
}
}
#[derive(Debug, Clone)]
pub struct SubprocessError {
pub message: String,
}
impl SubprocessError {
pub fn new(message: impl Into<String>) -> Self {
Self { message: message.into() }
}
}
impl fmt::Display for SubprocessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for SubprocessError {}
pub trait SubprocessRuntime: Send + Sync {
fn run_command(
&self,
program: &str,
args: &[&str],
stdin: Option<&[u8]>,
) -> Result<SubprocessOutput, SubprocessError>;
}
#[cfg(not(target_arch = "wasm32"))]
pub struct OsSubprocessRuntime {
timeout_secs: Option<u64>,
}
#[cfg(not(target_arch = "wasm32"))]
impl OsSubprocessRuntime {
pub fn new() -> Self {
Self { timeout_secs: None }
}
pub fn with_timeout(timeout_secs: u64) -> Self {
assert!(timeout_secs > 0, "timeout_secs must be greater than zero");
Self { timeout_secs: Some(timeout_secs) }
}
}
#[cfg(not(target_arch = "wasm32"))]
impl Default for OsSubprocessRuntime {
fn default() -> Self {
Self::new()
}
}
#[cfg(not(target_arch = "wasm32"))]
impl SubprocessRuntime for OsSubprocessRuntime {
fn run_command(
&self,
program: &str,
args: &[&str],
stdin: Option<&[u8]>,
) -> Result<SubprocessOutput, SubprocessError> {
use std::io::Write;
use std::process::{Command, Stdio};
validate_command_input(program, args)?;
let (resolved_program, resolved_args) = resolve_command_invocation(program, args);
let mut cmd = Command::new(&resolved_program);
cmd.args(resolved_args.iter().map(String::as_str));
if stdin.is_some() {
cmd.stdin(Stdio::piped());
}
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
let mut child = cmd
.spawn()
.map_err(|e| SubprocessError::new(format!("Failed to start {}: {}", program, e)))?;
if let Some(input) = stdin
&& let Some(mut child_stdin) = child.stdin.take()
{
child_stdin.write_all(input).map_err(|e| {
SubprocessError::new(format!("Failed to write to {} stdin: {}", program, e))
})?;
}
match self.timeout_secs {
None => {
let output = child.wait_with_output().map_err(|e| {
SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
})?;
Ok(SubprocessOutput {
stdout: output.stdout,
stderr: output.stderr,
status_code: output.status.code().unwrap_or(-1),
})
}
Some(secs) => {
use std::time::{Duration, Instant};
let deadline = Instant::now() + Duration::from_secs(secs);
loop {
if child
.try_wait()
.map_err(|e| {
SubprocessError::new(format!("Failed to poll {}: {}", program, e))
})?
.is_some()
{
let output = child.wait_with_output().map_err(|e| {
SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
})?;
return Ok(SubprocessOutput {
stdout: output.stdout,
stderr: output.stderr,
status_code: output.status.code().unwrap_or(-1),
});
}
if Instant::now() >= deadline {
if let Err(kill_err) = child.kill() {
let already_exited = child
.try_wait()
.map_err(|e| {
SubprocessError::new(format!(
"Failed to poll {}: {}",
program, e
))
})?
.is_some();
if !already_exited {
return Err(SubprocessError::new(format!(
"subprocess timed out after {} seconds and failed to terminate {}: {}",
secs, program, kill_err
)));
}
}
let _ = child.wait();
return Err(SubprocessError::new(format!(
"subprocess timed out after {} seconds",
secs
)));
}
std::thread::sleep(Duration::from_millis(50));
}
}
}
}
}
#[cfg(not(target_arch = "wasm32"))]
fn validate_command_input(program: &str, args: &[&str]) -> Result<(), SubprocessError> {
if program.trim().is_empty() {
return Err(SubprocessError::new("program name must not be empty"));
}
if program.contains('\0') {
return Err(SubprocessError::new("program name must not contain NUL bytes"));
}
if args.iter().any(|arg| arg.contains('\0')) {
return Err(SubprocessError::new("arguments must not contain NUL bytes"));
}
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn resolve_command_invocation(program: &str, args: &[&str]) -> (String, Vec<String>) {
#[cfg(windows)]
{
let resolved_program =
resolve_windows_program(program).unwrap_or_else(|| program.to_string());
if windows_requires_cmd_shell(&resolved_program) {
let command_line = std::iter::once(resolved_program.as_str())
.chain(args.iter().copied())
.map(windows_quote_for_cmd)
.collect::<Vec<_>>()
.join(" ");
let shell_args = vec![
"/D".to_string(),
"/V:OFF".to_string(),
"/S".to_string(),
"/C".to_string(),
command_line,
];
return ("cmd.exe".to_string(), shell_args);
}
(resolved_program, args.iter().map(|arg| (*arg).to_string()).collect())
}
#[cfg(not(windows))]
{
(program.to_string(), args.iter().map(|arg| (*arg).to_string()).collect())
}
}
#[cfg(all(not(target_arch = "wasm32"), windows))]
fn windows_quote_for_cmd(arg: &str) -> String {
let mut escaped = String::with_capacity(arg.len() + 2);
escaped.push('"');
for ch in arg.chars() {
match ch {
'%' => escaped.push_str("%%"),
'"' => escaped.push_str("\"\""),
_ => escaped.push(ch),
}
}
escaped.push('"');
escaped
}
#[cfg(all(not(target_arch = "wasm32"), windows))]
fn resolve_windows_program(program: &str) -> Option<String> {
let program_path = Path::new(program);
let has_separator = program.contains('\\') || program.contains('/');
let has_extension = program_path.extension().is_some();
if has_separator || has_extension {
return Some(program.to_string());
}
let output = std::process::Command::new("where")
.arg(program)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::null())
.output()
.ok()?;
if !output.status.success() {
return None;
}
String::from_utf8(output.stdout)
.ok()?
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.max_by_key(|candidate| windows_program_priority(candidate))
.map(String::from)
}
#[cfg(all(not(target_arch = "wasm32"), windows))]
fn windows_program_priority(candidate: &str) -> u8 {
match Path::new(candidate)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.to_ascii_lowercase())
{
Some(ext) if ext == "exe" => 5,
Some(ext) if ext == "com" => 4,
Some(ext) if ext == "cmd" => 3,
Some(ext) if ext == "bat" => 2,
Some(_) => 1,
None => 0,
}
}
#[cfg(all(not(target_arch = "wasm32"), windows))]
fn windows_requires_cmd_shell(program: &str) -> bool {
Path::new(program)
.extension()
.and_then(|ext| ext.to_str())
.map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd"))
.unwrap_or(false)
}
pub mod mock {
use super::*;
use std::sync::{Arc, Mutex, MutexGuard};
fn lock<'a, T>(mutex: &'a Mutex<T>) -> MutexGuard<'a, T> {
match mutex.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
}
}
#[derive(Debug, Clone)]
pub struct CommandInvocation {
pub program: String,
pub args: Vec<String>,
pub stdin: Option<Vec<u8>>,
}
#[derive(Debug, Clone)]
pub struct MockResponse {
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
pub status_code: i32,
}
impl MockResponse {
pub fn success(stdout: impl Into<Vec<u8>>) -> Self {
Self { stdout: stdout.into(), stderr: Vec::new(), status_code: 0 }
}
pub fn failure(stderr: impl Into<Vec<u8>>, status_code: i32) -> Self {
Self { stdout: Vec::new(), stderr: stderr.into(), status_code }
}
}
pub struct MockSubprocessRuntime {
invocations: Arc<Mutex<Vec<CommandInvocation>>>,
responses: Arc<Mutex<Vec<MockResponse>>>,
default_response: MockResponse,
}
impl MockSubprocessRuntime {
pub fn new() -> Self {
Self {
invocations: Arc::new(Mutex::new(Vec::new())),
responses: Arc::new(Mutex::new(Vec::new())),
default_response: MockResponse::success(Vec::new()),
}
}
pub fn add_response(&self, response: MockResponse) {
lock(&self.responses).push(response);
}
pub fn set_default_response(&mut self, response: MockResponse) {
self.default_response = response;
}
pub fn invocations(&self) -> Vec<CommandInvocation> {
lock(&self.invocations).clone()
}
pub fn clear_invocations(&self) {
lock(&self.invocations).clear();
}
}
impl Default for MockSubprocessRuntime {
fn default() -> Self {
Self::new()
}
}
impl SubprocessRuntime for MockSubprocessRuntime {
fn run_command(
&self,
program: &str,
args: &[&str],
stdin: Option<&[u8]>,
) -> Result<SubprocessOutput, SubprocessError> {
lock(&self.invocations).push(CommandInvocation {
program: program.to_string(),
args: args.iter().map(|s| s.to_string()).collect(),
stdin: stdin.map(|s| s.to_vec()),
});
let response = {
let mut responses = lock(&self.responses);
if responses.is_empty() {
self.default_response.clone()
} else {
responses.remove(0)
}
};
Ok(SubprocessOutput {
stdout: response.stdout,
stderr: response.stderr,
status_code: response.status_code,
})
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_subprocess_output_success() {
let output = SubprocessOutput { stdout: vec![1, 2, 3], stderr: vec![], status_code: 0 };
assert!(output.success());
}
#[test]
fn test_subprocess_output_failure() {
let output = SubprocessOutput { stdout: vec![], stderr: b"error".to_vec(), status_code: 1 };
assert!(!output.success());
assert_eq!(output.stderr_lossy(), "error");
}
#[test]
fn test_subprocess_error_display() {
let error = SubprocessError::new("test error");
assert_eq!(format!("{}", error), "test error");
}
#[test]
fn test_mock_runtime() {
use mock::*;
let runtime = MockSubprocessRuntime::new();
runtime.add_response(MockResponse::success(b"formatted code".to_vec()));
let result = runtime.run_command("perltidy", &["-st"], Some(b"my $x = 1;"));
assert!(result.is_ok());
let output = perl_tdd_support::must(result);
assert!(output.success());
assert_eq!(output.stdout_lossy(), "formatted code");
let invocations = runtime.invocations();
assert_eq!(invocations.len(), 1);
assert_eq!(invocations[0].program, "perltidy");
assert_eq!(invocations[0].args, vec!["-st"]);
assert_eq!(invocations[0].stdin, Some(b"my $x = 1;".to_vec()));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_os_runtime_echo() {
let runtime = OsSubprocessRuntime::new();
#[cfg(windows)]
let result = runtime.run_command("cmd.exe", &["/C", "echo", "hello"], None);
#[cfg(not(windows))]
let result = runtime.run_command("echo", &["hello"], None);
assert!(result.is_ok());
let output = perl_tdd_support::must(result);
assert!(output.success());
assert!(output.stdout_lossy().trim() == "hello");
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_os_runtime_nonexistent() {
let runtime = OsSubprocessRuntime::new();
let result = runtime.run_command("nonexistent_program_xyz", &[], None);
assert!(result.is_err());
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_os_runtime_rejects_empty_program_name() {
let runtime = OsSubprocessRuntime::new();
let result = runtime.run_command(" ", &["--version"], None);
assert!(result.is_err());
let err = result.expect_err("empty program name must be rejected");
assert!(err.message.contains("must not be empty"));
}
#[cfg(not(target_arch = "wasm32"))]
#[test]
fn test_os_runtime_rejects_nul_bytes_in_program_or_args() {
let runtime = OsSubprocessRuntime::new();
let bad_program = runtime.run_command("perl\0", &["--version"], None);
assert!(bad_program.is_err());
let bad_program_err = bad_program.expect_err("NUL in program must be rejected");
assert!(bad_program_err.message.contains("NUL"));
let bad_arg = runtime.run_command("perl", &["-e", "print \"ok\"\0"], None);
assert!(bad_arg.is_err());
let bad_arg_err = bad_arg.expect_err("NUL in arg must be rejected");
assert!(bad_arg_err.message.contains("NUL"));
}
#[cfg(windows)]
#[test]
fn test_resolve_command_invocation_uses_cmd_for_batch_wrappers() {
let (program, args) =
resolve_command_invocation(r"C:\Strawberry\perl\bin\perltidy.bat", &["-st", "-se"]);
assert_eq!(program, "cmd.exe");
assert_eq!(
args,
vec![
"/D".to_string(),
"/V:OFF".to_string(),
"/S".to_string(),
"/C".to_string(),
"\"C:\\Strawberry\\perl\\bin\\perltidy.bat\" \"-st\" \"-se\"".to_string(),
]
);
}
#[cfg(windows)]
#[test]
fn test_windows_quote_for_cmd_metacharacters_are_literal_inside_quotes() {
let quoted = windows_quote_for_cmd(r#"profile&name|1>%TEMP%^"x""#);
assert_eq!(quoted, r#""profile&name|1>%%TEMP%%^""x""""#);
}
#[cfg(windows)]
#[test]
fn test_windows_quote_for_cmd_caret_not_doubled() {
let quoted = windows_quote_for_cmd(r"foo^bar");
assert_eq!(quoted, r#""foo^bar""#);
}
#[cfg(windows)]
#[test]
fn test_windows_quote_for_cmd_embedded_quote_uses_doubling() {
let quoted = windows_quote_for_cmd(r#"arg"with"quotes"#);
assert_eq!(quoted, r#""arg""with""quotes""#);
}
#[cfg(windows)]
#[test]
fn test_windows_quote_for_cmd_injection_attempt_is_inert() {
let quoted = windows_quote_for_cmd("&calc.exe");
assert_eq!(quoted, "\"&calc.exe\"");
}
#[cfg(windows)]
#[test]
fn test_resolve_command_invocation_includes_v_off_flag() {
let (program, args) =
resolve_command_invocation(r"C:\tools\perlcritic.bat", &["--profile=!TEMP!"]);
assert_eq!(program, "cmd.exe");
assert!(
args.contains(&"/V:OFF".to_string()),
"/V:OFF must be present to disable delayed expansion; got: {:?}",
args
);
}
#[cfg(windows)]
#[test]
fn test_resolve_command_invocation_preserves_executable_paths() {
let (program, args) =
resolve_command_invocation(r"C:\tools\perlcritic.exe", &["--version"]);
assert_eq!(program, r"C:\tools\perlcritic.exe");
assert_eq!(args, vec!["--version".to_string()]);
}
#[cfg(windows)]
#[test]
fn test_windows_program_priority_prefers_real_wrappers_over_extensionless_shims() {
let mut candidates = vec![
r"C:\Strawberry\perl\bin\perltidy".to_string(),
r"C:\Strawberry\perl\bin\perltidy.bat".to_string(),
r"C:\tools\perltidy.exe".to_string(),
];
candidates.sort_by_key(|candidate| windows_program_priority(candidate));
assert_eq!(candidates.last().map(String::as_str), Some(r"C:\tools\perltidy.exe"));
assert!(
windows_program_priority(r"C:\Strawberry\perl\bin\perltidy.bat")
> windows_program_priority(r"C:\Strawberry\perl\bin\perltidy")
);
}
}