75f92691
fix: invalidate borrowed web selection results
a73x 2026-08-18 12:27
Commit message
src/wasm_core.zig
| Old | New | ||
|---|---|---|---|
| @@ -98,6 +98,15 @@ const ClientAction = enum(i32) { | |||
| 98 | selection = 4, | 98 | selection = 4, |
| 99 | }; | 99 | }; |
| 100 | 100 | ||
| 101 | fn clearSelectionResult(c: *Core) void { | ||
| 102 | c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; | ||
| 103 | } | ||
| 104 | |||
| 105 | fn clearBorrowedInputResults(c: *Core) void { | ||
| 106 | c.clipboard = .{ .target = 0, .base64 = &.{} }; | ||
| 107 | clearSelectionResult(c); | ||
| 108 | } | ||
| 109 | |||
| 101 | // --------------------------------------------------------------------- | 110 | // --------------------------------------------------------------------- |
| 102 | // Lifecycle | 111 | // Lifecycle |
| 103 | // --------------------------------------------------------------------- | 112 | // --------------------------------------------------------------------- |
| @@ -172,6 +181,7 @@ export fn mux_deinit() void { | |||
| 172 | // --------------------------------------------------------------------- | 181 | // --------------------------------------------------------------------- |
| 173 | 182 | ||
| 174 | export fn mux_input_ptr() [*]u8 { | 183 | export fn mux_input_ptr() [*]u8 { |
| 184 | if (core) |c| clearBorrowedInputResults(c); | ||
| 175 | return &input_buf; | 185 | return &input_buf; |
| 176 | } | 186 | } |
| 177 | 187 | ||
| @@ -187,6 +197,7 @@ export fn mux_input_cap() u32 { | |||
| 187 | /// frame or a grid the core refuses. | 197 | /// frame or a grid the core refuses. |
| 188 | export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | 198 | export fn mux_apply_frame(msg_type: u32, len: u32) i32 { |
| 189 | const c = core orelse return -1; | 199 | const c = core orelse return -1; |
| 200 | clearBorrowedInputResults(c); | ||
| 190 | if (len > input_buf.len) return -2; | 201 | if (len > input_buf.len) return -2; |
| 191 | if (msg_type > 0xff) return -3; | 202 | if (msg_type > 0xff) return -3; |
| 192 | const t = std.meta.intToEnum(proto.MsgType, @as(u8, @intCast(msg_type))) catch return -3; | 203 | const t = std.meta.intToEnum(proto.MsgType, @as(u8, @intCast(msg_type))) catch return -3; |
| @@ -256,8 +267,7 @@ export fn mux_apply_frame(msg_type: u32, len: u32) i32 { | |||
| 256 | /// copy or allocate them. | 267 | /// copy or allocate them. |
| 257 | export fn mux_client_frame(msg_type: u32, len: u32) i32 { | 268 | export fn mux_client_frame(msg_type: u32, len: u32) i32 { |
| 258 | const c = core orelse return -1; | 269 | const c = core orelse return -1; |
| 259 | c.clipboard = .{ .target = 0, .base64 = &.{} }; | 270 | clearBorrowedInputResults(c); |
| 260 | c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; | ||
| 261 | if (len > input_buf.len) return -2; | 271 | if (len > input_buf.len) return -2; |
| 262 | if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored); | 272 | if (msg_type > 0xff) return @intFromEnum(ClientAction.ignored); |
| 263 | 273 | ||
| @@ -317,6 +327,7 @@ export fn mux_selection_request( | |||
| 317 | ) i32 { | 327 | ) i32 { |
| 318 | output_len = 0; | 328 | output_len = 0; |
| 319 | const c = core orelse return -1; | 329 | const c = core orelse return -1; |
| 330 | clearSelectionResult(c); | ||
| 320 | if (anchor_col > std.math.maxInt(u16) or active_col > std.math.maxInt(u16)) return -3; | 331 | if (anchor_col > std.math.maxInt(u16) or active_col > std.math.maxInt(u16)) return -3; |
| 321 | 332 | ||
| 322 | const payload = c.client.beginSelection(.{ | 333 | const payload = c.client.beginSelection(.{ |
| @@ -324,12 +335,13 @@ export fn mux_selection_request( | |||
| 324 | .anchor = .{ .row = anchor_row, .col = @intCast(anchor_col) }, | 335 | .anchor = .{ .row = anchor_row, .col = @intCast(anchor_col) }, |
| 325 | .active = .{ .row = active_row, .col = @intCast(active_col) }, | 336 | .active = .{ .row = active_row, .col = @intCast(active_col) }, |
| 326 | }); | 337 | }); |
| 327 | c.selection = .{ .id = 0, .status = .unavailable, .text = &.{} }; | ||
| 328 | @memcpy(output_buf[0..payload.len], &payload); | 338 | @memcpy(output_buf[0..payload.len], &payload); |
| 329 | output_len = payload.len; | 339 | output_len = payload.len; |
| 330 | return @intCast(payload.len); | 340 | return @intCast(payload.len); |
| 331 | } | 341 | } |
| 332 | 342 | ||
| 343 | /// WebAssembly exposes this u32 to JavaScript as an i32; browser consumers | ||
| 344 | /// must normalize it with `mux_selection_id() >>> 0` before comparing IDs. | ||
| 333 | export fn mux_selection_id() u32 { | 345 | export fn mux_selection_id() u32 { |
| 334 | const c = core orelse return 0; | 346 | const c = core orelse return 0; |
| 335 | return c.selection.id; | 347 | return c.selection.id; |
| @@ -461,6 +473,7 @@ export fn mux_key_encode(key: u32, cp: u32, mods: u32) i32 { | |||
| 461 | /// | 473 | /// |
| 462 | /// -2 if the host staged more than either buffer holds. | 474 | /// -2 if the host staged more than either buffer holds. |
| 463 | export fn mux_text_encode(len: u32) i32 { | 475 | export fn mux_text_encode(len: u32) i32 { |
| 476 | if (core) |c| clearBorrowedInputResults(c); | ||
| 464 | if (len > input_buf.len or len > output_buf.len) return -2; | 477 | if (len > input_buf.len or len > output_buf.len) return -2; |
| 465 | @memcpy(output_buf[0..len], input_buf[0..len]); | 478 | @memcpy(output_buf[0..len], input_buf[0..len]); |
| 466 | output_len = len; | 479 | output_len = len; |
| @@ -599,6 +612,7 @@ fn packColor(col: anytype) u32 { | |||
| 599 | /// it. Returns 0, -1 uninit, -2 overflow. | 612 | /// it. Returns 0, -1 uninit, -2 overflow. |
| 600 | export fn mux_scroll_feed(len: u32) i32 { | 613 | export fn mux_scroll_feed(len: u32) i32 { |
| 601 | const c = core orelse return -1; | 614 | const c = core orelse return -1; |
| 615 | clearBorrowedInputResults(c); | ||
| 602 | if (len > input_buf.len) return -2; | 616 | if (len > input_buf.len) return -2; |
| 603 | if (c.scroll_eng) |se| { | 617 | if (c.scroll_eng) |se| { |
| 604 | if (se.term.cols != c.cols or se.term.rows != c.rows) { | 618 | if (se.term.cols != c.cols or se.term.rows != c.rows) { |
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -738,12 +738,20 @@ async function main() { | |||
| 738 | const { instance } = await WebAssembly.instantiate(bin, {}); | 738 | const { instance } = await WebAssembly.instantiate(bin, {}); |
| 739 | const e = instance.exports; | 739 | const e = instance.exports; |
| 740 | const mem = () => Buffer.from(e.memory.buffer); // ALWAYS fresh | 740 | const mem = () => Buffer.from(e.memory.buffer); // ALWAYS fresh |
| 741 | const inputPtr = e.mux_input_ptr(); | ||
| 741 | 742 | ||
| 742 | const stage = (buf) => { | 743 | const stage = (buf) => { |
| 743 | if (buf.length > e.mux_input_cap()) throw new Error('over cap'); | 744 | if (buf.length > e.mux_input_cap()) throw new Error('over cap'); |
| 744 | buf.copy(mem(), e.mux_input_ptr()); | 745 | buf.copy(mem(), e.mux_input_ptr()); |
| 745 | return buf.length; | 746 | return buf.length; |
| 746 | }; | 747 | }; |
| 748 | // Exercise consumers without asking for the staging pointer again. This | ||
| 749 | // catches invalidation at the consumer boundary as well as mux_input_ptr. | ||
| 750 | const stageThroughKnownPtr = (buf) => { | ||
| 751 | if (buf.length > e.mux_input_cap()) throw new Error('over cap'); | ||
| 752 | buf.copy(mem(), inputPtr); | ||
| 753 | return buf.length; | ||
| 754 | }; | ||
| 747 | const outBytes = () => | 755 | const outBytes = () => |
| 748 | Buffer.from(mem().subarray(e.mux_output_ptr(), e.mux_output_ptr() + e.mux_output_len())); | 756 | Buffer.from(mem().subarray(e.mux_output_ptr(), e.mux_output_ptr() + e.mux_output_len())); |
| 749 | const cell = (x, y) => { | 757 | const cell = (x, y) => { |
| @@ -884,6 +892,17 @@ async function main() { | |||
| 884 | ); | 892 | ); |
| 885 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); | 893 | check('selection repeated reply clears getter', e.mux_selection_len(), 0); |
| 886 | 894 | ||
| 895 | const highSelectionId = 0xfedcba98; | ||
| 896 | check('high-bit selection request', e.mux_selection_request(highSelectionId, 0, 0, 0, 0), 16); | ||
| 897 | check( | ||
| 898 | 'high-bit selection reply action', | ||
| 899 | e.mux_client_frame(0x90, stage(selectionReply(highSelectionId, 0, 'high'))), | ||
| 900 | clientAction.selection, | ||
| 901 | ); | ||
| 902 | // WebAssembly exposes u32 results to JS as signed i32. Every browser | ||
| 903 | // consumer must normalize this getter with >>> 0 before comparing IDs. | ||
| 904 | check('high-bit selection id uses unsigned JS consumer contract', e.mux_selection_id() >>> 0, highSelectionId); | ||
| 905 | |||
| 887 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { | 906 | for (const [name, status] of [['invalid', 1], ['too large', 2], ['unavailable', 3]]) { |
| 888 | const id = 100 + status; | 907 | const id = 100 + status; |
| 889 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); | 908 | check(`selection ${name} request`, e.mux_selection_request(id, 0, 0, 0, 0), 16); |
| @@ -922,6 +941,41 @@ async function main() { | |||
| 922 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); | 941 | check('valid new request after selection', e.mux_selection_request(206, 1, 2, 3, 4), 16); |
| 923 | check('valid new request clears selection getter', e.mux_selection_len(), 0); | 942 | check('valid new request clears selection getter', e.mux_selection_len(), 0); |
| 924 | 943 | ||
| 944 | populateSelection(207); | ||
| 945 | e.mux_input_ptr(); | ||
| 946 | check('asking for staging pointer clears selection id', e.mux_selection_id(), 0); | ||
| 947 | check('asking for staging pointer clears selection status', e.mux_selection_status(), 3); | ||
| 948 | check('asking for staging pointer clears selection len', e.mux_selection_len(), 0); | ||
| 949 | check('asking for staging pointer resets selection ptr', e.mux_selection_ptr(), inputPtr); | ||
| 950 | |||
| 951 | check('clipboard before staging pointer invalidation', e.mux_client_frame(0x8f, stage(clipboard)), clientAction.clipboard); | ||
| 952 | e.mux_input_ptr(); | ||
| 953 | check('staging pointer clears borrowed clipboard len', e.mux_clipboard_len(), 0); | ||
| 954 | check('staging pointer clears borrowed clipboard target', e.mux_clipboard_target(), 0); | ||
| 955 | |||
| 956 | populateSelection(208); | ||
| 957 | check('text encode guard after selection', e.mux_text_encode(e.mux_input_cap() + 1), -2); | ||
| 958 | check('text encode guard clears selection', e.mux_selection_len(), 0); | ||
| 959 | populateSelection(209); | ||
| 960 | check('apply frame guard after selection', e.mux_apply_frame(0x81, e.mux_input_cap() + 1), -2); | ||
| 961 | check('apply frame guard clears selection', e.mux_selection_len(), 0); | ||
| 962 | populateSelection(210); | ||
| 963 | check('scroll feed guard after selection', e.mux_scroll_feed(e.mux_input_cap() + 1), -2); | ||
| 964 | check('scroll feed guard clears selection', e.mux_selection_len(), 0); | ||
| 965 | |||
| 966 | populateSelection(211); | ||
| 967 | check('invalid selection request after result', e.mux_selection_request(212, 0, 65536, 0, 0), -3); | ||
| 968 | check('invalid selection request clears exposed id', e.mux_selection_id(), 0); | ||
| 969 | check('invalid selection request clears exposed status', e.mux_selection_status(), 3); | ||
| 970 | check('invalid selection request clears exposed len', e.mux_selection_len(), 0); | ||
| 971 | check('pending selection request before invalid request', e.mux_selection_request(213, 1, 2, 3, 4), 16); | ||
| 972 | check('invalid selection request preserves pending correlation', e.mux_selection_request(214, 1, 2, 3, 65536), -3); | ||
| 973 | check( | ||
| 974 | 'matching reply after invalid request is accepted', | ||
| 975 | e.mux_client_frame(0x90, stage(selectionReply(213, 0, 'still pending'))), | ||
| 976 | clientAction.selection, | ||
| 977 | ); | ||
| 978 | |||
| 925 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- | 979 | // --- attach payload before any state: quotes (0,0); wall spelling 1x1 --- |
| 926 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); | 980 | check('attach len', e.mux_attach_payload(1, 1, 0), 20); |
| 927 | let att = outBytes(); | 981 | let att = outBytes(); |
| @@ -935,7 +989,9 @@ async function main() { | |||
| 935 | { seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn }, | 989 | { seq: 7, history: 3, cols: 80, rows: 24, epoch: 0xabcdn }, |
| 936 | '\x1b[1;31mhello\x1b[0m world', | 990 | '\x1b[1;31mhello\x1b[0m world', |
| 937 | ); | 991 | ); |
| 938 | check('apply snapshot', e.mux_apply_frame(0x81, stage(snap)), 0); | 992 | populateSelection(215); |
| 993 | check('apply snapshot', e.mux_apply_frame(0x81, stageThroughKnownPtr(snap)), 0); | ||
| 994 | check('apply snapshot clears borrowed selection', e.mux_selection_len(), 0); | ||
| 939 | check('seq adopted', resumeArgs().seq, 7n); | 995 | check('seq adopted', resumeArgs().seq, 7n); |
| 940 | check('epoch adopted', resumeArgs().epoch, 0xabcdn); | 996 | check('epoch adopted', resumeArgs().epoch, 0xabcdn); |
| 941 | check('history', e.mux_history_rows(), 3); | 997 | check('history', e.mux_history_rows(), 3); |
| @@ -1019,8 +1075,10 @@ async function main() { | |||
| 1019 | // bytes with ONE wrap around the whole of it, however many chunks it | 1075 | // bytes with ONE wrap around the whole of it, however many chunks it |
| 1020 | // takes. Reassembling here is what proves a chunked paste carries | 1076 | // takes. Reassembling here is what proves a chunked paste carries |
| 1021 | // exactly one begin and one end. | 1077 | // exactly one begin and one end. |
| 1022 | stage(Buffer.from('two\nlines')); | 1078 | populateSelection(216); |
| 1079 | stageThroughKnownPtr(Buffer.from('two\nlines')); | ||
| 1023 | check('text len', e.mux_text_encode(9), 9); | 1080 | check('text len', e.mux_text_encode(9), 9); |
| 1081 | check('text encode clears borrowed selection', e.mux_selection_len(), 0); | ||
| 1024 | check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); | 1082 | check('text bytes: no wrap', outBytes().toString('latin1'), 'two\nlines'); |
| 1025 | 1083 | ||
| 1026 | // Fresh sample: this assertion must not inherit mode state from the | 1084 | // Fresh sample: this assertion must not inherit mode state from the |
| @@ -1045,8 +1103,10 @@ async function main() { | |||
| 1045 | 1103 | ||
| 1046 | // --- scroll scratch: never touches the live replica --- | 1104 | // --- scroll scratch: never touches the live replica --- |
| 1047 | check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates | 1105 | check('scroll start', e.mux_scroll_start(1, 30), 0); // history 0: saturates |
| 1048 | stage(Buffer.from('old history line')); | 1106 | populateSelection(217); |
| 1107 | stageThroughKnownPtr(Buffer.from('old history line')); | ||
| 1049 | check('scroll feed', e.mux_scroll_feed(16), 0); | 1108 | check('scroll feed', e.mux_scroll_feed(16), 0); |
| 1109 | check('scroll feed clears borrowed selection', e.mux_selection_len(), 0); | ||
| 1050 | check('scroll read', e.mux_read_scroll_viewport(), 30); | 1110 | check('scroll read', e.mux_read_scroll_viewport(), 30); |
| 1051 | check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0)); | 1111 | check('scroll cell', cell(0, 0).cp, 'o'.codePointAt(0)); |
| 1052 | check('live untouched: full repaint queued', e.mux_read_viewport(), 30); | 1112 | check('live untouched: full repaint queued', e.mux_read_viewport(), 30); |