mirror of
https://github.com/morluto/rea.git
synced 2026-11-06 23:06:18 +00:00
fix(javascript): admit Electron script loaders only on renderer evidence
The previous commit dropped a module package type whenever a source required an Electron module. A Node-loaded ES module that requires Electron, or one whose Electron call goes through a local `require`, then regained the false require edges the package scope removes. The parser applies the package type unconditionally again. The worker re-reads a package-module script that requires an Electron module once outside the package scope. The source keeps that context only when the static pass there directly observes a contextBridge exposure or a renderer IPC operation, which only Electron's renderer-side loaders provide.
This commit is contained in:
1 parent
5ad7f52a03
commit
b54845a6b8
6 files changed
+165
-77
No files matched your search
@@ -1,6 +1,11 @@
|
||||
import {
|
||||
hasElectronRendererEvidence,
|
||||
mayRunUnderElectronScriptLoader,
|
||||
} from "../domain/javascript/electronScriptLoaderEvidence.js";
|
||||
import { moduleResolutionModeForJavaScriptReference } from "../domain/javascript/javascriptModuleResolutionMode.js";
|
||||
import { parseJavaScriptSource } from "../domain/javascript/javascriptSourceParser.js";
|
||||
import { analyzeParsedJavaScriptStaticSourceSteps } from "../domain/javascript/javascriptStaticAnalysis.js";
|
||||
import type { JavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisTypes.js";
|
||||
import { analyzeParsedJavaScriptSemanticsSteps } from "../domain/javascript/javascriptSemanticAnalysis.js";
|
||||
import { failedJavaScriptStaticAnalysis } from "../domain/javascript/javascriptStaticAnalysisHelpers.js";
|
||||
import { projectJavaScriptModuleSemantics } from "../domain/javascript/javascriptModuleSemanticIr.js";
|
||||
@@ -31,7 +36,23 @@ const analyze: JavaScriptSourceWorkerAnalyzer = async (
|
||||
return null;
|
||||
}
|
||||
await callbacks.phase("static");
|
||||
const facts = await completeJavaScriptAnalysisSteps(
|
||||
let facts: JavaScriptStaticAnalysis | undefined;
|
||||
if (mayRunUnderElectronScriptLoader(parsed.program)) {
|
||||
// Renderer-side evidence is read outside the package scope, where an
|
||||
// unbound require keeps its Electron identity; with that evidence the
|
||||
// whole source keeps that context, so its static and semantic facts agree.
|
||||
const script = parseJavaScriptSource(file.text.value, file.path);
|
||||
if (script !== null) {
|
||||
const scriptFacts = await completeJavaScriptAnalysisSteps(
|
||||
analyzeParsedJavaScriptStaticSourceSteps(file.text.value, script),
|
||||
);
|
||||
if (hasElectronRendererEvidence(scriptFacts.electron)) {
|
||||
parsed = script;
|
||||
facts = scriptFacts;
|
||||
}
|
||||
}
|
||||
}
|
||||
facts ??= await completeJavaScriptAnalysisSteps(
|
||||
analyzeParsedJavaScriptStaticSourceSteps(file.text.value, parsed),
|
||||
);
|
||||
const resultBudgetBytes = await callbacks.checkpoint(facts);
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
hasElectronRendererEvidence,
|
||||
mayRunUnderElectronScriptLoader,
|
||||
} from "./electronScriptLoaderEvidence.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
import { analyzeParsedJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
const parse = (...input: Parameters<typeof parseJavaScriptSource>) => {
|
||||
const parsed = parseJavaScriptSource(...input);
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
return parsed;
|
||||
};
|
||||
|
||||
it.each([
|
||||
[
|
||||
'const { ipcRenderer } = require("electron");',
|
||||
"preload.js",
|
||||
"module",
|
||||
true,
|
||||
],
|
||||
['require("electron/renderer");', "preload.ts", "module", true],
|
||||
['void (() => require("electron"));', "preload.js", "module", true],
|
||||
['const dependency = require("./dep.cjs");', "main.js", "module", false],
|
||||
['const store = require("electron-store");', "main.js", "module", false],
|
||||
['import "./setup.js"; require("electron");', "main.js", "module", false],
|
||||
['require("electron");', "preload.mjs", "module", false],
|
||||
['require("electron");', "preload.js", "commonjs", false],
|
||||
['require("electron");', "preload.js", undefined, false],
|
||||
] as const)(
|
||||
"selects package-module scripts that require Electron: %s (%s, %s)",
|
||||
(source, path, packageType, expected) => {
|
||||
expect(
|
||||
mayRunUnderElectronScriptLoader(parse(source, path, packageType).program),
|
||||
).toBe(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
"a destructured contextBridge exposure",
|
||||
'const { contextBridge } = require("electron");\ncontextBridge.exposeInMainWorld("api", {});',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"a direct ipcRenderer send",
|
||||
'require("electron").ipcRenderer.send("ready");',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"a renderer IPC listener",
|
||||
'const { ipcRenderer } = require("electron");\nipcRenderer.on("update", () => {});',
|
||||
true,
|
||||
],
|
||||
[
|
||||
"main-process IPC only",
|
||||
'const { app, ipcMain } = require("electron");\nipcMain.handle("ping", () => app.getName());',
|
||||
false,
|
||||
],
|
||||
["no Electron operation", 'require("electron");', false],
|
||||
[
|
||||
"a locally bound require",
|
||||
'function load(require) {\n require("electron").contextBridge.exposeInMainWorld("api", {});\n}',
|
||||
false,
|
||||
],
|
||||
] as const)(
|
||||
"reads renderer-side Electron evidence from %s",
|
||||
(_name, source, expected) => {
|
||||
const parsed = parse(source, "preload.js");
|
||||
const { electron } = analyzeParsedJavaScriptStaticSource(source, parsed);
|
||||
expect(hasElectronRendererEvidence(electron)).toBe(expected);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import type { ElectronStaticFindings } from "./electronStaticAnalysisTypes.js";
|
||||
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { javaScriptRuntimeContext } from "./javascriptSourceParser.js";
|
||||
|
||||
/**
|
||||
* Whether a script owes its module context only to a `"type": "module"`
|
||||
* package scope and requires an Electron module. Electron's preload loader
|
||||
* ignores that package type and gives a .js preload `require`; ESM preloads
|
||||
* need the .mjs suffix ("ES Modules (ESM) in Electron"). The call alone is not
|
||||
* evidence of that loader: Node's ESM loader, which the main process uses,
|
||||
* has no `require`, so the same call there throws. Module syntax would make
|
||||
* the source a module under every loader, so only scripts qualify.
|
||||
*/
|
||||
export const mayRunUnderElectronScriptLoader = (
|
||||
program: t.Program,
|
||||
): boolean => {
|
||||
if (
|
||||
javaScriptRuntimeContext(program) !== "module" ||
|
||||
program.sourceType !== "script"
|
||||
)
|
||||
return false;
|
||||
let found = false;
|
||||
traverseJavaScriptAst(program, {
|
||||
skip: () => found,
|
||||
enter: (node) => {
|
||||
found ||=
|
||||
t.isCallExpression(node) &&
|
||||
t.isIdentifier(node.callee, { name: "require" }) &&
|
||||
t.isStringLiteral(node.arguments[0]) &&
|
||||
ELECTRON_MODULE.test(node.arguments[0].value);
|
||||
},
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
/**
|
||||
* `contextBridge` and `ipcRenderer` are renderer-process Electron modules; the
|
||||
* main process cannot obtain them. A directly observed exposure or renderer
|
||||
* IPC operation therefore places its source under a renderer-side loader.
|
||||
* Wrapper substitutions are inferred, so they do not count.
|
||||
*/
|
||||
export const hasElectronRendererEvidence = (
|
||||
electron: ElectronStaticFindings,
|
||||
): boolean =>
|
||||
electron.context_bridge_apis.some(
|
||||
({ resolution_basis }) => resolution_basis === undefined,
|
||||
) ||
|
||||
electron.ipc.some(
|
||||
({ side, resolution_basis }) =>
|
||||
side === "renderer" && resolution_basis === undefined,
|
||||
);
|
||||
@@ -49,38 +49,6 @@ it.each([
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[
|
||||
'const { ipcRenderer } = require("electron");',
|
||||
"preload.js",
|
||||
"module",
|
||||
"unknown",
|
||||
],
|
||||
[
|
||||
'require("electron/renderer").ipcRenderer.send("ready");',
|
||||
"preload.js",
|
||||
"module",
|
||||
"unknown",
|
||||
],
|
||||
['void (() => require("electron"));', "preload.ts", "module", "unknown"],
|
||||
['const dependency = require("./dep.cjs");', "main.js", "module", "module"],
|
||||
['const store = require("electron-store");', "main.js", "module", "module"],
|
||||
['import "./setup.js"; require("electron");', "main.js", "module", "module"],
|
||||
[
|
||||
'const { ipcRenderer } = require("electron");',
|
||||
"preload.js",
|
||||
"commonjs",
|
||||
"commonjs",
|
||||
],
|
||||
] as const)(
|
||||
"applies an observed package type unless Electron's script loaders apply: %s (%s, %s)",
|
||||
(source, path, packageType, context) => {
|
||||
const parsed = parseJavaScriptSource(source, path, packageType);
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
expect(javaScriptRuntimeContext(parsed.program)).toBe(context);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["const args=[]; export = args;", "fixture.ts", "sloppy"],
|
||||
['"use strict"; const args=[]; export = args;', "fixture.ts", "strict"],
|
||||
|
||||
@@ -8,8 +8,6 @@ import {
|
||||
isErasedJavaScriptDeclaration,
|
||||
semanticImportEqualsSpecifier,
|
||||
} from "./javascriptModuleDeclaration.js";
|
||||
import { ELECTRON_MODULE } from "./javascriptElectronMemberWrites.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
|
||||
/** Babel AST produced by REA's inert JavaScript parser boundary. */
|
||||
export type ParsedJavaScriptSource = ReturnType<typeof parse>;
|
||||
@@ -72,43 +70,13 @@ const hasRuntimeModuleDeclaration = (program: t.Program): boolean =>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Electron's preload loader ignores a package's `"type": "module"` and gives
|
||||
* the script `require`; ESM preloads need the .mjs suffix ("ES Modules (ESM)
|
||||
* in Electron"). A renderer with Node integration also provides `require`.
|
||||
* The main process loads such a .js source as an ES module, where `require`
|
||||
* is undefined. A call that requires an Electron module therefore indicates
|
||||
* one of Electron's script loaders, which the package type does not select.
|
||||
*/
|
||||
const requiresElectronModule = (program: t.Program): boolean => {
|
||||
let found = false;
|
||||
traverseJavaScriptAst(program, {
|
||||
skip: () => found,
|
||||
enter: (node) => {
|
||||
found ||=
|
||||
t.isCallExpression(node) &&
|
||||
t.isIdentifier(node.callee, { name: "require" }) &&
|
||||
t.isStringLiteral(node.arguments[0]) &&
|
||||
ELECTRON_MODULE.test(node.arguments[0].value);
|
||||
},
|
||||
});
|
||||
return found;
|
||||
};
|
||||
|
||||
const parseWithRuntimeContext = (
|
||||
source: string,
|
||||
sourcePath: string | undefined,
|
||||
options: ParserOptions,
|
||||
observedPackageContext?: JavaScriptPackageType,
|
||||
electronScriptLoaders = false,
|
||||
packageContext?: JavaScriptPackageType,
|
||||
): ParsedJavaScriptSource => {
|
||||
const file = parse(source, options);
|
||||
const packageContext =
|
||||
electronScriptLoaders &&
|
||||
observedPackageContext === "module" &&
|
||||
requiresElectronModule(file.program)
|
||||
? undefined
|
||||
: observedPackageContext;
|
||||
runtimeContextByProgram.set(
|
||||
file.program,
|
||||
/\.c[jt]s$/iu.test(sourcePath ?? "")
|
||||
@@ -287,9 +255,7 @@ export const parseRecoveredJavaScript = (
|
||||
/**
|
||||
* Parse inert JavaScript or TypeScript facts without attaching comments to AST
|
||||
* nodes. An observed package type selects the runtime context of .js/.ts
|
||||
* sources as it does for reference imports, except that a module type does
|
||||
* not apply to a source that requires an Electron module; syntax recovery is
|
||||
* unchanged.
|
||||
* sources as it does for reference imports; syntax recovery is unchanged.
|
||||
*/
|
||||
export const parseJavaScriptSource = (
|
||||
source: string,
|
||||
@@ -320,7 +286,6 @@ export const parseJavaScriptSource = (
|
||||
allowNewTargetOutsideFunction: commonjs,
|
||||
},
|
||||
packageContext,
|
||||
true,
|
||||
);
|
||||
// Unambiguous mode retains module-only diagnostics after choosing a script.
|
||||
// The .cjs extension establishes wrapper mode, so obtain its own diagnostics.
|
||||
@@ -335,7 +300,6 @@ export const parseJavaScriptSource = (
|
||||
sourceType: "commonjs",
|
||||
},
|
||||
packageContext,
|
||||
true,
|
||||
)
|
||||
: file;
|
||||
} catch (cause: unknown) {
|
||||
|
||||
@@ -365,10 +365,14 @@ cliTest(
|
||||
const source = (dependency: string) =>
|
||||
`try { console.log(require(${JSON.stringify(dependency)})); } catch (error) { console.log(error.name); }\n`;
|
||||
// The nearest package.json owns each .js file's scope (Node packages.md,
|
||||
// "Determining module system").
|
||||
// "Determining module system"). Requiring Electron, directly or through a
|
||||
// local loader, does not give a Node-loaded module a require.
|
||||
await writeFixtureFiles(root, {
|
||||
"package.json": JSON.stringify({ type: "module" }),
|
||||
"main.js": source("./dep.cjs"),
|
||||
"electron-main.js":
|
||||
'try { require("electron"); console.log(require("./dep.cjs")); } catch (error) { console.log(error.name); }\n',
|
||||
"shadowed.js": `function load(require) { return require("electron").contextBridge; }\n${source("./dep.cjs")}`,
|
||||
"dep.cjs": "module.exports = 1;\n",
|
||||
"legacy/package.json": JSON.stringify({ type: "commonjs" }),
|
||||
"legacy/main.js": source("../dep.cjs"),
|
||||
@@ -377,6 +381,8 @@ cliTest(
|
||||
});
|
||||
for (const [path, expected] of [
|
||||
["main.js", "ReferenceError"],
|
||||
["electron-main.js", "ReferenceError"],
|
||||
["shadowed.js", "ReferenceError"],
|
||||
["legacy/main.js", "1"],
|
||||
["untyped/main.js", "1"],
|
||||
] as const) {
|
||||
@@ -396,11 +402,12 @@ cliTest(
|
||||
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
|
||||
parseEvidence(response.json).normalized_result,
|
||||
);
|
||||
expect(sourceRequireFacts(graph, "main.js")).toEqual({
|
||||
module_format: "unknown",
|
||||
import_relationships: 0,
|
||||
require_targets: [],
|
||||
});
|
||||
for (const path of ["main.js", "electron-main.js", "shadowed.js"])
|
||||
expect(sourceRequireFacts(graph, path), path).toEqual({
|
||||
module_format: "unknown",
|
||||
import_relationships: 0,
|
||||
require_targets: [],
|
||||
});
|
||||
for (const path of ["legacy/main.js", "untyped/main.js"])
|
||||
expect(sourceRequireFacts(graph, path), path).toEqual({
|
||||
module_format: "commonjs",
|
||||
|
||||
Reference in new issue
Block a user