diff --git a/src/browser/WebSourceMapFetcher.ts b/src/browser/WebSourceMapFetcher.ts index 47aa00aa6c..3e6dbe7e44 100644 --- a/src/browser/WebSourceMapFetcher.ts +++ b/src/browser/WebSourceMapFetcher.ts @@ -1,8 +1,5 @@ import * as t from "@babel/types"; -import { - decodeValidatedSourceMapLeaves, - isBeforeSourceMapLeafStop, -} from "../javascript/sourceMaps/DecodedSourceMap.js"; +import { decodeValidatedSourceMapLeaves } from "../javascript/sourceMaps/DecodedSourceMap.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js"; @@ -653,7 +650,6 @@ const normalizeSourceMap = ( for (const [line, row] of rows.entries()) { if ((line & 255) === 0) check(); for (const segment of row) { - if (!isBeforeSourceMapLeafStop(leaf, line, segment[0])) continue; if (segment.length === 1) continue; const rawSource = leaf.map.sources[segment[1]]; if (rawSource === undefined) continue; @@ -741,7 +737,10 @@ const normalizeSourceMap = ( return emptySourceMapItem( request, "invalid", - "Source-map JSON mappings could not be decoded safely.", + cause instanceof SourceMapFormatFailure && + cause.reason === "section-overlap" + ? cause.message + : "Source-map JSON mappings could not be decoded safely.", fetchedUrl, ); } diff --git a/src/javascript/sourceMaps/DecodedSourceMap.ts b/src/javascript/sourceMaps/DecodedSourceMap.ts index e9ed304e1a..cf431d6329 100644 --- a/src/javascript/sourceMaps/DecodedSourceMap.ts +++ b/src/javascript/sourceMaps/DecodedSourceMap.ts @@ -5,7 +5,7 @@ import { } from "./SourceMapFormat.js"; /** Determine whether a leaf-local mapping falls before its enclosing boundary. */ -export const isBeforeSourceMapLeafStop = ( +const isBeforeSourceMapLeafStop = ( leaf: SourceMapLeaf, line: number, column: number, @@ -40,6 +40,11 @@ export const decodeValidatedSourceMapLeaves = ( const generatedLine = leaf.offset.line + line; const generatedColumn = segment[0] + (line === 0 ? leaf.offset.column : 0); + if (!isBeforeSourceMapLeafStop(leaf, line, segment[0])) + throw new SourceMapFormatFailure( + "section-overlap", + "Embedded source-map mappings overlap the following section boundary.", + ); if ( !Number.isSafeInteger(generatedLine) || !Number.isSafeInteger(generatedColumn) || diff --git a/src/javascript/sourceMaps/SourceMapCodecProcess.ts b/src/javascript/sourceMaps/SourceMapCodecProcess.ts index cad362c57e..8cedb0a508 100644 --- a/src/javascript/sourceMaps/SourceMapCodecProcess.ts +++ b/src/javascript/sourceMaps/SourceMapCodecProcess.ts @@ -50,7 +50,11 @@ const execute = async (): Promise => { throw cause; return JSON.stringify({ state: "failure", - reason: cause instanceof SourceMapFormatFailure ? cause.reason : "format", + reason: + cause instanceof SourceMapFormatFailure && + cause.reason !== "section-overlap" + ? cause.reason + : "format", message: cause.message, }); } diff --git a/src/javascript/sourceMaps/SourceMapFormat.ts b/src/javascript/sourceMaps/SourceMapFormat.ts index 1db4aed60c..64579c63b1 100644 --- a/src/javascript/sourceMaps/SourceMapFormat.ts +++ b/src/javascript/sourceMaps/SourceMapFormat.ts @@ -55,7 +55,7 @@ export interface SourceMapStructureOptions { /** A decoder-profile failure with an actionable producer constraint. */ export class SourceMapFormatFailure extends Error { constructor( - readonly reason: "format" | "unsupported" | "limit", + readonly reason: "format" | "unsupported" | "limit" | "section-overlap", message: string, ) { super(message); diff --git a/src/javascript/sourceMaps/TraceSourceMap.test.ts b/src/javascript/sourceMaps/TraceSourceMap.test.ts index d2ca915fd5..51377e7ec5 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.test.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.test.ts @@ -310,7 +310,7 @@ describe("source-map layout admission", () => { { offset: { line: 0, column: 5 }, map: leaf }, ], }), - ).toThrow(/overlap/u); + ).toThrow(expect.objectContaining({ reason: "section-overlap" })); }); it("accepts absent optional names and null contents without changing reported identity", () => { expect( diff --git a/src/javascript/sourceMaps/TraceSourceMap.ts b/src/javascript/sourceMaps/TraceSourceMap.ts index d85f79b36a..56c188e95d 100644 --- a/src/javascript/sourceMaps/TraceSourceMap.ts +++ b/src/javascript/sourceMaps/TraceSourceMap.ts @@ -22,10 +22,7 @@ import { SourceMapFormatFailure, type SourceMapSourceDeclaration, } from "./SourceMapFormat.js"; -import { - decodeValidatedSourceMapLeaves, - isBeforeSourceMapLeafStop, -} from "./DecodedSourceMap.js"; +import { decodeValidatedSourceMapLeaves } from "./DecodedSourceMap.js"; /** Decode with the pinned upstream codec; preserve every equal-position candidate. */ export const traceSourceMap = ( @@ -35,14 +32,6 @@ export const traceSourceMap = ( ): WebSourceMapReport => { const inspected = inspectSourceMap(text); const decodedLeaves = decodeValidatedSourceMapLeaves(inspected.leaves, url); - for (const { leaf, rows } of decodedLeaves) - for (const [line, row] of rows.entries()) - for (const segment of row) - if (!isBeforeSourceMapLeafStop(leaf, line, segment[0])) - throw new SourceMapFormatFailure( - "format", - "Embedded source-map mappings overlap the following section boundary.", - ); let map: TraceMap; if (inspected.format === "regular") { const decoded = decodedLeaves[0]; diff --git a/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts b/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts index 3ed94f72df..7408a48354 100644 --- a/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts +++ b/tests/boundary/browser/webSourceMapNestedCoordinates.test.ts @@ -127,6 +127,7 @@ const cases = [ [1, 10, "b.ts"], ], dedicatedControl: false, + expectedStatus: "invalid", }, { name: "nested gap clips previous parent leaf", @@ -177,6 +178,7 @@ const cases = [ [1, 8, "b.ts"], ], dedicatedControl: false, + expectedStatus: "invalid", }, { name: "empty indexed sibling clips previous leaf", @@ -210,6 +212,7 @@ const cases = [ }, expected: [[1, 0, "a.ts"]], dedicatedControl: false, + expectedStatus: "invalid", }, { name: "depth-three same-line composition", @@ -307,12 +310,16 @@ const cases = [ [4, 1, "b.ts"], ], dedicatedControl: false, + expectedStatus: "invalid", }, ] as const; it.each(cases)( "preserves indexed coordinate contract: $name", - async ({ map, expected, dedicatedControl }) => { + async (testCase) => { + const { map, expected, dedicatedControl } = testCase; + const expectedStatus = + "expectedStatus" in testCase ? testCase.expectedStatus : "included"; const origin = "https://example.test"; const url = origin + "/app.js.map"; const text = JSON.stringify(map); @@ -340,6 +347,15 @@ it.each(cases)( { fetch: () => Promise.resolve(new Response(text, { status: 200 })) }, ); const item = result.items[0]; + if (expectedStatus === "invalid") { + expect(item).toMatchObject({ + status: "invalid", + artifact: null, + mappings: [], + limitation: expect.stringContaining("overlap"), + }); + return; + } if (item?.status !== "included") throw new Error(JSON.stringify(result)); expect( item.mappings.map((entry) => [ @@ -358,3 +374,76 @@ it.each(cases)( ]); }, ); + +it("rejects a source mapping that crosses an indexed section boundary", async () => { + const origin = "https://example.test"; + const url = `${origin}/app.js.map`; + const makeMap = (firstMappings: string) => ({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ["a.js"], + sourcesContent: ["x();"], + names: [], + mappings: firstMappings, + }, + }, + { + offset: { line: 0, column: 1 }, + map: { + version: 3, + sources: ["b.js"], + sourcesContent: ["y();"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const input = analyzeWebBundleInputSchema.parse({ + cdp_endpoint: "http://127.0.0.1:9222", + allowed_origins: [origin], + target_id: "page-1", + fetch_source_maps: true, + }); + const request = { + scriptKey: "scr_" + "1".repeat(64), + declaredUrl: url, + fetchUrl: url, + }; + const fetchMap = (map: ReturnType) => + fetchWebSourceMaps([request], input, undefined, { + fetch: () => + Promise.resolve(new Response(JSON.stringify(map), { status: 200 })), + }); + const overlapping = makeMap("AAAA,EAAA"); + const overlapText = JSON.stringify(overlapping); + expect(() => + traceSourceMap(overlapText, url, { line: 1, column: 2 }), + ).toThrow(/overlap/u); + const overlappingResult = await fetchMap(overlapping); + expect(overlappingResult).toMatchObject({ + status: "unavailable", + requested: 1, + processed: 1, + }); + expect(overlappingResult.items[0]).toMatchObject({ + status: "invalid", + artifact: null, + mappings: [], + limitation: expect.stringContaining("overlap"), + }); + + const controlResult = await fetchMap(makeMap("AAAA")); + expect(controlResult.items[0]).toMatchObject({ + status: "included", + limitation: null, + mappings: [ + { generated_line: 1, generated_column: 0, source: `${origin}/a.js` }, + { generated_line: 1, generated_column: 1, source: `${origin}/b.js` }, + ], + }); +}); diff --git a/tests/boundary/javascript/sourceMapDecoder.test.ts b/tests/boundary/javascript/sourceMapDecoder.test.ts index 974fa33a34..d388c31ed5 100644 --- a/tests/boundary/javascript/sourceMapDecoder.test.ts +++ b/tests/boundary/javascript/sourceMapDecoder.test.ts @@ -1,6 +1,7 @@ import { fileURLToPath } from "node:url"; import { access } from "node:fs/promises"; import { expect, it, vi } from "vitest"; +import { AnalysisInputError } from "../../../src/domain/analysisErrorCore.js"; import { SourceMapDecoder } from "../../../src/javascript/sourceMaps/SourceMapDecoder.js"; import { SOURCE_MAP_CODEC_MEMORY_LIMIT_BYTES, @@ -139,6 +140,47 @@ it.each([ if (response.ok) throw new Error("expected failed map"); expect(response.error._tag).toBe(tag); }); + +it("keeps indexed section overlap typed as a malformed source map", async () => { + const text = JSON.stringify({ + version: 3, + sections: [ + { + offset: { line: 0, column: 0 }, + map: { + version: 3, + sources: ["a.js"], + sourcesContent: ["x();"], + names: [], + mappings: "AAAA,EAAA", + }, + }, + { + offset: { line: 0, column: 1 }, + map: { + version: 3, + sources: ["b.js"], + sourcesContent: ["y();"], + names: [], + mappings: "AAAA", + }, + }, + ], + }); + const response = await realDecoder().trace({ ...input, text }); + if (response.ok) throw new Error("expected overlapping map rejection"); + expect(response.error).toBeInstanceOf(AnalysisInputError); + if (!(response.error instanceof AnalysisInputError)) + throw new Error("expected malformed source-map input error"); + expect(response.error.issues).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + reason: "invalid_format", + message: expect.stringContaining("overlap"), + }), + ]), + ); +}); it("reports cleanup failure with the actual owning operation", async () => { const response = await realDecoder({ createRuntime: async () => {