a73x

bdb6f588

refactor: drive cli side channels from client core

a73x   2026-08-18 12:27

Commit message
refactor: drive cli side channels from client core

build.zig
Old New
@@ -221,7 +221,7 @@ const mod_table = [_]ModSpec{
221 // display decision and never becomes state anybody else can see. It 221 // display decision and never becomes state anybody else can see. It
222 // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so 222 // also borrows ignoreSigpipe, which proxy owns — proxy is a leaf, so
223 // this adds no cycle and teaches the proxy nothing. 223 // this adds no cycle and teaches the proxy nothing.
224 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true }, 224 .{ .name = "client", .path = "src/client.zig", .layer = 2, .link_libc = true, .imports = &.{ "engine", "protocol", "replica", "client_core", "quic_client", "quic", "predict", "handoff", "proxy", "paint" }, .test_imports = &.{"testtmp"}, .quic_tests = true },
225 // The agent-facing client. It speaks frames and owns no terminal, which 225 // The agent-facing client. It speaks frames and owns no terminal, which
226 // is the whole point — it attaches at 0x0 and never claims the grid. 226 // is the whole point — it attaches at 0x0 and never claims the grid.
227 // The transport modules are the CLI client's, minus everything that 227 // The transport modules are the CLI client's, minus everything that
src/client.zig
Old New
@@ -8,6 +8,7 @@
8 const std = @import("std"); 8 const std = @import("std");
9 const Engine = @import("engine").Engine; 9 const Engine = @import("engine").Engine;
10 const Replica = @import("replica").Replica; 10 const Replica = @import("replica").Replica;
11 const client_core = @import("client_core");
11 const proto = @import("protocol"); 12 const proto = @import("protocol");
12 const TmpDir = @import("testtmp").TmpDir; 13 const TmpDir = @import("testtmp").TmpDir;
13 const quic_client = @import("quic_client"); 14 const quic_client = @import("quic_client");
@@ -915,6 +916,10 @@ fn session(
915 // The replay core, extracted to replica.zig; the engine stays owned 916 // The replay core, extracted to replica.zig; the engine stays owned
916 // here (the Replica borrows it), so the deinit above is the one owner. 917 // here (the Replica borrows it), so the deinit above is the one owner.
917 var rep = Replica.init(alloc, eng); 918 var rep = Replica.init(alloc, eng);
919 // Semantic terminal state and host effects are decoded once here, then
920 // handed to the native adapter below. The web client owns another
921 // instance of this same platform-neutral state machine.
922 var semantic_core: client_core.ClientCore = .{};
918 923
919 // Registered before the terminal-restore defer so it runs after it: 924 // Registered before the terminal-restore defer so it runs after it:
920 // messages land on the normal screen, not the wiped alternate one. 925 // messages land on the normal screen, not the wiped alternate one.
@@ -1304,14 +1309,7 @@ fn session(
1304 if (scroll_pages == 0 or frame.payload.len < 6) continue; 1309 if (scroll_pages == 0 or frame.payload.len < 6) continue;
1305 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd); 1310 try paint_mod.renderScrollback(alloc, frame.payload[6..], size, stdout_fd);
1306 }, 1311 },
1307 .term_event => try writeSideChannel( 1312 .term_event, .term_modes => {
1308 alloc,
1309 stdout_fd,
1310 alt_screen,
1311 frame.payload,
1312 appendTermEvent,
1313 ),
1314 .term_modes => {
1315 // Repeats are expected, not a bug to filter. The daemon 1313 // Repeats are expected, not a bug to filter. The daemon
1316 // sends this from `sendResync`, whose only two callers 1314 // sends this from `sendResync`, whose only two callers
1317 // are attach arms, so the repeats are attaches and 1315 // are attach arms, so the repeats are attaches and
@@ -1335,7 +1333,23 @@ fn session(
1335 // a real term_modes(false), and relaying it strands the 1333 // a real term_modes(false), and relaying it strands the
1336 // terminal exactly that way. The daemon told the truth 1334 // terminal exactly that way. The daemon told the truth
1337 // and we passed it on — there is no better move here. 1335 // and we passed it on — there is no better move here.
1338 try writeSideChannel(alloc, stdout_fd, alt_screen, frame.payload, appendTermModes); 1336 switch (semantic_core.receive(frame.type, frame.payload)) {
1337 .ignored => {},
1338 .state => |state| try writeSideChannel(
1339 alloc,
1340 stdout_fd,
1341 alt_screen,
1342 state,
1343 appendTermState,
1344 ),
1345 .effect => |effect| try writeSideChannel(
1346 alloc,
1347 stdout_fd,
1348 alt_screen,
1349 effect,
1350 appendHostEffect,
1351 ),
1352 }
1339 }, 1353 },
1340 .term_title => { 1354 .term_title => {
1341 // Repeats are expected here for the same reason as 1355 // Repeats are expected here for the same reason as
@@ -1450,31 +1464,9 @@ fn ttySize(fd: std.posix.fd_t) ?proto.Size {
1450 1464
1451 // ---- side channels ----------------------------------------------------- 1465 // ---- side channels -----------------------------------------------------
1452 // 1466 //
1453 // Everything below turns one wire event into bytes for the host terminal, 1467 // Everything below turns a typed semantic value into bytes for the host
1454 // and refuses rather than approximates. A term_event is the only thing mux 1468 // terminal. Untrusted wire validation belongs to client_core; these adapters
1455 // hands the user's real tty that it did not compose itself, so each of 1469 // only perform the native platform operation selected by that shared core.
1456 // these builders is all-or-nothing: it writes the whole escape or none of
1457 // it, and every rejection happens before the first byte is appended.
1458
1459 /// The standard base64 alphabet plus its padding. Validated on the way OUT
1460 /// of the wire and not only on the way in: what makes an OSC 52 payload
1461 /// safe to hand a terminal is that it cannot contain ESC or BEL, and that
1462 /// is a property of the bytes, not of who sent them.
1463 ///
1464 /// Deliberately looser than "is valid base64", and named for what it
1465 /// actually checks so that it stays that way. Do not add a `len % 4 == 0`
1466 /// or padding-position check to "complete" it: those would refuse payloads
1467 /// a terminal would have accepted, which silently drops real user copies,
1468 /// and neither is needed for the safety property. `std.base64` is no help
1469 /// either — `Decoder.calcSizeForSlice` validates length and padding but
1470 /// NOT the alphabet, so it misses the only thing that matters here.
1471 fn isBase64Alphabet(s: []const u8) bool {
1472 for (s) |ch| switch (ch) {
1473 'A'...'Z', 'a'...'z', '0'...'9', '+', '/', '=' => {},
1474 else => return false,
1475 };
1476 return true;
1477 }
1478 1470
1479 /// Everything the client does TO the host terminal on the way in, in the 1471 /// Everything the client does TO the host terminal on the way in, in the
1480 /// order it does it: push the title, enter the alternate screen, hide the 1472 /// order it does it: push the title, enter the alternate screen, hide the
@@ -1524,64 +1516,42 @@ const terminal_setup = "\x1b[22;0t\x1b[?1049h\x1b[?25l\x1b[?7l";
1524 /// fallback this would otherwise have shipped as. 1516 /// fallback this would otherwise have shipped as.
1525 const terminal_teardown = "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l"; 1517 const terminal_teardown = "\x1b[?2004l\x1b[?7h\x1b[?25h\x1b[23;0t\x1b[?1049l";
1526 1518
1527 /// Render a term_modes frame as the DECSET/DECRST writes it implies. 1519 /// Render validated terminal state as the DECSET/DECRST writes it implies.
1528 /// Nothing at all for a payload we cannot parse: a payload we cannot parse 1520 fn appendTermState(
1529 /// tells us nothing about what the session wants, and the only honest
1530 /// response to that is to assert nothing about the user's terminal.
1531 fn appendTermModes(
1532 out: *std.ArrayList(u8), 1521 out: *std.ArrayList(u8),
1533 alloc: std.mem.Allocator, 1522 alloc: std.mem.Allocator,
1534 payload: []const u8, 1523 state: client_core.State,
1535 ) !void { 1524 ) !void {
1536 const m = proto.decodeTermModes(payload) catch return; 1525 switch (state) {
1537 try out.appendSlice(alloc, if (m.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l"); 1526 .terminal_modes => |modes| try out.appendSlice(
1527 alloc,
1528 if (modes.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l",
1529 ),
1530 }
1538 } 1531 }
1539 1532
1540 /// Render one term_event onto the bytes destined for the host terminal. 1533 /// Render one host effect onto the bytes destined for the terminal.
1541 /// Writes nothing at all for anything it cannot fully understand — a 1534 ///
1542 /// partial escape on a real tty paints garbage the user then has to clear. 1535 /// The target and alphabet are re-checked here rather than trusted from the
1543 fn appendTermEvent( 1536 /// effect. `ClipboardSet` is a plain struct, so Zig cannot make the
1537 /// validating decoder its only constructor — and one caller already builds
1538 /// an unvalidated one: `wasm_core.zig` default-initialises its borrowed
1539 /// clipboard slot to `.{ .target = 0, .base64 = &.{} }`, which
1540 /// `validClipboard` refuses. A linear scan over at most 64 KiB is free next
1541 /// to the write it guards, and the alternative is `ESC]52;<NUL>;BEL` on a
1542 /// real tty.
1543 ///
1544 /// Built whole before the first byte is appended, like every other builder
1545 /// here: a rejection must not leave half an escape behind for a caller that
1546 /// reuses one buffer across events.
1547 fn appendHostEffect(
1544 out: *std.ArrayList(u8), 1548 out: *std.ArrayList(u8),
1545 alloc: std.mem.Allocator, 1549 alloc: std.mem.Allocator,
1546 payload: []const u8, 1550 effect: client_core.Effect,
1547 ) !void { 1551 ) !void {
1548 const ev = proto.decodeTermEvent(payload) catch return; 1552 switch (effect) {
1549 switch (ev) { 1553 .clipboard_set => |clip| {
1550 .clipboard => |clip| { 1554 if (!client_core.validClipboard(clip.target, clip.base64)) return;
1551 if (!isBase64Alphabet(clip.base64)) return;
1552 // The daemon caps on the way in; re-checked here for the same
1553 // reason the alphabet is — the peer is not necessarily this
1554 // version of muxd.
1555 if (clip.base64.len > proto.clipboard_base64_max) return;
1556 // Zero length is the OSC 52 "clear the clipboard" form. The
1557 // engine refuses it on the way in because an interrupted copy
1558 // that lands empty would silently wipe whatever the human last
1559 // copied; this is the client half of that one policy, not a
1560 // second opinion about it. Kept out of `isBase64Alphabet`,
1561 // which is right to call the empty string well-formed.
1562 if (clip.base64.len == 0) return;
1563 // Pc as xterm's ctlseqs defines it, and this one DOES narrow:
1564 // ghostty sets `kind = data[0]` with no validation of its own
1565 // (osc/parsers/clipboard_operation.zig), so any byte a program
1566 // inside the session writes arrives here — `ESC]52;X;…` lands
1567 // as target 0x58. This list is the only thing standing between
1568 // that byte and the user's terminal, so it is NOT dead code.
1569 //
1570 // Multi-character targets are a separate matter and cannot
1571 // reach here at all: ghostty rejects the whole OSC when
1572 // `data[1] != ';'`, so `ESC]52;pc;…` yields no event to
1573 // forward. Nothing to widen this list for.
1574 switch (clip.target) {
1575 'c', 'p', 'q', 's', '0'...'7' => {},
1576 else => return,
1577 }
1578 // Built whole before the first byte is appended: every check
1579 // above has already returned, so no rejection can leave half an
1580 // escape behind for the caller to write out. All-or-nothing and
1581 // append-only, which is what a caller reusing one buffer across
1582 // events needs — the sole exception is an allocation failure
1583 // mid-sequence, and that returns an error the caller must not
1584 // write past.
1585 try out.appendSlice(alloc, "\x1b]52;"); 1555 try out.appendSlice(alloc, "\x1b]52;");
1586 try out.append(alloc, clip.target); 1556 try out.append(alloc, clip.target);
1587 try out.append(alloc, ';'); 1557 try out.append(alloc, ';');
@@ -1664,21 +1634,17 @@ fn appendTermTitle(
1664 /// way to change back. Gating here rather than at the three call sites so 1634 /// way to change back. Gating here rather than at the three call sites so
1665 /// a fourth channel cannot arrive without it. 1635 /// a fourth channel cannot arrive without it.
1666 /// 1636 ///
1667 /// `build` is a declared function type rather than `anytype` because the
1668 /// declaration is the specification of a side-channel builder: three
1669 /// parameters, and allocation as the only way it may fail. Everything else
1670 /// it rejects, it rejects by writing nothing.
1671 fn writeSideChannel( 1637 fn writeSideChannel(
1672 alloc: std.mem.Allocator, 1638 alloc: std.mem.Allocator,
1673 stdout_fd: std.posix.fd_t, 1639 stdout_fd: std.posix.fd_t,
1674 owns_terminal: bool, 1640 owns_terminal: bool,
1675 payload: []const u8, 1641 value: anytype,
1676 comptime build: fn (*std.ArrayList(u8), std.mem.Allocator, []const u8) std.mem.Allocator.Error!void, 1642 comptime append: anytype,
1677 ) !void { 1643 ) !void {
1678 if (!owns_terminal) return; 1644 if (!owns_terminal) return;
1679 var esc: std.ArrayList(u8) = .empty; 1645 var esc: std.ArrayList(u8) = .empty;
1680 defer esc.deinit(alloc); 1646 defer esc.deinit(alloc);
1681 try build(&esc, alloc, payload); 1647 try append(&esc, alloc, value);
1682 if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items); 1648 if (esc.items.len > 0) try proto.writeAllFd(stdout_fd, esc.items);
1683 } 1649 }
1684 1650
@@ -2862,150 +2828,58 @@ test "openFailure: a message too long for the buffer clips, and still fails" {
2862 /// any escape the builder emits. 2828 /// any escape the builder emits.
2863 const refusal_sentinel: u8 = 0xfe; 2829 const refusal_sentinel: u8 = 0xfe;
2864 2830
2865 test "client: a clipboard event becomes an OSC 52 write" { 2831 test "client: a validated clipboard effect becomes an OSC 52 write" {
2866 const alloc = std.testing.allocator; 2832 const alloc = std.testing.allocator;
2867 var out: std.ArrayList(u8) = .empty; 2833 var out: std.ArrayList(u8) = .empty;
2868 defer out.deinit(alloc); 2834 defer out.deinit(alloc);
2869 2835
2870 var payload: std.ArrayList(u8) = .empty; 2836 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
2871 defer payload.deinit(alloc); 2837 .target = 'c',
2872 try proto.encodeClipboardEvent(&payload, alloc, 'c', "aGk="); 2838 .base64 = "aGk=",
2873 2839 } });
2874 try appendTermEvent(&out, alloc, payload.items);
2875 // BEL rather than ESC-backslash: it is what most emitters in the wild 2840 // BEL rather than ESC-backslash: it is what most emitters in the wild
2876 // use, and every terminal that accepts one accepts it. 2841 // use, and every terminal that accepts one accepts it.
2877 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items); 2842 try std.testing.expectEqualStrings("\x1b]52;c;aGk=\x07", out.items);
2878 } 2843 }
2879 2844
2880 test "client: a clipboard payload that is not base64 is refused" { 2845 test "client: a validated bell effect becomes a BEL" {
2881 const alloc = std.testing.allocator; 2846 const alloc = std.testing.allocator;
2882 var out: std.ArrayList(u8) = .empty; 2847 var out: std.ArrayList(u8) = .empty;
2883 defer out.deinit(alloc); 2848 defer out.deinit(alloc);
2884 2849
2885 // Seeded so the assertion below pins "appends nothing to a buffer that 2850 try appendHostEffect(&out, alloc, .bell);
2886 // already holds something" rather than merely "leaves an empty buffer
2887 // empty" — which a function that never appended at all would satisfy.
2888 // That is the contract a caller reusing one buffer across events needs.
2889 try out.append(alloc, refusal_sentinel);
2890
2891 var payload: std.ArrayList(u8) = .empty;
2892 defer payload.deinit(alloc);
2893 // A payload carrying BEL would terminate the escape early and leave the
2894 // rest of it painting on the user's screen. The daemon validates on the
2895 // way out; the client validates again rather than trusting the wire,
2896 // because the wire is not necessarily this version of muxd.
2897 try proto.encodeClipboardEvent(&payload, alloc, 'c', "aGk=\x07rm -rf");
2898
2899 try appendTermEvent(&out, alloc, payload.items);
2900 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
2901 }
2902
2903 test "client: an empty clipboard payload is refused, not proxied as a clear" {
2904 const alloc = std.testing.allocator;
2905 var out: std.ArrayList(u8) = .empty;
2906 defer out.deinit(alloc);
2907
2908 try out.append(alloc, refusal_sentinel);
2909
2910 var payload: std.ArrayList(u8) = .empty;
2911 defer payload.deinit(alloc);
2912 // The engine drops this on the way in: an interrupted copy that lands
2913 // empty would wipe whatever the human last copied. A client that obeyed
2914 // it from a foreign peer would make the policy "we do not proxy clears,
2915 // except from peers we did not write".
2916 try proto.encodeClipboardEvent(&payload, alloc, 'c', "");
2917
2918 try appendTermEvent(&out, alloc, payload.items);
2919 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
2920 }
2921
2922 test "client: a bell event becomes a BEL" {
2923 const alloc = std.testing.allocator;
2924 var out: std.ArrayList(u8) = .empty;
2925 defer out.deinit(alloc);
2926
2927 var payload: std.ArrayList(u8) = .empty;
2928 defer payload.deinit(alloc);
2929 try proto.encodeBellEvent(&payload, alloc);
2930
2931 try appendTermEvent(&out, alloc, payload.items);
2932 try std.testing.expectEqualStrings("\x07", out.items); 2851 try std.testing.expectEqualStrings("\x07", out.items);
2933 } 2852 }
2934 2853
2935 test "client: an unparseable term_event writes nothing at all" { 2854 test "client: xterm Pc targets retain their exact OSC 52 spelling" {
2936 const alloc = std.testing.allocator; 2855 const alloc = std.testing.allocator;
2937 var out: std.ArrayList(u8) = .empty;
2938 defer out.deinit(alloc);
2939 try out.append(alloc, refusal_sentinel);
2940 try appendTermEvent(&out, alloc, &[_]u8{0x7e});
2941 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
2942 }
2943 2856
2944 test "client: only xterm's Pc targets reach the terminal" {
2945 const alloc = std.testing.allocator;
2946
2947 // ghostty takes the target byte straight off the wire without
2948 // validating it, so every one of these is reachable from inside a
2949 // session. The refusals are the point of the allowlist: without it
2950 // `ESC]52;X;…` would be forwarded verbatim to the user's terminal.
2951 // The accepted row is the other half — a list that refused a
2952 // legitimate target would drop copies nobody would ever debug.
2953 for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| { 2857 for ([_]u8{ 'c', 'p', 'q', 's', '0', '7' }) |target| {
2954 var out: std.ArrayList(u8) = .empty; 2858 var out: std.ArrayList(u8) = .empty;
2955 defer out.deinit(alloc); 2859 defer out.deinit(alloc);
2956 2860
2957 var payload: std.ArrayList(u8) = .empty; 2861 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
2958 defer payload.deinit(alloc); 2862 .target = target,
2959 try proto.encodeClipboardEvent(&payload, alloc, target, "aGk="); 2863 .base64 = "aGk=",
2960 2864 } });
2961 try appendTermEvent(&out, alloc, payload.items);
2962 const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 }; 2865 const want = [_]u8{ 0x1b, ']', '5', '2', ';', target, ';', 'a', 'G', 'k', '=', 0x07 };
2963 try std.testing.expectEqualSlices(u8, &want, out.items); 2866 try std.testing.expectEqualSlices(u8, &want, out.items);
2964 } 2867 }
2965
2966 // 0x58 is the 'X' a probe of `ESC]52;X;aGk=BEL` actually produces;
2967 // ';' would close the field early and NUL is the empty byte a sloppy
2968 // emitter leaves behind. 'C' catches a case-insensitive widening; '8'
2969 // pins the top of the '0'...'7' range and '/' pins its bottom — and '/'
2970 // is the likeliest accident of the lot because it is IN the base64
2971 // alphabet, so an emitter that miscounts its fields lands exactly there.
2972 for ([_]u8{ 'X', 0x00, ';', 'C', '8', '/' }) |target| {
2973 var out: std.ArrayList(u8) = .empty;
2974 defer out.deinit(alloc);
2975 try out.append(alloc, refusal_sentinel);
2976
2977 var payload: std.ArrayList(u8) = .empty;
2978 defer payload.deinit(alloc);
2979 try proto.encodeClipboardEvent(&payload, alloc, target, "aGk=");
2980
2981 try appendTermEvent(&out, alloc, payload.items);
2982 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
2983 }
2984 } 2868 }
2985 2869
2986 test "client: term_modes turns bracketed paste on and off on the host" { 2870 test "client: terminal mode state turns bracketed paste on and off on the host" {
2987 const alloc = std.testing.allocator; 2871 const alloc = std.testing.allocator;
2988 var out: std.ArrayList(u8) = .empty; 2872 var out: std.ArrayList(u8) = .empty;
2989 defer out.deinit(alloc); 2873 defer out.deinit(alloc);
2990 2874
2991 try appendTermModes(&out, alloc, &proto.encodeTermModes(.{ .bracketed_paste = true })); 2875 try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = true } });
2992 try std.testing.expectEqualStrings("\x1b[?2004h", out.items); 2876 try std.testing.expectEqualStrings("\x1b[?2004h", out.items);
2993 2877
2994 out.clearRetainingCapacity(); 2878 out.clearRetainingCapacity();
2995 try appendTermModes(&out, alloc, &proto.encodeTermModes(.{ .bracketed_paste = false })); 2879 try appendTermState(&out, alloc, .{ .terminal_modes = .{ .bracketed_paste = false } });
2996 try std.testing.expectEqualStrings("\x1b[?2004l", out.items); 2880 try std.testing.expectEqualStrings("\x1b[?2004l", out.items);
2997 } 2881 }
2998 2882
2999 test "client: a malformed term_modes payload writes nothing" {
3000 const alloc = std.testing.allocator;
3001 var out: std.ArrayList(u8) = .empty;
3002 defer out.deinit(alloc);
3003
3004 try out.append(alloc, refusal_sentinel);
3005 try appendTermModes(&out, alloc, &[_]u8{ 0x01, 0x00 });
3006 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
3007 }
3008
3009 test "client: a title becomes an OSC 0 write, and empty or control bytes are refused" { 2883 test "client: a title becomes an OSC 0 write, and empty or control bytes are refused" {
3010 const alloc = std.testing.allocator; 2884 const alloc = std.testing.allocator;
3011 var out: std.ArrayList(u8) = .empty; 2885 var out: std.ArrayList(u8) = .empty;
@@ -3084,7 +2958,31 @@ test "client: the exit teardown unsets every mode mux turned on, and pops the ti
3084 ); 2958 );
3085 } 2959 }
3086 2960
3087 test "client: the clipboard cap is a cap, not an off-by-one" { 2961 test "client: an unvalidated clipboard effect writes nothing" {
2962 const alloc = std.testing.allocator;
2963 var out: std.ArrayList(u8) = .empty;
2964 defer out.deinit(alloc);
2965
2966 // Exactly the value wasm_core.zig default-initialises its borrowed
2967 // clipboard slot to, and exactly what client_core.validClipboard
2968 // refuses. Written verbatim it is `ESC]52;<NUL>;BEL` on a real tty.
2969 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
2970 .target = 0,
2971 .base64 = &.{},
2972 } });
2973 try std.testing.expectEqual(@as(usize, 0), out.items.len);
2974
2975 // The refusal is the whole value, not just its target: a legal target
2976 // carrying bytes outside the base64 alphabet is the injection this
2977 // check exists for.
2978 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
2979 .target = 'c',
2980 .base64 = "aGk=\x1b]0;pwned\x07",
2981 } });
2982 try std.testing.expectEqual(@as(usize, 0), out.items.len);
2983 }
2984
2985 test "client: a clipboard effect at the cap retains its exact framing" {
3088 const alloc = std.testing.allocator; 2986 const alloc = std.testing.allocator;
3089 2987
3090 const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max); 2988 const at_cap = try alloc.alloc(u8, proto.clipboard_base64_max);
@@ -3098,32 +2996,13 @@ test "client: the clipboard cap is a cap, not an off-by-one" {
3098 var out: std.ArrayList(u8) = .empty; 2996 var out: std.ArrayList(u8) = .empty;
3099 defer out.deinit(alloc); 2997 defer out.deinit(alloc);
3100 2998
3101 var payload: std.ArrayList(u8) = .empty; 2999 try appendHostEffect(&out, alloc, .{ .clipboard_set = .{
3102 defer payload.deinit(alloc); 3000 .target = 'c',
3103 try proto.encodeClipboardEvent(&payload, alloc, 'c', at_cap); 3001 .base64 = at_cap,
3104 3002 } });
3105 try appendTermEvent(&out, alloc, payload.items);
3106 // "\x1b]52;c;" ++ payload ++ BEL 3003 // "\x1b]52;c;" ++ payload ++ BEL
3107 try std.testing.expectEqual(at_cap.len + 8, out.items.len); 3004 try std.testing.expectEqual(at_cap.len + 8, out.items.len);
3108 } 3005 }
3109
3110 // One byte past it is not.
3111 {
3112 const over = try alloc.alloc(u8, proto.clipboard_base64_max + 1);
3113 defer alloc.free(over);
3114 @memset(over, 'A');
3115
3116 var out: std.ArrayList(u8) = .empty;
3117 defer out.deinit(alloc);
3118 try out.append(alloc, refusal_sentinel);
3119
3120 var payload: std.ArrayList(u8) = .empty;
3121 defer payload.deinit(alloc);
3122 try proto.encodeClipboardEvent(&payload, alloc, 'c', over);
3123
3124 try appendTermEvent(&out, alloc, payload.items);
3125 try std.testing.expectEqualSlices(u8, &[_]u8{refusal_sentinel}, out.items);
3126 }
3127 } 3006 }
3128 3007
3129 // Forces semantic analysis of every pub decl under `zig build test`, so an 3008 // Forces semantic analysis of every pub decl under `zig build test`, so an
src/client_core.zig
Old New
@@ -59,7 +59,11 @@ fn receiveEvent(payload: []const u8) Result {
59 }; 59 };
60 } 60 }
61 61
62 fn validClipboard(target: u8, base64: []const u8) bool { 62 /// Zero length is refused rather than proxied: it is OSC 52's "clear the
63 /// clipboard" form, and an interrupted copy that lands empty would silently
64 /// wipe whatever the human last copied. That is a policy, not a parse
65 /// question — the empty string is perfectly well-formed base64.
66 pub fn validClipboard(target: u8, base64: []const u8) bool {
63 if (!validTarget(target) or base64.len == 0 or base64.len > proto.clipboard_base64_max) 67 if (!validTarget(target) or base64.len == 0 or base64.len > proto.clipboard_base64_max)
64 return false; 68 return false;
65 // Enforce only the injection-safe alphabet; canonical base64 length and 69 // Enforce only the injection-safe alphabet; canonical base64 length and
@@ -73,6 +77,17 @@ fn validClipboard(target: u8, base64: []const u8) bool {
73 return true; 77 return true;
74 } 78 }
75 79
80 /// Pc as xterm's ctlseqs defines it, and this list DOES narrow: ghostty
81 /// sets `kind = data[0]` with no validation of its own
82 /// (osc/parsers/clipboard_operation.zig), so any byte a program inside the
83 /// session writes arrives here — `ESC]52;X;…` lands as target 0x58.
84 /// This list is the only thing standing between that byte and a consumer,
85 /// so it is NOT dead code.
86 ///
87 /// Multi-character targets are a separate matter and cannot reach here at
88 /// all: ghostty rejects the whole OSC when `data[1] != ';'`, so
89 /// `ESC]52;pc;…` yields no event to forward. Nothing to widen this
90 /// list for.
76 fn validTarget(target: u8) bool { 91 fn validTarget(target: u8) bool {
77 return target == 'c' or target == 'p' or target == 'q' or target == 's' or 92 return target == 'c' or target == 'p' or target == 'q' or target == 's' or
78 (target >= '0' and target <= '7'); 93 (target >= '0' and target <= '7');
@@ -151,7 +166,13 @@ test "client core accepts every clipboard target boundary" {
151 166
152 test "client core refuses invalid clipboard targets" { 167 test "client core refuses invalid clipboard targets" {
153 var core = ClientCore{}; 168 var core = ClientCore{};
154 const targets = [_]u8{ 'C', 'x', '/', '8', 0x07 }; 169 // 0x58 is the 'X' a probe of `ESC]52;X;aGk=BEL` actually produces; ';'
170 // would close the field early and NUL is the empty byte a sloppy emitter
171 // leaves behind. 'C' catches a case-insensitive widening; '8' pins the
172 // top of the '0'...'7' range and '/' pins its bottom — and '/' is the
173 // likeliest accident of the lot because it is IN the base64 alphabet, so
174 // an emitter that miscounts its fields lands exactly there.
175 const targets = [_]u8{ 'X', 0x00, ';', 'C', 'x', '8', '/', 0x07 };
155 for (targets) |target| { 176 for (targets) |target| {
156 const payload = [_]u8{ 0, target, 'A' }; 177 const payload = [_]u8{ 0, target, 'A' };
157 try expectIgnored(core.receive(.term_event, &payload)); 178 try expectIgnored(core.receive(.term_event, &payload));