a493bcfe
test: make web routing checks comment safe
a73x 2026-08-18 12:27
Commit message
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -31,6 +31,155 @@ function check(name, got, want) { | |||
| 31 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } | 31 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } |
| 32 | } | 32 | } |
| 33 | 33 | ||
| 34 | // Replace comments with spaces, preserving newlines and every live token. | ||
| 35 | // This is a deliberately small lexer rather than a comment-shaped regex: | ||
| 36 | // mux.js uses strings and nested template expressions, and regex literals | ||
| 37 | // can themselves contain quotes and escaped comment delimiters. Regex-vs- | ||
| 38 | // division is decided from the preceding token, covering every expression | ||
| 39 | // form used by mux.js and the mutation fixture below. | ||
| 40 | function maskJsComments(source) { | ||
| 41 | const out = source.split(''); | ||
| 42 | let i = 0; | ||
| 43 | |||
| 44 | const mask = (start, end) => { | ||
| 45 | for (let j = start; j < end; j++) { | ||
| 46 | if (out[j] !== '\n' && out[j] !== '\r') out[j] = ' '; | ||
| 47 | } | ||
| 48 | }; | ||
| 49 | |||
| 50 | const skipQuoted = (quote) => { | ||
| 51 | i++; | ||
| 52 | while (i < source.length) { | ||
| 53 | if (source[i] === '\\') { i += 2; continue; } | ||
| 54 | const c = source[i++]; | ||
| 55 | if (c === quote || c === '\n' || c === '\r') return; | ||
| 56 | } | ||
| 57 | }; | ||
| 58 | |||
| 59 | const skipRegex = () => { | ||
| 60 | i++; // opening slash | ||
| 61 | let inClass = false; | ||
| 62 | while (i < source.length) { | ||
| 63 | if (source[i] === '\\') { i += 2; continue; } | ||
| 64 | const c = source[i++]; | ||
| 65 | if (c === '[') inClass = true; | ||
| 66 | else if (c === ']') inClass = false; | ||
| 67 | else if (c === '/' && !inClass) { | ||
| 68 | while (i < source.length && /[a-z]/i.test(source[i])) i++; | ||
| 69 | return; | ||
| 70 | } else if (c === '\n' || c === '\r') return; | ||
| 71 | } | ||
| 72 | }; | ||
| 73 | |||
| 74 | const expressionKeywords = new Set([ | ||
| 75 | 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', | ||
| 76 | 'of', 'return', 'throw', 'typeof', 'void', 'yield', | ||
| 77 | ]); | ||
| 78 | |||
| 79 | let scanCode; | ||
| 80 | const skipTemplate = () => { | ||
| 81 | i++; // opening backtick | ||
| 82 | while (i < source.length) { | ||
| 83 | if (source[i] === '\\') { i += 2; continue; } | ||
| 84 | if (source[i] === '`') { i++; return; } | ||
| 85 | if (source[i] === '$' && source[i + 1] === '{') { | ||
| 86 | i += 2; | ||
| 87 | scanCode(true); | ||
| 88 | continue; | ||
| 89 | } | ||
| 90 | i++; | ||
| 91 | } | ||
| 92 | }; | ||
| 93 | |||
| 94 | scanCode = (templateExpression) => { | ||
| 95 | let expressionAllowed = true; | ||
| 96 | let braceDepth = 0; | ||
| 97 | while (i < source.length) { | ||
| 98 | const c = source[i]; | ||
| 99 | const next = source[i + 1]; | ||
| 100 | |||
| 101 | if (/\s/.test(c)) { i++; continue; } | ||
| 102 | if (templateExpression && c === '}' && braceDepth === 0) { i++; return; } | ||
| 103 | |||
| 104 | if (c === '/' && next === '/') { | ||
| 105 | const start = i; | ||
| 106 | i += 2; | ||
| 107 | while (i < source.length && source[i] !== '\n' && source[i] !== '\r') i++; | ||
| 108 | mask(start, i); | ||
| 109 | continue; | ||
| 110 | } | ||
| 111 | if (c === '/' && next === '*') { | ||
| 112 | const start = i; | ||
| 113 | i += 2; | ||
| 114 | while (i < source.length && !(source[i] === '*' && source[i + 1] === '/')) i++; | ||
| 115 | if (i < source.length) i += 2; | ||
| 116 | mask(start, i); | ||
| 117 | continue; | ||
| 118 | } | ||
| 119 | if (c === '"' || c === "'") { | ||
| 120 | skipQuoted(c); | ||
| 121 | expressionAllowed = false; | ||
| 122 | continue; | ||
| 123 | } | ||
| 124 | if (c === '`') { | ||
| 125 | skipTemplate(); | ||
| 126 | expressionAllowed = false; | ||
| 127 | continue; | ||
| 128 | } | ||
| 129 | if (c === '/' && expressionAllowed) { | ||
| 130 | skipRegex(); | ||
| 131 | expressionAllowed = false; | ||
| 132 | continue; | ||
| 133 | } | ||
| 134 | |||
| 135 | if (/[A-Za-z_$]/.test(c)) { | ||
| 136 | const start = i++; | ||
| 137 | while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++; | ||
| 138 | expressionAllowed = expressionKeywords.has(source.slice(start, i)); | ||
| 139 | continue; | ||
| 140 | } | ||
| 141 | if (/[0-9]/.test(c)) { | ||
| 142 | i++; | ||
| 143 | while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++; | ||
| 144 | expressionAllowed = false; | ||
| 145 | continue; | ||
| 146 | } | ||
| 147 | |||
| 148 | if (c === '{') { | ||
| 149 | if (templateExpression) braceDepth++; | ||
| 150 | i++; | ||
| 151 | expressionAllowed = true; | ||
| 152 | } else if (c === '}') { | ||
| 153 | if (templateExpression) braceDepth--; | ||
| 154 | i++; | ||
| 155 | expressionAllowed = false; | ||
| 156 | } else if (c === ')' || c === ']') { | ||
| 157 | i++; | ||
| 158 | expressionAllowed = false; | ||
| 159 | } else if (c === '.' || ((c === '+' || c === '-') && next === c)) { | ||
| 160 | i += next === c ? 2 : 1; | ||
| 161 | expressionAllowed = false; | ||
| 162 | } else if (c === '/') { | ||
| 163 | i += next === '=' ? 2 : 1; | ||
| 164 | expressionAllowed = true; | ||
| 165 | } else { | ||
| 166 | i++; | ||
| 167 | expressionAllowed = true; | ||
| 168 | } | ||
| 169 | } | ||
| 170 | }; | ||
| 171 | |||
| 172 | scanCode(false); | ||
| 173 | return out.join(''); | ||
| 174 | } | ||
| 175 | |||
| 176 | function wasmCalls(source) { | ||
| 177 | const executable = maskJsComments(source); | ||
| 178 | return [...new Set( | ||
| 179 | [...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), | ||
| 180 | )].sort(); | ||
| 181 | } | ||
| 182 | |||
| 34 | // --- wire builders (layouts golden-pinned in protocol.zig) --- | 183 | // --- wire builders (layouts golden-pinned in protocol.zig) --- |
| 35 | function snapshotPayload({ seq, history, cols, rows, epoch }, state) { | 184 | function snapshotPayload({ seq, history, cols, rows, epoch }, state) { |
| 36 | const stateBytes = Buffer.from(state, 'utf8'); | 185 | const stateBytes = Buffer.from(state, 'utf8'); |
| @@ -294,14 +443,57 @@ async function main() { | |||
| 294 | // nowhere else, and the wasm builds fine without it. | 443 | // nowhere else, and the wasm builds fine without it. |
| 295 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); | 444 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); |
| 296 | 445 | ||
| 446 | // Mutation fixture for the source checks below. A correct-looking route | ||
| 447 | // in either kind of comment must be invisible, while comment delimiters | ||
| 448 | // inside live literals must survive unchanged. | ||
| 449 | const lexicalFixture = [ | ||
| 450 | '// const MSG = { term_modes: 0x8d, term_event: 0x8f };', | ||
| 451 | '/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */', | ||
| 452 | '/* case MSG.term_modes:', | ||
| 453 | 'case MSG.term_event: { const action = this.core.mux_client_frame(type, payload.length); }', | ||
| 454 | 'case MSG.pty_mode: */', | ||
| 455 | '// sendPaste(text) { try { this.sendText(text); } finally {} }', | ||
| 456 | '/* onClipboardEffect() {} */', | ||
| 457 | '// this.core.mux_comment_decoy(1);', | ||
| 458 | 'const stringLiteral = "https://host/*literal*/";', | ||
| 459 | 'const templateLiteral = `// literal /* template */`;', | ||
| 460 | "const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail", | ||
| 461 | ].join('\n'); | ||
| 462 | const executableFixture = maskJsComments(lexicalFixture); | ||
| 463 | check('source masker rejects line-comment declaration decoy', /\bconst MSG\b/.test(executableFixture), false); | ||
| 464 | check('source masker rejects block-comment declaration decoy', /\bconst CLIENT_ACTION\b/.test(executableFixture), false); | ||
| 465 | check('source masker rejects commented semantic-route decoy', /\bcase MSG\.term_modes\b/.test(executableFixture), false); | ||
| 466 | check('source masker rejects commented sendPaste decoy', /\bsendPaste\s*\(/.test(executableFixture), false); | ||
| 467 | check('source masker rejects commented placeholder decoy', /\bonClipboardEffect\s*\(/.test(executableFixture), false); | ||
| 468 | check('WASM-call scan rejects commented decoy', wasmCalls(lexicalFixture).includes('mux_comment_decoy'), false); | ||
| 469 | check('source masker removes a regex trailing comment', /remove this tail/.test(executableFixture), false); | ||
| 470 | check('source masker preserves string comment text', executableFixture.includes('"https://host/*literal*/"'), true); | ||
| 471 | check('source masker preserves template comment text', executableFixture.includes('`// literal /* template */`'), true); | ||
| 472 | check('source masker preserves regex comment text', executableFixture.includes('/[\"\']https?:\\/\\/host/'), true); | ||
| 473 | |||
| 474 | const executableShell = maskJsComments(shell); | ||
| 475 | |||
| 297 | // The browser must route sampled terminal state and host effects through | 476 | // The browser must route sampled terminal state and host effects through |
| 298 | // the same semantic core as the CLI. Pin the real page source here: this | 477 | // the same semantic core as the CLI. Pin the real page source here: this |
| 299 | // verifier otherwise exercises only the WASM half of that handshake. | 478 | // verifier otherwise exercises only the WASM half of that handshake. |
| 300 | const msgDecl = shell.match(/const MSG\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? ''; | 479 | const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []; |
| 480 | const msgObjects = [...executableShell.matchAll(/\bconst MSG\s*=\s*\{([\s\S]*?)\};/g)]; | ||
| 481 | check('shell has exactly one live MSG declaration', msgBindings.length, 1); | ||
| 482 | check('shell MSG declaration has the expected object shape', msgObjects.length, 1); | ||
| 483 | const msgDecl = msgObjects.length === 1 ? msgObjects[0][1] : ''; | ||
| 484 | check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); | ||
| 485 | check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); | ||
| 301 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); | 486 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); |
| 302 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); | 487 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); |
| 303 | 488 | ||
| 304 | const actionDecl = shell.match(/const CLIENT_ACTION\s*=\s*\{([\s\S]*?)\};/)?.[1] ?? ''; | 489 | const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; |
| 490 | const actionObjects = [...executableShell.matchAll(/\bconst CLIENT_ACTION\s*=\s*\{([\s\S]*?)\};/g)]; | ||
| 491 | check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); | ||
| 492 | check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); | ||
| 493 | const actionDecl = actionObjects.length === 1 ? actionObjects[0][1] : ''; | ||
| 494 | for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) { | ||
| 495 | check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); | ||
| 496 | } | ||
| 305 | check( | 497 | check( |
| 306 | 'shell pins shared client action ABI', | 498 | 'shell pins shared client action ABI', |
| 307 | /\bignored\s*:\s*0\b/.test(actionDecl) | 499 | /\bignored\s*:\s*0\b/.test(actionDecl) |
| @@ -311,10 +503,14 @@ async function main() { | |||
| 311 | true, | 503 | true, |
| 312 | ); | 504 | ); |
| 313 | 505 | ||
| 314 | const semanticCases = shell.match( | 506 | const semanticRoutes = [...executableShell.matchAll( |
| 315 | /case MSG\.term_modes:\s*case MSG\.term_event:\s*\{([\s\S]*?)\n\s*\}\s*case MSG\.pty_mode:/, | 507 | /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{([\s\S]*?)\}\s*case\s+MSG\.pty_mode\s*:/g, |
| 316 | )?.[1] ?? ''; | 508 | )]; |
| 317 | check('shell shares one semantic frame route', semanticCases.length > 0, true); | 509 | check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); |
| 510 | check('shell has one live term_event case', (executableShell.match(/case\s+MSG\.term_event\s*:/g) ?? []).length, 1); | ||
| 511 | check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); | ||
| 512 | const semanticCases = semanticRoutes.length === 1 ? semanticRoutes[0][1] : ''; | ||
| 513 | check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); | ||
| 318 | check( | 514 | check( |
| 319 | 'shell stages semantic payload before WASM', | 515 | 'shell stages semantic payload before WASM', |
| 320 | /if\s*\(\s*!this\.stage\(payload\)\s*\)\s*return\s*;/.test(semanticCases), | 516 | /if\s*\(\s*!this\.stage\(payload\)\s*\)\s*return\s*;/.test(semanticCases), |
| @@ -325,6 +521,7 @@ async function main() { | |||
| 325 | /(?:const|let)\s+action\s*=\s*this\.core\.mux_client_frame\(type,\s*payload\.length\)\s*;/.test(semanticCases), | 521 | /(?:const|let)\s+action\s*=\s*this\.core\.mux_client_frame\(type,\s*payload\.length\)\s*;/.test(semanticCases), |
| 326 | true, | 522 | true, |
| 327 | ); | 523 | ); |
| 524 | check('shell calls the semantic WASM entry exactly once', (semanticCases.match(/\.mux_client_frame\s*\(/g) ?? []).length, 1); | ||
| 328 | check( | 525 | check( |
| 329 | 'shell dispatches only the clipboard action', | 526 | 'shell dispatches only the clipboard action', |
| 330 | /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases) | 527 | /if\s*\(\s*action\s*===\s*CLIENT_ACTION\.clipboard\s*\)\s*this\.onClipboardEffect\(\)\s*;/.test(semanticCases) |
| @@ -334,12 +531,21 @@ async function main() { | |||
| 334 | check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false); | 531 | check('shell leaves semantic payload parsing to WASM', /DataView|TextDecoder|payload\s*\[/.test(semanticCases), false); |
| 335 | check( | 532 | check( |
| 336 | 'shell has no duplicate bracketed-paste state', | 533 | 'shell has no duplicate bracketed-paste state', |
| 337 | /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(shell), | 534 | /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell), |
| 338 | false, | 535 | false, |
| 339 | ); | 536 | ); |
| 340 | check('shell clipboard effect is an empty placeholder', /onClipboardEffect\s*\(\s*\)\s*\{\s*\}/.test(shell), true); | 537 | const clipboardMethods = executableShell.match(/\bonClipboardEffect\s*\([^)]*\)\s*\{/g) ?? []; |
| 341 | 538 | const emptyClipboardMethods = executableShell.match(/\bonClipboardEffect\s*\(\s*\)\s*\{\s*\}/g) ?? []; | |
| 342 | const sendPaste = shell.match(/sendPaste\(text\)\s*\{([\s\S]*?)\n\s*\}\n\s*sendResizeIfDiffers/)?.[1] ?? ''; | 539 | check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1); |
| 540 | check('shell clipboard effect is the one empty placeholder', emptyClipboardMethods.length, 1); | ||
| 541 | |||
| 542 | const sendPasteMethods = executableShell.match(/\bsendPaste\s*\([^)]*\)\s*\{/g) ?? []; | ||
| 543 | const sendPasteBodies = [...executableShell.matchAll( | ||
| 544 | /\bsendPaste\s*\(\s*text\s*\)\s*\{([\s\S]*?)\}\s*sendResizeIfDiffers/g, | ||
| 545 | )]; | ||
| 546 | check('shell has exactly one live sendPaste method', sendPasteMethods.length, 1); | ||
| 547 | check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1); | ||
| 548 | const sendPaste = sendPasteBodies.length === 1 ? sendPasteBodies[0][1] : ''; | ||
| 343 | check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1); | 549 | check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1); |
| 344 | check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1); | 550 | check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1); |
| 345 | check( | 551 | check( |
| @@ -348,9 +554,7 @@ async function main() { | |||
| 348 | true, | 554 | true, |
| 349 | ); | 555 | ); |
| 350 | 556 | ||
| 351 | const called = [...new Set( | 557 | const called = wasmCalls(shell); |
| 352 | [...shell.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), | ||
| 353 | )].sort(); | ||
| 354 | check('shell calls something', called.length > 0, true); | 558 | check('shell calls something', called.length > 0, true); |
| 355 | const absent = called.filter((n) => typeof e[n] !== 'function'); | 559 | const absent = called.filter((n) => typeof e[n] !== 'function'); |
| 356 | check(`shell calls only real exports (${called.length} of them)`, absent.join(','), ''); | 560 | check(`shell calls only real exports (${called.length} of them)`, absent.join(','), ''); |