Skip to main content

perl_subprocess_runtime/
lib.rs

1//! Subprocess execution abstraction for provider purity
2//!
3//! This crate provides a trait-based abstraction for subprocess execution,
4//! enabling testing with mock implementations and WASM compatibility.
5
6#![deny(unsafe_code)]
7#![cfg_attr(test, allow(clippy::panic, clippy::unwrap_used, clippy::expect_used))]
8#![warn(rust_2018_idioms)]
9#![warn(missing_docs)]
10#![warn(clippy::all)]
11
12use std::fmt;
13#[cfg(all(not(target_arch = "wasm32"), windows))]
14use std::path::Path;
15
16/// Output from a subprocess execution
17#[derive(Debug, Clone)]
18pub struct SubprocessOutput {
19    /// Standard output bytes
20    pub stdout: Vec<u8>,
21    /// Standard error bytes
22    pub stderr: Vec<u8>,
23    /// Exit status code (0 typically indicates success)
24    pub status_code: i32,
25}
26
27impl SubprocessOutput {
28    /// Returns true if the subprocess exited successfully (status code 0)
29    pub fn success(&self) -> bool {
30        self.status_code == 0
31    }
32
33    /// Returns stdout as a UTF-8 string, lossy converting invalid bytes
34    pub fn stdout_lossy(&self) -> String {
35        String::from_utf8_lossy(&self.stdout).into_owned()
36    }
37
38    /// Returns stderr as a UTF-8 string, lossy converting invalid bytes
39    pub fn stderr_lossy(&self) -> String {
40        String::from_utf8_lossy(&self.stderr).into_owned()
41    }
42}
43
44/// Error type for subprocess execution failures
45#[derive(Debug, Clone)]
46pub struct SubprocessError {
47    /// Human-readable error message
48    pub message: String,
49}
50
51impl SubprocessError {
52    /// Create a new subprocess error with the given message
53    pub fn new(message: impl Into<String>) -> Self {
54        Self { message: message.into() }
55    }
56}
57
58impl fmt::Display for SubprocessError {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        write!(f, "{}", self.message)
61    }
62}
63
64impl std::error::Error for SubprocessError {}
65
66/// Abstraction trait for subprocess execution.
67pub trait SubprocessRuntime: Send + Sync {
68    /// Execute a command with the given arguments and optional stdin.
69    fn run_command(
70        &self,
71        program: &str,
72        args: &[&str],
73        stdin: Option<&[u8]>,
74    ) -> Result<SubprocessOutput, SubprocessError>;
75}
76
77/// Default implementation using `std::process::Command`.
78#[cfg(not(target_arch = "wasm32"))]
79pub struct OsSubprocessRuntime {
80    timeout_secs: Option<u64>,
81}
82
83#[cfg(not(target_arch = "wasm32"))]
84impl OsSubprocessRuntime {
85    /// Create a new OS subprocess runtime with no timeout.
86    pub fn new() -> Self {
87        Self { timeout_secs: None }
88    }
89
90    /// Create a new OS subprocess runtime with the given wall-clock timeout.
91    ///
92    /// If the subprocess does not complete within `timeout_secs` seconds the
93    /// call returns a `SubprocessError` with a "timed out" message and attempts
94    /// to terminate the spawned process before returning.
95    ///
96    /// # Stdin size caveat
97    ///
98    /// Stdin data is written synchronously before the timeout poll loop begins.
99    /// If the subprocess hangs before consuming stdin and the data exceeds the
100    /// OS pipe buffer (~64 KiB on Linux), `run_command` will block in the write
101    /// phase and the timeout will not fire.  For typical Perl source files this
102    /// is not a concern.
103    ///
104    /// # Panics
105    ///
106    /// Panics if `timeout_secs` is zero (a zero-second timeout would time out
107    /// every command immediately and is almost certainly a caller bug).
108    pub fn with_timeout(timeout_secs: u64) -> Self {
109        assert!(timeout_secs > 0, "timeout_secs must be greater than zero");
110        Self { timeout_secs: Some(timeout_secs) }
111    }
112}
113
114#[cfg(not(target_arch = "wasm32"))]
115impl Default for OsSubprocessRuntime {
116    fn default() -> Self {
117        Self::new()
118    }
119}
120
121#[cfg(not(target_arch = "wasm32"))]
122impl SubprocessRuntime for OsSubprocessRuntime {
123    fn run_command(
124        &self,
125        program: &str,
126        args: &[&str],
127        stdin: Option<&[u8]>,
128    ) -> Result<SubprocessOutput, SubprocessError> {
129        use std::io::Write;
130        use std::process::{Command, Stdio};
131
132        validate_command_input(program, args)?;
133
134        let (resolved_program, resolved_args) = resolve_command_invocation(program, args);
135        let mut cmd = Command::new(&resolved_program);
136        cmd.args(resolved_args.iter().map(String::as_str));
137
138        if stdin.is_some() {
139            cmd.stdin(Stdio::piped());
140        }
141
142        cmd.stdout(Stdio::piped());
143        cmd.stderr(Stdio::piped());
144
145        let mut child = cmd
146            .spawn()
147            .map_err(|e| SubprocessError::new(format!("Failed to start {}: {}", program, e)))?;
148
149        if let Some(input) = stdin
150            && let Some(mut child_stdin) = child.stdin.take()
151        {
152            child_stdin.write_all(input).map_err(|e| {
153                SubprocessError::new(format!("Failed to write to {} stdin: {}", program, e))
154            })?;
155        }
156
157        match self.timeout_secs {
158            None => {
159                let output = child.wait_with_output().map_err(|e| {
160                    SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
161                })?;
162                Ok(SubprocessOutput {
163                    stdout: output.stdout,
164                    stderr: output.stderr,
165                    status_code: output.status.code().unwrap_or(-1),
166                })
167            }
168            Some(secs) => {
169                use std::time::{Duration, Instant};
170
171                let deadline = Instant::now() + Duration::from_secs(secs);
172                loop {
173                    if child
174                        .try_wait()
175                        .map_err(|e| {
176                            SubprocessError::new(format!("Failed to poll {}: {}", program, e))
177                        })?
178                        .is_some()
179                    {
180                        let output = child.wait_with_output().map_err(|e| {
181                            SubprocessError::new(format!("Failed to wait for {}: {}", program, e))
182                        })?;
183                        return Ok(SubprocessOutput {
184                            stdout: output.stdout,
185                            stderr: output.stderr,
186                            status_code: output.status.code().unwrap_or(-1),
187                        });
188                    }
189
190                    if Instant::now() >= deadline {
191                        if let Err(kill_err) = child.kill() {
192                            // Best effort: process may have already exited between `try_wait`
193                            // and `kill`.
194                            let already_exited = child
195                                .try_wait()
196                                .map_err(|e| {
197                                    SubprocessError::new(format!(
198                                        "Failed to poll {}: {}",
199                                        program, e
200                                    ))
201                                })?
202                                .is_some();
203                            if !already_exited {
204                                return Err(SubprocessError::new(format!(
205                                    "subprocess timed out after {} seconds and failed to terminate {}: {}",
206                                    secs, program, kill_err
207                                )));
208                            }
209                        }
210                        let _ = child.wait();
211                        return Err(SubprocessError::new(format!(
212                            "subprocess timed out after {} seconds",
213                            secs
214                        )));
215                    }
216
217                    std::thread::sleep(Duration::from_millis(50));
218                }
219            }
220        }
221    }
222}
223
224#[cfg(not(target_arch = "wasm32"))]
225fn validate_command_input(program: &str, args: &[&str]) -> Result<(), SubprocessError> {
226    if program.trim().is_empty() {
227        return Err(SubprocessError::new("program name must not be empty"));
228    }
229    if program.contains('\0') {
230        return Err(SubprocessError::new("program name must not contain NUL bytes"));
231    }
232    if args.iter().any(|arg| arg.contains('\0')) {
233        return Err(SubprocessError::new("arguments must not contain NUL bytes"));
234    }
235    Ok(())
236}
237
238#[cfg(not(target_arch = "wasm32"))]
239fn resolve_command_invocation(program: &str, args: &[&str]) -> (String, Vec<String>) {
240    #[cfg(windows)]
241    {
242        let resolved_program =
243            resolve_windows_program(program).unwrap_or_else(|| program.to_string());
244
245        if windows_requires_cmd_shell(&resolved_program) {
246            let command_line = std::iter::once(resolved_program.as_str())
247                .chain(args.iter().copied())
248                .map(windows_quote_for_cmd)
249                .collect::<Vec<_>>()
250                .join(" ");
251            // /D  – disable AutoRun registry commands.
252            // /V:OFF – disable delayed expansion so that !VAR! patterns in
253            //          arguments are not expanded even when the caller's
254            //          environment has delayed expansion enabled.
255            // /S  – strip the outer quotes from the /C argument and re-parse
256            //        the remainder, which lets each individual token retain its
257            //        own double-quoting.
258            let shell_args = vec![
259                "/D".to_string(),
260                "/V:OFF".to_string(),
261                "/S".to_string(),
262                "/C".to_string(),
263                command_line,
264            ];
265            return ("cmd.exe".to_string(), shell_args);
266        }
267
268        (resolved_program, args.iter().map(|arg| (*arg).to_string()).collect())
269    }
270
271    #[cfg(not(windows))]
272    {
273        (program.to_string(), args.iter().map(|arg| (*arg).to_string()).collect())
274    }
275}
276
277/// Quote a single argument for use inside a `cmd.exe /V:OFF /S /C "..."` command line.
278///
279/// ## cmd.exe quoting rules inside double-quoted regions
280///
281/// Once cmd.exe sees an opening `"` it enters a quoted region.  Inside that region:
282///
283/// - Shell metacharacters (`&`, `|`, `<`, `>`, `(`, `)`) are **literal** — no
284///   `^` prefix is needed or correct.  Inserting `^` before them would deliver
285///   a spurious `^` character to the program for every argument that legitimately
286///   contains one of these characters.
287/// - `^` itself is **literal** inside a quoted region.  It is NOT an escape prefix,
288///   so it must not be doubled.  The original PR doubled it, which corrupted any
289///   argument containing a `^` (e.g. `C:\tools\my^profile.txt` became
290///   `C:\tools\my^^profile.txt`).
291/// - `%` is still processed by the variable-substitution pass, which runs before
292///   the shell-metachar pass and is not suppressed by quoting.  Double it (`%%`)
293///   to produce a literal `%`.
294/// - `!` would be processed by the delayed-expansion pass when `/V:ON` is in
295///   effect.  We invoke cmd.exe with `/V:OFF` to suppress this entirely, so `!`
296///   needs no escaping here.
297/// - To embed a literal `"` inside a double-quoted cmd.exe token, use `""` (the
298///   cmd.exe shell convention).  The `\"` form is for `CommandLineToArgvW` (the
299///   Win32 C-runtime argv parser) which is a **different** parser from the
300///   cmd.exe shell.  Using `\"` in cmd.exe context causes the shell to treat the
301///   `\` as a literal character and the `"` as ending the current quoted region,
302///   which breaks argument boundaries and can enable injection.
303#[cfg(all(not(target_arch = "wasm32"), windows))]
304fn windows_quote_for_cmd(arg: &str) -> String {
305    let mut escaped = String::with_capacity(arg.len() + 2);
306    escaped.push('"');
307    for ch in arg.chars() {
308        match ch {
309            // % must be doubled: %VAR% expansion runs before the shell-metachar
310            // pass and is not suppressed by double-quoting.
311            '%' => escaped.push_str("%%"),
312            // Use "" (doubled quote) to represent a literal " inside a
313            // cmd.exe double-quoted token.  This is the cmd.exe shell convention.
314            // The \" form (CommandLineToArgvW convention) would end the quoted
315            // region from cmd.exe's perspective and enable injection.
316            '"' => escaped.push_str("\"\""),
317            // All other characters — including shell metacharacters (&, |, <,
318            // >, (, )) and the caret (^) — are already literal inside a
319            // double-quoted cmd.exe token.  No escaping is required or correct.
320            _ => escaped.push(ch),
321        }
322    }
323    escaped.push('"');
324    escaped
325}
326
327#[cfg(all(not(target_arch = "wasm32"), windows))]
328fn resolve_windows_program(program: &str) -> Option<String> {
329    let program_path = Path::new(program);
330    let has_separator = program.contains('\\') || program.contains('/');
331    let has_extension = program_path.extension().is_some();
332
333    if has_separator || has_extension {
334        return Some(program.to_string());
335    }
336
337    let output = std::process::Command::new("where")
338        .arg(program)
339        .stdout(std::process::Stdio::piped())
340        .stderr(std::process::Stdio::null())
341        .output()
342        .ok()?;
343
344    if !output.status.success() {
345        return None;
346    }
347
348    String::from_utf8(output.stdout)
349        .ok()?
350        .lines()
351        .map(str::trim)
352        .filter(|line| !line.is_empty())
353        .max_by_key(|candidate| windows_program_priority(candidate))
354        .map(String::from)
355}
356
357#[cfg(all(not(target_arch = "wasm32"), windows))]
358fn windows_program_priority(candidate: &str) -> u8 {
359    match Path::new(candidate)
360        .extension()
361        .and_then(|ext| ext.to_str())
362        .map(|ext| ext.to_ascii_lowercase())
363    {
364        Some(ext) if ext == "exe" => 5,
365        Some(ext) if ext == "com" => 4,
366        Some(ext) if ext == "cmd" => 3,
367        Some(ext) if ext == "bat" => 2,
368        Some(_) => 1,
369        None => 0,
370    }
371}
372
373#[cfg(all(not(target_arch = "wasm32"), windows))]
374fn windows_requires_cmd_shell(program: &str) -> bool {
375    Path::new(program)
376        .extension()
377        .and_then(|ext| ext.to_str())
378        .map(|ext| ext.eq_ignore_ascii_case("bat") || ext.eq_ignore_ascii_case("cmd"))
379        .unwrap_or(false)
380}
381
382/// Mock subprocess runtime for testing.
383pub mod mock {
384    use super::*;
385    use std::sync::{Arc, Mutex, MutexGuard};
386
387    fn lock<'a, T>(mutex: &'a Mutex<T>) -> MutexGuard<'a, T> {
388        match mutex.lock() {
389            Ok(guard) => guard,
390            Err(poisoned) => poisoned.into_inner(),
391        }
392    }
393
394    /// A recorded command invocation.
395    #[derive(Debug, Clone)]
396    pub struct CommandInvocation {
397        /// The program that was called.
398        pub program: String,
399        /// The arguments passed.
400        pub args: Vec<String>,
401        /// The stdin data provided.
402        pub stdin: Option<Vec<u8>>,
403    }
404
405    /// Builder for mock responses.
406    #[derive(Debug, Clone)]
407    pub struct MockResponse {
408        /// Stdout to return.
409        pub stdout: Vec<u8>,
410        /// Stderr to return.
411        pub stderr: Vec<u8>,
412        /// Status code to return.
413        pub status_code: i32,
414    }
415
416    impl MockResponse {
417        /// Create a successful mock response with the given stdout.
418        pub fn success(stdout: impl Into<Vec<u8>>) -> Self {
419            Self { stdout: stdout.into(), stderr: Vec::new(), status_code: 0 }
420        }
421
422        /// Create a failed mock response with the given stderr.
423        pub fn failure(stderr: impl Into<Vec<u8>>, status_code: i32) -> Self {
424            Self { stdout: Vec::new(), stderr: stderr.into(), status_code }
425        }
426    }
427
428    /// Mock subprocess runtime for testing.
429    pub struct MockSubprocessRuntime {
430        invocations: Arc<Mutex<Vec<CommandInvocation>>>,
431        responses: Arc<Mutex<Vec<MockResponse>>>,
432        default_response: MockResponse,
433    }
434
435    impl MockSubprocessRuntime {
436        /// Create a new mock runtime with a default successful response.
437        pub fn new() -> Self {
438            Self {
439                invocations: Arc::new(Mutex::new(Vec::new())),
440                responses: Arc::new(Mutex::new(Vec::new())),
441                default_response: MockResponse::success(Vec::new()),
442            }
443        }
444
445        /// Add a response to be returned for the next command.
446        pub fn add_response(&self, response: MockResponse) {
447            lock(&self.responses).push(response);
448        }
449
450        /// Set the default response when no queued responses remain.
451        pub fn set_default_response(&mut self, response: MockResponse) {
452            self.default_response = response;
453        }
454
455        /// Get all recorded invocations.
456        pub fn invocations(&self) -> Vec<CommandInvocation> {
457            lock(&self.invocations).clone()
458        }
459
460        /// Clear recorded invocations.
461        pub fn clear_invocations(&self) {
462            lock(&self.invocations).clear();
463        }
464    }
465
466    impl Default for MockSubprocessRuntime {
467        fn default() -> Self {
468            Self::new()
469        }
470    }
471
472    impl SubprocessRuntime for MockSubprocessRuntime {
473        fn run_command(
474            &self,
475            program: &str,
476            args: &[&str],
477            stdin: Option<&[u8]>,
478        ) -> Result<SubprocessOutput, SubprocessError> {
479            lock(&self.invocations).push(CommandInvocation {
480                program: program.to_string(),
481                args: args.iter().map(|s| s.to_string()).collect(),
482                stdin: stdin.map(|s| s.to_vec()),
483            });
484
485            let response = {
486                let mut responses = lock(&self.responses);
487                if responses.is_empty() {
488                    self.default_response.clone()
489                } else {
490                    responses.remove(0)
491                }
492            };
493
494            Ok(SubprocessOutput {
495                stdout: response.stdout,
496                stderr: response.stderr,
497                status_code: response.status_code,
498            })
499        }
500    }
501}
502
503#[cfg(test)]
504mod tests {
505    use super::*;
506
507    #[test]
508    fn test_subprocess_output_success() {
509        let output = SubprocessOutput { stdout: vec![1, 2, 3], stderr: vec![], status_code: 0 };
510        assert!(output.success());
511    }
512
513    #[test]
514    fn test_subprocess_output_failure() {
515        let output = SubprocessOutput { stdout: vec![], stderr: b"error".to_vec(), status_code: 1 };
516        assert!(!output.success());
517        assert_eq!(output.stderr_lossy(), "error");
518    }
519
520    #[test]
521    fn test_subprocess_error_display() {
522        let error = SubprocessError::new("test error");
523        assert_eq!(format!("{}", error), "test error");
524    }
525
526    #[test]
527    fn test_mock_runtime() {
528        use mock::*;
529
530        let runtime = MockSubprocessRuntime::new();
531        runtime.add_response(MockResponse::success(b"formatted code".to_vec()));
532
533        let result = runtime.run_command("perltidy", &["-st"], Some(b"my $x = 1;"));
534
535        assert!(result.is_ok());
536        let output = perl_tdd_support::must(result);
537        assert!(output.success());
538        assert_eq!(output.stdout_lossy(), "formatted code");
539
540        let invocations = runtime.invocations();
541        assert_eq!(invocations.len(), 1);
542        assert_eq!(invocations[0].program, "perltidy");
543        assert_eq!(invocations[0].args, vec!["-st"]);
544        assert_eq!(invocations[0].stdin, Some(b"my $x = 1;".to_vec()));
545    }
546
547    #[cfg(not(target_arch = "wasm32"))]
548    #[test]
549    fn test_os_runtime_echo() {
550        let runtime = OsSubprocessRuntime::new();
551        #[cfg(windows)]
552        let result = runtime.run_command("cmd.exe", &["/C", "echo", "hello"], None);
553        #[cfg(not(windows))]
554        let result = runtime.run_command("echo", &["hello"], None);
555
556        assert!(result.is_ok());
557        let output = perl_tdd_support::must(result);
558        assert!(output.success());
559        assert!(output.stdout_lossy().trim() == "hello");
560    }
561
562    #[cfg(not(target_arch = "wasm32"))]
563    #[test]
564    fn test_os_runtime_nonexistent() {
565        let runtime = OsSubprocessRuntime::new();
566
567        let result = runtime.run_command("nonexistent_program_xyz", &[], None);
568
569        assert!(result.is_err());
570    }
571
572    #[cfg(not(target_arch = "wasm32"))]
573    #[test]
574    fn test_os_runtime_rejects_empty_program_name() {
575        let runtime = OsSubprocessRuntime::new();
576        let result = runtime.run_command("   ", &["--version"], None);
577        assert!(result.is_err());
578        let err = result.expect_err("empty program name must be rejected");
579        assert!(err.message.contains("must not be empty"));
580    }
581
582    #[cfg(not(target_arch = "wasm32"))]
583    #[test]
584    fn test_os_runtime_rejects_nul_bytes_in_program_or_args() {
585        let runtime = OsSubprocessRuntime::new();
586
587        let bad_program = runtime.run_command("perl\0", &["--version"], None);
588        assert!(bad_program.is_err());
589        let bad_program_err = bad_program.expect_err("NUL in program must be rejected");
590        assert!(bad_program_err.message.contains("NUL"));
591
592        let bad_arg = runtime.run_command("perl", &["-e", "print \"ok\"\0"], None);
593        assert!(bad_arg.is_err());
594        let bad_arg_err = bad_arg.expect_err("NUL in arg must be rejected");
595        assert!(bad_arg_err.message.contains("NUL"));
596    }
597
598    #[cfg(windows)]
599    #[test]
600    fn test_resolve_command_invocation_uses_cmd_for_batch_wrappers() {
601        let (program, args) =
602            resolve_command_invocation(r"C:\Strawberry\perl\bin\perltidy.bat", &["-st", "-se"]);
603
604        assert_eq!(program, "cmd.exe");
605        assert_eq!(
606            args,
607            vec![
608                "/D".to_string(),
609                "/V:OFF".to_string(),
610                "/S".to_string(),
611                "/C".to_string(),
612                "\"C:\\Strawberry\\perl\\bin\\perltidy.bat\" \"-st\" \"-se\"".to_string(),
613            ]
614        );
615    }
616
617    /// Verify that cmd.exe shell metacharacters are handled correctly inside
618    /// double-quoted tokens.
619    ///
620    /// Inside a cmd.exe double-quoted region, shell metacharacters (`&`, `|`,
621    /// `<`, `>`, `(`, `)`) are already literal — no `^` prefix is used.
622    /// `^` is also literal and must not be doubled.
623    /// `%` is doubled to prevent `%VAR%` expansion.
624    /// `"` is doubled (`""`) per the cmd.exe shell convention.
625    #[cfg(windows)]
626    #[test]
627    fn test_windows_quote_for_cmd_metacharacters_are_literal_inside_quotes() {
628        // Metacharacters & | < > are passed through literally — no ^ prefix.
629        // ^ is literal — must NOT be doubled.
630        // % is doubled to prevent %VAR% expansion.
631        // " is doubled (cmd.exe "" convention), not backslash-escaped.
632        let quoted = windows_quote_for_cmd(r#"profile&name|1>%TEMP%^"x""#);
633        assert_eq!(quoted, r#""profile&name|1>%%TEMP%%^""x""""#);
634    }
635
636    /// Verify that a caret in an argument is not doubled.
637    ///
638    /// The original PR erroneously included `'^'` in the metacharacter match
639    /// arm, which caused `windows_quote_for_cmd("foo^bar")` to return
640    /// `"foo^^bar"` — delivering two carets to the program.  Inside a
641    /// cmd.exe double-quoted region `^` is literal and must not be escaped.
642    #[cfg(windows)]
643    #[test]
644    fn test_windows_quote_for_cmd_caret_not_doubled() {
645        let quoted = windows_quote_for_cmd(r"foo^bar");
646        assert_eq!(quoted, r#""foo^bar""#);
647    }
648
649    /// Verify that an embedded double-quote uses the cmd.exe `""` convention.
650    ///
651    /// The original PR used `\"` which is the `CommandLineToArgvW` / C-runtime
652    /// convention.  In cmd.exe context the backslash is literal and the `"`
653    /// terminates the quoted region, breaking argument boundaries.
654    #[cfg(windows)]
655    #[test]
656    fn test_windows_quote_for_cmd_embedded_quote_uses_doubling() {
657        let quoted = windows_quote_for_cmd(r#"arg"with"quotes"#);
658        // cmd.exe convention: "" represents a literal " inside a quoted token.
659        assert_eq!(quoted, r#""arg""with""quotes""#);
660    }
661
662    /// Verify that an attacker-controlled injection attempt is rendered inert.
663    ///
664    /// An arg like `&calc.exe` must not break out of the quoted token.
665    /// After quoting, cmd.exe sees `&` as a literal character inside the
666    /// double-quoted region.
667    #[cfg(windows)]
668    #[test]
669    fn test_windows_quote_for_cmd_injection_attempt_is_inert() {
670        let quoted = windows_quote_for_cmd("&calc.exe");
671        assert_eq!(quoted, "\"&calc.exe\"");
672    }
673
674    /// Verify that /V:OFF is present in the cmd.exe argument list.
675    ///
676    /// Without /V:OFF, cmd.exe with delayed expansion enabled would expand
677    /// `!VAR!` patterns inside arguments, which is an information-disclosure
678    /// vector and, in edge cases, an injection vector.
679    #[cfg(windows)]
680    #[test]
681    fn test_resolve_command_invocation_includes_v_off_flag() {
682        let (program, args) =
683            resolve_command_invocation(r"C:\tools\perlcritic.bat", &["--profile=!TEMP!"]);
684
685        assert_eq!(program, "cmd.exe");
686        assert!(
687            args.contains(&"/V:OFF".to_string()),
688            "/V:OFF must be present to disable delayed expansion; got: {:?}",
689            args
690        );
691    }
692
693    #[cfg(windows)]
694    #[test]
695    fn test_resolve_command_invocation_preserves_executable_paths() {
696        let (program, args) =
697            resolve_command_invocation(r"C:\tools\perlcritic.exe", &["--version"]);
698
699        assert_eq!(program, r"C:\tools\perlcritic.exe");
700        assert_eq!(args, vec!["--version".to_string()]);
701    }
702
703    #[cfg(windows)]
704    #[test]
705    fn test_windows_program_priority_prefers_real_wrappers_over_extensionless_shims() {
706        let mut candidates = vec![
707            r"C:\Strawberry\perl\bin\perltidy".to_string(),
708            r"C:\Strawberry\perl\bin\perltidy.bat".to_string(),
709            r"C:\tools\perltidy.exe".to_string(),
710        ];
711        candidates.sort_by_key(|candidate| windows_program_priority(candidate));
712
713        assert_eq!(candidates.last().map(String::as_str), Some(r"C:\tools\perltidy.exe"));
714        assert!(
715            windows_program_priority(r"C:\Strawberry\perl\bin\perltidy.bat")
716                > windows_program_priority(r"C:\Strawberry\perl\bin\perltidy")
717        );
718    }
719}