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