Skip to content

Commit 422464d

Browse files
committed
std.process.Child: Mitigate arbitrary command execution vulnerability on Windows (BatBadBut)
> Note: This first part is mostly a rephrasing of https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/ > See that article for more details On Windows, it is possible to execute `.bat`/`.cmd` scripts via CreateProcessW. When this happens, `CreateProcessW` will (under-the-hood) spawn a `cmd.exe` process with the path to the script and the args like so: cmd.exe /c script.bat arg1 arg2 This is a problem because: - `cmd.exe` has its own, separate, parsing/escaping rules for arguments - Environment variables in arguments will be expanded before the `cmd.exe` parsing rules are applied Together, this means that (1) maliciously constructed arguments can lead to arbitrary command execution via the APIs in `std.process.Child` and (2) escaping according to the rules of `cmd.exe` is not enough on its own. A basic example argv field that reproduces the vulnerability (this will erroneously spawn `calc.exe`): .argv = &.{ "test.bat", "\"&calc.exe" }, And one that takes advantage of environment variable expansion to still spawn calc.exe even if the args are properly escaped for `cmd.exe`: .argv = &.{ "test.bat", "%CMDCMDLINE:~-1%&calc.exe" }, (note: if these spawned e.g. `test.exe` instead of `test.bat`, they wouldn't be vulnerable; it's only `.bat`/`.cmd` scripts that are vulnerable since they go through `cmd.exe`) Zig allows passing `.bat`/`.cmd` scripts as `argv[0]` via `std.process.Child`, so the Zig API is affected by this vulnerability. Note also that Zig will search `PATH` for `.bat`/`.cmd` scripts, so spawning something like `foo` may end up executing `foo.bat` somewhere in the PATH (the PATH searching of Zig matches the behavior of cmd.exe). > Side note to keep in mind: On Windows, the extension is significant in terms of how Windows will try to execute the command. If the extension is not `.bat`/`.cmd`, we know that it will not attempt to be executed as a `.bat`/`.cmd` script (and vice versa). This means that we can just look at the extension to know if we are trying to execute a `.bat`/`.cmd` script. --- This general class of problem has been documented before in 2011 here: https://learn.microsoft.com/en-us/archive/blogs/twistylittlepassagesallalike/everyone-quotes-command-line-arguments-the-wrong-way and the course of action it suggests for escaping when executing .bat/.cmd files is: - Escape first using the non-cmd.exe rules - Then escape all cmd.exe 'metacharacters' (`(`, `)`, `%`, `!`, `^`, `"`, `<`, `>`, `&`, and `|`) with `^` However, escaping with ^ on its own is insufficient because it does not stop cmd.exe from expanding environment variables. For example: ``` args.bat %PATH% ``` escaped with ^ (and wrapped in quotes that are also escaped), it *will* stop cmd.exe from expanding `%PATH%`: ``` > args.bat ^"^%PATH^%^" "%PATH%" ``` but it will still try to expand `%PATH^%`: ``` set PATH^^=123 > args.bat ^"^%PATH^%^" "123" ``` The goal is to stop *all* environment variable expansion, so this won't work. Another problem with the ^ approach is that it does not seem to allow all possible command lines to round trip through cmd.exe (as far as I can tell at least). One known example: ``` args.bat ^"\^"key^=value\^"^" ``` where args.bat is: ``` @echo %1 %2 %3 %4 %5 %6 %7 %8 %9 ``` will print ``` "\"key value\"" ``` (it will turn the `=` into a space for an unknown reason; other minor variations do roundtrip, e.g. `\^"key^=value\^"`, `^"key^=value^"`, so it's unclear what's going on) It may actually be possible to escape with ^ such that every possible command line round trips correctly, but it's probably not worth the effort to figure it out, since the suggested mitigation for BatBadBut has better roundtripping and leads to less garbled command lines overall. --- Ultimately, the mitigation used here is the same as the one suggested in: https://flatt.tech/research/posts/batbadbut-you-cant-securely-execute-commands-on-windows/ The mitigation steps are reproduced here, noted with one deviation that Zig makes (following Rust's lead): 1. Replace percent sign (%) with %%cd:~,%. 2. Replace the backslash (\) in front of the double quote (") with two backslashes (\\). 3. Replace the double quote (") with two double quotes (""). 4. ~~Remove newline characters (\n).~~ - Instead, `\n`, `\r`, and NUL are disallowed and will trigger `error.InvalidBatchScriptArg` if they are found in `argv`. These three characters do not roundtrip through a `.bat` file and therefore are of dubious/no use. It's unclear to me if `\n` in particular is relevant to the BatBadBut vulnerability (I wasn't able to find a reproduction with \n and the post doesn't mention anything about it except in the suggested mitigation steps); it just seems to act as a 'end of arguments' marker and therefore anything after the `\n` is lost (and same with NUL). `\r` seems to be stripped from the command line arguments when passed through a `.bat`/`.cmd`, so that is also disallowed to ensure that `argv` can always fully roundtrip through `.bat`/`.cmd`. 5. Enclose the argument with double quotes ("). The escaped command line is then run as something like: cmd.exe /d /e:ON /v:OFF /c "foo.bat arg1 arg2" Note: Previously, we would pass `foo.bat arg1 arg2` as the command line and the path to `foo.bat` as the app name and let CreateProcessW handle the `cmd.exe` spawning for us, but because we need to pass `/e:ON` and `/v:OFF` to cmd.exe to ensure the mitigation is effective, that is no longer tenable. Instead, we now get the full path to `cmd.exe` and use that as the app name when executing `.bat`/`.cmd` files. --- A standalone test has also been added that tests two things: 1. Known reproductions of the vulnerability are tested to ensure that they do not reproduce the vulnerability 2. Randomly generated command line arguments roundtrip when passed to a `.bat` file and then are passed from the `.bat` file to a `.exe`. This fuzz test is as thorough as possible--it tests that things like arbitrary Unicode codepoints and unpaired surrogates roundtrip successfully. Note: In order for the `CreateProcessW` -> `.bat` -> `.exe` roundtripping to succeed, the .exe must split the arguments using the post-2008 C runtime argv splitting implementation, see #19655 for details on when that change was made in Zig.
1 parent 84f4c5d commit 422464d

