Serializes argv to a Windows command-line string that uses cmd.exe /c and cmd.exe-specific
escaping rules. The caller owns the returned slice.
Escapes argv using the suggested mitigation against arbitrary command execution from:
https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
The return of this function will look like
cmd.exe /d /e:ON /v:OFF /c "<escaped command line>"
and should be used as the lpCommandLine of CreateProcessW, while the return of
WindowsCommandLineCache.cmdExePath should be used as lpApplicationName.
Should only be used when spawning .bat/.cmd scripts, see argvToCommandLineWindows otherwise.
The .bat/.cmd file must be known to both have the .bat/.cmd extension and exist on the filesystem.
fn argvToScriptCommandLineWindows(
allocator: Allocator,
/// Path to the `.bat`/`.cmd` script. If this path is relative, it is assumed to be relative to the CWD.
/// The script must have been verified to exist at this path before calling this function.
script_path: []const u16,
/// Arguments, not including the script name itself. Expected to be encoded as WTF-8.
script_args: []const []const u8,
) ArgvToScriptCommandLineError![:0]u16
fn argvToScriptCommandLineWindows(
allocator: Allocator,
/// Path to the `.bat`/`.cmd` script. If this path is relative, it is assumed to be relative to the CWD.
/// The script must have been verified to exist at this path before calling this function.
script_path: []const u16,
/// Arguments, not including the script name itself. Expected to be encoded as WTF-8.
script_args: []const []const u8,
) ArgvToScriptCommandLineError![:0]u16 {
var buf = try std.array_list.Managed(u8).initCapacity(allocator, 64);
defer buf.deinit();
// `/d` disables execution of AutoRun commands.
// `/e:ON` and `/v:OFF` are needed for BatBadBut mitigation:
// > If delayed expansion is enabled via the registry value DelayedExpansion,
// > it must be disabled by explicitly calling cmd.exe with the /V:OFF option.
// > Escaping for % requires the command extension to be enabled.
// > If it’s disabled via the registry value EnableExtensions, it must be enabled with the /E:ON option.
// https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/
buf.appendSliceAssumeCapacity("cmd.exe /d /e:ON /v:OFF /c \"");
// Always quote the path to the script arg
buf.appendAssumeCapacity('"');
// We always want the path to the batch script to include a path separator in order to
// avoid cmd.exe searching the PATH for the script. This is not part of the arbitrary
// command execution mitigation, we just know exactly what script we want to execute
// at this point, and potentially making cmd.exe re-find it is unnecessary.
//
// If the script path does not have a path separator, then we know its relative to CWD and
// we can just put `.\` in the front.
if (std.mem.findAny(u16, script_path, &[_]u16{
std.mem.nativeToLittle(u16, '\\'), std.mem.nativeToLittle(u16, '/'),
}) == null) {
try buf.appendSlice(".\\");
}
// Note that we don't do any escaping/mitigations for this argument, since the relevant
// characters (", %, etc) are illegal in file paths and this function should only be called
// with script paths that have been verified to exist.
try std.unicode.wtf16LeToWtf8ArrayList(&buf, script_path);
buf.appendAssumeCapacity('"');
for (script_args) |arg| {
// Literal carriage returns get stripped when run through cmd.exe
// and NUL/newlines act as 'end of command.' Because of this, it's basically
// always a mistake to include these characters in argv, so it's
// an error condition in order to ensure that the return of this
// function can always roundtrip through cmd.exe.
if (std.mem.findAny(u8, arg, "\x00\r\n") != null) {
return error.InvalidBatchScriptArg;
}
// Separate args with a space.
try buf.append(' ');
// Need to quote if the argument is empty (otherwise the arg would just be lost)
// or if the last character is a `\`, since then something like "%~2" in a .bat
// script would cause the closing " to be escaped which we don't want.
var needs_quotes = arg.len == 0 or arg[arg.len - 1] == '\\';
if (!needs_quotes) {
for (arg) |c| {
switch (c) {
// Known good characters that don't need to be quoted
'A'...'Z', 'a'...'z', '0'...'9', '#', '$', '*', '+', '-', '.', '/', ':', '?', '@', '\\', '_' => {},
// When in doubt, quote
else => {
needs_quotes = true;
break;
},
}
}
}
if (needs_quotes) {
try buf.append('"');
}
var backslashes: usize = 0;
for (arg) |c| {
switch (c) {
'\\' => {
backslashes += 1;
},
'"' => {
try buf.appendNTimes('\\', backslashes);
try buf.append('"');
backslashes = 0;
},
// Replace `%` with `%%cd:~,%`.
//
// cmd.exe allows extracting a substring from an environment
// variable with the syntax: `%foo:~<start_index>,<end_index>%`.
// Therefore, `%cd:~,%` will always expand to an empty string
// since both the start and end index are blank, and it is assumed
// that `%cd%` is always available since it is a built-in variable
// that corresponds to the current directory.
//
// This means that replacing `%foo%` with `%%cd:~,%foo%%cd:~,%`
// will stop `%foo%` from being expanded and *after* expansion
// we'll still be left with `%foo%` (the literal string).
'%' => {
// the trailing `%` is appended outside the switch
try buf.appendSlice("%%cd:~,");
backslashes = 0;
},
else => {
backslashes = 0;
},
}
try buf.append(c);
}
if (needs_quotes) {
try buf.appendNTimes('\\', backslashes);
try buf.append('"');
}
}
try buf.append('"');
return try std.unicode.wtf8ToWtf16LeAllocZ(allocator, buf.items);
}