From 5cbcd5855055574938612e676bc6b213f3d6734f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 24 Oct 2026 02:28:03 +0800 Subject: [PATCH] fix(javascript): bind bundle exports and mutations to factory roles --- docs/javascript-artifact-reconstruction.md | 10 ++ .../electronBundledRequireIdentity.test.ts | 23 +++++ .../javascript/javascriptAstFingerprint.ts | 94 ++++++++++++------- .../javascriptBootstrapIntegrity.ts | 34 ++++--- .../javascriptBundledRequireOrigin.ts | 39 ++++++-- .../javascriptFactoryExportRoles.test.ts | 64 +++++++++++++ .../javascriptStaticAnalysisBundler.ts | 32 +++++-- 7 files changed, 234 insertions(+), 62 deletions(-) create mode 100644 src/domain/javascript/javascriptFactoryExportRoles.test.ts diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 719e0c93b7..68c7ad521c 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -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, diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index e3f34043ef..f2d6e7cf90 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -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) => { diff --git a/src/domain/javascript/javascriptAstFingerprint.ts b/src/domain/javascript/javascriptAstFingerprint.ts index 7b3407d853..6b76eb124a 100644 --- a/src/domain/javascript/javascriptAstFingerprint.ts +++ b/src/domain/javascript/javascriptAstFingerprint.ts @@ -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(); - 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, - 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, - 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) { diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts index 69043fe079..142fd41ef1 100644 --- a/src/domain/javascript/javascriptBootstrapIntegrity.ts +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -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>; @@ -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): void => { - const { parent, ancestors } = use; +/** Unknown computed writes may replace any existing slot; fresh installation is unproven. */ +const exposedSlotWrite = (use: Use, replacedKeys: Set): 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): 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 = ( diff --git a/src/domain/javascript/javascriptBundledRequireOrigin.ts b/src/domain/javascript/javascriptBundledRequireOrigin.ts index b668986ae7..27f29aef4c 100644 --- a/src/domain/javascript/javascriptBundledRequireOrigin.ts +++ b/src/domain/javascript/javascriptBundledRequireOrigin.ts @@ -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(); + const loaderMayOwnReference = (root: t.Node): boolean => { + const pending = [root]; + const seen = new Set(); + 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"]] : []; }, diff --git a/src/domain/javascript/javascriptFactoryExportRoles.test.ts b/src/domain/javascript/javascriptFactoryExportRoles.test.ts new file mode 100644 index 0000000000..0cecbad025 --- /dev/null +++ b/src/domain/javascript/javascriptFactoryExportRoles.test.ts @@ -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 } = {}; + 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", + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisBundler.ts b/src/domain/javascript/javascriptStaticAnalysisBundler.ts index 7aae839daf..688f3eefd3 100644 --- a/src/domain/javascript/javascriptStaticAnalysisBundler.ts +++ b/src/domain/javascript/javascriptStaticAnalysisBundler.ts @@ -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,