diff --git a/src/application/JavaScriptFileUrlPaths.test.ts b/src/application/JavaScriptFileUrlPaths.test.ts new file mode 100644 index 0000000000..0a4a5cdd87 --- /dev/null +++ b/src/application/JavaScriptFileUrlPaths.test.ts @@ -0,0 +1,95 @@ +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join, relative } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { parseExpression } from "@babel/parser"; +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js"; +import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js"; +import { staticPath } from "../domain/javascriptStaticAnalysisHelpers.js"; +import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js"; + +const urlCases = [ + ["./preload%20name.cjs", "preload name.cjs", "main.mjs"], + ["./pr%C3%A9load.cjs", "préload.cjs", "main.mjs"], + ["./preload%3F%23.cjs", "preload?#.cjs", "main.mjs"], + ["./preload.cjs?cache=1#v2", "preload.cjs", "main.mjs"], + ["../preload.cjs?cache=1", "preload.cjs", "src/main.mjs"], + ["./sub/../preload.cjs", "preload.cjs", "main.mjs"], + ["./preload.cjs", "preload.cjs", "main.mjs"], +] as const; + +const unknownExpressions = [ + 'fileURLToPath(new URL("./invalid%.cjs", import.meta.url))', + 'fileURLToPath(new URL("./preload%2fname.cjs", import.meta.url))', + 'fileURLToPath(new URL("./preload%5cname.cjs", import.meta.url))', + 'fileURLToPath(new URL("./%2e/preload.cjs", import.meta.url))', + 'fileURLToPath(new URL("https://example.test/preload.cjs", import.meta.url))', + 'fileURLToPath(new URL("//example.test/preload.cjs", import.meta.url))', + 'fileURLToPath(new URL("#fragment", import.meta.url))', + 'fileURLToPath(new URL("?cache=1", import.meta.url))', + 'fileURLToPath(new URL("./preload.cjs", runtimeBase))', + "fileURLToPath(new URL(runtimePath, import.meta.url))", + 'convertFileURLToPath(new URL("./preload.cjs", import.meta.url))', +]; + +describe("source-relative file URL paths", () => { + it.each(urlCases)( + "resolves %s to the file read by Node", + async (reference, target, sourcePath) => { + const root = await createTestTempDirectory("rea-file-url-path-"); + const sourceFile = join(root, sourcePath); + await mkdir(dirname(sourceFile), { recursive: true }); + await writeFile(join(root, target), 'module.exports = "real preload";\n'); + if (reference.includes("%") && sourcePath === "main.mjs") + await writeFile( + join(root, reference), + 'module.exports = "encoded-name decoy";\n', + ); + const nativePath = fileURLToPath( + new URL(reference, pathToFileURL(sourceFile)), + ); + expect(relative(root, nativePath)).toBe(target); + expect(await readFile(nativePath, "utf8")).toContain("real preload"); + await writeFile( + sourceFile, + `import { fileURLToPath } from "node:url";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath(new URL(${JSON.stringify(reference)}, import.meta.url)) } });\n`, + ); + + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + expect(result.ok).toBe(true); + if (!result.ok) throw result.error; + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + const preloads = graph.nodes.filter( + ({ kind }) => kind === "electron-preload", + ); + expect(preloads).toHaveLength(1); + expect(preloads[0]?.observations).not.toHaveLength(0); + for (const observation of preloads[0]?.observations ?? []) + expect(observation.properties).toMatchObject({ + resolved_path: target, + resolution_status: "resolved", + resolution_context: "filesystem-expression", + }); + }, + ); + + it.each(unknownExpressions)("leaves unsupported %s unknown", (expression) => { + expect( + staticPath(parseExpression(expression, { sourceType: "module" })), + ).toBeUndefined(); + }); + + it("does not URL-decode literal filesystem punctuation", () => { + const expression = 'path.join(__dirname, "preload%20?#.cjs")'; + expect( + staticPath(parseExpression(expression, { sourceType: "module" })), + ).toBe("preload%20?#.cjs"); + }); +}); diff --git a/src/domain/javascriptStaticAnalysisHelpers.ts b/src/domain/javascriptStaticAnalysisHelpers.ts index 1f1c3a9590..a1353690ae 100644 --- a/src/domain/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascriptStaticAnalysisHelpers.ts @@ -1,5 +1,6 @@ import { createHash } from "node:crypto"; import { posix } from "node:path"; +import { fileURLToPath } from "node:url"; import * as t from "@babel/types"; @@ -184,8 +185,7 @@ const staticCallPath = ( ? stringValue(node.arguments[0]) : undefined; if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) { - const argument = argumentNode(node.arguments[0]); - return argument === undefined ? undefined : staticPathAt(argument); + return staticFileUrlPath(argumentNode(node.arguments[0])); } if ( (name === "dirname" || name.endsWith(".dirname")) && @@ -207,6 +207,59 @@ const staticCallPath = ( return parts.length === 0 ? undefined : posix.join(...parts); }; +const staticFileUrlPath = ( + argument: t.Node | undefined, +): string | undefined => { + if (argument === undefined) return undefined; + return isSourceFileUrl(argument) + ? sourceRelativeFileUrlPath(argument) + : staticPathAt(argument); +}; + +/** Project a known source-relative URL through the actual file URL decoder. */ +const sourceRelativeFileUrlPath = (node: t.Node): string | undefined => { + if (!isSourceFileUrl(node)) return undefined; + const value = stringValue(node.arguments[0]); + if ( + value === undefined || + (value.split(/[?#]/u, 1)[0] ?? "") === "" || + value !== value.trim() || + value.includes("\\") || + [...value].some((character) => character.charCodeAt(0) < 32) || + /^[a-z][a-z0-9+.-]*:/iu.test(value) || + value.startsWith("//") || + /%(?:2e|2f|5c)/iu.test(value.split(/[?#]/u, 1)[0] ?? "") + ) + return undefined; + // A sufficiently deep inert anchor preserves any leading parent segments + // without using this host's cwd or inventing the application's directory. + const directory = `/${"source/".repeat(value.split("/").length + 1)}`; + try { + const resolved = new URL(value, `file://${directory}entry.js`); + const path = fileURLToPath(resolved, { windows: false }); + if (value.startsWith("/")) return path; + const relative = posix.relative(directory, path) || "."; + // Keep the established explicit source-directory observation spelling. + return value.startsWith("./") && + relative !== "." && + !relative.startsWith("../") + ? `./${relative}` + : relative; + } catch (cause: unknown) { + // Malformed escapes and non-file URL forms remain unknown. + void cause; + return undefined; + } +}; + +const isSourceFileUrl = ( + node: t.Node, +): node is t.CallExpression | t.NewExpression => + (t.isCallExpression(node) || t.isNewExpression(node)) && + (calleeName(node.callee) === "URL" || + calleeName(node.callee).endsWith(".URL")) && + isFileIdentity(argumentNode(node.arguments[1])); + const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, ): string | undefined => {