diff --git a/src/domain/javascript/javascriptSourceMapPaths.ts b/src/domain/javascript/javascriptSourceMapPaths.ts new file mode 100644 index 0000000000..8b46c4363e --- /dev/null +++ b/src/domain/javascript/javascriptSourceMapPaths.ts @@ -0,0 +1,69 @@ +import { posix } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +interface SourceMapPath { + readonly value: string; + readonly scope: "artifact-relative" | "suffix"; +} + +/** Resolve a source-map URL reference without changing its recorded identity. */ +export const resolveJavaScriptSourceMapPath = ( + source: string, + mapPath?: string, +): SourceMapPath | null => { + const portable = source.replaceAll("\\", "/"); + const windowsPath = /^[a-z]:\//iu.test(portable) || source.startsWith("\\\\"); + // Preserve parent traversal rather than letting URL resolution clamp at '/'. + const anchor = `/${"source/".repeat(portable.split("/").length + 1)}`; + const directory = posix.join( + anchor, + posix.dirname(mapPath?.replaceAll("\\", "/") ?? "."), + ); + const base = pathToFileURL(`${directory}/`, { windows: false }); + const absolute = windowsPath ? windowsFileUrl(source) : URL.parse(source); + const resolved = windowsPath ? absolute : URL.parse(source, base); + if ( + resolved === null || + !resolved.pathname.startsWith("/") || + resolved.pathname.endsWith("/") + ) + return null; + const relative = + absolute === null + ? !portable.startsWith("/") + : absolute.href !== resolved.href; + const path = decodedUrlPath(resolved); + if (path === null) return null; + const value = relative + ? posix.relative(anchor, path) + : path.replace(/^\/+/, ""); + if (value === "" || value === ".." || value.startsWith("../")) return null; + return { + value, + scope: relative && mapPath !== undefined ? "artifact-relative" : "suffix", + }; +}; + +const windowsFileUrl = (path: string): URL | null => { + try { + return pathToFileURL(path, { windows: true }); + } catch (cause: unknown) { + if (cause instanceof TypeError) return null; + throw cause; + } +}; + +const decodedUrlPath = (url: URL): string | null => { + // Encoded separators must not manufacture a different filesystem hierarchy. + if (/%(?:2f|5c)/iu.test(url.pathname)) return null; + try { + if (url.protocol === "file:") + return fileURLToPath(url, { + windows: url.hostname !== "" && url.hostname !== "localhost", + }).replaceAll("\\", "/"); + return `${url.host}${decodeURIComponent(url.pathname)}`; + } catch (cause: unknown) { + if (cause instanceof URIError || cause instanceof TypeError) return null; + throw cause; + } +}; diff --git a/src/domain/javascript/sourceToBundleComparison.test.ts b/src/domain/javascript/sourceToBundleComparison.test.ts index 0610da1da3..6bc764a1e1 100644 --- a/src/domain/javascript/sourceToBundleComparison.test.ts +++ b/src/domain/javascript/sourceToBundleComparison.test.ts @@ -5,6 +5,7 @@ import { createJavaScriptApplicationNode, } from "./javascriptApplicationGraph.js"; import { createHistoricalSourceGraph } from "../referenceSourceGraph.js"; +import { compareCodePoints } from "../canonicalOrdering.js"; import { compareSourceToBundle } from "./sourceToBundleComparison.js"; import { SOURCE_TO_BUNDLE_SIGNAL_WEIGHTS, @@ -187,44 +188,47 @@ describe("source to bundle digest changes", () => { }); describe("source to bundle path syntax", () => { - it.each(["src/modified#part.ts", "src/modified?part.ts"])( - "preserves literal filesystem punctuation in %s", - (path) => { - const node = moduleNode("punctuated", { path }); - const graph = createJavaScriptApplicationGraph({ - schema: "JavaScriptApplicationGraph", - root_node_ids: [node.node_id], - nodes: [node], - edges: [], - coverage: { - status: "complete", - truncated: false, - omitted_count: 0, - limits: [], - }, - limitations: [], - }); - const result = compareSourceToBundle({ - reference: historicalGraph("complete", [path]), - application: { - evidenceId: EVIDENCE_ID, - rootArtifactSha256: HASH.artifact, - graph, - }, - }); - expect(item(result, path)).toMatchObject({ - status: "unknown", - current_node_ids: [node.node_id], - candidates: [ - expect.objectContaining({ - signals: expect.arrayContaining([ - expect.objectContaining({ kind: "current-path-exact" }), - ]), - }), - ], - }); - }, - ); + it.each([ + "src/modified#part.ts", + "src/modified?part.ts", + "pkg:app/src/modified#part.ts", + "pkg:app/src/modified?part.ts", + "data:/src/modified.ts", + ])("preserves literal filesystem punctuation in %s", (path) => { + const node = moduleNode("punctuated", { path }); + const graph = createJavaScriptApplicationGraph({ + schema: "JavaScriptApplicationGraph", + root_node_ids: [node.node_id], + nodes: [node], + edges: [], + coverage: { + status: "complete", + truncated: false, + omitted_count: 0, + limits: [], + }, + limitations: [], + }); + const result = compareSourceToBundle({ + reference: historicalGraph("complete", [path]), + application: { + evidenceId: EVIDENCE_ID, + rootArtifactSha256: HASH.artifact, + graph, + }, + }); + expect(item(result, path)).toMatchObject({ + status: "unknown", + current_node_ids: [node.node_id], + candidates: [ + expect.objectContaining({ + signals: expect.arrayContaining([ + expect.objectContaining({ kind: "current-path-exact" }), + ]), + }), + ], + }); + }); it.each([ "https://example.test/src/modified.ts?revision=2#section", @@ -347,7 +351,7 @@ const historicalGraph = ( content_state: "hashed" as const, limitations: [], })), - ], + ].sort((left, right) => compareCodePoints(left.path, right.path)), relationships: [], parse_failures: [], exclusions: [], diff --git a/src/domain/javascript/sourceToBundleSignals.ts b/src/domain/javascript/sourceToBundleSignals.ts index 096307d2e0..b72a0d1bf3 100644 --- a/src/domain/javascript/sourceToBundleSignals.ts +++ b/src/domain/javascript/sourceToBundleSignals.ts @@ -9,6 +9,7 @@ import { type SourceToBundleSignal, } from "./sourceToBundleComparisonSchemas.js"; import { isDigest } from "../digests.js"; +import { resolveJavaScriptSourceMapPath } from "./javascriptSourceMapPaths.js"; type SourceFile = Extract< HistoricalSourceGraph["entries"][number], @@ -23,6 +24,8 @@ type CurrentPathKind = interface CurrentPath { readonly kind: CurrentPathKind; readonly value: string; + /** Artifact-relative map resolution identifies one path, not its suffixes. */ + readonly allowSuffix: boolean; } interface CurrentProjection { @@ -93,7 +96,9 @@ export const buildSourceToBundleCandidateIndex = ( for (const digest of projection.digests) addIndexValue(byDigest, digest, projection.node.node_id); for (const path of projection.paths) { - for (const suffix of pathSuffixes(path.value)) + for (const suffix of path.allowSuffix + ? pathSuffixes(path.value) + : [path.value]) addIndexValue(byPathSuffix, suffix, projection.node.node_id); addIndexValue( byBasename, @@ -169,7 +174,22 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => { if (node.identity.strategy === "source-map-original") { if (node.identity.source_sha256 !== null) digests.add(node.identity.source_sha256); - addPath(paths, "source-map-original", node.identity.original_source); + const mapLocations = node.observations.flatMap(({ evidence }) => + evidence.extractor.operation === "parse-local-source-map" && + evidence.location.available && + evidence.location.value.kind === "artifact-path" + ? [evidence.location.value.path] + : [], + ); + if (mapLocations.length === 0) + addPath(paths, "source-map-original", node.identity.original_source); + for (const mapPath of mapLocations) + addPath( + paths, + "source-map-original", + node.identity.original_source, + mapPath, + ); } if (node.identity.strategy === "canonical-path") addPath(paths, "canonical-path", node.identity.path); @@ -177,8 +197,18 @@ const projectCurrentNode = (node: ApplicationNode): CurrentProjection => { const sourceDigest = observation.properties.source_sha256; if (typeof sourceDigest === "string" && isDigest(sourceDigest)) digests.add(sourceDigest); - for (const key of PATH_PROPERTIES) - addJsonPath(paths, observation.properties[key]); + for (const key of PATH_PROPERTIES) { + const location = observation.evidence.location; + const mapPath = + node.identity.strategy === "source-map-original" && + observation.evidence.extractor.operation === "parse-local-source-map" && + (key === "source" || key === "original_source") && + location.available && + location.value.kind === "artifact-path" + ? location.value.path + : undefined; + addJsonPath(paths, observation.properties[key], mapPath); + } } return { node, @@ -212,7 +242,10 @@ const candidateSignals = ( signals.push(signal("current-path-exact", source.path, exactPaths)); const suffixPaths = otherPaths .filter( - ({ value }) => value !== source.path && value.endsWith(`/${source.path}`), + ({ value, allowSuffix }) => + allowSuffix && + value !== source.path && + value.endsWith(`/${source.path}`), ) .map(({ value }) => value); if (suffixPaths.length > 0) @@ -259,45 +292,70 @@ const matchingPaths = ( ): string[] => currentPaths .filter( - ({ value }) => value === sourcePath || value.endsWith(`/${sourcePath}`), + ({ value, allowSuffix }) => + value === sourcePath || + (allowSuffix && value.endsWith(`/${sourcePath}`)), ) .map(({ value }) => value); -const addJsonPath = (paths: CurrentPath[], value: unknown): void => { - if (typeof value === "string") addPath(paths, "observation-path", value); +const addJsonPath = ( + paths: CurrentPath[], + value: unknown, + mapPath?: string, +): void => { + if (typeof value === "string") + addPath(paths, "observation-path", value, mapPath); }; const addPath = ( paths: CurrentPath[], kind: CurrentPathKind, raw: string, + mapPath?: string, ): void => { + if (kind === "source-map-original" || mapPath !== undefined) { + const resolved = resolveJavaScriptSourceMapPath(raw, mapPath); + if (resolved !== null) + paths.push({ + kind, + value: resolved.value, + allowSuffix: resolved.scope === "suffix", + }); + return; + } const value = normalizeCurrentPath(raw); - if (value !== null) paths.push({ kind, value }); + if (value !== null) paths.push({ kind, value, allowSuffix: true }); }; -/** Matches a `scheme://...` URL prefix; bare filesystem paths keep `?`/`#`. */ const SCHEME_URL = /^[a-z][a-z0-9+.-]*:\/\//iu; -/** Matches a `scheme://...` URL prefix with any run of slashes, for stripping. */ const SCHEME_URL_PREFIX = /^[a-z][a-z0-9+.-]*:\/\/+/iu; const normalizeCurrentPath = (raw: string): string | null => { const withoutQuery = SCHEME_URL.test(raw) ? (raw.split(/[?#]/u, 1)[0] ?? "") : raw; - const withoutScheme = withoutQuery.replace(SCHEME_URL_PREFIX, ""); - const parts = withoutScheme - .replaceAll("\\", "/") - .split("/") - .filter((part) => part !== "" && part !== "."); - if (parts.length === 0 || parts.includes("..")) return null; + const portable = withoutQuery + .replace(SCHEME_URL_PREFIX, "") + .replaceAll("\\", "/"); + const parts: string[] = []; + for (const part of portable.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") { + if (parts.length === 0) return null; + parts.pop(); + } else parts.push(part); + } + if (parts.length === 0) return null; return parts.join("/"); }; const uniquePaths = (paths: readonly CurrentPath[]): CurrentPath[] => [ ...new Map( - paths.map((path) => [`${path.kind}\0${path.value}`, path]), + paths.map((path) => [ + `${path.kind}\0${path.value}\0${String(path.allowSuffix)}`, + path, + ]), ).values(), ].sort((left, right) => compareCodePoints( diff --git a/tests/boundary/filesystem/sourceToBundlePaths.test.ts b/tests/boundary/filesystem/sourceToBundlePaths.test.ts index fffd7bf05f..17ee916c98 100644 --- a/tests/boundary/filesystem/sourceToBundlePaths.test.ts +++ b/tests/boundary/filesystem/sourceToBundlePaths.test.ts @@ -6,6 +6,7 @@ import { analyzeJavaScriptApplication } from "../../../src/application/javascrip import { parseApplicationGraphEvidence } from "../../../src/application/javascript/JavaScriptApplicationEvidenceGraph.js"; import { importReferenceSource } from "../../../src/application/ReferenceSourceImport.js"; import { compareSourceToBundle } from "../../../src/domain/javascript/sourceToBundleComparison.js"; +import { resolveSourceMapSource } from "../../../src/javascript/sourceMaps/DecodedSourceMap.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; it("keeps literal hash characters in historical/current filesystem paths", async () => { @@ -17,25 +18,9 @@ it("keeps literal hash characters in historical/current filesystem paths", async writeFile(join(previous, "worker#main.js"), "export const value = 1;"), writeFile(join(current, "worker#main.js"), "export const value = 2;"), ]); - const reference = await importReferenceSource({ - root: previous, - caller: "source-to-bundle-path-test", - policy: { secretPatterns: [] }, - }); - if (!reference.ok) throw new Error(reference.error.message); - const analyzed = await analyzeJavaScriptApplication({ input_path: current }); - if (!analyzed.ok) throw analyzed.error; - const application = parseApplicationGraphEvidence(analyzed.value); - const comparison = compareSourceToBundle({ - reference: reference.value, - application: { - evidenceId: application.evidence.evidence_id, - rootArtifactSha256: application.rootArtifactSha256, - graph: application.graph, - }, - }); + const { reference, comparison } = await compareTrees(previous, current); - expect(reference.value.entries).toContainEqual( + expect(reference.entries).toContainEqual( expect.objectContaining({ kind: "file", path: "worker#main.js", @@ -59,6 +44,507 @@ it("keeps literal hash characters in historical/current filesystem paths", async ); }); +it("matches source-map originals named relative to the map", async () => { + const root = await createTestTempDirectory("rea-source-map-parent-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, "src"), { recursive: true }), + mkdir(join(current, "dist"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, "src", "a.js"), "export const value = 1;\n"), + writeFile( + join(current, "dist", "main.js"), + "var value = 2;\n//# sourceMappingURL=main.js.map\n", + ), + writeFile( + join(current, "dist", "main.js.map"), + JSON.stringify({ + version: 3, + file: "main.js", + sources: ["../src/a.js"], + sourcesContent: ["export const value = 2;\n"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison } = await compareTrees(previous, current); + + expect(comparison.items).toContainEqual( + expect.objectContaining({ + source_path: "src/a.js", + candidates: expect.arrayContaining([ + expect.objectContaining({ + current_node_kind: "source-module", + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "source-map-original-path", + current_values: ["src/a.js"], + }), + ]), + }), + ]), + }), + ); +}); + +it.each([ + { + mapPath: "pkg/dist/main.js.map", + source: "..\\src\\a.js", + expected: "pkg/src/a.js", + }, + { + mapPath: "pkg/dist/main.js.map", + source: "../src/a.js", + expected: "pkg/src/a.js", + }, + { + mapPath: "apps/pkg/dist/maps/main.js.map", + source: "../../src/a.js", + expected: "apps/pkg/src/a.js", + }, + { + mapPath: "pkg/dist/main.js.map", + sourceRoot: "../src", + source: "a.js", + expected: "pkg/src/a.js", + }, + { + mapPath: "pkg/maps/main.map", + sourceRoot: "../src", + source: "/vendor/a.js", + expected: "pkg/src/vendor/a.js", + decoy: "vendor/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "modules/a.js", + expected: "pkg/maps/modules/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "generated/../src/a.js", + expected: "pkg/maps/src/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + sourceRoot: "generated/..", + source: "src/a.js", + expected: "pkg/maps/src/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "../actual/%2e%2e/src/a.js", + expected: "pkg/src/a.js", + decoy: "pkg/actual/%2e%2e/src/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + sourceRoot: "../actual/.%2E", + source: "src/a.js", + expected: "pkg/src/a.js", + decoy: "pkg/actual/.%2E/src/a.js", + }, + { + mapPath: "pkg#name?/maps/main.js.map", + source: "../src/a.js", + expected: "pkg#name?/src/a.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "modules/a%20b.js", + expected: "pkg/maps/modules/a b.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "modules/a%23b.js", + expected: "pkg/maps/modules/a#b.js", + }, + { + mapPath: "pkg/maps/main.js.map", + source: "../actual/%252e%252e/src/a.js", + expected: "pkg/actual/%2e%2e/src/a.js", + decoy: "pkg/src/a.js", + }, +])( + "resolves $source against $mapPath without selecting a same-basename decoy", + async ({ + mapPath, + source, + sourceRoot, + expected, + decoy: decoyPath = "src/a.js", + }) => { + if (sourceRoot !== undefined) { + const resolved = resolveSourceMapSource( + source, + sourceRoot, + `https://example.test/${mapPath}`, + ); + expect(decodeURIComponent(new URL(resolved).pathname)).toBe( + `/${expected}`, + ); + } + const root = await createTestTempDirectory("rea-nested-map-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, expected, ".."), { recursive: true }), + mkdir(join(previous, decoyPath, ".."), { recursive: true }), + mkdir(join(current, mapPath, ".."), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, expected), "export const value = 1;"), + writeFile(join(previous, decoyPath), "export const decoy = 99;"), + writeFile( + join(current, mapPath), + JSON.stringify({ + version: 3, + sources: [source], + ...(sourceRoot === undefined ? {} : { sourceRoot }), + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison } = await compareTrees(previous, current); + const correct = comparison.items.find( + ({ source_path }) => source_path === expected, + ); + expect(correct?.current_node_ids).toHaveLength(1); + expect(correct?.candidates).toContainEqual( + expect.objectContaining({ + confidence: "high", + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "source-map-original-path", + current_values: [expected], + }), + ]), + }), + ); + const decoy = comparison.items.find( + ({ source_path }) => source_path === decoyPath, + ); + expect(decoy?.current_node_ids).toEqual([]); + expect( + decoy?.candidates + .flatMap(({ signals }) => signals) + .some(({ kind }) => kind === "current-path-exact"), + ).toBe(false); + // A suffix alone must not claim a second map-relative original identity. + expect( + decoy?.candidates + .flatMap(({ signals }) => signals) + .some(({ kind }) => kind === "source-map-original-path"), + ).toBe(false); + }, +); + +it("retains every map location when identical map artifacts merge", async () => { + const root = await createTestTempDirectory("rea-source-map-merged-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + const map = JSON.stringify({ + version: 3, + sources: ["../src/x.js"], + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }); + for (const prefix of ["a", "b"]) { + await Promise.all([ + mkdir(join(previous, prefix, "src"), { recursive: true }), + mkdir(join(current, prefix, "maps"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, prefix, "src/x.js"), "export const value = 1;"), + writeFile(join(current, prefix, "maps/main.js.map"), map), + ]); + } + const { comparison } = await compareTrees(previous, current); + for (const prefix of ["a", "b"]) { + const path = `${prefix}/src/x.js`; + const item = comparison.items.find( + ({ source_path }) => source_path === path, + ); + expect(item?.current_node_ids).toHaveLength(1); + expect(item?.candidates).toContainEqual( + expect.objectContaining({ + confidence: "high", + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "source-map-original-path", + current_values: [path], + }), + ]), + }), + ); + } +}); + +it.each([ + { source: "file:/repo/src/a.js", hasPath: true }, + { source: "file:C:/repo/src/a.js", hasPath: true }, + { source: "file:C:\\repo\\src\\a.js", hasPath: true }, + { source: "C:\\repo\\src\\a.js", hasPath: true }, + { source: "file://server/share/src/a.js", hasPath: true }, + { source: "file:/repo/src/a.js?version=2#original", hasPath: true }, + { source: "webpack:///src/a.js", hasPath: true }, + { source: "https://example.com/src/a.js#original", hasPath: true }, + { source: "node:internal/src/a.js", hasPath: false }, + { source: "data:text/javascript,virtual/src/a.js", hasPath: false }, + { source: "data:text/javascript,virtual/../src/a.js", hasPath: false }, + { source: "data:\\src\\a.js", hasPath: false }, +])( + "projects source references from $source only when they name a file", + async ({ source, hasPath }) => { + const root = await createTestTempDirectory("rea-source-map-uri-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, "src"), { recursive: true }), + mkdir(join(current, "maps"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, "src/a.js"), "export const value = 1;"), + writeFile( + join(current, "maps/main.js.map"), + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison, application } = await compareTrees(previous, current); + expect(application.graph.nodes).toContainEqual( + expect.objectContaining({ + identity: expect.objectContaining({ original_source: source }), + }), + ); + if (hasPath) + expect(comparison.items[0]?.candidates).toContainEqual( + expect.objectContaining({ + confidence: "high", + signals: expect.arrayContaining([ + expect.objectContaining({ kind: "source-map-original-path" }), + ]), + }), + ); + else expect(comparison.items[0]?.candidates).toEqual([]); + }, +); + +it.each(["#original.js", "?version.js", "?version.js#original.js"])( + "uses the relative URL pathname rather than a literal %s filename", + async (suffix) => { + const root = await createTestTempDirectory("rea-source-map-suffix-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, "src"), { recursive: true }), + mkdir(join(current, "maps"), { recursive: true }), + ]); + const source = `../src/a.js${suffix}`; + await Promise.all([ + writeFile(join(previous, "src/a.js"), "export const value = 1;"), + writeFile(join(previous, `src/a.js${suffix}`), "export const decoy = 3;"), + writeFile( + join(current, "maps/main.js.map"), + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison, application } = await compareTrees(previous, current); + const actual = comparison.items.find( + ({ source_path }) => source_path === "src/a.js", + ); + expect(actual?.current_node_ids).toHaveLength(1); + expect(actual?.candidates).toContainEqual( + expect.objectContaining({ + confidence: "high", + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "source-map-original-path", + current_values: ["src/a.js"], + }), + ]), + }), + ); + const decoy = comparison.items.find( + ({ source_path }) => source_path === `src/a.js${suffix}`, + ); + expect(decoy?.current_node_ids).toEqual([]); + expect(decoy?.candidates).toEqual([]); + expect(application.graph.nodes).toContainEqual( + expect.objectContaining({ + identity: expect.objectContaining({ original_source: source }), + }), + ); + }, +); + +it.each([ + { filename: "a#b.js", decoyFilename: "a" }, + { filename: "a?b.js", decoyFilename: "a" }, + { filename: "a%23b.js", decoyFilename: "a#b.js" }, +])( + "preserves the literal UNC filesystem name $filename without selecting a URL decoy", + async ({ filename, decoyFilename }) => { + const root = await createTestTempDirectory("rea-source-map-unc-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + const source = `\\\\server\\share\\src\\${filename}`; + const expected = `src/${filename}`; + const decoy = `src/${decoyFilename}`; + await Promise.all([ + mkdir(join(previous, "src"), { recursive: true }), + mkdir(join(current, "maps"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, expected), "export const value = 1;"), + writeFile(join(previous, decoy), "export const decoy = 3;"), + writeFile( + join(current, "maps/main.map"), + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison, application } = await compareTrees(previous, current); + const actual = comparison.items.find( + ({ source_path }) => source_path === expected, + ); + expect(actual?.current_node_ids).toHaveLength(1); + expect(actual?.candidates).toContainEqual( + expect.objectContaining({ + signals: expect.arrayContaining([ + expect.objectContaining({ kind: "source-map-original-path" }), + ]), + }), + ); + expect( + comparison.items.find(({ source_path }) => source_path === decoy) + ?.current_node_ids, + ).toEqual([]); + expect(application.graph.nodes).toContainEqual( + expect.objectContaining({ + identity: expect.objectContaining({ original_source: source }), + }), + ); + }, +); + +it.each([ + { source: "", historicalPath: "maps.js" }, + { source: ".", historicalPath: "maps.js" }, + { source: "./", historicalPath: "maps.js" }, + { source: "child/..", historicalPath: "maps.js" }, + { source: "child.js/", historicalPath: "maps.js/child.js" }, + { source: "#original.js", historicalPath: "maps.js" }, + { source: "?version.js", historicalPath: "maps.js" }, + { source: "./?version.js", historicalPath: "maps.js" }, + { source: "%2e", historicalPath: "maps.js" }, + { source: "%2e%2e", historicalPath: "maps.js" }, + { source: "child%2fpart.js", historicalPath: "maps.js/child/part.js" }, + { source: "child%5cpart.js", historicalPath: "maps.js/child/part.js" }, + { source: "bad%zz.js", historicalPath: "maps.js/bad%zz.js" }, + { source: "\\\\bad host\\share\\a.js", historicalPath: "share/a.js" }, + { source: "../../outside/a.js", historicalPath: "outside/a.js" }, +])( + "declines source references without a trustworthy file path: $source", + async ({ source, historicalPath }) => { + const root = await createTestTempDirectory("rea-source-map-empty-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, historicalPath, ".."), { recursive: true }), + mkdir(join(current, "maps.js"), { recursive: true }), + ]); + await Promise.all([ + writeFile(join(previous, historicalPath), "export const value = 1;"), + writeFile( + join(current, "maps.js/main.js.map"), + JSON.stringify({ + version: 3, + sources: [source], + sourcesContent: ["export const value = 2;"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison } = await compareTrees(previous, current); + expect(comparison.items[0]?.source_path).toBe(historicalPath); + expect(comparison.items[0]?.candidates).toEqual([]); + }, +); + +it("resolves parent segments against a nested source map's directory", async () => { + const root = await createTestTempDirectory("rea-source-map-nested-"); + const previous = join(root, "previous"); + const current = join(root, "current"); + await Promise.all([ + mkdir(join(previous, "web", "src"), { recursive: true }), + mkdir(join(current, "web", "maps"), { recursive: true }), + ]); + await Promise.all([ + writeFile( + join(previous, "web", "src", "a.js"), + "export const value = 1;\n", + ), + writeFile( + join(current, "web", "main.js"), + "var value = 2;\n//# sourceMappingURL=maps/main.js.map\n", + ), + writeFile( + join(current, "web", "maps", "main.js.map"), + JSON.stringify({ + version: 3, + file: "../main.js", + sources: ["../src/a.js"], + sourcesContent: ["export const value = 2;\n"], + names: [], + mappings: "AAAA", + }), + ), + ]); + const { comparison } = await compareTrees(previous, current); + + expect(comparison.items).toContainEqual( + expect.objectContaining({ + source_path: "web/src/a.js", + candidates: expect.arrayContaining([ + expect.objectContaining({ + current_node_kind: "source-module", + signals: expect.arrayContaining([ + expect.objectContaining({ + kind: "source-map-original-path", + current_values: ["web/src/a.js"], + }), + ]), + }), + ]), + }), + ); +}); + it("rejects static application Evidence whose subject path disagrees with its result", async () => { const root = await createTestTempDirectory("rea-application-subject-path-"); const analyzed = await analyzeJavaScriptApplication({ input_path: root }); @@ -77,3 +563,24 @@ it("rejects static application Evidence whose subject path disagrees with its re "JavaScript application Evidence subject does not match its result", ); }); + +const compareTrees = async (previous: string, current: string) => { + const reference = await importReferenceSource({ + root: previous, + caller: "source-to-bundle-path-test", + policy: { secretPatterns: [] }, + }); + if (!reference.ok) throw new Error(reference.error.message); + const analyzed = await analyzeJavaScriptApplication({ input_path: current }); + if (!analyzed.ok) throw analyzed.error; + const application = parseApplicationGraphEvidence(analyzed.value); + const comparison = compareSourceToBundle({ + reference: reference.value, + application: { + evidenceId: application.evidence.evidence_id, + rootArtifactSha256: application.rootArtifactSha256, + graph: application.graph, + }, + }); + return { reference: reference.value, comparison, application }; +};