From 7e1c560dd608594687d4ef94c6f88e4085ad9719 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:58:13 +0800 Subject: [PATCH] fix(javascript): decode literal file URLs at the path boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Decode known literal file URLs with explicit path semantics before assigning filesystem-expression context. Preserve unknown drive, UNC, dynamic and malformed inputs and existing source-relative projection. Validation: 64 path-owner and 26 filesystem application tests pass. Fixes #2561. Based on #3147. Co-authored-by: João Pedro de Faria --- .../javascriptStaticAnalysisHelpers.ts | 43 +++++++++++++++---- .../javascriptStaticPathResolution.test.ts | 27 ++++++++++++ .../javascriptFileUrlPaths.test.ts | 38 +++++++++++++++- 3 files changed, 98 insertions(+), 10 deletions(-) diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 952abdd0e5..3b7e79c1dc 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -223,7 +223,7 @@ const staticCallPath = ( ? stringValue(node.arguments[0]) : undefined; if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) { - return staticFileUrlPath(argumentNode(node.arguments[0])); + return staticFileUrlPath(node); } if (name.endsWith(".resolve")) return name === "win32.resolve" || name.endsWith(".win32.resolve") @@ -246,12 +246,34 @@ const staticCallPath = ( }; const staticFileUrlPath = ( - argument: t.Node | undefined, + node: t.CallExpression | t.NewExpression, ): string | undefined => { + if (node.arguments.length !== 1) return undefined; + const argument = argumentNode(node.arguments[0]); if (argument === undefined) return undefined; - return isSourceFileUrl(argument) - ? sourceRelativeFileUrlPath(argument) - : staticPath(argument); + if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument); + const value = staticPath(argument); + const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; + if ( + value === undefined || + !/^file:/iu.test(value) || + value !== value.trim() || + value.includes("\\") || + [...value].some((character) => character.charCodeAt(0) < 32) || + /%(?:2f|5c)/iu.test(rawPath) + ) + return undefined; + try { + const url = new URL(value); + if (url.protocol !== "file:") return undefined; + // Canonical analysis paths use POSIX spelling so results do not depend on + // the analyzer host. Drive and UNC paths need an explicit target platform. + const path = fileURLToPath(url, { windows: false }); + return /^\/[a-z]:/iu.test(path) ? undefined : path; + } catch (cause: unknown) { + void cause; + return undefined; + } }; /** Project a known source-relative URL through the actual file URL decoder. */ @@ -339,10 +361,13 @@ const isFilesystemPathExpression = (node: t.Node): boolean => { if (name === "URL" || name.endsWith(".URL")) return isFileIdentity(argumentNode(node.arguments[1])); if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) - return node.arguments.some((argument) => { - const value = argumentNode(argument); - return value !== undefined && isFilesystemPathExpression(value); - }); + return ( + staticFileUrlPath(node) !== undefined || + node.arguments.some((argument) => { + const value = argumentNode(argument); + return value !== undefined && isFilesystemPathExpression(value); + }) + ); return node.arguments.some((argument) => { const value = argumentNode(argument); return value !== undefined && isFilesystemPathExpression(value); diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 30bbc317ff..5081eb37dc 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -1,4 +1,5 @@ import { posix } from "node:path"; +import { fileURLToPath } from "node:url"; import { parseExpression } from "@babel/parser"; import { describe, expect, it } from "vitest"; @@ -207,6 +208,32 @@ describe("inert JavaScript path resolution", () => { ); }); +it.each([ + "file:///app/preload%20file.js", + "file:///app/%E2%9C%93.js", + "file:///app/preload.js?query=1#fragment", +])("decodes literal file URL %s as a filesystem expression", (url) => { + assertPathProjection( + `fileURLToPath(${JSON.stringify(url)})`, + fileURLToPath(url, { windows: false }), + "filesystem-expression", + ); +}); + +it.each([ + 'fileURLToPath("file:///app/%2Fpreload.js")', + 'fileURLToPath("file:///app/%5Cpreload.js")', + 'fileURLToPath("file:///app/%")', + 'fileURLToPath("file:///C:/app/preload.js")', + 'fileURLToPath("file://remote/share/preload.js")', + 'fileURLToPath("file:///app/preload.js", options)', + "fileURLToPath(selectedUrl)", +])("leaves platform-dependent or invalid %s unknown", (expression) => + expect( + staticPath(parseExpression(expression, { sourceType: "module" })), + ).toBeUndefined(), +); + const analyzePathConsumers = (expression: string) => { const property = `preload: ${expression}`; const genericPrefix = "const options = { "; diff --git a/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts index 3597c45033..89c582c508 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts @@ -21,6 +21,12 @@ const urlCases = [ ] as const; const unknownExpressions = [ + 'fileURLToPath("file:///app/%2Fpreload.cjs")', + 'fileURLToPath("file:///app/%5Cpreload.cjs")', + 'fileURLToPath("file:///app/%")', + 'fileURLToPath("file:///C:/app/preload.cjs")', + 'fileURLToPath("file://remote/share/preload.cjs")', + 'fileURLToPath("file:///app/preload.cjs", { windows: selectedPlatform })', '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))', @@ -34,7 +40,7 @@ const unknownExpressions = [ 'convertFileURLToPath(new URL("./preload.cjs", import.meta.url))', ]; -describe("source-relative file URL paths", () => { +describe("JavaScript file URL paths", () => { it.each(urlCases)( "resolves %s to the file read by Node", async (reference, target, sourcePath) => { @@ -79,6 +85,36 @@ describe("source-relative file URL paths", () => { }, ); + it("resolves a decoded literal file URL at the filesystem boundary", async () => { + const root = await createTestTempDirectory("rea-literal-file-url-"); + await mkdir(join(root, "app"), { recursive: true }); + await writeFile( + join(root, "app", "preload name.cjs"), + 'module.exports = "literal file URL preload";\n', + ); + await writeFile( + join(root, "main.mjs"), + `import { fileURLToPath } from "node:url";\nimport { BrowserWindow } from "electron";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath("file:///app/preload%20name.cjs") } });\n`, + ); + + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + const preload = graph.nodes.find(({ kind }) => kind === "electron-preload"); + expect(preload?.observations).not.toHaveLength(0); + for (const observation of preload?.observations ?? []) + expect(observation.properties).toMatchObject({ + resolved_path: "app/preload name.cjs", + resolution_status: "resolved", + resolution_context: "filesystem-expression", + }); + }); + it.each(unknownExpressions)("leaves unsupported %s unknown", (expression) => { expect( staticPath(parseExpression(expression, { sourceType: "module" })),