master
  1const std = @import("std");
  2
  3const windows = std.os.windows;
  4const utf16Literal = std.unicode.utf8ToUtf16LeStringLiteral;
  5
  6pub fn main() anyerror!void {
  7    var gpa: std.heap.GeneralPurposeAllocator(.{}) = .init;
  8    defer if (gpa.deinit() == .leak) @panic("found memory leaks");
  9    const allocator = gpa.allocator();
 10
 11    var it = try std.process.argsWithAllocator(allocator);
 12    defer it.deinit();
 13    _ = it.next() orelse unreachable; // skip binary name
 14    const hello_exe_cache_path = it.next() orelse unreachable;
 15
 16    var tmp = std.testing.tmpDir(.{});
 17    defer tmp.cleanup();
 18
 19    const tmp_absolute_path = try tmp.dir.realpathAlloc(allocator, ".");
 20    defer allocator.free(tmp_absolute_path);
 21    const tmp_absolute_path_w = try std.unicode.utf8ToUtf16LeAllocZ(allocator, tmp_absolute_path);
 22    defer allocator.free(tmp_absolute_path_w);
 23    const cwd_absolute_path = try std.fs.cwd().realpathAlloc(allocator, ".");
 24    defer allocator.free(cwd_absolute_path);
 25    const tmp_relative_path = try std.fs.path.relative(allocator, cwd_absolute_path, tmp_absolute_path);
 26    defer allocator.free(tmp_relative_path);
 27
 28    // Clear PATH
 29    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
 30        utf16Literal("PATH"),
 31        null,
 32    ) == windows.TRUE);
 33
 34    // Set PATHEXT to something predictable
 35    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
 36        utf16Literal("PATHEXT"),
 37        utf16Literal(".COM;.EXE;.BAT;.CMD;.JS"),
 38    ) == windows.TRUE);
 39
 40    // No PATH, so it should fail to find anything not in the cwd
 41    try testExecError(error.FileNotFound, allocator, "something_missing");
 42
 43    // make sure we don't get error.BadPath traversing out of cwd with a relative path
 44    try testExecError(error.FileNotFound, allocator, "..\\.\\.\\.\\\\..\\more_missing");
 45
 46    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
 47        utf16Literal("PATH"),
 48        tmp_absolute_path_w,
 49    ) == windows.TRUE);
 50
 51    // Move hello.exe into the tmp dir which is now added to the path
 52    try std.fs.cwd().copyFile(hello_exe_cache_path, tmp.dir, "hello.exe", .{});
 53
 54    // with extension should find the .exe (case insensitive)
 55    try testExec(allocator, "HeLLo.exe", "hello from exe\n");
 56    // without extension should find the .exe (case insensitive)
 57    try testExec(allocator, "heLLo", "hello from exe\n");
 58    // with invalid cwd
 59    try std.testing.expectError(error.FileNotFound, testExecWithCwd(allocator, "hello.exe", "missing_dir", ""));
 60
 61    // now add a .bat
 62    try tmp.dir.writeFile(.{ .sub_path = "hello.bat", .data = "@echo hello from bat" });
 63    // and a .cmd
 64    try tmp.dir.writeFile(.{ .sub_path = "hello.cmd", .data = "@echo hello from cmd" });
 65
 66    // with extension should find the .bat (case insensitive)
 67    try testExec(allocator, "heLLo.bat", "hello from bat\r\n");
 68    // with extension should find the .cmd (case insensitive)
 69    try testExec(allocator, "heLLo.cmd", "hello from cmd\r\n");
 70    // without extension should find the .exe (since its first in PATHEXT)
 71    try testExec(allocator, "heLLo", "hello from exe\n");
 72
 73    // now rename the exe to not have an extension
 74    try renameExe(tmp.dir, "hello.exe", "hello");
 75
 76    // with extension should now fail
 77    try testExecError(error.FileNotFound, allocator, "hello.exe");
 78    // without extension should succeed (case insensitive)
 79    try testExec(allocator, "heLLo", "hello from exe\n");
 80
 81    try tmp.dir.makeDir("something");
 82    try renameExe(tmp.dir, "hello", "something/hello.exe");
 83
 84    const relative_path_no_ext = try std.fs.path.join(allocator, &.{ tmp_relative_path, "something/hello" });
 85    defer allocator.free(relative_path_no_ext);
 86
 87    // Giving a full relative path to something/hello should work
 88    try testExec(allocator, relative_path_no_ext, "hello from exe\n");
 89    // But commands with path separators get excluded from PATH searching, so this will fail
 90    try testExecError(error.FileNotFound, allocator, "something/hello");
 91
 92    // Now that .BAT is the first PATHEXT that should be found, this should succeed
 93    try testExec(allocator, "heLLo", "hello from bat\r\n");
 94
 95    // Add a hello.exe that is not a valid executable
 96    try tmp.dir.writeFile(.{ .sub_path = "hello.exe", .data = "invalid" });
 97
 98    // Trying to execute it with extension will give InvalidExe. This is a special
 99    // case for .EXE extensions, where if they ever try to get executed but they are
