src/client/client_core.zig
Ref: Size: 17.8 KiB History
//! What a client does with a daemon frame besides paint it: the terminal
//! modes a session set, a clipboard write, a bell, the answer to a selection
//! request. One decoder with no transport and no terminal under it, so the
//! CLI client and the browser core read the same frame the same way. Receive
//! results borrow the payload; the optional clipboard text adapter explicitly
//! allocates an owned decoded copy.
const std = @import("std");
const proto = @import("term").protocol;
/// The bytes BORROW the frame payload; copy before reusing it.
pub const ClipboardSet = struct {
target: u8,
base64: []const u8,
};
pub const ClipboardText = struct { primary: bool, text: []u8 };
/// Text clipboard adapters must not silently truncate NUL or invalid UTF-8.
pub fn validClipboardText(text: []const u8) bool {
return text.len != 0 and std.unicode.utf8ValidateSlice(text) and std.mem.indexOfScalar(u8, text, 0) == null;
}
/// Decode a supported clipboard target into owned, validated text. Targets
/// addressed to numbered buffers or the secondary selection are ignored.
pub fn decodeClipboard(alloc: std.mem.Allocator, target: u8, base64: []const u8) !?ClipboardText {
if (target != 'c' and target != 'p' and target != 's') return null;
if (!validClipboard(target, base64)) return error.InvalidClipboard;
const size = std.base64.standard.Decoder.calcSizeForSlice(base64) catch return error.InvalidClipboard;
if (size == 0) return error.InvalidClipboard;
const text = try alloc.alloc(u8, size);
errdefer alloc.free(text);
std.base64.standard.Decoder.decode(text, base64) catch return error.InvalidClipboard;
if (!validClipboardText(text)) return error.InvalidClipboard;
return .{ .primary = target == 'p' or target == 's', .text = text };
}
pub const State = union(enum) {
terminal_modes: proto.TermModes,
};
pub const Effect = union(enum) {
clipboard_set: ClipboardSet,
bell,
};
pub const Reply = union(enum) {
selection: proto.SelectionReply,
};
pub const Result = union(enum) {
ignored,
state: State,
effect: Effect,
reply: Reply,
};
pub const ClientCore = struct {
terminal_modes: proto.TermModes = .{ .bracketed_paste = false },
pending_selection_id: ?u32 = null,
/// Decode one daemon frame's semantic terminal state or event. Any
/// borrowed clipboard bytes or selection reply text in the result remain
/// valid only while `payload` remains valid and unchanged.
pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result {
return switch (msg_type) {
.term_modes => self.receiveModes(payload),
.term_event => receiveEvent(payload),
.selection_reply => self.receiveSelectionReply(payload),
else => .ignored,
};
}
/// Start (or replace) the single correlated selection operation.
pub fn beginSelection(self: *ClientCore, req: proto.SelectionReq) [proto.selection_req_len]u8 {
self.pending_selection_id = req.id;
return proto.encodeSelectionReq(req);
}
fn receiveModes(self: *ClientCore, payload: []const u8) Result {
const modes = proto.decodeTermModes(payload) catch return .ignored;
self.terminal_modes = modes;
return .{ .state = .{ .terminal_modes = modes } };
}
fn receiveSelectionReply(self: *ClientCore, payload: []const u8) Result {
const reply = proto.decodeSelectionReply(payload) catch return .ignored;
const pending_id = self.pending_selection_id orelse return .ignored;
if (reply.id != pending_id) return .ignored;
self.pending_selection_id = null;
return .{ .reply = .{ .selection = reply } };
}
};
fn receiveEvent(payload: []const u8) Result {
const event = proto.decodeTermEvent(payload) catch return .ignored;
return switch (event) {
.bell => .{ .effect = .bell },
.clipboard => |clipboard| {
if (!validClipboard(clipboard.target, clipboard.base64)) return .ignored;
return .{ .effect = .{ .clipboard_set = .{
.target = clipboard.target,
.base64 = clipboard.base64,
} } };
},
};
}
/// Zero length is refused rather than proxied: it is OSC 52's "clear the
/// clipboard" form, and an interrupted copy that lands empty would silently
/// wipe whatever the human last copied. That is a policy, not a parse
/// question — the empty string is perfectly well-formed base64.
pub fn validClipboard(target: u8, base64: []const u8) bool {
if (!validTarget(target) or base64.len == 0 or base64.len > proto.clipboard_base64_max)
return false;
// Enforce only the injection-safe alphabet; canonical base64 length and
// padding rules stay terminal-compatible and are intentionally not added.
for (base64) |byte| {
if (!((byte >= 'A' and byte <= 'Z') or
(byte >= 'a' and byte <= 'z') or
(byte >= '0' and byte <= '9') or
byte == '+' or byte == '/' or byte == '=')) return false;
}
return true;
}
/// ghostty sets `kind = data[0]` unvalidated, so any byte a program in
/// the session writes arrives here: this list is the only guard.
fn validTarget(target: u8) bool {
return target == 'c' or target == 'p' or target == 'q' or target == 's' or
(target >= '0' and target <= '7');
}
test "client core mode updates deliver on and off samples" {
var core = ClientCore{};
const on = proto.encodeTermModes(.{ .bracketed_paste = true });
const off = proto.encodeTermModes(.{ .bracketed_paste = false });
const on_result = core.receive(.term_modes, &on);
try std.testing.expectEqual(true, core.terminal_modes.bracketed_paste);
try expectModes(on_result, true);
const off_result = core.receive(.term_modes, &off);
try std.testing.expectEqual(false, core.terminal_modes.bracketed_paste);
try expectModes(off_result, false);
}
test "client core delivers repeated equal mode samples" {
var core = ClientCore{};
const payload = proto.encodeTermModes(.{ .bracketed_paste = true });
try expectModes(core.receive(.term_modes, &payload), true);
try expectModes(core.receive(.term_modes, &payload), true);
}
test "client core malformed mode sample is ignored atomically" {
var core = ClientCore{ .terminal_modes = .{ .bracketed_paste = true } };
const malformed = [_]u8{ 1, 0, 0 };
try expectIgnored(core.receive(.term_modes, &malformed));
try std.testing.expectEqual(true, core.terminal_modes.bracketed_paste);
}
test "client core accepts clipboard and bell events" {
var core = ClientCore{};
const clipboard = [_]u8{ 0, 'c', 'a', 'G', 'k', '=' };
const clipboard_result = core.receive(.term_event, &clipboard);
switch (clipboard_result) {
.effect => |effect| switch (effect) {
.clipboard_set => |set| {
try std.testing.expectEqual(@as(u8, 'c'), set.target);
try std.testing.expectEqualStrings("aGk=", set.base64);
},
else => return error.ExpectedClipboard,
},
else => return error.ExpectedClipboard,
}
const bell = [_]u8{1};
switch (core.receive(.term_event, &bell)) {
.effect => |effect| switch (effect) {
.bell => {},
else => return error.ExpectedBell,
},
else => return error.ExpectedBell,
}
}
test "decodeClipboard decodes supported targets and rejects unsafe text" {
const a = std.testing.allocator;
const decoded = (try decodeClipboard(a, 'c', "aMOp")) orelse return error.ExpectedClipboard;
defer a.free(decoded.text);
try std.testing.expect(!decoded.primary);
try std.testing.expectEqualStrings("hé", decoded.text);
const primary = (try decodeClipboard(a, 's', "aGk=")) orelse return error.ExpectedClipboard;
defer a.free(primary.text);
try std.testing.expect(primary.primary);
try std.testing.expect((try decodeClipboard(a, 'q', "aGk=")) == null);
try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "aGk"));
try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "AA=="));
try std.testing.expectError(error.InvalidClipboard, decodeClipboard(a, 'c', "//8="));
}
test "client core accepts every clipboard target boundary" {
var core = ClientCore{};
const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' };
for (targets) |target| {
const payload = [_]u8{ 0, target, 'A' };
switch (core.receive(.term_event, &payload)) {
.effect => |effect| switch (effect) {
.clipboard_set => |set| try std.testing.expectEqual(target, set.target),
else => return error.ExpectedClipboard,
},
else => return error.ExpectedClipboard,
}
}
}
test "client core refuses invalid clipboard targets" {
var core = ClientCore{};
// 'X' is what a probe of `ESC]52;X;aGk=BEL` produces; ';' would close the
// field early and NUL is what a sloppy emitter leaves. 'C' catches a
// case-insensitive widening, '8' and '/' pin the ends of the '0'..'7' range
// — and '/' is the likeliest accident, being IN the base64 alphabet.
const targets = [_]u8{ 'X', 0x00, ';', 'C', 'x', '8', '/', 0x07 };
for (targets) |target| {
const payload = [_]u8{ 0, target, 'A' };
try expectIgnored(core.receive(.term_event, &payload));
}
}
test "client core refuses invalid clipboard alphabet" {
var core = ClientCore{};
const payload = [_]u8{ 0, 'c', 'A', 'G', 'k', '!' };
try expectIgnored(core.receive(.term_event, &payload));
}
test "client core refuses BEL as the only invalid clipboard alphabet byte" {
var core = ClientCore{};
const payload = [_]u8{ 0, 'c', 'A', 'G', 'k', 0x07 };
try expectIgnored(core.receive(.term_event, &payload));
}
test "client core accepts digits plus slash and plus in clipboard alphabet" {
var core = ClientCore{};
const payload = [_]u8{ 0, 'c', 'A', 'B', '0', '1', '2', '9', '+', '/', '=' };
switch (core.receive(.term_event, &payload)) {
.effect => |effect| switch (effect) {
.clipboard_set => |set| try std.testing.expectEqualStrings("AB0129+/=", set.base64),
else => return error.ExpectedClipboard,
},
else => return error.ExpectedClipboard,
}
}
test "client core refuses empty clipboard" {
var core = ClientCore{};
const payload = [_]u8{ 0, 'c' };
try expectIgnored(core.receive(.term_event, &payload));
}
test "client core accepts clipboard exactly at base64 cap" {
var core = ClientCore{};
var payload: [2 + proto.clipboard_base64_max]u8 = undefined;
payload[0] = 0;
payload[1] = 'c';
@memset(payload[2..], 'A');
switch (core.receive(.term_event, &payload)) {
.effect => |effect| switch (effect) {
.clipboard_set => |set| try std.testing.expectEqual(proto.clipboard_base64_max, set.base64.len),
else => return error.ExpectedClipboard,
},
else => return error.ExpectedClipboard,
}
}
test "client core refuses clipboard over base64 cap" {
var core = ClientCore{};
var payload: [3 + proto.clipboard_base64_max]u8 = undefined;
payload[0] = 0;
payload[1] = 'c';
@memset(payload[2..], 'A');
try expectIgnored(core.receive(.term_event, &payload));
}
test "client core ignores truncated unknown and trailing event forms" {
var core = ClientCore{};
const forms = [_][]const u8{
&.{},
&.{0},
&.{ 0, 'c' },
&.{0x7e},
&.{ 1, 0xaa },
};
for (forms) |payload| try expectIgnored(core.receive(.term_event, payload));
}
test "client core ignores unknown message types" {
var core = ClientCore{};
const payload = [_]u8{ 1, 2, 3 };
try expectIgnored(core.receive(@enumFromInt(0xa0), &payload));
}
test "client core begins selection with exact request bytes" {
var core = ClientCore{};
const req: proto.SelectionReq = .{
.id = 0x78563412,
.anchor = .{ .row = 0x44332211, .col = 0x6655 },
.active = .{ .row = 0xaa998877, .col = 0xccbb },
};
const encoded = core.beginSelection(req);
const expected = [_]u8{
0x12, 0x34, 0x56, 0x78,
0x11, 0x22, 0x33, 0x44,
0x55, 0x66, 0x77, 0x88,
0x99, 0xaa, 0xbb, 0xcc,
} ++ ([_]u8{0} ** 21);
try std.testing.expectEqualSlices(u8, &expected, &encoded);
try std.testing.expectEqualDeep(req, try proto.decodeSelectionReq(&encoded));
try std.testing.expectEqual(@as(?u32, req.id), core.pending_selection_id);
}
test "client core ignores stale selection reply then accepts matching reply once" {
var core = ClientCore{};
_ = core.beginSelection(.{
.id = 22,
.anchor = .{ .row = 1, .col = 2 },
.active = .{ .row = 3, .col = 4 },
});
var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
std.mem.writeInt(u32, stale[0..4], 21, .little);
stale[proto.selection_reply_prefix_len..][0] = 'n';
stale[proto.selection_reply_prefix_len..][1] = 'o';
var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
std.mem.writeInt(u32, matching[0..4], 22, .little);
std.mem.writeInt(u32, matching[5..9], 8, .little);
matching[proto.selection_reply_prefix_len..][0] = 'o';
matching[proto.selection_reply_prefix_len..][1] = 'k';
try expectIgnored(core.receive(.selection_reply, &stale));
try std.testing.expectEqual(@as(?u32, 22), core.pending_selection_id);
try expectSelection(core.receive(.selection_reply, &matching), 22, .ok, 8, "ok");
try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id);
try expectIgnored(core.receive(.selection_reply, &matching));
}
test "client core latest selection begin replaces the older pending id" {
var core = ClientCore{};
_ = core.beginSelection(.{
.id = 7,
.anchor = .{ .row = 0, .col = 0 },
.active = .{ .row = 0, .col = 1 },
});
_ = core.beginSelection(.{
.id = 8,
.anchor = .{ .row = 2, .col = 3 },
.active = .{ .row = 4, .col = 5 },
});
var stale = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
std.mem.writeInt(u32, stale[0..4], 7, .little);
stale[proto.selection_reply_prefix_len] = 'x';
try expectIgnored(core.receive(.selection_reply, &stale));
try std.testing.expectEqual(@as(?u32, 8), core.pending_selection_id);
var matching = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
std.mem.writeInt(u32, matching[0..4], 8, .little);
matching[proto.selection_reply_prefix_len] = 'y';
try expectSelection(core.receive(.selection_reply, &matching), 8, .ok, 0, "y");
}
test "client core malformed matching selection reply preserves pending request" {
var core = ClientCore{};
_ = core.beginSelection(.{
.id = 9,
.anchor = .{ .row = 0, .col = 0 },
.active = .{ .row = 0, .col = 0 },
});
var malformed = [_]u8{0} ** (proto.selection_reply_prefix_len + 1);
std.mem.writeInt(u32, malformed[0..4], 9, .little);
malformed[4] = @intFromEnum(proto.SelectionStatus.invalid);
malformed[proto.selection_reply_prefix_len] = 'x';
try expectIgnored(core.receive(.selection_reply, &malformed));
try std.testing.expectEqual(@as(?u32, 9), core.pending_selection_id);
var matching = [_]u8{0} ** proto.selection_reply_prefix_len;
std.mem.writeInt(u32, matching[0..4], 9, .little);
try expectSelection(core.receive(.selection_reply, &matching), 9, .ok, 0, "");
}
test "client core delivers every matching non-ok selection status with empty text" {
const statuses = [_]proto.SelectionStatus{ .invalid, .too_large, .unavailable };
for (statuses, 0..) |status, i| {
var core = ClientCore{};
const id: u32 = @intCast(100 + i);
_ = core.beginSelection(.{
.id = id,
.anchor = .{ .row = 0, .col = 0 },
.active = .{ .row = 0, .col = 0 },
});
var payload = [_]u8{0} ** proto.selection_reply_prefix_len;
std.mem.writeInt(u32, payload[0..4], id, .little);
payload[4] = @intFromEnum(status);
try expectSelection(core.receive(.selection_reply, &payload), id, status, 0, "");
try std.testing.expectEqual(@as(?u32, null), core.pending_selection_id);
}
}
test "client core selection reply text borrows the frame payload" {
var core = ClientCore{};
_ = core.beginSelection(.{
.id = 1,
.anchor = .{ .row = 0, .col = 0 },
.active = .{ .row = 0, .col = 0 },
});
var payload = [_]u8{0} ** (proto.selection_reply_prefix_len + 2);
std.mem.writeInt(u32, payload[0..4], 1, .little);
std.mem.writeInt(u32, payload[5..9], 4, .little);
payload[proto.selection_reply_prefix_len..][0] = 'h';
payload[proto.selection_reply_prefix_len..][1] = 'i';
const result = core.receive(.selection_reply, &payload);
payload[proto.selection_reply_prefix_len] = 'H';
try expectSelection(result, 1, .ok, 4, "Hi");
}
fn expectIgnored(result: Result) !void {
switch (result) {
.ignored => {},
else => return error.ExpectedIgnored,
}
}
fn expectModes(result: Result, expected: bool) !void {
switch (result) {
.state => |state| switch (state) {
.terminal_modes => |modes| try std.testing.expectEqual(expected, modes.bracketed_paste),
},
else => return error.ExpectedModes,
}
}
fn expectSelection(
result: Result,
id: u32,
status: proto.SelectionStatus,
history_rows: u32,
text: []const u8,
) !void {
switch (result) {
.reply => |reply| switch (reply) {
.selection => |selection| {
try std.testing.expectEqual(id, selection.id);
try std.testing.expectEqual(status, selection.status);
try std.testing.expectEqual(history_rows, selection.history_rows);
try std.testing.expectEqualStrings(text, selection.text);
},
},
else => return error.ExpectedSelection,
}
}