- commit
- 954f8db
- parent
- ac857cf
- author
- Eric Bower
- date
- 2026-03-03 19:53:37 -0500 EST
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
1@@ -274,7 +274,8 @@ const Daemon = struct {
2 pub fn handleInfo(self: *Daemon, client: *Client) !void {
3 const clients_len = self.clients.items.len - 1;
4
5- // Build command string from args
6+ // Build command string from args, re-quoting args that contain
7+ // shell-special characters so the displayed command is copy-pasteable.
8 var cmd_buf: [ipc.MAX_CMD_LEN]u8 = undefined;
9 var cmd_len: u16 = 0;
10 const cur_cmd = self.command orelse self.task_command;
11@@ -286,10 +287,19 @@ const Daemon = struct {
12 cmd_len += 1;
13 }
14 }
15- const remaining = ipc.MAX_CMD_LEN - cmd_len;
16- const copy_len: u16 = @intCast(@min(arg.len, remaining));
17- @memcpy(cmd_buf[cmd_len..][0..copy_len], arg[0..copy_len]);
18- cmd_len += copy_len;
19+ if (shellNeedsQuoting(arg)) {
20+ const quoted = shellQuote(self.alloc, arg) catch arg;
21+ defer if (quoted.ptr != arg.ptr) self.alloc.free(quoted);
22+ const remaining = ipc.MAX_CMD_LEN - cmd_len;
23+ const copy_len: u16 = @intCast(@min(quoted.len, remaining));
24+ @memcpy(cmd_buf[cmd_len..][0..copy_len], quoted[0..copy_len]);
25+ cmd_len += copy_len;
26+ } else {
27+ const remaining = ipc.MAX_CMD_LEN - cmd_len;
28+ const copy_len: u16 = @intCast(@min(arg.len, remaining));
29+ @memcpy(cmd_buf[cmd_len..][0..copy_len], arg[0..copy_len]);
30+ cmd_len += copy_len;
31+ }
32 }
33 }
34
35@@ -967,6 +977,79 @@ fn attach(daemon: *Daemon) !void {
36 try clientLoop(daemon.cfg, client_sock);
37 }
38
39+fn shellNeedsQuoting(arg: []const u8) bool {
40+ if (arg.len == 0) return true;
41+ for (arg) |ch| {
42+ switch (ch) {
43+ ' ', '\t', '"', '\'', '\\', '$', '`', '!', '(', ')', '{', '}', '[', ']', '|', '&', ';', '<', '>', '?', '*', '~', '#', '\n' => return true,
44+ else => {},
45+ }
46+ }
47+ return false;
48+}
49+
50+fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 {
51+ // Prefer double quotes when the arg has no double quotes (cleaner output).
52+ // Fall back to single quotes with '\'' escaping for embedded single quotes.
53+ const has_double_quote = std.mem.indexOfScalar(u8, arg, '"') != null;
54+
55+ if (!has_double_quote) {
56+ // Double-quote style: escape only $ ` \ ! "
57+ var len: usize = 2; // opening and closing "
58+ for (arg) |ch| {
59+ if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
60+ len += 2; // backslash + char
61+ } else {
62+ len += 1;
63+ }
64+ }
65+ const buf = try alloc.alloc(u8, len);
66+ var i: usize = 0;
67+ buf[i] = '"';
68+ i += 1;
69+ for (arg) |ch| {
70+ if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') {
71+ buf[i] = '\\';
72+ buf[i + 1] = ch;
73+ i += 2;
74+ } else {
75+ buf[i] = ch;
76+ i += 1;
77+ }
78+ }
79+ buf[i] = '"';
80+ return buf;
81+ }
82+
83+ // Single-quote style: escape embedded single quotes as '\''
84+ var len: usize = 2;
85+ for (arg) |ch| {
86+ if (ch == '\'') {
87+ len += 4;
88+ } else {
89+ len += 1;
90+ }
91+ }
92+ const buf = try alloc.alloc(u8, len);
93+ var i: usize = 0;
94+ buf[i] = '\'';
95+ i += 1;
96+ for (arg) |ch| {
97+ if (ch == '\'') {
98+ buf[i] = '\'';
99+ buf[i + 1] = '\\';
100+ buf[i + 2] = '\'';
101+ buf[i + 3] = '\'';
102+ i += 4;
103+ } else {
104+ buf[i] = ch;
105+ i += 1;
106+ }
107+ }
108+ buf[i] = '\'';
109+ return buf;
110+}
111+
112 fn run(daemon: *Daemon, command_args: [][]const u8) !void {
113 const alloc = daemon.alloc;
114 var buf: [4096]u8 = undefined;
115@@ -985,19 +1068,36 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void {
116 defer if (allocated_cmd) |cmd| alloc.free(cmd);
117
118 if (command_args.len > 0) {
119+ var parts: std.ArrayList([]const u8) = .empty;
120+ defer {
121+ for (parts.items) |part| alloc.free(part);
122+ parts.deinit(alloc);
123+ }
124+
125 var total_len: usize = 0;
126- for (command_args) |arg| {
127- total_len += arg.len + 1;
128+ for (command_args, 0..) |arg, i| {
129+ // Last arg is the sentinel (e.g. "; echo ZMX_TASK_COMPLETED:$?")
130+ // which must not be quoted so the shell interprets it.
131+ const is_last = i == command_args.len - 1;
132+ if (!is_last and shellNeedsQuoting(arg)) {
133+ const quoted = try shellQuote(alloc, arg);
134+ try parts.append(alloc, quoted);
135+ total_len += quoted.len + 1;
136+ } else {
137+ const duped = try alloc.dupe(u8, arg);
138+ try parts.append(alloc, duped);
139+ total_len += duped.len + 1;
140+ }
141 }
142
143 const cmd_buf = try alloc.alloc(u8, total_len);
144 allocated_cmd = cmd_buf;
145
146 var offset: usize = 0;
147- for (command_args, 0..) |arg, i| {
148- @memcpy(cmd_buf[offset .. offset + arg.len], arg);
149- offset += arg.len;
150- if (i < command_args.len - 1) {
151+ for (parts.items, 0..) |part, i| {
152+ @memcpy(cmd_buf[offset .. offset + part.len], part);
153+ offset += part.len;
154+ if (i < parts.items.len - 1) {
155 cmd_buf[offset] = ' ';
156 } else {
157 cmd_buf[offset] = '\n';