From f2c0c6f1aa90a567dc9d37c5eb8f53ece58cff88 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 14:11:05 -0300 Subject: Terminal replies and malformed escape sequences never become typed text --- build.zig | 188 +++++++++++++++++++++++++++++++++++++++++++++++++++++++- src/tty/tty.zig | 79 ++++++++++++++++++++++++ 2 files changed, 265 insertions(+), 2 deletions(-) diff --git a/build.zig b/build.zig index 79de8a81..aee8541a 100644 --- a/build.zig +++ b/build.zig @@ -682,11 +682,195 @@ pub fn build(b: *std.Build) void { .tables_path = uucode_tables, }).module("uucode"); - const vaxis_mod = b.dependency("vaxis", .{ + const vaxis_dep = b.dependency("vaxis", .{ .target = target, .optimize = optimize, .external_uucode = true, - }).module("vaxis"); + }); + const vaxis_mod = vaxis_dep.module("vaxis"); + // vaxis's input parser, patched here at build time like mvzr so that a + // fresh fetch keeps it: no reply, split read or malformed sequence may + // reach the editor as typed text, and none may panic or end input. + // - A cursor position report that no F3 could have sent is no key: the + // startup width queries' `CSI 1;1R` arrived as two F3s, which a shell + // pane took for text. + // - ESC before a CSI or SS3 key (rxvt's Alt+arrow) is Alt on that key, + // not Alt+Escape followed by `[A` as text. + // - An X10 mouse report and a kitty graphics reply cut short by a read + // wait for their rest: the first became three bytes of text, the + // second sliced past its input. + // - A malformed color or clipboard reply is dropped instead of failing + // the parse, which stopped input; so is a byte no UTF-8 starts with. + // - An OSC reply ends at its first terminator: one ended by BEL ran on to + // the next sequence's ESC and left what lay between as text. + // - A device report's fields are sought in the sequence, not past it. + // A bump that moves an anchor stops the build. + { + const parser_path = vaxis_dep.path("src/Parser.zig").getPath(b); + var src = std.Io.Dir.cwd().readFileAlloc(io, parser_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Parser.zig"); + const edits = [_]struct { anchor: []const u8, with: []const u8, count: usize = 1 }{ + .{ + .anchor = + \\ 0x5F => return parseApc(input), + \\ else => { + , + .with = + \\ 0x5F => return parseApc(input), + \\ // pardes's patch (its build.zig): ESC before a key's sequence is Alt on the key. + \\ 0x1B => if (input.len > 2 and (input[2] == '[' or input[2] == 'O')) { + \\ var inner = if (input[2] == '[') parseCsi(input[1..], &self.buf) else parseSs3(input[1..]); + \\ if (inner.n == 0) return inner; + \\ if (inner.event) |*event| switch (event.*) { + \\ .key_press, .key_release => |*key| key.mods.alt = true, + \\ else => {}, + \\ }; + \\ inner.n += 1; + \\ return inner; + \\ } else return .{ .event = .{ .key_press = .{ .codepoint = Key.escape, .mods = .{ .alt = true } } }, .n = 2 }, + \\ else => { + , + }, + .{ + .anchor = " const first_cp = iter.next() orelse return error.InvalidUTF8;\n", + .with = " const first_cp = iter.next() orelse return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return error.InvalidUTF8;\n", + .with = " n = std.unicode.utf8CodepointSequenceLength(first_cp) catch return .{ .event = null, .n = 1 }; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + .with = + \\ // pardes's patch (its build.zig): an ESC that ends the input is half an ST. + \\ if (input.len < end + 2) return .{ .event = null, .n = 0 }; + \\ const sequence = input[0 .. end + 1 + 1]; + \\ + \\ switch (input[2]) { + , + }, + .{ + .anchor = + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ + \\ // No escape, could be BEL terminated + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07) orelse return .{ + \\ .event = null, + \\ .n = 0, + \\ }; + \\ bel_terminated = true; + \\ break :blk bel + 1; + , + .with = + \\ // pardes's patch (its build.zig): whichever terminator comes first ends it. + \\ const esc = std.mem.indexOfScalarPos(u8, input, 2, 0x1b); + \\ const bel = std.mem.indexOfScalarPos(u8, input, 2, 0x07); + \\ if (bel != null and (esc == null or bel.? < esc.?)) { + \\ bel_terminated = true; + \\ break :blk bel.? + 1; + \\ } + \\ const esc_result = skipUntilST(input); + \\ if (esc_result.n > 0) break :blk esc_result.n; + \\ return .{ .event = null, .n = 0 }; + , + }, + .{ + .anchor = " const color = try Color.rgbFromSpec(color_spec);\n", + .with = " const color = Color.rgbFromSpec(color_spec) catch return null_event; // pardes's patch (its build.zig)\n", + .count = 2, + }, + .{ + .anchor = + \\ if (input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = if (bel_terminated) + \\ input[semicolon_idx + 3 .. sequence.len - 1] + \\ else + \\ input[semicolon_idx + 3 .. sequence.len - 2]; + \\ const decoder = std.base64.standard.Decoder; + \\ const text = try paste_allocator.?.alloc(u8, try decoder.calcSizeForSlice(payload)); + \\ try decoder.decode(text, payload); + , + .with = + \\ // pardes's patch (its build.zig): a malformed reply is dropped. + \\ const body_end = if (bel_terminated) sequence.len - 1 else sequence.len - 2; + \\ if (semicolon_idx + 3 > body_end or input[semicolon_idx + 1] != 'c') return null_event; + \\ const payload = input[semicolon_idx + 3 .. body_end]; + \\ const decoder = std.base64.standard.Decoder; + \\ const gpa = paste_allocator orelse return null_event; + \\ const text = gpa.alloc(u8, decoder.calcSizeForSlice(payload) catch return null_event) catch return null_event; + \\ decoder.decode(text, payload) catch { + \\ gpa.free(text); + \\ return null_event; + \\ }; + , + }, + .{ + .anchor = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ + , + .with = + \\ 'A', 'B', 'C', 'D', 'E', 'F', 'H', 'P', 'Q', 'R', 'S' => { + \\ // pardes's patch (its build.zig): a cursor position report is no key. + \\ if (final == 'R' and cursorReport(sequence)) return null_event; + \\ + , + }, + .{ + .anchor = " std.debug.assert(sequence.len >= 4); // ESC [ ? c == 4 bytes\n", + .with = " if (sequence.len < 4) return null_event; // pardes's patch (its build.zig)\n", + }, + .{ + .anchor = "const delim_idx = std.mem.indexOfScalarPos(u8, input, 3, ';')", + .with = "const delim_idx = std.mem.indexOfScalarPos(u8, sequence, 3, ';')", + .count = 2, + }, + .{ + .anchor = "input[delim_idx + 1 .. sequence.len - 2]", + .with = "sequence[@min(delim_idx + 1, sequence.len - 2) .. sequence.len - 2]", + }, + .{ + .anchor = " if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) {\n", + .with = + \\ // pardes's patch (its build.zig): an X10 report still arriving is incomplete, not text. + \\ if (input.len == 3 and input[2] == 'M' and full_input.len < 6) return .{ .event = null, .n = 0 }; + \\ if (input.len == 3 and (input[2] == 'M') and full_input.len >= 6) { + \\ + , + }, + }; + for (edits) |edit| { + if (std.mem.count(u8, src, edit.anchor) != edit.count) std.debug.panic("vaxis's Parser.zig changed at `{s}`: redo its patch in build.zig", .{edit.anchor}); + src = std.mem.replaceOwned(u8, b.allocator, src, edit.anchor, edit.with) catch @panic("OOM"); + } + const cursor_report = + \\ + \\/// pardes's patch (its build.zig): whether `CSI ... R` answers a cursor + \\/// position query. Only `CSI 1 ; mods R` with mods past 1 is a modified F3 + \\/// (a bare F3 is `SS3 R`); a report has another row, or col 1 (mods 1). + \\fn cursorReport(sequence: []const u8) bool { + \\ const params = sequence[2 .. sequence.len - 1]; + \\ if (params.len > 0 and params[0] == '?') return true; + \\ var fields = std.mem.splitScalar(u8, params, ';'); + \\ const row = fields.first(); + \\ const col = fields.next() orelse return false; + \\ if (fields.next() != null or row.len == 0 or col.len == 0) return false; + \\ for (params) |c| if (c != ';' and !std.ascii.isDigit(c)) return false; + \\ return !std.mem.eql(u8, row, "1") or std.mem.eql(u8, col, "1"); + \\} + \\ + ; + const files = b.addWriteFiles(); + _ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report })); + _ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig"); + // Suffixes: this leaves out both Parser.zig files, written above. + _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} }); + vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig"); + } var ghostty_vt_for_snap: ?*std.Build.Module = null; const ghostty_simd = !freestanding_core and (!target.result.os.tag.isDarwin() or b.graph.host.result.os.tag.isDarwin()); diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a6f6bdfc..8992b51b 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -243,6 +243,85 @@ test "terminal input preserves fragmented keys queries paste and text after EOF" try std.testing.expect(resized and up and pasted and clipboard and escape); } +test "terminal replies and malformed sequences never become typed text" { + if (comptime builtin.os.tag == .windows) return error.SkipZigTest; + const gpa = std.testing.allocator; + const io = std.testing.io; + var env = try std.testing.environ.createMap(gpa); + defer env.deinit(); + var vx = try vaxis.init(io, gpa, &env, .{ .system_clipboard_allocator = gpa }); + var output: std.Io.Writer.Allocating = .init(gpa); + defer output.deinit(); + defer vx.deinit(gpa, &output.writer); + vx.queries_done.store(false, .unordered); + var tty: vaxis.Tty = undefined; + var loop: Loop = .init(io, &tty, &vx); + var cache: vaxis.GraphemeCache = .{}; + const Reader = struct { + parts: []const []const u8, + next: usize = 0, + fn getWinsize(_: *@This()) !vaxis.Winsize { + return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }; + } + fn read(self: *@This(), buf: []u8) !usize { + if (self.next == self.parts.len) return 0; + const part = self.parts[self.next]; + self.next += 1; + @memcpy(buf[0..part.len], part); + return part.len; + } + }; + var reader: Reader = .{ + .parts = &.{ + // The startup width queries' answers where the terminal has no + // explicit width: both used to arrive as a bare F3, typed as text. + "a\x1b[1;1R\x1b[1;1R", + // A late report somewhere else on the screen, and DECXCPR. + "\x1b[12;40R\x1b[?3;7;1R", + // rxvt's Alt+Up: ESC before the sequence, not Alt+Esc then `[A`. + "b\x1b\x1b[A", + // A kitty graphics reply cut inside its ST sliced past its input. + "\x1b_Gi=1;OK\x1b", + "\\", + // An X10 mouse report cut after `CSI M` left three bytes as text. + "\x1b[M", + " !!", + // Malformed color and clipboard replies failed the parse, which + // ended input altogether; and a reply ended by BEL ran on to the + // next sequence's ESC, leaving what lay between as text. + "\x1b]11;rgb:zz/zz/zz\x1b\\\x1b]52;c;!!!!\x07\x1b]52\x07", + // Device reports whose fields end early. + "\x1b[?1;y\x1b[?997;n\x1b[c", + "\x1b[?62;c", + "c", + }, + }; + try readInput(&loop, &reader, &cache); + try std.testing.expect(vx.queries_done.load(.unordered)); + try std.testing.expect(vx.caps.kitty_graphics); + var text: std.ArrayList(u8) = .empty; + defer text.deinit(gpa); + var alt_up = false; + var clicked = false; + while (try loop.tryEvent()) |event| switch (event) { + .winsize => {}, + .key_press => |key| { + if (key.codepoint == vaxis.Key.up and key.mods.alt) { + alt_up = true; + } else if (key.text) |bytes| { + try text.appendSlice(gpa, bytes); + } else return error.UnexpectedInputKey; + }, + .mouse => |mouse| { + try std.testing.expectEqual(@as(i16, 0), mouse.col); + clicked = true; + }, + else => return error.UnexpectedInputEvent, + }; + try std.testing.expectEqualStrings("abc", text.items); + try std.testing.expect(alt_up and clicked); +} + test "terminal input cancellation joins blocked reads and queued EOF" { if (comptime builtin.os.tag == .windows) return error.SkipZigTest; const gpa = std.testing.allocator; -- cgit v1.3