terminal/tmux: lazily decode %output octal escapes

pull/12076/head
henok3878 2026-04-08 15:00:14 -04:00
parent 9d65278f5a
commit 7f8dc85913
2 changed files with 109 additions and 69 deletions

View File

@ -183,55 +183,6 @@ pub const Parser = struct {
return terminator;
}
/// Decode tmux control mode octal escaping in place. tmux encodes
/// bytes less than ASCII 32 and `\` itself as `\ooo` (backslash +
/// three octal digits). The decoded output is always <= the input
/// length, so this can safely write back into the same buffer.
fn decodeEscapedOutput(data: []u8) []const u8 {
var read_idx: usize = 0;
var write_idx: usize = 0;
while (read_idx < data.len) {
// Pass through non escape bytes as is.
if (data[read_idx] != '\\') {
data[write_idx] = data[read_idx];
read_idx += 1;
write_idx += 1;
continue;
}
read_idx += 1; // consume '\'
// Try to decode three octal digits following the backslash.
// If fewer than 3 bytes remain or any digit is non-octal,
// emit '?' as a visible replacement rather than silently
// dropping %output payload
var value: u8 = 0;
var ok = false;
// after consuming '\' read_idx is at the first potential octal digit, we need +2 after it.
if (read_idx + 2 < data.len) {
var i: usize = 0;
while (i < 3) : (i += 1) {
const digit = data[read_idx];
if (digit < '0' or digit > '7') break;
// tmux encodes bytes < 32 and '\' (92 = \134),
// so the max decoded value is 92. No overflow.
value = value * 8 + (digit - '0');
read_idx += 1;
}
ok = i == 3;
}
data[write_idx] = if (ok) value else '?';
write_idx += 1;
}
return data[0..write_idx];
}
fn parseNotification(self: *Parser) ParseError!?Notification {
assert(self.state == .notification);
@ -285,7 +236,7 @@ pub const Parser = struct {
line[@intCast(starts[1])..@intCast(ends[1])],
10,
) catch unreachable;
const data = decodeEscapedOutput(line[@intCast(starts[2])..@intCast(ends[2])]);
const data = line[@intCast(starts[2])..@intCast(ends[2])];
// Important: do not clear buffer here since name points to it
self.state = .idle;
@ -541,6 +492,55 @@ pub const Parser = struct {
}
};
/// Decode tmux control mode octal escaping in place. tmux encodes
/// bytes less than ASCII 32 and `\` itself as `\ooo` (backslash +
/// three octal digits). The decoded output is always <= the input
/// length, so this can safely write back into the same buffer.
fn decodeEscapedOutput(data: []u8) []const u8 {
var read_idx: usize = 0;
var write_idx: usize = 0;
while (read_idx < data.len) {
// Pass through non escape bytes as is.
if (data[read_idx] != '\\') {
data[write_idx] = data[read_idx];
read_idx += 1;
write_idx += 1;
continue;
}
read_idx += 1; // consume '\'
// Try to decode three octal digits following the backslash.
// If fewer than 3 bytes remain or any digit is non-octal,
// emit '?' as a visible replacement rather than silently
// dropping %output payload
var value: u8 = 0;
var ok = false;
// after consuming '\' read_idx is at the first potential octal digit, we need +2 after it.
if (read_idx + 2 < data.len) {
var i: usize = 0;
while (i < 3) : (i += 1) {
const digit = data[read_idx];
if (digit < '0' or digit > '7') break;
// tmux encodes bytes < 32 and '\' (92 = \134),
// so the max decoded value is 92. No overflow.
value = value * 8 + (digit - '0');
read_idx += 1;
}
ok = i == 3;
}
data[write_idx] = if (ok) value else '?';
write_idx += 1;
}
return data[0..write_idx];
}
/// Possible notification types from tmux control mode. These are documented
/// in tmux(1). A lot of the simple documentation was copied from that man
/// page here.
@ -566,10 +566,7 @@ pub const Notification = union(enum) {
block_err: []const u8,
/// Raw output from a pane.
output: struct {
pane_id: usize,
data: []const u8, // unescaped
},
output: Output,
/// The client is now attached to the session with ID session-id, which is
/// named name.
@ -620,6 +617,28 @@ pub const Notification = union(enum) {
name: []const u8,
},
pub const Output = struct {
pane_id: usize,
data: []u8,
/// Tracks whether `data` has been decoded for this Output copy.
///
/// `data` points into the parser buffer and is mutated in place, while this
/// flag belongs to this Output copy. A copy made before `decoded()` runs
/// keeps the flag false even after another copy decodes the buffer in place,
/// so only one copy of an Output may call `decoded()`.
is_decoded: bool = false,
pub fn decoded(self: *Output) []const u8 {
if (!self.is_decoded) {
const data = decodeEscapedOutput(self.data);
self.data = self.data[0..data.len];
self.is_decoded = true;
}
return self.data;
}
};
pub fn format(self: Notification, writer: *std.Io.Writer) !void {
const T = Notification;
const info = @typeInfo(T).@"union";
@ -777,7 +796,7 @@ test "tmux decode octal escape" {
const testing = std.testing;
var input = [_]u8{ '\\', '0', '3', '3', '[', '?', '2', '0', '0', '4', 'h' };
const got = Parser.decodeEscapedOutput(input[0..]);
const got = decodeEscapedOutput(input[0..]);
const expected = [_]u8{ 0x1b, '[', '?', '2', '0', '0', '4', 'h' };
try testing.expectEqualSlices(u8, expected[0..], got);
@ -787,7 +806,7 @@ test "tmux decode malformed escape" {
const testing = std.testing;
var input = [_]u8{ '\\', '0', 'x', '3' };
const got = Parser.decodeEscapedOutput(input[0..]);
const got = decodeEscapedOutput(input[0..]);
const expected = [_]u8{ '?', 'x', '3' };
try testing.expectEqualSlices(u8, expected[0..], got);
@ -800,12 +819,32 @@ test "tmux output decodes escapes" {
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 \\033ktitle\\033\\134") |byte| try testing.expect(try c.put(byte) == null);
const n = (try c.put('\n')).?;
var n = (try c.put('\n')).?;
const expected = [_]u8{ 0x1b, 'k', 't', 'i', 't', 'l', 'e', 0x1b, '\\' };
try testing.expect(n == .output);
try testing.expectEqual(42, n.output.pane_id);
try testing.expectEqualSlices(u8, expected[0..], n.output.data);
try testing.expectEqualStrings("\\033ktitle\\033\\134", n.output.data);
try testing.expectEqualSlices(u8, expected[0..], n.output.decoded());
}
test "tmux output decoded is idempotent" {
const testing = std.testing;
const alloc = testing.allocator;
var c: Parser = .{ .buffer = .init(alloc) };
defer c.deinit();
for ("%output %42 \\033ktitle\\033\\134") |byte| try testing.expect(try c.put(byte) == null);
var n = (try c.put('\n')).?;
try testing.expect(n == .output);
const first = n.output.decoded();
const second = n.output.decoded();
try testing.expectEqual(@intFromPtr(first.ptr), @intFromPtr(second.ptr));
try testing.expectEqual(first.len, second.len);
try testing.expectEqualSlices(u8, first, second);
}
test "tmux session-changed" {

View File

@ -459,10 +459,7 @@ pub const Viewer = struct {
command_consumed = true;
},
.output => |out| self.receivedOutput(
out.pane_id,
out.data,
) catch |err| {
.output => |out| self.receivedOutput(out) catch |err| {
log.warn(
"failed to process output for pane id={}: {}",
.{ out.pane_id, err },
@ -1099,15 +1096,16 @@ pub const Viewer = struct {
fn receivedOutput(
self: *Viewer,
id: usize,
data: []const u8,
out: control.Notification.Output,
) !void {
const entry = self.panes.getEntry(id) orelse {
log.info("received output for untracked pane id={}", .{id});
const entry = self.panes.getEntry(out.pane_id) orelse {
log.info("received output for untracked pane id={}", .{out.pane_id});
return;
};
const pane: *Pane = entry.value_ptr;
const t: *Terminal = &pane.terminal;
var output_val = out;
const data = output_val.decoded();
var stream = t.vtStream();
defer stream.deinit();
@ -1608,6 +1606,9 @@ test "initial flow" {
var viewer = try Viewer.init(testing.allocator);
defer viewer.deinit();
var new_output = "new\\134output".*;
var ignored_output = "ignored".*;
try testViewer(&viewer, &.{
.{ .input = .{ .tmux = .{ .block_end = "" } } },
.{
@ -1755,7 +1756,7 @@ test "initial flow" {
.input = .{ .tmux = .{ .block_end = "" } },
},
.{
.input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = "new output" } } },
.input = .{ .tmux = .{ .output = .{ .pane_id = 0, .data = new_output[0..] } } },
.check = (struct {
fn check(v: *Viewer, actions: []const Viewer.Action) anyerror!void {
try testing.expectEqual(0, actions.len);
@ -1766,12 +1767,12 @@ test "initial flow" {
.{ .active = .{} },
);
defer testing.allocator.free(str);
try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new output"));
try testing.expect(std.mem.containsAtLeast(u8, str, 1, "new\\output"));
}
}).check,
},
.{
.input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = "ignored" } } },
.input = .{ .tmux = .{ .output = .{ .pane_id = 999, .data = ignored_output[0..] } } },
.check = (struct {
fn check(_: *Viewer, actions: []const Viewer.Action) anyerror!void {
try testing.expectEqual(0, actions.len);