mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(javascript): bind bundle exports and mutations to factory roles
This commit is contained in:
@@ -330,6 +330,16 @@ recorded path keeps those leading carrier segments; the procedure path is its
|
||||
suffix. Utility calls such as `useUtils().invoice.list.fetch()`, `useQueries`,
|
||||
and links whose callee was renamed by a minifier are not recognized.
|
||||
|
||||
Bundler adapters own callback roles: Webpack supplies module, exports, then
|
||||
loader; esbuild CommonJS supplies exports, then module. Export extraction uses
|
||||
those binding identities for assignments and declarations, so renaming either
|
||||
parameter does not change the recovered names. Global property-definition
|
||||
intrinsics require lexical and visible-write proof. Computed writes through a
|
||||
standalone loader's exposed module/cache table use the shared value owner; an
|
||||
unknown key invalidates the original-slot proof rather than implying a fresh
|
||||
chunk installation. Export names describe recognized declarations, not a proof
|
||||
of final runtime property lifetime or arbitrary helper behavior.
|
||||
|
||||
Each recovered bundle module retains the exact factory-source digest. A complete
|
||||
bounded AST also receives a `babel-ast-v3` structural fingerprint. It ignores
|
||||
ordinary identifier spellings while retaining literal and directive values,
|
||||
|
||||
@@ -31,6 +31,21 @@ const expectUnresolved = (source: string) => {
|
||||
};
|
||||
|
||||
describe("Electron identity through Webpack externals", () => {
|
||||
it.each([
|
||||
["const key=45840;n.m[key]=local;", 0],
|
||||
["const key='45840';delete n.m[key];", 0],
|
||||
["n.m[runtimeKey]=local;", 0],
|
||||
["const key=45841;n.m[key]=local;", 1],
|
||||
] as const)(
|
||||
"preserves only unmodified external slots after %s",
|
||||
(write, expected) => {
|
||||
expect(
|
||||
electronOf(bootstrap(EXTERNAL, CONSUMER, `n.m=m;${write}n(100);`))
|
||||
.browser_windows,
|
||||
).toHaveLength(expected);
|
||||
},
|
||||
);
|
||||
|
||||
it("follows the default-export getter produced by webpack n.n", () => {
|
||||
const source = bootstrap(
|
||||
EXTERNAL,
|
||||
@@ -70,6 +85,14 @@ describe("Electron identity through Webpack externals", () => {
|
||||
["delete", "delete n.n;"],
|
||||
["a destructuring write", "({g:n.n}={g:e=>()=>0});"],
|
||||
["an unknown key", "n[key]=e=>()=>0;"],
|
||||
[
|
||||
"a stable loader alias",
|
||||
"const copy=n;copy.n=e=>()=>({BrowserWindow:class{}});",
|
||||
],
|
||||
[
|
||||
"a reflective write through a loader alias",
|
||||
"const copy=n;Reflect.set(copy,'n',e=>()=>({BrowserWindow:class{}}));",
|
||||
],
|
||||
])(
|
||||
"does not infer Electron after a getter write through %s",
|
||||
(_name, write) => {
|
||||
|
||||
@@ -2,6 +2,8 @@ import { createHash } from "node:crypto";
|
||||
|
||||
import * as t from "@babel/types";
|
||||
|
||||
import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js";
|
||||
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
|
||||
import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js";
|
||||
@@ -29,32 +31,42 @@ export const fingerprintJavaScriptAst = (node: t.Node): string => {
|
||||
return createHash("sha256").update(JSON.stringify(tokens)).digest("hex");
|
||||
};
|
||||
|
||||
/** Calling-convention roles established by the bundler adapter. */
|
||||
export interface JavaScriptFactoryExportBindings {
|
||||
readonly exports: t.Identifier | undefined;
|
||||
readonly module: t.Identifier | undefined;
|
||||
readonly loader: t.Identifier | undefined;
|
||||
}
|
||||
|
||||
/** Collect statically declared CommonJS/bundler export names from one factory. */
|
||||
export const collectJavaScriptExports = (
|
||||
node: t.Node,
|
||||
bindings?: JavaScriptSemanticAnalysisState,
|
||||
exportsParameter?: t.Identifier,
|
||||
roles?: JavaScriptFactoryExportBindings,
|
||||
): StaticExports => {
|
||||
const exports = new Set<string>();
|
||||
const factoryExports =
|
||||
bindings !== undefined &&
|
||||
t.isFunction(node) &&
|
||||
exportsParameter !== undefined
|
||||
? resolveSemanticBindingState(
|
||||
bindings,
|
||||
exportsParameter,
|
||||
exportsParameter.name,
|
||||
)?.bindingId
|
||||
const bindingId = (
|
||||
reference: t.Node | null | undefined,
|
||||
): string | undefined =>
|
||||
bindings !== undefined && t.isIdentifier(reference)
|
||||
? resolveSemanticBindingState(bindings, reference, reference.name)
|
||||
?.bindingId
|
||||
: undefined;
|
||||
const isExportsAlias = (reference: t.Node | null): boolean => {
|
||||
if (!t.isIdentifier(reference)) return false;
|
||||
if (factoryExports === undefined) return reference.name === "exports";
|
||||
return (
|
||||
bindings !== undefined &&
|
||||
resolveSemanticBindingState(bindings, reference, reference.name)
|
||||
?.bindingId === factoryExports
|
||||
);
|
||||
const identity = (
|
||||
role: keyof JavaScriptFactoryExportBindings,
|
||||
fallback: string,
|
||||
) => {
|
||||
const id = bindingId(roles?.[role]);
|
||||
return (reference: t.Node | null): boolean => {
|
||||
if (!t.isIdentifier(reference)) return false;
|
||||
if (roles === undefined) return reference.name === fallback;
|
||||
return id !== undefined && bindingId(reference) === id;
|
||||
};
|
||||
};
|
||||
const isExportsAlias = identity("exports", "exports");
|
||||
const isModuleAlias = identity("module", "module");
|
||||
const isLoaderAlias = identity("loader", "__webpack_require__");
|
||||
const receivers = { isExportsAlias, isModuleAlias };
|
||||
traverseJavaScriptAst(node, {
|
||||
enter: (current) => {
|
||||
if (t.isAssignmentExpression(current))
|
||||
@@ -62,18 +74,34 @@ export const collectJavaScriptExports = (
|
||||
current.left,
|
||||
current.right,
|
||||
exports,
|
||||
isExportsAlias,
|
||||
receivers,
|
||||
);
|
||||
if (t.isCallExpression(current))
|
||||
collectCallExports(current, exports, isExportsAlias);
|
||||
if (t.isCallExpression(current)) {
|
||||
const intrinsic =
|
||||
bindings !== undefined &&
|
||||
isUnmodifiedGlobalReference(current.callee, bindings, [
|
||||
"Object",
|
||||
"defineProperty",
|
||||
]);
|
||||
collectCallExports(
|
||||
current,
|
||||
exports,
|
||||
receivers,
|
||||
intrinsic,
|
||||
isLoaderAlias,
|
||||
);
|
||||
}
|
||||
return undefined;
|
||||
},
|
||||
});
|
||||
return {
|
||||
values: [...exports].sort(compareUnicodeCodePoints),
|
||||
};
|
||||
return { values: [...exports].sort(compareUnicodeCodePoints) };
|
||||
};
|
||||
|
||||
interface ExportReceivers {
|
||||
readonly isExportsAlias: (root: t.Node | null) => boolean;
|
||||
readonly isModuleAlias: (root: t.Node | null) => boolean;
|
||||
}
|
||||
|
||||
const semanticTokens = (node: t.Node): string[] => {
|
||||
const tokens = [`node:${node.type}`];
|
||||
if (t.isStringLiteral(node)) tokens.push(`string:${node.value}`);
|
||||
@@ -140,7 +168,7 @@ const collectAssignmentExports = (
|
||||
left: t.LVal | t.OptionalMemberExpression,
|
||||
right: t.Expression,
|
||||
output: Set<string>,
|
||||
isExportsAlias: (root: t.Node | null) => boolean,
|
||||
{ isExportsAlias, isModuleAlias }: ExportReceivers,
|
||||
): void => {
|
||||
const path = memberPath(left);
|
||||
if (path === null) return;
|
||||
@@ -150,7 +178,7 @@ const collectAssignmentExports = (
|
||||
if (path.length === 1 && exportsAlias) return;
|
||||
const exportOffset = exportsAlias
|
||||
? 1
|
||||
: path[0] === "module" && path[1] === "exports"
|
||||
: isModuleAlias(root) && path[1] === "exports"
|
||||
? 2
|
||||
: null;
|
||||
if (exportOffset === null) return;
|
||||
@@ -168,15 +196,17 @@ const collectAssignmentExports = (
|
||||
const collectCallExports = (
|
||||
call: t.CallExpression,
|
||||
output: Set<string>,
|
||||
isExportsAlias: (root: t.Node | null) => boolean,
|
||||
{ isExportsAlias, isModuleAlias }: ExportReceivers,
|
||||
intrinsic: boolean,
|
||||
isLoaderAlias: (root: t.Node | null) => boolean,
|
||||
): void => {
|
||||
const callee = memberPath(call.callee);
|
||||
if (callee === null) return;
|
||||
const definePropertyCall =
|
||||
const definePropertyCall = intrinsic;
|
||||
const declarationCall =
|
||||
callee.length === 2 &&
|
||||
callee[0] === "Object" &&
|
||||
callee[1] === "defineProperty";
|
||||
const declarationCall = callee.length >= 2 && callee.at(-1) === "d";
|
||||
callee[1] === "d" &&
|
||||
isLoaderAlias(memberRoot(call.callee));
|
||||
if (!definePropertyCall && !declarationCall) return;
|
||||
const targetNode = call.arguments[0];
|
||||
const target = memberPath(targetNode);
|
||||
@@ -184,7 +214,7 @@ const collectCallExports = (
|
||||
isExportsAlias(targetNode ?? null) ||
|
||||
(target !== null &&
|
||||
target.length === 2 &&
|
||||
target[0] === "module" &&
|
||||
isModuleAlias(memberRoot(targetNode)) &&
|
||||
target[1] === "exports");
|
||||
if (!exportTarget) return;
|
||||
if (definePropertyCall) {
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js";
|
||||
import { argumentValue } from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { evaluateSemanticExpression } from "./javascriptSemanticValues.js";
|
||||
|
||||
type Bootstrap = NonNullable<ReturnType<typeof javascriptBootstrapAt>>;
|
||||
|
||||
@@ -452,7 +452,7 @@ const referencesAreContained = (
|
||||
const use: Use = { node, parent, ancestors, shape, loaderIds, state };
|
||||
if (role !== "loader") contained = containedUse(role, use);
|
||||
else if (exposedTableUse(use) === "escaped") contained = false;
|
||||
else exposedSlotWrite(use, replacedKeys);
|
||||
else contained = exposedSlotWrite(use, replacedKeys);
|
||||
},
|
||||
});
|
||||
return contained ? { replacedKeys } : undefined;
|
||||
@@ -522,13 +522,9 @@ const exposedTableUse = (use: Use): "other" | "contained" | "escaped" => {
|
||||
return entryWritten ? "escaped" : "contained";
|
||||
};
|
||||
|
||||
/**
|
||||
* `loader.m[key] = factory` or `delete loader.c[key]` replaces a literal slot.
|
||||
* Writes with runtime keys are chunk installation, which adds modules under
|
||||
* their own ids; they are not treated as replacing a declared slot.
|
||||
*/
|
||||
const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): void => {
|
||||
const { parent, ancestors } = use;
|
||||
/** Unknown computed writes may replace any existing slot; fresh installation is unproven. */
|
||||
const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): boolean => {
|
||||
const { parent, ancestors, state } = use;
|
||||
const slot = ancestors.at(-2);
|
||||
const write = ancestors.at(-3);
|
||||
if (
|
||||
@@ -545,11 +541,21 @@ const exposedSlotWrite = (use: Use, replacedKeys: Set<string>): void => {
|
||||
t.isUpdateExpression(write)
|
||||
)
|
||||
)
|
||||
return;
|
||||
const key = slot.computed
|
||||
? argumentValue(slot.property)
|
||||
: semanticStaticPropertyKey(slot.property, false);
|
||||
if (key !== undefined && key !== null) replacedKeys.add(key);
|
||||
return true;
|
||||
if (!slot.computed) {
|
||||
const key = semanticStaticPropertyKey(slot.property, false);
|
||||
if (key === null) return false;
|
||||
replacedKeys.add(key);
|
||||
return true;
|
||||
}
|
||||
const key = evaluateSemanticExpression(slot.property, state);
|
||||
if (
|
||||
key.status !== "literal" ||
|
||||
(typeof key.value !== "string" && typeof key.value !== "number")
|
||||
)
|
||||
return false;
|
||||
replacedKeys.add(String(key.value));
|
||||
return true;
|
||||
};
|
||||
|
||||
const bindingOf = (
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import { collectJavaScriptIdentityWritesSteps } from "./javascriptElectronMemberWrites.js";
|
||||
import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js";
|
||||
import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import {
|
||||
semanticRequireOrigin,
|
||||
@@ -153,18 +155,41 @@ const canonicalWebpackInteropBindings = (
|
||||
// The shared identity-write owner covers assignment, delete, destructuring,
|
||||
// and Object/Reflect mutators; any write that may select `n` counts.
|
||||
const writes = new Set<t.Node>();
|
||||
const loaderMayOwnReference = (root: t.Node): boolean => {
|
||||
const pending = [root];
|
||||
const seen = new Set<t.Node>();
|
||||
while (pending.length > 0) {
|
||||
const reference = pending.pop();
|
||||
if (reference === undefined || seen.has(reference)) continue;
|
||||
seen.add(reference);
|
||||
if (seen.size > SEMANTIC_ALIAS_SELECTION_LIMIT) {
|
||||
state.valueResourceLimits.add("alias-selections");
|
||||
return true;
|
||||
}
|
||||
const node = unwrapJavaScriptExpression(reference).node;
|
||||
if (!t.isIdentifier(node)) continue;
|
||||
const binding = resolveSemanticBindingState(state, node, node.name);
|
||||
if (binding === undefined) continue;
|
||||
if (loaderBindingIds.has(binding.bindingId)) return true;
|
||||
const selected = semanticBindingAtRead(
|
||||
binding,
|
||||
node,
|
||||
state.parentsByNode,
|
||||
).binding;
|
||||
for (const source of [
|
||||
...selected.initializers,
|
||||
...selected.referenceInitializers,
|
||||
])
|
||||
if (source.projection.length === 0) pending.push(source.node);
|
||||
}
|
||||
return false;
|
||||
};
|
||||
const interop = createSemanticModuleInterop(state);
|
||||
completeSemanticSteps(
|
||||
collectJavaScriptIdentityWritesSteps(
|
||||
state.program,
|
||||
(root, kind) => {
|
||||
const node = unwrapJavaScriptExpression(root).node;
|
||||
return kind === "member" &&
|
||||
t.isIdentifier(node) &&
|
||||
loaderBindingIds.has(
|
||||
resolveSemanticBindingState(state, node, node.name)?.bindingId ??
|
||||
"",
|
||||
)
|
||||
return kind === "member" && loaderMayOwnReference(root)
|
||||
? [["loader"]]
|
||||
: [];
|
||||
},
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { runInNewContext } from "node:vm";
|
||||
|
||||
import { transformSync } from "esbuild";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
it.each([false, true])(
|
||||
"retains generated esbuild exports with module use %s",
|
||||
(usesModule) => {
|
||||
const { code } = transformSync(
|
||||
`exports.parse = () => ${usesModule ? "module.id" : "42"};`,
|
||||
{
|
||||
format: "iife",
|
||||
globalName: "fixture",
|
||||
},
|
||||
);
|
||||
const runtime: { fixture?: Record<string, unknown> } = {};
|
||||
runInNewContext(code, runtime);
|
||||
const expected = Object.keys(runtime.fixture ?? {});
|
||||
expect(expected).toEqual(["parse"]);
|
||||
const analysis = analyzeJavaScriptStaticSource(code);
|
||||
expect(analysis.bundler_registrations).toMatchObject([
|
||||
{
|
||||
bundler: "esbuild",
|
||||
modules: [{ exports: expected, factory_require_name: null }],
|
||||
},
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["module", "renamed"])(
|
||||
"uses the %s factory binding for every export receiver",
|
||||
(moduleName) => {
|
||||
const analysis =
|
||||
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
|
||||
17(${moduleName}, api, load) {
|
||||
${moduleName}.exports.direct = 1;
|
||||
Object.defineProperty(${moduleName}.exports, "defined", {value: 2});
|
||||
load.d(${moduleName}.exports, {declared: () => 3});
|
||||
{ const module = {exports: {}}; module.exports.ghost = 4; }
|
||||
{ const load = {d() {}}; load.d(api, {fake: () => 5}); }
|
||||
}
|
||||
}]);`);
|
||||
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
|
||||
"declared",
|
||||
"defined",
|
||||
"direct",
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"const Object = {defineProperty() {}};",
|
||||
"globalThis.Object.defineProperty = () => {};",
|
||||
])("requires the global property-definition intrinsic: %s", (prefix) => {
|
||||
const analysis =
|
||||
analyzeJavaScriptStaticSource(`globalThis.webpackChunkApp.push([[1], {
|
||||
17(mod, api) { ${prefix} Object.defineProperty(api, "ghost", {value: 1}); api.real = 2; }
|
||||
}]);`);
|
||||
expect(analysis.bundler_registrations[0]?.modules[0]?.exports).toEqual([
|
||||
"real",
|
||||
]);
|
||||
});
|
||||
@@ -256,15 +256,29 @@ const recoverBundlerModule = (
|
||||
if (key.startsWith("[computed@") || key.startsWith("[unknown@"))
|
||||
accumulator.unknownFindings += 1;
|
||||
const fingerprint = fingerprintJavaScriptAst(factory);
|
||||
const exportsParameter =
|
||||
runtimeFunctionParameters(factory)[runtime === "esbuild-commonjs" ? 0 : 1];
|
||||
const exportsValue = collectJavaScriptExports(
|
||||
factory,
|
||||
bindings,
|
||||
t.isIdentifier(exportsParameter) ? exportsParameter : undefined,
|
||||
);
|
||||
const requireName = factoryRequireName(factory);
|
||||
const requireParameter = runtimeFunctionParameters(factory)[2];
|
||||
const parameters = runtimeFunctionParameters(factory);
|
||||
const esbuild = runtime.startsWith("esbuild-");
|
||||
const identifierAt = (index: number): t.Identifier | undefined => {
|
||||
const parameter = parameters[index];
|
||||
return t.isIdentifier(parameter) ? parameter : undefined;
|
||||
};
|
||||
const exportsValue = collectJavaScriptExports(factory, bindings, {
|
||||
exports:
|
||||
runtime === "esbuild-commonjs"
|
||||
? identifierAt(0)
|
||||
: esbuild
|
||||
? undefined
|
||||
: identifierAt(1),
|
||||
module:
|
||||
runtime === "esbuild-commonjs"
|
||||
? identifierAt(1)
|
||||
: esbuild
|
||||
? undefined
|
||||
: identifierAt(0),
|
||||
loader: esbuild ? undefined : identifierAt(2),
|
||||
});
|
||||
const requireName = esbuild ? null : factoryRequireName(factory);
|
||||
const requireParameter = esbuild ? undefined : parameters[2];
|
||||
const requireBindingId = t.isIdentifier(requireParameter)
|
||||
? (resolveSemanticBindingState(
|
||||
bindings,
|
||||
|
||||
Reference in New Issue
Block a user