Commit 954f8db

Eric Bower  ·  2026-03-03 19:53:37 -0500 EST
parent ac857cf
fix(run): re-quote when using shell meta chars

argv doesn't receive the quotes in the command line so we need to apply an algorithm to re-quote args when it uses special meta-characters.

Closes: https://github.com/neurosnap/zmx/issues/72
1 files changed,  +111, -11
+111, -11
......@@ -274,7 +274,8 @@ const Daemon = struct {
274274 pub fn handleInfo(self: *Daemon, client: *Client) !void {
275275 const clients_len = self.clients.items.len - 1;
276276
277- // Build command string from args
277+ // Build command string from args, re-quoting args that contain
278+ // shell-special characters so the displayed command is copy-pasteable.
278279 var cmd_buf: [ipc.MAX_CMD_LEN]u8 = undefined;
279280 var cmd_len: u16 = 0;
280281 const cur_cmd = self.command orelse self.task_command;
......@@ -286,10 +287,19 @@ const Daemon = struct {
286287 cmd_len += 1;
287288 }
288289 }
289- const remaining = ipc.MAX_CMD_LEN - cmd_len;
290- const copy_len: u16 = @intCast(@min(arg.len, remaining));
291- @memcpy(cmd_buf[cmd_len..][0..copy_len], arg[0..copy_len]);
292- cmd_len += copy_len;
290+ if (shellNeedsQuoting(arg)) {
291+ const quoted = shellQuote(self.alloc, arg) catch arg;
292+ defer if (quoted.ptr != arg.ptr) self.alloc.free(quoted);
293+ const remaining = ipc.MAX_CMD_LEN - cmd_len;
294+ const copy_len: u16 = @intCast(@min(quoted.len, remaining));
295+ @memcpy(cmd_buf[cmd_len..][0..copy_len], quoted[0..copy_len]);
296+ cmd_len += copy_len;
297+ } else {
298+ const remaining = ipc.MAX_CMD_LEN - cmd_len;
299+ const copy_len: u16 = @intCast(@min(arg.len, remaining));
300+ @memcpy(cmd_buf[cmd_len..][0..copy_len], arg[0..copy_len]);
301+ cmd_len += copy_len;
302+ }
293303 }
294304 }
295305
......@@ -967,6 +977,79 @@ fn attach(daemon: *Daemon) !void {
967977 try clientLoop(daemon.cfg, client_sock);
968978 }
969979
980+fn shellNeedsQuoting(arg: []const u8) bool {
981+ if (arg.len == 0) return true;
982+ for (arg) |ch| {
983+ switch (ch) {
984+ ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
985+ else => {},
986+ }
987+ }
988+ return false;
989+}
990+
991+fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
992+ // Prefer double quotes when the arg has no double quotes (cleaner output).
993+ // Fall back to single quotes with '\'' escaping for embedded single quotes.
994+ const has_double_quote = std.mem.indexOfScalar(u8, arg, '"') != null;
995+
996+ if (!has_double_quote) {
997+ // Double-quote style: escape only $ ` \ ! "
998+ var len: usize = 2; // opening and closing "
999+ for (arg) |ch| {
1000+ if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
1001+ len += 2; // backslash + char
1002+ } else {
1003+ len += 1;
1004+ }
1005+ }
1006+ const buf = try alloc.alloc(u8, len);
1007+ var i: usize = 0;
1008+ buf[i] = '"';
1009+ i += 1;
1010+ for (arg) |ch| {
1011+ if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
1012+ buf[i] = '\\';
1013+ buf[i + 1] = ch;
1014+ i += 2;
1015+ } else {
1016+ buf[i] = ch;
1017+ i += 1;
1018+ }
1019+ }
1020+ buf[i] = '"';
1021+ return buf;
1022+ }
1023+
1024+ // Single-quote style: escape embedded single quotes as '\''
1025+ var len: usize = 2;
1026+ for (arg) |ch| {
1027+ if (ch == '\'') {
1028+ len += 4;
1029+ } else {
1030+ len += 1;
1031+ }
1032+ }
1033+ const buf = try alloc.alloc(u8, len);
1034+ var i: usize = 0;
1035+ buf[i] = '\'';
1036+ i += 1;
1037+ for (arg) |ch| {
1038+ if (ch == '\'') {
1039+ buf[i] = '\'';
1040+ buf[i + 1] = '\\';
1041+ buf[i + 2] = '\'';
1042+ buf[i + 3] = '\'';
1043+ i += 4;
1044+ } else {
1045+ buf[i] = ch;
1046+ i += 1;
1047+ }
1048+ }
1049+ buf[i] = '\'';
1050+ return buf;
1051+}
1052+
9701053 fn run(daemon: *Daemon, command_args: [][]const u8) !void {
9711054 const alloc = daemon.alloc;
9721055 var buf: [4096]u8 = undefined;
......@@ -985,19 +1068,36 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
9851068 defer if (allocated_cmd) |cmd| alloc.free(cmd);
9861069
9871070 if (command_args.len > 0) {
1071+ var parts: std.ArrayList([]const u8) = .empty;
1072+ defer {
1073+ for (parts.items) |part| alloc.free(part);
1074+ parts.deinit(alloc);
1075+ }
1076+
9881077 var total_len: usize = 0;
989- for (command_args) |arg| {
990- total_len += arg.len + 1;
1078+ for (command_args, 0..) |arg, i| {
1079+ // Last arg is the sentinel (e.g. "; echo ZMX_TASK_COMPLETED:$?")
1080+ // which must not be quoted so the shell interprets it.
1081+ const is_last = i == command_args.len - 1;
1082+ if (!is_last and shellNeedsQuoting(arg)) {
1083+ const quoted = try shellQuote(alloc, arg);
1084+ try parts.append(alloc, quoted);
1085+ total_len += quoted.len + 1;
1086+ } else {
1087+ const duped = try alloc.dupe(u8, arg);
1088+ try parts.append(alloc, duped);
1089+ total_len += duped.len + 1;
1090+ }
9911091 }
9921092
9931093 const cmd_buf = try alloc.alloc(u8, total_len);
9941094 allocated_cmd = cmd_buf;
9951095
9961096 var offset: usize = 0;
997- for (command_args, 0..) |arg, i| {
998- @memcpy(cmd_buf[offset .. offset + arg.len], arg);
999- offset += arg.len;
1000- if (i < command_args.len - 1) {
1097+ for (parts.items, 0..) |part, i| {
1098+ @memcpy(cmd_buf[offset .. offset + part.len], part);
1099+ offset += part.len;
1100+ if (i < parts.items.len - 1) {
10011101 cmd_buf[offset] = ' ';
10021102 } else {
10031103 cmd_buf[offset] = '\n';