fix(javascript): retain stored receiver capture authority

This commit is contained in:
morluto
2026-10-24 08:02:16 +08:00
parent d3a33fcfca
commit eb721ebbf1
3 changed files with 284 additions and 23 deletions
@@ -1,3 +1,4 @@
import { runInNewContext } from "node:vm";
import { describe, expect, it } from "vitest";
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
@@ -69,6 +70,8 @@ describe("Electron constant option evidence", () => {
"const { webPreferences: alias } = options; mutate(alias);",
"Object.assign(options, unknown);",
"const box = {}; box.options = options; box.options.webPreferences.sandbox = false;",
"const box = {}; box.options = options; const saved = box.options; box.options = {}; saved.webPreferences.sandbox = false;",
"const values = [options]; values[Symbol.iterator] = input; const box = {}; [box.options] = values; box.options.webPreferences.sandbox = false;",
"options = replacement;",
])("keeps changed or escaped constant options unknown: %s", (mutation) => {
const analysis = analyzeJavaScriptStaticSource(`
@@ -87,6 +90,77 @@ describe("Electron constant option evidence", () => {
]);
});
it.each([
"const box = {}; box.options = options; box.options = { webPreferences: { sandbox: false } }; box.options.webPreferences.sandbox = false;",
"let box = { options: { webPreferences: { sandbox: false } } }; const saved = box; box = {}; box.options = options; saved.options.webPreferences.sandbox = false;",
"const values = [{ webPreferences: { sandbox: false } }]; values[Symbol.iterator] = input; const box = {}; [box.options] = values; box.options.webPreferences.sandbox = false;",
])(
"keeps independent options intact after stored receiver replacement: %s",
(mutation) => {
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow } = require("electron");
const options = { webPreferences: { sandbox: true } };
${mutation}
new BrowserWindow(options);
`);
expect(analysis.electron.browser_windows).toMatchObject([
{
options_status: "object-literal",
web_preferences_status: "object-literal",
web_preferences: [
{ name: "sandbox", value: { status: "literal", value: true } },
],
},
]);
},
);
it.each([
["inner.current = second;", [true, false], ["object-literal", "dynamic"]],
[
"source.inner = { current: second };",
[false, true],
["dynamic", "object-literal"],
],
] as const)(
"preserves shallow-copy capture depth across later writes: %s",
(replacement, runtime, statuses) => {
const body = `
const first = { webPreferences: { sandbox: true } };
const second = { webPreferences: { sandbox: true } };
const inner = { current: first };
const source = { inner };
const box = {};
({...box.options} = source);
${replacement}
box.options.inner.current.webPreferences.sandbox = false;
`;
expect(
Array.from(
runInNewContext(
`${body};[first.webPreferences.sandbox, second.webPreferences.sandbox]`,
),
),
).toEqual(runtime);
const analysis = analyzeJavaScriptStaticSource(`
const { BrowserWindow } = require("electron");
${body}
new BrowserWindow(first);
new BrowserWindow(second);
`);
expect(
analysis.electron.browser_windows.map(
({ options_status }) => options_status,
),
).toEqual(statuses);
const intact =
analysis.electron.browser_windows[statuses.indexOf("object-literal")];
expect(intact?.web_preferences).toMatchObject([
{ name: "sandbox", value: { status: "literal", value: true } },
]);
},
);
it.each([
"new BrowserWindow(options); const options = { webPreferences: { sandbox: true } };",
"const options = alias; const alias = { webPreferences: { sandbox: true } }; new BrowserWindow(options);",
@@ -33,6 +33,13 @@ import {
evaluateSemanticExpression,
} from "./javascriptSemanticValues.js";
import { semanticContainer } from "./javascriptSemanticSlots.js";
import {
semanticProjectedSourceFactsAtRead,
semanticShiftCapturedSelections,
semanticNormalizeCapturedSelections,
type SemanticCapturedSelection,
} from "./javascriptSemanticProjectedSources.js";
import {
semanticArrayPropertyReferences,
semanticClassCandidates,
@@ -84,6 +91,13 @@ import {
} from "./javascriptAstValues.js";
type PropertyPath = JavaScriptSemanticPropertyPath;
interface MutationSelectionContext {
readonly memberReadAt?: t.Node | undefined;
readonly capturedSelections?:
| readonly SemanticCapturedSelection[]
| undefined;
}
type ValueEffect = "write" | "escape";
/** Only replacing or possibly selecting an iterator-bearing prototype slot changes iteration. */
@@ -570,7 +584,7 @@ export function* collectSemanticMemberMutationsSteps(
// A capture key already ends the array; a path stays one nested element.
const lifetimeKey = (captureKey: string, path: PropertyPath): string =>
`${captureKey.slice(0, -1)},${JSON.stringify(path)}]`;
const pendingReferences: {
const pendingReferences: (MutationSelectionContext & {
readonly initializer: JavaScriptSemanticBindingState["referenceInitializers"][number];
readonly path: PropertyPath;
readonly bindings: ReadonlySet<string>;
@@ -580,7 +594,7 @@ export function* collectSemanticMemberMutationsSteps(
readonly widenedGetterResult?: true;
readonly definiteReceiver: boolean;
readonly assignedSources?: readonly SemanticIterationSource[];
}[] = [];
})[] = [];
const iterableReferences: IterableReference[] = [];
const iterableReferenceKeys = new WeakMap<t.Node, Set<string>>();
const deferIterable = (reference: IterableReference): void => {
@@ -594,6 +608,14 @@ export function* collectSemanticMemberMutationsSteps(
reference.mutation?.end,
reference.originAt?.start,
reference.originAt?.end,
reference.memberReadAt?.start,
reference.memberReadAt?.end,
reference.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
reference.iterationOnly,
reference.widenedGetterResult === true,
reference.definiteReceiver === true,
@@ -637,26 +659,41 @@ export function* collectSemanticMemberMutationsSteps(
widenedGetterResult = false,
assignedSources?: readonly SemanticIterationSource[],
definiteReceiver = true,
selection?: MutationSelectionContext,
): void => {
const pending: (ReferencedValue & {
readonly bindings: ReadonlySet<string>;
readonly originAt: t.Node | undefined;
readonly definiteReceiver: boolean;
})[] = [
const pending: (ReferencedValue &
MutationSelectionContext & {
readonly bindings: ReadonlySet<string>;
readonly originAt: t.Node | undefined;
readonly definiteReceiver: boolean;
})[] = [
{
node,
path,
bindings,
originAt,
definiteReceiver,
memberReadAt: selection?.memberReadAt ?? mutation ?? originAt,
capturedSelections: selection?.capturedSelections,
...(ownerInitializer === undefined ? {} : { ownerInitializer }),
},
];
// Visit shallower alias traversals first so their exact prefixes suppress
// longer loop paths before those paths enumerate every assignment order.
for (let cursor = 0; cursor < pending.length; cursor++) {
const current = pending[cursor];
if (current === undefined) break;
const candidate = pending[cursor];
if (candidate === undefined) break;
// A capture deeper than the selected path is no longer applicable. Keeping
// it would manufacture new contexts on a repeated source alias cycle.
const current = candidate.capturedSelections?.length
? {
...candidate,
capturedSelections: semanticNormalizeCapturedSelections(
candidate.capturedSelections,
candidate.path.length,
),
}
: candidate;
const expression = unwrapJavaScriptExpression(current.node).node;
const required = semanticRequireOrigin(expression, state);
if (required !== undefined) {
@@ -719,11 +756,23 @@ export function* collectSemanticMemberMutationsSteps(
// An alias restored from a saved reference can revisit the same binding
// at an earlier capture or a shorter getter-receiver path. Only a
// repeated capture and path closes a cycle.
const captureKey = captureKeyOf(
binding,
current.originAt,
current.ownerInitializer,
);
const captureSelectionIdentity = JSON.stringify([
current.memberReadAt?.start,
current.memberReadAt?.end,
current.capturedSelections?.map(({ at, depth, ownEnumerable }) => [
at.start,
at.end,
depth,
ownEnumerable,
]),
]);
// Ancestry closes repeated source identities before recursively selected
// copy vectors grow; independent contexts remain distinct in coverage.
const captureKey = JSON.stringify([
captureKeyOf(binding, current.originAt, current.ownerInitializer),
current.memberReadAt?.start,
current.memberReadAt?.end,
]);
const lifetime = lifetimeKey(captureKey, current.path);
if (current.bindings.has(lifetime)) continue;
// A self alias such as `node = node[key]` revisits its capture with the
@@ -756,6 +805,9 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt,
current.ownerInitializer,
widenedGetterResult,
undefined,
undefined,
current,
);
continue;
}
@@ -788,6 +840,7 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt?.end,
current.ownerInitializer?.start,
current.ownerInitializer?.end,
captureSelectionIdentity,
]);
const previous = selections.get(selectionIdentity);
const excludedKeys =
@@ -844,6 +897,10 @@ export function* collectSemanticMemberMutationsSteps(
mutation,
current.originAt,
current.ownerInitializer,
false,
undefined,
undefined,
current,
);
}
// Keep an antichain of exact paths. Widening distinct member keys would
@@ -860,7 +917,7 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt?.start ?? -1
}\u0001${current.originAt?.end ?? -1}\u0001${
current.ownerInitializer?.start ?? -1
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}`;
}\u0001${current.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${current.definiteReceiver}\u0001${assignedSources?.[0]?.target?.start ?? -1}\u0001${captureSelectionIdentity}`;
let covering = coveringPaths.get(pathState);
if (covering === undefined) {
covering = new SemanticPropertyPathCoverage();
@@ -883,6 +940,7 @@ export function* collectSemanticMemberMutationsSteps(
nested.add(captureKey);
// Returning a literal's receiver reaches that allocation, not the
// binding's older values read while evaluating its initializer.
let retainsInitializers = true;
if (!primitiveWrite) {
for (const origin of origins.directOrigins) {
const shared = semanticMutableBuiltinOrigin(origin, path);
@@ -898,6 +956,27 @@ export function* collectSemanticMemberMutationsSteps(
...origins.initializers,
...origins.referenceInitializers,
];
const receiver =
effect === "write" &&
current.path.length > 1 &&
binding.memberWrites.some(
(write) =>
write.assignedSources !== undefined &&
write.path.length < current.path.length,
)
? semanticProjectedSourceFactsAtRead(
binding,
current.node,
current.path.slice(0, -1),
state,
false,
current.memberReadAt ?? current.originAt ?? current.node,
current.capturedSelections,
)
: undefined;
// A defined owner result already includes every eligible initializer;
// replaying them separately duplicates selection/capture histories.
retainsInitializers = receiver === undefined;
if (effect === "escape")
binding.escapedPaths.push({
path,
@@ -905,7 +984,7 @@ export function* collectSemanticMemberMutationsSteps(
receiverInitializers,
...(current.originAt === undefined
? {}
: { receiverAt: current.originAt }),
: { receiverAt: current.memberReadAt ?? current.originAt }),
});
else
binding.memberWrites.push({
@@ -917,11 +996,34 @@ export function* collectSemanticMemberMutationsSteps(
receiverInitializers,
...(current.originAt === undefined
? {}
: { receiverAt: current.originAt }),
: { receiverAt: current.memberReadAt ?? current.originAt }),
...(assignedSources === undefined ? {} : { assignedSources }),
});
clearSemanticValueCaches(state);
for (const initializer of origins.initializers)
// Resolve a stored child at the receiver capture, before selecting
// the final written slot. The source owner preserves replacements
// and captures; a local container does not isolate its children.
if (receiver !== undefined) {
for (const source of receiver.sources)
pending.push({
node: source.node,
path: [...source.path, current.path.at(-1)!],
bindings: nested,
definiteReceiver: false,
originAt: source.node,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
source.path.length - (current.path.length - 1),
) ?? []),
...(source.capturedSelections ?? []),
]),
});
}
for (const initializer of retainsInitializers
? origins.initializers
: [])
pending.push({
node: initializer.node,
path: [...initializer.projection, ...path],
@@ -932,9 +1034,16 @@ export function* collectSemanticMemberMutationsSteps(
origins.referenceInitializers.length ===
1,
originAt: initializer.node,
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
initializer.projection.length,
),
});
}
for (const initializer of origins.referenceInitializers) {
for (const initializer of retainsInitializers
? origins.referenceInitializers
: []) {
const copiedPath = semanticCopiedReferencePath(
initializer,
path,
@@ -957,6 +1066,11 @@ export function* collectSemanticMemberMutationsSteps(
effect,
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
originAt: initializer.node,
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
copiedPath.length - path.length,
),
...(mutation === undefined ? {} : { mutation }),
});
}
@@ -974,6 +1088,17 @@ export function* collectSemanticMemberMutationsSteps(
bindings: current.bindings,
definiteReceiver: current.definiteReceiver && key !== null,
originAt: current.originAt,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
1,
) ?? []),
...(current.originAt !== undefined &&
unwrapJavaScriptExpression(current.originAt).node === expression
? [{ at: current.originAt, depth: 1 }]
: []),
]),
});
} else {
const references =
@@ -985,6 +1110,13 @@ export function* collectSemanticMemberMutationsSteps(
)
: expandedValues(expression, current.path, effect);
for (const value of references) {
if (
value.inherited &&
current.capturedSelections?.some(
(capture) => capture.depth === 1 && capture.ownEnumerable,
)
)
continue;
if (
value.escapes &&
(effect !== "escape" ||
@@ -999,6 +1131,9 @@ export function* collectSemanticMemberMutationsSteps(
current.originAt,
value.ownerInitializer,
widenedGetterResult || value.widenedGetterResult === true,
undefined,
undefined,
current,
);
else if (value.iterableFallbackPath !== undefined)
deferIterable({
@@ -1011,6 +1146,11 @@ export function* collectSemanticMemberMutationsSteps(
...(assignedSources === undefined ? {} : { assignedSources }),
effect,
originAt: current.originAt,
memberReadAt: current.memberReadAt,
capturedSelections: semanticShiftCapturedSelections(
current.capturedSelections,
value.path.length - current.path.length,
),
...(widenedGetterResult ? { widenedGetterResult: true } : {}),
...(mutation === undefined ? {} : { mutation }),
});
@@ -1034,6 +1174,24 @@ export function* collectSemanticMemberMutationsSteps(
(key) => key !== null && typeof key !== "object",
),
originAt: current.originAt,
memberReadAt: current.memberReadAt,
capturedSelections: semanticNormalizeCapturedSelections([
...(semanticShiftCapturedSelections(
current.capturedSelections,
value.path.length - current.path.length,
) ?? []),
...(value.capturedSelectionAt === undefined
? []
: [
{
at: value.capturedSelectionAt,
depth: value.path.length - current.path.length + 1,
...(value.ownEnumerable
? { ownEnumerable: true as const }
: {}),
},
]),
]),
});
}
}
@@ -1369,6 +1527,8 @@ export function* collectSemanticMemberMutationsSteps(
? { widenedGetterResult: true }
: {}),
originAt: initializer.node,
memberReadAt: reference.memberReadAt,
capturedSelections: reference.capturedSelections,
...(reference.mutation === undefined
? {}
: { mutation: reference.mutation }),
@@ -1394,6 +1554,8 @@ export function* collectSemanticMemberMutationsSteps(
? { widenedGetterResult: true }
: {}),
originAt: reference.originAt,
memberReadAt: reference.memberReadAt,
capturedSelections: reference.capturedSelections,
...(reference.mutation === undefined
? {}
: { mutation: reference.mutation }),
@@ -1410,6 +1572,7 @@ export function* collectSemanticMemberMutationsSteps(
reference.widenedGetterResult === true,
reference.assignedSources,
reference.definiteReceiver,
reference,
);
}
for (const [index, reference] of [...iterableReferences].entries()) {
@@ -1438,6 +1601,7 @@ export function* collectSemanticMemberMutationsSteps(
reference.widenedGetterResult === true,
reference.assignedSources,
reference.definiteReceiver === true,
reference,
);
}
// Native Map lookup methods do not expose their receiver to user code.
@@ -1470,7 +1634,7 @@ interface ReferencedValue extends JavaScriptSemanticObjectReference {
readonly iterableFallbackPath?: PropertyPath;
}
interface IterableReference extends ReferencedValue {
interface IterableReference extends ReferencedValue, MutationSelectionContext {
readonly projection: PropertyPath;
readonly fallbackPath: PropertyPath;
readonly bindings: ReadonlySet<string>;
@@ -178,10 +178,33 @@ export const semanticProjectedSourceEligibilityAtRead = (
)?.sources;
/** Keep imported origins subject to the same selected writes as other values. */
export const semanticProjectedSourceFactsAtRead = (
// Projection alternatives are introduced by callers. An ordinary property path
// remains ordinary while the owner selects stored and copied source prefixes.
export function semanticProjectedSourceFactsAtRead(
binding: JavaScriptSemanticBindingState,
reference: t.Node,
path: JavaScriptSemanticPropertyPath,
state: JavaScriptSemanticAnalysisState,
includeUnknown: boolean,
memberReadAt?: t.Node,
capturedSelections?: readonly SemanticCapturedSelection[],
retainEligibility?: boolean,
):
| {
readonly sources: readonly (Omit<ProjectedSource, "path"> & {
readonly path: JavaScriptSemanticPropertyPath;
})[];
readonly retainsOrigin: boolean;
}
| undefined;
export function semanticProjectedSourceFactsAtRead(
...arguments_: Parameters<typeof projectedSourceFactsAtRead>
): ReturnType<typeof projectedSourceFactsAtRead> =>
projectedSourceFactsAtRead(...arguments_);
): ReturnType<typeof projectedSourceFactsAtRead>;
export function semanticProjectedSourceFactsAtRead(
...arguments_: Parameters<typeof projectedSourceFactsAtRead>
): ReturnType<typeof projectedSourceFactsAtRead> {
return projectedSourceFactsAtRead(...arguments_);
}
const projectedSourceFactsAtRead = (
binding: JavaScriptSemanticBindingState,