use std::ffi::{OsStr, OsString};
use std::fmt;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use secrecy::{ExposeSecret, SecretBox, SecretString};
use super::WslError;
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
pub const DEFAULT_STDOUT_LIMIT: usize = 1024 * 1024;
pub const DEFAULT_STDERR_LIMIT: usize = 64 * 1024;
const ARGV_SCAN_LIMIT: usize = 32 * 1024;
const POLL_INTERVAL: Duration = Duration::from_millis(5);
#[derive(Debug, Clone, Default)]
pub struct Cancellation(Arc<AtomicBool>);
impl Cancellation {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn cancel(&self) {
self.0.store(true, Ordering::SeqCst);
}
#[must_use]
pub fn is_cancelled(&self) -> bool {
self.0.load(Ordering::SeqCst)
}
}
pub struct PipedInput {
bytes: SecretBox<Vec<u8>>,
length: usize,
}
impl PipedInput {
#[must_use]
pub fn from_bytes(bytes: Vec<u8>) -> Self {
let length = bytes.len();
Self {
bytes: SecretBox::new(Box::new(bytes)),
length,
}
}
#[must_use]
pub fn from_secret_text(text: &SecretString) -> Self {
Self::from_bytes(text.expose_secret().as_bytes().to_vec())
}
#[must_use]
pub fn len(&self) -> usize {
self.length
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.length == 0
}
pub(crate) fn expose_bytes(&self) -> &[u8] {
self.bytes.expose_secret()
}
}
impl fmt::Debug for PipedInput {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "PipedInput(<redacted; {} bytes>)", self.length)
}
}
#[derive(Debug, Default)]
pub enum ChildInput {
#[default]
Empty,
Piped(PipedInput),
}
impl ChildInput {
#[must_use]
pub fn piped(&self) -> Option<&PipedInput> {
match self {
Self::Empty => None,
Self::Piped(input) => Some(input),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OutputLimits {
pub stdout: usize,
pub stderr: usize,
}
impl Default for OutputLimits {
fn default() -> Self {
Self {
stdout: DEFAULT_STDOUT_LIMIT,
stderr: DEFAULT_STDERR_LIMIT,
}
}
}
pub struct CommandRequest {
program: PathBuf,
arguments: Vec<OsString>,
input: ChildInput,
limits: OutputLimits,
timeout: Duration,
cancellation: Option<Cancellation>,
}
impl CommandRequest {
#[must_use]
pub fn new(program: impl Into<PathBuf>) -> Self {
Self {
program: program.into(),
arguments: Vec::new(),
input: ChildInput::Empty,
limits: OutputLimits::default(),
timeout: DEFAULT_TIMEOUT,
cancellation: None,
}
}
#[must_use]
pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
self.arguments.push(argument.into());
self
}
#[must_use]
pub fn args<I, S>(mut self, arguments: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<OsString>,
{
self.arguments.extend(arguments.into_iter().map(Into::into));
self
}
#[must_use]
pub fn with_input(mut self, input: ChildInput) -> Self {
self.input = input;
self
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
#[must_use]
pub fn with_limits(mut self, limits: OutputLimits) -> Self {
self.limits = limits;
self
}
#[must_use]
pub fn with_cancellation(mut self, cancellation: Cancellation) -> Self {
self.cancellation = Some(cancellation);
self
}
#[must_use]
pub fn program(&self) -> &Path {
&self.program
}
#[must_use]
pub fn arguments(&self) -> &[OsString] {
&self.arguments
}
#[must_use]
pub fn argument_strings(&self) -> Vec<String> {
self.arguments
.iter()
.map(|argument| argument.to_string_lossy().into_owned())
.collect()
}
#[must_use]
pub fn input(&self) -> &ChildInput {
&self.input
}
#[must_use]
pub fn limits(&self) -> OutputLimits {
self.limits
}
#[must_use]
pub fn timeout(&self) -> Duration {
self.timeout
}
#[must_use]
pub fn cancellation(&self) -> Option<&Cancellation> {
self.cancellation.as_ref()
}
pub fn refuse_payload_in_argv(&self) -> Result<(), WslError> {
let Some(payload) = self.input.piped() else {
return Ok(());
};
if payload.is_empty() || payload.len() > ARGV_SCAN_LIMIT {
return Ok(());
}
let needle = payload.expose_bytes();
let found_in = |value: &OsStr| {
let text = value.to_string_lossy();
contains_subslice(text.as_bytes(), needle)
};
if found_in(self.program.as_os_str()) {
return Err(WslError::SecretInCommandLine {
program: self.program.clone(),
location: "the program path".to_string(),
});
}
for (index, argument) in self.arguments.iter().enumerate() {
if found_in(argument) {
return Err(WslError::SecretInCommandLine {
program: self.program.clone(),
location: format!("argument {index}"),
});
}
}
Ok(())
}
}
impl fmt::Debug for CommandRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("CommandRequest")
.field("program", &self.program)
.field("arguments", &self.arguments)
.field("input", &self.input)
.field("limits", &self.limits)
.field("timeout", &self.timeout)
.finish_non_exhaustive()
}
}
fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
if needle.is_empty() || needle.len() > haystack.len() {
return false;
}
haystack
.windows(needle.len())
.any(|window| window == needle)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Completion {
Exited,
TimedOut,
Cancelled,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommandOutput {
completion: Completion,
exit_code: Option<i32>,
stdout: Vec<u8>,
stderr: Vec<u8>,
stdout_truncated: bool,
stderr_truncated: bool,
}
impl CommandOutput {
#[must_use]
pub fn exited(exit_code: i32, stdout: impl Into<Vec<u8>>, stderr: impl Into<Vec<u8>>) -> Self {
Self {
completion: Completion::Exited,
exit_code: Some(exit_code),
stdout: stdout.into(),
stderr: stderr.into(),
stdout_truncated: false,
stderr_truncated: false,
}
}
#[must_use]
pub fn timed_out() -> Self {
Self {
completion: Completion::TimedOut,
exit_code: None,
stdout: Vec::new(),
stderr: Vec::new(),
stdout_truncated: false,
stderr_truncated: false,
}
}
#[must_use]
pub fn with_truncation(mut self, stdout: bool, stderr: bool) -> Self {
self.stdout_truncated = stdout;
self.stderr_truncated = stderr;
self
}
#[must_use]
pub fn completion(&self) -> Completion {
self.completion
}
#[must_use]
pub fn exit_code(&self) -> Option<i32> {
self.exit_code
}
#[must_use]
pub fn success(&self) -> bool {
self.completion == Completion::Exited && self.exit_code == Some(0)
}
#[must_use]
pub fn stdout(&self) -> &[u8] {
&self.stdout
}
#[must_use]
pub fn stderr(&self) -> &[u8] {
&self.stderr
}
#[must_use]
pub fn stdout_truncated(&self) -> bool {
self.stdout_truncated
}
#[must_use]
pub fn stderr_truncated(&self) -> bool {
self.stderr_truncated
}
#[must_use]
pub fn stdout_text(&self) -> String {
super::discovery::decode_console_output(&self.stdout)
.into_text()
.trim()
.to_string()
}
#[must_use]
pub fn stderr_text(&self) -> String {
super::discovery::decode_console_output(&self.stderr)
.into_text()
.trim()
.to_string()
}
#[must_use]
pub fn diagnostic(&self) -> String {
let stderr = self.stderr_text();
if !stderr.is_empty() {
return stderr;
}
let stdout = self.stdout_text();
if !stdout.is_empty() {
return stdout;
}
match self.completion {
Completion::Exited => match self.exit_code {
Some(code) => format!("it exited with status {code} and said nothing"),
None => "it was terminated and said nothing".to_string(),
},
Completion::TimedOut => "it did not finish before its deadline".to_string(),
Completion::Cancelled => "it was cancelled".to_string(),
}
}
}
pub trait CommandRunner: fmt::Debug + Send + Sync {
fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError>;
}
#[derive(Debug, Clone, Copy, Default)]
pub struct HostCommandRunner;
impl CommandRunner for HostCommandRunner {
fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError> {
request.refuse_payload_in_argv()?;
let mut command = Command::new(request.program());
command
.args(request.arguments())
.stdin(match request.input() {
ChildInput::Empty => Stdio::null(),
ChildInput::Piped(_) => Stdio::piped(),
})
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().map_err(|source| WslError::Spawn {
program: request.program().to_path_buf(),
source,
})?;
let stdin = child.stdin.take();
let stdout = child
.stdout
.take()
.expect("stdout was piped when the child was configured");
let stderr = child
.stderr
.take()
.expect("stderr was piped when the child was configured");
let limits = request.limits();
let (waited, out, err) = std::thread::scope(|scope| {
let writer = scope.spawn(move || write_input(stdin, request.input()));
let out = scope.spawn(move || read_bounded(stdout, limits.stdout));
let err = scope.spawn(move || read_bounded(stderr, limits.stderr));
let waited = wait_for(&mut child, request.timeout(), request.cancellation());
drop(writer.join());
(
waited,
out.join().unwrap_or_else(|_| (Vec::new(), false)),
err.join().unwrap_or_else(|_| (Vec::new(), false)),
)
});
let (completion, exit_code) = waited.map_err(|source| WslError::ChildControl {
program: request.program().to_path_buf(),
source,
})?;
Ok(CommandOutput {
completion,
exit_code,
stdout: out.0,
stderr: err.0,
stdout_truncated: out.1,
stderr_truncated: err.1,
})
}
}
fn write_input(stdin: Option<std::process::ChildStdin>, input: &ChildInput) -> std::io::Result<()> {
let Some(mut pipe) = stdin else {
return Ok(());
};
if let Some(payload) = input.piped() {
pipe.write_all(payload.expose_bytes())?;
pipe.flush()?;
}
drop(pipe);
Ok(())
}
fn read_bounded(mut source: impl Read, limit: usize) -> (Vec<u8>, bool) {
let mut kept: Vec<u8> = Vec::new();
let mut truncated = false;
let mut buffer = [0_u8; 8192];
loop {
match source.read(&mut buffer) {
Ok(0) => break,
Ok(read) => {
let room = limit.saturating_sub(kept.len());
if room > 0 {
kept.extend_from_slice(&buffer[..read.min(room)]);
}
if read > room {
truncated = true;
}
}
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(_) => break,
}
}
(kept, truncated)
}
fn wait_for(
child: &mut Child,
timeout: Duration,
cancellation: Option<&Cancellation>,
) -> std::io::Result<(Completion, Option<i32>)> {
let deadline = Instant::now().checked_add(timeout);
loop {
if let Some(status) = child.try_wait()? {
return Ok((Completion::Exited, status.code()));
}
if cancellation.is_some_and(Cancellation::is_cancelled) {
child.kill()?;
let status = child.wait()?;
return Ok((Completion::Cancelled, status.code()));
}
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
child.kill()?;
let status = child.wait()?;
return Ok((Completion::TimedOut, status.code()));
}
std::thread::sleep(POLL_INTERVAL);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecordedRequest {
pub program: PathBuf,
pub arguments: Vec<String>,
pub stdin: Vec<u8>,
pub timeout: Duration,
}
impl RecordedRequest {
#[must_use]
pub fn command_line(&self) -> String {
let mut line = self.program.to_string_lossy().into_owned();
for argument in &self.arguments {
line.push(' ');
line.push_str(argument);
}
line
}
}
#[derive(Debug, Default)]
pub struct ScriptedRunner {
rules: Mutex<Vec<Rule>>,
recorded: Mutex<Vec<RecordedRequest>>,
default_response: Mutex<Option<CommandOutput>>,
}
#[derive(Debug)]
struct Rule {
contains: String,
responses: Vec<CommandOutput>,
used: usize,
}
impl ScriptedRunner {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn always(self, contains: &str, response: CommandOutput) -> Self {
self.push_rule(contains, vec![response]);
self
}
#[must_use]
pub fn sequence(self, contains: &str, responses: Vec<CommandOutput>) -> Self {
self.push_rule(contains, responses);
self
}
#[must_use]
pub fn otherwise(self, response: CommandOutput) -> Self {
*self
.default_response
.lock()
.expect("the scripted runner's response is not shared across a panic") = Some(response);
self
}
fn push_rule(&self, contains: &str, responses: Vec<CommandOutput>) {
self.rules
.lock()
.expect("the scripted runner's rules are not shared across a panic")
.push(Rule {
contains: contains.to_string(),
responses,
used: 0,
});
}
#[must_use]
pub fn recorded(&self) -> Vec<RecordedRequest> {
self.recorded
.lock()
.expect("the scripted runner's log is not shared across a panic")
.clone()
}
#[must_use]
pub fn call_count(&self) -> usize {
self.recorded
.lock()
.expect("the scripted runner's log is not shared across a panic")
.len()
}
#[must_use]
pub fn command_lines(&self) -> Vec<String> {
self.recorded()
.iter()
.map(RecordedRequest::command_line)
.collect()
}
#[must_use]
pub fn piped_input(&self) -> Vec<u8> {
let mut all = Vec::new();
for request in self.recorded() {
all.extend_from_slice(&request.stdin);
}
all
}
}
impl CommandRunner for ScriptedRunner {
fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError> {
request.refuse_payload_in_argv()?;
let recorded = RecordedRequest {
program: request.program().to_path_buf(),
arguments: request.argument_strings(),
stdin: request
.input()
.piped()
.map(|input| input.expose_bytes().to_vec())
.unwrap_or_default(),
timeout: request.timeout(),
};
let line = recorded.command_line();
self.recorded
.lock()
.expect("the scripted runner's log is not shared across a panic")
.push(recorded);
let mut rules = self
.rules
.lock()
.expect("the scripted runner's rules are not shared across a panic");
for rule in rules.iter_mut() {
if line.contains(&rule.contains) {
let index = rule.used.min(rule.responses.len().saturating_sub(1));
rule.used += 1;
if let Some(response) = rule.responses.get(index) {
return Ok(response.clone());
}
}
}
drop(rules);
Ok(self
.default_response
.lock()
.expect("the scripted runner's response is not shared across a panic")
.clone()
.unwrap_or_else(|| CommandOutput::exited(0, Vec::new(), Vec::new())))
}
}
#[cfg(test)]
mod tests {
use super::*;
fn canary() -> SecretString {
SecretString::from(format!("{}{}", "ghu_", "a1WslFixtureNotARealCredential00"))
}
#[test]
fn a_piped_payload_never_appears_in_debug_output() {
let secret = canary();
let request = CommandRequest::new("wsl.exe")
.arg("--distribution")
.arg("Ubuntu")
.with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
let printed = format!("{request:?}");
assert!(
!printed.contains(secret.expose_secret()),
"the payload reached Debug output: {printed}"
);
assert!(
printed.contains("<redacted; 36 bytes>"),
"the redacted form should still say how much there was: {printed}"
);
}
#[test]
fn a_payload_that_is_also_an_argument_refuses_to_launch() {
let secret = canary();
let request = CommandRequest::new("wsl.exe")
.arg("--exec")
.arg(format!("--token={}", secret.expose_secret()))
.with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
let error = request
.refuse_payload_in_argv()
.expect_err("the payload is in argument 1");
assert!(
matches!(&error, WslError::SecretInCommandLine { location, .. } if location == "argument 1"),
"unexpected error: {error:?}"
);
assert!(!error.to_string().contains(secret.expose_secret()));
}
#[test]
fn a_payload_that_is_only_on_stdin_is_allowed() {
let secret = canary();
let request = CommandRequest::new("wsl.exe")
.arg("--distribution")
.arg("Ubuntu")
.with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
request
.refuse_payload_in_argv()
.expect("stdin is the supported channel");
}
#[test]
fn a_payload_too_large_for_a_command_line_is_not_scanned() {
let request = CommandRequest::new("wsl.exe")
.arg("--exec")
.with_input(ChildInput::Piped(PipedInput::from_bytes(vec![
b'x';
ARGV_SCAN_LIMIT
+ 1
])));
request.refuse_payload_in_argv().expect("not scanned");
}
#[test]
fn arguments_are_kept_verbatim_and_never_joined() {
let request = CommandRequest::new("wsl.exe")
.arg("--distribution")
.arg("Ubuntu & shutdown /s")
.arg("--exec");
assert_eq!(
request.argument_strings(),
vec![
"--distribution".to_string(),
"Ubuntu & shutdown /s".to_string(),
"--exec".to_string(),
]
);
}
#[test]
fn a_scripted_runner_answers_in_rule_order_and_records_stdin() {
let secret = canary();
let runner = ScriptedRunner::new()
.always(
"--version",
CommandOutput::exited(0, "runner-manager 0.4.0", ""),
)
.otherwise(CommandOutput::exited(1, "", "no rule"));
let versioned = runner
.run(&CommandRequest::new("wsl.exe").arg("--version"))
.expect("scripted");
assert_eq!(versioned.stdout_text(), "runner-manager 0.4.0");
let other = runner
.run(
&CommandRequest::new("wsl.exe")
.arg("--exec")
.with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
)
.expect("scripted");
assert_eq!(other.exit_code(), Some(1));
assert_eq!(runner.call_count(), 2);
assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
assert!(
runner
.command_lines()
.iter()
.all(|line| !line.contains(secret.expose_secret())),
"the canary must not be in any recorded command line"
);
}
#[test]
fn a_sequence_rule_advances_and_then_repeats_its_last_answer() {
let runner = ScriptedRunner::new().sequence(
"probe",
vec![
CommandOutput::exited(1, "", "not yet"),
CommandOutput::exited(0, "ready", ""),
],
);
let first = runner.run(&CommandRequest::new("probe")).expect("scripted");
let second = runner.run(&CommandRequest::new("probe")).expect("scripted");
let third = runner.run(&CommandRequest::new("probe")).expect("scripted");
assert_eq!(first.exit_code(), Some(1));
assert_eq!(second.stdout_text(), "ready");
assert_eq!(third.stdout_text(), "ready");
}
#[test]
fn output_is_bounded_but_the_stream_is_still_drained() {
let (kept, truncated) = read_bounded(&b"0123456789"[..], 4);
assert_eq!(kept, b"0123");
assert!(truncated);
let (kept, truncated) = read_bounded(&b"012"[..], 4);
assert_eq!(kept, b"012");
assert!(!truncated);
}
#[test]
fn a_diagnostic_prefers_stderr_and_never_invents_one() {
let output = CommandOutput::exited(2, "some stdout", "the real reason");
assert_eq!(output.diagnostic(), "the real reason");
let output = CommandOutput::exited(2, "some stdout", "");
assert_eq!(output.diagnostic(), "some stdout");
let output = CommandOutput::exited(2, "", "");
assert_eq!(
output.diagnostic(),
"it exited with status 2 and said nothing"
);
assert_eq!(
CommandOutput::timed_out().diagnostic(),
"it did not finish before its deadline"
);
}
#[test]
fn cancellation_is_shared_by_every_clone() {
let cancellation = Cancellation::new();
let clone = cancellation.clone();
assert!(!clone.is_cancelled());
cancellation.cancel();
assert!(clone.is_cancelled());
}
fn this_test_binary() -> PathBuf {
std::env::current_exe().expect("a test binary knows its own path")
}
#[test]
fn the_host_runner_captures_output_and_an_exit_code() {
let output = HostCommandRunner
.run(
&CommandRequest::new(this_test_binary())
.arg("--list")
.with_timeout(Duration::from_secs(60)),
)
.expect("this binary can run itself");
assert_eq!(output.completion(), Completion::Exited);
assert_eq!(output.exit_code(), Some(0));
assert!(
output.stdout_text().contains("test"),
"`--list` should name at least one test: {}",
output.stdout_text()
);
}
#[test]
fn the_host_runner_reports_a_program_that_is_not_there() {
let error = HostCommandRunner
.run(&CommandRequest::new(
"runner-manager-a1-no-such-program-exists",
))
.expect_err("there is no such program");
assert!(matches!(error, WslError::Spawn { .. }), "{error:?}");
}
#[test]
fn the_host_runner_bounds_what_it_keeps() {
let output = HostCommandRunner
.run(
&CommandRequest::new(this_test_binary())
.arg("--list")
.with_limits(OutputLimits {
stdout: 8,
stderr: 8,
})
.with_timeout(Duration::from_secs(60)),
)
.expect("this binary can run itself");
assert!(output.stdout().len() <= 8);
assert!(output.stdout_truncated());
}
#[test]
fn the_host_runner_kills_a_child_that_outlives_its_deadline() {
let output = HostCommandRunner
.run(
&CommandRequest::new(this_test_binary())
.arg("--exact")
.arg("wsl::exec::tests::a_child_that_never_finishes")
.arg("--ignored")
.arg("--nocapture")
.with_timeout(Duration::from_millis(300)),
)
.expect("this binary can run itself");
assert_eq!(output.completion(), Completion::TimedOut);
}
#[test]
fn the_host_runner_kills_a_cancelled_child() {
let cancellation = Cancellation::new();
let flag = cancellation.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(200));
flag.cancel();
});
let output = HostCommandRunner
.run(
&CommandRequest::new(this_test_binary())
.arg("--exact")
.arg("wsl::exec::tests::a_child_that_never_finishes")
.arg("--ignored")
.arg("--nocapture")
.with_timeout(Duration::from_secs(60))
.with_cancellation(cancellation),
)
.expect("this binary can run itself");
assert_eq!(output.completion(), Completion::Cancelled);
}
#[test]
fn the_host_runner_writes_stdin_and_the_child_reads_it() {
let payload = b"a1-wsl-stdin-round-trip\n".to_vec();
let output = HostCommandRunner
.run(
&CommandRequest::new(this_test_binary())
.arg("--exact")
.arg("wsl::exec::tests::a_child_that_echoes_its_stdin")
.arg("--ignored")
.arg("--nocapture")
.with_input(ChildInput::Piped(PipedInput::from_bytes(payload)))
.with_timeout(Duration::from_secs(60)),
)
.expect("this binary can run itself");
assert!(
output.stdout_text().contains("a1-wsl-stdin-round-trip"),
"the child did not see the payload: {}",
output.stdout_text()
);
}
#[test]
#[ignore = "a helper child process, selected by name by the tests above"]
fn a_child_that_never_finishes() {
std::thread::sleep(Duration::from_secs(30));
}
#[test]
#[ignore = "a helper child process, selected by name by the test above"]
fn a_child_that_echoes_its_stdin() {
let mut text = String::new();
std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
.expect("the parent writes and closes the pipe");
println!("{text}");
}
}