diff --git a/src/application/javascript/ElectronBoundaryGraphNative.ts b/src/application/javascript/ElectronBoundaryGraphNative.ts index a140c9a2c..9fc0a8555 100644 --- a/src/application/javascript/ElectronBoundaryGraphNative.ts +++ b/src/application/javascript/ElectronBoundaryGraphNative.ts @@ -8,6 +8,7 @@ import { } from "./JavaScriptArtifactGraphContext.js"; import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js"; import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { javascriptDisplayText } from "../../domain/javascript/javascriptAstValues.js"; import { addElectronInferenceEdge, electronFindingSourceNode, @@ -51,17 +52,27 @@ const addNativeBinding = (input: NativeBindingInput): void => { identity: electronObservationIdentity( context, "javascript-requested-native-export", - // Members are a variable-length source-derived list: compositeKey keeps - // ["a\0b"] distinct from ["a", "b"] inside the digested identity. - `${file.path}:${value.specifier}:${value.binding_kind}:${compositeKey(value.members)}:${electronRangeKey(value.location)}`, + compositeKey([ + file.path, + value.specifier, + value.binding_kind, + value.members, + value.namespace_access ?? null, + value.dynamic_member_access ?? null, + electronRangeKey(value.location), + ]), ), observations: [ { - label: value.members.join(", "), + label: + value.members.map(javascriptDisplayText).join(", ") || + value.specifier, properties: { specifier: value.specifier, binding_kind: value.binding_kind, requested_members: value.members, + namespace_access: value.namespace_access ?? null, + dynamic_member_access: value.dynamic_member_access ?? null, resolved_path: resolved, native_export_verification: "not-performed", }, @@ -90,6 +101,8 @@ const addNativeBinding = (input: NativeBindingInput): void => { specifier: value.specifier, binding_kind: value.binding_kind, requested_members: value.members, + namespace_access: value.namespace_access ?? null, + dynamic_member_access: value.dynamic_member_access ?? null, }, limitations: [ "Requested JavaScript members are not verified native exports.", @@ -107,6 +120,8 @@ const addNativeBinding = (input: NativeBindingInput): void => { properties: { resolved_path: resolved, requested_members: value.members, + namespace_access: value.namespace_access ?? null, + dynamic_member_access: value.dynamic_member_access ?? null, verified: false, }, confidence: "medium", diff --git a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts index f09e87814..cce0d50ef 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBundlers.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBundlers.ts @@ -1,6 +1,7 @@ import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import type { JavaScriptBundlerRegistration } from "../../domain/javascript/javascriptStaticAnalysisTypes.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { addAstContainsEdge, artifactLocalIdentity, @@ -80,7 +81,11 @@ const createBundlerChunkNode = ( const chunkKey = `${registration.runtime}:${registration.chunk_keys.join(",")}`; return context.accumulator.addNode({ kind: "javascript-chunk", - identity: artifactLocalIdentity(file.sha256, "bundler-chunk", chunkKey), + identity: artifactLocalIdentity( + file.sha256, + "bundler-chunk", + compositeKey([registration.runtime, registration.chunk_keys]), + ), observations: [ { label: chunkKey, diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index 692f35ae9..0acc834ef 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -1,4 +1,5 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; import { completeApplicationCoverage, partialApplicationCoverage, @@ -330,7 +331,7 @@ export const chunkLookupKey = ( path: string, runtime: string, chunkKey: string, -): string => `${path}\0${runtime}\0${chunkKey}`; +): string => compositeKey([path, runtime, chunkKey]); /** Resolve a relative static specifier without escaping the artifact root. */ export const resolveArtifactPath = ( diff --git a/src/application/javascript/JavaScriptModuleRelationships.ts b/src/application/javascript/JavaScriptModuleRelationships.ts index 3784141a8..d1709b025 100644 --- a/src/application/javascript/JavaScriptModuleRelationships.ts +++ b/src/application/javascript/JavaScriptModuleRelationships.ts @@ -1,6 +1,9 @@ import { posix } from "node:path"; -import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; +import { + compareUnicodeCodePoints, + compositeKey, +} from "../../domain/unicodeCodePointOrder.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import { completeApplicationCoverage, @@ -177,7 +180,7 @@ const addExportRelationship = ( identity: artifactLocalIdentity( file.sha256, "module-export", - `${file.container_sha256}:${file.path}:${link.exportedName}`, + compositeKey([file.container_sha256, file.path, link.exportedName]), ), observations: [ { diff --git a/src/domain/apple/interfaceBuilderGraph.ts b/src/domain/apple/interfaceBuilderGraph.ts index bd35130a8..cfb80d91b 100644 --- a/src/domain/apple/interfaceBuilderGraph.ts +++ b/src/domain/apple/interfaceBuilderGraph.ts @@ -66,7 +66,9 @@ export const buildInterfaceBuilderAnalysis = (input: { let omittedHierarchyReferences = 0; for (const document of input.documents.slice(0, input.limits.max_documents)) { const parsed = parseInterfaceBuilderRecords(document.raw); - const prefix = `ib:${document.relativePath}:`; + const prefix = `ib:${JSON.stringify(document.relativePath)}:`; + const graphId = (kind: string, parts: readonly unknown[] = []): string => + `${prefix}${kind}:${JSON.stringify(parts)}`; const rootId = `${prefix}root`; const objectIds = new Set(parsed.objects.map(({ id }) => id)); const evidenceFor = (description: string) => [ @@ -104,7 +106,7 @@ export const buildInterfaceBuilderAnalysis = (input: { addNode(rootNode); let documentObjectCount = 0; for (const object of parsed.objects.slice(0, input.limits.max_objects)) { - const id = `${prefix}object:${object.id}`; + const id = graphId("object", [object.id]); const kind = object.kind === "other" ? "unknown" : object.kind; if ( !addNode({ @@ -129,7 +131,7 @@ export const buildInterfaceBuilderAnalysis = (input: { let documentOmittedConnections = 0; let documentOmittedHierarchyEdges = 0; const objectNodeId = (objectId: string): string => - `${prefix}object:${objectId}`; + graphId("object", [objectId]); const addEdge = ( edge: (typeof edges)[number], isHierarchy: boolean, @@ -238,7 +240,7 @@ export const buildInterfaceBuilderAnalysis = (input: { const known = objectIds.has(objectId); const childId = known ? objectNodeId(objectId) - : `${prefix}unknown:${objectId}`; + : graphId("unknown", [objectId]); if ( !known && !addNode({ @@ -252,7 +254,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ) hierarchyTruncated = true; connect( - `${prefix}hierarchy:${parentId}:${childId}`, + graphId("hierarchy", [parentId, childId]), parentId, childId, "contains", @@ -281,11 +283,11 @@ export const buildInterfaceBuilderAnalysis = (input: { const destinationId = item.destination_id === null ? null : objectNodeId(item.destination_id); if (item.kind === "action") { - const actionId = `${prefix}action:${item.id}`; + const actionId = graphId("action", [item.id]); const selectorId = item.label === null ? null - : `${prefix}selector:${item.destination_id ?? "unknown"}:${item.label}`; + : graphId("selector", [item.destination_id, item.label]); addNode({ id: actionId, kind: "action", @@ -299,7 +301,7 @@ export const buildInterfaceBuilderAnalysis = (input: { evidence: evidenceFor(`action ${item.id}`), }); connect( - `${actionId}:source`, + graphId("action-edge", [item.id, "source"]), sourceId, actionId, "target_action", @@ -315,7 +317,7 @@ export const buildInterfaceBuilderAnalysis = (input: { evidence: evidenceFor(`selector ${item.label}`), }); connect( - `${actionId}:selector`, + graphId("action-edge", [item.id, "selector"]), actionId, selectorId, "target_action", @@ -324,7 +326,7 @@ export const buildInterfaceBuilderAnalysis = (input: { } if (destinationId === null || !nodes.has(destinationId)) connect( - `${actionId}:destination`, + graphId("action-edge", [item.id, "destination"]), actionId, null, "target_action", @@ -333,14 +335,14 @@ export const buildInterfaceBuilderAnalysis = (input: { ); else connect( - `${actionId}:destination`, + graphId("action-edge", [item.id, "destination"]), actionId, destinationId, "target_action", `action destination ${item.destination_id}`, ); } else if (item.kind === "outlet") { - const outletId = `${prefix}outlet:${item.id}`; + const outletId = graphId("outlet", [item.id]); addNode({ id: outletId, kind: "outlet", @@ -350,7 +352,7 @@ export const buildInterfaceBuilderAnalysis = (input: { evidence: evidenceFor(`outlet ${item.id}`), }); connect( - `${outletId}:source`, + graphId("outlet-edge", [item.id, "source"]), sourceId, outletId, "contains", @@ -358,7 +360,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ); if (destinationId === null || !nodes.has(destinationId)) connect( - `${outletId}:destination`, + graphId("outlet-edge", [item.id, "destination"]), outletId, null, "outlet_to", @@ -367,7 +369,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ); else connect( - `${outletId}:destination`, + graphId("outlet-edge", [item.id, "destination"]), outletId, destinationId, "outlet_to", @@ -376,7 +378,7 @@ export const buildInterfaceBuilderAnalysis = (input: { } else if (item.kind === "segue") { if (destinationId === null || !nodes.has(destinationId)) connect( - `${prefix}segue:${index}`, + graphId("segue", [index]), sourceId, null, "segue_to", @@ -385,7 +387,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ); else connect( - `${prefix}segue:${index}`, + graphId("segue", [index]), sourceId, destinationId, "segue_to", @@ -393,7 +395,7 @@ export const buildInterfaceBuilderAnalysis = (input: { ); } else { connect( - `${prefix}unknown-connection:${index}`, + graphId("unknown-connection", [index]), sourceId, null, "contains", diff --git a/src/domain/javascript/electronStaticAnalysisNative.ts b/src/domain/javascript/electronStaticAnalysisNative.ts index 73b2d8b9d..a68d7e1c4 100644 --- a/src/domain/javascript/electronStaticAnalysisNative.ts +++ b/src/domain/javascript/electronStaticAnalysisNative.ts @@ -15,7 +15,7 @@ import { } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, - semanticStaticPropertyName, + semanticStaticPropertyKey, } from "./javascriptAstValues.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; @@ -26,6 +26,8 @@ interface NativeBindingInput { readonly kind: ElectronNativeAddonBindingFinding["binding_kind"]; readonly moduleKind: ElectronNativeAddonBindingFinding["module_kind"]; readonly members: readonly string[]; + readonly namespaceAccess: boolean; + readonly dynamicMemberAccess: boolean; } /** Inspect JavaScript-side imports and re-exports of native .node addons. */ @@ -45,10 +47,10 @@ const inspectImport = ( context: JavaScriptFindingContext, ): void => { if (!isNativeSpecifier(node.source.value, "import")) return; - const members = node.specifiers.map((specifier) => { - if (t.isImportDefaultSpecifier(specifier)) return "default"; - if (t.isImportNamespaceSpecifier(specifier)) return "*"; - return propertyName(specifier.imported) || "[dynamic-import]"; + const members = node.specifiers.flatMap((specifier) => { + if (t.isImportDefaultSpecifier(specifier)) return ["default"]; + if (t.isImportNamespaceSpecifier(specifier)) return []; + return [propertyName(specifier.imported)]; }); addBinding({ context, @@ -57,6 +59,10 @@ const inspectImport = ( kind: "import", moduleKind: "import", members, + namespaceAccess: node.specifiers.some((value) => + t.isImportNamespaceSpecifier(value), + ), + dynamicMemberAccess: false, }); }; @@ -70,9 +76,10 @@ const inspectNamedExport = ( !isNativeSpecifier(node.source.value, "import") ) return; - const members = node.specifiers.map((specifier) => { - if (t.isExportSpecifier(specifier)) return propertyName(specifier.local); - return "*"; + const members = node.specifiers.flatMap((specifier) => { + if (t.isExportSpecifier(specifier)) return [propertyName(specifier.local)]; + if (t.isExportDefaultSpecifier(specifier)) return ["default"]; + return []; }); addBinding({ context, @@ -81,6 +88,10 @@ const inspectNamedExport = ( kind: "re-export", moduleKind: "import", members, + namespaceAccess: node.specifiers.some((value) => + t.isExportNamespaceSpecifier(value), + ), + dynamicMemberAccess: false, }); }; @@ -99,7 +110,9 @@ const inspectExportAll = ( specifier: node.source.value, kind: "re-export", moduleKind: "import", - members: ["*"], + members: [], + namespaceAccess: true, + dynamicMemberAccess: false, }); }; @@ -115,8 +128,7 @@ const inspectRequireBinding = ( specifier: required.specifier, kind: "require", moduleKind: "require", - members: - required.member === null ? bindingMembers(node.id) : [required.member], + ...(required.hasMemberAccess ? required.access : bindingMembers(node.id)), }); }; @@ -132,27 +144,33 @@ const inspectReExport = ( specifier: required.specifier, kind: "re-export", moduleKind: "require", - members: [required.member ?? exportedMember(node.left) ?? "*"], + ...required.access, }); }; const addBinding = (input: NativeBindingInput): void => { const { context, node, specifier, kind } = input; - const unique = [ - ...new Set(input.members.filter((member) => member !== "")), - ].sort(compareUnicodeCodePoints); - const members = unique.length === 0 ? ["*"] : unique; + const members = [...new Set(input.members)].sort(compareUnicodeCodePoints); addLocatedFinding(context, { collection: context.accumulator.nativeAddonBindings, // Members are a variable-length source-derived list: compositeKey keeps // ["a\0b"] distinct from ["a", "b"] instead of collapsing one binding. - key: compositeKey(["native-addon-binding", kind, specifier, members]), + key: compositeKey([ + "native-addon-binding", + kind, + specifier, + members, + input.namespaceAccess, + input.dynamicMemberAccess, + ]), node, value: { specifier, binding_kind: kind, module_kind: input.moduleKind, members, + namespace_access: input.namespaceAccess, + dynamic_member_access: input.dynamicMemberAccess, module_key: null, location: range(node), }, @@ -162,13 +180,20 @@ const addBinding = (input: NativeBindingInput): void => { const nativeRequire = ( node: t.Node | null | undefined, ): - | { readonly specifier: string; readonly member: string | null } + | { + readonly specifier: string; + readonly hasMemberAccess: boolean; + readonly access: NativeMemberAccess; + } | undefined => { let member: string | null = null; + let hasMemberAccess = false; while (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { if (!t.isNode(node.object)) return undefined; - const property = semanticStaticPropertyName(node.property, node.computed); - if (member === null && property !== "") member = property; + const property = semanticStaticPropertyKey(node.property, node.computed); + // Walking inward leaves the first member consumed from the addon itself. + member = property; + hasMemberAccess = true; node = node.object; } if (!t.isCallExpression(node)) return undefined; @@ -181,34 +206,52 @@ const nativeRequire = ( return undefined; const specifier = stringValue(argumentNode(node.arguments[0])); return specifier !== undefined && isNativeSpecifier(specifier, "require") - ? { specifier, member } + ? { + specifier, + hasMemberAccess, + access: { + members: member === null ? [] : [member], + namespaceAccess: !hasMemberAccess, + dynamicMemberAccess: hasMemberAccess && member === null, + }, + } : undefined; }; -const bindingMembers = (pattern: t.Node): string[] => { - if (t.isIdentifier(pattern)) return ["*"]; - if (!t.isObjectPattern(pattern)) return ["*"]; - return pattern.properties.map((property) => { - if (t.isRestElement(property)) return "*"; - return propertyName(property.key) || "[dynamic-import]"; - }); +interface NativeMemberAccess { + readonly members: readonly string[]; + readonly namespaceAccess: boolean; + readonly dynamicMemberAccess: boolean; +} + +const bindingMembers = (pattern: t.Node): NativeMemberAccess => { + if (!t.isObjectPattern(pattern)) + return { members: [], namespaceAccess: true, dynamicMemberAccess: false }; + const members: string[] = []; + let namespaceAccess = false; + let dynamicMemberAccess = false; + for (const property of pattern.properties) { + if (t.isRestElement(property)) { + namespaceAccess = true; + continue; + } + const key = semanticStaticPropertyKey(property.key, property.computed); + if (key === null) dynamicMemberAccess = true; + else members.push(key); + } + return { members, namespaceAccess, dynamicMemberAccess }; }; const isModuleExport = (node: t.Node): boolean => { - const name = calleeName(node); - return ( - name === "module.exports" || - name.startsWith("module.exports.") || - name.startsWith("exports.") - ); -}; - -const exportedMember = (node: t.Node): string | undefined => { - const name = calleeName(node); - if (name.startsWith("module.exports.")) - return name.slice("module.exports.".length); - if (name.startsWith("exports.")) return name.slice("exports.".length); - return undefined; + 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 = ( diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 824c1ad8f..d96ef690e 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -163,7 +163,11 @@ export interface ElectronNativeAddonBindingFinding { readonly specifier: string; readonly binding_kind: "import" | "require" | "re-export"; readonly module_kind: "import" | "require"; + /** Exact requested addon member keys; namespace and dynamic access are separate. */ readonly members: readonly string[]; + /** Omitted in legacy findings; omission means unknown, not false. */ + readonly namespace_access?: boolean; + readonly dynamic_member_access?: boolean; readonly module_key: string | null; readonly location: JavaScriptSourceRange; } diff --git a/src/domain/javascript/javascriptApplicationVersionKeys.ts b/src/domain/javascript/javascriptApplicationVersionKeys.ts index 081968f3f..306e8d316 100644 --- a/src/domain/javascript/javascriptApplicationVersionKeys.ts +++ b/src/domain/javascript/javascriptApplicationVersionKeys.ts @@ -257,7 +257,15 @@ const semanticValue = ( return ( joined( properties.specifier, - stringList(properties.requested_members).join(","), + compositeKey([ + stringList(properties.requested_members), + typeof properties.namespace_access === "boolean" + ? properties.namespace_access + : null, + typeof properties.dynamic_member_access === "boolean" + ? properties.dynamic_member_access + : null, + ]), ) ?? firstLabel(node) ); if ( diff --git a/src/domain/javascript/javascriptNativeHandoff.ts b/src/domain/javascript/javascriptNativeHandoff.ts index ec197695f..daf224572 100644 --- a/src/domain/javascript/javascriptNativeHandoff.ts +++ b/src/domain/javascript/javascriptNativeHandoff.ts @@ -103,7 +103,12 @@ const nativeExportNodes = ( const requestedExports = (nodes: readonly ApplicationNode[]): string[] => { const values = nodes.flatMap((node) => node.observations.flatMap((observation) => [ - ...(observation.label === null ? [] : [observation.label]), + // Display labels can summarize several names or namespace access. + // Only legacy observations without structured member facts need them. + ...(observation.label === null || + Array.isArray(observation.properties.requested_members) + ? [] + : [observation.label]), ...stringArray(observation.properties.requested_members), ...stringArray(observation.properties.members), ]),