Commit e4e7ad7
Chance Zibolski
·
2026-08-05 00:41:09 -0400 EDT
parent a0fc894
fix(cwd): decode the OSC 7 cwd before the chdir
`Daemon.cwd` holds the OSC 7 value, which is percent-encoded, and nothing
decoded it before opening the directory. The chdir on session create
failed on any escaped character:
[warning] failed to open dir=/tmp/zmx%20spaced%20dir err=FileNotFound
The new session then silently landed in the daemon's inherited cwd.
`util.parseOsc7Cwd` decodes the value to an openable path and reports
whether its host is this machine. `Daemon.setCwd` keeps both: `cwd` stays
in `file://<host><path>` form so `zmx list` still shows the host, which is
what tells you a session is inside SSH, and `cwd_path` holds the decoded
path used for the chdir. A caller that supplies a plain path (`zmx run`,
`zmx attach`) gets a URI built by `util.toOsc7Cwd`, so the stored value
has one shape regardless of source.
`cwd_path` is left empty when the cwd is on another host, since OSC 7
crosses SSH boundaries and the directory does not exist here. That check
previously used `mem.eql` against gethostname(), so a missing host,
`localhost`, and short-name/FQDN mismatches all read as remote and
disabled the chdir even locally.
Refs #222
5 files changed,
+442,
-36
+2,
-0
| ... | ... | @@ -14,6 +14,8 @@ Use spec: https://common-changelog.org/ | |
| 14 | 14 | ||
| 15 | 15 | - Clear screen when switching sessions to prevent term state corruption | |
| 16 | 16 | - Stray NUL byte in the OSC 7 sequence replayed on attach | |
| 17 | + | - The OSC 7 cwd is now decoded before the chdir, so a new session can start in | |
| 18 | + | a directory whose name needed percent-encoding | |
| 17 | 19 | ||
| 18 | 20 | ### Changed | |
| 19 | 21 |
+70,
-32
| ... | ... | @@ -560,7 +560,18 @@ pub const Daemon = struct { | |
| 560 | 560 | running: bool = true, | |
| 561 | 561 | pid: i32 = undefined, | |
| 562 | 562 | command: ?[]const []const u8 = null, | |
| 563 | + | /// The session's working directory in OSC 7 form, `file://<host><path>`. | |
| 564 | + | /// Kept as a URI rather than a path so `zmx list` shows the host, which is | |
| 565 | + | /// what tells you a session is inside SSH. Points into `cwd_buf` once set, | |
| 566 | + | /// so a Daemon must not be copied by value after that. | |
| 563 | 567 | cwd: []const u8 = "", | |
| 568 | + | /// The same directory as a path that can be opened: percent-decoding | |
| 569 | + | /// applied, scheme and host stripped. Empty when the cwd is on another | |
| 570 | + | /// host, since then it names no directory here and nothing should chdir | |
| 571 | + | /// into it. Points into `cwd_path_buf`. | |
| 572 | + | cwd_path: []const u8 = "", | |
| 573 | + | cwd_buf: [std.fs.max_path_bytes]u8 = undefined, | |
| 574 | + | cwd_path_buf: [std.fs.max_path_bytes]u8 = undefined, | |
| 564 | 575 | has_pty_output: bool = false, | |
| 565 | 576 | has_had_client: bool = false, | |
| 566 | 577 | has_terminal_client: bool = false, // true only after a real attach (.Init received) |
| ... | ... | @@ -691,31 +702,19 @@ pub const Daemon = struct { | |
| 691 | 702 | var keep_fds_open = [_]i32{ server_sock_fd, dir.handle, log_fd }; | |
| 692 | 703 | const cmd = try daemonize.createCmdZ(self.shell, self.is_task_mode, self.command); | |
| 693 | 704 | ||
| 694 | - | // format will look like file://{host}{path} | |
| 695 | - | std.log.info("checking pwd={s}", .{self.cwd}); | |
| 696 | - | const uri_opt = std.Uri.parse(self.cwd) catch |err| blk: { | |
| 697 | - | std.log.warn("uri parse failed err={s}", .{@errorName(err)}); | |
| 698 | - | break :blk null; | |
| 699 | - | }; | |
| 700 | - | if (uri_opt) |uri| { | |
| 701 | - | var host_buf: [255]u8 = undefined; | |
| 702 | - | const pwd_host = if (uri.getHost(&host_buf) catch null) |host| host.bytes else "unknown"; | |
| 703 | - | var buf: [std.posix.HOST_NAME_MAX]u8 = undefined; | |
| 704 | - | const hostname = try std.posix.gethostname(&buf); | |
| 705 | - | std.log.info("pwd_host={s} hostname={s}", .{ pwd_host, hostname }); | |
| 706 | - | if (std.mem.eql(u8, pwd_host, hostname)) { | |
| 707 | - | const path_str = switch (uri.path) { | |
| 708 | - | .raw, .percent_encoded => |s| s, | |
| 709 | - | }; | |
| 710 | - | const pwd_dir = std.Io.Dir.openDirAbsolute(io, path_str, .{}) catch |err| blk: { | |
| 711 | - | std.log.warn("failed to open dir={s} err={s}", .{ path_str, @errorName(err) }); | |
| 712 | - | break :blk null; | |
| 713 | - | }; | |
| 714 | - | if (pwd_dir) |pdir| { | |
| 715 | - | defer std.Io.Dir.close(pdir, io); | |
| 716 | - | std.log.info("set directory dir={s}", .{path_str}); | |
| 717 | - | try std.process.setCurrentDir(io, pdir); | |
| 718 | - | } | |
| 705 | + | // `cwd_path` is the decoded path, and is empty when the cwd is on | |
| 706 | + | // another host: OSC 7 crosses SSH boundaries, so a session that ssh'd | |
| 707 | + | // elsewhere reports a directory that does not exist on this machine. | |
| 708 | + | std.log.info("checking pwd={s} path={s}", .{ self.cwd, self.cwd_path }); | |
| 709 | + | if (self.cwd_path.len > 0) { | |
| 710 | + | const pwd_dir = std.Io.Dir.openDirAbsolute(io, self.cwd_path, .{}) catch |err| blk: { | |
| 711 | + | std.log.warn("failed to open dir={s} err={s}", .{ self.cwd_path, @errorName(err) }); | |
| 712 | + | break :blk null; | |
| 713 | + | }; | |
| 714 | + | if (pwd_dir) |pdir| { | |
| 715 | + | defer std.Io.Dir.close(pdir, io); | |
| 716 | + | std.log.info("set directory dir={s}", .{self.cwd_path}); | |
| 717 | + | try std.process.setCurrentDir(io, pdir); | |
| 719 | 718 | } | |
| 720 | 719 | } | |
| 721 | 720 |
| ... | ... | @@ -866,8 +865,11 @@ pub const Daemon = struct { | |
| 866 | 865 | pub fn handleSwitch(self: *Daemon, gpa: std.mem.Allocator, session_name: []const u8) !void { | |
| 867 | 866 | for (self.clients.items) |client| { | |
| 868 | 867 | if (self.leader_client_fd == client.socket_fd) { | |
| 869 | - | // Include the daemon's current cwd so the new session can start in the right directory | |
| 870 | - | if (self.cwd.len > 0) { | |
| 868 | + | // Include the daemon's current cwd so the new session can start | |
| 869 | + | // in the right directory. A remote cwd is left out: it names no | |
| 870 | + | // directory here, so the new session is better off with the | |
| 871 | + | // attaching client's own cwd than with a path it cannot enter. | |
| 872 | + | if (self.cwd.len > 0 and self.cwd_path.len > 0) { | |
| 871 | 873 | var payload = gpa.alloc(u8, session_name.len + 1 + self.cwd.len) catch return; | |
| 872 | 874 | defer gpa.free(payload); | |
| 873 | 875 | @memcpy(payload[0..session_name.len], session_name); |
| ... | ... | @@ -1141,12 +1143,48 @@ pub const Daemon = struct { | |
| 1141 | 1143 | std.log.debug("run command len={d}", .{payload.len}); | |
| 1142 | 1144 | } | |
| 1143 | 1145 | ||
| 1144 | - | fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void { | |
| 1145 | - | const pwd_opt = term.getPwd(); | |
| 1146 | - | if (pwd_opt) |pwd| { | |
| 1147 | - | std.log.info("setting pwd to ghostty term pwd={s}", .{pwd}); | |
| 1148 | - | self.cwd = pwd; | |
| 1146 | + | /// Store the session's working directory as a plain path. | |
| 1147 | + | /// | |
| 1148 | + | /// Accepts either an OSC 7 value (`file://<host><path>`, percent-encoded) | |
| 1149 | + | /// or a path. Decoding here rather than at each use keeps `zmx list` | |
| 1150 | + | /// printing a path and lets the chdir on session create find directories | |
| 1151 | + | /// whose names needed escaping. | |
| 1152 | + | /// | |
| 1153 | + | /// The value is copied, so callers may pass a temporary. | |
| 1154 | + | pub fn setCwd(self: *Daemon, value: []const u8) void { | |
| 1155 | + | var buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1156 | + | var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined; | |
| 1157 | + | const hostname = std.posix.gethostname(&host_buf) catch ""; | |
| 1158 | + | const cwd = util.parseOsc7Cwd(&buf, value, hostname) orelse { | |
| 1159 | + | std.log.warn("ignoring unusable cwd={s}", .{value}); | |
| 1160 | + | return; | |
| 1161 | + | }; | |
| 1162 | + | ||
| 1163 | + | // Store the URI form. A caller that handed us a plain path gets one | |
| 1164 | + | // built here, so `cwd` has the same shape no matter the source. A value | |
| 1165 | + | // that already was a URI is kept verbatim, so `list` shows what the | |
| 1166 | + | // shell actually reported. | |
| 1167 | + | self.cwd = if (std.fs.path.isAbsolute(value)) | |
| 1168 | + | util.toOsc7Cwd(&self.cwd_buf, value, hostname) orelse return | |
| 1169 | + | else blk: { | |
| 1170 | + | if (value.len > self.cwd_buf.len) return; | |
| 1171 | + | @memcpy(self.cwd_buf[0..value.len], value); | |
| 1172 | + | break :blk self.cwd_buf[0..value.len]; | |
| 1173 | + | }; | |
| 1174 | + | ||
| 1175 | + | // Only keep an openable path when it names a directory on this host. | |
| 1176 | + | if (cwd.is_local and cwd.path.len <= self.cwd_path_buf.len) { | |
| 1177 | + | @memcpy(self.cwd_path_buf[0..cwd.path.len], cwd.path); | |
| 1178 | + | self.cwd_path = self.cwd_path_buf[0..cwd.path.len]; | |
| 1179 | + | } else { | |
| 1180 | + | self.cwd_path = ""; | |
| 1149 | 1181 | } | |
| 1182 | + | std.log.info("set cwd={s} path={s}", .{ self.cwd, self.cwd_path }); | |
| 1183 | + | } | |
| 1184 | + | ||
| 1185 | + | fn setPwd(self: *Daemon, term: *ghostty_vt.Terminal) void { | |
| 1186 | + | const pwd = term.getPwd() orelse return; | |
| 1187 | + | self.setCwd(pwd); | |
| 1150 | 1188 | } | |
| 1151 | 1189 | ||
| 1152 | 1190 | pub fn handleOutput(self: *Daemon, gpa: std.mem.Allocator, payload: []const u8, term: *ghostty_vt.Terminal, vt_stream: anytype) !void { |
+4,
-4
| ... | ... | @@ -147,7 +147,7 @@ pub fn main(init: std.process.Init) !void { | |
| 147 | 147 | }; | |
| 148 | 148 | var daemon = Daemon.init(io, &cfg, sesh, socket_path); | |
| 149 | 149 | daemon.command = command; | |
| 150 | - | daemon.cwd = cwd; | |
| 150 | + | daemon.setCwd(cwd); | |
| 151 | 151 | daemon.shell = shell_env; | |
| 152 | 152 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 153 | 153 | return attach(gpa, io, &daemon); |
| ... | ... | @@ -180,7 +180,7 @@ pub fn main(init: std.process.Init) !void { | |
| 180 | 180 | }; | |
| 181 | 181 | defer gpa.free(socket_path); | |
| 182 | 182 | var daemon = Daemon.init(io, &cfg, sesh, socket_path); | |
| 183 | - | daemon.cwd = cwd; | |
| 183 | + | daemon.setCwd(cwd); | |
| 184 | 184 | daemon.is_task_mode = true; | |
| 185 | 185 | daemon.shell = shell_env; | |
| 186 | 186 | std.log.info("socket path={s}", .{daemon.socket_path}); |
| ... | ... | @@ -395,7 +395,7 @@ pub fn main(init: std.process.Init) !void { | |
| 395 | 395 | }; | |
| 396 | 396 | var daemon = Daemon.init(io, &cfg, sesh, socket_path); | |
| 397 | 397 | daemon.is_task_mode = true; | |
| 398 | - | daemon.cwd = cwd; | |
| 398 | + | daemon.setCwd(cwd); | |
| 399 | 399 | daemon.shell = shell_env; | |
| 400 | 400 | std.log.info("socket path={s}", .{daemon.socket_path}); | |
| 401 | 401 | try writeFile(gpa, io, &daemon, file_path); |
| ... | ... | @@ -1371,7 +1371,7 @@ fn attach(gpa: std.mem.Allocator, io: std.Io, daemon: *Daemon) !void { | |
| 1371 | 1371 | // otherwise fall back to the client's original cwd | |
| 1372 | 1372 | const switch_cwd = looper.cwd orelse daemon.cwd; | |
| 1373 | 1373 | std.log.info("switching to new session cwd={s}", .{switch_cwd}); | |
| 1374 | - | target_daemon.cwd = switch_cwd; | |
| 1374 | + | target_daemon.setCwd(switch_cwd); | |
| 1375 | 1375 | target_daemon.shell = daemon.shell; | |
| 1376 | 1376 | return attach(gpa, io, &target_daemon); | |
| 1377 | 1377 | } |
+292,
-0
| ... | ... | @@ -115,6 +115,96 @@ pub fn get_session_entries( | |
| 115 | 115 | return sessions; | |
| 116 | 116 | } | |
| 117 | 117 | ||
| 118 | + | pub const Cwd = struct { | |
| 119 | + | /// A filesystem path, percent-decoded, with no scheme or host. | |
| 120 | + | path: []const u8, | |
| 121 | + | /// True when the OSC 7 host is this machine, so `path` names a directory we | |
| 122 | + | /// can actually chdir into. OSC 7 crosses SSH boundaries, so a session that | |
| 123 | + | /// ssh'd elsewhere reports a path that does not exist locally. | |
| 124 | + | is_local: bool, | |
| 125 | + | }; | |
| 126 | + | ||
| 127 | + | /// parseOsc7Cwd turns an OSC 7 value into a path that can be opened. | |
| 128 | + | /// | |
| 129 | + | /// The value looks like `file://<host><path>` with the path percent-encoded, so | |
| 130 | + | /// it cannot be handed to `openDirAbsolute` as-is: the escaping is never | |
| 131 | + | /// decoded and any directory whose name needed it fails to open. | |
| 132 | + | /// | |
| 133 | + | /// A plain absolute path is accepted and passed through, since a caller may | |
| 134 | + | /// hand us one directly before the session has reported an OSC 7. | |
| 135 | + | /// | |
| 136 | + | /// `buf` holds the decoded path, so the result stays valid after the source | |
| 137 | + | /// value changes. Returns null when the value is not a path we can use. | |
| 138 | + | pub fn parseOsc7Cwd(buf: []u8, value: []const u8, hostname: []const u8) ?Cwd { | |
| 139 | + | if (value.len == 0) return null; | |
| 140 | + | ||
| 141 | + | if (std.fs.path.isAbsolute(value)) { | |
| 142 | + | if (value.len > buf.len) return null; | |
| 143 | + | @memcpy(buf[0..value.len], value); | |
| 144 | + | return .{ .path = buf[0..value.len], .is_local = true }; | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | const uri = std.Uri.parse(value) catch return null; | |
| 148 | + | // kitty emits kitty-shell-cwd:// from its own shell integration, and | |
| 149 | + | // accepts it alongside file:// on the way back in. | |
| 150 | + | if (!std.mem.eql(u8, uri.scheme, "file") and | |
| 151 | + | !std.mem.eql(u8, uri.scheme, "kitty-shell-cwd")) return null; | |
| 152 | + | ||
| 153 | + | const decoded = uri.path.toRaw(buf) catch return null; | |
| 154 | + | if (!std.fs.path.isAbsolute(decoded)) return null; | |
| 155 | + | // toRaw returns the input slice when there was nothing to decode, and that | |
| 156 | + | // slice is owned by the caller of this fn, so copy it into buf either way. | |
| 157 | + | const path = if (decoded.ptr == buf.ptr) decoded else blk: { | |
| 158 | + | if (decoded.len > buf.len) return null; | |
| 159 | + | std.mem.copyForwards(u8, buf[0..decoded.len], decoded); | |
| 160 | + | break :blk buf[0..decoded.len]; | |
| 161 | + | }; | |
| 162 | + | ||
| 163 | + | return .{ .path = path, .is_local = isLocalHost(uri.host, hostname) }; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | fn isLocalHost(host: ?std.Uri.Component, hostname: []const u8) bool { | |
| 167 | + | // file:///path omits the host, which conventionally means the local machine. | |
| 168 | + | const component = host orelse return true; | |
| 169 | + | var host_buf: [std.posix.HOST_NAME_MAX]u8 = undefined; | |
| 170 | + | const value = component.toRaw(&host_buf) catch return false; | |
| 171 | + | if (value.len == 0) return true; | |
| 172 | + | if (std.ascii.eqlIgnoreCase(value, "localhost")) return true; | |
| 173 | + | if (std.ascii.eqlIgnoreCase(value, hostname)) return true; | |
| 174 | + | // gethostname often reports a short name while OSC 7 carries the FQDN | |
| 175 | + | // (or the reverse), so fall back to comparing the first label. | |
| 176 | + | const value_label = value[0 .. std.mem.indexOfScalar(u8, value, '.') orelse value.len]; | |
| 177 | + | const host_label = hostname[0 .. std.mem.indexOfScalar(u8, hostname, '.') orelse hostname.len]; | |
| 178 | + | return host_label.len > 0 and std.ascii.eqlIgnoreCase(value_label, host_label); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// toOsc7Cwd renders a plain path as the OSC 7 form, `file://<host><path>`. | |
| 182 | + | /// | |
| 183 | + | /// The daemon stores its cwd in this form so `zmx list` shows the host, which | |
| 184 | + | /// is how you can tell at a glance that a session is inside SSH. Callers that | |
| 185 | + | /// only have a local path (`zmx run`, `zmx attach`) go through this so the | |
| 186 | + | /// stored value has one shape regardless of where it came from. | |
| 187 | + | /// | |
| 188 | + | /// Returns null when the result would not fit in `buf`. | |
| 189 | + | pub fn toOsc7Cwd(buf: []u8, path: []const u8, hostname: []const u8) ?[]const u8 { | |
| 190 | + | var w: std.Io.Writer = .fixed(buf); | |
| 191 | + | w.print("file://{s}", .{hostname}) catch return null; | |
| 192 | + | // Percent-encode so a path with a space or a `%` in it round-trips back | |
| 193 | + | // through parseOsc7Cwd unchanged. | |
| 194 | + | std.Uri.Component.percentEncode(&w, path, isPathChar) catch return null; | |
| 195 | + | return w.buffered(); | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | /// Characters that need no escaping in a URI path. RFC 3986 pchar, minus the | |
| 199 | + | /// sub-delims that a shell would find surprising to see left raw in `zmx list`. | |
| 200 | + | fn isPathChar(c: u8) bool { | |
| 201 | + | return switch (c) { | |
| 202 | + | 'A'...'Z', 'a'...'z', '0'...'9' => true, | |
| 203 | + | '-', '.', '_', '~', '/', ':', '@' => true, | |
| 204 | + | else => false, | |
| 205 | + | }; | |
| 206 | + | } | |
| 207 | + | ||
| 118 | 208 | /// getCwd get the current working directory in a std.Uri format. | |
| 119 | 209 | /// Caller is responsible for releasing memory. | |
| 120 | 210 | pub fn getCwd(gpa: std.mem.Allocator, io: std.Io) ![]u8 { |
| ... | ... | @@ -1234,6 +1324,208 @@ test "isDetachKeyDisabled" { | |
| 1234 | 1324 | try testing.expect(isDetachKeyDisabled()); | |
| 1235 | 1325 | } | |
| 1236 | 1326 | ||
| 1327 | + | test "parseOsc7Cwd" { | |
| 1328 | + | const Case = struct { | |
| 1329 | + | name: []const u8, | |
| 1330 | + | value: []const u8, | |
| 1331 | + | hostname: []const u8, | |
| 1332 | + | expected: ?Cwd, | |
| 1333 | + | }; | |
| 1334 | + | ||
| 1335 | + | const cases = [_]Case{ | |
| 1336 | + | .{ | |
| 1337 | + | .name = "local file uri", | |
| 1338 | + | .value = "file://myhost/private/tmp", | |
| 1339 | + | .hostname = "myhost", | |
| 1340 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1341 | + | }, | |
| 1342 | + | .{ | |
| 1343 | + | .name = "percent-encoded path is decoded", | |
| 1344 | + | .value = "file://myhost/tmp/zmx%20spaced%20dir", | |
| 1345 | + | .hostname = "myhost", | |
| 1346 | + | .expected = .{ .path = "/tmp/zmx spaced dir", .is_local = true }, | |
| 1347 | + | }, | |
| 1348 | + | .{ | |
| 1349 | + | .name = "kitty scheme", | |
| 1350 | + | .value = "kitty-shell-cwd://myhost/private/tmp", | |
| 1351 | + | .hostname = "myhost", | |
| 1352 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1353 | + | }, | |
| 1354 | + | .{ | |
| 1355 | + | .name = "remote host keeps the path but is not local", | |
| 1356 | + | .value = "file://otherhost/home/me", | |
| 1357 | + | .hostname = "myhost", | |
| 1358 | + | .expected = .{ .path = "/home/me", .is_local = false }, | |
| 1359 | + | }, | |
| 1360 | + | .{ | |
| 1361 | + | .name = "empty host means local", | |
| 1362 | + | .value = "file:///private/tmp", | |
| 1363 | + | .hostname = "myhost", | |
| 1364 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1365 | + | }, | |
| 1366 | + | .{ | |
| 1367 | + | .name = "localhost means local", | |
| 1368 | + | .value = "file://localhost/private/tmp", | |
| 1369 | + | .hostname = "myhost", | |
| 1370 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1371 | + | }, | |
| 1372 | + | .{ | |
| 1373 | + | .name = "fqdn matches a short hostname", | |
| 1374 | + | .value = "file://myhost.local/private/tmp", | |
| 1375 | + | .hostname = "myhost", | |
| 1376 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1377 | + | }, | |
| 1378 | + | .{ | |
| 1379 | + | .name = "short host matches an fqdn hostname", | |
| 1380 | + | .value = "file://myhost/private/tmp", | |
| 1381 | + | .hostname = "myhost.lan", | |
| 1382 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1383 | + | }, | |
| 1384 | + | .{ | |
| 1385 | + | .name = "host comparison ignores case", | |
| 1386 | + | .value = "file://MyHost/private/tmp", | |
| 1387 | + | .hostname = "myhost", | |
| 1388 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1389 | + | }, | |
| 1390 | + | .{ | |
| 1391 | + | .name = "plain absolute path passes through", | |
| 1392 | + | .value = "/private/tmp", | |
| 1393 | + | .hostname = "myhost", | |
| 1394 | + | .expected = .{ .path = "/private/tmp", .is_local = true }, | |
| 1395 | + | }, | |
| 1396 | + | .{ | |
| 1397 | + | .name = "empty value", | |
| 1398 | + | .value = "", | |
| 1399 | + | .hostname = "myhost", | |
| 1400 | + | .expected = null, | |
| 1401 | + | }, | |
| 1402 | + | .{ | |
| 1403 | + | .name = "relative path", | |
| 1404 | + | .value = "some/dir", | |
| 1405 | + | .hostname = "myhost", | |
| 1406 | + | .expected = null, | |
| 1407 | + | }, | |
| 1408 | + | .{ | |
| 1409 | + | .name = "unsupported scheme", | |
| 1410 | + | .value = "http://myhost/private/tmp", | |
| 1411 | + | .hostname = "myhost", | |
| 1412 | + | .expected = null, | |
| 1413 | + | }, | |
| 1414 | + | .{ | |
| 1415 | + | .name = "uri without a path", | |
| 1416 | + | .value = "file://myhost", | |
| 1417 | + | .hostname = "myhost", | |
| 1418 | + | .expected = null, | |
| 1419 | + | }, | |
| 1420 | + | }; | |
| 1421 | + | ||
| 1422 | + | for (cases) |c| { | |
| 1423 | + | var buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1424 | + | const actual = parseOsc7Cwd(&buf, c.value, c.hostname); | |
| 1425 | + | testing.expectEqualDeep(c.expected, actual) catch |err| { | |
| 1426 | + | std.debug.print("case: {s}\n", .{c.name}); | |
| 1427 | + | return err; | |
| 1428 | + | }; | |
| 1429 | + | } | |
| 1430 | + | } | |
| 1431 | + | ||
| 1432 | + | test "parseOsc7Cwd result survives the source value changing" { | |
| 1433 | + | var buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1434 | + | var value: [32]u8 = undefined; | |
| 1435 | + | const src = "file://myhost/private/tmp"; | |
| 1436 | + | @memcpy(value[0..src.len], src); | |
| 1437 | + | ||
| 1438 | + | const cwd = parseOsc7Cwd(&buf, value[0..src.len], "myhost") orelse | |
| 1439 | + | return error.TestUnexpectedNull; | |
| 1440 | + | ||
| 1441 | + | @memset(&value, 'x'); | |
| 1442 | + | try testing.expectEqualDeep(Cwd{ .path = "/private/tmp", .is_local = true }, cwd); | |
| 1443 | + | } | |
| 1444 | + | ||
| 1445 | + | test "parseOsc7Cwd rejects a path longer than the buffer" { | |
| 1446 | + | var buf: [8]u8 = undefined; | |
| 1447 | + | try testing.expectEqual( | |
| 1448 | + | @as(?Cwd, null), | |
| 1449 | + | parseOsc7Cwd(&buf, "file://myhost/a/very/long/path", "myhost"), | |
| 1450 | + | ); | |
| 1451 | + | try testing.expectEqual( | |
| 1452 | + | @as(?Cwd, null), | |
| 1453 | + | parseOsc7Cwd(&buf, "/a/very/long/path", "myhost"), | |
| 1454 | + | ); | |
| 1455 | + | } | |
| 1456 | + | ||
| 1457 | + | test "toOsc7Cwd" { | |
| 1458 | + | const Case = struct { | |
| 1459 | + | name: []const u8, | |
| 1460 | + | path: []const u8, | |
| 1461 | + | expected: ?[]const u8, | |
| 1462 | + | }; | |
| 1463 | + | ||
| 1464 | + | const cases = [_]Case{ | |
| 1465 | + | .{ | |
| 1466 | + | .name = "plain path", | |
| 1467 | + | .path = "/private/tmp", | |
| 1468 | + | .expected = "file://myhost/private/tmp", | |
| 1469 | + | }, | |
| 1470 | + | .{ | |
| 1471 | + | .name = "space is encoded", | |
| 1472 | + | .path = "/tmp/zmx spaced dir", | |
| 1473 | + | .expected = "file://myhost/tmp/zmx%20spaced%20dir", | |
| 1474 | + | }, | |
| 1475 | + | .{ | |
| 1476 | + | .name = "percent is encoded so it round-trips", | |
| 1477 | + | .path = "/tmp/100%", | |
| 1478 | + | .expected = "file://myhost/tmp/100%25", | |
| 1479 | + | }, | |
| 1480 | + | .{ | |
| 1481 | + | .name = "unreserved characters are left alone", | |
| 1482 | + | .path = "/tmp/a-b_c.d~e", | |
| 1483 | + | .expected = "file://myhost/tmp/a-b_c.d~e", | |
| 1484 | + | }, | |
| 1485 | + | }; | |
| 1486 | + | ||
| 1487 | + | for (cases) |c| { | |
| 1488 | + | var buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1489 | + | testing.expectEqualDeep(c.expected, toOsc7Cwd(&buf, c.path, "myhost")) catch |err| { | |
| 1490 | + | std.debug.print("case: {s}\n", .{c.name}); | |
| 1491 | + | return err; | |
| 1492 | + | }; | |
| 1493 | + | } | |
| 1494 | + | } | |
| 1495 | + | ||
| 1496 | + | test "toOsc7Cwd returns null when the result would not fit" { | |
| 1497 | + | var buf: [8]u8 = undefined; | |
| 1498 | + | try testing.expectEqual( | |
| 1499 | + | @as(?[]const u8, null), | |
| 1500 | + | toOsc7Cwd(&buf, "/a/very/long/path", "myhost"), | |
| 1501 | + | ); | |
| 1502 | + | } | |
| 1503 | + | ||
| 1504 | + | test "toOsc7Cwd round-trips through parseOsc7Cwd" { | |
| 1505 | + | const paths = [_][]const u8{ | |
| 1506 | + | "/private/tmp", | |
| 1507 | + | "/tmp/zmx spaced dir", | |
| 1508 | + | "/tmp/100%", | |
| 1509 | + | "/tmp/a-b_c.d~e", | |
| 1510 | + | "/tmp/quote'and\"dquote", | |
| 1511 | + | }; | |
| 1512 | + | ||
| 1513 | + | for (paths) |path| { | |
| 1514 | + | var enc_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1515 | + | const uri = toOsc7Cwd(&enc_buf, path, "myhost") orelse | |
| 1516 | + | return error.TestUnexpectedNull; | |
| 1517 | + | ||
| 1518 | + | var dec_buf: [std.fs.max_path_bytes]u8 = undefined; | |
| 1519 | + | testing.expectEqualDeep( | |
| 1520 | + | Cwd{ .path = path, .is_local = true }, | |
| 1521 | + | parseOsc7Cwd(&dec_buf, uri, "myhost"), | |
| 1522 | + | ) catch |err| { | |
| 1523 | + | std.debug.print("path: {s} uri: {s}\n", .{ path, uri }); | |
| 1524 | + | return err; | |
| 1525 | + | }; | |
| 1526 | + | } | |
| 1527 | + | } | |
| 1528 | + | ||
| 1237 | 1529 | test "serializeTerminalState excludes synchronized output replay" { | |
| 1238 | 1530 | const alloc = testing.allocator; | |
| 1239 | 1531 | const io = testing.io; |
+74,
-0
| ... | ... | @@ -0,0 +1,74 @@ | |
| 1 | + | #!/usr/bin/env bats | |
| 2 | + | # Working directory tracking tests for zmx. | |
| 3 | + | # | |
| 4 | + | # zmx learns a session's cwd from the OSC 7 the shell emits, which arrives as a | |
| 5 | + | # percent-encoded file://<host><path> URI. That URI is what `list` reports, so | |
| 6 | + | # the host stays visible and you can tell an SSH session apart from a local one. | |
| 7 | + | # These tests pin that output plus the thing it depends on: the URI is decoded | |
| 8 | + | # for the chdir, so a new session lands in a directory whose name needed | |
| 9 | + | # escaping. | |
| 10 | + | ||
| 11 | + | load test_helper | |
| 12 | + | ||
| 13 | + | # Emit an OSC 7 for $2 from inside session $1, as a shell integration would. | |
| 14 | + | osc7_session() { | |
| 15 | + | local name="$1" path="$2" encoded | |
| 16 | + | # Percent-encode spaces, the character that actually broke the chdir. The % | |
| 17 | + | # is doubled because this goes through printf, which would otherwise read | |
| 18 | + | # "%20s" as a width specifier. | |
| 19 | + | encoded="${path// /%%20}" | |
| 20 | + | "$ZMX" run "$name" -d sh -c \ | |
| 21 | + | "printf '\033]7;file://$(hostname)$encoded\007marker-$name\n'; sleep 30" | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | @test "list: reports the cwd in OSC 7 form, host included" { | |
| 25 | + | local dir="$BATS_TEST_TMPDIR/zmx spaced dir" | |
| 26 | + | mkdir -p "$dir" | |
| 27 | + | ||
| 28 | + | osc7_session test-cwd-uri "$dir" | |
| 29 | + | wait_for_session test-cwd-uri | |
| 30 | + | wait_for_output test-cwd-uri marker-test-cwd-uri | |
| 31 | + | ||
| 32 | + | run "$ZMX" list | |
| 33 | + | [ "$status" -eq 0 ] | |
| 34 | + | [[ "$output" == *"cwd=file://$(hostname)${dir// /%20}"* ]] | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | @test "list: shows a remote cwd's host, so SSH is visible" { | |
| 38 | + | "$ZMX" run test-cwd-remote -d sh -c \ | |
| 39 | + | "printf '\033]7;file://some-remote-box/home/me\007marker-remote\n'; sleep 30" | |
| 40 | + | wait_for_session test-cwd-remote | |
| 41 | + | wait_for_output test-cwd-remote marker-remote | |
| 42 | + | ||
| 43 | + | run "$ZMX" list | |
| 44 | + | [ "$status" -eq 0 ] | |
| 45 | + | [[ "$output" == *"cwd=file://some-remote-box/home/me"* ]] | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | @test "list: reports an OSC 7 URI even when given a plain path" { | |
| 49 | + | # `zmx run` hands the daemon the client's cwd as a path, so this covers the | |
| 50 | + | # encode direction rather than the decode one. | |
| 51 | + | cd "$BATS_TEST_TMPDIR" | |
| 52 | + | "$ZMX" run test-cwd-encode -d sleep 30 | |
| 53 | + | wait_for_session test-cwd-encode | |
| 54 | + | ||
| 55 | + | run "$ZMX" list | |
| 56 | + | [ "$status" -eq 0 ] | |
| 57 | + | [[ "$output" == *"cwd=file://$(hostname)/"* ]] | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | @test "new session starts in a cwd whose name needed escaping" { | |
| 61 | + | local dir="$BATS_TEST_TMPDIR/zmx spaced dir" | |
| 62 | + | mkdir -p "$dir" | |
| 63 | + | ||
| 64 | + | cd "$dir" | |
| 65 | + | "$ZMX" run test-cwd-chdir -d sh -c 'pwd; sleep 30' | |
| 66 | + | wait_for_session test-cwd-chdir | |
| 67 | + | wait_for_output test-cwd-chdir "zmx spaced dir" | |
| 68 | + | ||
| 69 | + | # `pwd` inside the session is what the daemon actually chdir'd into. Compare | |
| 70 | + | # basenames because macOS resolves /tmp to /private/tmp. | |
| 71 | + | run "$ZMX" history test-cwd-chdir | |
| 72 | + | [ "$status" -eq 0 ] | |
| 73 | + | [[ "$output" == *"/zmx spaced dir"* ]] | |
| 74 | + | } |