File tree

7 files changed

+645
-12
lines changed

7 files changed

+645
-12
lines changed

lib/std/child_process.zig

Lines changed: 276 additions & 12 deletions
Large diffs are not rendered by default.

lib/std/os/windows/kernel32.zig

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,8 @@ pub extern "kernel32" fn GetSystemInfo(lpSystemInfo: *SYSTEM_INFO) callconv(WINA
243243
pub extern "kernel32" fn GetSystemTimeAsFileTime(*FILETIME) callconv(WINAPI) void;
244244
pub extern "kernel32" fn IsProcessorFeaturePresent(ProcessorFeature: DWORD) BOOL;
245245

246+
pub extern "kernel32" fn GetSystemDirectoryW(lpBuffer: LPWSTR, uSize: UINT) callconv(WINAPI) UINT;
247+
246248
pub extern "kernel32" fn HeapCreate(flOptions: DWORD, dwInitialSize: SIZE_T, dwMaximumSize: SIZE_T) callconv(WINAPI) ?HANDLE;
247249
pub extern "kernel32" fn HeapDestroy(hHeap: HANDLE) callconv(WINAPI) BOOL;
248250
pub extern "kernel32" fn HeapReAlloc(hHeap: HANDLE, dwFlags: DWORD, lpMem: *anyopaque, dwBytes: SIZE_T) callconv(WINAPI) ?*anyopaque;

test/standalone/build.zig.zon

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,9 @@
107107
.windows_argv = .{
108108
.path = "windows_argv",
109109
},
110+
.windows_bat_args = .{
111+
.path = "windows_bat_args",
112+
},
110113
.self_exe_symlink = .{
111114
.path = "self_exe_symlink",
112115
},
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
const std = @import("std");
2+
const builtin = @import("builtin");
3+
4+
pub fn build(b: *std.Build) !void {
5+
const test_step = b.step("test", "Test it");
6+
b.default_step = test_step;
7+
8+
const optimize: std.builtin.OptimizeMode = .Debug;
9+
const target = b.host;
10+
11+
if (builtin.os.tag != .windows) return;
12+
13+
const echo_args = b.addExecutable(.{
14+
.name = "echo-args",
15+
.root_source_file = b.path("echo-args.zig"),
16+
.optimize = optimize,
17+
.target = target,
18+
});
19+
20+
const test_exe = b.addExecutable(.{
21+
.name = "test",
22+
.root_source_file = b.path("test.zig"),
23+
.optimize = optimize,
24+
.target = target,
25+
});
26+
27+
const run = b.addRunArtifact(test_exe);
28+
run.addArtifactArg(echo_args);
29+
run.expectExitCode(0);
30+
run.skip_foreign_checks = true;
31+
32+
test_step.dependOn(&run.step);
33+
34+
const fuzz = b.addExecutable(.{
35+
.name = "fuzz",
36+
.root_source_file = b.path("fuzz.zig"),
37+
.optimize = optimize,
38+
.target = target,
39+
});
40+
41+
const fuzz_max_iterations = b.option(u64, "iterations", "The max fuzz iterations (default: 100)") orelse 100;
42+
const fuzz_iterations_arg = std.fmt.allocPrint(b.allocator, "{}", .{fuzz_max_iterations}) catch @panic("oom");
43+
44+
const fuzz_seed = b.option(u64, "seed", "Seed to use for the PRNG (default: random)") orelse seed: {
45+
var buf: [8]u8 = undefined;
46+
try std.posix.getrandom(&buf);
47+
break :seed std.mem.readInt(u64, &buf, builtin.cpu.arch.endian());
48+
};
49+
const fuzz_seed_arg = std.fmt.allocPrint(b.allocator, "{}", .{fuzz_seed}) catch @panic("oom");
50+
51+
const fuzz_run = b.addRunArtifact(fuzz);
52+
fuzz_run.addArtifactArg(echo_args);
53+
fuzz_run.addArgs(&.{ fuzz_iterations_arg, fuzz_seed_arg });
54+
fuzz_run.expectExitCode(0);
55+
fuzz_run.skip_foreign_checks = true;
56+
57+
test_step.dependOn(&fuzz_run.step);
58+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const std = @import("std");
2+
3+
pub fn main() !void {
4+
var arena_state = std.heap.ArenaAllocator.init(std.heap.page_allocator);
5+
defer arena_state.deinit();
6+
const arena = arena_state.allocator();
7+
8+
const stdout = std.io.getStdOut().writer();
9+
var args = try std.process.argsAlloc(arena);
10+
for (args[1..], 1..) |arg, i| {
11+
try stdout.writeAll(arg);
12+
if (i != args.len - 1) try stdout.writeByte('\x00');
13+
}
14+
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
const std = @import("std");
2+
const builtin = @import("builtin");
3+
const Allocator = std.mem.Allocator;
4+
5+
pub fn main() anyerror!void {
6+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
7+
defer if (gpa.deinit() == .leak) @panic("found memory leaks");
8+
const allocator = gpa.allocator();
9+
10+
var it = try std.process.argsWithAllocator(allocator);
11+
defer it.deinit();
12+
_ = it.next() orelse unreachable; // skip binary name
13+
const child_exe_path = it.next() orelse unreachable;
14+
15+
const iterations: u64 = iterations: {
16+
const arg = it.next() orelse "0";
17+
break :iterations try std.fmt.parseUnsigned(u64, arg, 10);
18+
};
19+
20+
var rand_seed = false;
21+
const seed: u64 = seed: {
22+
const seed_arg = it.next() orelse {
23+
rand_seed = true;
24+
var buf: [8]u8 = undefined;
25+
try std.posix.getrandom(&buf);
26+
break :seed std.mem.readInt(u64, &buf, builtin.cpu.arch.endian());
27+
};
28+
break :seed try std.fmt.parseUnsigned(u64, seed_arg, 10);
29+
};
30+
var random = std.rand.DefaultPrng.init(seed);
31+
const rand = random.random();
32+
33+
// If the seed was not given via the CLI, then output the
34+
// randomly chosen seed so that this run can be reproduced
35+
if (rand_seed) {
36+
std.debug.print("rand seed: {}\n", .{seed});
37+
}
38+
39+
var tmp = std.testing.tmpDir(.{});
40+
defer tmp.cleanup();
41+
42+
try tmp.dir.setAsCwd();
43+
defer tmp.parent_dir.setAsCwd() catch {};
44+
45+
var buf = try std.ArrayList(u8).initCapacity(allocator, 128);
46+
defer buf.deinit();
47+
try buf.appendSlice("@echo off\n");
48+
try buf.append('"');
49+
try buf.appendSlice(child_exe_path);
50+
try buf.append('"');
51+
const preamble_len = buf.items.len;
52+
53+
try buf.appendSlice(" %*");
54+
try tmp.dir.writeFile("args1.bat", buf.items);
55+
buf.shrinkRetainingCapacity(preamble_len);
56+
57+
try buf.appendSlice(" %1 %2 %3 %4 %5 %6 %7 %8 %9");
58+
try tmp.dir.writeFile("args2.bat", buf.items);
59+
buf.shrinkRetainingCapacity(preamble_len);
60+
61+
try buf.appendSlice(" \"%~1\" \"%~2\" \"%~3\" \"%~4\" \"%~5\" \"%~6\" \"%~7\" \"%~8\" \"%~9\"");
62+
try tmp.dir.writeFile("args3.bat", buf.items);
63+
buf.shrinkRetainingCapacity(preamble_len);
64+
65+
var i: u64 = 0;
66+
while (iterations == 0 or i < iterations) {
67+
const rand_arg = try randomArg(allocator, rand);
68+
defer allocator.free(rand_arg);
69+
70+
try testExec(allocator, &.{rand_arg}, null);
71+
72+
i += 1;
73+
}
74+
}
75+
76+
fn testExec(allocator: std.mem.Allocator, args: []const []const u8, env: ?*std.process.EnvMap) !void {
77+
try testExecBat(allocator, "args1.bat", args, env);
78+
try testExecBat(allocator, "args2.bat", args, env);
79+
try testExecBat(allocator, "args3.bat", args, env);
80+
}
81+
82+
fn testExecBat(allocator: std.mem.Allocator, bat: []const u8, args: []const []const u8, env: ?*std.process.EnvMap) !void {
83+
var argv = try std.ArrayList([]const u8).initCapacity(allocator, 1 + args.len);
84+
defer argv.deinit();
85+
argv.appendAssumeCapacity(bat);
86+
argv.appendSliceAssumeCapacity(args);
87+
88+
const can_have_trailing_empty_args = std.mem.eql(u8, bat, "args3.bat");
89+
90+
const result = try std.ChildProcess.run(.{
91+
.allocator = allocator,
92+
.env_map = env,
93+
.argv = argv.items,
94+
});
95+
defer allocator.free(result.stdout);
96+
defer allocator.free(result.stderr);
97+
98+
try std.testing.expectEqualStrings("", result.stderr);
99+
var it = std.mem.splitScalar(u8, result.stdout, '\x00');
100+
var i: usize = 0;
101+
while (it.next()) |actual_arg| {
102+
if (i >= args.len and can_have_trailing_empty_args) {
103+
try std.testing.expectEqualStrings("", actual_arg);
104+
continue;
105+
}
106+
const expected_arg = args[i];
107+
try std.testing.expectEqualSlices(u8, expected_arg, actual_arg);
108+
i += 1;
109+
}
110+
}
111+
112+
fn randomArg(allocator: Allocator, rand: std.rand.Random) ![]const u8 {
113+
const Choice = enum {
114+
backslash,
115+
quote,
116+
space,
117+
control,
118+
printable,
119+
surrogate_half,
120+
non_ascii,
121+
};
122+
123+
const choices = rand.uintAtMostBiased(u16, 256);
124+
var buf = try std.ArrayList(u8).initCapacity(allocator, choices);
125+
errdefer buf.deinit();
126+
127+
var last_codepoint: u21 = 0;
128+
for (0..choices) |_| {
129+
const choice = rand.enumValue(Choice);
130+
const codepoint: u21 = switch (choice) {
131+
.backslash => '\\',
132+
.quote => '"',
133+
.space => ' ',
134+
.control => switch (rand.uintAtMostBiased(u8, 0x21)) {
135+
// NUL/CR/LF can't roundtrip
136+
'\x00', '\r', '\n' => ' ',
137+
0x21 => '\x7F',
138+
else => |b| b,
139+
},
140+
.printable => '!' + rand.uintAtMostBiased(u8, '~' - '!'),
141+
.surrogate_half => rand.intRangeAtMostBiased(u16, 0xD800, 0xDFFF),
142+
.non_ascii => rand.intRangeAtMostBiased(u21, 0x80, 0x10FFFF),
143+
};
144+
// Ensure that we always return well-formed WTF-8.
145+
// Instead of concatenating to ensure well-formed WTF-8,
146+
// we just skip encoding the low surrogate.
147+
if (std.unicode.isSurrogateCodepoint(last_codepoint) and std.unicode.isSurrogateCodepoint(codepoint)) {
148+
if (std.unicode.utf16IsHighSurrogate(@intCast(last_codepoint)) and std.unicode.utf16IsLowSurrogate(@intCast(codepoint))) {
149+
continue;
150+
}
151+
}
152+
try buf.ensureUnusedCapacity(4);
153+
const unused_slice = buf.unusedCapacitySlice();
154+
const len = std.unicode.wtf8Encode(codepoint, unused_slice) catch unreachable;
155+
buf.items.len += len;
156+
last_codepoint = codepoint;
157+
}
158+
159+
return buf.toOwnedSlice();
160+
}
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
const std = @import("std");
2+
3+
pub fn main() anyerror!void {
4+
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
5+
defer if (gpa.deinit() == .leak) @panic("found memory leaks");
6+
const allocator = gpa.allocator();
7+
8+
var it = try std.process.argsWithAllocator(allocator);
9+
defer it.deinit();
10+
_ = it.next() orelse unreachable; // skip binary name
11+
const child_exe_path = it.next() orelse unreachable;
12+
13+
var tmp = std.testing.tmpDir(.{});
14+
defer tmp.cleanup();
15+
16+
try tmp.dir.setAsCwd();
17+
defer tmp.parent_dir.setAsCwd() catch {};
18+
19+
var buf = try std.ArrayList(u8).initCapacity(allocator, 128);
20+
defer buf.deinit();
21+
try buf.appendSlice("@echo off\n");
22+
try buf.append('"');
23+
try buf.appendSlice(child_exe_path);
24+
try buf.append('"');
25+
const preamble_len = buf.items.len;
26+
27+
try buf.appendSlice(" %*");
28+
try tmp.dir.writeFile("args1.bat", buf.items);
29+
buf.shrinkRetainingCapacity(preamble_len);
30+
31+
try buf.appendSlice(" %1 %2 %3 %4 %5 %6 %7 %8 %9");
32+
try tmp.dir.writeFile("args2.bat", buf.items);
33+
buf.shrinkRetainingCapacity(preamble_len);
34+
35+
try buf.appendSlice(" \"%~1\" \"%~2\" \"%~3\" \"%~4\" \"%~5\" \"%~6\" \"%~7\" \"%~8\" \"%~9\"");
36+
try tmp.dir.writeFile("args3.bat", buf.items);
37+
buf.shrinkRetainingCapacity(preamble_len);
38+
39+
// Test cases are from https://github.com/rust-lang/rust/blob/master/tests/ui/std/windows-bat-args.rs
40+
try testExecError(error.InvalidBatchScriptArg, allocator, &.{"\x00"});
41+
try testExecError(error.InvalidBatchScriptArg, allocator, &.{"\n"});
42+
try testExecError(error.InvalidBatchScriptArg, allocator, &.{"\r"});
43+
try testExec(allocator, &.{ "a", "b" }, null);
44+
try testExec(allocator, &.{ "c is for cat", "d is for dog" }, null);
45+
try testExec(allocator, &.{ "\"", " \"" }, null);
46+
try testExec(allocator, &.{ "\\", "\\" }, null);
47+
try testExec(allocator, &.{">file.txt"}, null);
48+
try testExec(allocator, &.{"whoami.exe"}, null);
49+
try testExec(allocator, &.{"&a.exe"}, null);
50+
try testExec(allocator, &.{"&echo hello "}, null);
51+
try testExec(allocator, &.{ "&echo hello", "&whoami", ">file.txt" }, null);
52+
try testExec(allocator, &.{"!TMP!"}, null);
53+
try testExec(allocator, &.{"key=value"}, null);
54+
try testExec(allocator, &.{"\"key=value\""}, null);
55+
try testExec(allocator, &.{"key = value"}, null);
56+
try testExec(allocator, &.{"key=[\"value\"]"}, null);
57+
try testExec(allocator, &.{ "", "a=b" }, null);
58+
try testExec(allocator, &.{"key=\"foo bar\""}, null);
59+
try testExec(allocator, &.{"key=[\"my_value]"}, null);
60+
try testExec(allocator, &.{"key=[\"my_value\",\"other-value\"]"}, null);
61+
try testExec(allocator, &.{"key\\=value"}, null);
62+
try testExec(allocator, &.{"key=\"&whoami\""}, null);
63+
try testExec(allocator, &.{"key=\"value\"=5"}, null);
64+
try testExec(allocator, &.{"key=[\">file.txt\"]"}, null);
65+
try testExec(allocator, &.{"%hello"}, null);
66+
try testExec(allocator, &.{"%PATH%"}, null);
67+
try testExec(allocator, &.{"%%cd:~,%"}, null);
68+
try testExec(allocator, &.{"%PATH%PATH%"}, null);
69+
try testExec(allocator, &.{"\">file.txt"}, null);
70+
try testExec(allocator, &.{"abc\"&echo hello"}, null);
71+
try testExec(allocator, &.{"123\">file.txt"}, null);
72+
try testExec(allocator, &.{"\"&echo hello&whoami.exe"}, null);
73+
try testExec(allocator, &.{ "\"hello^\"world\"", "hello &echo oh no >file.txt" }, null);
74+
try testExec(allocator, &.{"&whoami.exe"}, null);
75+
76+
var env = env: {
77+
var env = try std.process.getEnvMap(allocator);
78+
errdefer env.deinit();
79+
// No escaping
80+
try env.put("FOO", "123");
81+
// Some possible escaping of %FOO% that could be expanded
82+
// when escaping cmd.exe meta characters with ^
83+
try env.put("FOO^", "123"); // only escaping %
84+
try env.put("^F^O^O^", "123"); // escaping every char
85+
break :env env;
86+
};
87+
defer env.deinit();
88+
try testExec(allocator, &.{"%FOO%"}, &env);
89+
90+
// Ensure that none of the `>file.txt`s have caused file.txt to be created
91+
try std.testing.expectError(error.FileNotFound, tmp.dir.access("file.txt", .{}));
92+
}
93+
94+
fn testExecError(err: anyerror, allocator: std.mem.Allocator, args: []const []const u8) !void {
95+
return std.testing.expectError(err, testExec(allocator, args, null));
96+
}
97+
98+
fn testExec(allocator: std.mem.Allocator, args: []const []const u8, env: ?*std.process.EnvMap) !void {
99+
try testExecBat(allocator, "args1.bat", args, env);
100+
try testExecBat(allocator, "args2.bat", args, env);
101+
try testExecBat(allocator, "args3.bat", args, env);
102+
}
103+
104+
fn testExecBat(allocator: std.mem.Allocator, bat: []const u8, args: []const []const u8, env: ?*std.process.EnvMap) !void {
105+
var argv = try std.ArrayList([]const u8).initCapacity(allocator, 1 + args.len);
106+
defer argv.deinit();
107+
argv.appendAssumeCapacity(bat);
108+
argv.appendSliceAssumeCapacity(args);
109+
110+
const can_have_trailing_empty_args = std.mem.eql(u8, bat, "args3.bat");
111+
112+
const result = try std.ChildProcess.run(.{
113+
.allocator = allocator,
114+
.env_map = env,
115+
.argv = argv.items,
116+
});
117+
defer allocator.free(result.stdout);
118+
defer allocator.free(result.stderr);
119+
120+
try std.testing.expectEqualStrings("", result.stderr);
121+
var it = std.mem.splitScalar(u8, result.stdout, '\x00');
122+
var i: usize = 0;
123+
while (it.next()) |actual_arg| {
124+
if (i >= args.len and can_have_trailing_empty_args) {
125+
try std.testing.expectEqualStrings("", actual_arg);
126+
continue;
127+
}
128+
const expected_arg = args[i];
129+
try std.testing.expectEqualStrings(expected_arg, actual_arg);
130+
i += 1;
131+
}
132+
}

0 commit comments

Comments
 (0)