From 032fcee9ffa1f4f066839049ebf7fbf773bb0651 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 11:04:32 -0700 Subject: [PATCH 1/7] terminal: DCS handler, XTGETTCAP parsing --- src/terminal/Parser.zig | 4 +- src/terminal/dcs.zig | 218 ++++++++++++++++++++++++++++++++++++++++ src/terminal/main.zig | 3 + src/terminal/stream.zig | 4 +- src/termio/Exec.zig | 5 + 5 files changed, 231 insertions(+), 3 deletions(-) create mode 100644 src/terminal/dcs.zig diff --git a/src/terminal/Parser.zig b/src/terminal/Parser.zig index 3ed5dc1be..500306201 100644 --- a/src/terminal/Parser.zig +++ b/src/terminal/Parser.zig @@ -127,8 +127,8 @@ pub const Action = union(enum) { }; pub const DCS = struct { - intermediates: []u8, - params: []u16, + intermediates: []const u8 = "", + params: []const u16 = &.{}, final: u8, }; diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig new file mode 100644 index 000000000..b6a78afe9 --- /dev/null +++ b/src/terminal/dcs.zig @@ -0,0 +1,218 @@ +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const terminal = @import("main.zig"); +const DCS = terminal.DCS; + +const log = std.log.scoped(.terminal_dcs); + +/// DCS command handler. This should be hooked into a terminal.Stream handler. +/// The hook/put/unhook functions are meant to be called from the +/// terminal.stream dcsHook, dcsPut, and dcsUnhook functions, respectively. +pub const Handler = struct { + state: State = .{ .inactive = {} }, + + /// Maximum bytes any DCS command can take. This is to prevent + /// malicious input from causing us to allocate too much memory. + /// This is arbitrarily set to 1MB today, increase if needed. + max_bytes: usize = 1024 * 1024, + + pub fn deinit(self: *Handler) void { + self.discard(); + } + + pub fn hook(self: *Handler, alloc: Allocator, dcs: DCS) void { + assert(self.state == .inactive); + self.state = if (tryHook(alloc, dcs)) |state_| state: { + if (state_) |state| break :state state else { + log.info("unknown DCS hook: {}", .{dcs}); + break :state .{ .ignore = {} }; + } + } else |err| state: { + log.info( + "error initializing DCS hook, will ignore hook err={}", + .{err}, + ); + break :state .{ .ignore = {} }; + }; + } + + fn tryHook(alloc: Allocator, dcs: DCS) !?State { + return switch (dcs.intermediates.len) { + 1 => switch (dcs.intermediates[0]) { + '+' => switch (dcs.final) { + // XTGETTCAP + // https://github.com/mitchellh/ghostty/issues/517 + 'q' => .{ + .xtgettcap = try std.ArrayList(u8).initCapacity( + alloc, + 128, // Arbitrary choice + ), + }, + + else => null, + }, + + else => null, + }, + + else => null, + }; + } + + pub fn put(self: *Handler, byte: u8) void { + self.tryPut(byte) catch |err| { + // On error we just discard our state and ignore the rest + log.info("error putting byte into DCS handler err={}", .{err}); + self.discard(); + self.state = .{ .ignore = {} }; + }; + } + + fn tryPut(self: *Handler, byte: u8) !void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| { + if (list.items.len >= self.max_bytes) { + return error.OutOfMemory; + } + + try list.append(byte); + }, + } + } + + pub fn unhook(self: *Handler) ?Command { + defer self.state = .{ .inactive = {} }; + return switch (self.state) { + .inactive, + .ignore, + => null, + + .xtgettcap => |list| .{ .xtgettcap = .{ .data = list } }, + }; + } + + fn discard(self: *Handler) void { + switch (self.state) { + .inactive, + .ignore, + => {}, + + .xtgettcap => |*list| list.deinit(), + } + + self.state = .{ .inactive = {} }; + } +}; + +pub const Command = union(enum) { + /// XTGETTCAP + xtgettcap: XTGETTCAP, + + pub fn deinit(self: Command) void { + switch (self) { + .xtgettcap => |*v| { + v.data.deinit(); + }, + } + } + + pub const XTGETTCAP = struct { + data: std.ArrayList(u8), + + // Note: do not reset this. The next function mutates data so it is + // unsafe to reset this and reiterate. + i: usize = 0, + + /// Returns the next terminfo key being requested and null + /// when there are no more keys. + pub fn next(self: *XTGETTCAP) ?[]const u8 { + if (self.i >= self.data.items.len) return null; + + var rem = self.data.items[self.i..]; + const idx = std.mem.indexOf(u8, rem, ";") orelse rem.len; + + // Note that if we're at the end, idx + 1 is len + 1 so we're over + // the end but that's okay because our check above is >= so we'll + // never read. + self.i += idx + 1; + + // HEX decode in-place so we don't have to allocate. If invalid + // hex is given then we just return null and stop processing. + return std.fmt.hexToBytes(rem, rem[0..idx]) catch null; + } + }; +}; + +const State = union(enum) { + /// We're not in a DCS state at the moment. + inactive: void, + + /// We're hooked, but its an unknown DCS command or one that went + /// invalid due to some bad input, so we're ignoring the rest. + ignore: void, + + // XTGETTCAP + xtgettcap: std.ArrayList(u8), +}; + +test "unknown DCS command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .final = 'A' }); + try testing.expect(h.state == .ignore); + try testing.expect(h.unhook() == null); + try testing.expect(h.state == .inactive); +} + +test "XTGETTCAP command" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command multiple keys" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("536D756C78;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); + try testing.expect(cmd.xtgettcap.next() == null); +} + +test "XTGETTCAP command invalid data" { + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{}; + defer h.deinit(); + h.hook(alloc, .{ .intermediates = "+", .final = 'q' }); + for ("who;536D756C78") |byte| h.put(byte); + var cmd = h.unhook().?; + defer cmd.deinit(); + try testing.expect(cmd == .xtgettcap); + try testing.expect(cmd.xtgettcap.next() == null); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 4e8d3819a..8c0a7fc74 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -6,6 +6,7 @@ const ansi = @import("ansi.zig"); const csi = @import("csi.zig"); const sgr = @import("sgr.zig"); pub const apc = @import("apc.zig"); +pub const dcs = @import("dcs.zig"); pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); @@ -16,6 +17,8 @@ pub const parse_table = @import("parse_table.zig"); pub const Charset = charsets.Charset; pub const CharsetSlot = charsets.Slots; pub const CharsetActiveSlot = charsets.ActiveSlot; +pub const CSI = Parser.Action.CSI; +pub const DCS = Parser.Action.DCS; pub const MouseShape = @import("mouse_shape.zig").MouseShape; pub const Terminal = @import("Terminal.zig"); pub const Parser = @import("Parser.zig"); diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index 6a8acad9c..debc12e0e 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -64,7 +64,9 @@ pub fn Stream(comptime Handler: type) type { .csi_dispatch => |csi_action| try self.csiDispatch(csi_action), .esc_dispatch => |esc| try self.escDispatch(esc), .osc_dispatch => |cmd| try self.oscDispatch(cmd), - .dcs_hook => |dcs| log.warn("unhandled DCS hook: {}", .{dcs}), + .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { + try self.handler.dcsHook(dcs); + } else log.warn("unimplemented DCS hook", .{}), .dcs_put => |code| log.warn("unhandled DCS put: {x}", .{code}), .dcs_unhook => log.warn("unhandled DCS unhook", .{}), .apc_start => if (@hasDecl(T, "apcStart")) { diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 654dfae0b..a0e7c1856 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1199,6 +1199,11 @@ const StreamHandler = struct { }; } + pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { + _ = self; + log.warn("DCS HOOK: {}", .{dcs}); + } + pub fn apcStart(self: *StreamHandler) !void { self.apc.start(); } From 823f47f69515d1b9f5ef1780a1ad8bae6bb122eb Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 13:32:00 -0700 Subject: [PATCH 2/7] termio: hook up dcs callbacks --- src/terminal/stream.zig | 8 ++++++-- src/termio/Exec.zig | 25 +++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index debc12e0e..6e4ffb235 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -67,8 +67,12 @@ pub fn Stream(comptime Handler: type) type { .dcs_hook => |dcs| if (@hasDecl(T, "dcsHook")) { try self.handler.dcsHook(dcs); } else log.warn("unimplemented DCS hook", .{}), - .dcs_put => |code| log.warn("unhandled DCS put: {x}", .{code}), - .dcs_unhook => log.warn("unhandled DCS unhook", .{}), + .dcs_put => |code| if (@hasDecl(T, "dcsPut")) { + try self.handler.dcsPut(code); + } else log.warn("unimplemented DCS put: {x}", .{code}), + .dcs_unhook => if (@hasDecl(T, "dcsUnhook")) { + try self.handler.dcsUnhook(); + } else log.warn("unimplemented DCS unhook", .{}), .apc_start => if (@hasDecl(T, "apcStart")) { try self.handler.apcStart(); } else log.warn("unimplemented APC start", .{}), diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index a0e7c1856..5ece318fb 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1157,6 +1157,11 @@ const StreamHandler = struct { /// the kitty graphics protocol. apc: terminal.apc.Handler = .{}, + /// The DCS handler maintains DCS state. DCS is like CSI or OSC, + /// but requires more stateful parsing. This is used by functionality + /// such as XTGETTCAP. + dcs: terminal.dcs.Handler = .{}, + /// This is set to true when a message was written to the writer /// mailbox. This can be used by callers to determine if they need /// to wake up the writer. @@ -1173,6 +1178,7 @@ const StreamHandler = struct { pub fn deinit(self: *StreamHandler) void { self.apc.deinit(); + self.dcs.deinit(); } inline fn queueRender(self: *StreamHandler) !void { @@ -1200,8 +1206,23 @@ const StreamHandler = struct { } pub fn dcsHook(self: *StreamHandler, dcs: terminal.DCS) !void { - _ = self; - log.warn("DCS HOOK: {}", .{dcs}); + self.dcs.hook(self.alloc, dcs); + } + + pub fn dcsPut(self: *StreamHandler, byte: u8) !void { + self.dcs.put(byte); + } + + pub fn dcsUnhook(self: *StreamHandler) !void { + var cmd = self.dcs.unhook() orelse return; + cmd.deinit(); + + // log.warn("DCS command: {}", .{cmd}); + switch (cmd) { + .xtgettcap => |gettcap| { + _ = gettcap; + }, + } } pub fn apcStart(self: *StreamHandler) !void { From a02378f9697ab4cfd15d663da3b84aafa96d4e70 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 14:23:57 -0700 Subject: [PATCH 3/7] terminfo: comptime map for xtgettcap results --- src/terminfo/Source.zig | 81 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 46ea8bd97..c28abff22 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -65,6 +65,87 @@ pub fn encode(self: Source, writer: anytype) !void { } } +/// Returns a ComptimeStringMap for all of the capabilities in this terminfo. +/// The value is the value that should be sent as a response to XTGETTCAP. +/// Important: the value is the FULL response included the escape sequences. +pub fn xtgettcapMap(comptime self: Source) type { + const KV = struct { []const u8, []const u8 }; + + // We have all of our capabilities plus To, TN, and RGB which aren't + // in the capabilities list but are query-able. + const len = self.capabilities.len + 3; + var kvs: [len]KV = .{.{ "", "" }} ** len; + + // We first build all of our entries with raw K=V pairs. + kvs[0] = .{ "TN", "ghostty" }; + kvs[1] = .{ "Co", "256" }; + kvs[2] = .{ "RGB", "8" }; + for (self.capabilities, 3..) |cap, i| { + kvs[i] = .{ cap.name, switch (cap.value) { + .canceled => @compileError("canceled not handled yet"), + .boolean => "", + .string => |v| v, + .numeric => |v| numeric: { + var buf: [1024]u8 = undefined; + const num_len = std.fmt.formatIntBuf(&buf, v, 10, .upper, .{}); + break :numeric buf[0..num_len]; + }, + } }; + } + + // Now go through and convert them all to hex-encoded strings. + for (&kvs) |*entry| { + // The key is just the raw hex-encoded string + entry[0] = hexencode(entry[0]); + + // The value is more complex + var buf: [5 + entry[0].len + 1 + (entry[1].len * 2) + 2]u8 = undefined; + entry[1] = if (std.mem.eql(u8, entry[1], "")) std.fmt.bufPrint( + &buf, + "\x1bP1+r{s}\x1b\\", + .{entry[0]}, // important: hex-encoded name + ) catch unreachable else std.fmt.bufPrint( + &buf, + "\x1bP1+r{s}={s}\x1b\\", + .{ entry[0], hexencode(entry[1]) }, // important: hex-encoded name + ) catch unreachable; + } + + return std.ComptimeStringMap([]const u8, kvs); +} + +fn hexencode(comptime input: []const u8) []const u8 { + return comptime &(std.fmt.bytesToHex(input, .upper)); +} + +test "xtgettcap map" { + const testing = std.testing; + + const src: Source = .{ + .names = &.{ + "ghostty", + "xterm-ghostty", + "Ghostty", + }, + + .capabilities = &.{ + .{ .name = "am", .value = .{ .boolean = {} } }, + .{ .name = "colors", .value = .{ .numeric = 256 } }, + .{ .name = "Smulx", .value = .{ .string = "\\E[4:%p1%dm" } }, + }, + }; + + const map = comptime src.xtgettcapMap(); + try testing.expectEqualStrings( + "\x1bP1+r616D\x1b\\", + map.get(hexencode("am")).?, + ); + try testing.expectEqualStrings( + "\x1bP1+r536D756C78=5C455B343A25703125646D\x1b\\", + map.get(hexencode("Smulx")).?, + ); +} + test "encode" { const src: Source = .{ .names = &.{ From 8208947290b33d292fa60128d49b050ae3c51b43 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 14:27:56 -0700 Subject: [PATCH 4/7] termio/exec: hook up xtgettcap --- src/terminal/dcs.zig | 18 ++++++++---------- src/termio/Exec.zig | 9 +++++++-- 2 files changed, 15 insertions(+), 12 deletions(-) diff --git a/src/terminal/dcs.zig b/src/terminal/dcs.zig index b6a78afe9..83d4c7d22 100644 --- a/src/terminal/dcs.zig +++ b/src/terminal/dcs.zig @@ -123,13 +123,11 @@ pub const Command = union(enum) { pub const XTGETTCAP = struct { data: std.ArrayList(u8), - - // Note: do not reset this. The next function mutates data so it is - // unsafe to reset this and reiterate. i: usize = 0, /// Returns the next terminfo key being requested and null - /// when there are no more keys. + /// when there are no more keys. The returned value is NOT hex-decoded + /// because we expect to use a comptime lookup table. pub fn next(self: *XTGETTCAP) ?[]const u8 { if (self.i >= self.data.items.len) return null; @@ -141,9 +139,7 @@ pub const Command = union(enum) { // never read. self.i += idx + 1; - // HEX decode in-place so we don't have to allocate. If invalid - // hex is given then we just return null and stop processing. - return std.fmt.hexToBytes(rem, rem[0..idx]) catch null; + return rem[0..idx]; } }; }; @@ -183,7 +179,7 @@ test "XTGETTCAP command" { var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); try testing.expect(cmd.xtgettcap.next() == null); } @@ -198,8 +194,8 @@ test "XTGETTCAP command multiple keys" { var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); - try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); - try testing.expectEqualStrings("Smulx", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); try testing.expect(cmd.xtgettcap.next() == null); } @@ -214,5 +210,7 @@ test "XTGETTCAP command invalid data" { var cmd = h.unhook().?; defer cmd.deinit(); try testing.expect(cmd == .xtgettcap); + try testing.expectEqualStrings("who", cmd.xtgettcap.next().?); + try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?); try testing.expect(cmd.xtgettcap.next() == null); } diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index 5ece318fb..d41d2f05c 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -13,6 +13,7 @@ const Command = @import("../Command.zig"); const Pty = @import("../Pty.zig"); const SegmentedPool = @import("../segmented_pool.zig").SegmentedPool; const terminal = @import("../terminal/main.zig"); +const terminfo = @import("../terminfo/main.zig"); const xev = @import("xev"); const renderer = @import("../renderer.zig"); const tracy = @import("tracy"); @@ -1219,8 +1220,12 @@ const StreamHandler = struct { // log.warn("DCS command: {}", .{cmd}); switch (cmd) { - .xtgettcap => |gettcap| { - _ = gettcap; + .xtgettcap => |*gettcap| { + const map = comptime terminfo.ghostty.xtgettcapMap(); + while (gettcap.next()) |key| { + const response = map.get(key) orelse continue; + self.messageWriter(.{ .write_stable = response }); + } }, } } From 28329761a714342c785c4b18492aa35532d764d8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 14:30:27 -0700 Subject: [PATCH 5/7] terminfo: xtgettcap map name should use the source name --- src/terminfo/Source.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index c28abff22..2190e0210 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -77,7 +77,7 @@ pub fn xtgettcapMap(comptime self: Source) type { var kvs: [len]KV = .{.{ "", "" }} ** len; // We first build all of our entries with raw K=V pairs. - kvs[0] = .{ "TN", "ghostty" }; + kvs[0] = .{ "TN", self.names[0] }; kvs[1] = .{ "Co", "256" }; kvs[2] = .{ "RGB", "8" }; for (self.capabilities, 3..) |cap, i| { From 4724e0dcb412137df1e9714bfd330709b60615b8 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 14:35:22 -0700 Subject: [PATCH 6/7] terminfo: numeric comptime buffer only needs to be 10 bytes --- src/terminfo/Source.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/terminfo/Source.zig b/src/terminfo/Source.zig index 2190e0210..82bb7f1fc 100644 --- a/src/terminfo/Source.zig +++ b/src/terminfo/Source.zig @@ -86,7 +86,7 @@ pub fn xtgettcapMap(comptime self: Source) type { .boolean => "", .string => |v| v, .numeric => |v| numeric: { - var buf: [1024]u8 = undefined; + var buf: [10]u8 = undefined; const num_len = std.fmt.formatIntBuf(&buf, v, 10, .upper, .{}); break :numeric buf[0..num_len]; }, From 1dcb23de0fdcc0c9a09f31db236bfa881da3a9d0 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 27 Sep 2023 14:52:13 -0700 Subject: [PATCH 7/7] termio/exec: need to defer deinit for DCS command --- src/termio/Exec.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/termio/Exec.zig b/src/termio/Exec.zig index d41d2f05c..a3df264d2 100644 --- a/src/termio/Exec.zig +++ b/src/termio/Exec.zig @@ -1216,7 +1216,7 @@ const StreamHandler = struct { pub fn dcsUnhook(self: *StreamHandler) !void { var cmd = self.dcs.unhook() orelse return; - cmd.deinit(); + defer cmd.deinit(); // log.warn("DCS command: {}", .{cmd}); switch (cmd) {