diff --git a/scripts/verify-wasm-artifact.mjs b/scripts/verify-wasm-artifact.mjs index 305063ccc..5123d3400 100644 --- a/scripts/verify-wasm-artifact.mjs +++ b/scripts/verify-wasm-artifact.mjs @@ -190,12 +190,21 @@ 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 glue = join(fixtureRoot, "glue.js"); - await writeFile( - glue, - 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);', + const glueSource = + 'fetch("./module.wasm"); new URL("https://example.invalid/module.wasm?version=1", import.meta.url);'; + const glueSources = [ + glueSource, + `\uFEFF${glueSource}`, + `\uFEFF\n${glueSource}`, + `#!/usr/bin/env node\n${glueSource}`, + `\uFEFF#!/usr/bin/env node\n${glueSource}`, + ]; + const gluePaths = glueSources.map((_, index) => + join(fixtureRoot, `glue-${index}.js`), ); - const input = { path, glue_paths: [glue], candidate_paths: [candidate] }; + for (const [index, gluePath] of gluePaths.entries()) + await writeFile(gluePath, glueSources[index]); + const input = { path, glue_paths: gluePaths, candidate_paths: [candidate] }; assert.ok(ajv.validate(advertised.inputSchema, input)); const cliValue = await inspectCli(input); assert.equal(cliValue.category, undefined, JSON.stringify(cliValue)); @@ -219,11 +228,31 @@ try { assert.equal(report.wat.sha256, sha256(wat)); assert.equal(report.headers, `${headers}\n`); assert.equal(report.details, details.slice(1)); - assert.equal(report.glue[0].artifact.sha256, sha256(await readFile(glue))); - assert.deepEqual( - report.glue[0].references.map(({ candidate_paths }) => candidate_paths), - [[path], [path, candidate]], - ); + assert.equal(report.glue.length, glueSources.length); + for (const [index, source] of glueSources.entries()) { + const observed = report.glue[index]; + assert.equal(observed.artifact.path, gluePaths[index]); + assert.equal(observed.artifact.sha256, sha256(source)); + 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(({ candidate_paths }) => candidate_paths), + [[path], [path, candidate]], + ); + 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, + }; + }, + ), + ); + } assert.notEqual(report.candidates[0].sha256, report.candidates[1].sha256); for (const command of report.tool_profile.commands) assert.equal(command.sha256, sha256(await readFile(command.path))); diff --git a/src/wasm/WabtArtifactProvider.test.ts b/src/wasm/WabtArtifactProvider.test.ts index 161e542d2..7d1eca0f7 100644 --- a/src/wasm/WabtArtifactProvider.test.ts +++ b/src/wasm/WabtArtifactProvider.test.ts @@ -1,8 +1,10 @@ +import { createHash } from "node:crypto"; import { access, chmod, readFile, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { expect, it, vi } from "vitest"; import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; import { WabtArtifactProvider } from "./WabtArtifactProvider.js"; +import { wasmArtifactSchema } from "../domain/wasm/wasmArtifact.js"; import { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js"; import { PrivateRuntimeRootUnavailableError } from "../process/PrivateRuntimeRootUnavailableError.js"; import { spawnOwnedProviderProcess } from "../process/ProviderProcess.js"; @@ -271,6 +273,35 @@ it.each([false, true])( }); }, ); +const assertGlueObservations = ( + value: unknown, + inputs: readonly { readonly path: string; readonly source: string }[], + selectedPath: string, +): void => { + const glue = wasmArtifactSchema.parse(value).glue; + expect(glue).toHaveLength(inputs.length); + for (const [index, { path, source }] of inputs.entries()) { + expect(glue[index]).toMatchObject({ + artifact: { + path, + sha256: createHash("sha256").update(source).digest("hex"), + bytes: Buffer.byteLength(source), + }, + parse_status: "complete", + references: [ + { + value: "./selected.wasm", + line: source.includes("\n") ? 2 : 1, + column: source.startsWith("\uFEFFfetch") ? 7 : 6, + candidate_paths: [selectedPath], + association: "local-path-candidate", + evidence_kind: "static-literal", + }, + ], + }); + } +}; + it.each([ "success", "invalid", @@ -371,10 +402,34 @@ it.each([ }; }, ); + const literal = 'fetch("./selected.wasm");'; + const glueInputs = ( + mode === "success" + ? [ + literal, + `\uFEFF${literal}`, + `\uFEFF\n${literal}`, + `#!/usr/bin/env node\n${literal}`, + `\uFEFF#!/usr/bin/env node\n${literal}`, + ] + : [] + ).map((source, index) => ({ + path: join(selectedRoot, `glue-${index}.js`), + source, + })); + await Promise.all( + glueInputs.map(({ path, source }) => writeFile(path, source)), + ); const result = await provider.inspect( - { path, glue_paths: [], candidate_paths: [] }, + { + path, + glue_paths: glueInputs.map(({ path }) => path), + candidate_paths: [], + }, { signal: controller.signal }, ); + if (mode === "success" && result.ok) + assertGlueObservations(result.value.result, glueInputs, path); assertModeOutcome(mode, result, path); if (mode === "cleanup") { await assertCleanupRecovery({ diff --git a/src/wasm/WabtArtifactProvider.ts b/src/wasm/WabtArtifactProvider.ts index 3be21b2a1..d706c97e8 100644 --- a/src/wasm/WabtArtifactProvider.ts +++ b/src/wasm/WabtArtifactProvider.ts @@ -268,9 +268,11 @@ export class WabtArtifactProvider implements WasmArtifactPort { undefined, this.#resources, ); - const source = new TextDecoder("utf-8", { fatal: true }).decode( - bytes.bytes, - ); + // Keep source characters intact so locations match the hashed artifact. + const source = new TextDecoder("utf-8", { + fatal: true, + ignoreBOM: true, + }).decode(bytes.bytes); glue.push({ artifact: { path, sha256: bytes.sha256, bytes: bytes.bytes.length }, ...associateWasmGlue( diff --git a/src/wasm/WasmGlueAdapter.ts b/src/wasm/WasmGlueAdapter.ts index 1f8713042..91c524270 100644 --- a/src/wasm/WasmGlueAdapter.ts +++ b/src/wasm/WasmGlueAdapter.ts @@ -8,7 +8,11 @@ export const associateWasmGlue = ( path: string, candidates: readonly string[], ) => { - const file = parseJavaScriptSource(source); + // Babel requires a hashbang at offset zero. Removing only its preceding BOM + // leaves reference coordinates intact: all code starts on a later line. + const file = parseJavaScriptSource( + source.startsWith("\uFEFF#!") ? source.slice(1) : source, + ); const references: { value: string; line: number;