100    // invalid, that gets treated as a fatal error wherever they are found and InvalidExe
101    // is returned immediately.
102    try testExecError(error.InvalidExe, allocator, "hello.exe");
103    // Same thing applies to the command with no extension--even though there is a
104    // hello.bat that could be executed, it should stop after it tries executing
105    // hello.exe and getting InvalidExe.
106    try testExecError(error.InvalidExe, allocator, "hello");
107
108    // If we now rename hello.exe to have no extension, it will behave differently
109    try renameExe(tmp.dir, "hello.exe", "hello");
110
111    // Now, trying to execute it without an extension should treat InvalidExe as recoverable
112    // and skip over it and find hello.bat and execute that
113    try testExec(allocator, "hello", "hello from bat\r\n");
114
115    // If we rename the invalid exe to something else
116    try renameExe(tmp.dir, "hello", "goodbye");
117    // Then we should now get FileNotFound when trying to execute 'goodbye',
118    // since that is what the original error will be after searching for 'goodbye'
119    // in the cwd. It will try to execute 'goodbye' from the PATH but the InvalidExe error
120    // should be ignored in this case.
121    try testExecError(error.FileNotFound, allocator, "goodbye");
122
123    // Now let's set the tmp dir as the cwd and set the path only include the "something" sub dir
124    try tmp.dir.setAsCwd();
125    defer tmp.parent_dir.setAsCwd() catch {};
126    const something_subdir_abs_path = try std.mem.concatWithSentinel(allocator, u16, &.{ tmp_absolute_path_w, utf16Literal("\\something") }, 0);
127    defer allocator.free(something_subdir_abs_path);
128
129    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
130        utf16Literal("PATH"),
131        something_subdir_abs_path,
132    ) == windows.TRUE);
133
134    // Now trying to execute goodbye should give error.InvalidExe since it's the original
135    // error that we got when trying within the cwd
136    try testExecError(error.InvalidExe, allocator, "goodbye");
137
138    // hello should still find the .bat
139    try testExec(allocator, "hello", "hello from bat\r\n");
140
141    // If we rename something/hello.exe to something/goodbye.exe
142    try renameExe(tmp.dir, "something/hello.exe", "something/goodbye.exe");
143    // And try to execute goodbye, then the one in something should be found
144    // since the one in cwd is an invalid executable
145    try testExec(allocator, "goodbye", "hello from exe\n");
146
147    // If we use an absolute path to execute the invalid goodbye
148    const goodbye_abs_path = try std.mem.join(allocator, "\\", &.{ tmp_absolute_path, "goodbye" });
149    defer allocator.free(goodbye_abs_path);
150    // then the PATH should not be searched and we should get InvalidExe
151    try testExecError(error.InvalidExe, allocator, goodbye_abs_path);
152
153    // If we try to exec but provide a cwd that is an absolute path, the PATH
154    // should still be searched and the goodbye.exe in something should be found.
155    try testExecWithCwd(allocator, "goodbye", tmp_absolute_path, "hello from exe\n");
156
157    // introduce some extra path separators into the path which is dealt with inside the spawn call.
158    const denormed_something_subdir_size = std.mem.replacementSize(u16, something_subdir_abs_path, utf16Literal("\\"), utf16Literal("\\\\\\\\"));
159
160    const denormed_something_subdir_abs_path = try allocator.allocSentinel(u16, denormed_something_subdir_size, 0);
161    defer allocator.free(denormed_something_subdir_abs_path);
162
163    _ = std.mem.replace(u16, something_subdir_abs_path, utf16Literal("\\"), utf16Literal("\\\\\\\\"), denormed_something_subdir_abs_path);
164
165    const denormed_something_subdir_wtf8 = try std.unicode.wtf16LeToWtf8Alloc(allocator, denormed_something_subdir_abs_path);
166    defer allocator.free(denormed_something_subdir_wtf8);
167
168    // clear the path to ensure that the match comes from the cwd
169    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
170        utf16Literal("PATH"),
171        null,
172    ) == windows.TRUE);
173
174    try testExecWithCwd(allocator, "goodbye", denormed_something_subdir_wtf8, "hello from exe\n");
175
176    // normalization should also work if the non-normalized path is found in the PATH var.
177    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
178        utf16Literal("PATH"),
179        denormed_something_subdir_abs_path,
180    ) == windows.TRUE);
181    try testExec(allocator, "goodbye", "hello from exe\n");
182
183    // now make sure we can launch executables "outside" of the cwd
184    var subdir_cwd = try tmp.dir.openDir(denormed_something_subdir_wtf8, .{});
185    defer subdir_cwd.close();
186
187    try renameExe(tmp.dir, "something/goodbye.exe", "hello.exe");
188    try subdir_cwd.setAsCwd();
189
190    // clear the PATH again
191    std.debug.assert(windows.kernel32.SetEnvironmentVariableW(
192        utf16Literal("PATH"),
193        null,
194    ) == windows.TRUE);
195
196    // while we're at it make sure non-windows separators work fine
197    try testExec(allocator, "../hello", "hello from exe\n");
198}
199
200fn testExecError(err: anyerror, allocator: std.mem.Allocator, command: []const u8) !void {
201    return std.testing.expectError(err, testExec(allocator, command, ""));
202}
203
204fn testExec(allocator: std.mem.Allocator, command: []const u8, expected_stdout: []const u8) !void {
205    return testExecWithCwd(allocator, command, null, expected_stdout);
206}
207
208fn testExecWithCwd(allocator: std.mem.Allocator, command: []const u8, cwd: ?[]const u8, expected_stdout: []const u8) !void {
209    const result = try std.process.Child.run(.{
210        .allocator = allocator,
211        .argv = &[_][]const u8{command},
212        .cwd = cwd,
213    });
214    defer allocator.free(result.stdout);
215    defer allocator.free(result.stderr);
216
217    try std.testing.expectEqualStrings("", result.stderr);
218    try std.testing.expectEqualStrings(expected_stdout, result.stdout);
219}
220
221fn renameExe(dir: std.fs.Dir, old_sub_path: []const u8, new_sub_path: []const u8) !void {
222    var attempt: u5 = 0;
223    while (true) break dir.rename(old_sub_path, new_sub_path) catch |err| switch (err) {
224        error.AccessDenied => {
225            if (attempt == 13) return error.AccessDenied;
226            // give the kernel a chance to finish closing the executable handle
227            _ = std.os.windows.kernel32.SleepEx(@as(u32, 1) << attempt >> 1, std.os.windows.FALSE);
228            attempt += 1;
229            continue;
230        },
231        else => |e| return e,
232    };
233}