From a6488a96a967d4e2ef75a896c65172ee72240c83 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Tue, 27 Oct 2026 06:42:58 +0800 Subject: [PATCH] fix(javascript): preserve renderer path anchors --- .../JavaScriptArtifactGraphFindings.ts | 22 +++- .../javascriptStaticAnalysisCalls.ts | 79 +++++++----- .../javascriptStaticAnalysisHelpers.ts | 117 +++++++++++++----- .../javascriptStaticAnalysisTypes.ts | 2 + .../javascriptStaticPathResolution.test.ts | 44 +++++++ .../JavaScriptAnalysisWorkerSchemas.ts | 1 + .../electronRendererStaticPaths.test.ts | 59 +++++++++ 7 files changed, 259 insertions(+), 65 deletions(-) create mode 100644 tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 3dd863e594..1250fb3319 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -492,7 +492,7 @@ const addDiscoveredRole = ( changedRoots: ReadonlySet | null, ): void => { const root = applicationRootPath(context, input.file); - const resolution = resolveArtifactPathByContext({ + const resolved = resolveArtifactPathByContext({ declaredPath: input.value.path, sourcePath: input.file.path, context: input.value.resolution_context, @@ -500,7 +500,8 @@ const addDiscoveredRole = ( jsonDocuments: context.analysis.jsonDocuments, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, - ...(input.value.role === "preload" + ...(input.value.role === "preload" || + input.value.path_base === "source-directory" ? { runtimePathKind: "exact-file" as const } : input.value.role === "renderer" && input.value.resolution_context === "filesystem-expression" @@ -520,6 +521,16 @@ const addDiscoveredRole = ( ? { runtimePathKind: "url-resource" as const } : {}), }); + const resolution = + input.value.path_base === "source-directory" + ? { + ...resolved, + limitations: [ + ...resolved.limitations, + "The source-directory anchor is inferred from authored code; bundled or transformed runtime path behavior was not verified.", + ], + } + : resolved; const role = createElectronRoleNode(context, { kind: input.value.role === "preload" ? "electron-preload" : "electron-renderer", @@ -543,7 +554,12 @@ const addDiscoveredRole = ( range: input.value.location, coverage: input.coverage, relation: "loads", - properties: { mechanism: input.value.mechanism }, + properties: { + mechanism: input.value.mechanism, + ...(input.value.path_base === undefined + ? {} + : { path_base: input.value.path_base }), + }, }); linkElectronRoleToAsset(context, { role, diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index ea46a0f474..bf934a9d30 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -19,6 +19,7 @@ import { rangeForOffsets, sourceSlice, stringValue, + staticFileExpressionPath, } from "./javascriptStaticAnalysisHelpers.js"; import { semanticStaticPropertyKey, @@ -149,40 +150,54 @@ export const inspectCall = ( inspectStorageCall(node, name, first, context, bindings); if ( (name === "loadFile" || name.endsWith(".loadFile")) && - context.rendererLoad?.(node.callee, node) === true && - first !== undefined - ) - addLocatedFinding(context, { - collection: accumulator.roles, - key: `role\0renderer\0${first}`, - node, - value: { - role: "renderer", - path: first, - resolution_context: "filesystem-expression", - mechanism: `call:${name}`, - module_key: null, - location: range(node), - }, - }); + context.rendererLoad?.(node.callee, node) === true + ) { + const argument = argumentNode(node.arguments[0]); + const filePath = + first === undefined && argument !== undefined + ? staticFileExpressionPath(argument, context.pathIntrinsics) + : first === undefined + ? undefined + : { path: first, base: "literal-path" as const }; + if (filePath === undefined) accumulator.unknownFindings += 1; + else + addLocatedFinding(context, { + collection: accumulator.roles, + key: `role\0renderer\0${filePath.path}\0${filePath.base}`, + node, + value: { + role: "renderer", + path: filePath.path, + ...(filePath.base === "source-directory" + ? { path_base: "source-directory" as const } + : {}), + resolution_context: "filesystem-expression", + mechanism: `call:${name}`, + module_key: null, + location: range(node), + }, + }); + } if ( (name === "loadURL" || name.endsWith(".loadURL")) && - context.rendererLoad?.(node.callee, node) === true && - first !== undefined - ) - addLocatedFinding(context, { - collection: accumulator.roles, - key: `role\0renderer\0${first}`, - node, - value: { - role: "renderer", - path: first, - resolution_context: "module-specifier", - mechanism: `call:${name}`, - module_key: null, - location: range(node), - }, - }); + context.rendererLoad?.(node.callee, node) === true + ) { + if (first === undefined) accumulator.unknownFindings += 1; + else + addLocatedFinding(context, { + collection: accumulator.roles, + key: `role\0renderer\0${first}`, + node, + value: { + role: "renderer", + path: first, + resolution_context: "module-specifier", + mechanism: `call:${name}`, + module_key: null, + location: range(node), + }, + }); + } }; const moduleUrlSpecifier = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index 5cc4187e74..b3569e29b1 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -187,14 +187,25 @@ export const staticPathResolutionContext = ( ? "filesystem-expression" : "module-specifier"; +interface StaticPathFact { + readonly path: string; + readonly base: "source-directory" | "literal-path" | "unknown"; +} + /** Resolve a path string composed only from inert literal syntax. */ export const staticPath = ( node: t.Node, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { - node = unwrapJavaScriptExpression(node).node; +): string | undefined => staticPathFact(node, pathIntrinsics)?.path; + +/** Parse spelling and its source-directory provenance together at the owning boundary. */ +const staticPathFact = ( + raw: t.Node, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): StaticPathFact | undefined => { + const node = unwrapJavaScriptExpression(raw).node; const value = stringValue(node); - if (value !== undefined) return value; + if (value !== undefined) return { path: value, base: "literal-path" }; if (t.isBinaryExpression(node, { operator: "+" })) return staticConcatenation(node, pathIntrinsics); if (t.isCallExpression(node) || t.isNewExpression(node)) @@ -202,6 +213,22 @@ export const staticPath = ( return undefined; }; +/** Admit file expressions only when the normalized spelling retains a proven base. */ +export const staticFileExpressionPath = ( + node: t.Node, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): + | { + readonly path: string; + readonly base: "source-directory" | "literal-path"; + } + | undefined => { + const fact = staticPathFact(node, pathIntrinsics); + return fact === undefined || fact.base === "unknown" + ? undefined + : { path: fact.path, base: fact.base }; +}; + /** * Concatenate inert text. A leading source directory contributes no text: * `__dirname + "/preload.js"` names a file inside it, while text without a @@ -210,7 +237,7 @@ export const staticPath = ( const staticConcatenation = ( node: t.BinaryExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { const operands: t.Node[] = []; let current: t.Node = node; while (t.isBinaryExpression(current, { operator: "+" })) { @@ -220,43 +247,62 @@ const staticConcatenation = ( const anchored = isDirectoryPath(current, pathIntrinsics); if (!anchored) operands.push(current); let text = ""; + let literal = true; for (const operand of operands.reverse()) { - const value = staticPath(operand, pathIntrinsics); + const value = staticPathFact(operand, pathIntrinsics); if (value === undefined) return undefined; - text += value; + text += value.path; + literal &&= value.base === "literal-path"; } - if (!anchored) return text; - return text.startsWith("/") ? posix.join(".", text) : undefined; + if (!anchored) + return { path: text, base: literal ? "literal-path" : "unknown" }; + return text.startsWith("/") + ? { + path: posix.join(".", text), + base: literal ? "source-directory" : "unknown", + } + : undefined; }; const staticCallPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { if (t.isCallExpression(node) && pathIntrinsics?.(node.callee)) return staticResolvedPath(node, pathIntrinsics); if ( t.isCallExpression(node) && pathIntrinsics?.(node.callee, "file-url-to-path") ) - return staticFileUrlPath(node, pathIntrinsics); + return staticFileUrlPathFact(node, pathIntrinsics); if (!t.isCallExpression(node) || !pathIntrinsics?.(node.callee, "join")) return undefined; - const parts: string[] = []; + const parts: StaticPathFact[] = []; for (const argument of node.arguments) { if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { - // join never resets at an absolute segment, so `/preload.js` after the - // source directory stays inside it. - parts.push("."); + // join never resets at an absolute segment after the source directory. + parts.push({ path: ".", base: "source-directory" }); continue; } const value = t.isNode(argument) - ? staticPath(argument, pathIntrinsics) + ? staticPathFact(argument, pathIntrinsics) : undefined; if (value === undefined) return undefined; parts.push(value); } - return parts.length === 0 ? undefined : posix.join(...parts); + if (parts.length === 0) return undefined; + const first = parts.findIndex(({ path }) => path !== ""); + const sourceAnchors = parts.flatMap(({ base }, index) => + base === "source-directory" ? [index] : [], + ); + const base = parts.some(({ base }) => base === "unknown") + ? "unknown" + : sourceAnchors.length === 0 + ? "literal-path" + : sourceAnchors.length === 1 && sourceAnchors[0] === first + ? "source-directory" + : "unknown"; + return { path: posix.join(...parts.map(({ path }) => path)), base }; }; /** Normalized path facts from a source-relative file URL expression. */ @@ -269,13 +315,22 @@ export interface JavaScriptStaticSourceUrlPath { export const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): string | undefined => staticFileUrlPathFact(node, pathIntrinsics)?.path; + +const staticFileUrlPathFact = ( + node: t.CallExpression | t.NewExpression, + pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, +): StaticPathFact | undefined => { if (node.arguments.length !== 1) return undefined; const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; - if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url")) - return staticSourceRelativeUrlPath(argument)?.path; + if (t.isNewExpression(argument) && pathIntrinsics?.(argument, "source-url")) { + const resolved = staticSourceRelativeUrlPath(argument); + return resolved === undefined + ? undefined + : { path: resolved.path, base: "source-directory" }; + } const value = staticPath(argument, pathIntrinsics); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( @@ -293,7 +348,9 @@ export const staticFileUrlPath = ( // 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; + return /^\/[a-z]:/iu.test(path) + ? undefined + : { path, base: "literal-path" }; } catch (cause: unknown) { void cause; return undefined; @@ -345,30 +402,30 @@ export const staticSourceRelativeUrlPath = ( const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, pathIntrinsics?: JavaScriptStaticPathIntrinsicQuery, -): string | undefined => { +): StaticPathFact | undefined => { const parts: string[] = []; - let anchored = false; + let base: StaticPathFact["base"] | undefined; // Node walks right-to-left and never inspects arguments before its anchor. for (let index = node.arguments.length - 1; index >= 0; index -= 1) { const argument = node.arguments[index]; if (isDirectoryPath(argumentNode(argument), pathIntrinsics)) { - anchored = true; + base = "source-directory"; break; } const value = t.isNode(argument) - ? staticPath(argument, pathIntrinsics) + ? staticPathFact(argument, pathIntrinsics) : undefined; - if (value === undefined) return undefined; - parts.push(value); - if (posix.isAbsolute(value)) { - anchored = true; + if (value === undefined || value.base === "unknown") return undefined; + parts.push(value.path); + if (value.base === "source-directory" || posix.isAbsolute(value.path)) { + base = value.base; break; } } // Relative-only calls depend on the target process cwd, which is unknown. - if (!anchored) return undefined; + if (base === undefined) return undefined; const path = posix.join(...parts.reverse()); - return path === "/" ? path : path.replace(/\/$/u, ""); + return { path: path === "/" ? path : path.replace(/\/$/u, ""), base }; }; const isFilesystemPathExpression = ( diff --git a/src/domain/javascript/javascriptStaticAnalysisTypes.ts b/src/domain/javascript/javascriptStaticAnalysisTypes.ts index 4a49d611fe..994ab20a55 100644 --- a/src/domain/javascript/javascriptStaticAnalysisTypes.ts +++ b/src/domain/javascript/javascriptStaticAnalysisTypes.ts @@ -116,6 +116,8 @@ export interface JavaScriptRolePath { readonly role: "preload" | "renderer"; readonly path: string; readonly resolution_context: JavaScriptStaticPathContext; + /** Inferred from intact source-directory intrinsics; not a runtime/bundler guarantee. */ + readonly path_base?: "source-directory"; readonly mechanism: string; readonly module_key: string | null; /** Recovered factory runtime; absent in older or unbundled findings. */ diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 948fd1f0f0..2f13040109 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -5,6 +5,50 @@ import { parseExpression } from "@babel/parser"; import { describe, expect, it } from "vitest"; import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; + +it.each([ + ['path.join(__dirname, "page.html")', "page.html", "source-directory"], + ['path.resolve(__dirname, "page.html")', "page.html", "source-directory"], + ['__dirname + "/page.html"', "page.html", "source-directory"], + ['path.join("pages", "page.html")', "pages/page.html", undefined], + ['path.join("prefix", __dirname, "page.html")', null, undefined], + ['path.join(__dirname, __dirname, "page.html")', null, undefined], + ["path.join(__dirname, runtimePage)", null, undefined], +] as const)( + "retains the owned renderer path base for %s", + (expression, path, base) => { + const analysis = analyzeCommonJsStaticFixture( + `const path=require('node:path');const{BrowserWindow}=require('electron');new BrowserWindow().loadFile(${expression});`, + ); + if (path === null) { + expect(analysis.role_paths).toEqual([]); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); + } else { + expect(analysis.role_paths).toMatchObject([ + { role: "renderer", path, resolution_context: "filesystem-expression" }, + ]); + expect(analysis.role_paths[0]?.path_base).toBe(base); + } + }, +); + +it.each(["loadFile", "loadURL"])( + "reports an unresolved proven BrowserWindow %s expression", + (method) => { + const analysis = analyzeCommonJsStaticFixture( + `const{BrowserWindow}=require('electron');new BrowserWindow().${method}(runtimePage);`, + ); + expect(analysis.role_paths).toEqual([]); + expect( + analysis.analysis_coverage?.semantics.unknown_findings, + ).toBeGreaterThan(0); + expect(analysis.limitations).toContain( + "One or more static keys, expressions, or Electron boundary values were dynamic and remain unknown.", + ); + }, +); import { parserPluginsForPath } from "./javascriptSourceParser.js"; import { calleeName, diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index e06022c2f0..31a7f5f311 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -401,6 +401,7 @@ export const javaScriptWorkerStaticAnalysisSchema = z ...findingContext, role: z.enum(["preload", "renderer"]), path: z.string(), + path_base: z.literal("source-directory").exactOptional(), resolution_context: pathContext, mechanism: z.string(), }), diff --git a/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts b/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts new file mode 100644 index 0000000000..253dec5200 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/electronRendererStaticPaths.test.ts @@ -0,0 +1,59 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; + +it("distinguishes module-anchored and application-relative BrowserWindow loadFile calls", async () => { + const root = await createTestTempDirectory("rea-renderer-static-path-"); + await mkdir(join(root, "src")); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ main: "src/main.cjs" }), + ), + writeFile( + join(root, "src", "main.cjs"), + `const path = require('node:path'); +const { BrowserWindow } = require('electron'); +new BrowserWindow().loadFile(path.join(__dirname, 'anchored.html')); +new BrowserWindow().loadFile('literal.html'); +new BrowserWindow().loadFile(runtimePage);`, + ), + writeFile( + join(root, "src", "anchored.html"), + "actual source-anchored page", + ), + writeFile(join(root, "anchored.html"), "wrong app-root decoy"), + writeFile(join(root, "literal.html"), "actual app-root page"), + writeFile(join(root, "src", "literal.html"), "wrong source-relative decoy"), + ]); + const result = await analyzeJavaScriptApplication({ + input_path: root, + format: "directory", + }); + if (!result.ok) throw result.error; + const graph = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ).graph; + const rendererPaths = graph.nodes + .filter(({ kind }) => kind === "electron-renderer") + .flatMap(({ observations }) => + observations.map(({ properties }) => properties.resolved_path), + ); + expect(rendererPaths.sort()).toEqual(["literal.html", "src/anchored.html"]); + expect(graph.coverage.status).toBe("partial"); + const anchored = graph.nodes.find( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties }) => properties.resolved_path === "src/anchored.html", + ), + ); + expect(anchored?.observations[0]?.evidence.limitations).toContain( + "The source-directory anchor is inferred from authored code; bundled or transformed runtime path behavior was not verified.", + ); +});