ReviewOS

also looking at this

craft-native/craft

fix(shell): actually pass spawn its arguments

#77
Merged glennmichael123 wants to merge fix/shell-spawn-args into main
4 files +284 -11
packages/zig/src/bridge_shell.zigmodified+247-7
Changes to packages/zig/src/bridge_shell.zig
@@ -111,6 +111,13 @@ pub const ShellBridge = struct {
111111 }
112112
113113 /// Shell argument tuple for cross-platform support.
114 /// Most arguments a page may hand to one `spawn`.
115 ///
116 /// Generous for any real command and bounded because the array comes from
117 /// the page: without a limit a single message can ask craft to build an
118 /// argv as large as the JSON it is willing to parse.
119 const max_spawn_args = 256;
120
114121 const ShellArgs = struct { argv: [3][]const u8 };
115122
116123 /// Return the platform-appropriate shell invocation for a command string.
@@ -121,6 +128,48 @@ pub const ShellBridge = struct {
121128 };
122129 }
123130
131 /// The argv a spawn should run, and the only place that decision is made.
132 ///
133 /// Pure and separated from `spawn` so it can be tested without starting a
134 /// process: the interesting behaviour is entirely in what ends up in the
135 /// array, and nothing about that needs a running child to check.
136 fn buildSpawnArgv(
137 allocator: std.mem.Allocator,
138 command: []const u8,
139 args: []const []const u8,
140 use_shell: bool,
141 ) BridgeError!std.ArrayListUnmanaged([]const u8) {
142 var argv: std.ArrayListUnmanaged([]const u8) = .empty;
143 errdefer argv.deinit(allocator);
144
145 if (use_shell) {
146 // A command line run by the platform shell. Arguments have no
147 // meaning here — `sh -c` takes one string — and silently ignoring
148 // them is the exact bug this change exists to fix, so refuse
149 // rather than half-honour them.
150 if (args.len > 0) {
151 std.log.warn("bridge_shell: spawn with shell:true cannot take args; pass one command string", .{});
152 return BridgeError.InvalidParameter;
153 }
154 try validateCommand(command);
155 const shell = getShellArgs(command);
156 argv.appendSlice(allocator, &shell.argv) catch return BridgeError.AllocationFailed;
157 return argv;
158 }
159
160 // Executed directly: argv[0] is the program, the rest are its
161 // arguments, and no shell ever sees them.
162 //
163 // Deliberately not `validateCommand`. That blocklist exists to stop
164 // `sh -c` interpreting metacharacters; with no shell in the picture a
165 // `;` in an argument is a semicolon the program receives, and
166 // rejecting it would refuse ordinary commands to defend against
167 // nothing.
168 argv.append(allocator, command) catch return BridgeError.AllocationFailed;
169 argv.appendSlice(allocator, args) catch return BridgeError.AllocationFailed;
170 return argv;
171 }
172
124173 /// Validate a command string for dangerous shell metacharacters.
125174 /// Returns error.UnsafeCommand if the command contains injection patterns.
126175 ///
@@ -260,13 +309,44 @@ pub const ShellBridge = struct {
260309 self.sendShellResult(callback_id, "exec", exit_code, stdout, stderr);
261310 }
262311
263 /// Spawn a process without waiting
264 /// JSON: {"id": "proc1", "command": "long-running-cmd", "cwd": "/path"}
312 /// Spawn a process without waiting.
313 ///
314 /// JSON: {"id", "command", "args": [..], "cwd", "shell": false}
315 ///
316 /// `command` is an executable and `args` are its arguments — the shape both
317 /// `craft.shell.spawn(id, cmd, args)` and the SDK's
318 /// `spawn(command, args?, options?)` have always advertised. The arguments
319 /// were parsed and thrown away: `SpawnParams` did not declare `args`, and
320 /// `ignore_unknown_fields` dropped it without a word, so
321 /// `craft.shell.spawn('p', 'git', ['status'])` ran `git` bare and reported
322 /// success.
323 ///
324 /// ## Why this no longer goes through a shell
325 ///
326 /// It used to run `/bin/sh -c <command>`, which is why the arguments had
327 /// nowhere to go — a shell takes one string, not an argv. Appending them to
328 /// that string would have meant quoting every argument correctly forever,
329 /// on two platforms, against page-supplied input, to avoid handing a web
330 /// page a shell. Executing the program directly removes the shell, and with
331 /// it the entire class: `;`, `|` and `$(...)` inside an argument are now
332 /// just bytes the program receives.
333 ///
334 /// `shell: true` asks for the old behaviour explicitly, for a caller who
335 /// genuinely wants a command line. That path keeps the metacharacter
336 /// blocklist, because that is the path that needs it.
337 ///
338 /// This changes what `spawn('p', 'git status')` does: it used to work,
339 /// because `sh` split the string, and now looks for a program named
340 /// "git status". That is the same break Node made deliberate — the fix is
341 /// `spawn('p', 'git', ['status'])`, which is what the signature asked for
342 /// all along.
265343 fn spawn(self: *Self, data: []const u8) !void {
266344 const SpawnParams = struct {
267345 id: []const u8 = "",
268346 command: []const u8 = "",
269347 cwd: []const u8 = "",
348 args: []const []const u8 = &.{},
349 shell: bool = false,
270350 };
271351
272352 const parsed = std.json.parseFromSlice(SpawnParams, self.allocator, data, .{
@@ -282,18 +362,23 @@ pub const ShellBridge = struct {
282362
283363 if (id.len == 0 or command.len == 0) return BridgeError.MissingData;
284364
285 try validateCommand(command);
365 // Bounded so a page cannot ask craft to build an unbounded argv.
366 if (params.args.len > max_spawn_args) {
367 std.log.warn("bridge_shell: spawn refused, {d} arguments exceeds {d}", .{ params.args.len, max_spawn_args });
368 return BridgeError.InvalidParameter;
369 }
370
371 var argv = try buildSpawnArgv(self.allocator, command, params.args, params.shell);
372 defer argv.deinit(self.allocator);
286373
287374 if (comptime builtin.mode == .debug)
288 std.debug.print("[ShellBridge] spawn: {s} -> {s}\n", .{ id, command });
375 std.debug.print("[ShellBridge] spawn: {s} -> {s} ({d} args, shell={})\n", .{ id, command, params.args.len, params.shell });
289376
290 // Build argv array using cross-platform shell args
291 const shell = getShellArgs(command);
292377 const io = io_context.get();
293378
294379 // Create child process using Zig 0.16 API
295380 const child = std.process.spawn(io, .{
296 .argv = &shell.argv,
381 .argv = argv.items,
297382 .cwd = if (cwd.len > 0) .{ .path = cwd } else .inherit,
298383 .stdout = .inherit,
299384 .stderr = .inherit,
@@ -798,3 +883,158 @@ pub fn getGlobalShellBridge() ?*ShellBridge {
798883pub fn setGlobalShellBridge(bridge: *ShellBridge) void {
799884 global_state.instance.setShellBridge(bridge);
800885}
886
887// =============================================================================
888// Tests
889// =============================================================================
890//
891// `spawn` had no tests, which is how it shipped parsing an `args` array and
892// throwing it away. The decision worth testing is what ends up in the argv, and
893// none of that needs a child process.
894
895const testing = std.testing;
896
897fn argvFor(command: []const u8, args: []const []const u8, use_shell: bool) !std.ArrayListUnmanaged([]const u8) {
898 return ShellBridge.buildSpawnArgv(testing.allocator, command, args, use_shell);
899}
900
901test "arguments reach the program instead of being dropped" {
902 // The bug: `craft.shell.spawn('p', 'git', ['status'])` ran `git` bare and
903 // reported success, because SpawnParams never declared `args`.
904 var argv = try argvFor("git", &.{ "status", "--porcelain" }, false);
905 defer argv.deinit(testing.allocator);
906
907 try testing.expectEqual(@as(usize, 3), argv.items.len);
908 try testing.expectEqualStrings("git", argv.items[0]);
909 try testing.expectEqualStrings("status", argv.items[1]);
910 try testing.expectEqualStrings("--porcelain", argv.items[2]);
911}
912
913test "no shell is involved, so no shell metacharacters are interpreted" {
914 // These would each have been an injection through `sh -c` had the
915 // arguments been appended to a command string. Executed directly they are
916 // bytes the program receives, so they are passed through untouched rather
917 // than rejected.
918 const nasty = [_][]const u8{ "; rm -rf /", "$(whoami)", "a|b", "x>y", "`id`", "&& curl evil" };
919 var argv = try argvFor("echo", &nasty, false);
920 defer argv.deinit(testing.allocator);
921
922 try testing.expectEqual(nasty.len + 1, argv.items.len);
923 for (nasty, 0..) |expected, i| {
924 try testing.expectEqualStrings(expected, argv.items[i + 1]);
925 }
926 // And nothing that could run them is in the argv.
927 try testing.expectEqualStrings("echo", argv.items[0]);
928 for (argv.items) |a| {
929 try testing.expect(!std.mem.eql(u8, a, "/bin/sh"));
930 try testing.expect(!std.mem.eql(u8, a, "cmd.exe"));
931 try testing.expect(!std.mem.eql(u8, a, "-c"));
932 }
933}
934
935test "a program with no arguments is just the program" {
936 var argv = try argvFor("/usr/bin/true", &.{}, false);
937 defer argv.deinit(testing.allocator);
938 try testing.expectEqual(@as(usize, 1), argv.items.len);
939 try testing.expectEqualStrings("/usr/bin/true", argv.items[0]);
940}
941
942test "shell:true still runs a command line through the shell" {
943 var argv = try argvFor("echo hello", &.{}, true);
944 defer argv.deinit(testing.allocator);
945
946 try testing.expectEqual(@as(usize, 3), argv.items.len);
947 const shell_path = if (builtin.os.tag == .windows) "cmd.exe" else "/bin/sh";
948 const shell_flag = if (builtin.os.tag == .windows) "/c" else "-c";
949 try testing.expectEqualStrings(shell_path, argv.items[0]);
950 try testing.expectEqualStrings(shell_flag, argv.items[1]);
951 try testing.expectEqualStrings("echo hello", argv.items[2]);
952}
953
954test "shell:true keeps the blocklist that path needs" {
955 // The metacharacter check is not dropped, it is moved to the only place it
956 // defends anything: the path that hands a string to a shell.
957 try testing.expectError(BridgeError.UnsafeCommand, argvFor("ls; rm -rf /", &.{}, true));
958 try testing.expectError(BridgeError.UnsafeCommand, argvFor("echo $(whoami)", &.{}, true));
959 try testing.expectError(BridgeError.UnsafeCommand, argvFor("a | b", &.{}, true));
960}
961
962test "asking for a shell and arguments at once is refused, not half-honoured" {
963 // `sh -c` takes one string. Quietly ignoring the arguments is exactly the
964 // failure this change exists to remove, so it must not be reintroduced by
965 // the compatibility path.
966 try testing.expectError(
967 BridgeError.InvalidParameter,
968 argvFor("git", &.{"status"}, true),
969 );
970}
971
972test "the argv is built in order, and order is the whole meaning" {
973 var argv = try argvFor("cmd", &.{ "1", "2", "3", "4", "5" }, false);
974 defer argv.deinit(testing.allocator);
975 const expected = [_][]const u8{ "cmd", "1", "2", "3", "4", "5" };
976 try testing.expectEqual(expected.len, argv.items.len);
977 for (expected, 0..) |e, i| try testing.expectEqualStrings(e, argv.items[i]);
978}
979
980test "an empty argument is still an argument" {
981 // `find . -name ""` is meaningful; dropping empty strings would silently
982 // change the command.
983 var argv = try argvFor("find", &.{ ".", "-name", "" }, false);
984 defer argv.deinit(testing.allocator);
985 try testing.expectEqual(@as(usize, 4), argv.items.len);
986 try testing.expectEqualStrings("", argv.items[3]);
987}
988
989test "the JSON the page sends decodes into the arguments the argv is built from" {
990 // The seam the bug lived in: the payload was parsed correctly and the
991 // struct simply had nowhere to put `args`, so `ignore_unknown_fields`
992 // dropped them without a word.
993 const Params = struct {
994 id: []const u8 = "",
995 command: []const u8 = "",
996 cwd: []const u8 = "",
997 args: []const []const u8 = &.{},
998 shell: bool = false,
999 };
1000 const payload =
1001 \\{"id":"p1","command":"git","args":["status","--porcelain"],"cwd":"/tmp"}
1002 ;
1003 const parsed = try std.json.parseFromSlice(Params, testing.allocator, payload, .{
1004 .ignore_unknown_fields = true,
1005 .allocate = .alloc_always,
1006 });
1007 defer parsed.deinit();
1008
1009 try testing.expectEqual(@as(usize, 2), parsed.value.args.len);
1010 try testing.expectEqualStrings("status", parsed.value.args[0]);
1011 try testing.expect(!parsed.value.shell);
1012
1013 var argv = try argvFor(parsed.value.command, parsed.value.args, parsed.value.shell);
1014 defer argv.deinit(testing.allocator);
1015 try testing.expectEqual(@as(usize, 3), argv.items.len);
1016 try testing.expectEqualStrings("--porcelain", argv.items[2]);
1017}
1018
1019test "a payload from before this change still spawns the same program" {
1020 // No `args` key at all: an older page, or any caller that never used them.
1021 const Params = struct {
1022 id: []const u8 = "",
1023 command: []const u8 = "",
1024 args: []const []const u8 = &.{},
1025 shell: bool = false,
1026 };
1027 const payload =
1028 \\{"id":"p1","command":"/usr/bin/true"}
1029 ;
1030 const parsed = try std.json.parseFromSlice(Params, testing.allocator, payload, .{
1031 .ignore_unknown_fields = true,
1032 .allocate = .alloc_always,
1033 });
1034 defer parsed.deinit();
1035
1036 var argv = try argvFor(parsed.value.command, parsed.value.args, parsed.value.shell);
1037 defer argv.deinit(testing.allocator);
1038 try testing.expectEqual(@as(usize, 1), argv.items.len);
1039 try testing.expectEqualStrings("/usr/bin/true", argv.items[0]);
1040}