a73x

75f92691

fix: invalidate borrowed web selection results

a73x   2026-08-18 12:27

Commit message
fix: invalidate borrowed web selection results

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);