Commit 4d398e8
External.Kai-Fronsdal
·
2026-03-04 12:17:55 -0500 EST
parent b2833e7
fix(run): always single-quote args to avoid bash history expansion bug The previous shellQuote preferred double quotes, but \! does not suppress history expansion inside double quotes in interactive bash. Switch to always using single quotes (matching Python's shlex.quote), which are universally safe since nothing is special inside single quotes except ' itself. Add unit tests for shellNeedsQuoting and shellQuote. Fixes #72 Made-with: Cursor
1 files changed,
+147,
-86
+147,
-86
| ... | ... | @@ -281,25 +281,29 @@ const Daemon = struct { | |
| 281 | 281 | const cur_cmd = self.command orelse self.task_command; | |
| 282 | 282 | if (cur_cmd) |args| { | |
| 283 | 283 | for (args, 0..) |arg, i| { | |
| 284 | - | if (i > 0) { | |
| 285 | - | if (cmd_len < ipc.MAX_CMD_LEN) { | |
| 286 | - | cmd_buf[cmd_len] = ' '; | |
| 287 | - | cmd_len += 1; | |
| 284 | + | const quoted = if (shellNeedsQuoting(arg)) | |
| 285 | + | shellQuote(self.alloc, arg) catch null | |
| 286 | + | else | |
| 287 | + | null; | |
| 288 | + | defer if (quoted) |q| self.alloc.free(q); | |
| 289 | + | const src = quoted orelse arg; | |
| 290 | + | ||
| 291 | + | const need = src.len + @as(usize, if (i > 0) 1 else 0); | |
| 292 | + | if (cmd_len + need > ipc.MAX_CMD_LEN) { | |
| 293 | + | const ellipsis = "..."; | |
| 294 | + | if (cmd_len + ellipsis.len <= ipc.MAX_CMD_LEN) { | |
| 295 | + | @memcpy(cmd_buf[cmd_len..][0..ellipsis.len], ellipsis); | |
| 296 | + | cmd_len += ellipsis.len; | |
| 288 | 297 | } | |
| 298 | + | break; | |
| 289 | 299 | } | |
| 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; | |
| 300 | + | ||
| 301 | + | if (i > 0) { | |
| 302 | + | cmd_buf[cmd_len] = ' '; | |
| 303 | + | cmd_len += 1; | |
| 302 | 304 | } | |
| 305 | + | @memcpy(cmd_buf[cmd_len..][0..src.len], src); | |
| 306 | + | cmd_len += @intCast(src.len); | |
| 303 | 307 | } | |
| 304 | 308 | } | |
| 305 | 309 |
| ... | ... | @@ -977,46 +981,15 @@ fn shellNeedsQuoting(arg: []const u8) bool { | |
| 977 | 981 | } | |
| 978 | 982 | ||
| 979 | 983 | fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 { | |
| 980 | - | // Prefer double quotes when the arg has no double quotes (cleaner output). | |
| 981 | - | // Fall back to single quotes with '\'' escaping for embedded single quotes. | |
| 982 | - | const has_double_quote = std.mem.indexOfScalar(u8, arg, '"') != null; | |
| 983 | - | ||
| 984 | - | if (!has_double_quote) { | |
| 985 | - | // Double-quote style: escape only $ ` \ ! " | |
| 986 | - | var len: usize = 2; // opening and closing " | |
| 987 | - | for (arg) |ch| { | |
| 988 | - | if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') { | |
| 989 | - | len += 2; // backslash + char | |
| 990 | - | } else { | |
| 991 | - | len += 1; | |
| 992 | - | } | |
| 993 | - | } | |
| 994 | - | const buf = try alloc.alloc(u8, len); | |
| 995 | - | var i: usize = 0; | |
| 996 | - | buf[i] = '"'; | |
| 997 | - | i += 1; | |
| 998 | - | for (arg) |ch| { | |
| 999 | - | if (ch == '$' or ch == '`' or ch == '\\' or ch == '!') { | |
| 1000 | - | buf[i] = '\\'; | |
| 1001 | - | buf[i + 1] = ch; | |
| 1002 | - | i += 2; | |
| 1003 | - | } else { | |
| 1004 | - | buf[i] = ch; | |
| 1005 | - | i += 1; | |
| 1006 | - | } | |
| 1007 | - | } | |
| 1008 | - | buf[i] = '"'; | |
| 1009 | - | return buf; | |
| 1010 | - | } | |
| 1011 | - | ||
| 1012 | - | // Single-quote style: escape embedded single quotes as '\'' | |
| 984 | + | // Always use single quotes (like Python's shlex.quote). Inside single | |
| 985 | + | // quotes nothing is special except ' itself, which we handle with the | |
| 986 | + | // '\'' trick (end quote, escaped literal quote, reopen quote). | |
| 987 | + | // | |
| 988 | + | // The previous double-quote strategy broke in interactive bash because | |
| 989 | + | // \! does not suppress history expansion inside double quotes. | |
| 1013 | 990 | var len: usize = 2; | |
| 1014 | 991 | for (arg) |ch| { | |
| 1015 | - | if (ch == '\'') { | |
| 1016 | - | len += 4; | |
| 1017 | - | } else { | |
| 1018 | - | len += 1; | |
| 1019 | - | } | |
| 992 | + | len += if (ch == '\'') 4 else 1; | |
| 1020 | 993 | } | |
| 1021 | 994 | const buf = try alloc.alloc(u8, len); | |
| 1022 | 995 | var i: usize = 0; |
| ... | ... | @@ -1024,10 +997,7 @@ fn shellQuote(alloc: std.mem.Allocator, arg: []const u8) ![]u8 { | |
| 1024 | 997 | i += 1; | |
| 1025 | 998 | for (arg) |ch| { | |
| 1026 | 999 | if (ch == '\'') { | |
| 1027 | - | buf[i] = '\''; | |
| 1028 | - | buf[i + 1] = '\\'; | |
| 1029 | - | buf[i + 2] = '\''; | |
| 1030 | - | buf[i + 3] = '\''; | |
| 1000 | + | @memcpy(buf[i..][0..4], "'\\''"); | |
| 1031 | 1001 | i += 4; | |
| 1032 | 1002 | } else { | |
| 1033 | 1003 | buf[i] = ch; |
| ... | ... | @@ -1067,42 +1037,25 @@ fn run(daemon: *Daemon, command_args: [][]const u8) !void { | |
| 1067 | 1037 | "echo ZMX_TASK_COMPLETED:$?"; | |
| 1068 | 1038 | ||
| 1069 | 1039 | if (command_args.len > 0) { | |
| 1070 | - | var parts: std.ArrayList([]const u8) = .empty; | |
| 1071 | - | defer { | |
| 1072 | - | for (parts.items) |part| alloc.free(part); | |
| 1073 | - | parts.deinit(alloc); | |
| 1074 | - | } | |
| 1040 | + | var cmd_list = std.ArrayList(u8).empty; | |
| 1041 | + | defer cmd_list.deinit(alloc); | |
| 1075 | 1042 | ||
| 1076 | - | var total_len: usize = 0; | |
| 1077 | - | for (command_args) |arg| { | |
| 1043 | + | for (command_args, 0..) |arg, i| { | |
| 1044 | + | if (i > 0) try cmd_list.append(alloc, ' '); | |
| 1078 | 1045 | if (shellNeedsQuoting(arg)) { | |
| 1079 | 1046 | const quoted = try shellQuote(alloc, arg); | |
| 1080 | - | try parts.append(alloc, quoted); | |
| 1081 | - | total_len += quoted.len + 1; | |
| 1047 | + | defer alloc.free(quoted); | |
| 1048 | + | try cmd_list.appendSlice(alloc, quoted); | |
| 1082 | 1049 | } else { | |
| 1083 | - | const duped = try alloc.dupe(u8, arg); | |
| 1084 | - | try parts.append(alloc, duped); | |
| 1085 | - | total_len += duped.len + 1; | |
| 1050 | + | try cmd_list.appendSlice(alloc, arg); | |
| 1086 | 1051 | } | |
| 1087 | 1052 | } | |
| 1088 | 1053 | ||
| 1089 | - | total_len += inline_task_marker.len + 1; | |
| 1090 | - | ||
| 1091 | - | const cmd_buf = try alloc.alloc(u8, total_len); | |
| 1092 | - | allocated_cmd = cmd_buf; | |
| 1054 | + | try cmd_list.appendSlice(alloc, inline_task_marker); | |
| 1055 | + | try cmd_list.append(alloc, '\n'); | |
| 1093 | 1056 | ||
| 1094 | - | var offset: usize = 0; | |
| 1095 | - | for (parts.items) |part| { | |
| 1096 | - | @memcpy(cmd_buf[offset .. offset + part.len], part); | |
| 1097 | - | offset += part.len; | |
| 1098 | - | cmd_buf[offset] = ' '; | |
| 1099 | - | offset += 1; | |
| 1100 | - | } | |
| 1101 | - | ||
| 1102 | - | @memcpy(cmd_buf[offset .. offset + inline_task_marker.len], inline_task_marker); | |
| 1103 | - | offset += inline_task_marker.len; | |
| 1104 | - | cmd_buf[offset] = '\n'; | |
| 1105 | - | cmd_to_send = cmd_buf; | |
| 1057 | + | cmd_to_send = try cmd_list.toOwnedSlice(alloc); | |
| 1058 | + | allocated_cmd = @constCast(cmd_to_send.?); | |
| 1106 | 1059 | } else { | |
| 1107 | 1060 | const stdin_fd = posix.STDIN_FILENO; | |
| 1108 | 1061 | if (!std.posix.isatty(stdin_fd)) { |
| ... | ... | @@ -1913,6 +1866,114 @@ fn serializeTerminal(alloc: std.mem.Allocator, term: *ghostty_vt.Terminal, forma | |
| 1913 | 1866 | }; | |
| 1914 | 1867 | } | |
| 1915 | 1868 | ||
| 1869 | + | test "shellNeedsQuoting" { | |
| 1870 | + | try std.testing.expect(shellNeedsQuoting("")); | |
| 1871 | + | try std.testing.expect(shellNeedsQuoting("hello world")); | |
| 1872 | + | try std.testing.expect(shellNeedsQuoting("hello!")); | |
| 1873 | + | try std.testing.expect(shellNeedsQuoting("$PATH")); | |
| 1874 | + | try std.testing.expect(shellNeedsQuoting("it's")); | |
| 1875 | + | try std.testing.expect(shellNeedsQuoting("a|b")); | |
| 1876 | + | try std.testing.expect(shellNeedsQuoting("a;b")); | |
| 1877 | + | try std.testing.expect(!shellNeedsQuoting("hello")); | |
| 1878 | + | try std.testing.expect(!shellNeedsQuoting("bash")); | |
| 1879 | + | try std.testing.expect(!shellNeedsQuoting("-c")); | |
| 1880 | + | try std.testing.expect(!shellNeedsQuoting("/usr/bin/env")); | |
| 1881 | + | } | |
| 1882 | + | ||
| 1883 | + | test "shellQuote" { | |
| 1884 | + | const alloc = std.testing.allocator; | |
| 1885 | + | ||
| 1886 | + | const empty = try shellQuote(alloc, ""); | |
| 1887 | + | defer alloc.free(empty); | |
| 1888 | + | try std.testing.expectEqualStrings("''", empty); | |
| 1889 | + | ||
| 1890 | + | const space = try shellQuote(alloc, "hello world"); | |
| 1891 | + | defer alloc.free(space); | |
| 1892 | + | try std.testing.expectEqualStrings("'hello world'", space); | |
| 1893 | + | ||
| 1894 | + | const bang = try shellQuote(alloc, "hello!"); | |
| 1895 | + | defer alloc.free(bang); | |
| 1896 | + | try std.testing.expectEqualStrings("'hello!'", bang); | |
| 1897 | + | ||
| 1898 | + | const dollar = try shellQuote(alloc, "$PATH"); | |
| 1899 | + | defer alloc.free(dollar); | |
| 1900 | + | try std.testing.expectEqualStrings("'$PATH'", dollar); | |
| 1901 | + | ||
| 1902 | + | const sq = try shellQuote(alloc, "it's"); | |
| 1903 | + | defer alloc.free(sq); | |
| 1904 | + | try std.testing.expectEqualStrings("'it'\\''s'", sq); | |
| 1905 | + | ||
| 1906 | + | const dq = try shellQuote(alloc, "say \"hi\""); | |
| 1907 | + | defer alloc.free(dq); | |
| 1908 | + | try std.testing.expectEqualStrings("'say \"hi\"'", dq); | |
| 1909 | + | ||
| 1910 | + | const both = try shellQuote(alloc, "it's \"cool\""); | |
| 1911 | + | defer alloc.free(both); | |
| 1912 | + | try std.testing.expectEqualStrings("'it'\\''s \"cool\"'", both); | |
| 1913 | + | ||
| 1914 | + | // just a single quote | |
| 1915 | + | const lone_sq = try shellQuote(alloc, "'"); | |
| 1916 | + | defer alloc.free(lone_sq); | |
| 1917 | + | try std.testing.expectEqualStrings("''\\'''", lone_sq); | |
| 1918 | + | ||
| 1919 | + | // multiple consecutive single quotes | |
| 1920 | + | const triple_sq = try shellQuote(alloc, "'''"); | |
| 1921 | + | defer alloc.free(triple_sq); | |
| 1922 | + | try std.testing.expectEqualStrings("''\\'''\\'''\\'''", triple_sq); | |
| 1923 | + | ||
| 1924 | + | // backtick command substitution | |
| 1925 | + | const backtick = try shellQuote(alloc, "`whoami`"); | |
| 1926 | + | defer alloc.free(backtick); | |
| 1927 | + | try std.testing.expectEqualStrings("'`whoami`'", backtick); | |
| 1928 | + | ||
| 1929 | + | // dollar command substitution | |
| 1930 | + | const dollar_cmd = try shellQuote(alloc, "$(whoami)"); | |
| 1931 | + | defer alloc.free(dollar_cmd); | |
| 1932 | + | try std.testing.expectEqualStrings("'$(whoami)'", dollar_cmd); | |
| 1933 | + | ||
| 1934 | + | // glob | |
| 1935 | + | const glob = try shellQuote(alloc, "*.txt"); | |
| 1936 | + | defer alloc.free(glob); | |
| 1937 | + | try std.testing.expectEqualStrings("'*.txt'", glob); | |
| 1938 | + | ||
| 1939 | + | // tilde | |
| 1940 | + | const tilde = try shellQuote(alloc, "~/file"); | |
| 1941 | + | defer alloc.free(tilde); | |
| 1942 | + | try std.testing.expectEqualStrings("'~/file'", tilde); | |
| 1943 | + | ||
| 1944 | + | // trailing backslash | |
| 1945 | + | const trailing_bs = try shellQuote(alloc, "path\\"); | |
| 1946 | + | defer alloc.free(trailing_bs); | |
| 1947 | + | try std.testing.expectEqualStrings("'path\\'", trailing_bs); | |
| 1948 | + | ||
| 1949 | + | // semicolon (command injection) | |
| 1950 | + | const semi = try shellQuote(alloc, "; rm -rf /"); | |
| 1951 | + | defer alloc.free(semi); | |
| 1952 | + | try std.testing.expectEqualStrings("'; rm -rf /'", semi); | |
| 1953 | + | ||
| 1954 | + | // embedded newline | |
| 1955 | + | const newline = try shellQuote(alloc, "line1\nline2"); | |
| 1956 | + | defer alloc.free(newline); | |
| 1957 | + | try std.testing.expectEqualStrings("'line1\nline2'", newline); | |
| 1958 | + | ||
| 1959 | + | // parentheses (subshell) | |
| 1960 | + | const parens = try shellQuote(alloc, "(echo hi)"); | |
| 1961 | + | defer alloc.free(parens); | |
| 1962 | + | try std.testing.expectEqualStrings("'(echo hi)'", parens); | |
| 1963 | + | ||
| 1964 | + | // heredoc marker | |
| 1965 | + | const heredoc = try shellQuote(alloc, "<<EOF"); | |
| 1966 | + | defer alloc.free(heredoc); | |
| 1967 | + | try std.testing.expectEqualStrings("'<<EOF'", heredoc); | |
| 1968 | + | ||
| 1969 | + | // no quoting needed -- plain word should still be quoted | |
| 1970 | + | // (shellQuote is only called when shellNeedsQuoting returns true, | |
| 1971 | + | // but verify it produces valid output anyway) | |
| 1972 | + | const plain = try shellQuote(alloc, "hello"); | |
| 1973 | + | defer alloc.free(plain); | |
| 1974 | + | try std.testing.expectEqualStrings("'hello'", plain); | |
| 1975 | + | } | |
| 1976 | + | ||
| 1916 | 1977 | test "isKittyCtrlBackslash" { | |
| 1917 | 1978 | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5u")); | |
| 1918 | 1979 | try std.testing.expect(isKittyCtrlBackslash("\x1b[92;5:1u")); |