Skip to main content

prick_exec/
cmdline.rs

1//! Building a `cmd.exe` command line for a batch shim.
2//!
3//! # Why this module has to exist at all
4//!
5//! `npm`, `pnpm`, `npx`, `yarn` and `tsc` are `.cmd` files on Windows, not
6//! executables. Two separate things then go wrong:
7//!
8//! 1. `std::process::Command`'s program resolution only ever appends `.exe`, so
9//!    `Command::new("npm")` does not find `npm.cmd` and fails with "not found".
10//!    [`crate::launch`] resolves through `which`, which honours `PATHEXT`.
11//! 2. A batch file is not executed by the loader; `cmd.exe` interprets it. That
12//!    means the arguments are parsed **twice** -- once when `cmd.exe` reads the
13//!    command line we hand it, and again inside the script when `%*` is
14//!    substituted into a line and that line is re-parsed. Getting this wrong is
15//!    CVE-2024-24576: an argument containing `&` becomes a second command.
16//!
17//! # The algorithm
18//!
19//! This mirrors the escaping the Rust standard library adopted for the
20//! CVE-2024-24576 fix, which is the only construction that is known to survive
21//! both parses. Reimplementing it here rather than deferring to std is what
22//! lets `prk run -- npm test` work: std applies it only when *it* resolved the
23//! program to a `.bat`/`.cmd`, and it never resolves `npm` to `npm.cmd` at all.
24//!
25//! The pieces, each of which is load-bearing:
26//!
27//! | Piece | Defeats |
28//! |---|---|
29//! | Wrap the whole command in one outer quote pair | `cmd.exe`'s argument splitting |
30//! | `/s` | The conditional "should I strip the outer quotes" rule, which is genuinely hard to predict |
31//! | `/d` | An `AutoRun` registry value running before the command |
32//! | `/v:OFF` | `!DELAYED!` expansion |
33//! | `/e:ON` | Needed for the `%` construction below to evaluate |
34//! | Quote any argument that is not purely alphanumeric | `& \| < > ( ) ^` and whitespace splitting |
35//! | Double an inner `"` rather than backslash-escaping it | A `\"` would end the quoted region as far as `cmd.exe` is concerned, exposing everything after it |
36//! | Replace `%` with `%%cd:~,%` | `%PATH%` expanding to its value |
37//!
38//! The `%` construction deserves a sentence. `%cd:~,%` is a substring of the
39//! built-in `cd` variable with an empty start and end index, so it expands to
40//! nothing. Splicing that no-op in front of every `%` leaves the text unchanged
41//! but leaves `cmd.exe` with no `%NAME%` pair to match, so nothing expands.
42//!
43//! # What cannot be escaped
44//!
45//! `\r` and `\n` terminate a `cmd.exe` command line; there is no encoding that
46//! carries them through. They are rejected rather than silently dropped --
47//! silently dropping half an argument is how a `prk run` invocation quietly
48//! does something other than what it was asked to.
49//!
50//! # Portability of this module
51//!
52//! Everything here operates on UTF-16 code units and is compiled on every
53//! platform, so the adversarial-argument tests run on Linux and macOS CI too.
54//! Only [`crate::launch`] restricts its use to Windows.
55
56use std::fmt;
57
58/// Switches passed to `cmd.exe` ahead of the command itself.
59///
60/// See the module documentation for what each one defeats. The order matches
61/// what `cmd.exe` documents.
62pub const CMD_SWITCHES: &str = "/d /e:ON /v:OFF /s /c";
63
64/// An argument that cannot be carried through `cmd.exe` at all.
65#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
66#[non_exhaustive]
67pub enum CmdLineError {
68    /// The argument contained a carriage return or line feed.
69    ///
70    /// Both terminate a `cmd.exe` command line. There is no quoting or escape
71    /// that survives, so the argument is refused rather than truncated.
72    #[error(
73        "argument {index} cannot be passed to a .cmd or .bat file: it contains a line break, \
74         which terminates a cmd.exe command line"
75    )]
76    LineBreak {
77        /// Position of the offending argument, counting the program as 0.
78        index: usize,
79    },
80
81    /// The argument contained a NUL, which no Windows API accepts.
82    #[error("argument {index} contains a NUL byte")]
83    Nul {
84        /// Position of the offending argument, counting the program as 0.
85        index: usize,
86    },
87
88    /// The resolved script path cannot be quoted unambiguously.
89    ///
90    /// A Windows file name cannot contain `"`, and one ending in `\` would
91    /// escape the closing quote of its own quote pair.
92    #[error("the script path contains a quote or ends with a backslash, so it cannot be quoted")]
93    ScriptPath,
94}
95
96/// UTF-16 code units for the characters this module has to reason about.
97mod unit {
98    /// `"`
99    pub(super) const QUOTE: u16 = b'"' as u16;
100    /// `\`
101    pub(super) const BACKSLASH: u16 = b'\\' as u16;
102    /// `%`
103    pub(super) const PERCENT: u16 = b'%' as u16;
104    /// Carriage return.
105    pub(super) const CR: u16 = b'\r' as u16;
106    /// Line feed.
107    pub(super) const LF: u16 = b'\n' as u16;
108    /// NUL.
109    pub(super) const NUL: u16 = 0;
110    /// Space.
111    pub(super) const SPACE: u16 = b' ' as u16;
112}
113
114/// Characters that are safe unquoted in both `cmd.exe` parses.
115///
116/// Deliberately an allowlist. Enumerating the characters that *must* be quoted
117/// means being wrong the moment `cmd.exe` grows another metacharacter; an
118/// allowlist of the ones known to be inert is wrong only in the direction of
119/// quoting something unnecessarily.
120const UNQUOTED_PUNCTUATION: &str = r"#$*+-./:?@\_";
121
122/// The no-op substring expansion spliced in front of every `%`.
123///
124/// `%cd:~,%` is the current directory with an empty start and end index, so it
125/// contributes nothing. Its purpose is to leave `cmd.exe` without a `%NAME%`
126/// pair to match.
127const PERCENT_GUARD: &str = "%%cd:~,";
128
129/// Whether an argument has to be wrapped in quotes.
130fn needs_quoting(arg: &[u16]) -> bool {
131    if arg.is_empty() {
132        // An empty argument would otherwise vanish entirely.
133        return true;
134    }
135    if arg.last() == Some(&unit::BACKSLASH) {
136        // A trailing backslash would escape the closing quote of a `"%~1"` in
137        // the script, so force quoting and let the doubling below handle it.
138        return true;
139    }
140
141    arg.iter().any(|&unit| {
142        // `u8::try_from` is not an ASCII test: it succeeds for the whole
143        // Latin-1 range, which would classify `é` as punctuation that needs
144        // quoting. The bound has to be 0x80.
145        let Some(ch) = u8::try_from(unit).ok().filter(u8::is_ascii).map(char::from) else {
146            // Non-ASCII. Quote anything in a Unicode control block; leave the
147            // rest, which `cmd.exe` does not interpret.
148            return char::from_u32(u32::from(unit)).is_some_and(char::is_control);
149        };
150        !(ch.is_ascii_alphanumeric() || UNQUOTED_PUNCTUATION.contains(ch))
151    })
152}
153
154/// Appends one escaped argument to a `cmd.exe` command line.
155///
156/// # Errors
157///
158/// Returns [`CmdLineError::LineBreak`] or [`CmdLineError::Nul`] for an argument
159/// that cannot be represented on a `cmd.exe` command line at all.
160pub fn append_argument(out: &mut Vec<u16>, arg: &[u16], index: usize) -> Result<(), CmdLineError> {
161    if arg.contains(&unit::CR) || arg.contains(&unit::LF) {
162        return Err(CmdLineError::LineBreak { index });
163    }
164    if arg.contains(&unit::NUL) {
165        return Err(CmdLineError::Nul { index });
166    }
167
168    let quote = needs_quoting(arg);
169    if quote {
170        out.push(unit::QUOTE);
171    }
172
173    let mut backslashes: usize = 0;
174    for &code in arg {
175        match code {
176            unit::BACKSLASH => backslashes += 1,
177            unit::QUOTE => {
178                // 2n backslashes before a literal quote, so the argv parser on
179                // the far side sees n of them and a quote it does not treat as
180                // a delimiter.
181                out.extend(std::iter::repeat_n(unit::BACKSLASH, backslashes));
182                backslashes = 0;
183                // Doubling rather than `\"`: a backslash-escaped quote still
184                // closes the quoted region as far as cmd.exe is concerned, and
185                // everything after it would be re-exposed to the metacharacter
186                // parser. `""` leaves and re-enters with nothing in between.
187                out.push(unit::QUOTE);
188            }
189            unit::PERCENT => {
190                backslashes = 0;
191                out.extend(PERCENT_GUARD.encode_utf16());
192            }
193            _ => backslashes = 0,
194        }
195        out.push(code);
196    }
197
198    if quote {
199        // 2n backslashes before the closing quote, so it stays a delimiter.
200        out.extend(std::iter::repeat_n(unit::BACKSLASH, backslashes));
201        out.push(unit::QUOTE);
202    }
203
204    Ok(())
205}
206
207/// Builds the argument string for `cmd.exe`, running `script` with `args`.
208///
209/// The returned value starts at the switches, so a caller passes it as a single
210/// raw argument to a `cmd.exe` process: nothing re-quotes it on the way out.
211///
212/// # Errors
213///
214/// Returns [`CmdLineError::ScriptPath`] if the script path cannot be quoted,
215/// and [`CmdLineError::LineBreak`] or [`CmdLineError::Nul`] for an argument
216/// that `cmd.exe` cannot carry.
217pub fn batch_command_line(script: &[u16], args: &[Vec<u16>]) -> Result<Vec<u16>, CmdLineError> {
218    if script.contains(&unit::QUOTE) || script.last() == Some(&unit::BACKSLASH) {
219        return Err(CmdLineError::ScriptPath);
220    }
221    if script.contains(&unit::NUL) {
222        return Err(CmdLineError::Nul { index: 0 });
223    }
224
225    let mut out: Vec<u16> = CMD_SWITCHES.encode_utf16().collect();
226    out.push(unit::SPACE);
227
228    // The outer quote pair. `/s` makes cmd.exe strip exactly the first and last
229    // quote of what follows and treat everything between as the command, with
230    // no conditional rule to reason about.
231    out.push(unit::QUOTE);
232
233    out.push(unit::QUOTE);
234    out.extend_from_slice(script);
235    out.push(unit::QUOTE);
236
237    for (offset, arg) in args.iter().enumerate() {
238        out.push(unit::SPACE);
239        append_argument(&mut out, arg, offset + 1)?;
240    }
241
242    out.push(unit::QUOTE);
243    Ok(out)
244}
245
246/// A `Vec<u16>` rendered for a test assertion or a diagnostic.
247///
248/// Lossy by construction; never used to build a command line.
249#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct Utf16Display<'a>(pub &'a [u16]);
251
252impl fmt::Display for Utf16Display<'_> {
253    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
254        for ch in char::decode_utf16(self.0.iter().copied()) {
255            f.write_fmt(format_args!("{}", ch.unwrap_or(char::REPLACEMENT_CHARACTER)))?;
256        }
257        Ok(())
258    }
259}
260
261#[cfg(test)]
262mod tests {
263    use super::*;
264
265    fn units(text: &str) -> Vec<u16> {
266        text.encode_utf16().collect()
267    }
268
269    fn escape(arg: &str) -> String {
270        let mut out = Vec::new();
271        append_argument(&mut out, &units(arg), 1).expect("representable");
272        Utf16Display(&out).to_string()
273    }
274
275    fn line(script: &str, args: &[&str]) -> String {
276        let args: Vec<Vec<u16>> = args.iter().map(|a| units(a)).collect();
277        let out = batch_command_line(&units(script), &args).expect("representable");
278        Utf16Display(&out).to_string()
279    }
280
281    #[test]
282    fn the_switches_disable_every_feature_that_could_run_something_else() {
283        assert!(CMD_SWITCHES.contains("/d"), "AutoRun must be disabled");
284        assert!(CMD_SWITCHES.contains("/v:OFF"), "delayed expansion must be disabled");
285        assert!(CMD_SWITCHES.contains("/s"), "the conditional quote rule must be pinned");
286        assert!(CMD_SWITCHES.contains("/e:ON"), "the percent guard needs command extensions");
287    }
288
289    #[test]
290    fn a_plain_word_is_left_alone() {
291        assert_eq!(escape("test"), "test");
292        assert_eq!(escape("build2"), "build2");
293        // The unquoted allowlist.
294        assert_eq!(escape("--flag"), "--flag");
295        assert_eq!(escape(r"C:\dir\file.txt"), r"C:\dir\file.txt");
296    }
297
298    #[test]
299    fn whitespace_forces_quoting() {
300        assert_eq!(escape("a b"), r#""a b""#);
301        assert_eq!(escape("a\tb"), "\"a\tb\"");
302    }
303
304    #[test]
305    fn an_empty_argument_is_quoted_so_it_survives() {
306        // Without the quotes it would disappear from argv entirely.
307        assert_eq!(escape(""), r#""""#);
308    }
309
310    #[test]
311    fn command_separators_are_quoted_rather_than_executed() {
312        // The CVE-2024-24576 shape: `a&b` unquoted runs `b` as a command.
313        assert_eq!(escape("a&b"), r#""a&b""#);
314        assert_eq!(escape("a|b"), r#""a|b""#);
315        assert_eq!(escape("a>b"), r#""a>b""#);
316        assert_eq!(escape("a<b"), r#""a<b""#);
317        assert_eq!(escape("a&&b"), r#""a&&b""#);
318    }
319
320    #[test]
321    fn a_caret_is_quoted_rather_than_treated_as_an_escape() {
322        assert_eq!(escape("a^b"), r#""a^b""#);
323        // Doubling would be wrong: inside quotes a caret is already literal.
324        assert!(!escape("a^b").contains("^^"));
325    }
326
327    #[test]
328    fn a_variable_reference_is_defused_rather_than_expanded() {
329        let escaped = escape("%PATH%");
330        // Every `%` gets the no-op substring expansion spliced in front of it,
331        // so cmd.exe never sees a `%NAME%` pair.
332        assert_eq!(escaped, "\"%%cd:~,%PATH%%cd:~,%\"");
333        assert_eq!(escaped.matches("%%cd:~,").count(), 2);
334    }
335
336    #[test]
337    fn a_lone_percent_is_guarded_too() {
338        assert_eq!(escape("100%"), "\"100%%cd:~,%\"");
339        assert_eq!(escape("a%b"), "\"a%%cd:~,%b\"");
340    }
341
342    #[test]
343    fn delayed_expansion_syntax_is_quoted_and_the_switch_disables_it() {
344        // `!` needs both: quoting stops the metacharacter parse seeing it, and
345        // /v:OFF stops the script expanding it if it were re-enabled.
346        assert_eq!(escape("!DELAYED!"), r#""!DELAYED!""#);
347        assert!(CMD_SWITCHES.contains("/v:OFF"));
348    }
349
350    #[test]
351    fn an_inner_quote_is_doubled_not_backslash_escaped() {
352        // `\"` would close the quoted region as far as cmd.exe is concerned,
353        // re-exposing everything after it to the metacharacter parser.
354        assert_eq!(escape(r#"a"b"#), r#""a""b""#);
355        assert!(!escape(r#"a"b"#).contains(r#"\""#));
356    }
357
358    #[test]
359    fn backslashes_before_a_quote_are_doubled() {
360        // 2n backslashes then the doubled quote, so the argv parser on the far
361        // side reconstructs n backslashes and a literal quote.
362        assert_eq!(escape(r#"a\"b"#), r#""a\\""b""#);
363        assert_eq!(escape(r#"a\\"b"#), r#""a\\\\""b""#);
364    }
365
366    #[test]
367    fn a_trailing_backslash_is_doubled_against_the_closing_quote() {
368        // Otherwise `"%~1"` in the script would see the backslash escape the
369        // quote and swallow the rest of the line.
370        assert_eq!(escape(r"a\"), r#""a\\""#);
371        assert_eq!(escape(r"C:\dir\"), r#""C:\dir\\""#);
372    }
373
374    #[test]
375    fn backslashes_not_adjacent_to_a_quote_are_left_alone() {
376        assert_eq!(escape(r"C:\a\b"), r"C:\a\b");
377    }
378
379    #[test]
380    fn a_line_break_is_refused_rather_than_truncated() {
381        let mut out = Vec::new();
382        assert_eq!(
383            append_argument(&mut out, &units("a\nb"), 3),
384            Err(CmdLineError::LineBreak { index: 3 })
385        );
386        assert_eq!(
387            append_argument(&mut out, &units("a\rb"), 1),
388            Err(CmdLineError::LineBreak { index: 1 })
389        );
390    }
391
392    #[test]
393    fn a_nul_is_refused() {
394        let mut out = Vec::new();
395        assert_eq!(
396            append_argument(&mut out, &[0x61, 0, 0x62], 2),
397            Err(CmdLineError::Nul { index: 2 })
398        );
399    }
400
401    #[test]
402    fn the_error_names_the_argument_position() {
403        let args = vec![units("ok"), units("bad\nvalue")];
404        let err = batch_command_line(&units(r"C:\n\npm.cmd"), &args).unwrap_err();
405        assert_eq!(err, CmdLineError::LineBreak { index: 2 });
406        assert!(err.to_string().contains("argument 2"));
407    }
408
409    #[test]
410    fn the_whole_command_is_wrapped_in_one_outer_quote_pair() {
411        let built = line(r"C:\Program Files\nodejs\npm.cmd", &["test"]);
412        assert_eq!(built, "/d /e:ON /v:OFF /s /c \"\"C:\\Program Files\\nodejs\\npm.cmd\" test\"");
413        assert!(built.ends_with('"'));
414    }
415
416    #[test]
417    fn an_unquotable_script_path_is_refused() {
418        assert_eq!(
419            batch_command_line(&units(r#"C:\we"ird.cmd"#), &[]),
420            Err(CmdLineError::ScriptPath)
421        );
422        assert_eq!(batch_command_line(&units(r"C:\dir\"), &[]), Err(CmdLineError::ScriptPath));
423    }
424
425    #[test]
426    fn the_full_adversarial_set_produces_a_balanced_command_line() {
427        let adversarial =
428            [r#"a"b"#, "a&b", "%PATH%", "!DELAYED!", "a b", "a^b", "", "a|b", r"a\", "100%"];
429        let built = line(r"C:\tools\shim.cmd", &adversarial);
430
431        // Every quote is either an opening/closing delimiter or half of a
432        // doubled literal, so the total is even. An odd count would mean some
433        // argument left cmd.exe inside a quoted region, which is the failure
434        // that turns the next argument into a command.
435        assert_eq!(built.matches('"').count() % 2, 0, "unbalanced quotes in {built}");
436        assert!(!built.contains('\n') && !built.contains('\r'));
437    }
438
439    #[test]
440    fn non_ascii_arguments_survive_unquoted() {
441        assert_eq!(escape("café"), "café");
442        assert_eq!(escape("日本"), "日本");
443    }
444
445    #[test]
446    fn utf16_display_round_trips() {
447        assert_eq!(Utf16Display(&units("héllo")).to_string(), "héllo");
448    }
449}