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(); }