daiimus 2026-06-01 12:31:01 -07:00 committed by GitHub
commit f21be02ed4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 315 additions and 1 deletions

View File

@ -18,6 +18,13 @@ pub const Handler = struct {
/// This is arbitrarily set to 1MB today, increase if needed.
max_bytes: usize = 1024 * 1024,
/// Tracks whether we received an ESC byte that could be the start
/// of a 7-bit ST (ESC \). Because ESC is now forwarded as .put in
/// dcs_passthrough (to prevent premature DCS termination from ESC
/// bytes embedded in tmux control mode output), we must detect
/// 7-bit ST ourselves.
pending_esc: bool = false,
pub fn deinit(self: *Handler) void {
self.discard();
}
@ -27,6 +34,7 @@ pub const Handler = struct {
// Initialize our state to ignore in case of error
self.state = .ignore;
self.pending_esc = false;
// Try to parse the hook.
const hk_ = self.tryHook(alloc, dcs) catch |err| {
@ -112,8 +120,34 @@ pub const Handler = struct {
/// Put a byte into the DCS handler. This will return a command
/// if a command needs to be executed.
pub fn put(self: *Handler, byte: u8) ?Command {
// Handle 7-bit ST detection. Because ESC (0x1B) is now forwarded
// as .put in dcs_passthrough (rather than triggering a state
// transition to .escape), we must detect the ESC + '\' sequence
// that forms 7-bit ST ourselves.
if (self.pending_esc) {
self.pending_esc = false;
if (byte == 0x5C) {
// ESC \ = 7-bit ST terminate DCS, same as unhook()
return self.unhook();
}
// Not ST: forward the stored ESC to the sub-handler first,
// then fall through to handle the current byte normally.
if (self.forwardPut(0x1B)) |cmd| return cmd;
return self.forwardPut(byte);
}
if (byte == 0x1B) {
self.pending_esc = true;
return null;
}
return self.forwardPut(byte);
}
/// Forward a byte to the appropriate sub-handler.
fn forwardPut(self: *Handler, byte: u8) ?Command {
return 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;
@ -158,6 +192,7 @@ pub const Handler = struct {
// Note: we do NOT call deinit here on purpose because some commands
// transfer memory ownership. If state needs cleanup, the switch
// prong below should handle it.
self.pending_esc = false;
defer self.state = .inactive;
return switch (self.state) {
@ -199,6 +234,7 @@ pub const Handler = struct {
}
fn discard(self: *Handler) void {
self.pending_esc = false;
self.state.deinit();
self.state = .inactive;
}
@ -428,3 +464,139 @@ test "tmux enter and implicit exit" {
try testing.expect(cmd.tmux == .exit);
}
}
test "7-bit ST (ESC \\) terminates DCS via put" {
const testing = std.testing;
const alloc = testing.allocator;
// Use XTGETTCAP as a simple DCS to test with
var h: Handler = .{};
defer h.deinit();
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
// Put some data
for ("536D756C78") |byte| _ = h.put(byte);
// Now send ESC \ (7-bit ST)
try testing.expect(h.put(0x1B) == null); // ESC buffered
var cmd = h.put(0x5C).?; // '\' completes ST unhook
defer cmd.deinit();
try testing.expect(cmd == .xtgettcap);
try testing.expectEqualStrings("536D756C78", cmd.xtgettcap.next().?);
try testing.expect(cmd.xtgettcap.next() == null);
// Handler should be inactive after ST
try testing.expect(h.state == .inactive);
try testing.expect(h.pending_esc == false);
}
test "ESC followed by non-backslash is forwarded" {
const testing = std.testing;
const alloc = testing.allocator;
// Use XTGETTCAP as a simple DCS to test with
var h: Handler = .{};
defer h.deinit();
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
// Put some data
for ("AB") |byte| _ = h.put(byte);
// Send ESC followed by '[' (not ST this is CSI start)
try testing.expect(h.put(0x1B) == null); // ESC buffered
try testing.expect(h.put('[') == null); // Not ST, both bytes forwarded
// The ESC and '[' should be in the buffer
var cmd = h.unhook().?;
defer cmd.deinit();
try testing.expect(cmd == .xtgettcap);
// Buffer should contain: "AB" + ESC + "["
try testing.expectEqualStrings("AB\x1b[", cmd.xtgettcap.next().?);
}
test "tmux: ESC in block content does not cause exit" {
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
defer h.deinit();
{
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
defer cmd.deinit();
try testing.expect(cmd == .tmux);
try testing.expect(cmd.tmux == .enter);
}
// Simulate tmux sending a %begin block with embedded ESC sequences
// (like capture-pane -e output with SGR codes)
const block_with_esc =
"%begin 1234 1 0\n" ++
"\x1b[32mhello\x1b[0m\n" ++ // SGR green + reset
"%end 1234 1 0\n";
var got_block_end = false;
for (block_with_esc) |byte| {
if (h.put(byte)) |cmd| {
// We should get a tmux block_end notification, NOT an exit
try testing.expect(cmd == .tmux);
switch (cmd.tmux) {
.exit => return error.TestUnexpectedResult,
.block_end => {
got_block_end = true;
},
else => {},
}
}
}
// We should still be in tmux state (not exited)
try testing.expect(h.state == .tmux);
try testing.expect(got_block_end);
}
test "tmux: 7-bit ST exits tmux control mode" {
if (comptime !build_options.tmux_control_mode) return error.SkipZigTest;
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
defer h.deinit();
{
var cmd = h.hook(alloc, .{ .params = &.{1000}, .final = 'p' }).?;
defer cmd.deinit();
try testing.expect(cmd == .tmux);
try testing.expect(cmd.tmux == .enter);
}
// Send ESC \ (7-bit ST) to terminate tmux control mode
try testing.expect(h.put(0x1B) == null);
var cmd = h.put(0x5C).?;
defer cmd.deinit();
try testing.expect(cmd == .tmux);
try testing.expect(cmd.tmux == .exit);
try testing.expect(h.state == .inactive);
}
test "pending_esc is cleared on hook" {
const testing = std.testing;
const alloc = testing.allocator;
var h: Handler = .{};
defer h.deinit();
// Manually set pending_esc (simulating leftover state)
h.pending_esc = true;
// Hook into XTGETTCAP hook should clear pending_esc
try testing.expect(h.hook(alloc, .{ .intermediates = "+", .final = 'q' }) == null);
try testing.expect(h.pending_esc == false);
// Clean up: unhook so deinit doesn't leak
var cmd = h.unhook().?;
cmd.deinit();
}

View File

@ -238,9 +238,30 @@ fn genTable() Table {
// events
single(&result, 0x19, source, source, .put);
range(&result, 0, 0x17, source, source, .put);
single(&result, 0x18, source, source, .put); // CAN: override "anywhere" ground
single(&result, 0x1A, source, source, .put); // SUB: override "anywhere" ground
single(&result, 0x1B, source, source, .put); // ESC: override "anywhere" escape
range(&result, 0x1C, 0x1F, source, source, .put);
range(&result, 0x20, 0x7E, source, source, .put);
single(&result, 0x7F, source, source, .ignore);
// High bytes (0x80-0xFF): override "anywhere" transitions to stay
// in dcs_passthrough. tmux control mode protocol data contains raw
// UTF-8 in %output pane content. UTF-8 multi-byte sequences use:
// - Continuation bytes: 0x80-0xBF
// - 2-byte start bytes: 0xC0-0xDF
// - 3-byte start bytes: 0xE0-0xEF
// - 4-byte start bytes: 0xF0-0xF4
//
// Without these overrides, bytes >= 0xA0 would default to .none
// (silently dropped), mangling all non-ASCII UTF-8 text. Bytes
// 0x80-0x9F also need overrides to prevent "anywhere" C1 transitions
// from kicking the parser out of dcs_passthrough.
//
// 0x9C (C1 ST) is NOT overridden it is the legitimate 8-bit DCS
// string terminator and must still transition to ground.
range(&result, 0x80, 0x9B, source, source, .put);
range(&result, 0x9D, 0xFF, source, source, .put);
}
// csi_param
@ -386,3 +407,124 @@ test {
// that it succeeds in creation.
_ = table;
}
test "dcs_passthrough: ESC stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x1B][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: CAN stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x18][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: SUB stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x1A][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: C1 0x80 stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x80][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: C1 0x9B (CSI) stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x9B][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: C1 0x9D (OSC) stays in dcs_passthrough with put" {
const t = table;
const entry = t[0x9D][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry.state);
try @import("std").testing.expectEqual(Action.put, entry.action);
}
test "dcs_passthrough: C1 ST (0x9C) still transitions to ground" {
const t = table;
const entry = t[0x9C][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.ground, entry.state);
try @import("std").testing.expectEqual(Action.none, entry.action);
}
test "dcs_passthrough: regular bytes still produce put" {
const t = table;
// Printable ASCII
const entry_a = t['A'][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry_a.state);
try @import("std").testing.expectEqual(Action.put, entry_a.action);
// Control byte in put range
const entry_0 = t[0x00][@intFromEnum(State.dcs_passthrough)];
try @import("std").testing.expectEqual(State.dcs_passthrough, entry_0.state);
try @import("std").testing.expectEqual(Action.put, entry_0.action);
}
test "dcs_passthrough: UTF-8 start bytes (0xC0-0xF4) produce put" {
const t = table;
const testing = @import("std").testing;
// 2-byte start: 0xC0 (start of 2-byte range)
const entry_c0 = t[0xC0][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_c0.state);
try testing.expectEqual(Action.put, entry_c0.action);
// 2-byte start: 0xDF (end of 2-byte range)
const entry_df = t[0xDF][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_df.state);
try testing.expectEqual(Action.put, entry_df.action);
// 3-byte start: 0xE2 (box-drawing U+250x starts with 0xE2)
const entry_e2 = t[0xE2][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_e2.state);
try testing.expectEqual(Action.put, entry_e2.action);
// 4-byte start: 0xF0 (emoji U+1Fxxx starts with 0xF0)
const entry_f0 = t[0xF0][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_f0.state);
try testing.expectEqual(Action.put, entry_f0.action);
// Last valid 4-byte start: 0xF4
const entry_f4 = t[0xF4][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_f4.state);
try testing.expectEqual(Action.put, entry_f4.action);
}
test "dcs_passthrough: UTF-8 continuation bytes 0xA0-0xBF produce put" {
const t = table;
const testing = @import("std").testing;
// 0xA0 first continuation byte above C1 range
const entry_a0 = t[0xA0][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_a0.state);
try testing.expectEqual(Action.put, entry_a0.action);
// 0xBF last continuation byte
const entry_bf = t[0xBF][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry_bf.state);
try testing.expectEqual(Action.put, entry_bf.action);
}
test "dcs_passthrough: all bytes 0xA0-0xFF stay in dcs_passthrough with put" {
const t = table;
const testing = @import("std").testing;
// Verify the entire 0xA0-0xFF range. These bytes appear in UTF-8
// encoded text within tmux %output lines and must be forwarded
// to the DCS handler, not silently dropped.
for (0xA0..0x100) |byte| {
const entry = t[byte][@intFromEnum(State.dcs_passthrough)];
try testing.expectEqual(State.dcs_passthrough, entry.state);
try testing.expectEqual(Action.put, entry.action);
}
}