diff --git a/docs/javascript-artifact-reconstruction.md b/docs/javascript-artifact-reconstruction.md index c36a3b7225..8a635af622 100644 --- a/docs/javascript-artifact-reconstruction.md +++ b/docs/javascript-artifact-reconstruction.md @@ -514,6 +514,14 @@ candidate. Dynamic channels are never paired, and multiple compatible handlers are reported as ambiguous without adding a caller-to-handler edge. A matching channel still does not prove registration order, reachability, or runtime use. +Native-addon call bindings consume the same loader identity and normalized +arguments as dependency analysis, including optional calls. Local methods or +parameters named `require` establish no native binding; `require.resolve` records +lookup rather than a native namespace. CommonJS re-export bindings require a +selected export value and an applicable shared export receiver. Arithmetic and +logical compound stores keep their dependency observations without asserting that +the addon is the selected exported value. + For `.node` bindings, member names mean “requested by JavaScript syntax.” A resolved add-on path does not convert them into verified native exports. Native symbol verification remains a separate deep-analysis claim. diff --git a/src/domain/javascript/electronStaticAnalysisNative.ts b/src/domain/javascript/electronStaticAnalysisNative.ts index 0e46cd7c41..bbff6fbfbc 100644 --- a/src/domain/javascript/electronStaticAnalysisNative.ts +++ b/src/domain/javascript/electronStaticAnalysisNative.ts @@ -15,14 +15,23 @@ import { import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; import { argumentNode, - calleeName, range, stringValue, } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, semanticStaticPropertyKey, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { semanticRequireCallAt } from "./javascriptSemanticRequireOrigin.js"; +import { + commonJsRuntimeExportValues, + indexCommonJsExportReceiversSteps, + type CommonJsExportReceiverEffect, + type CommonJsExportSite, +} from "./javascriptCommonJsExportReceivers.js"; +import type { JavaScriptReceiverIndex } from "./javascriptSemanticAnalysis.js"; +import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; interface NativeBindingInput { @@ -44,10 +53,44 @@ export const inspectElectronNativeNode = ( if (t.isImportDeclaration(node)) inspectImport(node, context); else if (t.isExportNamedDeclaration(node)) inspectNamedExport(node, context); else if (t.isExportAllDeclaration(node)) inspectExportAll(node, context); - else if (t.isVariableDeclarator(node)) inspectRequireBinding(node, context); - else if (t.isAssignmentExpression(node)) inspectReExport(node, context); + else if (t.isVariableDeclarator(node) || t.isAssignmentExpression(node)) { + const value = nativeCallReceiver( + t.isVariableDeclarator(node) ? node.init : node.right, + ); + // Admission is syntax only; the shared call owner proves loader and arguments later. + if ( + value !== undefined && + value.arguments.some((argument) => + t.isArrayExpression(argument) + ? argument.elements.some((element) => + isNativeSpecifier(stringValue(element) ?? "", "require"), + ) + : isNativeSpecifier(stringValue(argument) ?? "", "require"), + ) + ) + context.accumulator.pendingNativeAddonBindings.push(node); + } }; +/** Shared loader, argument and receiver facts settle before native projection. */ +export function* finishElectronNativeBindingsSteps( + context: JavaScriptFindingContext, + index: JavaScriptReceiverIndex, +): Generator { + const pending = context.accumulator.pendingNativeAddonBindings; + if (pending.length === 0) return; + yield* index.indexMemberMutationFactsSteps(); + const receivers = pending.some((node) => t.isAssignmentExpression(node)) + ? yield* indexCommonJsExportReceiversSteps(index.bindings) + : new Map(); + for (const node of pending) { + if (t.isVariableDeclarator(node)) + inspectRequireBinding(node, context, index.bindings); + else inspectReExport(node, context, index.bindings, receivers.get(node)); + } + pending.length = 0; +} + const inspectImport = ( node: t.ImportDeclaration, context: JavaScriptFindingContext, @@ -140,8 +183,9 @@ const inspectExportAll = ( const inspectRequireBinding = ( node: t.VariableDeclarator, context: JavaScriptFindingContext, + bindings: JavaScriptSemanticAnalysisState, ): void => { - const required = nativeRequire(node.init); + const required = nativeRequire(node.init, context, bindings); if (required === undefined) return; addBinding({ context, @@ -156,9 +200,20 @@ const inspectRequireBinding = ( const inspectReExport = ( node: t.AssignmentExpression, context: JavaScriptFindingContext, + bindings: JavaScriptSemanticAnalysisState, + receiver: CommonJsExportReceiverEffect | undefined, ): void => { - const required = nativeRequire(node.right); - if (required === undefined || !isModuleExport(node.left)) return; + const exported = commonJsRuntimeExportValues(node, bindings); + if (exported.length === 0 || receiver === "detached") return; + if (receiver === "uncertain") { + context.accumulator.unknownFindings += 1; + return; + } + // The export owner leaves arithmetic/logical stores without a selected RHS. + const value = exported[0]?.value; + if (value == null) return; + const required = nativeRequire(value, context, bindings); + if (required === undefined) return; addBinding({ context, node, @@ -198,8 +253,22 @@ const addBinding = (input: NativeBindingInput): void => { }); }; +const nativeCallReceiver = ( + node: t.Node | null | undefined, +): t.CallExpression | t.OptionalCallExpression | undefined => { + if (node == null) return undefined; + node = unwrapJavaScriptExpression(node).node; + while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + node = unwrapJavaScriptExpression(node.object).node; + return t.isCallExpression(node) || t.isOptionalCallExpression(node) + ? node + : undefined; +}; + const nativeRequire = ( node: t.Node | null | undefined, + context: JavaScriptFindingContext, + bindings: JavaScriptSemanticAnalysisState, ): | { readonly specifier: string; @@ -207,6 +276,8 @@ const nativeRequire = ( readonly access: NativeMemberAccess; } | undefined => { + if (node == null) return undefined; + node = unwrapJavaScriptExpression(node).node; let member: string | null = null; let hasMemberAccess = false; while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { @@ -215,28 +286,41 @@ const nativeRequire = ( // Walking inward leaves the first member consumed from the addon itself. member = property; hasMemberAccess = true; - node = node.object; + node = unwrapJavaScriptExpression(node.object).node; } - if (!t.isCallExpression(node)) return undefined; - const name = calleeName(node.callee); + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) + return undefined; + const call = semanticRequireCallAt( + node, + bindings, + context.accumulator.factoryRequireBindingIds, + ); if ( - name !== "require" && - !name.endsWith(".require") && - !name.includes("__webpack_require__") + call === undefined || + call.loader === "local" || + call.operation !== "load" ) return undefined; - const specifier = stringValue(argumentNode(node.arguments[0])); - return specifier !== undefined && isNativeSpecifier(specifier, "require") - ? { - specifier, - hasMemberAccess, - access: { - members: member === null ? [] : [member], - namespaceAccess: !hasMemberAccess, - dynamicMemberAccess: hasMemberAccess && member === null, - }, - } - : undefined; + const specifier = stringValue(argumentNode(call.arguments?.[0])); + if (specifier === undefined || !isNativeSpecifier(specifier, "require")) + return undefined; + if ( + call.loader === "unknown" || + !call.sourceRelative || + call.sourceRelativePath !== undefined + ) { + context.accumulator.unknownFindings += 1; + return undefined; + } + return { + specifier, + hasMemberAccess, + access: { + members: member === null ? [] : [member], + namespaceAccess: !hasMemberAccess, + dynamicMemberAccess: hasMemberAccess && member === null, + }, + }; }; interface NativeMemberAccess { @@ -263,18 +347,6 @@ const bindingMembers = (pattern: t.Node): NativeMemberAccess => { return { members, namespaceAccess, dynamicMemberAccess }; }; -const isModuleExport = (node: t.Node): boolean => { - const keys: (string | null)[] = []; - while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { - keys.unshift(semanticStaticPropertyKey(node.property, node.computed)); - node = node.object; - } - if (!t.isIdentifier(node)) return false; - return node.name === "exports" - ? keys.length > 0 - : node.name === "module" && keys[0] === "exports"; -}; - const isNativeSpecifier = ( specifier: string, moduleKind: ElectronNativeAddonBindingFinding["module_kind"], diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 6b6d93824b..d798cfe45f 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -654,6 +654,7 @@ const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { }; export interface JavaScriptRequireCallFact { + readonly operation: "load" | "resolve"; readonly loader: JavaScriptRequireLoaderFact; readonly arguments: | readonly t.CallExpression["arguments"][number][] @@ -670,7 +671,12 @@ export const semanticRequireCallAt = ( factoryBindingIds: ReadonlySet, ): JavaScriptRequireCallFact | undefined => { if (semanticModuleRequireCallAt(node, state)) - return { loader: "unknown", arguments: undefined, sourceRelative: false }; + return { + operation: "load", + loader: "unknown", + arguments: undefined, + sourceRelative: false, + }; const callee = unwrapJavaScriptExpression(node.callee).node; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && @@ -680,7 +686,12 @@ export const semanticRequireCallAt = ( selectedRequireSourcesAt(callee, node, state, factoryBindingIds)?.loader === "local" ) - return { loader: "local", arguments: undefined, sourceRelative: false }; + return { + operation: "load", + loader: "local", + arguments: undefined, + sourceRelative: false, + }; if ( (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" @@ -701,6 +712,7 @@ export const semanticRequireCallAt = ( factoryBindingIds, ); return { + operation: "resolve", loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: @@ -726,6 +738,7 @@ export const semanticRequireCallAt = ( ); if (selected !== undefined || loader !== "local") return { + operation: "load", loader: selected?.loader ?? loader, arguments: node.arguments, sourceRelative: selected?.sourceRelative ?? loader !== "create-require", @@ -767,6 +780,7 @@ export const semanticRequireCallAt = ( factoryBindingIds, ); return { + operation: "load", loader: invocation.path[1] === "local" ? "local" @@ -790,6 +804,7 @@ export const semanticRequireCallAt = ( return factory === undefined ? undefined : { + operation: "load", loader: factory, arguments: node.arguments.slice(t.isIdentifier(node.callee) ? 0 : 1), sourceRelative: true, diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 7b45553f16..16c72077a9 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -15,6 +15,7 @@ import { } from "./javascriptSemanticTraversal.js"; import { isElectronRendererLoad } from "./electronStaticRenderer.js"; +import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js"; import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js"; import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; @@ -124,6 +125,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( ); yield* traverseStaticSourceSteps(file, findings, bindings, routeScan); const routes = yield* routeScan.finishSteps(); + yield* finishElectronNativeBindingsSteps(findings, index); yield* finishElectronBrowserWindowsSteps(findings, index); if (accumulator.pendingModuleRequireCalls.length > 0) { yield* index.indexMemberMutationFactsSteps(); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index e3c2ab4196..d450f425de 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -77,6 +77,10 @@ export interface JavaScriptAnalysisAccumulator { readonly senderValidations: LocatedJavaScriptFinding[]; readonly utilityProcesses: LocatedJavaScriptFinding[]; readonly nativeAddonBindings: LocatedJavaScriptFinding[]; + readonly pendingNativeAddonBindings: ( + | t.VariableDeclarator + | t.AssignmentExpression + )[]; readonly moduleRangeIndex: JavaScriptModuleRangeIndex; /** Binding identities proven as third-parameter loaders of recovered factories. */ readonly factoryRequireBindingIds: Set; @@ -157,6 +161,7 @@ export const createJavaScriptAnalysisAccumulator = ( senderValidations: [], utilityProcesses: [], nativeAddonBindings: [], + pendingNativeAddonBindings: [], moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength), factoryRequireBindingIds: new Set(), seen: new Set(), diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index eb94ccf5d2..171a85dcd4 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -108,7 +108,7 @@ export const referenceSourceRequireCalls = ( : "source-file" : "unknown"; const searchBase = requireResolveSearchBase( - node, + callFact.operation, callFact.arguments, index.bindings, base, @@ -144,14 +144,14 @@ export const referenceSourceRequireCalls = ( /** `require.resolve` options replace the default search paths unless exact current-directory paths are supplied. */ const requireResolveSearchBase = ( - call: t.CallExpression | t.OptionalCallExpression, + operation: JavaScriptRequireCallFact["operation"], callArguments: JavaScriptRequireCallFact["arguments"], bindings: JavaScriptSemanticAnalysisState, loaderBase: ReferenceSourceRequireResolutionBase, hasCommonJsWrapper: boolean, ): ReferenceSourceRequireResolutionBase => { const arguments_ = callArguments ?? []; - if (!isRequireResolveCall(call) || arguments_.length < 2) return loaderBase; + if (operation !== "resolve" || arguments_.length < 2) return loaderBase; if (arguments_.length !== 2) return "unknown"; const options = t.isExpression(arguments_[1]) ? arguments_[1] : undefined; if (options === undefined || !t.isObjectExpression(options)) return "unknown"; @@ -172,17 +172,6 @@ const requireResolveSearchBase = ( : "unknown"; }; -const isRequireResolveCall = ( - call: t.CallExpression | t.OptionalCallExpression, -): boolean => { - const callee = call.callee; - if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) - return false; - return ( - semanticStaticPropertyKey(callee.property, callee.computed) === "resolve" - ); -}; - type CommonJsWrapperMode = "present" | "absent" | "unknown"; const commonJsWrapperMode = ( diff --git a/tests/acceptance/applications/javascriptNativeBindingOwnership.test.ts b/tests/acceptance/applications/javascriptNativeBindingOwnership.test.ts new file mode 100644 index 0000000000..b4a5151a3a --- /dev/null +++ b/tests/acceptance/applications/javascriptNativeBindingOwnership.test.ts @@ -0,0 +1,111 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +cliTest.each(["CLI", "stdio MCP"] as const)( + "uses shared loader and export ownership for native bindings through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-native-binding-ownership-"); + await writeFixtureFiles(root, { + "ordinary.cjs": 'const addon = require("./addon.node");', + "optional.cjs": 'const addon = require?.("./addon.node");', + "plain.cjs": 'module.exports = require("./addon.node");', + "members.cjs": + 'const {run} = require?.("./addon.node"); module.exports = require?.("./addon.node").open;', + "local.cjs": + 'const helper = { require: name => ({name}) }; const local = helper.require("./addon.node"); function shadow(require) { const local = require("./addon.node"); }', + "shadow-export.cjs": + 'function local(module, exports) { module.exports = require("./addon.node"); exports.addon = require("./addon.node"); }', + "detached.cjs": + 'module.exports = {}; exports.addon = require("./addon.node");', + "arithmetic.cjs": 'exports.sum += require("./addon.node");', + "logical.cjs": 'module.exports ||= require("./addon.node");', + "resolver.cjs": 'const filename = require.resolve?.("./addon.node");', + "addon.node": "inert native addon placeholder; never executed", + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const bindings = result.graph.nodes.flatMap((node) => + node.kind === "native-export" + ? node.observations.map((observation) => ({ + source: + observation.evidence.location.available && + observation.evidence.location.value.kind === "source-range" + ? observation.evidence.location.value.source + : null, + kind: observation.properties.binding_kind, + members: observation.properties.requested_members, + target: observation.properties.resolved_path, + })) + : [], + ); + expect( + bindings.sort( + (left, right) => + String(left.source).localeCompare(String(right.source)) || + String(left.kind).localeCompare(String(right.kind)), + ), + ).toEqual([ + { + source: "members.cjs", + kind: "re-export", + members: ["open"], + target: "addon.node", + }, + { + source: "members.cjs", + kind: "require", + members: ["run"], + target: "addon.node", + }, + { + source: "optional.cjs", + kind: "require", + members: [], + target: "addon.node", + }, + { + source: "ordinary.cjs", + kind: "require", + members: [], + target: "addon.node", + }, + { + source: "plain.cjs", + kind: "re-export", + members: [], + target: "addon.node", + }, + ]); + expect(result.summary).toMatchObject({ + native_addon_bindings: 5, + resolved_native_addon_bindings: 5, + }); + // Loading and assigning an addon are separate: compound stores keep their load edge. + const dependencies = result.graph.edges.filter( + (edge) => + (edge.relation === "imports" || edge.relation === "loads") && + edge.properties.specifier === "./addon.node" && + edge.evidence.extractor.operation === "map-static-relationship", + ); + const dependencySources = dependencies.flatMap((edge) => + edge.evidence.location.available && + edge.evidence.location.value.kind === "source-range" + ? [edge.evidence.location.value.source] + : [], + ); + expect(dependencySources).toEqual( + expect.arrayContaining([ + "ordinary.cjs", + "optional.cjs", + "arithmetic.cjs", + "logical.cjs", + "resolver.cjs", + ]), + ); + expect(dependencySources).not.toContain("local.cjs"); + }, +);