a73x

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,
    }
}