use crate::error::RunnerError;
use std::ffi::OsStr;
use std::process::Stdio;
use std::time::Duration;
use super::{CommandSpec, ProcessOutput, ProcessRunner};
#[derive(Debug, Clone, Default)]
pub struct WslRunner {
pub distro: Option<String>,
}
impl WslRunner {
#[must_use]
pub const fn new() -> Self {
Self { distro: None }
}
#[must_use]
pub fn with_distro(distro: impl Into<String>) -> Self {
Self {
distro: Some(distro.into()),
}
}
fn validate_argument(arg: &OsStr) -> Result<(), RunnerError> {
let arg_bytes = arg.as_encoded_bytes();
if arg_bytes.contains(&0) {
return Err(RunnerError::WslExecutionFailed {
reason: "Argument contains null byte which is not allowed".to_string(),
});
}
Ok(())
}
fn build_wsl_command(&self, cmd: &CommandSpec) -> Result<CommandSpec, RunnerError> {
Self::validate_argument(&cmd.program)?;
for arg in &cmd.args {
Self::validate_argument(arg)?;
}
let mut wsl_cmd = CommandSpec::new("wsl");
if let Some(ref distro) = self.distro {
wsl_cmd = wsl_cmd.arg("-d").arg(distro);
}
wsl_cmd = wsl_cmd.arg("--exec");
wsl_cmd = wsl_cmd.arg(&cmd.program);
for arg in &cmd.args {
wsl_cmd = wsl_cmd.arg(arg);
}
if let Some(ref cwd) = cmd.cwd {
wsl_cmd = wsl_cmd.cwd(cwd);
}
if let Some(ref env) = cmd.env {
for (key, value) in env {
wsl_cmd = wsl_cmd.env(key, value);
}
}
Ok(wsl_cmd)
}
}
impl ProcessRunner for WslRunner {
fn run(&self, cmd: &CommandSpec, timeout: Duration) -> Result<ProcessOutput, RunnerError> {
if !cfg!(target_os = "windows") {
return Err(RunnerError::WslNotAvailable {
reason: "WSL is only available on Windows".to_string(),
});
}
use std::sync::mpsc;
use std::thread;
let wsl_cmd = self.build_wsl_command(cmd)?;
let mut command = wsl_cmd.to_command();
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let child = command
.spawn()
.map_err(|e| RunnerError::WslExecutionFailed {
reason: format!(
"Failed to spawn WSL process for '{}': {}",
cmd.program.to_string_lossy(),
e
),
})?;
let child_id = child.id();
let (tx, rx) = mpsc::channel();
let handle = thread::spawn(move || {
let output = child.wait_with_output();
let _ = tx.send(output);
});
match rx.recv_timeout(timeout) {
Ok(output_result) => {
let _ = handle.join();
let output = output_result.map_err(|e| RunnerError::WslExecutionFailed {
reason: format!("Failed to wait for WSL process: {e}"),
})?;
Ok(ProcessOutput::new(
output.stdout,
output.stderr,
output.status.code(),
false,
))
}
Err(mpsc::RecvTimeoutError::Timeout) => {
Self::terminate_wsl_process(child_id);
let _ = handle.join();
Err(RunnerError::Timeout {
timeout_seconds: timeout.as_secs(),
})
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
Err(RunnerError::WslExecutionFailed {
reason: "WSL process monitoring thread terminated unexpectedly".to_string(),
})
}
}
}
}
impl WslRunner {
fn terminate_wsl_process(pid: u32) {
#[cfg(windows)]
{
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
OpenProcess, PROCESS_TERMINATE, TerminateProcess,
};
unsafe {
if let Ok(handle) = OpenProcess(PROCESS_TERMINATE, false, pid) {
let _ = TerminateProcess(handle, 1);
let _ = CloseHandle(handle);
}
}
}
#[cfg(not(windows))]
{
let _ = pid;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use proptest::prelude::*;
use std::ffi::OsString;
use std::path::PathBuf;
#[test]
fn test_wsl_runner_new() {
let runner = WslRunner::new();
assert!(runner.distro.is_none());
}
#[test]
fn test_wsl_runner_with_distro() {
let runner = WslRunner::with_distro("Ubuntu-22.04");
assert_eq!(runner.distro, Some("Ubuntu-22.04".to_string()));
}
#[test]
fn test_wsl_runner_default() {
let runner = WslRunner::default();
assert!(runner.distro.is_none());
}
#[test]
fn test_wsl_runner_clone() {
let runner = WslRunner::with_distro("Ubuntu");
let cloned = runner.clone();
assert_eq!(cloned.distro, runner.distro);
}
#[test]
fn test_wsl_runner_implements_process_runner() {
fn assert_process_runner<T: ProcessRunner>(_: &T) {}
let runner = WslRunner::new();
assert_process_runner(&runner);
}
#[test]
fn test_wsl_runner_build_command_basic() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo").arg("hello").arg("world");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
assert_eq!(wsl_cmd.program, OsString::from("wsl"));
assert_eq!(wsl_cmd.args.len(), 4);
assert_eq!(wsl_cmd.args[0], OsString::from("--exec"));
assert_eq!(wsl_cmd.args[1], OsString::from("echo"));
assert_eq!(wsl_cmd.args[2], OsString::from("hello"));
assert_eq!(wsl_cmd.args[3], OsString::from("world"));
}
#[test]
fn test_wsl_runner_build_command_with_distro() {
let runner = WslRunner::with_distro("Ubuntu-22.04");
let cmd = CommandSpec::new("echo").arg("test");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
assert_eq!(wsl_cmd.program, OsString::from("wsl"));
assert_eq!(wsl_cmd.args.len(), 5);
assert_eq!(wsl_cmd.args[0], OsString::from("-d"));
assert_eq!(wsl_cmd.args[1], OsString::from("Ubuntu-22.04"));
assert_eq!(wsl_cmd.args[2], OsString::from("--exec"));
assert_eq!(wsl_cmd.args[3], OsString::from("echo"));
assert_eq!(wsl_cmd.args[4], OsString::from("test"));
}
#[test]
fn test_wsl_runner_build_command_preserves_cwd() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("ls").cwd("/home/user");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
assert_eq!(wsl_cmd.cwd, Some(PathBuf::from("/home/user")));
}
#[test]
fn test_wsl_runner_build_command_preserves_env() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("env").env("MY_VAR", "my_value");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
let env = wsl_cmd.env.as_ref().unwrap();
assert_eq!(
env.get(&OsString::from("MY_VAR")),
Some(&OsString::from("my_value"))
);
}
#[test]
fn test_wsl_runner_build_command_shell_metacharacters_preserved() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo")
.arg("$(whoami)")
.arg("`id`")
.arg("${HOME}")
.arg("$PATH")
.arg("arg;with;semicolons")
.arg("arg|with|pipes")
.arg("arg&with&ersands");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
assert_eq!(wsl_cmd.args[2], OsString::from("$(whoami)"));
assert_eq!(wsl_cmd.args[3], OsString::from("`id`"));
assert_eq!(wsl_cmd.args[4], OsString::from("${HOME}"));
assert_eq!(wsl_cmd.args[5], OsString::from("$PATH"));
assert_eq!(wsl_cmd.args[6], OsString::from("arg;with;semicolons"));
assert_eq!(wsl_cmd.args[7], OsString::from("arg|with|pipes"));
assert_eq!(wsl_cmd.args[8], OsString::from("arg&with&ersands"));
}
#[test]
fn test_wsl_runner_validate_argument_rejects_null_bytes() {
let arg_with_null = OsString::from("hello\0world");
let result = WslRunner::validate_argument(&arg_with_null);
assert!(result.is_err());
match result {
Err(RunnerError::WslExecutionFailed { reason }) => {
assert!(reason.contains("null byte"));
}
_ => panic!("Expected WslExecutionFailed error"),
}
}
#[test]
fn test_wsl_runner_validate_argument_accepts_valid_args() {
let valid_args = [
"simple",
"with spaces",
"with-dashes",
"with_underscores",
"with.dots",
"/path/to/file",
"C:\\Windows\\Path",
"unicode: 日本語",
"emoji: 🎉",
"--flag=value",
"-v",
"$(not-expanded)",
"`backticks`",
"${variable}",
];
for arg in valid_args {
let os_arg = OsString::from(arg);
let result = WslRunner::validate_argument(&os_arg);
assert!(result.is_ok(), "Argument '{}' should be valid", arg);
}
}
#[test]
fn test_wsl_runner_build_command_rejects_null_in_program() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo\0bad");
let result = runner.build_wsl_command(&cmd);
assert!(result.is_err());
match result {
Err(RunnerError::WslExecutionFailed { reason }) => {
assert!(reason.contains("null byte"));
}
_ => panic!("Expected WslExecutionFailed error"),
}
}
#[test]
fn test_wsl_runner_build_command_rejects_null_in_args() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo")
.arg("valid")
.arg("has\0null")
.arg("also valid");
let result = runner.build_wsl_command(&cmd);
assert!(result.is_err());
match result {
Err(RunnerError::WslExecutionFailed { reason }) => {
assert!(reason.contains("null byte"));
}
_ => panic!("Expected WslExecutionFailed error"),
}
}
#[cfg(not(target_os = "windows"))]
#[test]
fn test_wsl_runner_returns_error_on_non_windows() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo").arg("test");
let result = runner.run(&cmd, Duration::from_secs(10));
assert!(result.is_err());
match result {
Err(RunnerError::WslNotAvailable { reason }) => {
assert!(reason.contains("only available on Windows"));
}
_ => panic!("Expected WslNotAvailable error"),
}
}
#[test]
fn test_wsl_runner_no_string_concatenation() {
let runner = WslRunner::with_distro("TestDistro");
let cmd = CommandSpec::new("program")
.arg("arg1")
.arg("arg2 with spaces")
.arg("arg3;semicolon");
let wsl_cmd = runner.build_wsl_command(&cmd).unwrap();
assert_eq!(wsl_cmd.args.len(), 7);
assert_eq!(wsl_cmd.args[4], OsString::from("arg1"));
assert_eq!(wsl_cmd.args[5], OsString::from("arg2 with spaces"));
assert_eq!(wsl_cmd.args[6], OsString::from("arg3;semicolon"));
}
#[test]
fn test_wsl_runner_command_construction() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo").arg("hello").arg("world");
let wsl_cmd = runner
.build_wsl_command(&cmd)
.expect("Failed to build WSL command");
assert_eq!(wsl_cmd.program, OsString::from("wsl"));
let args: Vec<String> = wsl_cmd
.args
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect();
assert_eq!(args[0], "--exec");
assert_eq!(args[1], "echo");
assert_eq!(args[2], "hello");
assert_eq!(args[3], "world");
for arg in &args {
assert!(!arg.contains("sh -c"));
assert!(!arg.contains("cmd /C"));
}
}
#[test]
fn test_wsl_runner_with_distro_command_construction() {
let runner = WslRunner::with_distro("Ubuntu-22.04");
let cmd = CommandSpec::new("ls").arg("-la");
let wsl_cmd = runner
.build_wsl_command(&cmd)
.expect("Failed to build WSL command");
let args: Vec<String> = wsl_cmd
.args
.iter()
.map(|s| s.to_string_lossy().to_string())
.collect();
assert_eq!(args[0], "-d");
assert_eq!(args[1], "Ubuntu-22.04");
assert_eq!(args[2], "--exec");
assert_eq!(args[3], "ls");
assert_eq!(args[4], "-la");
}
#[test]
fn test_wsl_runner_argument_validation() {
let runner = WslRunner::new();
let cmd = CommandSpec::new("echo").arg("hello\0world");
let result = runner.build_wsl_command(&cmd);
assert!(result.is_err());
if let Err(RunnerError::WslExecutionFailed { reason }) = result {
assert!(reason.contains("null byte"));
} else {
panic!("Expected WslExecutionFailed error");
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn test_wsl_runner_safety_property(
program in any::<String>(),
args in prop::collection::vec(any::<String>(), 0..10),
distro in prop::option::of(any::<String>())
) {
let mut runner = WslRunner::new();
if let Some(ref d) = distro {
runner = WslRunner::with_distro(d.clone());
}
let mut cmd = CommandSpec::new(&program);
for arg in &args {
cmd = cmd.arg(arg);
}
let result = runner.build_wsl_command(&cmd);
let has_null = program.contains('\0') || args.iter().any(|a| a.contains('\0'));
if has_null {
prop_assert!(result.is_err());
} else {
prop_assert!(result.is_ok());
let wsl_cmd = result.unwrap();
prop_assert_eq!(wsl_cmd.program, OsString::from("wsl"));
let mut expected_args_len = 1; let mut arg_idx = 0;
if let Some(ref d) = runner.distro {
prop_assert_eq!(&wsl_cmd.args[arg_idx], &OsString::from("-d"));
prop_assert_eq!(&wsl_cmd.args[arg_idx+1], &OsString::from(d));
arg_idx += 2;
expected_args_len += 2;
}
prop_assert_eq!(&wsl_cmd.args[arg_idx], &OsString::from("--exec"));
arg_idx += 1;
prop_assert_eq!(&wsl_cmd.args[arg_idx], &OsString::from(&program));
arg_idx += 1;
expected_args_len += 1;
for (i, arg) in args.iter().enumerate() {
prop_assert_eq!(&wsl_cmd.args[arg_idx + i], &OsString::from(arg));
}
expected_args_len += args.len();
prop_assert_eq!(wsl_cmd.args.len(), expected_args_len);
}
}
}
}