f056acea
refactor: add shared client terminal semantics
a73x 2026-08-16 14:30
Commit message
build.zig
| Old | New | ||
|---|---|---|---|
| @@ -150,6 +150,10 @@ const mod_table = [_]ModSpec{ | |||
| 150 | // short directory to put a socket in and knows nothing about the bytes. | 150 | // short directory to put a socket in and knows nothing about the bytes. |
| 151 | .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} }, | 151 | .{ .name = "proxy", .path = "src/proxy.zig", .layer = 0, .link_libc = true, .test_imports = &.{"testtmp"} }, |
| 152 | // ---- layer 1: single-hop over the leaves ---- | 152 | // ---- layer 1: single-hop over the leaves ---- |
| 153 | // Platform-neutral semantic decoding for terminal mode samples and | ||
| 154 | // side-channel events. The borrowed clipboard slice stays tied to the | ||
| 155 | // frame payload, so native and wasm consumers share one contract. | ||
| 156 | .{ .name = "client_core", .path = "src/client_core.zig", .layer = 1, .wasm = true, .imports = &.{"protocol"} }, | ||
| 153 | // The QUIC listener: the vocabulary plus a UDP socket and a connection | 157 | // The QUIC listener: the vocabulary plus a UDP socket and a connection |
| 154 | // table, and deliberately no protocol import — it carries opaque bytes, | 158 | // table, and deliberately no protocol import — it carries opaque bytes, |
| 155 | // exactly as proxy.zig does. | 159 | // exactly as proxy.zig does. |
| @@ -389,7 +393,7 @@ fn shellGate(b: *std.Build, step: *std.Build.Step) void { | |||
| 389 | /// escape pins. mux and exe are executable roots but carry the argument | 393 | /// escape pins. mux and exe are executable roots but carry the argument |
| 390 | /// parsers — a test that is never built is not a test (decisions.md). | 394 | /// parsers — a test that is never built is not a test (decisions.md). |
| 391 | const test_order = [_][]const u8{ | 395 | const test_order = [_][]const u8{ |
| 392 | "script", "protocol", "engine", "pty", "delta", | 396 | "script", "protocol", "client_core", "engine", "pty", "delta", |
| 393 | "cmd", "shellint", "replica", "keymap", "webhub", | 397 | "cmd", "shellint", "replica", "keymap", "webhub", |
| 394 | "sockpath", "muxa", "server", "client", "proxy", | 398 | "sockpath", "muxa", "server", "client", "proxy", |
| 395 | "mux", "quic", "quic_server", "exe", "testtmp", | 399 | "mux", "quic", "quic_server", "exe", "testtmp", |
| @@ -591,6 +595,7 @@ pub fn build(b: *std.Build) void { | |||
| 591 | const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig"); | 595 | const wasm_core_mod = wasmMod(b, wasm_target, "src/wasm_core.zig"); |
| 592 | wasm_core_mod.addImport("engine", engine_wasm_mod); | 596 | wasm_core_mod.addImport("engine", engine_wasm_mod); |
| 593 | wasm_core_mod.addImport("protocol", wasm_mods[comptime idxOf("protocol")].?); | 597 | wasm_core_mod.addImport("protocol", wasm_mods[comptime idxOf("protocol")].?); |
| 598 | wasm_core_mod.addImport("client_core", wasm_mods[comptime idxOf("client_core")].?); | ||
| 594 | wasm_core_mod.addImport("replica", wasm_mods[comptime idxOf("replica")].?); | 599 | wasm_core_mod.addImport("replica", wasm_mods[comptime idxOf("replica")].?); |
| 595 | wasm_core_mod.addImport("keymap", wasm_mods[comptime idxOf("keymap")].?); | 600 | wasm_core_mod.addImport("keymap", wasm_mods[comptime idxOf("keymap")].?); |
| 596 | const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod }); | 601 | const wasm_exe = b.addExecutable(.{ .name = "mux_core", .root_module = wasm_core_mod }); |
src/client_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -0,0 +1,229 @@ | |||
| 1 | const std = @import("std"); | ||
| 2 | const proto = @import("protocol"); | ||
| 3 | |||
| 4 | /// A clipboard event whose bytes borrow the frame payload. The slice is only | ||
| 5 | /// valid until that payload is reused or discarded; callers that need to keep | ||
| 6 | /// it must copy it before advancing their receive buffer. | ||
| 7 | pub const ClipboardSet = struct { | ||
| 8 | target: u8, | ||
| 9 | base64: []const u8, | ||
| 10 | }; | ||
| 11 | |||
| 12 | pub const State = union(enum) { | ||
| 13 | terminal_modes: proto.TermModes, | ||
| 14 | }; | ||
| 15 | |||
| 16 | pub const Effect = union(enum) { | ||
| 17 | clipboard_set: ClipboardSet, | ||
| 18 | bell, | ||
| 19 | }; | ||
| 20 | |||
| 21 | pub const Result = union(enum) { | ||
| 22 | ignored, | ||
| 23 | state: State, | ||
| 24 | effect: Effect, | ||
| 25 | }; | ||
| 26 | |||
| 27 | pub const ClientCore = struct { | ||
| 28 | terminal_modes: proto.TermModes = .{ .bracketed_paste = false }, | ||
| 29 | |||
| 30 | /// Decode one daemon frame's semantic terminal state or event. Any | ||
| 31 | /// borrowed clipboard bytes in the result remain valid only while | ||
| 32 | /// `payload` remains valid and unchanged. | ||
| 33 | pub fn receive(self: *ClientCore, msg_type: proto.MsgType, payload: []const u8) Result { | ||
| 34 | return switch (msg_type) { | ||
| 35 | .term_modes => self.receiveModes(payload), | ||
| 36 | .term_event => receiveEvent(payload), | ||
| 37 | else => .ignored, | ||
| 38 | }; | ||
| 39 | } | ||
| 40 | |||
| 41 | fn receiveModes(self: *ClientCore, payload: []const u8) Result { | ||
| 42 | const modes = proto.decodeTermModes(payload) catch return .ignored; | ||
| 43 | self.terminal_modes = modes; | ||
| 44 | return .{ .state = .{ .terminal_modes = modes } }; | ||
| 45 | } | ||
| 46 | }; | ||
| 47 | |||
| 48 | fn receiveEvent(payload: []const u8) Result { | ||
| 49 | const event = proto.decodeTermEvent(payload) catch return .ignored; | ||
| 50 | return switch (event) { | ||
| 51 | .bell => .{ .effect = .bell }, | ||
| 52 | .clipboard => |clipboard| { | ||
| 53 | if (!validClipboard(clipboard.target, clipboard.base64)) return .ignored; | ||
| 54 | return .{ .effect = .{ .clipboard_set = .{ | ||
| 55 | .target = clipboard.target, | ||
| 56 | .base64 = clipboard.base64, | ||
| 57 | } } }; | ||
| 58 | }, | ||
| 59 | }; | ||
| 60 | } | ||
| 61 | |||
| 62 | fn validClipboard(target: u8, base64: []const u8) bool { | ||
| 63 | if (!validTarget(target) or base64.len == 0 or base64.len > proto.clipboard_base64_max) | ||
| 64 | return false; | ||
| 65 | for (base64) |byte| { | ||
| 66 | if (!((byte >= 'A' and byte <= 'Z') or | ||
| 67 | (byte >= 'a' and byte <= 'z') or | ||
| 68 | (byte >= '0' and byte <= '9') or | ||
| 69 | byte == '+' or byte == '/' or byte == '=')) return false; | ||
| 70 | } | ||
| 71 | return true; | ||
| 72 | } | ||
| 73 | |||
| 74 | fn validTarget(target: u8) bool { | ||
| 75 | return target == 'c' or target == 'p' or target == 'q' or target == 's' or | ||
| 76 | (target >= '0' and target <= '7'); | ||
| 77 | } | ||
| 78 | |||
| 79 | test "client core mode updates deliver on and off samples" { | ||
| 80 | var core = ClientCore{}; | ||
| 81 | const on = proto.encodeTermModes(.{ .bracketed_paste = true }); | ||
| 82 | const off = proto.encodeTermModes(.{ .bracketed_paste = false }); | ||
| 83 | |||
| 84 | const on_result = core.receive(.term_modes, &on); | ||
| 85 | try std.testing.expectEqual(true, core.terminal_modes.bracketed_paste); | ||
| 86 | try expectModes(on_result, true); | ||
| 87 | |||
| 88 | const off_result = core.receive(.term_modes, &off); | ||
| 89 | try std.testing.expectEqual(false, core.terminal_modes.bracketed_paste); | ||
| 90 | try expectModes(off_result, false); | ||
| 91 | } | ||
| 92 | |||
| 93 | test "client core delivers repeated equal mode samples" { | ||
| 94 | var core = ClientCore{}; | ||
| 95 | const payload = proto.encodeTermModes(.{ .bracketed_paste = true }); | ||
| 96 | |||
| 97 | try expectModes(core.receive(.term_modes, &payload), true); | ||
| 98 | try expectModes(core.receive(.term_modes, &payload), true); | ||
| 99 | } | ||
| 100 | |||
| 101 | test "client core malformed mode sample is ignored atomically" { | ||
| 102 | var core = ClientCore{ .terminal_modes = .{ .bracketed_paste = true } }; | ||
| 103 | const malformed = [_]u8{ 1, 0, 0 }; | ||
| 104 | |||
| 105 | try expectIgnored(core.receive(.term_modes, &malformed)); | ||
| 106 | try std.testing.expectEqual(true, core.terminal_modes.bracketed_paste); | ||
| 107 | } | ||
| 108 | |||
| 109 | test "client core accepts clipboard and bell events" { | ||
| 110 | var core = ClientCore{}; | ||
| 111 | const clipboard = [_]u8{ 0, 'c', 'a', 'G', 'k', '=' }; | ||
| 112 | |||
| 113 | const clipboard_result = core.receive(.term_event, &clipboard); | ||
| 114 | switch (clipboard_result) { | ||
| 115 | .effect => |effect| switch (effect) { | ||
| 116 | .clipboard_set => |set| { | ||
| 117 | try std.testing.expectEqual(@as(u8, 'c'), set.target); | ||
| 118 | try std.testing.expectEqualStrings("aGk=", set.base64); | ||
| 119 | }, | ||
| 120 | else => return error.ExpectedClipboard, | ||
| 121 | }, | ||
| 122 | else => return error.ExpectedClipboard, | ||
| 123 | } | ||
| 124 | |||
| 125 | const bell = [_]u8{1}; | ||
| 126 | switch (core.receive(.term_event, &bell)) { | ||
| 127 | .effect => |effect| switch (effect) { | ||
| 128 | .bell => {}, | ||
| 129 | else => return error.ExpectedBell, | ||
| 130 | }, | ||
| 131 | else => return error.ExpectedBell, | ||
| 132 | } | ||
| 133 | } | ||
| 134 | |||
| 135 | test "client core accepts every clipboard target boundary" { | ||
| 136 | var core = ClientCore{}; | ||
| 137 | const targets = [_]u8{ 'c', 'p', 'q', 's', '0', '1', '2', '3', '4', '5', '6', '7' }; | ||
| 138 | for (targets) |target| { | ||
| 139 | const payload = [_]u8{ 0, target, 'A' }; | ||
| 140 | switch (core.receive(.term_event, &payload)) { | ||
| 141 | .effect => |effect| switch (effect) { | ||
| 142 | .clipboard_set => |set| try std.testing.expectEqual(target, set.target), | ||
| 143 | else => return error.ExpectedClipboard, | ||
| 144 | }, | ||
| 145 | else => return error.ExpectedClipboard, | ||
| 146 | } | ||
| 147 | } | ||
| 148 | } | ||
| 149 | |||
| 150 | test "client core refuses invalid clipboard targets" { | ||
| 151 | var core = ClientCore{}; | ||
| 152 | const targets = [_]u8{ 'C', 'x', '/', '8', 0x07 }; | ||
| 153 | for (targets) |target| { | ||
| 154 | const payload = [_]u8{ 0, target, 'A' }; | ||
| 155 | try expectIgnored(core.receive(.term_event, &payload)); | ||
| 156 | } | ||
| 157 | } | ||
| 158 | |||
| 159 | test "client core refuses invalid clipboard alphabet including BEL" { | ||
| 160 | var core = ClientCore{}; | ||
| 161 | const payload = [_]u8{ 0, 'c', 'A', 'G', 'k', '!', 0x07 }; | ||
| 162 | try expectIgnored(core.receive(.term_event, &payload)); | ||
| 163 | } | ||
| 164 | |||
| 165 | test "client core refuses empty clipboard" { | ||
| 166 | var core = ClientCore{}; | ||
| 167 | const payload = [_]u8{ 0, 'c' }; | ||
| 168 | try expectIgnored(core.receive(.term_event, &payload)); | ||
| 169 | } | ||
| 170 | |||
| 171 | test "client core accepts clipboard exactly at base64 cap" { | ||
| 172 | var core = ClientCore{}; | ||
| 173 | var payload: [2 + proto.clipboard_base64_max]u8 = undefined; | ||
| 174 | payload[0] = 0; | ||
| 175 | payload[1] = 'c'; | ||
| 176 | @memset(payload[2..], 'A'); | ||
| 177 | |||
| 178 | switch (core.receive(.term_event, &payload)) { | ||
| 179 | .effect => |effect| switch (effect) { | ||
| 180 | .clipboard_set => |set| try std.testing.expectEqual(proto.clipboard_base64_max, set.base64.len), | ||
| 181 | else => return error.ExpectedClipboard, | ||
| 182 | }, | ||
| 183 | else => return error.ExpectedClipboard, | ||
| 184 | } | ||
| 185 | } | ||
| 186 | |||
| 187 | test "client core refuses clipboard over base64 cap" { | ||
| 188 | var core = ClientCore{}; | ||
| 189 | var payload: [3 + proto.clipboard_base64_max]u8 = undefined; | ||
| 190 | payload[0] = 0; | ||
| 191 | payload[1] = 'c'; | ||
| 192 | @memset(payload[2..], 'A'); | ||
| 193 | |||
| 194 | try expectIgnored(core.receive(.term_event, &payload)); | ||
| 195 | } | ||
| 196 | |||
| 197 | test "client core ignores truncated unknown and trailing event forms" { | ||
| 198 | var core = ClientCore{}; | ||
| 199 | const forms = [_][]const u8{ | ||
| 200 | &.{}, | ||
| 201 | &.{0}, | ||
| 202 | &.{ 0, 'c' }, | ||
| 203 | &.{0x7e}, | ||
| 204 | &.{ 1, 0xaa }, | ||
| 205 | }; | ||
| 206 | for (forms) |payload| try expectIgnored(core.receive(.term_event, payload)); | ||
| 207 | } | ||
| 208 | |||
| 209 | test "client core ignores unknown message types" { | ||
| 210 | var core = ClientCore{}; | ||
| 211 | const payload = [_]u8{ 1, 2, 3 }; | ||
| 212 | try expectIgnored(core.receive(@enumFromInt(0xa0), &payload)); | ||
| 213 | } | ||
| 214 | |||
| 215 | fn expectIgnored(result: Result) !void { | ||
| 216 | switch (result) { | ||
| 217 | .ignored => {}, | ||
| 218 | else => return error.ExpectedIgnored, | ||
| 219 | } | ||
| 220 | } | ||
| 221 | |||
| 222 | fn expectModes(result: Result, expected: bool) !void { | ||
| 223 | switch (result) { | ||
| 224 | .state => |state| switch (state) { | ||
| 225 | .terminal_modes => |modes| try std.testing.expectEqual(expected, modes.bracketed_paste), | ||
| 226 | }, | ||
| 227 | else => return error.ExpectedModes, | ||
| 228 | } | ||
| 229 | } | ||