959a43f3
test: exclude literals from web source checks
a73x 2026-08-18 12:27
Commit message
web/verify.js
| Old | New | ||
|---|---|---|---|
| @@ -13,6 +13,7 @@ | |||
| 13 | 'use strict'; | 13 | 'use strict'; |
| 14 | const fs = require('fs'); | 14 | const fs = require('fs'); |
| 15 | const path = require('path'); | 15 | const path = require('path'); |
| 16 | const vm = require('vm'); | ||
| 16 | 17 | ||
| 17 | const wasmPath = process.argv[2] || | 18 | const wasmPath = process.argv[2] || |
| 18 | path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); | 19 | path.join(__dirname, '..', 'zig-out', 'bin', 'mux_core.wasm'); |
| @@ -31,13 +32,14 @@ function check(name, got, want) { | |||
| 31 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } | 32 | else { failed++; console.error(`FAIL ${name}: got ${got}, want ${want}`); } |
| 32 | } | 33 | } |
| 33 | 34 | ||
| 34 | // Replace comments with spaces, preserving newlines and every live token. | 35 | // Reduce JavaScript to executable tokens: comments and literal contents are |
| 35 | // This is a deliberately small lexer rather than a comment-shaped regex: | 36 | // spaces, while punctuation, identifiers, numbers, and newlines stay put. |
| 36 | // mux.js uses strings and nested template expressions, and regex literals | 37 | // Template expressions recurse back into code. This is intentionally a |
| 37 | // can themselves contain quotes and escaped comment delimiters. Regex-vs- | 38 | // lexer, not a comment-shaped regex: the verifier must not mistake a route |
| 38 | // division is decided from the preceding token, covering every expression | 39 | // written in a string/regex for behavior, nor `//` inside a regex for a |
| 39 | // form used by mux.js and the mutation fixture below. | 40 | // comment. The context tracking covers every form used by mux.js and the |
| 40 | function maskJsComments(source) { | 41 | // control-flow/division mutation fixtures below. |
| 42 | function executableJsTokens(source) { | ||
| 41 | const out = source.split(''); | 43 | const out = source.split(''); |
| 42 | let i = 0; | 44 | let i = 0; |
| 43 | 45 | ||
| @@ -48,58 +50,92 @@ function maskJsComments(source) { | |||
| 48 | }; | 50 | }; |
| 49 | 51 | ||
| 50 | const skipQuoted = (quote) => { | 52 | const skipQuoted = (quote) => { |
| 51 | i++; | 53 | const contentStart = ++i; // preserve the opening quote |
| 52 | while (i < source.length) { | 54 | while (i < source.length) { |
| 53 | if (source[i] === '\\') { i += 2; continue; } | 55 | if (source[i] === '\\') { i += 2; continue; } |
| 54 | const c = source[i++]; | 56 | const c = source[i]; |
| 55 | if (c === quote || c === '\n' || c === '\r') return; | 57 | if (c === quote) { |
| 58 | mask(contentStart, i); | ||
| 59 | i++; // preserve the closing quote | ||
| 60 | return; | ||
| 61 | } | ||
| 62 | if (c === '\n' || c === '\r') { | ||
| 63 | mask(contentStart, i); | ||
| 64 | return; | ||
| 65 | } | ||
| 66 | i++; | ||
| 56 | } | 67 | } |
| 68 | mask(contentStart, i); | ||
| 57 | }; | 69 | }; |
| 58 | 70 | ||
| 59 | const skipRegex = () => { | 71 | const skipRegex = () => { |
| 60 | i++; // opening slash | 72 | const contentStart = ++i; // preserve the opening slash |
| 61 | let inClass = false; | 73 | let inClass = false; |
| 62 | while (i < source.length) { | 74 | while (i < source.length) { |
| 63 | if (source[i] === '\\') { i += 2; continue; } | 75 | if (source[i] === '\\') { i += 2; continue; } |
| 64 | const c = source[i++]; | 76 | const c = source[i]; |
| 65 | if (c === '[') inClass = true; | 77 | if (c === '[') inClass = true; |
| 66 | else if (c === ']') inClass = false; | 78 | else if (c === ']') inClass = false; |
| 67 | else if (c === '/' && !inClass) { | 79 | else if (c === '/' && !inClass) { |
| 80 | mask(contentStart, i); | ||
| 81 | i++; // preserve the closing slash | ||
| 82 | const flagsStart = i; | ||
| 68 | while (i < source.length && /[a-z]/i.test(source[i])) i++; | 83 | while (i < source.length && /[a-z]/i.test(source[i])) i++; |
| 84 | mask(flagsStart, i); | ||
| 85 | return; | ||
| 86 | } else if (c === '\n' || c === '\r') { | ||
| 87 | mask(contentStart, i); | ||
| 69 | return; | 88 | return; |
| 70 | } else if (c === '\n' || c === '\r') return; | 89 | } |
| 90 | i++; | ||
| 71 | } | 91 | } |
| 92 | mask(contentStart, i); | ||
| 72 | }; | 93 | }; |
| 73 | 94 | ||
| 74 | const expressionKeywords = new Set([ | 95 | const expressionKeywords = new Set([ |
| 75 | 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', | 96 | 'await', 'case', 'delete', 'do', 'else', 'in', 'instanceof', 'new', |
| 76 | 'of', 'return', 'throw', 'typeof', 'void', 'yield', | 97 | 'of', 'return', 'throw', 'typeof', 'void', 'yield', |
| 77 | ]); | 98 | ]); |
| 99 | const controlKeywords = new Set(['catch', 'for', 'if', 'switch', 'while', 'with']); | ||
| 100 | const statementKeywords = new Set(['do', 'else', 'finally', 'try']); | ||
| 78 | 101 | ||
| 79 | let scanCode; | 102 | let scanCode; |
| 80 | const skipTemplate = () => { | 103 | const skipTemplate = () => { |
| 81 | i++; // opening backtick | 104 | i++; // preserve the opening backtick |
| 105 | let rawStart = i; | ||
| 82 | while (i < source.length) { | 106 | while (i < source.length) { |
| 83 | if (source[i] === '\\') { i += 2; continue; } | 107 | if (source[i] === '\\') { i += 2; continue; } |
| 84 | if (source[i] === '`') { i++; return; } | 108 | if (source[i] === '`') { |
| 109 | mask(rawStart, i); | ||
| 110 | i++; // preserve the closing backtick | ||
| 111 | return; | ||
| 112 | } | ||
| 85 | if (source[i] === '$' && source[i + 1] === '{') { | 113 | if (source[i] === '$' && source[i + 1] === '{') { |
| 114 | mask(rawStart, i); | ||
| 86 | i += 2; | 115 | i += 2; |
| 87 | scanCode(true); | 116 | scanCode(true); |
| 117 | rawStart = i; | ||
| 88 | continue; | 118 | continue; |
| 89 | } | 119 | } |
| 90 | i++; | 120 | i++; |
| 91 | } | 121 | } |
| 122 | mask(rawStart, i); | ||
| 92 | }; | 123 | }; |
| 93 | 124 | ||
| 94 | scanCode = (templateExpression) => { | 125 | scanCode = (templateExpression) => { |
| 95 | let expressionAllowed = true; | 126 | let expressionAllowed = true; |
| 96 | let braceDepth = 0; | 127 | let statementExpected = !templateExpression; |
| 128 | let pendingControl = false; | ||
| 129 | let previousToken = null; | ||
| 130 | const parenKinds = []; | ||
| 131 | const braceKinds = []; | ||
| 132 | |||
| 97 | while (i < source.length) { | 133 | while (i < source.length) { |
| 98 | const c = source[i]; | 134 | const c = source[i]; |
| 99 | const next = source[i + 1]; | 135 | const next = source[i + 1]; |
| 100 | 136 | ||
| 101 | if (/\s/.test(c)) { i++; continue; } | 137 | if (/\s/.test(c)) { i++; continue; } |
| 102 | if (templateExpression && c === '}' && braceDepth === 0) { i++; return; } | 138 | if (templateExpression && c === '}' && braceKinds.length === 0) { i++; return; } |
| 103 | 139 | ||
| 104 | if (c === '/' && next === '/') { | 140 | if (c === '/' && next === '/') { |
| 105 | const start = i; | 141 | const start = i; |
| @@ -119,52 +155,117 @@ function maskJsComments(source) { | |||
| 119 | if (c === '"' || c === "'") { | 155 | if (c === '"' || c === "'") { |
| 120 | skipQuoted(c); | 156 | skipQuoted(c); |
| 121 | expressionAllowed = false; | 157 | expressionAllowed = false; |
| 158 | statementExpected = false; | ||
| 159 | previousToken = 'literal'; | ||
| 122 | continue; | 160 | continue; |
| 123 | } | 161 | } |
| 124 | if (c === '`') { | 162 | if (c === '`') { |
| 125 | skipTemplate(); | 163 | skipTemplate(); |
| 126 | expressionAllowed = false; | 164 | expressionAllowed = false; |
| 165 | statementExpected = false; | ||
| 166 | previousToken = 'literal'; | ||
| 127 | continue; | 167 | continue; |
| 128 | } | 168 | } |
| 129 | if (c === '/' && expressionAllowed) { | 169 | if (c === '/' && expressionAllowed) { |
| 130 | skipRegex(); | 170 | skipRegex(); |
| 131 | expressionAllowed = false; | 171 | expressionAllowed = false; |
| 172 | statementExpected = false; | ||
| 173 | previousToken = 'literal'; | ||
| 132 | continue; | 174 | continue; |
| 133 | } | 175 | } |
| 134 | 176 | ||
| 135 | if (/[A-Za-z_$]/.test(c)) { | 177 | if (/[A-Za-z_$]/.test(c)) { |
| 136 | const start = i++; | 178 | const start = i++; |
| 137 | while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++; | 179 | while (i < source.length && /[A-Za-z0-9_$]/.test(source[i])) i++; |
| 138 | expressionAllowed = expressionKeywords.has(source.slice(start, i)); | 180 | const word = source.slice(start, i); |
| 181 | pendingControl = controlKeywords.has(word); | ||
| 182 | expressionAllowed = expressionKeywords.has(word); | ||
| 183 | statementExpected = statementKeywords.has(word); | ||
| 184 | previousToken = word; | ||
| 139 | continue; | 185 | continue; |
| 140 | } | 186 | } |
| 141 | if (/[0-9]/.test(c)) { | 187 | if (/[0-9]/.test(c)) { |
| 142 | i++; | 188 | i++; |
| 143 | while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++; | 189 | while (i < source.length && /[A-Za-z0-9_.]/.test(source[i])) i++; |
| 144 | expressionAllowed = false; | 190 | expressionAllowed = false; |
| 191 | statementExpected = false; | ||
| 192 | previousToken = 'number'; | ||
| 145 | continue; | 193 | continue; |
| 146 | } | 194 | } |
| 147 | 195 | ||
| 148 | if (c === '{') { | 196 | if (c === '(') { |
| 149 | if (templateExpression) braceDepth++; | 197 | parenKinds.push(pendingControl ? 'control' : 'expression'); |
| 198 | pendingControl = false; | ||
| 199 | i++; | ||
| 200 | expressionAllowed = true; | ||
| 201 | statementExpected = false; | ||
| 202 | previousToken = '('; | ||
| 203 | } else if (c === ')') { | ||
| 204 | const kind = parenKinds.pop() ?? 'expression'; | ||
| 205 | i++; | ||
| 206 | expressionAllowed = kind === 'control'; | ||
| 207 | statementExpected = kind === 'control'; | ||
| 208 | previousToken = kind === 'control' ? 'control-close' : ')'; | ||
| 209 | } else if (c === '{') { | ||
| 210 | const kind = statementExpected || !expressionAllowed || previousToken === '=>' | ||
| 211 | ? 'block' | ||
| 212 | : 'object'; | ||
| 213 | braceKinds.push(kind); | ||
| 150 | i++; | 214 | i++; |
| 151 | expressionAllowed = true; | 215 | expressionAllowed = true; |
| 216 | statementExpected = kind === 'block'; | ||
| 217 | previousToken = '{'; | ||
| 152 | } else if (c === '}') { | 218 | } else if (c === '}') { |
| 153 | if (templateExpression) braceDepth--; | 219 | const kind = braceKinds.pop() ?? 'block'; |
| 154 | i++; | 220 | i++; |
| 155 | expressionAllowed = false; | 221 | expressionAllowed = kind === 'block'; |
| 156 | } else if (c === ')' || c === ']') { | 222 | statementExpected = kind === 'block'; |
| 223 | previousToken = kind === 'block' ? 'block-close' : 'object-close'; | ||
| 224 | } else if (c === '[') { | ||
| 225 | i++; | ||
| 226 | expressionAllowed = true; | ||
| 227 | statementExpected = false; | ||
| 228 | previousToken = '['; | ||
| 229 | } else if (c === ']') { | ||
| 157 | i++; | 230 | i++; |
| 158 | expressionAllowed = false; | 231 | expressionAllowed = false; |
| 232 | statementExpected = false; | ||
| 233 | previousToken = ']'; | ||
| 159 | } else if (c === '.' || ((c === '+' || c === '-') && next === c)) { | 234 | } else if (c === '.' || ((c === '+' || c === '-') && next === c)) { |
| 160 | i += next === c ? 2 : 1; | 235 | i += next === c ? 2 : 1; |
| 161 | expressionAllowed = false; | 236 | expressionAllowed = false; |
| 237 | statementExpected = false; | ||
| 238 | previousToken = c === '.' ? '.' : c + next; | ||
| 162 | } else if (c === '/') { | 239 | } else if (c === '/') { |
| 163 | i += next === '=' ? 2 : 1; | 240 | i += next === '=' ? 2 : 1; |
| 164 | expressionAllowed = true; | 241 | expressionAllowed = true; |
| 242 | statementExpected = false; | ||
| 243 | previousToken = '/'; | ||
| 244 | } else if (c === ';') { | ||
| 245 | i++; | ||
| 246 | expressionAllowed = true; | ||
| 247 | statementExpected = true; | ||
| 248 | previousToken = ';'; | ||
| 249 | } else if (c === ',') { | ||
| 250 | i++; | ||
| 251 | expressionAllowed = true; | ||
| 252 | statementExpected = false; | ||
| 253 | previousToken = ','; | ||
| 254 | } else if (c === ':' || c === '?') { | ||
| 255 | i++; | ||
| 256 | expressionAllowed = true; | ||
| 257 | statementExpected = c === ':' && braceKinds.at(-1) === 'block'; | ||
| 258 | previousToken = c; | ||
| 259 | } else if (c === '=' && next === '>') { | ||
| 260 | i += 2; | ||
| 261 | expressionAllowed = true; | ||
| 262 | statementExpected = true; | ||
| 263 | previousToken = '=>'; | ||
| 165 | } else { | 264 | } else { |
| 166 | i++; | 265 | i++; |
| 167 | expressionAllowed = true; | 266 | expressionAllowed = true; |
| 267 | statementExpected = false; | ||
| 268 | previousToken = c; | ||
| 168 | } | 269 | } |
| 169 | } | 270 | } |
| 170 | }; | 271 | }; |
| @@ -173,8 +274,22 @@ function maskJsComments(source) { | |||
| 173 | return out.join(''); | 274 | return out.join(''); |
| 174 | } | 275 | } |
| 175 | 276 | ||
| 277 | function balancedBodiesAfter(source, headerPattern) { | ||
| 278 | return [...source.matchAll(headerPattern)].map((match) => { | ||
| 279 | const open = match.index + match[0].lastIndexOf('{'); | ||
| 280 | let depth = 1; | ||
| 281 | for (let at = open + 1; at < source.length; at++) { | ||
| 282 | if (source[at] === '{') depth++; | ||
| 283 | else if (source[at] === '}' && --depth === 0) { | ||
| 284 | return { body: source.slice(open + 1, at), start: open, end: at + 1 }; | ||
| 285 | } | ||
| 286 | } | ||
| 287 | return { body: null, start: open, end: source.length }; | ||
| 288 | }); | ||
| 289 | } | ||
| 290 | |||
| 176 | function wasmCalls(source) { | 291 | function wasmCalls(source) { |
| 177 | const executable = maskJsComments(source); | 292 | const executable = executableJsTokens(source); |
| 178 | return [...new Set( | 293 | return [...new Set( |
| 179 | [...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), | 294 | [...executable.matchAll(/\bcore\.(mux_[a-z_0-9]+)/g)].map((m) => m[1]), |
| 180 | )].sort(); | 295 | )].sort(); |
| @@ -444,8 +559,8 @@ async function main() { | |||
| 444 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); | 559 | const shell = fs.readFileSync(path.join(__dirname, 'mux.js'), 'utf8'); |
| 445 | 560 | ||
| 446 | // Mutation fixture for the source checks below. A correct-looking route | 561 | // Mutation fixture for the source checks below. A correct-looking route |
| 447 | // in either kind of comment must be invisible, while comment delimiters | 562 | // in either kind of comment must be invisible, while live literal |
| 448 | // inside live literals must survive unchanged. | 563 | // boundaries remain valid after their non-executable contents are masked. |
| 449 | const lexicalFixture = [ | 564 | const lexicalFixture = [ |
| 450 | '// const MSG = { term_modes: 0x8d, term_event: 0x8f };', | 565 | '// const MSG = { term_modes: 0x8d, term_event: 0x8f };', |
| 451 | '/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */', | 566 | '/* const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 }; */', |
| @@ -459,7 +574,7 @@ async function main() { | |||
| 459 | 'const templateLiteral = `// literal /* template */`;', | 574 | 'const templateLiteral = `// literal /* template */`;', |
| 460 | "const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail", | 575 | "const regexLiteral = /[\"']https?:\\/\\/host/; // remove this tail", |
| 461 | ].join('\n'); | 576 | ].join('\n'); |
| 462 | const executableFixture = maskJsComments(lexicalFixture); | 577 | const executableFixture = executableJsTokens(lexicalFixture); |
| 463 | check('source masker rejects line-comment declaration decoy', /\bconst MSG\b/.test(executableFixture), false); | 578 | 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); | 579 | 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); | 580 | check('source masker rejects commented semantic-route decoy', /\bcase MSG\.term_modes\b/.test(executableFixture), false); |
| @@ -467,30 +582,126 @@ async function main() { | |||
| 467 | check('source masker rejects commented placeholder decoy', /\bonClipboardEffect\s*\(/.test(executableFixture), false); | 582 | 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); | 583 | 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); | 584 | 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); | 585 | check('source masker excludes string literal contents', executableFixture.includes('https://host'), false); |
| 471 | check('source masker preserves template comment text', executableFixture.includes('`// literal /* template */`'), true); | 586 | check('source masker excludes template raw contents', executableFixture.includes('literal /* template */'), false); |
| 472 | check('source masker preserves regex comment text', executableFixture.includes('/[\"\']https?:\\/\\/host/'), true); | 587 | check('source masker excludes regex body contents', executableFixture.includes('https?:\\/\\/host'), false); |
| 588 | let lexicalFixtureCompiles = true; | ||
| 589 | try { new vm.Script(executableFixture); } catch (_) { lexicalFixtureCompiles = false; } | ||
| 590 | check('source masker preserves literal syntactic boundaries', lexicalFixtureCompiles, true); | ||
| 591 | |||
| 592 | const completeDecoy = [ | ||
| 593 | 'const MSG = { term_modes: 0x8d, term_event: 0x8f };', | ||
| 594 | 'const CLIENT_ACTION = { ignored: 0, terminalModes: 1, clipboard: 2, bell: 3 };', | ||
| 595 | 'case MSG.term_modes: case MSG.term_event: { if (!this.stage(payload)) return;', | ||
| 596 | 'const action = this.core.mux_client_frame(type, payload.length);', | ||
| 597 | 'if (action === CLIENT_ACTION.clipboard) this.onClipboardEffect(); return; }', | ||
| 598 | 'sendPaste(text) { if (this.core.mux_paste_begin() > 0) this.sendText(text);', | ||
| 599 | 'try { this.sendText(text); } finally { this.core.mux_paste_end(); } }', | ||
| 600 | 'onClipboardEffect() {} this.core.mux_client_frame(type, payload.length);', | ||
| 601 | ].join(' '); | ||
| 602 | const literalDecoys = [ | ||
| 603 | `const stringDecoy = '${completeDecoy}';`, | ||
| 604 | `const templateDecoy = \`${completeDecoy}\`;`, | ||
| 605 | `const regexDecoy = /${completeDecoy}/;`, | ||
| 606 | ].join('\n'); | ||
| 607 | let literalDecoysCompile = true; | ||
| 608 | try { new vm.Script(literalDecoys); } catch (_) { literalDecoysCompile = false; } | ||
| 609 | check('complete literal decoy attack is valid JavaScript', literalDecoysCompile, true); | ||
| 610 | const executableLiteralDecoys = executableJsTokens(literalDecoys); | ||
| 611 | check('literal decoys add no MSG declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []).length, 0); | ||
| 612 | check('literal decoys add no CLIENT_ACTION declaration', (executableLiteralDecoys.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []).length, 0); | ||
| 613 | check( | ||
| 614 | 'literal decoys add no semantic route body', | ||
| 615 | balancedBodiesAfter(executableLiteralDecoys, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, | ||
| 616 | 0, | ||
| 617 | ); | ||
| 618 | check( | ||
| 619 | 'literal decoys add no paste method body', | ||
| 620 | balancedBodiesAfter(executableLiteralDecoys, /\bsendPaste\s*\([^)]*\)\s*\{/g).length, | ||
| 621 | 0, | ||
| 622 | ); | ||
| 623 | check( | ||
| 624 | 'literal decoys add no placeholder body', | ||
| 625 | balancedBodiesAfter(executableLiteralDecoys, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g).length, | ||
| 626 | 0, | ||
| 627 | ); | ||
| 628 | check('WASM-call scan rejects all literal decoys', wasmCalls(literalDecoys).includes('mux_client_frame'), false); | ||
| 629 | |||
| 630 | const templateExpressionFixture = [ | ||
| 631 | 'const nested = `raw mux_raw_decoy ${', | ||
| 632 | 'this.core.mux_live_call("mux_string_decoy" /* mux_comment_decoy */)', | ||
| 633 | '}`;', | ||
| 634 | 'const flagged = /mux_regex_decoy/gim;', | ||
| 635 | ].join(' '); | ||
| 636 | const executableTemplateExpression = executableJsTokens(templateExpressionFixture); | ||
| 637 | check('template raw text is not searchable', /mux_raw_decoy/.test(executableTemplateExpression), false); | ||
| 638 | check('template expression code remains live', /\.mux_live_call\s*\(/.test(executableTemplateExpression), true); | ||
| 639 | check('nested string content is not searchable', /mux_string_decoy/.test(executableTemplateExpression), false); | ||
| 640 | check('nested comment content is not searchable', /mux_comment_decoy/.test(executableTemplateExpression), false); | ||
| 641 | check('regex body is not searchable', /mux_regex_decoy/.test(executableTemplateExpression), false); | ||
| 642 | check('regex flags are not searchable', /\bgim\b/.test(executableTemplateExpression), false); | ||
| 643 | |||
| 644 | const slashFixture = [ | ||
| 645 | 'let ok = true, x = "/", left = 6, right = 3;', | ||
| 646 | 'if (ok) /[//]/.test(x);', | ||
| 647 | 'if (ok) {} /[/*]/.test(x);', | ||
| 648 | 'const quotient = left / right;', | ||
| 649 | 'const objectQuotient = {} / 2;', | ||
| 650 | ].join('\n'); | ||
| 651 | let rawSlashFixtureCompiles = true; | ||
| 652 | try { new vm.Script(slashFixture); } catch (_) { rawSlashFixtureCompiles = false; } | ||
| 653 | check('regex/control and division attack is valid JavaScript', rawSlashFixtureCompiles, true); | ||
| 654 | const executableSlashes = executableJsTokens(slashFixture); | ||
| 655 | let slashFixtureCompiles = true; | ||
| 656 | try { new vm.Script(executableSlashes); } catch (_) { slashFixtureCompiles = false; } | ||
| 657 | check('masked regex/control and division fixture remains valid JS', slashFixtureCompiles, true); | ||
| 658 | check('regex after control and block remains live', (executableSlashes.match(/\.test\s*\(/g) ?? []).length, 2); | ||
| 659 | check('division after a value remains live', /left\s*\/\s*right/.test(executableSlashes), true); | ||
| 660 | check('division after an object expression remains live', /\{\}\s*\/\s*2/.test(executableSlashes), true); | ||
| 661 | |||
| 662 | const neighborFixture = executableJsTokens([ | ||
| 663 | 'case MSG.term_modes:', | ||
| 664 | 'case MSG.term_event: { if (ready) { route(); } }', | ||
| 665 | 'case MSG.unrelated: { return; }', | ||
| 666 | 'case MSG.pty_mode: return;', | ||
| 667 | 'sendPaste(text) { try { if (text) { this.sendText(text); } } finally { finish(); } }', | ||
| 668 | 'unrelatedMethod() { return 1; }', | ||
| 669 | 'sendResizeIfDiffers() {}', | ||
| 670 | ].join('\n')); | ||
| 671 | check( | ||
| 672 | 'semantic extraction ignores a later unrelated case', | ||
| 673 | balancedBodiesAfter(neighborFixture, /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g).length, | ||
| 674 | 1, | ||
| 675 | ); | ||
| 676 | check( | ||
| 677 | 'paste extraction ignores a later unrelated method', | ||
| 678 | balancedBodiesAfter(neighborFixture, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g).length, | ||
| 679 | 1, | ||
| 680 | ); | ||
| 473 | 681 | ||
| 474 | const executableShell = maskJsComments(shell); | 682 | const executableShell = executableJsTokens(shell); |
| 683 | let executableShellCompiles = true; | ||
| 684 | try { new vm.Script(executableShell); } catch (_) { executableShellCompiles = false; } | ||
| 685 | check('real shell executable-token representation remains valid JS', executableShellCompiles, true); | ||
| 475 | 686 | ||
| 476 | // The browser must route sampled terminal state and host effects through | 687 | // The browser must route sampled terminal state and host effects through |
| 477 | // the same semantic core as the CLI. Pin the real page source here: this | 688 | // the same semantic core as the CLI. Pin the real page source here: this |
| 478 | // verifier otherwise exercises only the WASM half of that handshake. | 689 | // verifier otherwise exercises only the WASM half of that handshake. |
| 479 | const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []; | 690 | const msgBindings = executableShell.match(/\b(?:const|let|var)\s+MSG\b/g) ?? []; |
| 480 | const msgObjects = [...executableShell.matchAll(/\bconst MSG\s*=\s*\{([\s\S]*?)\};/g)]; | 691 | const msgObjects = balancedBodiesAfter(executableShell, /\bconst MSG\s*=\s*\{/g); |
| 481 | check('shell has exactly one live MSG declaration', msgBindings.length, 1); | 692 | check('shell has exactly one live MSG declaration', msgBindings.length, 1); |
| 482 | check('shell MSG declaration has the expected object shape', msgObjects.length, 1); | 693 | check('shell MSG declaration has the expected object shape', msgObjects.length, 1); |
| 483 | const msgDecl = msgObjects.length === 1 ? msgObjects[0][1] : ''; | 694 | const msgDecl = msgObjects.length === 1 ? (msgObjects[0].body ?? '') : ''; |
| 484 | check('shell has one term_modes property', (msgDecl.match(/\bterm_modes\s*:/g) ?? []).length, 1); | 695 | 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); | 696 | check('shell has one term_event property', (msgDecl.match(/\bterm_event\s*:/g) ?? []).length, 1); |
| 486 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); | 697 | check('shell declares term_modes wire code', /\bterm_modes\s*:\s*0x8d\b/.test(msgDecl), true); |
| 487 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); | 698 | check('shell declares term_event wire code', /\bterm_event\s*:\s*0x8f\b/.test(msgDecl), true); |
| 488 | 699 | ||
| 489 | const actionBindings = executableShell.match(/\b(?:const|let|var)\s+CLIENT_ACTION\b/g) ?? []; | 700 | 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)]; | 701 | const actionObjects = balancedBodiesAfter(executableShell, /\bconst CLIENT_ACTION\s*=\s*\{/g); |
| 491 | check('shell has exactly one live CLIENT_ACTION declaration', actionBindings.length, 1); | 702 | 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); | 703 | check('shell CLIENT_ACTION declaration has the expected object shape', actionObjects.length, 1); |
| 493 | const actionDecl = actionObjects.length === 1 ? actionObjects[0][1] : ''; | 704 | const actionDecl = actionObjects.length === 1 ? (actionObjects[0].body ?? '') : ''; |
| 494 | for (const name of ['ignored', 'terminalModes', 'clipboard', 'bell']) { | 705 | 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); | 706 | check(`shell has one ${name} client action`, (actionDecl.match(new RegExp(`\\b${name}\\s*:`, 'g')) ?? []).length, 1); |
| 496 | } | 707 | } |
| @@ -503,13 +714,14 @@ async function main() { | |||
| 503 | true, | 714 | true, |
| 504 | ); | 715 | ); |
| 505 | 716 | ||
| 506 | const semanticRoutes = [...executableShell.matchAll( | 717 | const semanticRoutes = balancedBodiesAfter( |
| 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, | 718 | executableShell, |
| 508 | )]; | 719 | /case\s+MSG\.term_modes\s*:\s*case\s+MSG\.term_event\s*:\s*\{/g, |
| 720 | ); | ||
| 509 | check('shell has one live term_modes case', (executableShell.match(/case\s+MSG\.term_modes\s*:/g) ?? []).length, 1); | 721 | 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); | 722 | 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); | 723 | check('shell shares exactly one semantic frame route', semanticRoutes.length, 1); |
| 512 | const semanticCases = semanticRoutes.length === 1 ? semanticRoutes[0][1] : ''; | 724 | const semanticCases = semanticRoutes.length === 1 ? (semanticRoutes[0].body ?? '') : ''; |
| 513 | check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); | 725 | check('shell stages a semantic payload exactly once', (semanticCases.match(/this\.stage\(payload\)/g) ?? []).length, 1); |
| 514 | check( | 726 | check( |
| 515 | 'shell stages semantic payload before WASM', | 727 | 'shell stages semantic payload before WASM', |
| @@ -534,18 +746,16 @@ async function main() { | |||
| 534 | /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell), | 746 | /this\.(?:bracketedPaste|bracketed_paste)\s*=/.test(executableShell), |
| 535 | false, | 747 | false, |
| 536 | ); | 748 | ); |
| 537 | const clipboardMethods = executableShell.match(/\bonClipboardEffect\s*\([^)]*\)\s*\{/g) ?? []; | 749 | const clipboardMethods = balancedBodiesAfter(executableShell, /\bonClipboardEffect\s*\([^)]*\)\s*\{/g); |
| 538 | const emptyClipboardMethods = executableShell.match(/\bonClipboardEffect\s*\(\s*\)\s*\{\s*\}/g) ?? []; | 750 | const emptyClipboardMethods = clipboardMethods.filter((method) => method.body?.trim() === ''); |
| 539 | check('shell has exactly one live clipboard effect method', clipboardMethods.length, 1); | 751 | 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); | 752 | check('shell clipboard effect is the one empty placeholder', emptyClipboardMethods.length, 1); |
| 541 | 753 | ||
| 542 | const sendPasteMethods = executableShell.match(/\bsendPaste\s*\([^)]*\)\s*\{/g) ?? []; | 754 | const sendPasteMethods = balancedBodiesAfter(executableShell, /\bsendPaste\s*\([^)]*\)\s*\{/g); |
| 543 | const sendPasteBodies = [...executableShell.matchAll( | 755 | const sendPasteBodies = balancedBodiesAfter(executableShell, /\bsendPaste\s*\(\s*text\s*\)\s*\{/g); |
| 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); | 756 | check('shell has exactly one live sendPaste method', sendPasteMethods.length, 1); |
| 547 | check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1); | 757 | check('shell sendPaste has the expected text contract', sendPasteBodies.length, 1); |
| 548 | const sendPaste = sendPasteBodies.length === 1 ? sendPasteBodies[0][1] : ''; | 758 | const sendPaste = sendPasteBodies.length === 1 ? (sendPasteBodies[0].body ?? '') : ''; |
| 549 | check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1); | 759 | check('shell paste has one begin call', (sendPaste.match(/mux_paste_begin\(\)/g) ?? []).length, 1); |
| 550 | check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1); | 760 | check('shell paste has one end call', (sendPaste.match(/mux_paste_end\(\)/g) ?? []).length, 1); |
| 551 | check( | 761 | check( |