From 3beb6d717ac305e6a3c7b152103a6f7e81d8e3cf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Mon, 14 Sep 2026 12:30:53 -0700 Subject: [PATCH] terminal: decrqm don't truncate 16-bit mode requests DECRQM requests can use a full 16-bit number but we truncated to 15-bits for the reply (cause thats all we know about). Preserve, the full 16-bits for requests so we give the proper response. --- src/terminal/c/modes.zig | 2 +- src/terminal/modes.zig | 41 +++++++++++++++++++++++++++++--- src/terminal/stream_terminal.zig | 8 ++++++- src/termio/stream_handler.zig | 2 +- 4 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/terminal/c/modes.zig b/src/terminal/c/modes.zig index b249b2a51..4dc4e0ac2 100644 --- a/src/terminal/c/modes.zig +++ b/src/terminal/c/modes.zig @@ -24,7 +24,7 @@ pub fn report_encode( ) callconv(lib.calling_conv) Result { const mode_tag: modes.ModeTag = @bitCast(tag); const report: modes.Report = .{ - .tag = mode_tag, + .tag = .{ .value = mode_tag.value, .ansi = mode_tag.ansi }, .state = state.toZig() orelse return .invalid_value, }; diff --git a/src/terminal/modes.zig b/src/terminal/modes.zig index ed5f1b72a..9c99cc490 100644 --- a/src/terminal/modes.zig +++ b/src/terminal/modes.zig @@ -76,7 +76,7 @@ pub const ModeState = struct { /// Return a DECRPM report for the given mode tag. If the tag does /// not correspond to a known mode, the report state is .not_recognized. - pub fn getReport(self: *const ModeState, tag: ModeTag) Report { + pub fn getReport(self: *const ModeState, tag: Report.Tag) Report { // DECECM (Erase Color Mode, DEC private mode 117) controls whether erasing // and scrolling use the default background or the active background color. // Ghostty's behavior is fixed equivalent to DECECM reset, and DECRQM has a @@ -191,14 +191,27 @@ pub fn modeFromInt(v: u16, ansi: bool) ?Mode { /// A DECRPM mode report response. pub const Report = struct { - tag: ModeTag, + tag: Tag, state: State, + /// A query identifier can use the full parser parameter range. Keep it + /// separate from ModeTag, which packs supported modes and the ANSI flag + /// into a u16 and is also used by the public C API. + pub const Tag = struct { + value: u16, + ansi: bool = false, + + pub fn fromMode(mode: Mode) Tag { + const tag = ModeTag.fromMode(mode); + return .{ .value = tag.value, .ansi = tag.ansi }; + } + }; + pub const max_size = max_size: { // Construct the largest possible report in terms of values. const report: Report = .{ .tag = .{ - .value = std.math.maxInt(u15), + .value = std.math.maxInt(u16), .ansi = false, }, .state = .permanently_reset, @@ -460,3 +473,25 @@ test "Report.encode not recognized" { try report.encode(&writer); try testing.expectEqualStrings("\x1B[?9999;0$y", writer.buffered()); } + +test "getReport large unknown modes" { + const state: ModeState = .{}; + // These would alias ANSI insert mode and fixed-status DEC mode 117. + for ([_]Report.Tag{ + .{ .value = 32772, .ansi = true }, + .{ .value = 32885 }, + }) |tag| { + const report = state.getReport(tag); + try testing.expectEqual(Report.State.not_recognized, report.state); + try testing.expectEqualDeep(tag, report.tag); + } +} + +test "Report.encode maximum size" { + var buf: [Report.max_size]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + const report: Report = .{ .tag = .{ .value = 65535 }, .state = .permanently_reset }; + try report.encode(&writer); + try testing.expectEqualStrings("\x1b[?65535;4$y", writer.buffered()); + try testing.expectEqual(buf.len, writer.buffered().len); +} diff --git a/src/terminal/stream_terminal.zig b/src/terminal/stream_terminal.zig index 61172423a..09b944aa6 100644 --- a/src/terminal/stream_terminal.zig +++ b/src/terminal/stream_terminal.zig @@ -1517,7 +1517,7 @@ pub const Handler = struct { fn requestModeUnknown(self: *Handler, mode_raw: u16, ansi: bool) void { const report = self.terminal.modes.getReport(.{ - .value = @truncate(mode_raw), + .value = mode_raw, .ansi = ansi, }); self.sendModeReport(report); @@ -4605,6 +4605,12 @@ test "request mode DECRQM with write_pty callback" { s.nextSlice("\x1B[?7$p"); try testing.expectEqualStrings("\x1B[?7;2$y", S.last_response.?); + // A large unknown mode must not alias wraparound mode 7. + const before = t.modes; + s.nextSlice("\x1B[?32775$p"); + try testing.expectEqualStrings("\x1B[?32775;0$y", S.last_response.?); + try testing.expectEqualDeep(before, t.modes); + // Query an unknown mode s.nextSlice("\x1B[?9999$p"); try testing.expectEqualStrings("\x1B[?9999;0$y", S.last_response.?); diff --git a/src/termio/stream_handler.zig b/src/termio/stream_handler.zig index 27e06dcb7..f3026142e 100644 --- a/src/termio/stream_handler.zig +++ b/src/termio/stream_handler.zig @@ -583,7 +583,7 @@ pub const StreamHandler = struct { } fn requestModeUnknown(self: *StreamHandler, mode_raw: u16, ansi: bool) !void { - self.sendModeReport(self.terminal.modes.getReport(.{ .value = @truncate(mode_raw), .ansi = ansi })); + self.sendModeReport(self.terminal.modes.getReport(.{ .value = mode_raw, .ansi = ansi })); } fn sendModeReport(self: *StreamHandler, report: terminal.modes.Report) void {