repos / zmx

session persistence for terminal processes
git clone https://github.com/neurosnap/zmx.git

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
M src/main.zig
+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';