1const std = @import("std");
2const Io = std.Io;
3const Allocator = std.mem.Allocator;
4
5const windows = std.os.windows;
6const utf16Literal = std.unicode.utf8ToUtf16LeStringLiteral;
7
8pub 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
182fn testExecError(err: anyerror, gpa: Allocator, io: Io, command: []const u8) !void {
183 return std.testing.expectError(err, testExec(gpa, io, command, ""));
184}
185
186fn testExec(gpa: Allocator, io: Io, command: []const u8, expected_stdout: []const u8) !void {
187 return testExecWithCwdInner(gpa, io, command, .inherit, expected_stdout);
188}
189
190fn 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
200fn 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
212fn 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
227pub extern "kernel32" fn SetEnvironmentVariableW(
228 lpName: windows.LPCWSTR,
229 lpValue: ?windows.LPCWSTR,
230) callconv(.winapi) windows.BOOL;