From 4e817e79a1d7e3fe7393297e3c8f1269abb6523a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 25 Aug 2026 08:30:38 -0700 Subject: [PATCH] terminal: treat high bytes in DCS strings as payload data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refs #11216 The dcs_passthrough state only forwarded bytes 0x00-0x7E to the DCS handler. Bytes 0x80-0x9F hit the "anywhere" C1 transitions and exited the string, while 0xA0-0xFF fell through to the default transition and were silently dropped. **This breaks any DCS payload carrying UTF-8. ** A continuation byte in the C1 range terminates or corrupts the string: "Ü" is 0xC3 0x9C, so the 0xC3 is dropped and the 0x9C acts as 8-bit ST, ending the DCS mid-character. Also, a payload byte such as 0x9B (second byte of "Û") transitions to csi_entry, so the remainder of the payload executes as a live control sequence. This is a prerequisite for tmux control mode (#1935), whose %output notifications carry raw UTF-8 pane content. Fix this in the parse table only: override 0x80-0xFF in dcs_passthrough to put and in dcs_ignore to ignore, exactly how osc_string already claims 0x20-0xFF (including 0x9C) as data. This deviates from the vt100.net state machine (https://vt100.net/emu/dec_ansi_parser) deliberately and includes 0x9C: a raw 0x9C is indistinguishable from a UTF-8 continuation byte, and we don't honor 8-bit C1 controls in the ground state either. --- src/terminal/parse_table.zig | 60 ++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) diff --git a/src/terminal/parse_table.zig b/src/terminal/parse_table.zig index 01bd569cb..fa074fa07 100644 --- a/src/terminal/parse_table.zig +++ b/src/terminal/parse_table.zig @@ -206,6 +206,13 @@ fn genTable() Table { single(&result, 0x19, source, source, .ignore); range(&result, 0, 0x17, source, source, .ignore); range(&result, 0x1C, 0x1F, source, source, .ignore); + + // High bytes are ignored payload data, overriding the + // "anywhere" C1 transitions. See dcs_passthrough below for more. + // In dcs_ignore the additional concern is that a UTF-8 payload in + // an ignored DCS could otherwise begin a live sequence mid-string + // (e.g. 0x9B => csi_entry). + range(&result, 0x80, 0xFF, source, source, .ignore); } // dcs_param @@ -241,6 +248,21 @@ fn genTable() Table { range(&result, 0x1C, 0x1F, source, source, .put); range(&result, 0x20, 0x7E, source, source, .put); single(&result, 0x7F, source, source, .ignore); + + // High bytes are payload data, overriding the "anywhere" C1 + // transitions, matching how osc_string handles them below. + // Ghostty is UTF-8 only, and DCS payloads carry UTF-8 text + // (e.g. tmux control mode pane content): without this, a + // UTF-8 continuation byte in the C1 range terminates or + // corrupts the string and 0xA0-0xFF are silently dropped. + // + // This includes 0x9C (8-bit ST) on purpose: a raw 0x9C is + // indistinguishable from a UTF-8 continuation byte ("Ü" is + // 0xC3 0x9C), and Ghostty doesn't honor 8-bit C1 controls in + // the ground state either (they go through UTF-8 decoding). + // DCS strings terminate via 7-bit ST (ESC \) and abort via + // CAN/SUB, which are unaffected here. + range(&result, 0x80, 0xFF, source, source, .put); } // csi_param @@ -386,3 +408,41 @@ test { // that it succeeds in creation. _ = table; } + +test "dcs_passthrough: high bytes are payload data" { + // Bytes 0x80-0xFF within a DCS string are payload data, not C1 + // controls. This includes 0x9C (8-bit ST): a raw 0x9C is + // indistinguishable from a UTF-8 continuation byte (e.g. "Ü" is + // 0xC3 0x9C) and Ghostty doesn't support 8-bit C1 controls + // anywhere else. + for (0x80..0x100) |c| { + const entry = table[c][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.dcs_passthrough, entry.state); + try std.testing.expectEqual(Action.put, entry.action); + } +} + +test "dcs_ignore: high bytes are ignored payload data" { + // Same as dcs_passthrough: a UTF-8 payload inside an ignored DCS + // must not trigger "anywhere" C1 transitions (e.g. 0x9B beginning + // a CSI mid-string). + for (0x80..0x100) |c| { + const entry = table[c][@intFromEnum(State.dcs_ignore)]; + try std.testing.expectEqual(State.dcs_ignore, entry.state); + try std.testing.expectEqual(Action.ignore, entry.action); + } +} + +test "dcs_passthrough: ESC, CAN, and SUB still exit" { + // 7-bit ST (ESC \) is the DCS terminator and CAN/SUB abort, so + // these must continue to leave dcs_passthrough. dcs_unhook is + // emitted by the parser on any transition out of dcs_passthrough. + const esc = table[0x1B][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.escape, esc.state); + + const can = table[0x18][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.ground, can.state); + + const sub = table[0x1A][@intFromEnum(State.dcs_passthrough)]; + try std.testing.expectEqual(State.ground, sub.state); +}