bdb6f588
refactor: drive cli side channels from client core
a73x 2026-08-18 12:27
Commit message
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)); |