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}