a73x

f056acea

refactor: add shared client terminal semantics

a73x   2026-08-16 14:30

Commit message
refactor: add shared client terminal semantics

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 }