Skip to main content

runner_manager_platform/wsl/
exec.rs

1// owner: a1-wsl-platform-adapter
2
3//! Running a program with a **literal argument vector**, bounded output, a
4//! deadline, a cancellation flag, and an optional anonymous stdin pipe.
5//!
6//! # Why this exists instead of `std::process::Command`
7//!
8//! Everything the WSL adapter does is "run `wsl.exe` with these exact
9//! arguments and read what it says". Three properties of that sentence are
10//! load-bearing, and none of them is `Command`'s default:
11//!
12//! 1. **There is no shell.** Not a `cmd /c`, not a `bash -c`, not a string that
13//!    something downstream re-splits. [`CommandRequest`] holds a program and a
14//!    `Vec<OsString>`, and that is the only shape it can hold — a distribution
15//!    named `Ubuntu & rm -rf /` is one argument, everywhere, by construction.
16//! 2. **Output is bounded.** `Command::output` reads until EOF. A hung child
17//!    writing to stderr in a loop would then be an unbounded allocation in a
18//!    service that is supposed to stay up. [`OutputLimits`] caps what is kept
19//!    while still draining the pipe, because a child that is not drained
20//!    blocks instead of finishing.
21//! 3. **The credential goes in through stdin and comes out nowhere.**
22//!    [`ChildInput::Piped`] holds its bytes in a [`secrecy::SecretBox`], its
23//!    `Debug` prints a length, and [`CommandRequest::refuse_payload_in_argv`]
24//!    refuses to launch at all if the payload is a verbatim substring of the
25//!    program path or of any argument. `03-security-and-lifecycle.md` item 3
26//!    says the document "is absent from argv, environment, provider records,
27//!    logs, errors, status JSON, temporary files and scheduled-task XML"; this
28//!    module is where the argv and environment halves of that are enforced
29//!    rather than reviewed.
30//!
31//! # The environment half is enforced by absence
32//!
33//! [`CommandRequest`] has **no** method that sets an environment variable, and
34//! [`HostCommandRunner`] makes no `env` call. That is deliberate and is the
35//! whole control: there is no API through which a caller could put a secret in
36//! the child's environment, so there is no code path to audit for one. The
37//! child inherits this process's environment unchanged, which is the same
38//! environment `wsl.exe` would have inherited from an operator's shell.
39//!
40//! # The seam
41//!
42//! [`CommandRunner`] is the injection point. Production uses
43//! [`HostCommandRunner`], which really spawns. Tests use [`ScriptedRunner`],
44//! which answers from a table and records every request — including the stdin
45//! bytes, so that a test can assert a canary reached the child's stdin *and
46//! nothing else*. Both are usable on every CI leg, which is what lets the
47//! Windows-shaped logic in this module be tested on Linux and macOS too.
48
49use std::ffi::{OsStr, OsString};
50use std::fmt;
51use std::io::{Read, Write};
52use std::path::{Path, PathBuf};
53use std::process::{Child, Command, Stdio};
54use std::sync::atomic::{AtomicBool, Ordering};
55use std::sync::{Arc, Mutex};
56use std::time::{Duration, Instant};
57
58use secrecy::{ExposeSecret, SecretBox, SecretString};
59
60use super::WslError;
61
62/// How long a probe-shaped command is given before it is killed.
63///
64/// Chosen against the slowest thing this adapter routinely asks for: the first
65/// `wsl.exe` invocation after a boot starts the whole distribution, and a cold
66/// start on a spinning disk is seconds, not milliseconds.
67pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(120);
68
69/// How much of stdout is kept.
70///
71/// Everything this adapter reads from a child is a table, a version string, or
72/// a task document; a megabyte is three orders of magnitude more than any of
73/// them and still small enough to be irrelevant to a service's footprint.
74pub const DEFAULT_STDOUT_LIMIT: usize = 1024 * 1024;
75
76/// How much of stderr is kept. Smaller, because its only use is a diagnostic
77/// sentence in an error.
78pub const DEFAULT_STDERR_LIMIT: usize = 64 * 1024;
79
80/// The largest payload [`CommandRequest::refuse_payload_in_argv`] scans for.
81///
82/// Above this there is nothing to check: Windows caps a command line at 32 767
83/// characters, so a payload larger than that cannot be in one. The bound
84/// matters because the artifact installer pipes a whole release archive
85/// through the same [`ChildInput`], and scanning fifteen megabytes against
86/// every argument on every install would be pure cost for a question whose
87/// answer is already known.
88const ARGV_SCAN_LIMIT: usize = 32 * 1024;
89
90/// How often the wait loop looks at a running child.
91const POLL_INTERVAL: Duration = Duration::from_millis(5);
92
93// ---------------------------------------------------------------------------
94// Cancellation
95// ---------------------------------------------------------------------------
96
97/// A flag a caller can raise to stop a running child early.
98///
99/// Deliberately not a channel or a future: the one caller is a synchronous
100/// provisioning transaction that wants to abandon a `wsl.exe` invocation when
101/// the operator hits Ctrl-C, and a shared boolean is the whole of that
102/// requirement. Cloning shares the flag.
103#[derive(Debug, Clone, Default)]
104pub struct Cancellation(Arc<AtomicBool>);
105
106impl Cancellation {
107    /// A flag that has not been raised.
108    #[must_use]
109    pub fn new() -> Self {
110        Self::default()
111    }
112
113    /// Raises it. Every request holding a clone is killed at the next poll.
114    pub fn cancel(&self) {
115        self.0.store(true, Ordering::SeqCst);
116    }
117
118    /// Whether it has been raised.
119    #[must_use]
120    pub fn is_cancelled(&self) -> bool {
121        self.0.load(Ordering::SeqCst)
122    }
123}
124
125// ---------------------------------------------------------------------------
126// The child's stdin
127// ---------------------------------------------------------------------------
128
129/// Bytes destined for a child's stdin, which never appear anywhere else.
130///
131/// The inner value is a [`SecretBox`] and the `Debug` implementation prints a
132/// length. That is unconditional rather than opt-in: the two things this
133/// adapter pipes are a GitHub credential document and a release archive, and
134/// treating both as sensitive costs nothing while removing the possibility
135/// that a future caller pipes a secret through the "not a secret" variant.
136pub struct PipedInput {
137    bytes: SecretBox<Vec<u8>>,
138    length: usize,
139}
140
141impl PipedInput {
142    /// Takes ownership of bytes to be written to the child.
143    #[must_use]
144    pub fn from_bytes(bytes: Vec<u8>) -> Self {
145        let length = bytes.len();
146        Self {
147            bytes: SecretBox::new(Box::new(bytes)),
148            length,
149        }
150    }
151
152    /// The UTF-8 encoding of a secret string, which is how a stored credential
153    /// document crosses the Windows/Linux boundary.
154    #[must_use]
155    pub fn from_secret_text(text: &SecretString) -> Self {
156        Self::from_bytes(text.expose_secret().as_bytes().to_vec())
157    }
158
159    /// How many bytes will be written. Safe to print; the bytes are not.
160    #[must_use]
161    pub fn len(&self) -> usize {
162        self.length
163    }
164
165    /// Whether there is nothing to write.
166    #[must_use]
167    pub fn is_empty(&self) -> bool {
168        self.length == 0
169    }
170
171    /// The bytes themselves.
172    ///
173    /// Crate-visible on purpose. Two callers need them — the runner that
174    /// writes the pipe and [`ScriptedRunner`], which records them so a test can
175    /// assert the payload arrived *here* and nowhere else — and neither is
176    /// outside this crate. A caller in another crate that wants to make the
177    /// same assertion goes through [`ScriptedRunner::piped_input`], which is
178    /// one documented door rather than a general accessor.
179    pub(crate) fn expose_bytes(&self) -> &[u8] {
180        self.bytes.expose_secret()
181    }
182}
183
184impl fmt::Debug for PipedInput {
185    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
186        write!(f, "PipedInput(<redacted; {} bytes>)", self.length)
187    }
188}
189
190/// What the child sees on stdin.
191#[derive(Debug, Default)]
192pub enum ChildInput {
193    /// An immediately closed pipe. A child that reads gets EOF.
194    #[default]
195    Empty,
196    /// An anonymous pipe carrying these bytes, then closed.
197    Piped(PipedInput),
198}
199
200impl ChildInput {
201    /// The payload, when there is one.
202    #[must_use]
203    pub fn piped(&self) -> Option<&PipedInput> {
204        match self {
205            Self::Empty => None,
206            Self::Piped(input) => Some(input),
207        }
208    }
209}
210
211// ---------------------------------------------------------------------------
212// The request
213// ---------------------------------------------------------------------------
214
215/// How much of each stream is kept.
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
217pub struct OutputLimits {
218    /// Bytes of stdout kept; the rest is drained and dropped.
219    pub stdout: usize,
220    /// Bytes of stderr kept.
221    pub stderr: usize,
222}
223
224impl Default for OutputLimits {
225    fn default() -> Self {
226        Self {
227            stdout: DEFAULT_STDOUT_LIMIT,
228            stderr: DEFAULT_STDERR_LIMIT,
229        }
230    }
231}
232
233/// One program, one literal argument vector, and the bounds it runs under.
234///
235/// There is no `env`, no `current_dir`, and no shell. See the module
236/// documentation for why each absence is a control rather than an omission.
237///
238/// There is no `Default` either, and that absence is the same kind of control:
239/// `Duration`'s default is zero, so a defaulted request would carry a deadline
240/// that has already passed and kill every child at the first poll.
241/// [`CommandRequest::new`] is the only way to build one, and it starts from
242/// [`DEFAULT_TIMEOUT`].
243pub struct CommandRequest {
244    program: PathBuf,
245    arguments: Vec<OsString>,
246    input: ChildInput,
247    limits: OutputLimits,
248    timeout: Duration,
249    cancellation: Option<Cancellation>,
250}
251
252impl CommandRequest {
253    /// A request to run `program` with no arguments.
254    #[must_use]
255    pub fn new(program: impl Into<PathBuf>) -> Self {
256        Self {
257            program: program.into(),
258            arguments: Vec::new(),
259            input: ChildInput::Empty,
260            limits: OutputLimits::default(),
261            timeout: DEFAULT_TIMEOUT,
262            cancellation: None,
263        }
264    }
265
266    /// Appends one argument, verbatim.
267    #[must_use]
268    pub fn arg(mut self, argument: impl Into<OsString>) -> Self {
269        self.arguments.push(argument.into());
270        self
271    }
272
273    /// Appends several arguments, verbatim and in order.
274    #[must_use]
275    pub fn args<I, S>(mut self, arguments: I) -> Self
276    where
277        I: IntoIterator<Item = S>,
278        S: Into<OsString>,
279    {
280        self.arguments.extend(arguments.into_iter().map(Into::into));
281        self
282    }
283
284    /// Gives the child something on stdin.
285    #[must_use]
286    pub fn with_input(mut self, input: ChildInput) -> Self {
287        self.input = input;
288        self
289    }
290
291    /// Replaces the default deadline.
292    #[must_use]
293    pub fn with_timeout(mut self, timeout: Duration) -> Self {
294        self.timeout = timeout;
295        self
296    }
297
298    /// Replaces the default capture bounds.
299    #[must_use]
300    pub fn with_limits(mut self, limits: OutputLimits) -> Self {
301        self.limits = limits;
302        self
303    }
304
305    /// Lets a caller kill this child before its deadline.
306    #[must_use]
307    pub fn with_cancellation(mut self, cancellation: Cancellation) -> Self {
308        self.cancellation = Some(cancellation);
309        self
310    }
311
312    /// The program that will be launched.
313    #[must_use]
314    pub fn program(&self) -> &Path {
315        &self.program
316    }
317
318    /// The argument vector, in order.
319    #[must_use]
320    pub fn arguments(&self) -> &[OsString] {
321        &self.arguments
322    }
323
324    /// The argument vector as lossy strings, for assertions and diagnostics.
325    #[must_use]
326    pub fn argument_strings(&self) -> Vec<String> {
327        self.arguments
328            .iter()
329            .map(|argument| argument.to_string_lossy().into_owned())
330            .collect()
331    }
332
333    /// What the child will see on stdin.
334    #[must_use]
335    pub fn input(&self) -> &ChildInput {
336        &self.input
337    }
338
339    /// The capture bounds.
340    #[must_use]
341    pub fn limits(&self) -> OutputLimits {
342        self.limits
343    }
344
345    /// The deadline.
346    #[must_use]
347    pub fn timeout(&self) -> Duration {
348        self.timeout
349    }
350
351    /// The cancellation flag, when one was attached.
352    #[must_use]
353    pub fn cancellation(&self) -> Option<&Cancellation> {
354        self.cancellation.as_ref()
355    }
356
357    /// Refuses the launch when the stdin payload is also in the command line.
358    ///
359    /// The same tripwire as [`crate::process::SpawnSpec::spawn_with_handoff`],
360    /// and with the same honest limits: it looks for the payload as a verbatim
361    /// byte substring of the program path and of each argument, and that is
362    /// all. A payload that is re-encoded, split across two arguments, or
363    /// normalised differently walks straight past it. It is here because an
364    /// *obvious* mistake should fail the launch rather than fail a review — not
365    /// because passing it is evidence of anything.
366    ///
367    /// Payloads larger than a Windows command line cannot be in one, so they
368    /// are not scanned.
369    ///
370    /// # Errors
371    ///
372    /// [`WslError::SecretInCommandLine`] naming where the payload was found.
373    pub fn refuse_payload_in_argv(&self) -> Result<(), WslError> {
374        let Some(payload) = self.input.piped() else {
375            return Ok(());
376        };
377        if payload.is_empty() || payload.len() > ARGV_SCAN_LIMIT {
378            return Ok(());
379        }
380        let needle = payload.expose_bytes();
381        let found_in = |value: &OsStr| {
382            let text = value.to_string_lossy();
383            contains_subslice(text.as_bytes(), needle)
384        };
385        if found_in(self.program.as_os_str()) {
386            return Err(WslError::SecretInCommandLine {
387                program: self.program.clone(),
388                location: "the program path".to_string(),
389            });
390        }
391        for (index, argument) in self.arguments.iter().enumerate() {
392            if found_in(argument) {
393                return Err(WslError::SecretInCommandLine {
394                    program: self.program.clone(),
395                    location: format!("argument {index}"),
396                });
397            }
398        }
399        Ok(())
400    }
401}
402
403/// `Debug` that cannot print the payload: [`PipedInput`]'s own `Debug` prints
404/// a length, and every other field is a program name, an argument, or a bound.
405impl fmt::Debug for CommandRequest {
406    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
407        f.debug_struct("CommandRequest")
408            .field("program", &self.program)
409            .field("arguments", &self.arguments)
410            .field("input", &self.input)
411            .field("limits", &self.limits)
412            .field("timeout", &self.timeout)
413            .finish_non_exhaustive()
414    }
415}
416
417/// Whether `haystack` contains `needle`.
418fn contains_subslice(haystack: &[u8], needle: &[u8]) -> bool {
419    if needle.is_empty() || needle.len() > haystack.len() {
420        return false;
421    }
422    haystack
423        .windows(needle.len())
424        .any(|window| window == needle)
425}
426
427// ---------------------------------------------------------------------------
428// The result
429// ---------------------------------------------------------------------------
430
431/// How a child stopped.
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433pub enum Completion {
434    /// It ran to the end by itself.
435    Exited,
436    /// Its deadline passed and it was killed.
437    TimedOut,
438    /// Its cancellation flag was raised and it was killed.
439    Cancelled,
440}
441
442/// What a child said and how it stopped.
443#[derive(Debug, Clone, PartialEq, Eq)]
444pub struct CommandOutput {
445    completion: Completion,
446    exit_code: Option<i32>,
447    stdout: Vec<u8>,
448    stderr: Vec<u8>,
449    stdout_truncated: bool,
450    stderr_truncated: bool,
451}
452
453impl CommandOutput {
454    /// A completed run, for a test double or for the real runner.
455    #[must_use]
456    pub fn exited(exit_code: i32, stdout: impl Into<Vec<u8>>, stderr: impl Into<Vec<u8>>) -> Self {
457        Self {
458            completion: Completion::Exited,
459            exit_code: Some(exit_code),
460            stdout: stdout.into(),
461            stderr: stderr.into(),
462            stdout_truncated: false,
463            stderr_truncated: false,
464        }
465    }
466
467    /// A run that hit its deadline.
468    #[must_use]
469    pub fn timed_out() -> Self {
470        Self {
471            completion: Completion::TimedOut,
472            exit_code: None,
473            stdout: Vec::new(),
474            stderr: Vec::new(),
475            stdout_truncated: false,
476            stderr_truncated: false,
477        }
478    }
479
480    /// Marks the captured streams as having been cut short.
481    #[must_use]
482    pub fn with_truncation(mut self, stdout: bool, stderr: bool) -> Self {
483        self.stdout_truncated = stdout;
484        self.stderr_truncated = stderr;
485        self
486    }
487
488    /// How it stopped.
489    #[must_use]
490    pub fn completion(&self) -> Completion {
491        self.completion
492    }
493
494    /// The exit code, when the process exited with one.
495    ///
496    /// `None` covers both a killed child and a Unix child that died of a
497    /// signal, which have no code to report.
498    #[must_use]
499    pub fn exit_code(&self) -> Option<i32> {
500        self.exit_code
501    }
502
503    /// Whether it exited by itself with status zero.
504    #[must_use]
505    pub fn success(&self) -> bool {
506        self.completion == Completion::Exited && self.exit_code == Some(0)
507    }
508
509    /// The captured stdout bytes.
510    #[must_use]
511    pub fn stdout(&self) -> &[u8] {
512        &self.stdout
513    }
514
515    /// The captured stderr bytes.
516    #[must_use]
517    pub fn stderr(&self) -> &[u8] {
518        &self.stderr
519    }
520
521    /// Whether stdout was longer than the limit.
522    #[must_use]
523    pub fn stdout_truncated(&self) -> bool {
524        self.stdout_truncated
525    }
526
527    /// Whether stderr was longer than the limit.
528    #[must_use]
529    pub fn stderr_truncated(&self) -> bool {
530        self.stderr_truncated
531    }
532
533    /// stdout decoded for a human, trimmed.
534    ///
535    /// Goes through [`super::discovery::decode_console_output`] rather than
536    /// `from_utf8_lossy`, because the Windows console programs this adapter
537    /// runs answer in UTF-16 as often as in UTF-8.
538    #[must_use]
539    pub fn stdout_text(&self) -> String {
540        super::discovery::decode_console_output(&self.stdout)
541            .into_text()
542            .trim()
543            .to_string()
544    }
545
546    /// stderr decoded for a human, trimmed.
547    #[must_use]
548    pub fn stderr_text(&self) -> String {
549        super::discovery::decode_console_output(&self.stderr)
550            .into_text()
551            .trim()
552            .to_string()
553    }
554
555    /// The sentence an error carries: whichever stream said something.
556    #[must_use]
557    pub fn diagnostic(&self) -> String {
558        let stderr = self.stderr_text();
559        if !stderr.is_empty() {
560            return stderr;
561        }
562        let stdout = self.stdout_text();
563        if !stdout.is_empty() {
564            return stdout;
565        }
566        match self.completion {
567            Completion::Exited => match self.exit_code {
568                Some(code) => format!("it exited with status {code} and said nothing"),
569                None => "it was terminated and said nothing".to_string(),
570            },
571            Completion::TimedOut => "it did not finish before its deadline".to_string(),
572            Completion::Cancelled => "it was cancelled".to_string(),
573        }
574    }
575}
576
577// ---------------------------------------------------------------------------
578// The seam
579// ---------------------------------------------------------------------------
580
581/// Runs a [`CommandRequest`].
582///
583/// The whole reason the WSL adapter is testable on a Linux CI leg: production
584/// injects [`HostCommandRunner`], every test injects [`ScriptedRunner`], and
585/// nothing above this trait knows the difference.
586pub trait CommandRunner: fmt::Debug + Send + Sync {
587    /// Runs it, or says why it could not be started.
588    ///
589    /// A non-zero exit is **not** an error here — it is a [`CommandOutput`]
590    /// with a code. Only a failure to launch, a refused payload, or an
591    /// operating-system failure while waiting is an `Err`.
592    ///
593    /// # Errors
594    ///
595    /// [`WslError::Spawn`], [`WslError::SecretInCommandLine`], or
596    /// [`WslError::ChildControl`].
597    fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError>;
598}
599
600// ---------------------------------------------------------------------------
601// The real runner
602// ---------------------------------------------------------------------------
603
604/// Really spawns the program.
605#[derive(Debug, Clone, Copy, Default)]
606pub struct HostCommandRunner;
607
608impl CommandRunner for HostCommandRunner {
609    fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError> {
610        request.refuse_payload_in_argv()?;
611
612        // No `.env()`, no `.env_clear()`, no `.current_dir()`. See the module
613        // documentation: the absence is the control.
614        let mut command = Command::new(request.program());
615        command
616            .args(request.arguments())
617            .stdin(match request.input() {
618                ChildInput::Empty => Stdio::null(),
619                ChildInput::Piped(_) => Stdio::piped(),
620            })
621            .stdout(Stdio::piped())
622            .stderr(Stdio::piped());
623
624        let mut child = command.spawn().map_err(|source| WslError::Spawn {
625            program: request.program().to_path_buf(),
626            source,
627        })?;
628
629        let stdin = child.stdin.take();
630        let stdout = child
631            .stdout
632            .take()
633            .expect("stdout was piped when the child was configured");
634        let stderr = child
635            .stderr
636            .take()
637            .expect("stderr was piped when the child was configured");
638        let limits = request.limits();
639
640        // Scoped threads rather than spawned ones so that the payload is
641        // *borrowed* by the writer: moving it would mean a second copy of a
642        // credential on the heap for the lifetime of the call.
643        let (waited, out, err) = std::thread::scope(|scope| {
644            let writer = scope.spawn(move || write_input(stdin, request.input()));
645            let out = scope.spawn(move || read_bounded(stdout, limits.stdout));
646            let err = scope.spawn(move || read_bounded(stderr, limits.stderr));
647            let waited = wait_for(&mut child, request.timeout(), request.cancellation());
648            // A write that failed because the child stopped reading is the
649            // ordinary shape of a refused handoff, and the child's own exit
650            // status is the better diagnostic. It is dropped rather than
651            // reported for that reason.
652            drop(writer.join());
653            (
654                waited,
655                out.join().unwrap_or_else(|_| (Vec::new(), false)),
656                err.join().unwrap_or_else(|_| (Vec::new(), false)),
657            )
658        });
659
660        let (completion, exit_code) = waited.map_err(|source| WslError::ChildControl {
661            program: request.program().to_path_buf(),
662            source,
663        })?;
664
665        Ok(CommandOutput {
666            completion,
667            exit_code,
668            stdout: out.0,
669            stderr: err.0,
670            stdout_truncated: out.1,
671            stderr_truncated: err.1,
672        })
673    }
674}
675
676/// Writes the payload and closes the pipe, so a child blocked on EOF proceeds.
677fn write_input(stdin: Option<std::process::ChildStdin>, input: &ChildInput) -> std::io::Result<()> {
678    let Some(mut pipe) = stdin else {
679        return Ok(());
680    };
681    if let Some(payload) = input.piped() {
682        pipe.write_all(payload.expose_bytes())?;
683        pipe.flush()?;
684    }
685    drop(pipe);
686    Ok(())
687}
688
689/// Reads to EOF, keeping at most `limit` bytes.
690///
691/// It keeps reading after the limit rather than stopping: a child whose pipe
692/// is full blocks in `write`, so a reader that gives up early converts a
693/// chatty child into a hung one.
694fn read_bounded(mut source: impl Read, limit: usize) -> (Vec<u8>, bool) {
695    let mut kept: Vec<u8> = Vec::new();
696    let mut truncated = false;
697    let mut buffer = [0_u8; 8192];
698    loop {
699        match source.read(&mut buffer) {
700            Ok(0) => break,
701            Ok(read) => {
702                let room = limit.saturating_sub(kept.len());
703                if room > 0 {
704                    kept.extend_from_slice(&buffer[..read.min(room)]);
705                }
706                if read > room {
707                    truncated = true;
708                }
709            }
710            Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
711            Err(_) => break,
712        }
713    }
714    (kept, truncated)
715}
716
717/// Waits for the child, killing it on its deadline or on cancellation.
718fn wait_for(
719    child: &mut Child,
720    timeout: Duration,
721    cancellation: Option<&Cancellation>,
722) -> std::io::Result<(Completion, Option<i32>)> {
723    let deadline = Instant::now().checked_add(timeout);
724    loop {
725        if let Some(status) = child.try_wait()? {
726            return Ok((Completion::Exited, status.code()));
727        }
728        if cancellation.is_some_and(Cancellation::is_cancelled) {
729            child.kill()?;
730            let status = child.wait()?;
731            return Ok((Completion::Cancelled, status.code()));
732        }
733        if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
734            child.kill()?;
735            let status = child.wait()?;
736            return Ok((Completion::TimedOut, status.code()));
737        }
738        std::thread::sleep(POLL_INTERVAL);
739    }
740}
741
742// ---------------------------------------------------------------------------
743// The test double
744// ---------------------------------------------------------------------------
745
746/// One request as [`ScriptedRunner`] saw it.
747///
748/// The stdin bytes are held verbatim. That is the point: a security test
749/// injects a canary, runs the adapter, and asserts the canary is in
750/// [`RecordedRequest::stdin`] and in no argument, no rendered document, and no
751/// file on disk.
752#[derive(Debug, Clone, PartialEq, Eq)]
753pub struct RecordedRequest {
754    /// The program that would have been launched.
755    pub program: PathBuf,
756    /// The literal argument vector.
757    pub arguments: Vec<String>,
758    /// What would have been written to the child's stdin.
759    pub stdin: Vec<u8>,
760    /// The deadline it would have run under.
761    pub timeout: Duration,
762}
763
764impl RecordedRequest {
765    /// The program and arguments joined by a single space, for a match rule.
766    #[must_use]
767    pub fn command_line(&self) -> String {
768        let mut line = self.program.to_string_lossy().into_owned();
769        for argument in &self.arguments {
770            line.push(' ');
771            line.push_str(argument);
772        }
773        line
774    }
775}
776
777/// A [`CommandRunner`] that answers from a table and records what it was asked.
778///
779/// Match rules are checked in the order they were added, against the
780/// space-joined command line, as a substring. The first rule that matches
781/// answers; an unmatched request gets [`ScriptedRunner::default_response`],
782/// which starts as a successful, silent exit.
783#[derive(Debug, Default)]
784pub struct ScriptedRunner {
785    rules: Mutex<Vec<Rule>>,
786    recorded: Mutex<Vec<RecordedRequest>>,
787    default_response: Mutex<Option<CommandOutput>>,
788}
789
790#[derive(Debug)]
791struct Rule {
792    contains: String,
793    responses: Vec<CommandOutput>,
794    used: usize,
795}
796
797impl ScriptedRunner {
798    /// A runner whose every answer is a silent success.
799    #[must_use]
800    pub fn new() -> Self {
801        Self::default()
802    }
803
804    /// Answers any request whose command line contains `contains` with
805    /// `response`, for every match.
806    #[must_use]
807    pub fn always(self, contains: &str, response: CommandOutput) -> Self {
808        self.push_rule(contains, vec![response]);
809        self
810    }
811
812    /// Answers successive matches of `contains` with successive responses, and
813    /// repeats the last one once the list is exhausted.
814    #[must_use]
815    pub fn sequence(self, contains: &str, responses: Vec<CommandOutput>) -> Self {
816        self.push_rule(contains, responses);
817        self
818    }
819
820    /// Replaces the answer given to a request no rule matched.
821    #[must_use]
822    pub fn otherwise(self, response: CommandOutput) -> Self {
823        *self
824            .default_response
825            .lock()
826            .expect("the scripted runner's response is not shared across a panic") = Some(response);
827        self
828    }
829
830    fn push_rule(&self, contains: &str, responses: Vec<CommandOutput>) {
831        self.rules
832            .lock()
833            .expect("the scripted runner's rules are not shared across a panic")
834            .push(Rule {
835                contains: contains.to_string(),
836                responses,
837                used: 0,
838            });
839    }
840
841    /// Every request, in the order it was made.
842    #[must_use]
843    pub fn recorded(&self) -> Vec<RecordedRequest> {
844        self.recorded
845            .lock()
846            .expect("the scripted runner's log is not shared across a panic")
847            .clone()
848    }
849
850    /// How many requests were made.
851    #[must_use]
852    pub fn call_count(&self) -> usize {
853        self.recorded
854            .lock()
855            .expect("the scripted runner's log is not shared across a panic")
856            .len()
857    }
858
859    /// The command lines, joined, for a coarse assertion.
860    #[must_use]
861    pub fn command_lines(&self) -> Vec<String> {
862        self.recorded()
863            .iter()
864            .map(RecordedRequest::command_line)
865            .collect()
866    }
867
868    /// Everything that was written to a child's stdin, concatenated.
869    ///
870    /// The documented door for a security test in another crate: it is the one
871    /// place a piped payload can be read back, so an assertion that a canary is
872    /// *here and nowhere else* has exactly one thing to look at.
873    #[must_use]
874    pub fn piped_input(&self) -> Vec<u8> {
875        let mut all = Vec::new();
876        for request in self.recorded() {
877            all.extend_from_slice(&request.stdin);
878        }
879        all
880    }
881}
882
883impl CommandRunner for ScriptedRunner {
884    fn run(&self, request: &CommandRequest) -> Result<CommandOutput, WslError> {
885        request.refuse_payload_in_argv()?;
886        let recorded = RecordedRequest {
887            program: request.program().to_path_buf(),
888            arguments: request.argument_strings(),
889            stdin: request
890                .input()
891                .piped()
892                .map(|input| input.expose_bytes().to_vec())
893                .unwrap_or_default(),
894            timeout: request.timeout(),
895        };
896        let line = recorded.command_line();
897        self.recorded
898            .lock()
899            .expect("the scripted runner's log is not shared across a panic")
900            .push(recorded);
901
902        let mut rules = self
903            .rules
904            .lock()
905            .expect("the scripted runner's rules are not shared across a panic");
906        for rule in rules.iter_mut() {
907            if line.contains(&rule.contains) {
908                let index = rule.used.min(rule.responses.len().saturating_sub(1));
909                rule.used += 1;
910                if let Some(response) = rule.responses.get(index) {
911                    return Ok(response.clone());
912                }
913            }
914        }
915        drop(rules);
916
917        Ok(self
918            .default_response
919            .lock()
920            .expect("the scripted runner's response is not shared across a panic")
921            .clone()
922            .unwrap_or_else(|| CommandOutput::exited(0, Vec::new(), Vec::new())))
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929
930    fn canary() -> SecretString {
931        SecretString::from(format!("{}{}", "ghu_", "a1WslFixtureNotARealCredential00"))
932    }
933
934    #[test]
935    fn a_piped_payload_never_appears_in_debug_output() {
936        let secret = canary();
937        let request = CommandRequest::new("wsl.exe")
938            .arg("--distribution")
939            .arg("Ubuntu")
940            .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
941
942        let printed = format!("{request:?}");
943        assert!(
944            !printed.contains(secret.expose_secret()),
945            "the payload reached Debug output: {printed}"
946        );
947        assert!(
948            printed.contains("<redacted; 36 bytes>"),
949            "the redacted form should still say how much there was: {printed}"
950        );
951    }
952
953    #[test]
954    fn a_payload_that_is_also_an_argument_refuses_to_launch() {
955        let secret = canary();
956        let request = CommandRequest::new("wsl.exe")
957            .arg("--exec")
958            .arg(format!("--token={}", secret.expose_secret()))
959            .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
960
961        let error = request
962            .refuse_payload_in_argv()
963            .expect_err("the payload is in argument 1");
964        assert!(
965            matches!(&error, WslError::SecretInCommandLine { location, .. } if location == "argument 1"),
966            "unexpected error: {error:?}"
967        );
968        // And the refusal itself must not quote the payload.
969        assert!(!error.to_string().contains(secret.expose_secret()));
970    }
971
972    #[test]
973    fn a_payload_that_is_only_on_stdin_is_allowed() {
974        let secret = canary();
975        let request = CommandRequest::new("wsl.exe")
976            .arg("--distribution")
977            .arg("Ubuntu")
978            .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret)));
979        request
980            .refuse_payload_in_argv()
981            .expect("stdin is the supported channel");
982    }
983
984    #[test]
985    fn a_payload_too_large_for_a_command_line_is_not_scanned() {
986        // A release archive goes through the same pipe; scanning it would cost
987        // megabytes of comparison to answer a question Windows already answers.
988        let request = CommandRequest::new("wsl.exe")
989            .arg("--exec")
990            .with_input(ChildInput::Piped(PipedInput::from_bytes(vec![
991                b'x';
992                ARGV_SCAN_LIMIT
993                    + 1
994            ])));
995        request.refuse_payload_in_argv().expect("not scanned");
996    }
997
998    #[test]
999    fn arguments_are_kept_verbatim_and_never_joined() {
1000        let request = CommandRequest::new("wsl.exe")
1001            .arg("--distribution")
1002            .arg("Ubuntu & shutdown /s")
1003            .arg("--exec");
1004        assert_eq!(
1005            request.argument_strings(),
1006            vec![
1007                "--distribution".to_string(),
1008                "Ubuntu & shutdown /s".to_string(),
1009                "--exec".to_string(),
1010            ]
1011        );
1012    }
1013
1014    #[test]
1015    fn a_scripted_runner_answers_in_rule_order_and_records_stdin() {
1016        let secret = canary();
1017        let runner = ScriptedRunner::new()
1018            .always(
1019                "--version",
1020                CommandOutput::exited(0, "runner-manager 0.4.0", ""),
1021            )
1022            .otherwise(CommandOutput::exited(1, "", "no rule"));
1023
1024        let versioned = runner
1025            .run(&CommandRequest::new("wsl.exe").arg("--version"))
1026            .expect("scripted");
1027        assert_eq!(versioned.stdout_text(), "runner-manager 0.4.0");
1028
1029        let other = runner
1030            .run(
1031                &CommandRequest::new("wsl.exe")
1032                    .arg("--exec")
1033                    .with_input(ChildInput::Piped(PipedInput::from_secret_text(&secret))),
1034            )
1035            .expect("scripted");
1036        assert_eq!(other.exit_code(), Some(1));
1037
1038        assert_eq!(runner.call_count(), 2);
1039        assert_eq!(runner.piped_input(), secret.expose_secret().as_bytes());
1040        assert!(
1041            runner
1042                .command_lines()
1043                .iter()
1044                .all(|line| !line.contains(secret.expose_secret())),
1045            "the canary must not be in any recorded command line"
1046        );
1047    }
1048
1049    #[test]
1050    fn a_sequence_rule_advances_and_then_repeats_its_last_answer() {
1051        let runner = ScriptedRunner::new().sequence(
1052            "probe",
1053            vec![
1054                CommandOutput::exited(1, "", "not yet"),
1055                CommandOutput::exited(0, "ready", ""),
1056            ],
1057        );
1058        let first = runner.run(&CommandRequest::new("probe")).expect("scripted");
1059        let second = runner.run(&CommandRequest::new("probe")).expect("scripted");
1060        let third = runner.run(&CommandRequest::new("probe")).expect("scripted");
1061        assert_eq!(first.exit_code(), Some(1));
1062        assert_eq!(second.stdout_text(), "ready");
1063        assert_eq!(third.stdout_text(), "ready");
1064    }
1065
1066    #[test]
1067    fn output_is_bounded_but_the_stream_is_still_drained() {
1068        let (kept, truncated) = read_bounded(&b"0123456789"[..], 4);
1069        assert_eq!(kept, b"0123");
1070        assert!(truncated);
1071
1072        let (kept, truncated) = read_bounded(&b"012"[..], 4);
1073        assert_eq!(kept, b"012");
1074        assert!(!truncated);
1075    }
1076
1077    #[test]
1078    fn a_diagnostic_prefers_stderr_and_never_invents_one() {
1079        let output = CommandOutput::exited(2, "some stdout", "the real reason");
1080        assert_eq!(output.diagnostic(), "the real reason");
1081
1082        let output = CommandOutput::exited(2, "some stdout", "");
1083        assert_eq!(output.diagnostic(), "some stdout");
1084
1085        let output = CommandOutput::exited(2, "", "");
1086        assert_eq!(
1087            output.diagnostic(),
1088            "it exited with status 2 and said nothing"
1089        );
1090
1091        assert_eq!(
1092            CommandOutput::timed_out().diagnostic(),
1093            "it did not finish before its deadline"
1094        );
1095    }
1096
1097    #[test]
1098    fn cancellation_is_shared_by_every_clone() {
1099        let cancellation = Cancellation::new();
1100        let clone = cancellation.clone();
1101        assert!(!clone.is_cancelled());
1102        cancellation.cancel();
1103        assert!(clone.is_cancelled());
1104    }
1105
1106    // -- The real runner, exercised against a program every CI leg has -------
1107    //
1108    // `HostCommandRunner` is the one thing here that cannot be proven with a
1109    // double, so it is proven against this very test binary: `std::env::args`
1110    // gives a program that certainly exists on all three platforms, and the
1111    // harness's own `--list` flag makes it exit quickly with output.
1112
1113    fn this_test_binary() -> PathBuf {
1114        std::env::current_exe().expect("a test binary knows its own path")
1115    }
1116
1117    #[test]
1118    fn the_host_runner_captures_output_and_an_exit_code() {
1119        let output = HostCommandRunner
1120            .run(
1121                &CommandRequest::new(this_test_binary())
1122                    .arg("--list")
1123                    .with_timeout(Duration::from_secs(60)),
1124            )
1125            .expect("this binary can run itself");
1126        assert_eq!(output.completion(), Completion::Exited);
1127        assert_eq!(output.exit_code(), Some(0));
1128        assert!(
1129            output.stdout_text().contains("test"),
1130            "`--list` should name at least one test: {}",
1131            output.stdout_text()
1132        );
1133    }
1134
1135    #[test]
1136    fn the_host_runner_reports_a_program_that_is_not_there() {
1137        let error = HostCommandRunner
1138            .run(&CommandRequest::new(
1139                "runner-manager-a1-no-such-program-exists",
1140            ))
1141            .expect_err("there is no such program");
1142        assert!(matches!(error, WslError::Spawn { .. }), "{error:?}");
1143    }
1144
1145    #[test]
1146    fn the_host_runner_bounds_what_it_keeps() {
1147        let output = HostCommandRunner
1148            .run(
1149                &CommandRequest::new(this_test_binary())
1150                    .arg("--list")
1151                    .with_limits(OutputLimits {
1152                        stdout: 8,
1153                        stderr: 8,
1154                    })
1155                    .with_timeout(Duration::from_secs(60)),
1156            )
1157            .expect("this binary can run itself");
1158        assert!(output.stdout().len() <= 8);
1159        assert!(output.stdout_truncated());
1160    }
1161
1162    #[test]
1163    fn the_host_runner_kills_a_child_that_outlives_its_deadline() {
1164        // `--test-threads` with no value makes libtest wait on stdin? No: it
1165        // errors out. The reliable "runs forever" program on all three
1166        // platforms is this binary running the sleeping test below, which is
1167        // `#[ignore]`d in an ordinary run and selected by name here.
1168        let output = HostCommandRunner
1169            .run(
1170                &CommandRequest::new(this_test_binary())
1171                    .arg("--exact")
1172                    .arg("wsl::exec::tests::a_child_that_never_finishes")
1173                    .arg("--ignored")
1174                    .arg("--nocapture")
1175                    .with_timeout(Duration::from_millis(300)),
1176            )
1177            .expect("this binary can run itself");
1178        assert_eq!(output.completion(), Completion::TimedOut);
1179    }
1180
1181    #[test]
1182    fn the_host_runner_kills_a_cancelled_child() {
1183        let cancellation = Cancellation::new();
1184        let flag = cancellation.clone();
1185        std::thread::spawn(move || {
1186            std::thread::sleep(Duration::from_millis(200));
1187            flag.cancel();
1188        });
1189        let output = HostCommandRunner
1190            .run(
1191                &CommandRequest::new(this_test_binary())
1192                    .arg("--exact")
1193                    .arg("wsl::exec::tests::a_child_that_never_finishes")
1194                    .arg("--ignored")
1195                    .arg("--nocapture")
1196                    .with_timeout(Duration::from_secs(60))
1197                    .with_cancellation(cancellation),
1198            )
1199            .expect("this binary can run itself");
1200        assert_eq!(output.completion(), Completion::Cancelled);
1201    }
1202
1203    #[test]
1204    fn the_host_runner_writes_stdin_and_the_child_reads_it() {
1205        // The child is this binary again, selected onto the echoing test below.
1206        let payload = b"a1-wsl-stdin-round-trip\n".to_vec();
1207        let output = HostCommandRunner
1208            .run(
1209                &CommandRequest::new(this_test_binary())
1210                    .arg("--exact")
1211                    .arg("wsl::exec::tests::a_child_that_echoes_its_stdin")
1212                    .arg("--ignored")
1213                    .arg("--nocapture")
1214                    .with_input(ChildInput::Piped(PipedInput::from_bytes(payload)))
1215                    .with_timeout(Duration::from_secs(60)),
1216            )
1217            .expect("this binary can run itself");
1218        assert!(
1219            output.stdout_text().contains("a1-wsl-stdin-round-trip"),
1220            "the child did not see the payload: {}",
1221            output.stdout_text()
1222        );
1223    }
1224
1225    /// Not a test: the child program the deadline and cancellation tests need.
1226    ///
1227    /// Bounded at thirty seconds rather than left to sleep forever. Nothing
1228    /// selects it except the two tests above, both of which kill it in well
1229    /// under a second — but a stray `cargo test -- --ignored` should cost half
1230    /// a minute rather than hang a developer's terminal.
1231    #[test]
1232    #[ignore = "a helper child process, selected by name by the tests above"]
1233    fn a_child_that_never_finishes() {
1234        std::thread::sleep(Duration::from_secs(30));
1235    }
1236
1237    /// Not a test: the child program the stdin test needs.
1238    #[test]
1239    #[ignore = "a helper child process, selected by name by the test above"]
1240    fn a_child_that_echoes_its_stdin() {
1241        let mut text = String::new();
1242        std::io::Read::read_to_string(&mut std::io::stdin(), &mut text)
1243            .expect("the parent writes and closes the pipe");
1244        println!("{text}");
1245    }
1246}