mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-04-06 07:38:21 +00:00
Fix tmux control parser premature %end/%error block termination (#11597)
Fixes [#11935.](https://github.com/ghostty-org/ghostty/issues/11395) I’m new to Zig, so I used AI assistance (Codex) while preparing this change. Before opening this PR, I manually reviewed every line of the final patch and stepped through the parser in LLDB to verify the behavior. Happy to make any changes. To better understand the parser, I also built a small model-checker model [here](https://gist.github.com/wyounas/284036272ba5893b6e413cafe2fe2a24). Separately from this fix, I think formal verification and modeling could be useful for parser work in Ghostty. The model is written in FizzBee, which uses a Python-like Starlark syntax and is fairly readable. If that seems useful, I’d be happy to open a separate discussion about whether something like that belongs in the repository as executable documentation or an additional safety net for future parser changes.
This commit is contained in:
@@ -116,19 +116,23 @@ pub const Parser = struct {
|
||||
)) |v| v + 1 else 0;
|
||||
const line = written[idx..];
|
||||
|
||||
if (std.mem.startsWith(u8, line, "%end") or
|
||||
std.mem.startsWith(u8, line, "%error"))
|
||||
{
|
||||
const err = std.mem.startsWith(u8, line, "%error");
|
||||
const output = std.mem.trimRight(u8, written[0..idx], "\r\n");
|
||||
|
||||
// If it is an error then log it.
|
||||
if (err) log.warn("tmux control mode error={s}", .{output});
|
||||
if (parseBlockTerminator(line)) |terminator| {
|
||||
const output = std.mem.trimRight(
|
||||
u8,
|
||||
written[0..idx],
|
||||
"\r\n",
|
||||
);
|
||||
|
||||
// Important: do not clear buffer since the notification
|
||||
// contains it.
|
||||
self.state = .idle;
|
||||
return if (err) .{ .block_err = output } else .{ .block_end = output };
|
||||
switch (terminator) {
|
||||
.end => return .{ .block_end = output },
|
||||
.err => {
|
||||
log.warn("tmux control mode error={s}", .{output});
|
||||
return .{ .block_err = output };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Didn't end the block, continue accumulating.
|
||||
@@ -144,6 +148,41 @@ pub const Parser = struct {
|
||||
|
||||
const ParseError = error{RegexError};
|
||||
|
||||
const BlockTerminator = enum { end, err };
|
||||
|
||||
/// Block payload is raw data, so a line only terminates a block if it
|
||||
/// exactly matches tmux's `%end`/`%error` guard-line shape.
|
||||
fn parseBlockTerminator(line_raw: []const u8) ?BlockTerminator {
|
||||
var line = line_raw;
|
||||
if (line.len > 0 and line[line.len - 1] == '\r') {
|
||||
line = line[0 .. line.len - 1];
|
||||
}
|
||||
|
||||
var fields = std.mem.tokenizeScalar(u8, line, ' ');
|
||||
const cmd = fields.next() orelse return null;
|
||||
const terminator: BlockTerminator = if (std.mem.eql(u8, cmd, "%end"))
|
||||
.end
|
||||
else if (std.mem.eql(u8, cmd, "%error"))
|
||||
.err
|
||||
else
|
||||
return null;
|
||||
|
||||
const time = fields.next() orelse return null;
|
||||
const command_id = fields.next() orelse return null;
|
||||
const flags = fields.next() orelse return null;
|
||||
const extra = fields.next();
|
||||
|
||||
// In the future, we should compare these to the %begin block
|
||||
// because the tmux source guarantees that these always match and
|
||||
// that is a more robust way to match.
|
||||
_ = std.fmt.parseInt(usize, time, 10) catch return null;
|
||||
_ = std.fmt.parseInt(usize, command_id, 10) catch return null;
|
||||
_ = std.fmt.parseInt(usize, flags, 10) catch return null;
|
||||
if (extra != null) return null;
|
||||
|
||||
return terminator;
|
||||
}
|
||||
|
||||
fn parseNotification(self: *Parser) ParseError!?Notification {
|
||||
assert(self.state == .notification);
|
||||
|
||||
@@ -597,6 +636,81 @@ test "tmux begin/end data" {
|
||||
try testing.expectEqualStrings("hello\nworld", n.block_end);
|
||||
}
|
||||
|
||||
test "tmux block payload may start with %end" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c: Parser = .{ .buffer = .init(alloc) };
|
||||
defer c.deinit();
|
||||
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end not really\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
|
||||
const n = (try c.put('\n')).?;
|
||||
try testing.expect(n == .block_end);
|
||||
try testing.expectEqualStrings("%end not really\nhello", n.block_end);
|
||||
}
|
||||
|
||||
test "tmux block payload may start with %error" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c: Parser = .{ .buffer = .init(alloc) };
|
||||
defer c.deinit();
|
||||
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
|
||||
const n = (try c.put('\n')).?;
|
||||
try testing.expect(n == .block_end);
|
||||
try testing.expectEqualStrings("%error not really\nhello", n.block_end);
|
||||
}
|
||||
|
||||
test "tmux block may terminate with real %error after misleading payload" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c: Parser = .{ .buffer = .init(alloc) };
|
||||
defer c.deinit();
|
||||
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%error not really\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%error 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
|
||||
const n = (try c.put('\n')).?;
|
||||
try testing.expect(n == .block_err);
|
||||
try testing.expectEqualStrings("%error not really\nhello", n.block_err);
|
||||
}
|
||||
|
||||
test "tmux block terminator requires exact token count" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c: Parser = .{ .buffer = .init(alloc) };
|
||||
defer c.deinit();
|
||||
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end 1 1 1 trailing\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
|
||||
const n = (try c.put('\n')).?;
|
||||
try testing.expect(n == .block_end);
|
||||
try testing.expectEqualStrings("%end 1 1 1 trailing\nhello", n.block_end);
|
||||
}
|
||||
|
||||
test "tmux block terminator requires numeric metadata" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var c: Parser = .{ .buffer = .init(alloc) };
|
||||
defer c.deinit();
|
||||
for ("%begin 1 1 1\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end foo bar baz\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("hello\n") |byte| try testing.expect(try c.put(byte) == null);
|
||||
for ("%end 1 1 1") |byte| try testing.expect(try c.put(byte) == null);
|
||||
const n = (try c.put('\n')).?;
|
||||
try testing.expect(n == .block_end);
|
||||
try testing.expectEqualStrings("%end foo bar baz\nhello", n.block_end);
|
||||
}
|
||||
|
||||
test "tmux output" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
Reference in New Issue
Block a user