diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index fcf7412031..1276cba9b5 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -227,8 +227,23 @@ try { const candidate = join(candidateRoot, "module.wasm"); await writeFile(join(candidateRoot, "source.wat"), "(module (memory 2))"); await tool("wat2wasm", ["source.wat", "-o", "module.wasm"], candidateRoot); - const glueSource = - 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);'; + const glueValues = [ + "./module.wasm", + "https://example.invalid/module.wasm?version=1", + ...["\n", "\r", "\r\n", "\u2028", "\u2029"].flatMap((separator) => [ + `https://example.invalid/module.wasm?query=a${separator}b`, + `https://example.invalid/module.wasm#fragment=a${separator}b`, + ]), + ]; + const glueLiterals = glueValues.map((value) => + JSON.stringify(value) + .replace(/\u2028/gu, "\\u2028") + .replace(/\u2029/gu, "\\u2029"), + ); + const glueSource = [ + `fetch(${glueLiterals[0]}); new URL(${glueLiterals[1]}, import.meta.url);`, + ...glueLiterals.slice(2).map((literal) => `fetch(${literal});`), + ].join("\n"); const glueSources = [ glueSource, `\uFEFF${glueSource}`, @@ -283,21 +298,32 @@ try { assert.equal(observed.artifact.bytes, Buffer.byteLength(source)); assert.equal(await readFile(gluePaths[index], "utf8"), source); assert.equal(observed.parse_status, "complete"); + assert.deepEqual( + observed.references.map(({ value }) => value), + glueValues, + ); assert.deepEqual( observed.references.map(({ candidate_paths }) => candidate_paths), - [[path], [path, candidate]], + glueValues.map((_, index) => + index === 0 ? [path] : [path, candidate], + ), + ); + assert.deepEqual( + observed.references.map(({ association }) => association), + glueValues.map((_, index) => + index === 0 ? "local-path-candidate" : "basename-candidates", + ), ); assert.deepEqual( observed.references.map(({ line, column }) => ({ line, column })), - ["./module.wasm", "https://example.invalid/module.wasm?version=1"].map( - (value) => { - const offset = source.indexOf(`"${value}"`); - return { - line: source.slice(0, offset).split("\n").length, - column: offset - source.lastIndexOf("\n", offset) - 1, - }; - }, - ), + glueLiterals.map((literal) => { + const offset = source.indexOf(literal); + assert.notEqual(offset, -1); + return { + line: source.slice(0, offset).split("\n").length, + column: offset - source.lastIndexOf("\n", offset) - 1, + }; + }), ); } assert.notEqual(report.candidates[0].sha256, report.candidates[1].sha256); diff --git a/src/wasm/WabtOutput.test.ts b/src/wasm/WabtOutput.test.ts index 8df9508481..96ff632264 100644 --- a/src/wasm/WabtOutput.test.ts +++ b/src/wasm/WabtOutput.test.ts @@ -421,11 +421,58 @@ it("matches a CLI-selected ./ module exactly against its glue reference", () => ]); }); +it.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "retains complete .wasm query and fragment literals containing %j", + (separator) => { + const candidates = ["/artifacts/module.wasm", "/other/module.wasm"]; + for (const qualifier of ["?query=", "#fragment="]) { + const value = `https://example.invalid/module.wasm${qualifier}a${separator}b`; + const result = associateWasmGlue( + `fetch(${JSON.stringify(value)});`, + "/artifacts/glue.js", + candidates, + ); + expect(result).toEqual({ + parse_status: "complete", + references: [ + { + value, + line: 1, + column: 6, + candidate_paths: candidates, + association: "basename-candidates", + evidence_kind: "static-literal", + }, + ], + }); + } + }, +); + +it.each(["\n", "\r", "\r\n", "\u2028", "\u2029"])( + "does not accept a path-ending separator without a query or fragment: %j", + (separator) => { + const value = `https://example.invalid/module.wasm${separator}`; + expect( + associateWasmGlue(`fetch(${JSON.stringify(value)});`, "/glue.js", []), + ).toEqual({ parse_status: "complete", references: [] }); + }, +); + it("tests long multi-line glue literals for a .wasm suffix in linear time", () => { const value = `${".wasm#".repeat(32_000)}\nend`; const started = performance.now(); + const result = associateWasmGlue( + `const s = ${JSON.stringify(value)};`, + "/glue.js", + [], + ); + expect(result.parse_status).toBe("complete"); + expect(result.references).toHaveLength(1); + expect(result.references[0]?.value).toBe(value); + const negative = `${".wasm!".repeat(32_000)}\nend`; expect( - associateWasmGlue(`const s = ${JSON.stringify(value)};`, "/glue.js", []), + associateWasmGlue(`const s = ${JSON.stringify(negative)};`, "/glue.js", []), ).toEqual({ parse_status: "complete", references: [] }); expect(performance.now() - started).toBeLessThan(1_000); expect( diff --git a/src/wasm/WasmGlueAdapter.ts b/src/wasm/WasmGlueAdapter.ts index e860efff84..b0f22c3b15 100644 --- a/src/wasm/WasmGlueAdapter.ts +++ b/src/wasm/WasmGlueAdapter.ts @@ -5,21 +5,12 @@ import { traverseJavaScriptAst } from "../domain/javascript/javascriptSemanticTr import { absoluteCallerPath } from "../filesystem/callerPath.js"; /** - * `.` cannot cross a line terminator and `$` only matches at the end of the - * literal, so a `.wasm` suffix can only occur on its last line. Testing that - * line alone keeps long multi-line literals linear. + * Query/fragment content may contain line terminators. With dotAll, the first + * `.wasm?` or `.wasm#` consumes the rest without retrying overlapping suffixes; + * absent that qualifier, each candidate requires only a fixed suffix check. */ const endsWithWasmReference = (value: string): boolean => - /\.wasm(?:[?#].*)?$/iu.test( - value.slice( - Math.max( - value.lastIndexOf("\n"), - value.lastIndexOf("\r"), - value.lastIndexOf("
"), - value.lastIndexOf("
"), - ) + 1, - ), - ); + /\.wasm(?:[?#].*)?$/isu.test(value); /** Associate visible literals with selected candidates without asserting data flow or URL identity. */ export const associateWasmGlue = (