diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index 51e2df33fe..90569cb7f5 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -380,7 +380,12 @@ 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 +chunk installation. A function stored on a standalone loader member does not +write the table merely by being defined: the bootstrap owner excludes its +deferred writes only when that member is unread and the loader namespace stays +confined. Calls, member aliases, dynamic member reads, namespace escapes, or +receiver-based access retain uncertainty about those writes. 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 diff --git a/src/domain/javascript/electronBundledRequireIdentity.test.ts b/src/domain/javascript/electronBundledRequireIdentity.test.ts index f2d6e7cf90..774c302c76 100644 --- a/src/domain/javascript/electronBundledRequireIdentity.test.ts +++ b/src/domain/javascript/electronBundledRequireIdentity.test.ts @@ -246,6 +246,38 @@ describe("Webpack externals with a tampered runtime", () => { }); describe("Webpack externals with a tampered loader or module object", () => { + it.each([ + ["invoked", "n.i(runtimeChunks);"], + ["read through an alias", "const install=n.i;install(runtimeChunks);"], + ["read through a dynamic key", "n[runtimeMember](runtimeChunks);"], + ["exposed with its namespace", "publish(n);"], + [ + "reachable through a helper receiver", + "n.run=function(){this.i(runtimeChunks)};n.run();", + ], + ])( + "keeps an unknown chunk installation unresolved when %s", + (_name, invoke) => { + expectUnresolved( + bootstrap( + EXTERNAL, + CONSUMER, + `n.m=m;n.i=e=>{for(var k in e)n.m[k]=e[k]};${invoke}n(100);`, + ), + ); + }, + ); + + it("does not treat a called assignment value as an unused installer", () => { + expectUnresolved( + bootstrap( + EXTERNAL, + CONSUMER, + "n.m=m;(n.i=e=>{for(var k in e)n.m[k]=e[k]})(runtimeChunks);n(100);", + ), + ); + }); + it.each([ [ "cached exports rewritten through the runtime exposure", diff --git a/src/domain/javascript/javascriptBootstrapIntegrity.ts b/src/domain/javascript/javascriptBootstrapIntegrity.ts index 142fd41ef1..d60117081e 100644 --- a/src/domain/javascript/javascriptBootstrapIntegrity.ts +++ b/src/domain/javascript/javascriptBootstrapIntegrity.ts @@ -438,6 +438,12 @@ const referencesAreContained = ( [...tracked.keys()].flatMap((id) => state.bindingsById.get(id)?.name ?? []), ); const replacedKeys = new Set(); + const unreadDefinitions = unreadRuntimeDefinitions( + call, + shape, + loaderIds, + state, + ); let contained = true; traverseJavaScriptAst(call, { skip: () => !contained, @@ -452,12 +458,87 @@ 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 contained = exposedSlotWrite(use, replacedKeys); + else { + const activation = [...ancestors] + .reverse() + .find((ancestor) => t.isFunction(ancestor)); + contained = + (activation !== undefined && unreadDefinitions.has(activation)) || + exposedSlotWrite(use, replacedKeys); + } }, }); return contained ? { replacedKeys } : undefined; }; +/** + * A function stored on the confined runtime cannot execute until its member is + * read. Keep this proof local to the bootstrap's existing loader/table def-use + * boundary: aliases, dynamic member reads and escapes invalidate it. Merely + * defining Webpack's chunk installer does not install any chunks. + */ +const unreadRuntimeDefinitions = ( + call: t.CallExpression, + shape: LoaderShape, + loaderIds: ReadonlySet, + state: JavaScriptSemanticAnalysisState, +): ReadonlySet => { + const definitions = new Map(); + const readKeys = new Set(); + let confined = true; + traverseJavaScriptAst(call, { + enter: (node, parent, readAncestors) => { + // A helper invoked as `loader.helper()` can obtain the namespace through + // its receiver instead of a tracked loader identifier. + if (t.isThisExpression(node)) confined = false; + if (!t.isIdentifier(node) || parent === null) return; + const ancestors = readAncestors(); + if ( + !t.isReferenced(node, parent, ancestors.at(-2)) || + !loaderIds.has(bindingOf(state, node) ?? "") + ) + return; + if ( + t.isCallExpression(parent) && + (parent.callee === node || + (parent === shape.dispatch && parent.arguments.at(-1) === node)) + ) + return; + if (!t.isMemberExpression(parent) || parent.object !== node) { + confined = false; + return; + } + const key = semanticStaticPropertyKey(parent.property, parent.computed); + if (key === null) { + confined = false; + return; + } + const assignment = ancestors.at(-2); + if ( + t.isAssignmentExpression(assignment, { operator: "=" }) && + assignment.left === parent + ) { + if ( + key !== "m" && + key !== "c" && + t.isFunction(assignment.right) && + t.isExpressionStatement(ancestors.at(-3)) + ) + definitions.set(assignment.right, key); + return; + } + readKeys.add(key); + }, + }); + return new Set( + confined + ? [...definitions] + .filter(([, key]) => !readKeys.has(key)) + .map(([node]) => node) + : [], + ); +}; + interface Use { readonly node: t.Identifier; readonly parent: t.Node;