master
  1const std = @import("std");
  2
  3pub fn main() anyerror!void {
  4    var debug_alloc_inst: std.heap.DebugAllocator(.{}) = .init;
  5    defer std.debug.assert(debug_alloc_inst.deinit() == .ok);
  6    const gpa = debug_alloc_inst.allocator();
  7
  8    var it = try std.process.argsWithAllocator(gpa);
  9    defer it.deinit();
 10    _ = it.next() orelse unreachable; // skip binary name
 11    const child_exe_path_orig = 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    // `child_exe_path_orig` might be relative; make it relative to our new cwd.
 20    const child_exe_path = try std.fs.path.resolve(gpa, &.{ "..\\..\\..", child_exe_path_orig });
 21    defer gpa.free(child_exe_path);
 22
 23    var buf: std.ArrayList(u8) = .empty;
 24    defer buf.deinit(gpa);
 25    try buf.print(gpa,
 26        \\@echo off
 27        \\"{s}"
 28    , .{child_exe_path});
 29    // Trailing newline intentionally omitted above so we can add args.
 30    const preamble_len = buf.items.len;
 31
 32    try buf.appendSlice(gpa, " %*");
 33    try tmp.dir.writeFile(.{ .sub_path = "args1.bat", .data = buf.items });
 34    buf.shrinkRetainingCapacity(preamble_len);
 35
 36    try buf.appendSlice(gpa, " %1 %2 %3 %4 %5 %6 %7 %8 %9");
 37    try tmp.dir.writeFile(.{ .sub_path = "args2.bat", .data = buf.items });
 38    buf.shrinkRetainingCapacity(preamble_len);
 39
 40    try buf.appendSlice(gpa, " \"%~1\" \"%~2\" \"%~3\" \"%~4\" \"%~5\" \"%~6\" \"%~7\" \"%~8\" \"%~9\"");
 41    try tmp.dir.writeFile(.{ .sub_path = "args3.bat", .data = buf.items });
 42    buf.shrinkRetainingCapacity(preamble_len);
 43
 44    // Test cases are from https://github.com/rust-lang/rust/blob/master/tests/ui/std/windows-bat-args.rs
 45    try testExecError(error.InvalidBatchScriptArg, gpa, &.{"\x00"});
 46    try testExecError(error.InvalidBatchScriptArg, gpa, &.{"\n"});
 47    try testExecError(error.InvalidBatchScriptArg, gpa, &.{"\r"});
 48    try testExec(gpa, &.{ "a", "b" }, null);
 49    try testExec(gpa, &.{ "c is for cat", "d is for dog" }, null);
 50    try testExec(gpa, &.{ "\"", " \"" }, null);
 51    try testExec(gpa, &.{ "\\", "\\" }, null);
 52    try testExec(gpa, &.{">file.txt"}, null);
 53    try testExec(gpa, &.{"whoami.exe"}, null);
 54    try testExec(gpa, &.{"&a.exe"}, null);
 55    try testExec(gpa, &.{"&echo hello "}, null);
 56    try testExec(gpa, &.{ "&echo hello", "&whoami", ">file.txt" }, null);
 57    try testExec(gpa, &.{"!TMP!"}, null);
 58    try testExec(gpa, &.{"key=value"}, null);
 59    try testExec(gpa, &.{"\"key=value\""}, null);
 60    try testExec(gpa, &.{"key = value"}, null);
 61    try testExec(gpa, &.{"key=[\"value\"]"}, null);
 62    try testExec(gpa, &.{ "", "a=b" }, null);
 63    try testExec(gpa, &.{"key=\"foo bar\""}, null);
 64    try testExec(gpa, &.{"key=[\"my_value]"}, null);
 65    try testExec(gpa, &.{"key=[\"my_value\",\"other-value\"]"}, null);
 66    try testExec(gpa, &.{"key\\=value"}, null);
 67    try testExec(gpa, &.{"key=\"&whoami\""}, null);
 68    try testExec(gpa, &.{"key=\"value\"=5"}, null);
 69    try testExec(gpa, &.{"key=[\">file.txt\"]"}, null);
 70    try testExec(gpa, &.{"%hello"}, null);
 71    try testExec(gpa, &.{"%PATH%"}, null);
 72    try testExec(gpa, &.{"%%cd:~,%"}, null);
 73    try testExec(gpa, &.{"%PATH%PATH%"}, null);
 74    try testExec(gpa, &.{"\">file.txt"}, null);
 75    try testExec(gpa, &.{"abc\"&echo hello"}, null);
 76    try testExec(gpa, &.{"123\">file.txt"}, null);
 77    try testExec(gpa, &.{"\"&echo hello&whoami.exe"}, null);
 78    try testExec(gpa, &.{ "\"hello^\"world\"", "hello &echo oh no >file.txt" }, null);
 79    try testExec(gpa, &.{"&whoami.exe"}, null);
 80
 81    // Ensure that trailing space and . characters can't lead to unexpected bat/cmd script execution.
 82    // In many Windows APIs (including CreateProcess), trailing space and . characters are stripped
 83    // from paths, so if a path with trailing . and space character(s) is passed directly to
 84    // CreateProcess, then it could end up executing a batch/cmd script that naive extension detection
 85    // would not flag as .bat/.cmd.
 86    //
 87    // Note that we expect an error here, though, which *is* a valid mitigation, but also an implementation detail.
 88    // This error is caused by the use of a wildcard with NtQueryDirectoryFile to optimize PATHEXT searching. That is,
 89    // the trailing characters in the app name will lead to a FileNotFound error as the wildcard-appended path will not
 90    // match any real paths on the filesystem (e.g. `foo.bat .. *` will not match `foo.bat`; only `foo.bat*` will).
 91    //
 92    // This being an error matches the behavior of running a command via the command line of cmd.exe, too:
 93    //
 94    //     > "args1.bat .. "
 95    //     '"args1.bat .. "' is not recognized as an internal or external command,
 96    //     operable program or batch file.
 97    try std.testing.expectError(error.FileNotFound, testExecBat(gpa, "args1.bat .. ", &.{"abc"}, null));
 98    const absolute_with_trailing = blk: {
 99        const absolute_path = try std.fs.realpathAlloc(gpa, "args1.bat");
100        defer gpa.free(absolute_path);
101        break :blk try std.mem.concat(gpa, u8, &.{ absolute_path, " .. " });
102    };
103    defer gpa.free(absolute_with_trailing);
104    try std.testing.expectError(error.FileNotFound, testExecBat(gpa, absolute_with_trailing, &.{"abc"}, null));
105
106    var env = env: {
107        var env = try std.process.getEnvMap(gpa);
108        errdefer env.deinit();
109        // No escaping
110        try env.put("FOO", "123");
111        // Some possible escaping of %FOO% that could be expanded
112        // when escaping cmd.exe meta characters with ^
113        try env.put("FOO^", "123"); // only escaping %
114        try env.put("^F^O^O^", "123"); // escaping every char
115        break :env env;
116    };
117    defer env.deinit();
118    try testExec(gpa, &.{"%FOO%"}, &env);
119
120    // Ensure that none of the `>file.txt`s have caused file.txt to be created
121    try std.testing.expectError(error.FileNotFound, tmp.dir.access("file.txt", .{}));
122}
123
124fn testExecError(err: anyerror, gpa: std.mem.Allocator, args: []const []const u8) !void {
125    return std.testing.expectError(err, testExec(gpa, args, null));
126}
127
128fn testExec(gpa: std.mem.Allocator, args: []const []const u8, env: ?*std.process.EnvMap) !void {
129    try testExecBat(gpa, "args1.bat", args, env);
130    try testExecBat(gpa, "args2.bat", args, env);
131    try testExecBat(gpa, "args3.bat", args, env);
132}
133
134fn testExecBat(gpa: std.mem.Allocator, bat: []const u8, args: []const []const u8, env: ?*std.process.EnvMap) !void {
135    const argv = try gpa.alloc([]const u8, 1 + args.len);
136    defer gpa.free(argv);
137    argv[0] = bat;
138    @memcpy(argv[1..], args);
139
140    const can_have_trailing_empty_args = std.mem.eql(u8, bat, "args3.bat");
141
142    const result = try std.process.Child.run(.{
143        .allocator = gpa,
144        .env_map = env,
145        .argv = argv,
146    });
147    defer gpa.free(result.stdout);
148    defer gpa.free(result.stderr);
149
150    try std.testing.expectEqualStrings("", result.stderr);
151    var it = std.mem.splitScalar(u8, result.stdout, '\x00');
152    var i: usize = 0;
153    while (it.next()) |actual_arg| {
154        if (i >= args.len and can_have_trailing_empty_args) {
155            try std.testing.expectEqualStrings("", actual_arg);
156            continue;
157        }
158        const expected_arg = args[i];
159        try std.testing.expectEqualStrings(expected_arg, actual_arg);
160        i += 1;
161    }
162}