From e9fc8fb30cc18434475d6ab7cacd2916599b6eff Mon Sep 17 00:00:00 2001 From: ShuaixinHuang <1836656514@qq.com> Date: Tue, 20 Oct 2026 17:25:19 +0800 Subject: [PATCH] fix(javascript): preserve runtime path bytes and Electron ownership (#4941) * fix(javascript): preserve literal percent runtime paths * fix(javascript): ignore resolve prefixes before the final anchor * fix(electron): retain application roots for loadFile declarations * fix(electron): resolve loadFile from enclosing application package * fix(electron): retain outer application root across package scopes * fix(electron): derive application ownership from inventoried packages * fix(electron): retain resolved package entrypoint ownership * fix(electron): preserve competing application ownership * fix(electron): retain invalid and omitted runtime ownership * fix(electron): preserve omitted module ownership and runtime paths * fix(electron): preserve proven roots before confining page paths * fix(electron): retain possible owners behind missing imports * fix(electron): distinguish absent imports and owned page candidates * fix(electron): keep unavailable JSON out of owner gaps * fix(electron): retain exact loader formats and default entry status * fix(electron): retain unavailable entrypoint ownership candidates * test(ghidra): preserve Windows Node probe prerequisites * test(ghidra): assert cancellation during profile shutdown * fix(electron): separate examined imports from frontend ownership * fix(electron): retain unavailable directory entrypoint owners * test(jeb): retain Linux ownership prerequisite in isolation * fix(javascript): retain unchecked loader ownership and literal main paths * fix(javascript): preserve runtime paths and call-site module aliases * fix(javascript): bound module alias traversal and preserve projections * fix(javascript): retain host module and nested artifact path boundaries * fix(javascript): preserve ESM and archive module loader provenance * test(javascript): distinguish unmapped containers from missing exports * fix: retain default module aliases and Electron builtin imports * fix: preserve host file URL and inward ASAR ownership * fix: preserve CommonJS file URL classification in CI fixtures * fix: retain bound module loaders and Windows entrypoint uncertainty * fix: retain ASAR uncertainty and invalidate changed Electron app roots * fix(javascript): retain ownership uncertainty across loader boundaries * fix(javascript): preserve loader sources and bound alias queries * fix(javascript): retain destructured loader sources * fix(javascript): preserve loader uncertainty at resolution boundaries * fix(javascript): preserve callable and package launch ownership * fix(javascript): preserve captured values across member reads * test(process): assert tagged filesystem cancellation failures * fix(javascript): preserve inventory bases and invocation read timing * fix(javascript): preserve ownership across indirect and unread loads * test(javascript): order fixture directory creation on Windows * test(javascript): share ordered filesystem fixture writer * fix(javascript): unify loader provenance and package validation * fix(javascript): preserve captured Node require loader values * fix(javascript): preserve loader invocation authority * fix(javascript): verify generic loader invocation authority * fix(javascript): preserve conditional loader method alternatives * fix(javascript): require proven factories for source association * fix(javascript): honor replaced Electron app path setters * fix(javascript): prove path and assignment source ownership * fix(javascript): retain native delegate eligibility through shared writes * fix(process): settle stale baseline errors after confirmed absence * fix(javascript): preserve assignment and path boundary evidence * fix(javascript): prove source-directory resolve anchors * fix: preserve application path provenance and portable schemas * fix(javascript): retain CommonJS exported setter effects * fix: isolate Linux identity observations and retain native module exports * fix(javascript): preserve published app and helper provenance * fix(javascript): normalize typed stores and native path fixtures --- ...ScriptArtifactAnalysis.packageMain.test.ts | 64 + .../javascript/JavaScriptArtifactAnalysis.ts | 9 + .../JavaScriptArtifactAnalysisTypes.ts | 5 + .../JavaScriptArtifactGraphBuilder.ts | 65 +- .../JavaScriptArtifactGraphContext.ts | 128 +- .../JavaScriptArtifactGraphFindings.ts | 202 ++- .../JavaScriptArtifactGraphStructure.test.ts | 230 +++ .../JavaScriptArtifactGraphStructure.ts | 106 +- ...PathResolution.syntheticRootEscape.test.ts | 67 + .../JavaScriptArtifactPathResolution.test.ts | 1220 +++++++++++++- .../JavaScriptArtifactPathResolution.ts | 506 ++++-- .../JavaScriptSemanticGraphBuilder.test.ts | 31 + .../JavaScriptSemanticGraphBuilder.ts | 4 +- src/contracts/canonicalBase64Metadata.test.ts | 68 + .../javascript/electronStaticAnalysis.ts | 27 +- ...tronStaticAnalysisAppPathSelection.test.ts | 127 ++ .../electronStaticAnalysisBrowser.ts | 25 +- ...ctronStaticAnalysisExportedAppPath.test.ts | 136 ++ .../electronStaticAnalysisIdentity.test.ts | 124 ++ ...electronStaticAnalysisPublishedApp.test.ts | 112 ++ .../electronStaticAnalysisReturnedApp.test.ts | 231 +++ .../javascript/electronStaticAnalysisTypes.ts | 9 + ...avascriptApplicationProjectionResources.ts | 1 + .../javascriptCommonJsAssignAppPath.test.ts | 141 ++ .../javascriptCommonJsExportReceivers.ts | 244 ++- .../javascriptElectronAppPathChanges.ts | 390 +++++ .../javascriptIntrinsicInvocation.test.ts | 46 + .../javascriptIntrinsicInvocation.ts | 120 +- .../javascript/javascriptModuleDeclaration.ts | 31 + .../javascript/javascriptModuleInterop.ts | 17 +- .../javascriptModuleResolutionMode.ts | 8 + .../javascriptPackageImportAliases.test.ts | 42 + .../javascriptPackageImportAliases.ts | 9 +- .../javascriptPackageSubpathPatterns.test.ts | 30 + .../javascriptPackageSubpathPatterns.ts | 33 + .../javascriptRuntimeStaticCandidates.ts | 44 +- .../javascriptSemanticAliasSelections.ts | 126 ++ .../javascriptSemanticAmbientWrites.test.ts | 26 + .../javascriptSemanticAmbientWrites.ts | 57 +- .../javascript/javascriptSemanticAnalysis.ts | 139 +- .../javascriptSemanticCallResolution.test.ts | 87 +- .../javascriptSemanticCallResolution.ts | 21 +- .../javascript/javascriptSemanticCoverage.ts | 5 + .../javascriptSemanticDataEffectHelpers.ts | 60 +- .../javascriptSemanticDataEffects.ts | 46 +- .../javascriptSemanticFunctionEscapes.test.ts | 47 + .../javascriptSemanticIterationSources.ts | 75 +- .../javascriptSemanticMemberMutations.ts | 244 ++- .../javascriptSemanticMutationInitializers.ts | 35 +- .../javascriptSemanticObjectReferences.ts | 64 + .../javascriptSemanticProjectedSources.ts | 403 +++++ .../javascriptSemanticProjection.ts | 69 +- ...criptSemanticProvenanceAssignments.test.ts | 87 + .../javascriptSemanticReferenceSources.ts | 162 +- .../javascriptSemanticRequireCaptures.test.ts | 151 ++ .../javascriptSemanticRequireOrigin.ts | 1187 ++++++++++++-- .../javascriptSemanticResourceLimits.ts | 4 + ...ascriptSemanticResponseAssignments.test.ts | 81 + ...riptSemanticRestAssignmentCaptures.test.ts | 110 ++ .../javascriptSemanticReturnedReferences.ts | 644 +++++++- ...tSemanticReturnedReferencesBounded.test.ts | 32 + ...ptSemanticReturnedReferencesCopies.test.ts | 83 + ...ptSemanticReturnedReferencesWrites.test.ts | 203 +++ ...avascriptSemanticSharedChildWrites.test.ts | 223 +++ .../javascript/javascriptSemanticState.ts | 71 +- .../javascriptSemanticValueTypes.ts | 3 +- .../javascript/javascriptSemanticValues.ts | 79 +- ...vascriptSemanticWrapperAssignments.test.ts | 119 ++ .../javascript/javascriptStaticAnalysis.ts | 28 +- ...criptStaticAnalysisAssignedRequire.test.ts | 158 ++ ...iptStaticAnalysisAssignmentLoaders.test.ts | 88 ++ ...iptStaticAnalysisCallableReceivers.test.ts | 99 ++ ...scriptStaticAnalysisCalleeBoundary.test.ts | 202 +++ .../javascriptStaticAnalysisCalls.ts | 74 +- ...criptStaticAnalysisCapturedGeneric.test.ts | 98 ++ ...criptStaticAnalysisCapturedRequire.test.ts | 49 + ...ascriptStaticAnalysisCopiedRequire.test.ts | 75 + ...tStaticAnalysisDelegateEligibility.test.ts | 60 + ...tStaticAnalysisFactoryAlternatives.test.ts | 50 + .../javascriptStaticAnalysisHelpers.ts | 139 +- ...tStaticAnalysisInvocationIteration.test.ts | 122 ++ ...ptStaticAnalysisInvocationReadSite.test.ts | 56 + ...iptStaticAnalysisLoaderInvocations.test.ts | 290 ++++ ...riptStaticAnalysisLoaderMayTargets.test.ts | 70 + ...aticAnalysisLoaderMutationControls.test.ts | 79 + ...javascriptStaticAnalysisMainModule.test.ts | 90 ++ ...iptStaticAnalysisProcessMainModule.test.ts | 49 + ...taticAnalysisProjectedModuleWrites.test.ts | 144 ++ ...ptStaticAnalysisReturnedInvocation.test.ts | 180 +++ ...criptStaticAnalysisReturnedRequire.test.ts | 285 ++++ ...ptStaticAnalysisSharedHelperWrites.test.ts | 83 + .../javascriptStaticAnalysisState.ts | 18 + ...iptStaticAnalysisTypedMemberStores.test.ts | 55 + .../javascriptStaticPathIntrinsics.ts | 73 + .../javascriptStaticPathResolution.test.ts | 80 +- .../javascriptStaticPathResolveOwners.test.ts | 166 ++ .../javascriptStaticPathSourceAnchors.test.ts | 121 ++ src/domain/referenceSourceRequireBindings.ts | 12 +- .../JavaScriptAnalysisWorkerSchemas.test.ts | 35 + .../JavaScriptAnalysisWorkerSchemas.ts | 7 +- .../ProcessOwnership.baselineAbsence.test.ts | 124 ++ .../capture/ProcessCaptureLifecycle.test.ts | 32 +- .../capture/ProcessCaptureObservation.test.ts | 43 +- ...avascriptCapturedGenericInvocation.test.ts | 69 + .../javascriptDependencyMainHostPaths.test.ts | 68 + .../javascriptEscapedArtifactImports.test.ts | 63 + .../javascriptExportedAppPathChanges.test.ts | 81 + .../javascriptFactoryCompletion.test.ts | 7 +- .../javascriptIndirectLoaderOwners.test.ts | 280 ++++ .../javascriptLiteralPercentPaths.test.ts | 1403 +++++++++++++++++ .../javascriptLoaderReviewBoundaries.test.ts | 594 +++++++ .../javascriptNonStringMainControls.test.ts | 67 + ...vascriptPackageExportPatternOwners.test.ts | 121 ++ .../javascriptPackageLaunchBoundaries.test.ts | 165 ++ .../javascriptPossibleSetterOwners.test.ts | 68 + .../javascriptPreloadResolveOwners.test.ts | 126 ++ .../javascriptPublishedAppOwners.test.ts | 99 ++ .../javascriptReturnedAppPathChanges.test.ts | 124 ++ .../javascriptReturnedReferenceCycles.test.ts | 39 +- .../javascriptUnreadAppPathChanges.test.ts | 109 ++ .../javascriptWindowsLoadFilePaths.test.ts | 68 + .../javascriptWrapperElectronCaptures.test.ts | 100 ++ .../applications/packageSubpathsCli.test.ts | 16 +- .../applications/packageUrlUnicodeCli.test.ts | 16 +- .../boundary/cli/cliIntegerArguments.test.ts | 98 +- .../cli/electronWrappedCallPaths.test.ts | 4 +- .../artifactLiteralBackslash.test.ts | 190 ++- .../javascriptArtifactReconstruction.test.ts | 398 +++++ .../javascriptFileUrlPaths.test.ts | 30 +- .../javascriptModuleRelationships.test.ts | 53 + .../javascriptModuleSpecifierPaths.test.ts | 23 +- .../javascriptNonStringPackageMain.test.ts | 141 ++ .../javascriptRuntimeReconciliation.test.ts | 75 + ...riptArtifactGraphObservationLookup.test.ts | 2 + .../loaderInvocationMethodAuthority.test.ts | 50 + .../javascript/mainModuleSource.test.ts | 50 + .../packageConditionalExports.test.ts | 98 +- .../javascript/packageImportAliases.test.ts | 25 +- .../javascript/packageMainHostPaths.test.ts | 71 + .../process/processCaptureLifecycle.test.ts | 28 +- .../javascriptAliasSelectionBudget.ts | 10 + .../javascriptCapturedGenericInvocations.ts | 26 + ...systemSnapshotFallbackCancellation.test.ts | 7 +- .../filesystemSnapshotRaces.test.ts | 10 +- .../javascriptRuntimeObservationCli.test.ts | 2 +- .../processSnapshotCleanupMcp.test.ts | 10 +- tests/support/cli/javascriptApplication.ts | 19 +- tests/support/javascriptApplicationFixture.ts | 17 +- tests/support/workspace/writeFixtureFiles.ts | 20 + 149 files changed, 16881 insertions(+), 988 deletions(-) create mode 100644 src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts create mode 100644 src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts create mode 100644 src/contracts/canonicalBase64Metadata.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisAppPathSelection.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisExportedAppPath.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisPublishedApp.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisReturnedApp.test.ts create mode 100644 src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts create mode 100644 src/domain/javascript/javascriptElectronAppPathChanges.ts create mode 100644 src/domain/javascript/javascriptPackageSubpathPatterns.test.ts create mode 100644 src/domain/javascript/javascriptPackageSubpathPatterns.ts create mode 100644 src/domain/javascript/javascriptSemanticAliasSelections.ts create mode 100644 src/domain/javascript/javascriptSemanticFunctionEscapes.test.ts create mode 100644 src/domain/javascript/javascriptSemanticProjectedSources.ts create mode 100644 src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts create mode 100644 src/domain/javascript/javascriptSemanticRequireCaptures.test.ts create mode 100644 src/domain/javascript/javascriptSemanticResponseAssignments.test.ts create mode 100644 src/domain/javascript/javascriptSemanticRestAssignmentCaptures.test.ts create mode 100644 src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts create mode 100644 src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts create mode 100644 src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts create mode 100644 src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisAssignedRequire.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisAssignmentLoaders.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisCallableReceivers.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisCapturedGeneric.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisCapturedRequire.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisCopiedRequire.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisFactoryAlternatives.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisLoaderMayTargets.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisProcessMainModule.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisProjectedModuleWrites.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisReturnedInvocation.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisReturnedRequire.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisSharedHelperWrites.test.ts create mode 100644 src/domain/javascript/javascriptStaticAnalysisTypedMemberStores.test.ts create mode 100644 src/domain/javascript/javascriptStaticPathIntrinsics.ts create mode 100644 src/domain/javascript/javascriptStaticPathResolveOwners.test.ts create mode 100644 src/domain/javascript/javascriptStaticPathSourceAnchors.test.ts create mode 100644 src/process/ProcessOwnership.baselineAbsence.test.ts create mode 100644 tests/acceptance/applications/javascriptCapturedGenericInvocation.test.ts create mode 100644 tests/acceptance/applications/javascriptDependencyMainHostPaths.test.ts create mode 100644 tests/acceptance/applications/javascriptEscapedArtifactImports.test.ts create mode 100644 tests/acceptance/applications/javascriptExportedAppPathChanges.test.ts create mode 100644 tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts create mode 100644 tests/acceptance/applications/javascriptLiteralPercentPaths.test.ts create mode 100644 tests/acceptance/applications/javascriptLoaderReviewBoundaries.test.ts create mode 100644 tests/acceptance/applications/javascriptNonStringMainControls.test.ts create mode 100644 tests/acceptance/applications/javascriptPackageExportPatternOwners.test.ts create mode 100644 tests/acceptance/applications/javascriptPackageLaunchBoundaries.test.ts create mode 100644 tests/acceptance/applications/javascriptPossibleSetterOwners.test.ts create mode 100644 tests/acceptance/applications/javascriptPreloadResolveOwners.test.ts create mode 100644 tests/acceptance/applications/javascriptPublishedAppOwners.test.ts create mode 100644 tests/acceptance/applications/javascriptReturnedAppPathChanges.test.ts create mode 100644 tests/acceptance/applications/javascriptUnreadAppPathChanges.test.ts create mode 100644 tests/acceptance/applications/javascriptWindowsLoadFilePaths.test.ts create mode 100644 tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts create mode 100644 tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts create mode 100644 tests/boundary/javascript/loaderInvocationMethodAuthority.test.ts create mode 100644 tests/boundary/javascript/mainModuleSource.test.ts create mode 100644 tests/boundary/javascript/packageMainHostPaths.test.ts create mode 100644 tests/fixtures/javascriptAliasSelectionBudget.ts create mode 100644 tests/fixtures/javascriptCapturedGenericInvocations.ts create mode 100644 tests/support/workspace/writeFixtureFiles.ts diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts new file mode 100644 index 0000000000..e06f2e0cc7 --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactAnalysis.packageMain.test.ts @@ -0,0 +1,64 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptArtifactFiles } from "./JavaScriptArtifactAnalysis.js"; + +const packageObservation = (text: string) => + analyzeJavaScriptArtifactFiles({ + files: [ + { + path: "app/package.json", + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: Buffer.byteLength(text), + inventory_artifact_id: "package-main-fixture", + kind: "package-json", + unpacked: false, + text: { included: true, value: text }, + }, + ], + unavailable_relevant_files: [], + containers: [], + text_bytes_read: Buffer.byteLength(text), + invalid_utf8_files: 0, + }).packages[0]; + +it.each([null, false, 123, {}, []].map((main) => ({ main })))( + "retains non-string main $main evidence separately from Node index fallback", + ({ main }) => { + expect(packageObservation(JSON.stringify({ main }))).toMatchObject({ + status: "included", + main: null, + main_status: "invalid", + main_entrypoint: "", + }); + }, +); + +it.each([ + ["{}", "default", ""], + ['{"main":""}', "default", ""], + ['{"main":"main.cjs"}', "declared", "main.cjs"], + ['{"main":"sub\\\\main.cjs"}', "declared", "sub\\main.cjs"], + ['{"main":"C:main.cjs"}', "declared", "C:main.cjs"], +] as const)( + "preserves package launch declarations from %s", + (text, status, entry) => { + expect(packageObservation(text)).toMatchObject({ + status: "included", + main_status: status, + main_entrypoint: entry, + }); + }, +); + +it.each(["{", "null", "[]", "[{}]", "123", '"root"', "false"])( + "keeps malformed package metadata %s out of known launch fallback", + (text) => { + expect(packageObservation(text)).toMatchObject({ + status: "invalid", + main: null, + main_status: "unavailable", + main_entrypoint: null, + }); + }, +); diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index c0e3bdd7c3..b4187a52f8 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -512,6 +512,13 @@ const parsePackage = ( name: optionalString(value.name), version: optionalString(value.version), main: optionalString(value.main), + main_status: + value.main === undefined || value.main === "" + ? "default" + : typeof value.main === "string" + ? "declared" + : "invalid", + main_entrypoint: typeof value.main === "string" ? value.main : "", renderer: optionalString(value.renderer) ?? optionalString(value.browser) ?? @@ -538,6 +545,8 @@ const unavailablePackage = ( name: null, version: null, main: null, + main_status: "unavailable", + main_entrypoint: null, renderer: null, limitation, }); diff --git a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts index 500fb57cf6..f3387b9eb0 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysisTypes.ts @@ -20,6 +20,9 @@ interface JavaScriptPackageValues { readonly name: string | null; readonly version: string | null; readonly main: string | null; + readonly main_status: "default" | "declared" | "invalid"; + /** Node launch declaration; an empty string requests the package index fallback. */ + readonly main_entrypoint: string; readonly renderer: string | null; } @@ -35,6 +38,8 @@ export type JavaScriptPackageObservation = JavaScriptStructuredObservation & readonly name: null; readonly version: null; readonly main: null; + readonly main_status: "unavailable"; + readonly main_entrypoint: null; readonly renderer: null; readonly limitation: string; } diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 352838948a..d4ec631045 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -12,6 +12,8 @@ import { HTML_INLINE_SCRIPT_LOCATION_COVERAGE_LIMIT, omittedHtmlInlineScriptLocations, selfReferenceOmissions, + dependencyManifest, + applicationContainerIdentity, type JavaScriptArtifactGraphContext, } from "./JavaScriptArtifactGraphContext.js"; import { @@ -93,6 +95,57 @@ function* buildJavaScriptArtifactGraphInputSteps( ): Generator { const accumulator = new JavaScriptArtifactGraphAccumulator(); const root = createJavaScriptArtifactRootNode(accumulator, snapshot); + const parsedPackages = new Set( + analysis.packages + .filter((metadata) => metadata.status === "included") + .map(({ path }) => path), + ); + const applicationOwnershipUnknownContainers = new Set(); + const applicationModuleGapSources = new Map>(); + const applicationModuleReferencesRetained = new Set(); + const recordModuleGap = (file: { + path: string; + container_sha256: string; + }) => { + const container = applicationContainerIdentity(fileSet, file); + const sources = + applicationModuleGapSources.get(container) ?? new Set(); + sources.add(file.path); + applicationModuleGapSources.set(container, sources); + }; + for (const files of [fileSet.files, fileSet.unavailable_relevant_files]) { + for (const file of files) { + if ( + file.kind === "package-json" && + !dependencyManifest(file.path) && + !parsedPackages.has(file.path) + ) + applicationOwnershipUnknownContainers.add( + applicationContainerIdentity(fileSet, file), + ); + if ( + file.kind === "javascript" && + (!("text" in file) || !file.text.included) + ) + recordModuleGap(file); + } + } + // Omitted module relationships can hide another launch owner. Do not expand + // retained facts beyond the projection guard merely to recover ownership. + for (const analyzed of analysis.files) { + if ( + analyzed.application_projection_failure !== undefined || + analyzed.analysis_failure !== undefined || + analyzed.javascript?.parse_status === "failed" || + (analyzed.javascript?.parse_error_count ?? 0) > 0 + ) + recordModuleGap(analyzed.file); + else if (analyzed.javascript !== null) + applicationModuleReferencesRetained.add(analyzed.file.path); + } + const unavailableFilesByPath = new Map( + fileSet.unavailable_relevant_files.map((file) => [file.path, file]), + ); const context: JavaScriptArtifactGraphContext = { accumulator, snapshot, @@ -105,13 +158,23 @@ function* buildJavaScriptArtifactGraphInputSteps( ), root, filesByPath: new Map(fileSet.files.map((file) => [file.path, file])), - unanalyzedFiles: fileSet.unanalyzed_files, + unanalyzedFiles: { + get: (path) => + unavailableFilesByPath.get(path) ?? fileSet.unanalyzed_files?.get(path), + hasDirectory: (path, container) => + fileSet.unanalyzed_files?.hasDirectory(path, container) ?? false, + }, fileNodes: new Map(), assetNodes: new Map(), sourceModuleNodes: new Map(), chunkNodes: new Map(), moduleNodes: new Map(), containerNodes: new Map([[snapshot.manifest.root_sha256, root]]), + applicationRootsBySource: new Map(), + applicationOwnershipUnknownContainers, + applicationModuleGapSources, + applicationModuleReferencesRetained, + applicationPossibleRootsByContainer: new Map(), }; addJavaScriptArtifactContainers(context); yield; diff --git a/src/application/javascript/JavaScriptArtifactGraphContext.ts b/src/application/javascript/JavaScriptArtifactGraphContext.ts index db35cdca2a..7dc8b4b1bb 100644 --- a/src/application/javascript/JavaScriptArtifactGraphContext.ts +++ b/src/application/javascript/JavaScriptArtifactGraphContext.ts @@ -1,6 +1,9 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { JsonValue } from "../../domain/jsonValue.js"; -import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { + compositeKey, + compareUnicodeCodePoints, +} from "../../domain/unicodeCodePointOrder.js"; import { completeApplicationCoverage, partialApplicationCoverage, @@ -51,8 +54,112 @@ export interface JavaScriptArtifactGraphContext { readonly chunkNodes: Map; readonly moduleNodes: Map; readonly containerNodes: Map; + /** Resolved entrypoints/imports retain unique or competing application owners. */ + readonly applicationRootsBySource: Map>; + /** Incomplete package facts can hide competing launch declarations. */ + readonly applicationOwnershipUnknownContainers: ReadonlySet; + /** Gap origins retain possible owners separately from proven source owners. */ + readonly applicationModuleGapSources: Map>; + readonly applicationPossibleRootsByContainer: Map>; + /** Sources with retained references; unchecked loads carry their own gaps. */ + readonly applicationModuleReferencesRetained: ReadonlySet; } +/** Application manifests exclude dependency packages but retain nested app roots. */ +export const dependencyManifest = (path: string): boolean => + path.split("/").slice(0, -1).includes("node_modules"); + +/** Two distinct roots prove ambiguity; further roots cannot restore uniqueness. */ +export const addApplicationRootCandidate = ( + roots: Set, + root: string, +): void => { + if (roots.size < 2) roots.add(root); +}; + +const applicationContainerRoot = ( + fileSet: JavaScriptArtifactFileSet, + source: Pick, +): string => + fileSet.containers.find( + ({ sha256, path }) => + sha256 === source.container_sha256 && source.path.startsWith(`${path}/`), + )?.path ?? ""; + +/** Equal archive bytes at different logical paths are distinct ownership scopes. */ +export const applicationContainerIdentity = ( + fileSet: JavaScriptArtifactFileSet, + source: Pick, +): string => + compositeKey([ + source.container_sha256, + applicationContainerRoot(fileSet, source), + ]); + +/** Select the inventory's display root; this does not establish runtime ownership. */ +export const applicationManifestPath = ( + context: JavaScriptArtifactGraphContext, +): string | undefined => + context.analysis.packages + .map(({ path }) => path) + .filter((path) => { + const file = context.filesByPath.get(path); + return file !== undefined && !dependencyManifest(path); + }) + .sort( + (left, right) => + left.split("/").length - right.split("/").length || + compareUnicodeCodePoints(left, right), + )[0]; + +/** Inspect inventoried ancestor packages; absent parsing is not absent ownership. + * Entry-bearing packages are candidates; multiple candidates stay unknown. + */ +export const applicationRootPath = ( + context: JavaScriptArtifactGraphContext, + source: JavaScriptArtifactFile, +): string | null => { + // Unread launch metadata can name a main in another inventoried container. + // Archive occurrence identity does not prove isolation from the Node loader. + if (context.applicationOwnershipUnknownContainers.size > 0) return null; + const entryRoots = context.applicationRootsBySource.get(source.path); + const container = applicationContainerIdentity(context.fileSet, source); + for (const root of context.applicationPossibleRootsByContainer.get( + container, + ) ?? []) + if (!entryRoots?.has(root)) return null; + if ( + entryRoots === undefined && + context.applicationModuleGapSources.has(container) + ) + return null; + const roots = new Set(entryRoots); + const directories = source.path.split("/").slice(0, -1); + for (let depth = directories.length; depth >= 0; depth -= 1) { + const root = directories.slice(0, depth).join("/"); + const prefix = root === "" ? "" : `${root}/`; + const path = `${prefix}package.json`; + if (dependencyManifest(path)) continue; + const file = + context.filesByPath.get(path) ?? context.unanalyzedFiles?.get(path); + if (file?.container_sha256 !== source.container_sha256) continue; + const metadata = context.analysis.packages.find( + (value) => value.path === path, + ); + if (metadata?.status !== "included") return null; + const defaultEntry = context.filesByPath.get(`${prefix}index.js`); + if ( + entryRoots === undefined && + (metadata.main_status === "declared" || + defaultEntry?.container_sha256 === source.container_sha256) + ) + addApplicationRootCandidate(roots, root); + } + if (roots.size > 1) return null; + for (const root of roots) return root; + return applicationContainerRoot(context.fileSet, source); +}; + /** Evidence coverage shape shared by reconstruction graph helpers. */ export type JavaScriptArtifactGraphCoverage = ApplicationGraphEvidence["coverage"]; @@ -302,13 +409,25 @@ export const artifactFileNodeKind = ( export const createElectronRoleNode = ( context: JavaScriptArtifactGraphContext, input: RoleNodeInput, -): ApplicationNode => - context.accumulator.addNode({ +): ApplicationNode => { + const target = + input.resolution.resolved_path ?? + (input.resolution.runtime_path_base_proven === true + ? input.resolution.candidate_path + : undefined) ?? + input.resolution.declared_path; + // Raw application-relative declarations keep their source occurrence even + // when identical source bytes appear under separate application prefixes. + const identityPath = + input.resolution.runtime_path_base === "application-root" + ? compositeKey([input.anchor.path, target]) + : target; + return context.accumulator.addNode({ kind: input.kind, identity: artifactLocalIdentity( input.anchor.sha256, "electron-role", - `${input.kind}:${input.resolution.resolution_context}:${input.resolution.resolved_path ?? input.resolution.declared_path}`, + `${input.kind}:${input.resolution.resolution_context}:${identityPath}`, ), observations: [ { @@ -333,6 +452,7 @@ export const createElectronRoleNode = ( }, ], }); +}; /** Resolve an Electron role to an inventoried asset when available. */ export const linkElectronRoleToAsset = ( diff --git a/src/application/javascript/JavaScriptArtifactGraphFindings.ts b/src/application/javascript/JavaScriptArtifactGraphFindings.ts index 823c81f7dd..7794a7768c 100644 --- a/src/application/javascript/JavaScriptArtifactGraphFindings.ts +++ b/src/application/javascript/JavaScriptArtifactGraphFindings.ts @@ -6,6 +6,9 @@ import type { JavaScriptStaticAnalysis } from "../../domain/javascript/javascrip import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; import { addStaticInferenceEdge, + applicationRootPath, + applicationContainerIdentity, + addApplicationRootCandidate, artifactLocalIdentity, chunkLookupKey, createElectronRoleNode, @@ -26,7 +29,10 @@ import { resolveArtifactPathByContext, type ArtifactPathResolution, } from "./JavaScriptArtifactPathResolution.js"; -import { moduleResolutionModeForJavaScriptReference } from "../../domain/javascript/javascriptModuleResolutionMode.js"; +import { + moduleResolutionModeForJavaScriptReference, + isJsonModuleTarget, +} from "../../domain/javascript/javascriptModuleResolutionMode.js"; type StaticReference = JavaScriptStaticAnalysis["references"][number]; type StaticEndpoint = JavaScriptStaticAnalysis["endpoints"][number]; @@ -42,11 +48,11 @@ interface FindingInput { } interface ResolvedReference { + readonly targetImportsUnexamined?: true; readonly path: string | null; readonly file: JavaScriptArtifactFile | null; readonly node: ApplicationNode | null; readonly limitations: readonly string[]; - readonly resolutionStatus?: string; readonly resolution?: ArtifactPathResolution; } @@ -54,7 +60,10 @@ interface ResolvedReference { export function* addJavaScriptStaticFindingsSteps( context: JavaScriptArtifactGraphContext, ): Generator { - const omissions = { selfReferences: 0 }; + const omissions = { + selfReferences: 0, + imports: new Map>(), + }; for (const analyzed of context.analysis.files) { yield; const { file, javascript } = analyzed; @@ -67,11 +76,42 @@ export function* addJavaScriptStaticFindingsSteps( addEndpoint(context, { file, asset, value, coverage }); for (const value of javascript.storage) addStorage(context, { file, asset, value, coverage }); - for (const value of javascript.role_paths) - addDiscoveredRole(context, { file, asset, value, coverage }); for (const value of javascript.source_map_urls) addSourceMapEdge(context, { file, asset, value, coverage }); } + yield* propagateApplicationOwners(context, omissions.imports); + const possible = new Set(); + for (const roots of context.applicationPossibleRootsByContainer.values()) + for (const root of roots) addApplicationRootCandidate(possible, root); + for (const gaps of context.applicationModuleGapSources.values()) { + for (const gap of gaps) { + yield; + for (const root of context.applicationRootsBySource.get(gap) ?? []) + addApplicationRootCandidate(possible, root); + } + } + // An unknown loader may cross an inventoried archive boundary in either + // direction. Retain possible owners in every container it could reach. + for (const file of context.fileSet.files) { + yield; + context.applicationPossibleRootsByContainer.set( + applicationContainerIdentity(context.fileSet, file), + possible, + ); + } + const changedRoots = changedApplicationRoots(context); + for (const { file, javascript } of context.analysis.files) { + yield; + const asset = context.assetNodes.get(file.path); + if (javascript === null || asset === undefined) continue; + const coverage = javascriptAnalysisCoverage(javascript); + for (const value of javascript.role_paths) + addDiscoveredRole( + context, + { file, asset, value, coverage }, + changedRoots, + ); + } return selfReferenceOmissions( omissions.selfReferences, ["static reference", "static references"], @@ -79,10 +119,78 @@ export function* addJavaScriptStaticFindingsSteps( ); } +/** A setter changes process-wide runtime state, not package ownership. */ +const changedApplicationRoots = ( + context: JavaScriptArtifactGraphContext, +): ReadonlySet | null => { + const roots = new Set(); + // An unchecked load can execute a setter even when the imported source has + // no retained AST. Package ownership alone does not prove its runtime path + // unchanged. Only launch owners that can reach the gap are affected; an + // unrelated unselected source is not evidence of a running setter. + for (const sources of context.applicationModuleGapSources.values()) { + for (const source of sources) { + const owners = context.applicationRootsBySource.get(source); + if (owners === undefined) continue; + if (owners.size >= 2) return null; + for (const root of owners) roots.add(root); + } + } + for (const { file, javascript } of context.analysis.files) { + if (!javascript?.electron.app_path_changes?.length) continue; + const owners = context.applicationRootsBySource.get(file.path); + // A setter whose launching application is unknown cannot establish an + // unchanged root for any package-based fallback. + if (owners === undefined) return null; + // Owner sets stop at two distinct roots once uniqueness is disproven. + // They cannot enumerate every application affected by a shared setter. + if (owners.size >= 2) return null; + for (const root of owners) roots.add(root); + const possibleOwners = context.applicationPossibleRootsByContainer.get( + applicationContainerIdentity(context.fileSet, file), + ); + // An unchecked loader can execute this setter in another application's + // process even when only one entrypoint has a proven import of this source. + // Possible-owner sets have the same two-root saturation as proven owners. + if (possibleOwners !== undefined) { + if (possibleOwners.size >= 2) return null; + for (const root of possibleOwners) roots.add(root); + } + } + return roots; +}; + +/** Carry every entrypoint owner through already-resolved imports before roles. + * Sets and change-driven traversal retain competition and terminate on cycles. + */ +function* propagateApplicationOwners( + context: JavaScriptArtifactGraphContext, + imports: ReadonlyMap>, +): Generator { + const pending = [...context.applicationRootsBySource.keys()]; + while (pending.length > 0) { + yield; + const source = pending.pop(); + if (source === undefined) break; + const roots = context.applicationRootsBySource.get(source); + if (roots === undefined) continue; + for (const target of imports.get(source) ?? []) { + yield; + const candidates = + context.applicationRootsBySource.get(target) ?? new Set(); + const previousSize = candidates.size; + for (const root of roots) addApplicationRootCandidate(candidates, root); + if (candidates.size === previousSize) continue; + context.applicationRootsBySource.set(target, candidates); + pending.push(target); + } + } +} + const addReference = ( context: JavaScriptArtifactGraphContext, input: FindingInput, - omissions: { selfReferences: number }, + omissions: { selfReferences: number; imports: Map> }, ): void => { const source = sourceNodeFor( @@ -95,6 +203,32 @@ const addReference = ( input.value.specifier === null ? null : resolveReference(context, input.file.path, input.value, input.coverage); + if ( + (input.value.kind === "static-import" || + input.value.kind === "dynamic-import" || + input.value.kind === "require") && + (input.value.specifier === null || + resolved === null || + resolved.targetImportsUnexamined === true) + ) { + const container = applicationContainerIdentity(context.fileSet, input.file); + const gaps = + context.applicationModuleGapSources.get(container) ?? new Set(); + gaps.add(input.file.path); + context.applicationModuleGapSources.set(container, gaps); + } + if ( + resolved !== null && + resolved.path !== null && + resolved.file !== null && + (input.value.kind === "static-import" || + input.value.kind === "dynamic-import" || + input.value.kind === "require") + ) { + const targets = omissions.imports.get(input.file.path) ?? new Set(); + targets.add(resolved.path); + omissions.imports.set(input.file.path, targets); + } if (input.value.kind === "worker" || input.value.kind === "service-worker") { const worker = context.accumulator.addNode({ kind: input.value.kind === "worker" ? "worker" : "service-worker", @@ -110,7 +244,7 @@ const addReference = ( declared_specifier: input.value.specifier, expression: input.value.expression, resolved_path: resolved?.path ?? null, - resolution_status: resolved?.resolutionStatus ?? null, + resolution_status: resolved?.resolution?.resolution_status ?? null, ...artifactPathResolutionProperties(resolved?.resolution), }, evidence: staticInferenceEvidence({ @@ -133,7 +267,7 @@ const addReference = ( relation: "loads", properties: { kind: input.value.kind, - resolution_status: resolved?.resolutionStatus ?? null, + resolution_status: resolved?.resolution?.resolution_status ?? null, ...artifactPathResolutionProperties(resolved?.resolution), }, limitations: resolved?.limitations ?? [], @@ -182,7 +316,7 @@ const addReference = ( ) : null, resolved_path: resolved?.path ?? null, - resolution_status: resolved?.resolutionStatus ?? null, + resolution_status: resolved?.resolution?.resolution_status ?? null, ...artifactPathResolutionProperties(resolved?.resolution), }, ...(resolved === null ? {} : { limitations: resolved.limitations }), @@ -344,7 +478,9 @@ const addStorage = ( const addDiscoveredRole = ( context: JavaScriptArtifactGraphContext, input: FindingInput, + changedRoots: ReadonlySet | null, ): void => { + const root = applicationRootPath(context, input.file); const resolution = resolveArtifactPathByContext({ declaredPath: input.value.path, sourcePath: input.file.path, @@ -352,14 +488,25 @@ const addDiscoveredRole = ( files: context.filesByPath, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, - ...(input.value.role === "preload" || - (input.value.role === "renderer" && - input.value.resolution_context === "filesystem-expression") + ...(input.value.role === "preload" ? { runtimePathKind: "exact-file" as const } : input.value.role === "renderer" && - input.value.resolution_context === "module-specifier" - ? { runtimePathKind: "url-resource" as const } - : {}), + input.value.resolution_context === "filesystem-expression" + ? { + // Raw loadFile literals belong to the application, not its caller. + runtimePathKind: "application-file" as const, + applicationRootPath: + changedRoots === null || (root !== null && changedRoots.has(root)) + ? null + : root, + applicationRootIsProven: context.applicationRootsBySource.has( + input.file.path, + ), + } + : input.value.role === "renderer" && + input.value.resolution_context === "module-specifier" + ? { runtimePathKind: "url-resource" as const } + : {}), }); const role = createElectronRoleNode(context, { kind: @@ -533,6 +680,23 @@ const resolveReference = ( : {}), }); const path = resolution.resolved_path; + const targetPath = path ?? resolution.candidate_path; + const targetKind = + targetPath === undefined + ? undefined + : ( + context.filesByPath.get(targetPath) ?? + context.unanalyzedFiles?.get(targetPath) + )?.kind; + // Resolving bytes does not prove that the loader's module imports were read. + // Only an exact JSON loader target can be inert without JavaScript analysis. + const targetIsJson = + targetPath !== undefined && isJsonModuleTarget(targetPath, targetKind); + const targetImportsUnexamined = + !targetIsJson && + (resolution.resolution_status === "unavailable" || + (path !== null && + !context.applicationModuleReferencesRetained.has(path))); if (path === null) { const source = context.filesByPath.get(sourcePath); return source === undefined @@ -550,7 +714,9 @@ const resolveReference = ( resolution, ), limitations: resolution.limitations, - resolutionStatus: resolution.resolution_status, + ...(targetImportsUnexamined + ? { targetImportsUnexamined: true as const } + : {}), resolution, }; } @@ -563,7 +729,9 @@ const resolveReference = ( file, node, limitations: resolution.limitations, - resolutionStatus: resolution.resolution_status, + ...(targetImportsUnexamined + ? { targetImportsUnexamined: true as const } + : {}), resolution, }; }; diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts index dbfc19d765..224d3631e0 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from "vitest"; +import { createHash } from "node:crypto"; import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; @@ -14,6 +15,8 @@ import { addJavaScriptArtifactFiles, createJavaScriptArtifactRootNode, } from "./JavaScriptArtifactGraphStructure.js"; +import { addJavaScriptStaticFindingsSteps } from "./JavaScriptArtifactGraphFindings.js"; +import { applicationContainerIdentity } from "./JavaScriptArtifactGraphContext.js"; const SHA256 = "a".repeat(64); const REASON = @@ -98,6 +101,11 @@ const contextFor = ( chunkNodes: new Map(), moduleNodes: new Map(), containerNodes: new Map(), + applicationRootsBySource: new Map(), + applicationOwnershipUnknownContainers: new Set(), + applicationModuleGapSources: new Map(), + applicationPossibleRootsByContainer: new Map(), + applicationModuleReferencesRetained: new Set(), }; }; @@ -146,3 +154,225 @@ describe("skipped JavaScript parse reporting", () => { expect(fileObservation(context).parse_status).toBe(javascript.parse_status); }); }); + +it.each([false, true])( + "retains possible setter owners independently of proven imports (possible owner: %s)", + (possibleOwner) => { + const window: JavaScriptArtifactFile = { + ...skippedFile(), + path: "b/window.cjs", + sha256: "b".repeat(64), + }; + const setter: JavaScriptArtifactFile = { + ...skippedFile(), + path: "shared/change.cjs", + sha256: "c".repeat(64), + }; + const page: JavaScriptArtifactFile = { + ...skippedFile(), + path: "b/index.html", + sha256: "d".repeat(64), + kind: "html", + }; + const analysis = analysisFor([ + { + file: setter, + javascript: analyzeJavaScriptStaticSource( + 'const { app } = require("electron"); app.setAppPath("/changed");', + ), + semantic: null, + }, + { + file: window, + javascript: analyzeJavaScriptStaticSource( + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + ), + semantic: null, + }, + { file: page, javascript: null, semantic: null }, + ]); + const base = contextFor(window, analysis); + const files = [setter, window, page]; + const context: JavaScriptArtifactGraphContext = { + ...base, + fileSet: { ...base.fileSet, files }, + filesByPath: new Map(files.map((file) => [file.path, file])), + }; + context.applicationRootsBySource.set(setter.path, new Set(["a"])); + context.applicationRootsBySource.set(window.path, new Set(["b"])); + if (possibleOwner) + context.applicationPossibleRootsByContainer.set( + applicationContainerIdentity(context.fileSet, setter), + new Set(["b"]), + ); + addJavaScriptArtifactFiles(context); + const steps = addJavaScriptStaticFindingsSteps(context); + let step = steps.next(); + while (!step.done) step = steps.next(); + const role = context.accumulator + .nodes() + .find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: possibleOwner ? null : "b/index.html", + resolution_status: possibleOwner ? "unavailable" : "resolved", + }), + }), + ]), + ); + }, +); + +it.each([ + "unselected", + "transitive-unselected", + "invalid-utf8", + "worker-failure", + "parse-failed", + "opaque", + "unknown-loader", + "read-inert", + "unreferenced", + "json", + "missing", +] as const)( + "does not prove an unchanged application path across a reachable %s load gap", + (gap) => { + const makeFile = ( + path: string, + kind: JavaScriptArtifactFile["kind"] = "javascript", + ): JavaScriptArtifactFile => ({ + ...skippedFile(), + path, + sha256: createHash("sha256").update(path).digest("hex"), + kind, + }); + const main = makeFile("a/main.cjs"); + const other = makeFile("b/main.cjs"); + const target = makeFile( + gap === "json" ? "a/change.json" : "a/change.cjs", + gap === "json" ? "json" : "javascript", + ); + const page = makeFile("a/index.html", "html"); + const otherPage = makeFile("b/index.html", "html"); + const window = + 'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");'; + const statement = + gap === "unknown-loader" + ? "require(process.env.CHANGE);" + : gap === "transitive-unselected" + ? 'require("./middle.cjs");' + : gap === "unreferenced" + ? "" + : `require("./${gap === "json" ? "change.json" : "change.cjs"}");`; + const entry: AnalyzedJavaScriptArtifactFile = + gap === "worker-failure" + ? skippedEntry(target) + : { + file: + gap === "invalid-utf8" + ? { + ...target, + text: { included: false, reason: "invalid-utf8" }, + } + : target, + javascript: + gap === "read-inert" + ? analyzeJavaScriptStaticSource("module.exports=42;") + : gap === "parse-failed" + ? { + ...analyzeJavaScriptStaticSource(""), + parse_status: "failed", + parse_error_count: 1, + } + : null, + semantic: null, + }; + const entries: AnalyzedJavaScriptArtifactFile[] = [ + { + file: main, + javascript: analyzeJavaScriptStaticSource(statement + window), + semantic: null, + }, + { + file: other, + javascript: analyzeJavaScriptStaticSource(window), + semantic: null, + }, + ...(gap === "transitive-unselected" + ? [ + { + file: makeFile("a/middle.cjs"), + javascript: analyzeJavaScriptStaticSource( + 'require("./change.cjs");', + ), + semantic: null, + }, + ] + : []), + ...(gap === "missing" || gap === "opaque" ? [] : [entry]), + { file: page, javascript: null, semantic: null }, + { file: otherPage, javascript: null, semantic: null }, + ]; + const analysis = analysisFor(entries); + const base = contextFor(main, analysis); + const files = entries.map(({ file }) => file); + const context: JavaScriptArtifactGraphContext = { + ...base, + fileSet: { ...base.fileSet, files }, + filesByPath: new Map(files.map((file) => [file.path, file])), + unanalyzedFiles: + gap === "opaque" + ? { + get: (path) => (path === target.path ? target : undefined), + hasDirectory: () => false, + } + : undefined, + applicationModuleReferencesRetained: new Set([ + main.path, + other.path, + ...(gap === "transitive-unselected" ? ["a/middle.cjs"] : []), + ...(gap === "read-inert" ? [target.path] : []), + ]), + }; + context.applicationRootsBySource.set(main.path, new Set(["a"])); + context.applicationRootsBySource.set(other.path, new Set(["b"])); + addJavaScriptArtifactFiles(context); + const steps = addJavaScriptStaticFindingsSteps(context); + while (!steps.next().done) {} + const roles = context.accumulator + .nodes() + .filter(({ kind }) => kind === "electron-renderer"); + expect(roles).toHaveLength(2); + const changed = !["read-inert", "unreferenced", "json", "missing"].includes( + gap, + ); + for (const [path, expected] of [ + [main.path, changed ? null : "a/index.html"], + // Existing loader ownership also allows an unchecked source to import + // the other app's module; no process isolation has been established. + [other.path, changed ? null : "b/index.html"], + ] as const) { + const role = roles.find(({ observations }) => + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === path, + ), + ); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: expected, + resolution_status: expected === null ? "unavailable" : "resolved", + }), + }), + ]), + ); + } + }, +); diff --git a/src/application/javascript/JavaScriptArtifactGraphStructure.ts b/src/application/javascript/JavaScriptArtifactGraphStructure.ts index 6ec343af15..4594f1589b 100644 --- a/src/application/javascript/JavaScriptArtifactGraphStructure.ts +++ b/src/application/javascript/JavaScriptArtifactGraphStructure.ts @@ -1,4 +1,5 @@ import type { ArtifactInventorySnapshot } from "../../domain/artifactInventorySnapshot.js"; +import { isJsonModuleTarget } from "../../domain/javascript/javascriptModuleResolutionMode.js"; import type { ApplicationNode } from "../../domain/javascript/javascriptApplicationGraphSchemas.js"; import { htmlInlineScriptLimitation } from "./JavaScriptArtifactAnalysis.js"; import { @@ -18,6 +19,10 @@ import type { JavaScriptArtifactGraphAccumulator } from "./JavaScriptArtifactGra import { isSkippedJavaScriptAnalysis } from "./JavaScriptArtifactAnalysisTypes.js"; import { addArtifactContainsEdge, + applicationManifestPath, + applicationContainerIdentity, + addApplicationRootCandidate, + dependencyManifest, addUnavailableStaticParseScope, artifactFileNodeKind, artifactLocalIdentity, @@ -36,8 +41,8 @@ import { import { artifactPathResolutionProperties, resolveArtifactPathByContext, + type ArtifactPathResolution, } from "./JavaScriptArtifactPathResolution.js"; -import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; interface PackageRoleInput { readonly packageNode: ApplicationNode; @@ -480,6 +485,7 @@ export const addJavaScriptPackageNodes = ( name: packageValue.name, version: packageValue.version, main: packageValue.main, + main_status: packageValue.main_status, renderer: packageValue.renderer, parse_status: packageValue.status, }, @@ -516,6 +522,24 @@ export const addJavaScriptPackageNodes = ( operation: "inventory-package", }); if (dependencyManifest(file.path)) continue; + if ( + packageValue.status === "included" && + packageValue.main_entrypoint === "" + ) { + registerPackageResolutionOwner( + context, + file, + resolveArtifactPathByContext({ + declaredPath: packageValue.main_entrypoint, + sourcePath: file.path, + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files: context.filesByPath, + directoryPaths: context.directoryPaths, + unanalyzedFiles: context.unanalyzedFiles, + }), + ); + } addPackageRole(context, { packageNode: node, packageFile: file, @@ -532,24 +556,6 @@ export const addJavaScriptPackageNodes = ( return roots; }; -/** Dependency-only artifacts retain their inventory root. */ -const applicationManifestPath = ( - context: JavaScriptArtifactGraphContext, -): string | undefined => - context.analysis.packages - .map(({ path }) => path) - .filter( - (path) => context.filesByPath.has(path) && !dependencyManifest(path), - ) - .sort( - (left, right) => - left.split("/").length - right.split("/").length || - compareUnicodeCodePoints(left, right), - )[0]; - -const dependencyManifest = (path: string): boolean => - path.split("/").slice(0, -1).includes("node_modules"); - const createFileTarget = ( context: JavaScriptArtifactGraphContext, file: JavaScriptArtifactFile, @@ -645,10 +651,17 @@ const addPackageRole = ( declaredPath: input.declaredPath, sourcePath: input.packageFile.path, context: "package-entrypoint", + // Launch main uses Node's filesystem lookup, independently of renderer + // metadata. Literal percent bytes are filename bytes at this boundary. + ...(input.kind === "electron-main" + ? { runtimePathKind: "node-entrypoint" as const } + : {}), files: context.filesByPath, directoryPaths: context.directoryPaths, unanalyzedFiles: context.unanalyzedFiles, }); + if (input.kind === "electron-main") + registerPackageResolutionOwner(context, input.packageFile, resolution); const role = createElectronRoleNode(context, { kind: input.kind, anchor: input.packageFile, @@ -685,6 +698,61 @@ const addPackageRole = ( }); }; +const registerPackageResolutionOwner = ( + context: JavaScriptArtifactGraphContext, + packageFile: JavaScriptArtifactFile, + resolution: ArtifactPathResolution, +): void => { + if (resolution.resolution_status === "resolved") + registerPackageEntrypointOwner( + context, + packageFile, + resolution.resolved_path, + ); + else if ( + resolution.resolution_status === "unavailable" && + !( + resolution.candidate_path !== undefined && + isJsonModuleTarget( + resolution.candidate_path, + context.unanalyzedFiles?.get(resolution.candidate_path)?.kind, + ) + ) + ) + registerPossiblePackageOwner(context, packageFile); +}; + +const registerPackageEntrypointOwner = ( + context: JavaScriptArtifactGraphContext, + packageFile: JavaScriptArtifactFile, + sourcePath: string, +): void => { + const roots = + context.applicationRootsBySource.get(sourcePath) ?? new Set(); + addApplicationRootCandidate(roots, packageRoot(packageFile)); + context.applicationRootsBySource.set(sourcePath, roots); + if ( + !context.applicationModuleReferencesRetained.has(sourcePath) && + !isJsonModuleTarget(sourcePath, context.filesByPath.get(sourcePath)?.kind) + ) + registerPossiblePackageOwner(context, packageFile); +}; + +const packageRoot = (file: JavaScriptArtifactFile): string => + file.path.slice(0, -"package.json".length).replace(/\/$/u, ""); + +const registerPossiblePackageOwner = ( + context: JavaScriptArtifactGraphContext, + file: JavaScriptArtifactFile, +): void => { + const container = applicationContainerIdentity(context.fileSet, file); + const roots = + context.applicationPossibleRootsByContainer.get(container) ?? + new Set(); + addApplicationRootCandidate(roots, packageRoot(file)); + context.applicationPossibleRootsByContainer.set(container, roots); +}; + const containerProperties = (container: JavaScriptArtifactContainer) => ({ path: container.path, format: "asar", diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts new file mode 100644 index 0000000000..ec58e8b1aa --- /dev/null +++ b/src/application/javascript/JavaScriptArtifactPathResolution.syntheticRootEscape.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "vitest"; + +import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; + +const files = new Map( + ["app/main.mjs", "shared/window.cjs", "rea-artifact/shared/window.cjs"].map( + (path) => [ + path, + { + path, + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: 0, + inventory_artifact_id: path, + kind: "javascript", + unpacked: false, + text: { included: true, value: "" }, + } satisfies JavaScriptArtifactFile, + ], + ), +); + +it.each([ + "../../rea-artifact/shared/window.cjs", + "../%2e%2e/rea-artifact/shared/window.cjs", + "%2e%2e/%2e%2e/rea-artifact/shared/window.cjs", + "../../rea-artifact/shared/../shared/window.cjs", +])( + "retains above-root traversal before synthetic URL normalization: %s", + (declaredPath) => { + const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs"); + expect(actual.pathname.startsWith("/selected-root/")).toBe(false); + expect( + resolveArtifactPathByContext({ + declaredPath, + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }, +); + +it.each([ + ["../shared/window.cjs", "shared/window.cjs"], + ["./discard%2Fsegment/../../shared/window.cjs", "shared/window.cjs"], + ["./discard%/../../shared/window.cjs", "shared/window.cjs"], + ["./%252e%252e/../../shared/window.cjs", "shared/window.cjs"], + ["../rea-artifact/shared/window.cjs", "rea-artifact/shared/window.cjs"], +])( + "retains an inside-root URL without banning synthetic-looking names: %s", + (declaredPath, target) => { + const actual = new URL(declaredPath, "file:///selected-root/app/main.mjs"); + expect(actual.pathname).toBe(`/selected-root/${target}`); + expect( + resolveArtifactPathByContext({ + declaredPath, + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files, + }), + ).toMatchObject({ resolution_status: "resolved", resolved_path: target }); + }, +); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts index 2689324561..e5ece75e21 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.test.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.test.ts @@ -9,7 +9,396 @@ import { artifactLocalIdentity } from "./JavaScriptArtifactGraphContext.js"; import { applicationNodeIdentitySchema } from "../../domain/javascript/javascriptApplicationEvidenceSchemas.js"; import { analyzeJavaScriptStaticSource } from "../../domain/javascript/javascriptStaticAnalysis.js"; +describe("Windows CommonJS request classification", () => { + it.each([ + "..\\shared\\window.cjs", + ".\\window.cjs", + "fixture\\window.cjs", + "C:shared\\window.cjs", + ])( + "retains unknown Windows CommonJS request semantics for %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("app/main.cjs", "root"), + file("app/window.cjs", "root"), + file("shared/window.cjs", "root"), + ]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + + it.each([ + "node:\\fs", + "node:fs\\extra", + "https:\\example.invalid\\window.cjs", + ])( + "does not treat the rejected scheme request %s as an unknown Windows path", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([file("app/main.cjs", "root")]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "rejected", + resolved_path: null, + }); + }, + ); + + it("keeps rejected and URL-owned backslash requests distinct from CommonJS host syntax", () => { + const files = fileMap([ + file("app/main.cjs", "root"), + file("app/window.cjs", "root"), + ]); + for (const [declaredPath, moduleKind, status, resolvedPath] of [ + [".\\window\0.cjs", "require", "rejected", null], + [".\\window.cjs", "import", "rejected", null], + ["./window.cjs", "require", "resolved", "app/window.cjs"], + ["node:fs", "require", "external", null], + ] as const) + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind, + files, + }), + ).toMatchObject({ + resolution_status: status, + resolved_path: resolvedPath, + }); + }); +}); + +describe("literal POSIX inventory base segments", () => { + it("validates the final URL after a parent reference removes the encoded base segment", () => { + expect( + resolve({ + declaredPath: "../b.js", + sourcePath: "literal\\dir/a.mjs", + context: "module-specifier", + moduleKind: "import", + files: fileMap([file("b.js", "container")]), + }), + ).toMatchObject({ resolution_status: "resolved", resolved_path: "b.js" }); + }); + it.each( + ["literal\\dir", "resources/app.asar/literal\\dir"].flatMap((directory) => + (["import", "require", "compiler-dependent"] as const).map( + (moduleKind) => ({ directory, moduleKind }), + ), + ), + )( + "preserves loader-specific admission from $directory with mode $moduleKind", + ({ directory, moduleKind }) => { + const sourcePath = `${directory}/a.js`; + const target = `${directory}/b.js`; + const files = fileMap([ + file(sourcePath, "container"), + file(target, "container"), + file(`${directory.replaceAll("\\", "/")}/b.js`, "container"), + ]); + expect( + resolve({ + declaredPath: "./b.js", + sourcePath, + context: "module-specifier", + moduleKind, + files, + }), + ).toMatchObject({ + resolution_status: + moduleKind === "require" + ? "resolved" + : moduleKind === "import" + ? "rejected" + : "unavailable", + resolved_path: moduleKind === "require" ? target : null, + ...(moduleKind === "compiler-dependent" + ? { + conditional_resolutions: [ + expect.objectContaining({ + mode: "import", + resolution_status: "rejected", + resolved_path: null, + }), + expect.objectContaining({ + mode: "require", + resolution_status: "resolved", + resolved_path: target, + }), + ], + } + : {}), + }); + }, + ); + + it.each([ + "./bad%5Cb.js", + "./bad%2Fb.js", + "./bad%00b.js", + "./bad%xxb.js", + ".\\b.js", + ])( + "still rejects newly declared unsafe module URL syntax %s inside a literal inventory directory", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "literal\\dir/a.js", + context: "module-specifier", + moduleKind: "import", + files: fileMap([file("literal\\dir/b.js", "container")]), + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }, + ); + + it.each([ + "./discard%5C/../b.js", + "./discard%2F/../b.js", + "./discard%00/../b.js", + "./discard%xx/../b.js", + ])( + "still rejects inherited encoded separators after normalizing discarded request segments: %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "literal\\dir/a.js", + context: "module-specifier", + moduleKind: "import", + files: fileMap([file("literal\\dir/b.js", "container")]), + }), + ).toMatchObject({ + resolution_status: "rejected", + resolved_path: null, + }); + }, + ); +}); + +describe("runtime host path classification", () => { + it.each([ + "/discard%2F/../shared/window.cjs", + "/discard%5C/../shared/window.cjs", + "/discard%00/../shared/window.cjs", + "/discard%xx/../shared/window.cjs", + ])( + "normalizes discarded absolute URL segments before pathname validation: %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: new Map(), + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + it("normalizes discarded relative URL segments before pathname validation", () => { + expect( + resolve({ + declaredPath: "./discard%2F/../window.cjs", + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: fileMap([file("app/window.cjs", "root")]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/window.cjs", + }); + }); + it("retains unknown URL identity for an unpaired source directory segment", () => { + expect( + resolve({ + declaredPath: "./window.cjs", + sourcePath: "app/\ud800/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: new Map(), + }), + ).toMatchObject({ resolution_status: "unavailable", resolved_path: null }); + }); + it.each([ + "/shared%2Fwindow.mjs", + "/shared%5cwindow.mjs", + "/shared%00window.mjs", + "/shared%xxwindow.mjs", + ])( + "rejects invalid absolute ESM URL path syntax before host lookup: %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: new Map(), + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: new Map(), + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + it("keeps encoded query bytes outside ESM pathname admission", () => { + expect( + resolve({ + declaredPath: "/shared/window.mjs?token=%2F#fragment", + sourcePath: "app/main.mjs", + context: "module-specifier", + moduleKind: "import", + files: new Map(), + }), + ).toMatchObject({ resolution_status: "unavailable", resolved_path: null }); + }); + it.each(["exact-file", "application-file", "node-entrypoint"] as const)( + "keeps POSIX and Windows absolute %s paths unavailable before canonical admission", + (runtimePathKind) => { + for (const declaredPath of [ + "/opt/app/main.cjs", + "C:/app/main.cjs", + "C:\\app\\main.cjs", + "\\\\server\\share\\main.cjs", + "//server/share/main.cjs", + "\\app\\main.cjs", + ]) + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "filesystem-expression", + runtimePathKind, + files: fileMap([ + file("opt/app/main.cjs", "root"), + file("app/main.cjs", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + declared_path: declaredPath, + }); + expect( + resolve({ + declaredPath: "C:/app/\0main.cjs", + sourcePath: "app/main.cjs", + context: "filesystem-expression", + runtimePathKind, + files: new Map(), + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }, + ); +}); + describe("artifact URI scheme classification", () => { + it.each(["entry:prod.cjs", "entry:prod%2Fname.cjs"])( + "retains package main filename bytes for %s without URI classification", + (declaredPath) => { + const files = fileMap([ + file("app/package.json", "root"), + file(`app/${declaredPath}`, "root"), + ]); + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files, + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "resolved", + resolved_path: `app/${declaredPath}`, + }); + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ resolution_status: "external", resolved_path: null }); + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files: fileMap([file("app/package.json", "root")]), + }), + ).toMatchObject({ + resolution_status: "not-found", + resolved_path: null, + candidate_path: `app/${declaredPath}`, + }); + }, + ); + + it.each(["require", "import"] as const)( + "resolves a colon-bearing dependency package main with the %s loader", + (moduleKind) => { + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind, + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + '{"main":"entry:prod.cjs"}', + ), + file("app/node_modules/fixture/entry:prod.cjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/entry:prod.cjs", + }); + }, + ); + it.each(["html-reference", "module-specifier", "url-reference"] as const)( "keeps digit-bearing schemes external in %s even when a local file matches", (context) => { @@ -226,10 +615,13 @@ describe("loader-specific module suffix lookup", () => { files, unanalyzedFiles: { ...unanalyzedFiles, - get: () => ({ - ...opaque.get("app/core.jsc")!, - container_sha256: "b".repeat(64), - }), + get: (path: string) => + path === "app/core.jsc" + ? { + ...opaque.get(path)!, + container_sha256: "b".repeat(64), + } + : undefined, }, }), ).toMatchObject({ resolution_status: "not-found", resolved_path: null }); @@ -569,6 +961,37 @@ describe("legacy package main lookup", () => { }); describe("legacy package main metadata validation and fallback", () => { + it.each( + ["[]", "[{}]", "123", '"root"', "null", "false"].flatMap((text) => + (["import", "require", "compiler-dependent"] as const).map( + (moduleKind) => ({ text, moduleKind }), + ), + ), + )( + "blocks index fallback for non-object package root $text in $moduleKind mode", + ({ text, moduleKind }) => { + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/entry.js", + context: "module-specifier", + moduleKind, + files: fileMap([ + file("app/entry.js", "root"), + file("app/node_modules/fixture/package.json", "root", text), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + limitations: expect.arrayContaining([ + expect.stringContaining("not valid package JSON"), + ]), + }); + }, + ); + it("still rejects non-string package main metadata", () => { expect( resolve({ @@ -980,7 +1403,7 @@ describe("ESM module URL path decoding", () => { }); describe("JavaScript file URL resolution", () => { - it("resolves file URL paths without interpreting query bytes as path syntax", () => { + it("keeps absolute file URL modules unavailable without a host mapping", () => { const files = fileMap([file("assets/app.js", "root")]); for (const declaredPath of [ "file:///assets/app.js#entry", @@ -996,8 +1419,8 @@ describe("JavaScript file URL resolution", () => { }), ).toMatchObject({ declared_path: declaredPath, - resolution_status: "resolved", - resolved_path: "assets/app.js", + resolution_status: "unavailable", + resolved_path: null, }); } @@ -1024,7 +1447,7 @@ describe("JavaScript file URL resolution", () => { moduleKind: "import", files, }), - ).toMatchObject({ resolution_status: "external", resolved_path: null }); + ).toMatchObject({ resolution_status: "unavailable", resolved_path: null }); }); }); @@ -1447,7 +1870,118 @@ describe("scoped package directory and specifier resolution", () => { }); }); +const packageExportPatternCases = [ + { + name: "matching", + exports: { "./*": "./*.cjs" }, + request: "fixture/window", + status: "unavailable", + }, + { + name: "nonmatching", + exports: { "./other/*": "./*.cjs" }, + request: "fixture/window", + status: "external", + }, + { + name: "suffix mismatch", + exports: { "./*.js": "./*.cjs" }, + request: "fixture/window.cjs", + status: "external", + }, + { + name: "empty match", + exports: { "./*end": "./*.cjs" }, + request: "fixture/end", + status: "external", + }, + { + name: "multiple stars", + exports: { "./*/*": "./*.cjs" }, + request: "fixture/deep/window", + status: "external", + }, + { + name: "exact before pattern", + exports: { "./*": "./*.cjs", "./window": "./window.cjs" }, + request: "fixture/window", + status: "resolved", + }, + { + name: "exact null before pattern", + exports: { "./*": "./*.cjs", "./window": null }, + request: "fixture/window", + status: "external", + }, + { + name: "matching null", + exports: { "./*": null }, + request: "fixture/window", + status: "external", + }, + { + name: "overlapping null prefix", + exports: { "./*": "./*.cjs", "./private/*": null }, + request: "fixture/private/window", + status: "external", + }, + { + name: "overlapping null suffix", + exports: { "./*": "./*.cjs", "./*.hidden": null }, + request: "fixture/window.hidden", + status: "external", + }, + { + name: "overlapping value prefix", + exports: { "./*": null, "./public/*": "./*.cjs" }, + request: "fixture/public/window", + status: "unavailable", + }, + { + name: "inactive pattern condition", + exports: { "./*": { browser: "./*.cjs" } }, + request: "fixture/window", + status: "external", + }, + { + name: "conditional null", + exports: { "./*": { node: null, default: "./*.cjs" } }, + request: "fixture/window", + status: "external", + }, +]; + describe("package exports subpath encapsulation", () => { + it.each(packageExportPatternCases)( + "keeps $name exports patterns distinct from deterministic encapsulation", + ({ exports, request, status }) => { + for (const moduleKind of ["import", "require"] as const) { + expect( + resolve({ + declaredPath: request, + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind, + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ exports }), + ), + file("app/node_modules/fixture/window.cjs", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: status, + resolved_path: + status === "resolved" + ? "app/node_modules/fixture/window.cjs" + : null, + }); + } + }, + ); it("keeps subpaths encapsulated when only a root target is exported", () => { const files = fileMap([ file("app/src/consumer.js", "root"), @@ -2148,7 +2682,7 @@ describe("package import alias refusals", () => { files, }); expect(outcome).toMatchObject({ - resolution_status: "external", + resolution_status: "unavailable", resolved_path: null, }); expect(outcome.limitations.join(" ")).toMatch(/wildcard/i); @@ -2177,12 +2711,12 @@ describe("package import alias refusals", () => { files, }); expect(recursive).toMatchObject({ - resolution_status: "external", + resolution_status: "unavailable", resolved_path: null, }); expect(recursive.limitations.join(" ")).toMatch(/recursive/i); expect(external).toMatchObject({ - resolution_status: "external", + resolution_status: "unavailable", resolved_path: null, }); expect(external.limitations.join(" ")).toMatch(/external-package/i); @@ -2225,10 +2759,10 @@ describe("package import alias refusals", () => { files, }); expect(outcome).toMatchObject({ - resolution_status: "external", + resolution_status: "unavailable", resolved_path: null, }); - expect(outcome.limitations.join(" ")).toContain("package scopes"); + expect(outcome.limitations.join(" ")).toContain("container boundary"); }); }); @@ -2379,6 +2913,8 @@ describe("contextual JavaScript module identity", () => { const analysis = analyzeJavaScriptStaticSource( ` import { BrowserWindow } from "electron"; + import path, { dirname } from "node:path"; + import { fileURLToPath } from "node:url"; new BrowserWindow({ webPreferences: { preload: path.resolve("ignored", __dirname, "preload.cjs") }}); @@ -2911,6 +3447,122 @@ describe("url-reference JavaScript artifact path resolution", () => { }); }); +describe("module-backed package main host paths", () => { + it.each([ + ["sub\\entry.cjs", "unavailable", null], + ["sub/entry.cjs", "resolved", "app/node_modules/fixture/sub/entry.cjs"], + ] as const)( + "preserves dependency main host semantics for %s", + (main, status, expected) => { + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ main }), + ), + file("app/node_modules/fixture/sub/entry.cjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ resolution_status: status, resolved_path: expected }); + }, + ); + + it.each( + [ + "/shared/window.cjs", + "C:/shared/window.cjs", + "\\\\host\\share\\window.cjs", + ].flatMap((main) => [true, false].map((direct) => ({ main, direct }))), + )( + "retains host main $main before logical lookup (direct=$direct)", + ({ main, direct }) => { + expect( + resolve({ + declaredPath: direct ? main : "fixture", + sourcePath: direct + ? "app/node_modules/fixture/package.json" + : "app/main.cjs", + context: direct ? "package-entrypoint" : "module-specifier", + moduleKind: "require", + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ main }), + ), + file("shared/window.cjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ + declared_path: direct ? main : "fixture", + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + + it.each(["entry.cjs", "./entry.cjs"])( + "preserves relative package main lookup for %s", + (main) => { + expect( + resolve({ + declaredPath: "fixture", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ main }), + ), + file("app/node_modules/fixture/entry.cjs", "root"), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "app/node_modules/fixture/entry.cjs", + }); + }, + ); + + it.each([true, false])( + "keeps NUL package mains rejected (direct=%s)", + (direct) => { + const main = "/shared/\0window.cjs"; + expect( + resolve({ + declaredPath: direct ? main : "fixture", + sourcePath: direct ? "app/package.json" : "app/main.cjs", + context: direct ? "package-entrypoint" : "module-specifier", + moduleKind: "require", + files: fileMap([ + file("app/main.cjs", "root"), + file( + "app/node_modules/fixture/package.json", + "root", + JSON.stringify({ main }), + ), + file("app/node_modules/fixture/index.js", "root"), + ]), + }), + ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + }, + ); +}); + const resolve = (input: ResolveArtifactPathInput) => resolveArtifactPathByContext(input); @@ -2994,7 +3646,7 @@ describe("exact HTML resource resolution", () => { }); it.each(["require", "import"] as const)( - "rejects a literal trailing backslash package main before %s probing", + "retains unknown Windows trailing-backslash package main semantics before %s probing", (moduleKind) => { const files = fileMap([ file("entry.js", "root"), @@ -3013,10 +3665,240 @@ it.each(["require", "import"] as const)( moduleKind, files, }), - ).toMatchObject({ resolution_status: "rejected", resolved_path: null }); + ).toMatchObject({ resolution_status: "unavailable", resolved_path: null }); }, ); +describe("literal-percent runtime path resolution", () => { + it.each([ + ["exact-file", "filesystem-expression"], + ["exact-file", "module-specifier"], + ["node-entrypoint", "filesystem-expression"], + ] as const)( + "preserves literal percent bytes for %s in %s", + (runtimePathKind, context) => { + const declaredPath = "./preload%2F%5C%2E.cjs"; + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context, + runtimePathKind, + files: fileMap([ + file("app/main.cjs", "root"), + file("app/preload%2F%5C%2E.cjs", "root"), + file("app/preload/entry.cjs", "root"), + ]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_context: context, + resolution_status: "resolved", + resolved_path: "app/preload%2F%5C%2E.cjs", + limitations: [], + }); + }, + ); + + it("keeps a file URL's decoded percent bytes literal at runtime lookup", () => { + const analysis = analyzeJavaScriptStaticSource(` + import { BrowserWindow } from "electron"; + import { fileURLToPath } from "node:url"; + new BrowserWindow({ webPreferences: { + preload: fileURLToPath(new URL("./preload%252Fname.cjs", import.meta.url)) + }}); + `); + const preload = analysis.role_paths.find(({ role }) => role === "preload"); + if (preload === undefined) + throw new Error("Expected a static preload path"); + expect(analysis.parse_status).toBe("complete"); + expect(preload).toMatchObject({ + path: "./preload%2Fname.cjs", + resolution_context: "filesystem-expression", + }); + expect( + resolve({ + declaredPath: preload.path, + sourcePath: "app/main.mjs", + context: preload.resolution_context, + runtimePathKind: "exact-file", + files: fileMap([ + file("app/main.mjs", "root"), + file("app/preload%2Fname.cjs", "root"), + file("app/preload/name.cjs", "root"), + file("app/preload%252Fname.cjs", "root"), + ]), + }), + ).toMatchObject({ + declared_path: "./preload%2Fname.cjs", + resolution_status: "resolved", + resolved_path: "app/preload%2Fname.cjs", + limitations: [], + }); + }); + + it.each(["exact-file", "node-entrypoint"] as const)( + "confines literal-percent filesystem paths for %s", + (runtimePathKind) => { + expect( + resolve({ + declaredPath: "../../outside%2Fname.cjs", + sourcePath: "app/main.cjs", + context: "filesystem-expression", + runtimePathKind, + files: fileMap([ + file("app/main.cjs", "root"), + file("outside%2Fname.cjs", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "rejected", + resolved_path: null, + limitations: [ + expect.stringContaining("escapes the canonical artifact root"), + ], + }); + }, + ); + + it.each(["%2F", "%5C"])( + "keeps %s rejected in a URL-owned runtime resource", + (separator) => { + expect( + resolve({ + declaredPath: `file:///app/preload${separator}name.cjs`, + sourcePath: "app/main.cjs", + context: "module-specifier", + runtimePathKind: "url-resource", + files: fileMap([ + file("app/main.cjs", "root"), + file(`app/preload${separator}name.cjs`, "root"), + file("app/preload/name.cjs", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "rejected", + resolved_path: null, + }); + }, + ); +}); + +describe("application-root Electron loadFile resolution", () => { + it.each([ + "sub\\index.html", + ".\\index.html", + "C:index.html", + "C:sub\\index.html", + ])( + "retains unknown Windows loadFile host semantics for %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "filesystem-expression", + runtimePathKind: "application-file", + applicationRootPath: "app", + files: fileMap([ + file("app/main.cjs", "root"), + file("app/sub/index.html", "root"), + file("app/index.html", "root"), + ]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "unavailable", + resolved_path: null, + }); + }, + ); + + it.each([ + ["sub/index.html", "resolved", "app/sub/index.html"], + ["sub\\index\0.html", "rejected", null], + ] as const)( + "keeps loadFile admission controls for %s", + (declaredPath, status, path) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/main.cjs", + context: "filesystem-expression", + runtimePathKind: "application-file", + applicationRootPath: "app", + files: fileMap([ + file("app/main.cjs", "root"), + file("app/sub/index.html", "root"), + ]), + }), + ).toMatchObject({ resolution_status: status, resolved_path: path }); + }, + ); + + it("confines a sibling path after applying a nested application root", () => { + expect( + resolve({ + declaredPath: "../shared/index.html", + sourcePath: "app/main.cjs", + context: "filesystem-expression", + runtimePathKind: "application-file", + applicationRootPath: "app", + files: fileMap([ + file("app/main.cjs", "root"), + file("shared/index.html", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "shared/index.html", + }); + }); + it.each(["", "resources/app.asar"])( + "resolves loadFile within application root %j instead of the calling script", + (applicationRootPath) => { + const prefix = + applicationRootPath === "" ? "" : `${applicationRootPath}/`; + const files = fileMap([ + file(`${prefix}sub/main.cjs`, "app"), + file(`${prefix}dist%2Findex.html`, "app"), + file(`${prefix}sub/dist%2Findex.html`, "app"), + file(`${prefix}sub/absent.html`, "app"), + file(`${prefix}fallback/index.html`, "app"), + file(`${prefix}foreign.html`, "other-container"), + file("outside.html", "other-container"), + ]); + const loadFile = (declaredPath: string) => + resolve({ + declaredPath, + sourcePath: `${prefix}sub/main.cjs`, + context: "filesystem-expression", + runtimePathKind: "application-file", + applicationRootPath, + files, + }); + expect(loadFile("dist%2Findex.html")).toMatchObject({ + runtime_path_base: "application-root", + resolved_path: `${prefix}dist%2Findex.html`, + resolution_status: "resolved", + }); + expect(loadFile("/dist%2Findex.html")).toMatchObject({ + resolved_path: null, + resolution_status: "unavailable", + }); + for (const path of ["absent.html", "fallback", "foreign.html"]) + expect(loadFile(path)).toMatchObject({ + resolved_path: null, + resolution_status: "not-found", + }); + expect(loadFile("../../../../outside.html")).toMatchObject({ + resolved_path: null, + resolution_status: "rejected", + }); + }, + ); +}); + describe("exact Electron runtime entrypoint resolution", () => { it("requires a protocol for Electron URL resources", () => { const files = fileMap([ @@ -3177,7 +4059,7 @@ it("resolves Node runtime entrypoints without substituting emitted TypeScript so file("directory/index.js", "root"), ]); for (const [declaredPath, status, path, candidatePath] of [ - ["./worker.js", "not-found", null, "worker.js"], + ["./worker.js", "unavailable", null, "worker.js"], ["./runtime", "resolved", "runtime.js", undefined], ["./directory", "resolved", "directory/index.js", undefined], ] as const) @@ -3195,3 +4077,309 @@ it("resolves Node runtime entrypoints without substituting emitted TypeScript so ...(candidatePath === undefined ? {} : { candidate_path: candidatePath }), }); }); + +describe("Node launch package main ownership", () => { + it.each(["index.js", "index.json", "index.cjs"])( + "uses the same package index producer for a default launch with %s", + (fallback) => { + expect( + resolve({ + declaredPath: "", + sourcePath: "app/package.json", + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files: fileMap([ + file("app/package.json", "root"), + file(`app/${fallback}`, "root"), + file("app.js", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: fallback === "index.cjs" ? "not-found" : "resolved", + resolved_path: fallback === "index.cjs" ? null : `app/${fallback}`, + }); + }, + ); + + it("keeps unavailable main bytes ahead of a present package index", () => { + const source = file("app/package.json", "root"); + expect( + resolve({ + declaredPath: "declared.cjs", + sourcePath: source.path, + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files: fileMap([source, file("app/index.js", "root")]), + unanalyzedFiles: { + get: (path) => + path === "app/declared.cjs" + ? { + path, + container_sha256: source.container_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: null, + kind: "javascript", + hash_status: "unavailable", + limitations: ["main readback unavailable"], + } + : undefined, + hasDirectory: () => false, + }, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + candidate_path: "app/declared.cjs", + }); + }); + + it.each([ + ["missing.cjs", "index.js", "app/index.js"], + ["sub/missing", "index.json", "app/index.json"], + ["directory", "index.js", "app/index.js"], + ["declared.cjs", "index.js", "app/declared.cjs"], + ["missing.cjs", "index.cjs", null], + ] as const)( + "uses Node package main lookup for %s with %s", + (declaredPath, fallback, expected) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "package-entrypoint", + runtimePathKind: "node-entrypoint", + files: fileMap([ + file("app/package.json", "root"), + file(`app/${fallback}`, "root"), + file("app/declared.cjs", "root"), + file("app/directory/package.json", "root", '{"main":"nested.cjs"}'), + file("app/directory/nested.cjs", "root"), + ]), + }), + ).toMatchObject({ + resolution_status: expected === null ? "not-found" : "resolved", + resolved_path: expected, + }); + }, + ); + + it.each([ + [ + "app/package.json", + "../resources/CODE.ASAR/window.cjs", + "resources/CODE.ASAR/window.cjs", + "package-entrypoint", + ], + [ + "resources/APP.ASAR/package.json", + "../window.cjs", + "resources/window.cjs", + "package-entrypoint", + ], + [ + "main.cjs", + "./resources/CODE.ASAR/window.cjs", + "resources/CODE.ASAR/window.cjs", + "filesystem-expression", + ], + ] as const)( + "retains archive launch lookup for %s loading %s", + (sourcePath, declaredPath, target, context) => { + expect( + resolve({ + sourcePath, + declaredPath, + context, + runtimePathKind: "node-entrypoint", + files: fileMap([file(sourcePath, "source"), file(target, "target")]), + }), + ).toMatchObject({ resolution_status: "resolved", resolved_path: target }); + }, + ); + + it("does not use package fallback for a utility entrypoint or an unproven main container", () => { + for (const [context, declaredPath, status] of [ + ["filesystem-expression", "./missing.cjs", "not-found"], + ["package-entrypoint", "declared.cjs", "unavailable"], + ["package-entrypoint", "../../escape.cjs", "rejected"], + ] as const) + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context, + runtimePathKind: "node-entrypoint", + files: fileMap([ + file("app/package.json", "source"), + file("app/index.js", "source"), + file("app/declared.cjs", "foreign"), + ]), + }), + ).toMatchObject({ resolution_status: status, resolved_path: null }); + }); +}); + +it.each(["sub\\main.cjs", ".\\main.cjs", "C:main.cjs"])( + "retains unknown host semantics for the Windows-relative entrypoint %s", + (declaredPath) => { + expect( + resolve({ + declaredPath, + sourcePath: "app/package.json", + context: "filesystem-expression", + runtimePathKind: "node-entrypoint", + files: fileMap([ + file("app/package.json", "root"), + file("app/main.cjs", "root"), + file("app/sub/main.cjs", "root"), + ]), + }), + ).toMatchObject({ + declared_path: declaredPath, + resolution_status: "unavailable", + resolved_path: null, + }); + }, +); + +describe("package metadata at archive module boundaries", () => { + it.each(["APP.ASAR", "app.AsAr"])( + "preserves archive component bytes while admitting %s runtime crossings", + (archive) => { + const directory = `resources/${archive}`; + const files = fileMap([ + file("main.cjs", "outer"), + file(`${directory}/main.cjs`, "archive"), + file( + `${directory}/pkg/package.json`, + "archive", + '{"main":"actual.cjs"}', + ), + file(`${directory}/pkg/actual.cjs`, "archive"), + file(`${directory}/pkg/index.js`, "archive"), + file("resources/pkg/package.json", "outer", '{"main":"actual.cjs"}'), + file("resources/pkg/actual.cjs", "outer"), + file("resources/pkg/index.js", "outer"), + ]); + for (const [sourcePath, declaredPath, target] of [ + ["main.cjs", `./${directory}/pkg`, `${directory}/pkg/actual.cjs`], + [`${directory}/main.cjs`, "../pkg", "resources/pkg/actual.cjs"], + ] as const) + expect( + resolve({ + sourcePath, + declaredPath, + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: target, + }); + }, + ); + + it("does not admit same-archive or similarly named foreign containers", () => { + for (const directory of ["resources/APP.ASAR", "resources/APP.ASAR-extra"]) + expect( + resolve({ + sourcePath: `${directory}/main.cjs`, + declaredPath: "./window.cjs", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file(`${directory}/main.cjs`, "source"), + file(`${directory}/window.cjs`, "foreign"), + ]), + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + }); + it.each([ + ["resources/app.asar/main.cjs", "../pkg", "resources/pkg"], + ["app/main.cjs", "../resources/code.asar/pkg", "resources/code.asar/pkg"], + [ + "resources/app.asar/main.cjs", + "fixture", + "resources/node_modules/fixture", + ], + ])( + "uses the selected package main for %s loading %s", + (sourcePath, declaredPath, directory) => { + expect( + resolve({ + sourcePath, + declaredPath, + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file(sourcePath, "source"), + file( + `${directory}/package.json`, + "target", + '{"main":"actual.cjs"}', + ), + file(`${directory}/actual.cjs`, "target"), + file(`${directory}/index.js`, "target"), + ]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: `${directory}/actual.cjs`, + }); + }, + ); + + it("keeps metadata-less ancestor package directories after an archive escape", () => { + expect( + resolve({ + sourcePath: "resources/app.asar/main.cjs", + declaredPath: "fixture/parser", + context: "module-specifier", + moduleKind: "require", + files: fileMap([ + file("resources/app.asar/main.cjs", "source"), + file("resources/node_modules/fixture/parser.js", "target"), + ]), + }), + ).toMatchObject({ + resolution_status: "resolved", + resolved_path: "resources/node_modules/fixture/parser.js", + }); + }); + + it.each(["resources/app.asar/main.cjs", "resources/main.cjs"])( + "keeps unread metadata blocking a decoy index for %s", + (sourcePath) => { + const path = "resources/pkg/package.json"; + const target = file(path, "target", null); + const files = fileMap([ + file(sourcePath, "source"), + file("resources/pkg/index.js", "source"), + ]); + for (const opaque of [false, true]) { + expect( + resolve({ + sourcePath, + declaredPath: sourcePath.includes(".asar/") ? "../pkg" : "./pkg", + context: "module-specifier", + moduleKind: "require", + files: opaque ? files : fileMap([...files.values(), target]), + unanalyzedFiles: { + get: (requested) => + opaque && requested === path ? target : undefined, + hasDirectory: () => false, + }, + }), + ).toMatchObject({ + resolution_status: "unavailable", + resolved_path: null, + }); + } + }, + ); +}); diff --git a/src/application/javascript/JavaScriptArtifactPathResolution.ts b/src/application/javascript/JavaScriptArtifactPathResolution.ts index b536b0a337..fc0a390920 100644 --- a/src/application/javascript/JavaScriptArtifactPathResolution.ts +++ b/src/application/javascript/JavaScriptArtifactPathResolution.ts @@ -1,5 +1,6 @@ import { isBuiltin } from "node:module"; -import { posix } from "node:path"; +import { posix, win32 } from "node:path"; +import { ELECTRON_MODULE } from "../../domain/javascript/javascriptElectronMemberWrites.js"; import { normalizeJoinedLogicalPath, @@ -27,6 +28,7 @@ import { type TypeScriptEmittedSourceCandidate, } from "../TypeScriptEmittedSourceCandidates.js"; import { isRelativeJavaScriptModuleSpecifier } from "../../domain/javascript/javascriptModuleSpecifier.js"; +import { selectPackageSubpathPattern } from "../../domain/javascript/javascriptPackageSubpathPatterns.js"; import type { JavaScriptModuleResolutionMode } from "../../domain/javascript/javascriptModuleResolutionMode.js"; import type { JsonValue } from "../../domain/jsonValue.js"; import { err, ok, type Result } from "../../domain/result.js"; @@ -46,6 +48,8 @@ type UnresolvedArtifactPathStatus = interface ArtifactPathResolutionBase { readonly declared_path: string; + readonly runtime_path_base?: "application-root"; + readonly runtime_path_base_proven?: true; readonly resolution_context: ArtifactPathResolutionContext; readonly limitations: readonly string[]; readonly conditional_resolutions?: readonly ConditionalModuleResolution[]; @@ -87,7 +91,14 @@ export interface ResolveArtifactPathInput { /** Import/require semantics; runtime resource paths omit this provenance. */ readonly moduleKind?: JavaScriptModuleResolutionMode; /** Runtime paths retain their loader owner independently of source imports. */ - readonly runtimePathKind?: "exact-file" | "node-entrypoint" | "url-resource"; + readonly runtimePathKind?: + | "exact-file" + | "application-file" + | "node-entrypoint" + | "url-resource"; + /** Logical root of an inventoried nested application container. */ + readonly applicationRootPath?: string | null; + readonly applicationRootIsProven?: boolean; } /** Preserve an explicitly retained loader mode on graph relationships. */ @@ -193,6 +204,14 @@ export const resolveArtifactPathByContext = ( const resolution = resolvePath(input); const facts = { declared_path: input.declaredPath, + ...(input.runtimePathKind === "application-file" + ? { runtime_path_base: "application-root" as const } + : {}), + ...(input.runtimePathKind === "application-file" && + input.applicationRootIsProven === true && + input.applicationRootPath !== null + ? { runtime_path_base_proven: true as const } + : {}), resolution_context: input.context, limitations: resolution.limitations, ...(resolution.candidatePath === undefined @@ -215,6 +234,12 @@ export const resolveArtifactPathByContext = ( export const artifactPathResolutionProperties = ( resolution: ArtifactPathResolution | undefined, ): Readonly> => ({ + ...(resolution?.runtime_path_base_proven === undefined + ? {} + : { runtime_path_base_proven: resolution.runtime_path_base_proven }), + ...(resolution?.runtime_path_base === undefined + ? {} + : { runtime_path_base: resolution.runtime_path_base }), ...(resolution?.candidate_path === undefined ? {} : { candidate_path: resolution.candidate_path }), @@ -248,7 +273,50 @@ const conditionalResolution = ( : { candidate_path: result.candidatePath }), }); +/** Host path admission is shared by top-level and metadata-derived inputs. */ +const resolveRuntimeHostPath = ( + input: ResolveArtifactPathInput, +): CandidateResolution | null => { + if ( + (input.runtimePathKind !== undefined || isNodeFilesystemLookup(input)) && + input.runtimePathKind !== "url-resource" && + (posix.isAbsolute(input.declaredPath) || + win32.isAbsolute(input.declaredPath) || + (input.context === "module-specifier" && + input.moduleKind !== "require" && + /^file:/iu.test(input.declaredPath))) + ) { + const esm = + input.context === "module-specifier" && input.moduleKind === "import"; + if (esm) { + const normalized = URL.parse( + htmlUrlText(input.declaredPath), + PACKAGE_ARTIFACT_ROOT_URL, + ); + if (normalized === null) + return unresolvedCandidate("rejected", [ + "The module URL cannot be parsed.", + ]); + const admitted = decodeModuleUrlPath(normalized.pathname); + if (typeof admitted !== "string") return admitted; + } + // Host syntax is not a canonical logical path. In particular, Windows + // separators and drive letters must not be reclassified as invalid/URLs. + return input.declaredPath.includes("\0") || + (!esm && fileUrlPath(input.declaredPath) === null) + ? unresolvedCandidate("rejected", [ + "NUL or unsafe file URL path syntax is not admitted in a runtime path.", + ]) + : unresolvedCandidate("unavailable", [ + "An absolute runtime host path has no established mapping to the selected logical artifact root.", + ]); + } + return null; +}; + const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { + const hostPath = resolveRuntimeHostPath(input); + if (hostPath !== null) return hostPath; if (input.runtimePathKind === "node-entrypoint") return resolveNodeRuntimeEntrypoint(input); if (input.moduleKind === "compiler-dependent") { @@ -286,7 +354,11 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => { } if (input.runtimePathKind === "url-resource") return resolveExactUrlResource(input); - if (input.runtimePathKind === "exact-file" && isRuntimePathDeclaration(input)) + if ( + (input.runtimePathKind === "exact-file" || + input.runtimePathKind === "application-file") && + isRuntimePathDeclaration(input) + ) return resolveRuntimeEntrypoint(input); // Package import keys are literal lookup identities, not artifact paths. // The selected target is admitted separately by resolveExportTarget. @@ -365,18 +437,37 @@ const isRuntimePathDeclaration = (input: ResolveArtifactPathInput): boolean => const resolveNodeRuntimeEntrypoint = ( input: ResolveArtifactPathInput, ): CandidateResolution => { - const { runtimePathKind: _runtimePathKind, ...sourceInput } = input; - const runtimeInput = { ...sourceInput, moduleKind: "require" as const }; - if (!isRuntimePathDeclaration(runtimeInput)) return resolvePath(runtimeInput); + const windowsPath = unknownWindowsRuntimePath(input.declaredPath); + if (windowsPath !== null) return windowsPath; + const runtimeInput = { ...input, moduleKind: "require" as const }; + if (input.context === "package-entrypoint" && input.declaredPath === "") + return resolveNodePackageMain( + runtimeInput, + posix.dirname(input.sourcePath), + undefined, + ); + if ( + runtimeInput.context !== "package-entrypoint" && + !isRuntimePathDeclaration(runtimeInput) + ) { + const { runtimePathKind: _runtimePathKind, ...moduleInput } = runtimeInput; + return resolvePath(moduleInput); + } const candidate = contextualCandidate(runtimeInput); if (typeof candidate !== "string") return candidate; const confined = confineCandidate(candidate); if (typeof confined !== "string") return confined; // Node entrypoints use runtime file/directory probes. They do not originate // in emitted TypeScript import syntax and cannot select a source substitute. - const resolution = resolveCandidate(runtimeInput, confined, new Set()); - // Runtime entrypoint analysis has no extension or directory fallback, but - // retain the normalized loader path when that exact target is absent. + const resolution = + input.context === "package-entrypoint" + ? resolveNodePackageMain( + runtimeInput, + posix.dirname(input.sourcePath), + confined, + ) + : resolveCandidate(runtimeInput, confined, new Set()); + // Retain the normalized declaration when Node's owned probes are exhausted. return resolution.status === "not-found" && resolution.candidatePath === undefined ? { ...resolution, candidatePath: confined } @@ -386,14 +477,30 @@ const resolveNodeRuntimeEntrypoint = ( const resolveRuntimeEntrypoint = ( input: ResolveArtifactPathInput, ): CandidateResolution => { - const rejected = rejectDeclaration(input.declaredPath); + if (input.runtimePathKind === "application-file") { + const windowsPath = unknownWindowsRuntimePath(input.declaredPath); + if (windowsPath !== null) return windowsPath; + } + const rejected = rejectDeclaration( + input.declaredPath, + declaresLiteralPercent(input), + ); if (rejected !== null) return rejected; if (hasScheme(input.declaredPath) || looksExternal(input.declaredPath)) return unresolvedCandidate("external", [ "Electron runtime entrypoints must identify local paths, not external URLs.", ]); + if ( + input.runtimePathKind === "application-file" && + input.applicationRootPath === null + ) + return unresolvedCandidate("unavailable", [ + "Inventoried package metadata does not establish one application root for this source; the loadFile base is unknown.", + ]); const candidate = confineCandidate( - relativeCandidate(input.sourcePath, input.declaredPath), + input.runtimePathKind === "application-file" + ? posix.join(input.applicationRootPath ?? "", input.declaredPath) + : relativeCandidate(input.sourcePath, input.declaredPath), ); if (typeof candidate !== "string") return candidate; return ( @@ -408,6 +515,17 @@ const resolveRuntimeEntrypoint = ( ); }; +/** Host filesystem spellings need target-platform context before admission. */ +const unknownWindowsRuntimePath = ( + declared: string, +): CandidateResolution | null => + !declared.includes("\0") && + (declared.includes("\\") || /^[a-z]:/iu.test(declared)) + ? unresolvedCandidate("unavailable", [ + "Windows-relative runtime paths require target-platform and drive context that the selected logical artifact inventory does not establish.", + ]) + : null; + const resolveRuntimeSourceCandidates = ( input: ResolveArtifactPathInput, exactPath: string, @@ -453,14 +571,19 @@ const rejectDeclaration = ( }; /** - * Node's CommonJS loader never percent-decodes a request or a legacy package - * main, so `%2e` and `%2f` there are literal filename bytes, not encoded dots - * or separators. Root confinement still applies to the joined candidate. + * Filesystem expressions, exact runtime paths, and CommonJS requests retain + * literal filename bytes. URL-to-path conversion belongs to the producer; + * these consumers must not decode or reject the resulting percent spelling. + * Root confinement still applies to the joined candidate. */ const declaresLiteralPercent = (input: ResolveArtifactPathInput): boolean => - input.moduleKind === "require" && - (input.context === "module-specifier" || - input.context === "package-entrypoint"); + input.context === "filesystem-expression" || + (input.runtimePathKind === "exact-file" && + isRuntimePathDeclaration(input) && + !looksExternal(input.declaredPath)) || + (input.moduleKind === "require" && + (input.context === "module-specifier" || + input.context === "package-entrypoint")); const contextualCandidate = ( input: ResolveArtifactPathInput, @@ -497,6 +620,15 @@ const contextualCandidate = ( "File URL authorities or invalid file URL forms cannot be mapped to artifact-local paths.", ]); } + if ( + context === "module-specifier" && + input.moduleKind === "require" && + declared.includes("\\") && + (!hasScheme(declared) || /^[a-z]:/iu.test(declared)) + ) { + const windowsPath = unknownWindowsRuntimePath(declared); + if (windowsPath !== null) return windowsPath; + } const hasUrlPathSemantics = context === "module-specifier" && input.moduleKind === "import"; if (hasUrlPathSemantics) declared = htmlUrlText(declared); @@ -516,15 +648,59 @@ const contextualCandidate = ( ) return bareModuleCandidate(input); if (input.moduleKind !== "require") { - if (/%(?:2f|5c)/iu.test(stripQueryAndFragment(declared))) - return unresolvedCandidate("rejected", [ - "ES module URL paths cannot contain percent-encoded slash or backslash bytes.", - ]); + if (hasUrlPathSemantics) { + // URL normalization can leave and then re-enter the synthetic namespace. + // Preserve the shared URL owner's above-root markers before that loss. + if ( + applyUrlReference( + urlDirectoryState(posix.dirname(input.sourcePath)), + stripQueryAndFragment(declared), + true, + ).escapes > 0 + ) + return unresolvedCandidate("rejected", [ + "The module URL escapes the selected logical artifact root.", + ]); + const sourceUrl = packageMetadataUrl(input.sourcePath, declared); + const base = sourceUrl.ok + ? sourceUrl.value + : sourceUrl.error.structuralUrl; + const normalized = URL.parse(declared, base); + if (normalized === null) + return unresolvedCandidate("rejected", [ + "The module URL cannot be parsed.", + ]); + if (!normalized.pathname.startsWith(PACKAGE_ARTIFACT_ROOT_URL.pathname)) + return unresolvedCandidate("rejected", [ + "The module URL escapes the selected logical artifact root.", + ]); + // Node validates the complete normalized file URL, including encoded + // separator bytes inherited from an inventoried source directory. + const decoded = decodeModuleUrlPath( + normalized.pathname.slice(PACKAGE_ARTIFACT_ROOT_URL.pathname.length), + ); + if (typeof decoded !== "string") return decoded; + if ( + !sourceUrl.ok && + decoded + .split("/") + .some((segment) => sourceUrl.error.structuralSegments.has(segment)) + ) + return unresolvedCandidate("unavailable", [ + sourceUrl.error.limitation, + ]); + const candidate = urlRelativeCandidate("", `/${decoded}`); + return typeof candidate === "string" + ? exactUrlPathCandidate(decoded, candidate, "module URL") + : candidate; + } const decoded = decodeModuleUrlPath(stripQueryAndFragment(declared)); if (typeof decoded !== "string") return decoded; declared = decoded; } - } else if (looksExternal(declared)) + } else if (context !== "package-entrypoint" && looksExternal(declared)) + // Package main is a filesystem declaration. Scheme-like filename bytes + // remain literal; URI classification belongs to module and URL owners. return unresolvedCandidate("external", [ "URL schemes and protocol-relative URLs are outside this local artifact path context.", ]); @@ -574,6 +750,10 @@ const contextualCandidate = ( const decodeModuleUrlPath = ( declared: string, ): string | CandidateResolution => { + if (/%(?:2f|5c)/iu.test(declared)) + return unresolvedCandidate("rejected", [ + "ES module URL paths cannot contain percent-encoded slash or backslash bytes.", + ]); let decoded: string; try { decoded = decodeURIComponent(declared); @@ -618,17 +798,19 @@ const urlDirectoryState = (directory: string): UrlPathState => ({ const applyUrlReference = ( base: UrlPathState, reference: string, + encodedDotSegments = false, ): UrlPathState => { const absolute = reference.startsWith("/"); const segments = absolute ? [] : [...base.segments]; let escapes = absolute ? 0 : base.escapes; const parts = (absolute ? reference.slice(1) : reference).split("/"); for (const [index, part] of parts.entries()) { - if (part !== "." && part !== "..") { + const dot = encodedDotSegments ? part.replace(/%2e/giu, ".") : part; + if (dot !== "." && dot !== "..") { segments.push(part); continue; } - if (part === "..") + if (dot === "..") if (segments.length > 0) segments.pop(); else escapes += 1; if (index === parts.length - 1) segments.push(""); @@ -704,7 +886,6 @@ const bareModuleCandidate = ( if (typeof confined !== "string") return confined; dependencyDirectory = posix.dirname(confined); } - const source = input.files.get(input.sourcePath); let nearestNotFound: CandidateResolution | null = null; for (const directory of packageLookupDirectories(input)) { const candidate = posix.join(directory, dependencyDirectory); @@ -716,14 +897,7 @@ const bareModuleCandidate = ( (!request.dependencyExportsEligible || metadata.exports === undefined))); const packageResolution = - metadata.kind !== "absent" || - hasContainerDirectory( - input.files, - candidate, - source?.container_sha256, - input.directoryPaths, - input.unanalyzedFiles, - ) + metadata.kind !== "absent" || hasContainerDirectory(input, candidate) ? resolvePackageDependency(input, candidate, request, metadata) : request.packageSubpath === "." && input.moduleKind !== "import" ? resolveFileCandidates( @@ -795,10 +969,6 @@ const resolvePackageImportAlias = ( return unresolvedCandidate("rejected", [ `The package import specifier ${JSON.stringify(specifier)} is invalid; "#" and specifiers that end with "/" are not valid import aliases.`, ]); - if (classified.kind === "pattern") - return unresolvedCandidate("external", [ - `Wildcard and directory package import aliases remain unresolved; ${JSON.stringify(specifier)} is not an exact imports key.`, - ]); const metadata = enclosingPackageMetadata(input); if (metadata.kind === "none") return unresolvedCandidate("external", [ @@ -816,7 +986,7 @@ const resolvePackageImportAlias = ( `Package metadata ${metadata.path} does not define package import alias ${specifier}; it declares ${lookup.declared.join(", ") || "no imports keys"}.`, ]); if (lookup.kind === "pattern-unresolved") - return unresolvedCandidate("external", [ + return unresolvedCandidate("unavailable", [ `Wildcard package import aliases remain unresolved; package metadata ${metadata.path} has no exact imports key ${specifier}.`, ]); const selected = selectExportTarget( @@ -839,11 +1009,18 @@ const resolvePackageImportAlias = ( ]); if (selected.kind === "non-relative") { const target = classifyPackageImportTargetString(selected.value); - return unresolvedCandidate("external", [ - target.kind === "recursive" - ? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.` - : `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`, - ]); + // Unsupported lookup may still reach a selected module and another launch + // owner. Only a known builtin establishes that no local imports follow. + return unresolvedCandidate( + isBuiltin(selected.value) || ELECTRON_MODULE.test(selected.value) + ? "external" + : "unavailable", + [ + target.kind === "recursive" + ? `Recursive package import aliases remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.` + : `External-package import targets remain unresolved; package metadata ${metadata.path} maps ${specifier} to ${JSON.stringify(target.target)}.`, + ], + ); } return resolveExportTarget(input, metadata.path, selected, "imports"); }; @@ -958,16 +1135,7 @@ const resolveLegacyEsmSubpath = ( return unresolvedCandidate("unavailable", [unrepresentableBase.limitation]); const file = resolveFileCandidates(input, [candidate]); if (file !== null) return file; - const source = input.files.get(input.sourcePath); - if ( - hasContainerDirectory( - input.files, - candidate, - source?.container_sha256, - input.directoryPaths, - input.unanalyzedFiles, - ) - ) + if (hasContainerDirectory(input, candidate)) return unresolvedCandidate("rejected", [ `ESM package subpath ${candidate} names a directory; directory imports are unsupported and package main or index lookup was not attempted.`, ]); @@ -998,6 +1166,10 @@ const resolvePackageExports = ( return unresolvedCandidate("external", [ `Directory package metadata ${packagePath} blocks subpath ${subpath} with ${exported.cause === "null" ? "an explicit null exports target" : "an empty exports target array"}.`, ]); + if (exported.status === "pattern-unresolved") + return unresolvedCandidate("unavailable", [ + `Package metadata ${packagePath} selects wildcard exports key ${JSON.stringify(exported.pattern)} for subpath ${subpath}; wildcard target expansion remains unresolved.`, + ]); return resolveExportTarget(input, packagePath, exported); }; @@ -1080,37 +1252,35 @@ const parseBarePackageRequest = ( }; const hasContainerDirectory = ( - files: ReadonlyMap, + input: ResolveArtifactPathInput, candidate: string, - containerSha256: string | undefined, - directoryPaths: ReadonlySet | undefined, - unanalyzedFiles?: JavaScriptUnanalyzedArtifactLookup, ): boolean => { - if (directoryPaths?.has(candidate) === true) return true; + if (input.directoryPaths?.has(candidate) === true) return true; if ( [...indexCandidates(candidate), posix.join(candidate, "package.json")].some( (path) => { - const file = files.get(path); + const file = input.files.get(path); return ( file !== undefined && - (containerSha256 === undefined || - file.container_sha256 === containerSha256) + admitsRuntimeContainer(input, path, file.container_sha256) ); }, ) ) return true; - // Inventory has files rather than directory entries. Any same-container - // descendant establishes a package directory even without package.json. + // Inventory has files rather than directory entries. An admitted descendant + // establishes a package directory even without package.json. const prefix = `${candidate}/`; - for (const [path, file] of files) + for (const [path, file] of input.files) if ( path.startsWith(prefix) && - (containerSha256 === undefined || - file.container_sha256 === containerSha256) + admitsRuntimeContainer(input, path, file.container_sha256) ) return true; - return unanalyzedFiles?.hasDirectory(candidate, containerSha256) ?? false; + const container = crossesRuntimeArchiveBoundary(input, `${candidate}/`) + ? undefined + : input.files.get(input.sourcePath)?.container_sha256; + return input.unanalyzedFiles?.hasDirectory(candidate, container) ?? false; }; const htmlCandidate = ( @@ -1228,12 +1398,12 @@ const readPackageMetadata = ( ): PackageMetadata => { const path = posix.join(directory, "package.json"); const file = input.files.get(path); - const source = input.files.get(input.sourcePath); if (file === undefined) { const unavailable = input.unanalyzedFiles?.get(path); if ( - source !== undefined && - unavailable?.container_sha256 === source.container_sha256 + unavailable !== undefined && + (admitsRuntimeContainer(input, path, unavailable.container_sha256) || + input.moduleKind !== undefined) ) return { kind: "unavailable", @@ -1243,8 +1413,13 @@ const readPackageMetadata = ( }; return { kind: "absent" }; } - if (source !== undefined && file.container_sha256 !== source.container_sha256) - return { kind: "absent" }; + if (!admitsRuntimeContainer(input, path, file.container_sha256)) + return input.moduleKind === undefined + ? { kind: "absent" } + : { + kind: "unavailable", + limitation: `Inventoried package metadata ${path} crosses an artifact container boundary without an established runtime loader mapping.`, + }; if (!file.text.included) return { kind: "unavailable", @@ -1260,7 +1435,7 @@ const readPackageMetadata = ( limitation: `Directory package metadata ${path} is not valid package JSON.`, }; } - if (typeof value !== "object" || value === null) + if (typeof value !== "object" || value === null || Array.isArray(value)) return { kind: "unavailable", limitation: `Directory package metadata ${path} is not valid package JSON.`, @@ -1351,12 +1526,12 @@ const resolveDirectory = ( ? metadata.module : undefined; if (entry === undefined) - return ( - resolveFileCandidates( - input, - indexCandidates(directory, input.moduleKind), - ) ?? notFoundCandidate() - ); + return input.moduleKind !== undefined + ? resolveNodePackageMain(input, directory, undefined) + : (resolveFileCandidates( + input, + indexCandidates(directory, input.moduleKind), + ) ?? notFoundCandidate()); if (typeof entry !== "string" || entry.length === 0) return unresolvedCandidate("unavailable", [ `Directory package metadata ${metadata.path} is not valid package JSON.`, @@ -1374,6 +1549,12 @@ const resolveDirectory = ( : { unanalyzedFiles: input.unanalyzedFiles }), ...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }), }; + const hostPath = resolveRuntimeHostPath(entryInput); + if (hostPath !== null) return hostPath; + if (entryInput.moduleKind !== undefined) { + const windowsPath = unknownWindowsRuntimePath(entry); + if (windowsPath !== null) return windowsPath; + } const candidate = contextualCandidate(entryInput); if (typeof candidate !== "string") return candidate; const confined = confineCandidate(candidate); @@ -1397,55 +1578,47 @@ const resolveDirectory = ( const resolveNodePackageMain = ( input: ResolveArtifactPathInput, directory: string, - mainPath: string, + mainPath: string | undefined, ): CandidateResolution => { - const normalizedMain = mainPath.replace(/\/$/u, ""); - for (const suffix of NODE_PACKAGE_MAIN_SUFFIXES) { - const isDirectoryIndex = suffix.startsWith("/"); - if (normalizedMain === "" && !isDirectoryIndex) continue; - const selected = resolveFileCandidates(input, [ - isDirectoryIndex - ? posix.join(normalizedMain, suffix.slice(1)) - : posix.normalize(`${normalizedMain}${suffix}`), - ]); - if (selected === null) continue; - if (input.moduleKind === "require") return selected; - // ESM probes the normalized main, then appends the selected suffix to - // the original declaration before finalizing the resulting file path. - const finalized = confineCandidate( - relativeCandidate(input.sourcePath, `${input.declaredPath}${suffix}`), - ); - if (typeof finalized !== "string") return finalized; - const finalizedFile = resolveFileCandidates(input, [ - finalized.replace(/\/$/u, ""), - ]); - if (finalizedFile !== null) return finalizedFile; - const source = input.files.get(input.sourcePath); - if ( - hasContainerDirectory( - input.files, - finalized, - source?.container_sha256, - input.directoryPaths, - input.unanalyzedFiles, - ) - ) - return { - ...unresolvedCandidate("rejected", [ - `The selected legacy ESM package main finalizes to directory ${finalized}; directory imports are unsupported and no later main or index candidate was tried.`, - ]), - candidatePath: finalized, - }; - return unresolvedCandidate("not-found", [ - `The selected legacy ESM package main finalizes to ${finalized}, which is not an inventoried file; no later main or index candidate was tried.`, - ]); - } + const normalizedMain = mainPath?.replace(/\/$/u, ""); + if (normalizedMain !== undefined) + for (const suffix of NODE_PACKAGE_MAIN_SUFFIXES) { + const isDirectoryIndex = suffix.startsWith("/"); + if (normalizedMain === "" && !isDirectoryIndex) continue; + const selected = resolveFileCandidates(input, [ + isDirectoryIndex + ? posix.join(normalizedMain, suffix.slice(1)) + : posix.normalize(`${normalizedMain}${suffix}`), + ]); + if (selected === null) continue; + if (input.moduleKind === "require") return selected; + // ESM probes the normalized main, then appends the selected suffix to + // the original declaration before finalizing the resulting file path. + const finalized = confineCandidate( + relativeCandidate(input.sourcePath, `${input.declaredPath}${suffix}`), + ); + if (typeof finalized !== "string") return finalized; + const finalizedFile = resolveFileCandidates(input, [ + finalized.replace(/\/$/u, ""), + ]); + if (finalizedFile !== null) return finalizedFile; + if (hasContainerDirectory(input, finalized)) + return { + ...unresolvedCandidate("rejected", [ + `The selected legacy ESM package main finalizes to directory ${finalized}; directory imports are unsupported and no later main or index candidate was tried.`, + ]), + candidatePath: finalized, + }; + return unresolvedCandidate("not-found", [ + `The selected legacy ESM package main finalizes to ${finalized}, which is not an inventoried file; no later main or index candidate was tried.`, + ]); + } const fallback = resolveFileCandidates( input, indexCandidates(directory, input.moduleKind), ); if (fallback !== null) return fallback; - return input.moduleKind === "require" + return input.moduleKind === "require" && mainPath !== undefined ? { ...notFoundCandidate(), stopsAncestorLookup: true } : notFoundCandidate(); }; @@ -1472,24 +1645,83 @@ const resolveFileCandidates = ( return null; }; +/** Classify the extension as inventory does; retain exact archive occurrence bytes. */ +const runtimeArchiveRoot = (path: string): string | undefined => { + const components = path.split("/"); + const archive = components.findLastIndex( + (component, index) => + index < components.length - 1 && + component.toLowerCase().endsWith(".asar"), + ); + return archive < 0 + ? undefined + : `${components.slice(0, archive + 1).join("/")}/`; +}; + +/** One archive-path admission rule serves files, directories and their metadata. */ +const isNodeFilesystemLookup = (input: ResolveArtifactPathInput): boolean => + input.runtimePathKind === "node-entrypoint" || + (input.moduleKind !== undefined && + (input.context === "module-specifier" || + input.context === "package-entrypoint")); + +/** Runtime launch and module lookup share archive occurrence admission. */ +const crossesRuntimeArchiveBoundary = ( + input: ResolveArtifactPathInput, + path: string, +): boolean => { + // Electron's archive filesystem normalizes a relative module path before + // opening it, including entry into an inventoried archive occurrence and + // escape from one, after the existing artifact-root confinement check. + const sourceArchive = runtimeArchiveRoot(input.sourcePath); + const targetArchive = runtimeArchiveRoot(path); + return ( + isNodeFilesystemLookup(input) && + ((sourceArchive !== undefined && !path.startsWith(sourceArchive)) || + (targetArchive !== undefined && + !input.sourcePath.startsWith(targetArchive))) + ); +}; + +const admitsRuntimeContainer = ( + input: ResolveArtifactPathInput, + path: string, + containerSha256: string, +): boolean => { + const source = input.files.get(input.sourcePath); + return ( + crossesRuntimeArchiveBoundary(input, path) || + (input.runtimePathKind === "application-file" && + input.applicationRootIsProven === true) || + source === undefined || + containerSha256 === source.container_sha256 + ); +}; + /** Own candidate presence and container identity independently of loader policy. */ const findFileCandidate = ( input: ResolveArtifactPathInput, path: string, ): CandidateResolution | null => { - const source = input.files.get(input.sourcePath); const target = input.files.get(path); if ( target !== undefined && - (source === undefined || - target.container_sha256 === source.container_sha256) + admitsRuntimeContainer(input, path, target.container_sha256) ) return { resolvedPath: path, status: "resolved", limitations: [] }; + if (target !== undefined && isNodeFilesystemLookup(input)) + return { + resolvedPath: null, + status: "unavailable", + candidatePath: path, + limitations: [ + "The inventoried module crosses an artifact container boundary; its runtime loader mapping has not been established.", + ], + }; const unanalyzed = input.unanalyzedFiles?.get(path); if ( unanalyzed !== undefined && - (source === undefined || - unanalyzed.container_sha256 === source.container_sha256) + admitsRuntimeContainer(input, path, unanalyzed.container_sha256) ) return { resolvedPath: null, @@ -1542,9 +1774,10 @@ type PackageExportOutcome = | ({ readonly status: "value" } & PackageFileTarget) | { readonly status: "rejected"; readonly limitation: string } | { readonly status: "blocked"; readonly cause: PackageTargetBlockCause } + | { readonly status: "pattern-unresolved"; readonly pattern: string } | { readonly status: "unmatched"; readonly declared: readonly string[] }; -/** Only exact non-directory keys participate; wildcard expansion is unsupported. */ +/** Exact keys precede the best matching pattern; pattern expansion remains unknown. */ const selectPackageExport = ( value: unknown, subpath: string, @@ -1552,6 +1785,7 @@ const selectPackageExport = ( packagePath: string, ): PackageExportOutcome => { let target = value; + let pattern: string | undefined; let declared: string[] = []; if (typeof value === "object" && value !== null && !Array.isArray(value)) { const keys = Object.keys(value); @@ -1564,13 +1798,14 @@ const selectPackageExport = ( }; declared = keys; if (hasSubpaths) { - if ( - subpath.endsWith("/") || - subpath.includes("*") || - !Object.hasOwn(value, subpath) - ) - return { status: "unmatched", declared }; - target = Reflect.get(value, subpath); + if (subpath.endsWith("/")) return { status: "unmatched", declared }; + if (!subpath.includes("*") && Object.hasOwn(value, subpath)) + target = Reflect.get(value, subpath); + else { + pattern = selectPackageSubpathPattern(keys, subpath); + if (pattern === undefined) return { status: "unmatched", declared }; + target = Reflect.get(value, pattern); + } } else if (subpath !== ".") return { status: "unmatched", declared }; } else if (subpath !== ".") return { status: "unmatched", declared }; const selected = selectExportTarget( @@ -1592,6 +1827,7 @@ const selectPackageExport = ( status: "rejected", limitation: `The package exports target ${JSON.stringify(selected.value)} must start with "./".`, }; + if (pattern !== undefined) return { status: "pattern-unresolved", pattern }; return { status: "value", value: selected.value, diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts index 14312fc85e..c3c4fea0fc 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.test.ts @@ -2,6 +2,7 @@ import { primitiveByteExpansionSource } from "../../../tests/fixtures/javascript import { graphForJavaScript as graphFor } from "../../../tests/fixtures/javascriptSemanticGraph.js"; import * as t from "@babel/types"; import { expect, it } from "vitest"; +import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; import { SEMANTIC_GRAPH_FILE_NODE_CEILING, @@ -26,6 +27,36 @@ import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterE const SHA256 = "a".repeat(64); +it("projects bounded alias selection as a resource-limit unknown", () => { + const source = aliasSelectionBudgetSource(); + const ir = analyzeJavaScriptSemantics(source); + const graph = graphFor(source, ir); + expect(ir.coverage).toMatchObject({ + status: "partial", + resourceLimits: ["alias-selections"], + }); + const unknown = graph.unknowns.find( + ({ reason, detail }) => + reason === "resource-limit" && /Alias selection expansion/u.test(detail), + ); + expect(unknown).toBeDefined(); + expect( + graph.evidence_contexts.find( + ({ context_id }) => context_id === unknown?.evidence.context_id, + ), + ).toMatchObject({ + coverage: { + limits: expect.arrayContaining([ + { + name: "javascript_semantic_alias_selections", + value: 512, + unit: "items", + }, + ]), + }, + }); +}); + it.each([ ["plain export", "const answer = 42; export { answer };", 0], ["aliased export", "const answer = 42; export { answer as result };", 0], diff --git a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts index d39a7ef27c..3417fbb895 100644 --- a/src/application/javascript/JavaScriptSemanticGraphBuilder.ts +++ b/src/application/javascript/JavaScriptSemanticGraphBuilder.ts @@ -660,10 +660,10 @@ function* projectFileSteps( const projectFileResourceLimits = ( context: Pick, ): void => { - // Getter limits describe query effects without tagged values; other limits + // Query limits describe effects without tagged values; other limits // already project from binding or return-site values and must not be duplicated. for (const limit of semanticCoverageResourceLimits(context.ir.coverage)) { - if (limit !== "getter-expansion") continue; + if (limit !== "getter-expansion" && limit !== "alias-selections") continue; addSemanticGraphUnknown(context.state, { node_id: context.moduleNode.node_id, family: "data-flow", diff --git a/src/contracts/canonicalBase64Metadata.test.ts b/src/contracts/canonicalBase64Metadata.test.ts new file mode 100644 index 0000000000..fb5aea42fe --- /dev/null +++ b/src/contracts/canonicalBase64Metadata.test.ts @@ -0,0 +1,68 @@ +import { Ajv } from "ajv"; +import { Ajv2020 } from "ajv/dist/2020.js"; +import { expect, it } from "vitest"; +import { z } from "zod"; + +import { canonicalBase64Schema } from "../domain/stringPatterns.js"; +import { digestCanonicalValue } from "../domain/canonicalDigest.js"; +import { toolContract } from "./toolContracts.js"; +import { + toolInputSchemaWithMetadata, + toolOutputSchemaWithMetadata, +} from "./toolSchemaMetadata.js"; + +it.each(["draft-2020-12", "draft-07"] as const)( + "retains native base64 metadata and intersections in both MCP directions: %s", + (target) => { + const literal = { pattern: "^(?!literal)annotation$" }; + const canonical = z.strictObject({ + encoded: z.intersection(canonicalBase64Schema, z.string().max(4)), + literal: z + .strictObject({ pattern: z.literal(literal.pattern) }) + .meta({ default: literal, examples: [literal] }), + }); + const contract = { + ...toolContract("open_binary"), + inputSchema: canonical, + outputSchema: canonical, + }; + const input = toolInputSchemaWithMetadata(contract); + const output = toolOutputSchemaWithMetadata(contract); + const projectedInput = input["~standard"].jsonSchema.input({ target }); + const projectedOutput = output["~standard"].jsonSchema.output({ target }); + const { $id, ...withoutId } = projectedOutput; + expect($id).toBe( + `urn:rea:tool-output:sha256:${digestCanonicalValue({ target, schema: withoutId }, "Tool output schema")}`, + ); + for (const projected of [projectedInput, projectedOutput]) { + const properties = projected.properties as Record; + const literalProperty = properties.literal as Record; + const literalSchema = + typeof literalProperty.$ref === "string" + ? literalProperty.$ref + .slice(2) + .split("/") + .reduce( + (value, key) => (value as Record)[key], + projected, + ) + : literalProperty; + expect(literalSchema).toMatchObject({ + default: literal, + examples: [literal], + }); + const Validator = target === "draft-2020-12" ? Ajv2020 : Ajv; + const validate = new Validator({ strict: false }).compile(projected); + for (const [encoded, valid] of [ + ["AA==", true], + ["AB==", false], + ["AAAAAA==", false], + ] as const) { + const value = { encoded, literal }; + expect(validate(value), encoded).toBe(valid); + expect(input.safeParse(value).success, encoded).toBe(valid); + expect(output.safeParse(value).success, encoded).toBe(valid); + } + } + }, +); diff --git a/src/domain/javascript/electronStaticAnalysis.ts b/src/domain/javascript/electronStaticAnalysis.ts index d850a8458c..366aa7161b 100644 --- a/src/domain/javascript/electronStaticAnalysis.ts +++ b/src/domain/javascript/electronStaticAnalysis.ts @@ -1,9 +1,12 @@ -import type * as t from "@babel/types"; +import * as t from "@babel/types"; import { inspectElectronBrowserNode } from "./electronStaticAnalysisBrowser.js"; import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js"; import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js"; import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; +import { electronCalleePath } from "./electronStaticAnalysisValues.js"; +import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; +import { range } from "./javascriptStaticAnalysisHelpers.js"; /** Collect Electron-specific syntax during the shared inert AST traversal. */ export const inspectElectronStaticNode = ( @@ -13,4 +16,26 @@ export const inspectElectronStaticNode = ( inspectElectronBrowserNode(node, context); inspectElectronIpcNode(node, context); inspectElectronNativeNode(node, context); + const path = + t.isCallExpression(node) || t.isOptionalCallExpression(node) + ? (electronCalleePath(node.callee, context) ?? + context.electronInvocationPath?.(node)) + : undefined; + if ( + (path !== undefined && + path[0] === "app" && + path[1] === "setAppPath" && + path + .slice(2) + .every((member) => ["call", "apply", "bind"].includes(member))) || + context.electronAppPathChangePossible?.(node) === true + ) + // A bound or exported setter may escape this source before it changes the + // shared app path. Keep that possibility without claiming an invocation. + addLocatedFinding(context, { + collection: context.accumulator.appPathChanges, + key: "electron:app-path-change", + node, + value: { module_key: null, location: range(node) }, + }); }; diff --git a/src/domain/javascript/electronStaticAnalysisAppPathSelection.test.ts b/src/domain/javascript/electronStaticAnalysisAppPathSelection.test.ts new file mode 100644 index 0000000000..697d545316 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisAppPathSelection.test.ts @@ -0,0 +1,127 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const prefix = "const {app,BrowserWindow}=require('electron');"; +const cases = [ + { + name: "assigned native setter callee", + body: "let change;(change=app.setAppPath)('/changed');", + possible: true, + calls: [1], + }, + { + name: "assigned local setter callee", + body: "let change;(change=()=>{})('/changed');", + possible: false, + calls: [0], + }, + { + name: "assigned native app receiver", + body: "let runtime;(runtime=app).setAppPath('/changed');", + possible: true, + calls: [1], + }, + { + name: "assigned local app receiver", + body: "let runtime;(runtime={setAppPath(){}}).setAppPath('/changed');", + possible: false, + calls: [0], + }, + { + name: "logical-or assigned app receiver", + body: "let runtime=flag?{setAppPath(){}}:null;(runtime||=app).setAppPath('/changed');", + possible: true, + calls: [1, 0], + }, + { + name: "logical-and assigned app receiver", + body: "let runtime=flag?app:null;(runtime&&=app)?.setAppPath('/changed');", + possible: true, + calls: [0, 1], + }, + { + name: "nullish assigned app receiver", + body: "let runtime=flag?{setAppPath(){}}:null;(runtime??=app).setAppPath('/changed');", + possible: true, + calls: [1, 0], + }, + { + name: "definite local setter replacement", + body: "app.setAppPath=()=>{};app.setAppPath('/changed');", + possible: false, + calls: [0], + }, + { + name: "unchanged native setter", + body: "app.setAppPath('/changed');", + possible: true, + calls: [1], + }, + { + name: "conditional setter replacement", + body: "if(flag)app.setAppPath=()=>{};app.setAppPath('/changed');", + possible: true, + calls: [1, 0], + }, + { + name: "setter captured before replacement", + body: "const change=app.setAppPath;app.setAppPath=()=>{};change('/changed');", + possible: true, + calls: [1], + }, + { + name: "setter captured after replacement", + body: "app.setAppPath=()=>{};const change=app.setAppPath;change('/changed');", + possible: false, + calls: [0], + }, + { + name: "setter invoked before replacement", + body: "app.setAppPath('/changed');app.setAppPath=()=>{};", + possible: true, + calls: [1], + }, +]; + +it.each(cases)( + "selects the app-path callable for $name", + ({ body, possible, calls }) => { + const source = `${prefix}${body}new BrowserWindow().loadFile('index.html');`; + for (const [index, expected] of calls.entries()) { + let changed = 0; + const rendered: string[] = []; + // Record JavaScript member/capture behavior without executing Electron. + runInNewContext(source, { + flag: index === 1, + require: () => ({ + app: { + setAppPath() { + changed += 1; + }, + }, + BrowserWindow: class { + loadFile(file: string) { + rendered.push(file); + } + }, + }), + }); + expect(changed).toBe(expected); + expect(rendered).toEqual(["index.html"]); + } + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.app_path_changes ?? []).toEqual( + possible + ? [ + expect.objectContaining({ + module_key: null, + location: expect.any(Object), + }), + ] + : [], + ); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisBrowser.ts b/src/domain/javascript/electronStaticAnalysisBrowser.ts index c4a2ba193e..6bc5de9867 100644 --- a/src/domain/javascript/electronStaticAnalysisBrowser.ts +++ b/src/domain/javascript/electronStaticAnalysisBrowser.ts @@ -23,6 +23,7 @@ import { range, staticPath, staticPathResolutionContext, + type JavaScriptStaticPathIntrinsicQuery, } from "./javascriptStaticAnalysisHelpers.js"; import { semanticStaticPropertyKey, @@ -133,6 +134,7 @@ const inspectBrowserWindow = ( context.source, lookup.status, resolvedPreferences, + context.nodePathResolve, ); context.accumulator.unknownFindings += collected.unknown; const finding: ElectronBrowserWindowFinding = { @@ -163,6 +165,7 @@ const collectWindowOptions = ( source: string, lookupStatus: "explicit" | "missing" | "dynamic", preferences: t.Node | undefined, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): { readonly status: ElectronBrowserWindowFinding["web_preferences_status"]; readonly preferences: readonly ElectronWebPreference[]; @@ -176,12 +179,13 @@ const collectWindowOptions = ( preload: { preload_path: null, preload_resolution_context: null }, unknown: lookupStatus === "missing" ? 0 : 1, }; - return collectWebPreferences(source, preferences); + return collectWebPreferences(source, preferences, nodePathResolve); }; const collectWebPreferences = ( source: string, object: t.ObjectExpression, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): { readonly status: "object-literal"; readonly preferences: readonly ElectronWebPreference[]; @@ -193,7 +197,7 @@ const collectWebPreferences = ( const effectivePreload = staticObjectProperty(object, "preload"); const preloadPath = effectivePreload.status === "explicit" - ? staticPath(effectivePreload.property.value) + ? staticPath(effectivePreload.property.value, nodePathResolve) : undefined; const preload: ElectronBrowserWindowPreload = effectivePreload.status === "explicit" && preloadPath !== undefined @@ -201,6 +205,7 @@ const collectWebPreferences = ( preload_path: preloadPath, preload_resolution_context: staticPathResolutionContext( effectivePreload.property.value, + nodePathResolve, ), } : { preload_path: null, preload_resolution_context: null }; @@ -230,7 +235,10 @@ const collectWebPreferences = ( }); continue; } - const path = name === "preload" ? staticPath(property.value) : undefined; + const path = + name === "preload" + ? staticPath(property.value, nodePathResolve) + : undefined; const value: ElectronStaticValue = path === undefined ? electronStaticValue(source, property.value) @@ -305,12 +313,14 @@ const inspectUtilityProcess = ( return; const moduleNode = argumentNode(node.arguments[0]); const modulePath = - moduleNode === undefined ? undefined : staticPath(moduleNode); + moduleNode === undefined + ? undefined + : staticPath(moduleNode, context.nodePathResolve); const options = argumentNode(node.arguments[2]); const serviceNameProperty = staticObjectProperty(options, "serviceName"); const serviceName = serviceNameProperty.status === "explicit" - ? staticPath(serviceNameProperty.property.value) + ? staticPath(serviceNameProperty.property.value, context.nodePathResolve) : undefined; if (serviceNameProperty.status !== "missing" && serviceName === undefined) context.accumulator.unknownFindings += 1; @@ -324,7 +334,10 @@ const inspectUtilityProcess = ( } : { module_path: modulePath, - module_resolution_context: staticPathResolutionContext(moduleNode), + module_resolution_context: staticPathResolutionContext( + moduleNode, + context.nodePathResolve, + ), module_expression: null, }; const finding: ElectronUtilityProcessFinding = { diff --git a/src/domain/javascript/electronStaticAnalysisExportedAppPath.test.ts b/src/domain/javascript/electronStaticAnalysisExportedAppPath.test.ts new file mode 100644 index 0000000000..dbb0806a98 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisExportedAppPath.test.ts @@ -0,0 +1,136 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const cases = [ + ["native setter", "const change=app.setAppPath;", true, [1]], + [ + "local replacement", + "app.setAppPath=()=>{};const change=app.setAppPath;", + false, + [0], + ], + [ + "conditional replacement", + "if(flag)app.setAppPath=()=>{};const change=app.setAppPath;", + true, + [1, 0], + ], + [ + "capture before replacement", + "const change=app.setAppPath;app.setAppPath=()=>{};", + true, + [1], + ], + [ + "capture after replacement", + "app.setAppPath=()=>{};const change=app.setAppPath;", + false, + [0], + ], +] as const; + +it.each(cases)( + "retains the possible exported app-path effect after %s", + (_name, body, possible, calls) => { + const defining = `const {app}=require('electron');${body}`; + for (const [index, expected] of calls.entries()) { + let changed = 0; + // Authored module-namespace values model capture and invocation in two + // separate VM contexts; the fixture does not execute Electron itself. + const namespace = runInNewContext(`${defining}({change,app});`, { + flag: index === 1, + require: () => ({ app: { setAppPath: () => changed++ } }), + }); + expect(changed).toBe(0); + runInNewContext("change.call(app,'./lib');", namespace); + expect(changed).toBe(expected); + } + const exported = analyzeJavaScriptStaticSource( + `${defining}export{change,app};`, + ); + expect(exported.parse_error_count).toBe(0); + expect((exported.electron.app_path_changes ?? []).length > 0).toBe( + possible, + ); + const caller = analyzeJavaScriptStaticSource( + "import{change,app}from'./helper.mjs';change.call(app,'./lib');", + ); + expect(caller.parse_error_count).toBe(0); + expect(caller.electron.app_path_changes ?? []).toEqual([]); + }, +); + +it("does not treat a type-only exported setter as a runtime escape", () => { + const analysis = analyzeJavaScriptStaticSource( + "const{app}=require('electron');const change=app.setAppPath;export type{change};", + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.app_path_changes ?? []).toEqual([]); +}); + +it.each([ + ["export const change=app.setAppPath;", true], + ["export default app.setAppPath;", true], + ["export const change=()=>{};", false], + ["export default ()=>{};", false], +] as const)( + "selects the published runtime value in %s", + (declaration, possible) => { + const analysis = analyzeJavaScriptStaticSource( + `const{app}=require('electron');${declaration}`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + possible, + ); + }, +); + +it.each(cases)( + "retains the captured CommonJS namespace setter after %s", + (_name, body, possible, calls) => { + const defining = `const {app}=require('electron');${body}module.exports={change,app};`; + for (const [index, expected] of calls.entries()) { + let changed = 0; + const module = { exports: {} }; + runInNewContext(defining, { + module, + exports: module.exports, + flag: index === 1, + require: () => ({ app: { setAppPath: () => changed++ } }), + }); + expect(changed).toBe(0); + runInNewContext("change.call(app,'./lib');", module.exports); + expect(changed).toBe(expected); + } + const analysis = analyzeJavaScriptStaticSource(defining); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + possible, + ); + }, +); + +it.each([ + ["module.exports=change;", true], + ["exports.change=change;", true], + ["module.exports.change=change;", true], + ["Object.defineProperty(exports,'change',{value:change});", true], + ["exports.change=change;module.exports={};", false], + ["const module={exports:{}};module.exports={change};", false], + ["const exports={};exports.change=change;", false], + ["export const namespace={change,app};", true], +] as const)( + "follows shared runtime export values and receivers in %s", + (published, possible) => { + const analysis = analyzeJavaScriptStaticSource( + `const {app}=require('electron');const change=app.setAppPath;${published}`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + possible, + ); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts index 527b0b4631..4ed2377e78 100644 --- a/src/domain/javascript/electronStaticAnalysisIdentity.test.ts +++ b/src/domain/javascript/electronStaticAnalysisIdentity.test.ts @@ -2,6 +2,130 @@ import { expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +it.each([ + "module.require = replacement;", + "const holder = { loader: module }; holder.loader.require = replacement;", + "const holder = {}; holder.loader = module; holder.loader.require = replacement;", +])( + "does not prove Electron exports after a delegate replacement: %s", + (write) => { + const analysis = analyzeJavaScriptStaticSource( + `${write} const { BrowserWindow } = require("electron"); new BrowserWindow();`, + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); + }, +); + +it.each([ + ['const { app } = require("electron"); app.setAppPath("./other");', true], + [ + 'import { app as runtime } from "electron/main"; runtime.setAppPath(path);', + true, + ], + [ + 'import * as electron from "electron"; electron.app.setAppPath("./other");', + true, + ], + [ + 'const { app } = require("electron"); const runtime = app; runtime.setAppPath("./other");', + true, + ], + [ + 'const { app } = require("electron"); const change = app.setAppPath; change("./other");', + true, + ], + ['const app = { setAppPath() {} }; app.setAppPath("./other");', false], + [ + 'import { app } from "electron"; function local(app) { app.setAppPath("./other"); }', + false, + ], + [ + 'const electron = { app: { setAppPath() {} } }; electron.app.setAppPath("./other");', + false, + ], +])( + "retains Electron application path changes by binding identity: %s", + (source, expected) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.app_path_changes ?? []).toEqual( + expected + ? [ + expect.objectContaining({ + module_key: null, + location: expect.any(Object), + }), + ] + : [], + ); + }, +); + +it.each([ + ['app.setAppPath.call(app, "./other");', true], + ['app.setAppPath.apply(app, ["./other"]);', true], + ['Reflect.apply(app.setAppPath, app, ["./other"]);', true], + [ + 'const invoke = Reflect.apply; invoke(app.setAppPath, app, ["./other"]);', + true, + ], + [ + 'const invoke = Reflect.apply.bind(Reflect); invoke(app.setAppPath, app, ["./other"]);', + true, + ], + [ + 'const invoke = Reflect.apply; Reflect.apply = () => {}; invoke(app.setAppPath, app, ["./other"]);', + true, + ], + ['const change = app.setAppPath.bind(app); change("./other");', true], + ['app["setAppPath"][`call`](app, "./other");', true], + [ + 'const runtime = app; runtime.setAppPath.apply(runtime, ["./other"]);', + true, + ], + ['const change = app.setAppPath; change.call(app, "./other");', true], + ['function local(app) { app.setAppPath.call(app, "./other"); }', false], + [ + 'const local = { setAppPath() {} }; local.setAppPath.bind(local)("./other");', + false, + ], + ['app.setAppPath.other(app, "./other");', false], + [ + 'const Reflect = { apply() {} }; Reflect.apply(app.setAppPath, app, ["./other"]);', + false, + ], + [ + 'Reflect.apply = () => {}; Reflect.apply(app.setAppPath, app, ["./other"]);', + false, + ], + [ + 'const local = { setAppPath() {} }; Reflect.apply(local.setAppPath, local, ["./other"]);', + false, + ], +])( + "retains indirect application path changes by Electron identity: %s", + (call, expected) => { + const analysis = analyzeJavaScriptStaticSource(` + const { app } = require("electron"); + ${call} + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.app_path_changes ?? []).toEqual( + expected + ? [ + expect.objectContaining({ + module_key: null, + location: expect.any(Object), + }), + ] + : [], + ); + }, +); + it("recovers APIs through an ES module default import of Electron", () => { const analysis = analyzeJavaScriptStaticSource(` import electron from "electron"; diff --git a/src/domain/javascript/electronStaticAnalysisPublishedApp.test.ts b/src/domain/javascript/electronStaticAnalysisPublishedApp.test.ts new file mode 100644 index 0000000000..1145736c12 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisPublishedApp.test.ts @@ -0,0 +1,112 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +it.each([ + ["export {app};", true], + ["export default app;", true], + ["export const exposed={app};", true], + ["export const exposed=[app];", true], + ["app.setAppPath=()=>{};export{app};", false], + ["const local={setAppPath(){}};export{local};", false], +] as const)( + "retains whole exported application capabilities in %s", + (published, possible) => { + const result = analyzeJavaScriptStaticSource( + `import {app} from 'electron';${published}`, + ); + expect(result.parse_error_count).toBe(0); + expect((result.electron.app_path_changes ?? []).length > 0).toBe(possible); + }, +); + +it.each([ + [ + "later local", + "Object.assign(exports,{change:app.setAppPath},{change(){}});", + false, + ], + [ + "later native", + "Object.assign(exports,{change(){}},{change:app.setAppPath});", + true, + ], + [ + "inherited", + "const source={__proto__:{change:app.setAppPath}};Object.assign(exports,source);", + false, + ], + [ + "own", + "const source={__proto__:{change(){}},change:app.setAppPath};Object.assign(exports,source);", + true, + ], + [ + "later local alias", + "const local={change(){}};Object.assign(exports,{change:app.setAppPath},local);", + false, + ], + [ + "unrelated later field", + "Object.assign(exports,{change:app.setAppPath},{other(){}});", + true, + ], + ["array own", "Object.assign(exports,[app.setAppPath]);", true], + [ + "nullish sources", + "Object.assign(exports,{change:app.setAppPath},null,undefined);", + true, + ], + [ + "getter deletes a later source slot", + "Object.assign(exports,{change:app.setAppPath},{get first(){delete this.change},change(){}});", + true, + ], +] as const)("publishes only copied fields for %s", (_name, body, possible) => { + let changed = 0; + const app = { + setAppPath() { + changed++; + }, + }; + const module = { exports: {} }; + runInNewContext(body, { app, module, exports: module.exports }); + const fields = Object.values(module.exports) as unknown[]; + for (const field of fields) + if (typeof field === "function") field.call(app, "./lib"); + expect(changed > 0).toBe(possible); + const result = analyzeJavaScriptStaticSource( + `const{app}=require('electron');${body}`, + ); + expect(result.parse_error_count).toBe(0); + expect((result.electron.app_path_changes ?? []).length > 0).toBe(possible); +}); + +it("retains the final named own exports without publishing overwritten fields", () => { + const result = analyzeJavaScriptSemantics( + "const local=()=>{};Object.assign(exports,{change:require('electron').app.setAppPath,retained:local},{change:local});", + ); + expect( + result.moduleLinks + .filter(({ kind }) => kind === "commonjs-export") + .map(({ exportedName }) => exportedName) + .sort(), + ).toEqual(["change", "retained"]); +}); + +it("retains copied module provenance without publishing the namespace object", () => { + const result = analyzeJavaScriptSemantics( + "Object.assign(exports,require('./factory.cjs'));", + ); + expect(result.moduleLinks).toContainEqual( + expect.objectContaining({ + kind: "commonjs-export", + exportedName: "*", + specifier: "./factory.cjs", + importedName: null, + localName: null, + callableId: null, + }), + ); +}); diff --git a/src/domain/javascript/electronStaticAnalysisReturnedApp.test.ts b/src/domain/javascript/electronStaticAnalysisReturnedApp.test.ts new file mode 100644 index 0000000000..2169bf18ba --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisReturnedApp.test.ts @@ -0,0 +1,231 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + [ + "direct factory", + "const getApp=()=>app;getApp().setAppPath('./lib');", + true, + ], + [ + "returned alias", + "const getApp=()=>app;const runtime=getApp();runtime.setAppPath('./lib');", + true, + ], + [ + "method factory", + "const api={get(){return app}};api.get().setAppPath('./lib');", + true, + ], + [ + "projected return", + "const getApp=()=>({runtime:app});getApp().runtime.setAppPath('./lib');", + true, + ], + ["method return", "const change=()=>app.setAppPath;change()('./lib');", true], + [ + "awaited factory", + "const getApp=async()=>app;(await getApp()).setAppPath('./lib');", + true, + ], + [ + "unawaited promise", + "const getApp=async()=>app;getApp().setAppPath('./lib');", + false, + ], + [ + "generator", + "function*getApp(){return app}getApp().setAppPath('./lib');", + false, + ], + [ + "awaited generator", + "function*getApp(){return app}(await getApp()).setAppPath('./lib');", + false, + ], + [ + "local factory", + "const getApp=()=>({setAppPath(){}});getApp().setAppPath('./lib');", + false, + ], + [ + "escaped local", + "const local={setAppPath(){}};escape(local);local.setAppPath('./lib');", + false, + ], + [ + "external local", + "const local=require('local-library');local.setAppPath('./lib');", + false, + ], + [ + "unknown return", + "const getApp=()=>unknown;getApp().setAppPath('./lib');", + false, + ], + [ + "unrelated unknown invocation", + "const local={make(){}};escape(local);local.make();", + false, + ], + [ + "local method", + "const api={get(){return {setAppPath(){}}}};api.get().setAppPath('./lib');", + false, + ], + [ + "shadowed app", + "function local(app){const getApp=()=>app;getApp().setAppPath('./lib');}", + false, + ], + [ + "mixed return", + "const getApp=()=>flag?app:{setAppPath(){}};getApp().setAppPath('./lib');", + true, + ], + [ + "replacement factory", + "const api={get:()=>({setAppPath(){}})};api.get=()=>app;api.get().setAppPath('./lib');", + true, + ], + [ + "local replacement", + "const api={get:()=>app};api.get=()=>({setAppPath(){}});api.get().setAppPath('./lib');", + false, + ], + [ + "later member replacement", + "const getApp=()=>app;getApp().setAppPath('./lib');app.setAppPath=()=>{};", + true, + ], + [ + "returned setter call", + "const getApp=()=>app;getApp().setAppPath.call(app,'./lib');", + true, + ], + [ + "returned setter reflect", + "const getApp=()=>app;Reflect.apply(getApp().setAppPath,app,['./lib']);", + true, + ], + [ + "factory call delegate", + "const getApp=()=>app;getApp.call(null).setAppPath('./lib');", + true, + ], + [ + "factory apply delegate", + "const getApp=()=>app;getApp.apply(null,[]).setAppPath('./lib');", + true, + ], + [ + "captured app before replacement", + "let runtime=app;const held=runtime;runtime={setAppPath(){}};const getApp=()=>held;getApp().setAppPath('./lib');", + true, + ], + [ + "captured local before replacement", + "let runtime={setAppPath(){}};const held=runtime;runtime=app;const getApp=()=>held;getApp().setAppPath('./lib');", + false, + ], + [ + "captured local after replacement", + "let runtime=app;runtime={setAppPath(){}};const held=runtime;const getApp=()=>held;getApp().setAppPath('./lib');", + false, + ], + [ + "captured app after replacement", + "let runtime={setAppPath(){}};runtime=app;const held=runtime;const getApp=()=>held;getApp().setAppPath('./lib');", + true, + ], +] as const)( + "retains only possible Electron app path changes from %s", + (_name, body, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `const {app}=require('electron');${body}`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + expected, + ); + // A possible return must not promote other APIs to proven Electron identity. + expect(analysis.electron.browser_windows).toEqual([]); + }, +); + +it.each([ + "const getApp=()=>require('electron').app;getApp().setAppPath('./lib');", + "const getElectron=()=>require('electron');getElectron().app.setAppPath('./lib');", + "const getApp=()=>require('other').app;getApp().setAppPath('./lib');", +])( + "follows returned require origins without an import shortcut: %s", + (source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + !source.includes("'other'"), + ); + }, +); + +it("does not promote a returned Electron constructor to proven API identity", () => { + const analysis = analyzeJavaScriptStaticSource( + "const {BrowserWindow}=require('electron');const get=()=>BrowserWindow;new (get())();", + ); + expect(analysis.electron.browser_windows).toEqual([]); + expect(analysis.electron.app_path_changes ?? []).toEqual([]); +}); + +it.each([ + ["call", true], + ["call", false], + ["apply", true], + ["apply", false], +] as const)( + "uses the actual receiver of a captured %s wrapper (host=%s)", + (method, host) => { + const local = "({setAppPath(){}})"; + const analysis = analyzeJavaScriptStaticSource( + `const {app}=require('electron'); + const original=()=>${host ? local : "app"}; + const selected=()=>${host ? "app" : local}; + const invoke=original.${method}; + invoke.call(selected,null${method === "apply" ? ",[]" : ""}).setAppPath('./lib');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host); + }, +); + +it.each([true, false])( + "keeps a finite source closure after an Electron app selector budget is reached (host=%s)", + (host) => { + const declarations = Array.from( + { length: 550 }, + (_, index) => + `const selected${index}=selected${index === 0 ? "Root" : index - 1};`, + ).join(""); + const analysis = analyzeJavaScriptStaticSource( + `const {app}=require('electron');const selectedRoot={runtime:${host ? "app" : "{setAppPath(){}}"}};${declarations}const getApp=()=>selected549.runtime;getApp().setAppPath('./lib');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host); + expect(analysis.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("Alias selection expansion"), + ]), + ); + }, +); + +it.each([true, false])( + "closes cyclic returned app copies without inventing an Electron origin (host=%s)", + (host) => { + const analysis = analyzeJavaScriptStaticSource( + `const {app}=require('electron');let box={runtime:${host ? "app" : "{setAppPath(){}}"}};while(flag)box={...box};const getApp=()=>box.runtime;getApp().setAppPath('./lib');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe(host); + }, +); diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 5b8ebf4aa8..b0e4aca045 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -193,6 +193,8 @@ export interface ElectronNativeAddonBindingFinding { /** All Electron-specific facts collected during one shared AST traversal. */ export interface ElectronStaticFindings { + /** Calls that may replace the application path used by Electron loadFile. */ + readonly app_path_changes?: readonly ElectronAppPathChangeFinding[]; readonly browser_windows: readonly ElectronBrowserWindowFinding[]; readonly context_bridge_apis: readonly ElectronContextBridgeFinding[]; readonly ipc: readonly ElectronIpcFinding[]; @@ -203,6 +205,13 @@ export interface ElectronStaticFindings { readonly call_paths?: readonly ElectronCallPath[]; } +/** An Electron application path setter invocation or bound delegate capture. */ +export interface ElectronAppPathChangeFinding { + readonly module_key: string | null; + readonly module_runtime?: string; + readonly location: JavaScriptSourceRange; +} + export interface ElectronCallPathStep { readonly kind: | "exposure" diff --git a/src/domain/javascript/javascriptApplicationProjectionResources.ts b/src/domain/javascript/javascriptApplicationProjectionResources.ts index 3e5280c9e8..678a8ec3d3 100644 --- a/src/domain/javascript/javascriptApplicationProjectionResources.ts +++ b/src/domain/javascript/javascriptApplicationProjectionResources.ts @@ -10,6 +10,7 @@ export const estimateJavaScriptStaticApplicationProjection = ( // Allow for identifiers, locations, authority, coverage and schema copies, // independently of the producer's compact checkpoint representation. const findings = + (facts.electron.app_path_changes?.length ?? 0) + facts.references.length + facts.endpoints.length + facts.storage.length + diff --git a/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts new file mode 100644 index 0000000000..409151ec87 --- /dev/null +++ b/src/domain/javascript/javascriptCommonJsAssignAppPath.test.ts @@ -0,0 +1,141 @@ +import { runInNewContext } from "node:vm"; + +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const cases = [ + [ + "native source spread", + "Object.assign(exports,...[{change:app.setAppPath,app}]);", + true, + ], + [ + "native target spread", + "Object.assign(...[exports,{change:app.setAppPath,app}]);", + true, + ], + [ + "native call", + "Object.assign.call(null,exports,{change:app.setAppPath,app});", + true, + ], + [ + "native apply", + "Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);", + true, + ], + [ + "captured intrinsic before replacement", + "const publish=Object.assign;Object.assign=()=>{};publish(exports,{change:app.setAppPath,app});", + true, + ], + [ + "replaced intrinsic", + "Object.assign=()=>{};Object.assign(exports,{change:app.setAppPath,app});", + false, + ], + [ + "replaced call", + "Object.assign.call=()=>{};Object.assign.call(null,exports,{change:app.setAppPath,app});", + false, + ], + [ + "replaced apply", + "Object.assign.apply=()=>{};Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);", + false, + ], + [ + "replaced Function call", + "Function.prototype.call=()=>{};Object.assign.call(null,exports,{change:app.setAppPath,app});", + false, + ], + [ + "replaced Function apply", + "Function.prototype.apply=()=>{};Object.assign.apply(null,[exports,{change:app.setAppPath,app}]);", + false, + ], + [ + "native exports", + "Object.assign(exports,{change:app.setAppPath,app});", + true, + ], + [ + "native module exports", + "Object.assign(module.exports,{change:app.setAppPath,app});", + true, + ], + [ + "detached before", + "module.exports={};Object.assign(exports,{change:app.setAppPath,app});", + false, + ], + [ + "detached after", + "Object.assign(exports,{change:app.setAppPath,app});module.exports={};", + false, + ], + [ + "reconnected", + "exports=module.exports={};Object.assign(exports,{change:app.setAppPath,app});", + true, + ], + [ + "local exports", + "const exports={};Object.assign(exports,{change:app.setAppPath,app});", + false, + ], + [ + "local Object", + "const Object={assign(){}};Object.assign(exports,{change:app.setAppPath,app});", + false, + ], + [ + "local setter", + "app.setAppPath=()=>{};Object.assign(exports,{change:app.setAppPath,app});", + false, + ], +] as const; + +it.each(cases)( + "retains an Object.assign-published setter for %s", + (_name, body, possible) => { + const defining = `const{app}=require('electron');${body}`; + let changed = 0; + const module = { exports: {} }; + runInNewContext(defining, { + module, + exports: module.exports, + require: () => ({ app: { setAppPath: () => changed++ } }), + }); + expect(changed).toBe(0); + runInNewContext( + "if(typeof change==='function')change.call(app,'./lib');", + module.exports, + ); + expect(changed).toBe(possible ? 1 : 0); + const analysis = analyzeJavaScriptStaticSource(defining); + expect(analysis.parse_error_count).toBe(0); + expect((analysis.electron.app_path_changes ?? []).length > 0).toBe( + possible, + ); + }, +); + +it("retains native source uncertainty when an array iterator is overwritten", () => { + const defining = + "const{app}=require('electron');const sources=[{change:app.setAppPath,app}];sources[Symbol.iterator]=function*(){yield{change(){},app}};Object.assign(exports,...sources);"; + let changed = 0; + const module = { exports: {} }; + runInNewContext(defining, { + module, + exports: module.exports, + require: () => ({ app: { setAppPath: () => changed++ } }), + }); + runInNewContext("change.call(app,'./lib');", module.exports); + expect(changed).toBe(0); + // The static owner does not execute this custom iterator; it must retain the + // native origin still present in the source rather than silently drop it. + const analysis = analyzeJavaScriptStaticSource(defining); + expect((analysis.electron.app_path_changes ?? []).length).toBeGreaterThan(0); +}); diff --git a/src/domain/javascript/javascriptCommonJsExportReceivers.ts b/src/domain/javascript/javascriptCommonJsExportReceivers.ts index 848f5a8d6a..68cc826ff3 100644 --- a/src/domain/javascript/javascriptCommonJsExportReceivers.ts +++ b/src/domain/javascript/javascriptCommonJsExportReceivers.ts @@ -2,16 +2,54 @@ import * as t from "@babel/types"; import { semanticStaticPropertyKey, + semanticFinalObjectProperty, semanticWriteTargets, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { isUnshadowedGlobal, + resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { isUnmodifiedGlobalReference } from "./javascriptSemanticAmbientWrites.js"; import { traverseJavaScriptAstSteps } from "./javascriptSemanticTraversal.js"; import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPaths.js"; +import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js"; +import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; + +const exportInvocations = new WeakMap< + JavaScriptSemanticAnalysisState, + ReturnType +>(); + +/** Share the intrinsic owner's positional and capture proofs across CJS consumers. */ +const commonJsExportInvocation = ( + node: t.CallExpression, + state: JavaScriptSemanticAnalysisState, +) => { + let interop = exportInvocations.get(state); + if (interop === undefined) { + interop = createSemanticModuleInterop(state); + for (const { node: write, path } of state.ambientWriteEffects) + if (write !== undefined && path[0] === "global") + interop.retainWrite(write, [ + "intrinsic", + ...path + .slice(1) + .map((key) => + typeof key === "string" || typeof key === "number" + ? String(key) + : null, + ), + ]); + exportInvocations.set(state, interop); + } + return interop.intrinsicInvocation(node); +}; export type CommonJsExportReceiverEffect = | "connected" @@ -21,6 +59,111 @@ export type CommonJsExportReceiverEffect = /** A CommonJS export assignment or `Object.defineProperty` export call. */ export type CommonJsExportSite = t.AssignmentExpression | t.CallExpression; +interface CopiedOwnFields { + readonly definite: ReadonlySet; + readonly complete: boolean; +} + +/** Definite own fields survive aliases, but not a possible prior shape change. */ +const copiedOwnFields = ( + source: t.Node, + state: JavaScriptSemanticAnalysisState, +): CopiedOwnFields => { + const unknown = { definite: new Set(), complete: false }; + const pending = [source]; + const seen = new Set(); + let common: Set | undefined; + let complete = true; + while (pending.length > 0) { + const node = unwrapJavaScriptExpression(pending.pop()!).node; + if (seen.has(node)) continue; + if (seen.size >= SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + return unknown; + } + seen.add(node); + if (t.isIdentifier(node)) { + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) return unknown; + if ( + binding.memberWrites.some( + (effect) => + semanticEffectTiming(source, effect.node, state) !== "after", + ) || + binding.escapedPaths.some( + (effect) => + effect.node !== undefined && + !( + (effect.node.start ?? -1) <= (source.start ?? -1) && + (effect.node.end ?? -1) >= (source.end ?? -1) + ) && + semanticEffectTiming(source, effect.node, state) !== "after", + ) + ) + return unknown; + const selected = semanticProjectedSourcesAtRead( + binding, + node, + [], + state, + false, + source, + ); + if ( + selected === undefined || + selected.length === 0 || + selected.some((value) => value.path.length !== 0) + ) + return unknown; + if ( + seen.size + pending.length + selected.length > + SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + state.valueResourceLimits.add("alias-selections"); + return unknown; + } + for (const value of selected) pending.push(value.node); + continue; + } + // A getter can remove a later own slot while Object.assign is copying. + // Its initial presence cannot prove an earlier source was overwritten. + if ( + t.isObjectExpression(node) && + node.properties.some( + (property) => t.isObjectMethod(property) && property.kind !== "method", + ) + ) + return unknown; + const selected = evaluateSemanticExpression(node, state, source); + const fields = + selected.status === "object" + ? selected.properties + : selected.status === "array" + ? selected.items + : []; + const own = new Set( + fields + .filter((field) => field.presence === "present") + .map((field) => field.name), + ); + const exact = + selected.status === "object" + ? !selected.unknownProperties + : selected.status === "array" + ? !selected.unknownItems + : selected.status === "literal" && selected.value === null; + complete &&= + exact && + (common === undefined || + (common.size === own.size && [...common].every((key) => own.has(key)))); + common = + common === undefined + ? own + : new Set([...common].filter((key) => own.has(key))); + } + return { definite: common ?? new Set(), complete }; +}; + const isCommonJsModuleExports = ( node: t.Node, state: JavaScriptSemanticAnalysisState, @@ -90,6 +233,103 @@ export const commonJsDefinedExport = ( }; }; +/** Native Object.assign publishes source values only through a CJS receiver. */ +const commonJsAssignedExports = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): + | { readonly receiver: t.Node; readonly sources: readonly t.Node[] } + | undefined => { + if (!t.isCallExpression(node)) return undefined; + const invocation = commonJsExportInvocation(node, state); + if ( + invocation?.kind !== "known" || + invocation.path.length !== 3 || + invocation.path[0] !== "intrinsic" || + invocation.path[1] !== "Object" || + invocation.path[2] !== "assign" + ) + return undefined; + const [target, ...sources] = invocation.arguments ?? []; + if (!t.isExpression(target)) return undefined; + const receiver = unwrapJavaScriptExpression(target).node; + if ( + !isUnshadowedGlobal(receiver, state, "exports") && + !isCommonJsModuleExports(receiver, state) + ) + return undefined; + return { + receiver, + sources: sources.flatMap((source) => + t.isSpreadElement(source) + ? [source.argument] + : t.isExpression(source) + ? [source] + : [], + ), + }; +}; + +/** Runtime values published by the CommonJS syntax owner, before receiver applicability. */ +export const commonJsRuntimeExportValues = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): readonly { + readonly exportedName: string; + readonly value: t.Node | null; + /** Own enumerable fields copied at this source, after later-source overwrites. */ + readonly projection?: JavaScriptSemanticPropertyPath; + readonly ownEnumerable?: true; +}[] => { + if (t.isAssignmentExpression(node)) { + const exportedName = commonJsExportName(node.left, state); + return exportedName === undefined + ? [] + : [{ exportedName, value: node.operator === "=" ? node.right : null }]; + } + const assigned = commonJsAssignedExports(node, state); + if (assigned !== undefined) { + const exported: ReturnType[number][] = + []; + const overwritten = new Set(); + for (let index = assigned.sources.length - 1; index >= 0; index--) { + const value = assigned.sources[index]!; + const fields = copiedOwnFields(value, state); + if (fields.complete) { + for (const name of fields.definite) + if (!overwritten.has(name)) + exported.push({ + exportedName: name, + value, + projection: [name], + ownEnumerable: true, + }); + } else + exported.push({ + exportedName: "*", + value, + projection: [{ excludedKeys: [...overwritten] }], + ownEnumerable: true, + }); + // Copy applicability belongs to the shared own-slot inventory. Unknown + // later fields cannot prove that an earlier native field was replaced. + for (const field of fields.definite) overwritten.add(field); + } + return exported; + } + const defined = commonJsDefinedExport(node, state); + if (defined === undefined) return []; + const descriptor = unwrapJavaScriptExpression(defined.descriptor).node; + return [ + { + exportedName: defined.exportedName, + value: t.isObjectExpression(descriptor) + ? (semanticFinalObjectProperty(descriptor, "value") ?? null) + : null, + }, + ]; +}; + type Identity = t.Node | null; type Execution = "ordered" | "conditional" | "deferred"; @@ -217,7 +457,9 @@ export function* indexCommonJsExportReceiversSteps( : publicIdentity, ); } - const defined = commonJsDefinedExport(node, state); + const defined = + commonJsDefinedExport(node, state) ?? + commonJsAssignedExports(node, state); if (defined !== undefined && t.isCallExpression(node)) captures.set( node, diff --git a/src/domain/javascript/javascriptElectronAppPathChanges.ts b/src/domain/javascript/javascriptElectronAppPathChanges.ts new file mode 100644 index 0000000000..ae18db9aec --- /dev/null +++ b/src/domain/javascript/javascriptElectronAppPathChanges.ts @@ -0,0 +1,390 @@ +import * as t from "@babel/types"; + +import { + semanticStaticPropertyKey, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { + semanticAliasSelectionChoices, + type SemanticAliasSelectionPath, +} from "./javascriptSemanticAliasSelections.js"; +import { + semanticArrayPropertyReferences, + semanticObjectPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; +import { + semanticNormalizeCapturedSelections, + semanticProjectedSourceFactsAtRead, + semanticShiftCapturedSelections, + type SemanticCapturedSelection, +} from "./javascriptSemanticProjectedSources.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { + resolveSemanticBindingState, + type JavaScriptSemanticAnalysisState, +} from "./javascriptSemanticState.js"; +import type { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; +import { assignmentValueSources } from "./javascriptSemanticRequireOrigin.js"; +import { semanticRuntimeExportValueSources } from "./javascriptModuleDeclaration.js"; +import { + commonJsRuntimeExportValues, + indexCommonJsExportReceiversSteps, + type CommonJsExportReceiverEffect, + type CommonJsExportSite, +} from "./javascriptCommonJsExportReceivers.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; + +interface Candidate { + readonly node: t.Node; + readonly path: SemanticAliasSelectionPath | undefined; + readonly readAt?: t.Node | undefined; + readonly capturedSelections?: + | readonly SemanticCapturedSelection[] + | undefined; + readonly awaitDepths: readonly number[]; + readonly escaping?: true; +} + +const isSetterPath = (path: SemanticAliasSelectionPath): boolean => + path.length >= 2 && + path.every((segment, index) => + semanticAliasSelectionChoices(segment).some( + (key) => + key === null || + (index < 2 + ? key === ["app", "setAppPath"][index] + : ["call", "apply", "bind"].includes(String(key))), + ), + ); + +/** + * A source-local return may carry the shared Electron app even when it cannot + * establish a concrete API identity. Retain only the possibility of changing + * its root; callers must not use this query to prove other Electron APIs. + */ +export const createSemanticElectronAppPathChangeQuery = ( + state: JavaScriptSemanticAnalysisState, + exportPath: (node: t.Node) => readonly string[] | undefined, + interop: Pick< + ReturnType, + "intrinsicInvocation" | "intrinsicPath" + >, +): ((node: t.Node) => boolean) => { + const directReturns = createSemanticReturnedReferences(state, "direct-call"); + const awaitedReturns = createSemanticReturnedReferences( + state, + "awaited-call", + ); + let commonJsReceivers: + | ReadonlyMap + | undefined; + const possiblePath = ( + root: t.Node, + escaping = false, + projection: SemanticAliasSelectionPath = [], + ownEnumerable = false, + ): boolean => { + const pending: Candidate[] = []; + const seen = new WeakMap>(); + let scheduled = 0; + let widened = false; + const enqueue = (candidate: Candidate): void => { + const node = unwrapJavaScriptExpression(candidate.node).node; + // Close the finite source graph after the precise context budget is + // exhausted. Widening alone is never evidence of an Electron origin. + if (!widened && scheduled >= SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + widened = true; + } + if (widened) + candidate = { + node, + path: undefined, + awaitDepths: candidate.awaitDepths.length > 0 ? [0] : [], + ...(candidate.escaping ? { escaping: true as const } : {}), + }; + const capturedSelections = semanticNormalizeCapturedSelections( + candidate.capturedSelections, + candidate.path?.length, + ); + const key = JSON.stringify([ + candidate.path, + candidate.readAt?.start, + candidate.readAt?.end, + capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), + candidate.awaitDepths, + candidate.escaping, + ]); + const contexts = seen.get(node) ?? new Set(); + if (contexts.has(key)) return; + scheduled++; + contexts.add(key); + seen.set(node, contexts); + pending.push({ ...candidate, node, capturedSelections }); + }; + enqueue({ + node: root, + path: projection, + awaitDepths: [], + ...(escaping ? { escaping: true as const } : {}), + ...(ownEnumerable + ? { + capturedSelections: [ + { at: root, depth: 1, ownEnumerable: true as const }, + ], + } + : {}), + }); + while (pending.length > 0) { + const current = pending.pop()!; + if (widened && current.path !== undefined) { + enqueue(current); + continue; + } + const { node, path } = current; + const binding = t.isIdentifier(node) + ? resolveSemanticBindingState(state, node, node.name) + : undefined; + const selected = + binding === undefined || path === undefined + ? undefined + : semanticProjectedSourceFactsAtRead( + binding, + node, + path, + state, + false, + current.readAt ?? node, + current.capturedSelections, + ); + // Ordinary aliases are followed below with shared source selections. + // Re-evaluating their whole-binding provenance at every DAG node would + // repeat that graph merely to obtain a concrete identity we do not need. + const exported = + !t.isIdentifier(node) || + binding?.directOrigins.length || + binding?.initializers.some((source) => + t.isCallExpression(unwrapJavaScriptExpression(source.node).node), + ) + ? exportPath(node) + : undefined; + if ( + current.escaping && + path?.length === 0 && + exported !== undefined && + (exported.length === 0 || + (exported.length === 1 && exported[0] === "app")) + ) { + // Publishing the app (or its namespace) lets an importer select the + // setter. Re-enter the same projected-source proof so a definite local + // method replacement still suppresses that native alternative. + enqueue({ + ...current, + path: exported.length === 0 ? ["app", "setAppPath"] : ["setAppPath"], + }); + } + if ( + exported !== undefined && + // The retained app identity alone cannot prove its selected method + // survived an own write. Conditional or unknown writes keep the native + // alternative; a definite replacement follows its selected RHS below. + selected?.retainsOrigin !== false && + (path === undefined + ? exported.length === 0 || + (exported.length === 1 && exported[0] === "app") || + isSetterPath(exported) + : isSetterPath([...exported, ...path])) + ) + return true; + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + enqueue({ + ...current, + node: node.object, + path: + path === undefined + ? undefined + : [ + semanticStaticPropertyKey(node.property, node.computed), + ...path, + ], + readAt: current.readAt ?? node, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + 1, + ), + }); + } else if (t.isIdentifier(node)) { + if (binding === undefined) continue; + const sources = selected?.sources; + // Unknown iteration cannot prove an Electron origin, but must not drop + // retained source references that can still carry the shared app. + if (sources === undefined) { + for (const sources of [ + binding.initializers, + binding.referenceInitializers, + ]) + for (const source of sources) { + if (semanticReferenceSourcesInactive(source, state)) continue; + enqueue({ + ...current, + node: source.node, + path: + path === undefined + ? undefined + : [...source.projection, ...path], + }); + } + for (const effect of binding.memberWrites) + for (const source of effect.assignedSources ?? []) { + if (semanticReferenceSourcesInactive(source, state)) continue; + enqueue({ ...current, node: source.node, path: undefined }); + } + continue; + } + for (const source of sources) + enqueue({ + ...current, + ...source, + readAt: path?.length === 0 ? undefined : (current.readAt ?? node), + capturedSelections: [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - (path?.length ?? 0), + ) ?? []), + ...(source.capturedSelections ?? []), + ], + }); + } else if (t.isAwaitExpression(node)) { + enqueue({ + ...current, + node: node.argument, + awaitDepths: [ + ...new Set([...current.awaitDepths, path?.length ?? 0]), + ].sort((left, right) => left - right), + }); + } else if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + const returned = current.awaitDepths.includes(path?.length ?? 0) + ? awaitedReturns + : directReturns; + for (const source of returned(node.callee, node).references) + enqueue({ + ...current, + node: source.node, + path: + path === undefined ? undefined : [...source.projection, ...path], + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + source.projection.length, + ), + }); + } else if ( + t.isConditionalExpression(node) || + t.isLogicalExpression(node) + ) { + enqueue({ + ...current, + node: t.isConditionalExpression(node) ? node.consequent : node.left, + }); + enqueue({ + ...current, + node: t.isConditionalExpression(node) ? node.alternate : node.right, + }); + } else if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + if (last !== undefined) enqueue({ ...current, node: last }); + } else if (t.isAssignmentExpression(node)) { + for (const source of assignmentValueSources(node)) + enqueue({ ...current, node: source }); + } else if ( + (t.isObjectExpression(node) || t.isArrayExpression(node)) && + (path === undefined || path.length > 0 || current.escaping) + ) { + const [key, ...remaining] = + path?.length === 0 ? [null] : (path ?? [null]); + for (const choice of semanticAliasSelectionChoices(key!)) { + const sources = t.isObjectExpression(node) + ? semanticObjectPropertyReferences(node, [choice], state) + : semanticArrayPropertyReferences(node, [choice]); + for (const source of sources) + if ( + !source.inherited || + !current.capturedSelections?.some( + (capture) => capture.depth === 1 && capture.ownEnumerable, + ) + ) + enqueue({ + ...current, + node: source.node, + path: + path === undefined + ? undefined + : [...source.path, ...remaining], + awaitDepths: current.awaitDepths.filter( + (depth) => depth !== path?.length, + ), + capturedSelections: [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - 1, + ) ?? []), + ...(source.capturedSelectionAt === undefined + ? [] + : [ + { + at: source.capturedSelectionAt, + depth: source.path.length, + ...(source.ownEnumerable + ? { ownEnumerable: true as const } + : {}), + }, + ]), + ], + }); + } + } + } + return false; + }; + return (node) => { + if ( + (t.isCallExpression(node) || t.isOptionalCallExpression(node)) && + (possiblePath(node.callee) || + interop.intrinsicInvocation(node, (callee) => + possiblePath(callee) + ? ["electron-app-path-change"] + : interop.intrinsicPath(callee), + )?.path[0] === "electron-app-path-change") + ) + return true; + if ( + semanticRuntimeExportValueSources(node).some((value) => + possiblePath(value, true), + ) + ) + return true; + const exported = commonJsRuntimeExportValues(node, state); + if ( + !exported.some(({ value }) => value !== null) || + (!t.isAssignmentExpression(node) && !t.isCallExpression(node)) + ) + return false; + commonJsReceivers ??= completeSemanticSteps( + indexCommonJsExportReceiversSteps(state), + ); + return ( + commonJsReceivers.get(node) !== "detached" && + exported.some( + ({ value, projection, ownEnumerable }) => + value !== null && + possiblePath(value, true, projection, ownEnumerable), + ) + ); + }; +}; diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.test.ts b/src/domain/javascript/javascriptIntrinsicInvocation.test.ts index 66c58bff6f..b3858d8837 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.test.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.test.ts @@ -2,6 +2,11 @@ import { expect, it } from "vitest"; import * as t from "@babel/types"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { + javaScriptSourceInvocationContext, + javaScriptGenericInvocationMethod, + resolveJavaScriptGenericInvocation, +} from "./javascriptIntrinsicInvocation.js"; import { parseJavaScriptSource } from "./javascriptSourceParser.js"; import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; import { @@ -52,3 +57,44 @@ it("keeps a comma-expression intrinsic call unknown when its receiver is erased" }), ]); }); + +it.each([ + ["get.call(null)", "get"], + ["get.apply(null,[])", "get"], + ["get.call.call(other,null)", "get.call"], + ["get.apply.call(other,null,[])", "get.apply"], + ["(0,get.call)(null)", undefined], +])( + "retains one generic callable receiver transition through %s", + (source, name) => { + const parsed = parseJavaScriptSource(source); + const statement = parsed?.program.body[0]; + if ( + !t.isExpressionStatement(statement) || + !t.isCallExpression(statement.expression) + ) + throw new Error("Expected source invocation fixture"); + const call = statement.expression; + const method = javaScriptGenericInvocationMethod(call.callee); + const context = javaScriptSourceInvocationContext(call); + const result = + method === undefined + ? undefined + : resolveJavaScriptGenericInvocation(method, context); + expect(result?.callable).toEqual( + name === undefined + ? undefined + : name.includes(".") + ? expect.objectContaining({ + type: "MemberExpression", + object: expect.objectContaining({ name: "get" }), + property: expect.objectContaining({ name: name.split(".")[1] }), + }) + : expect.objectContaining({ type: "Identifier", name }), + ); + if (name?.includes(".")) + expect(result?.context.receiver).toEqual( + expect.objectContaining({ type: "Identifier", name: "other" }), + ); + }, +); diff --git a/src/domain/javascript/javascriptIntrinsicInvocation.ts b/src/domain/javascript/javascriptIntrinsicInvocation.ts index 6aeb5ab785..082fe5006d 100644 --- a/src/domain/javascript/javascriptIntrinsicInvocation.ts +++ b/src/domain/javascript/javascriptIntrinsicInvocation.ts @@ -6,9 +6,16 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; -/** Expand only literal array spreads whose positional arguments are all known. */ +export type JavaScriptArrayIterationProof = ( + array: t.ArrayExpression, +) => boolean; + +const unknownArrayIteration: JavaScriptArrayIterationProof = () => false; + +/** Expand literal array spreads only when their native iterator is proven. */ export const expandJavaScriptInvocationArguments = ( arguments_: readonly t.CallExpression["arguments"][number][], + nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration, ): t.CallExpression["arguments"] => { const expanded: t.CallExpression["arguments"] = []; const pending = [...arguments_].reverse(); @@ -20,6 +27,7 @@ export const expandJavaScriptInvocationArguments = ( : undefined; if ( t.isArrayExpression(array) && + nativeArrayIteration(array) && array.elements.every( (element) => t.isExpression(element) || t.isSpreadElement(element), ) @@ -33,7 +41,10 @@ export const expandJavaScriptInvocationArguments = ( return expanded; }; -const arrayArguments = (node: t.Node | undefined) => { +const arrayArguments = ( + node: t.Node | undefined, + nativeArrayIteration: JavaScriptArrayIterationProof, +) => { const array = node === undefined ? undefined : unwrapJavaScriptExpression(node).node; if ( @@ -45,6 +56,7 @@ const arrayArguments = (node: t.Node | undefined) => { array.elements.filter( (element): element is t.Expression | t.SpreadElement => element !== null, ), + nativeArrayIteration, ); return expanded.every((element) => t.isExpression(element)) ? expanded.filter((element): element is t.Expression => @@ -56,13 +68,80 @@ const arrayArguments = (node: t.Node | undefined) => { const invocationArguments = ( method: "call" | "apply", arguments_: JavaScriptIntrinsicInvocation["arguments"], + nativeArrayIteration: JavaScriptArrayIterationProof, ) => t.isSpreadElement(arguments_?.[0]) ? undefined : method === "apply" - ? arrayArguments(arguments_?.[1]) + ? arrayArguments(arguments_?.[1], nativeArrayIteration) : arguments_?.slice(1); +/** Positional state needed only by generic Function.call/apply invocation. */ +export interface JavaScriptSourceInvocationContext { + readonly receiver: t.Node | undefined; + readonly receiverUnknown?: boolean; + readonly arguments: JavaScriptIntrinsicInvocation["arguments"]; +} + +/** Only these generic function members consume a callable receiver. */ +export const javaScriptGenericInvocationMethod = ( + callee: t.Node, +): "call" | "apply" | undefined => { + const expression = unwrapJavaScriptExpression(callee).node; + const method = + t.isMemberExpression(expression) || t.isOptionalMemberExpression(expression) + ? semanticStaticPropertyKey(expression.property, expression.computed) + : undefined; + return method === "call" || method === "apply" ? method : undefined; +}; + +/** A member call supplies its receiver; a captured/comma callable does not. */ +export const javaScriptSourceInvocationContext = ( + node: t.CallExpression | t.OptionalCallExpression, + nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration, +): JavaScriptSourceInvocationContext => { + const callee = unwrapJavaScriptExpression(node.callee).node; + return { + receiver: + t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) + ? callee.object + : undefined, + arguments: expandJavaScriptInvocationArguments( + node.arguments, + nativeArrayIteration, + ), + }; +}; + +/** Select one generic function receiver without evaluating function arguments. */ +export const resolveJavaScriptGenericInvocation = ( + method: "call" | "apply", + context: JavaScriptSourceInvocationContext, + nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration, +): + | { + readonly callable: t.Expression; + readonly context: JavaScriptSourceInvocationContext; + } + | undefined => + !t.isExpression(context.receiver) + ? undefined + : { + callable: context.receiver, + context: { + receiver: context.arguments?.[0], + ...(context.arguments === undefined || + t.isSpreadElement(context.arguments[0]) + ? { receiverUnknown: true } + : {}), + arguments: invocationArguments( + method, + context.arguments, + nativeArrayIteration, + ), + }, + }; + /** * Normalize known invocation syntax once, retaining bound argument prefixes. * Binding a function alone is not a write; only its eventual call is examined. @@ -72,11 +151,12 @@ export const resolveJavaScriptIntrinsicInvocation = ( intrinsicPath: (node: t.Node) => readonly string[] | undefined, boundIntrinsic: (node: t.Node) => JavaScriptIntrinsicInvocation | undefined, intrinsicReadChanged: (node: t.Node, path: readonly string[]) => boolean, + nativeArrayIteration: JavaScriptArrayIterationProof = unknownArrayIteration, ): JavaScriptIntrinsicInvocation | undefined => { const visited = new Set(); let callee: t.Node = node.callee; let arguments_: JavaScriptIntrinsicInvocation["arguments"] = - expandJavaScriptInvocationArguments(node.arguments); + expandJavaScriptInvocationArguments(node.arguments, nativeArrayIteration); let receiver: t.Node | undefined; let explicitReceiver = false; for (;;) { @@ -106,15 +186,21 @@ export const resolveJavaScriptIntrinsicInvocation = ( ); if (method === "call" || method === "apply") { const bound = boundIntrinsic(callee.object); + // Caller-selected native loaders share Function.prototype authority. + const callablePath = bound?.path ?? intrinsicPath(callee.object); if ( - bound !== undefined && - intrinsicReadChanged(callee, [...bound.path, method]) + callablePath !== undefined && + intrinsicReadChanged(callee, [...callablePath, method]) ) - return { kind: "opaque", path: bound.path, arguments: undefined }; + return { kind: "opaque", path: callablePath, arguments: undefined }; receiver = arguments_?.[0]; explicitReceiver = true; callee = callee.object; - arguments_ = invocationArguments(method, arguments_); + arguments_ = invocationArguments( + method, + arguments_, + nativeArrayIteration, + ); continue; } } @@ -138,12 +224,20 @@ export const resolveJavaScriptIntrinsicInvocation = ( : t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee) ? callee.object : undefined; - if (!t.isExpression(callable)) + const selected = resolveJavaScriptGenericInvocation( + method, + { + receiver: callable, + arguments: arguments_, + }, + nativeArrayIteration, + ); + if (selected === undefined) return { kind: "opaque", path, arguments: undefined }; - callee = callable; - receiver = arguments_?.[0]; + callee = selected.callable; + receiver = selected.context.receiver; explicitReceiver = true; - arguments_ = invocationArguments(method, arguments_); + arguments_ = selected.context.arguments; continue; } if (path.join(".") === "intrinsic.Reflect.apply") { @@ -152,7 +246,7 @@ export const resolveJavaScriptIntrinsicInvocation = ( callee = callable; receiver = arguments_?.[1]; explicitReceiver = true; - arguments_ = arrayArguments(arguments_?.[2]); + arguments_ = arrayArguments(arguments_?.[2], nativeArrayIteration); continue; } return { diff --git a/src/domain/javascript/javascriptModuleDeclaration.ts b/src/domain/javascript/javascriptModuleDeclaration.ts index 48a8d4b9e5..2e29ba9324 100644 --- a/src/domain/javascript/javascriptModuleDeclaration.ts +++ b/src/domain/javascript/javascriptModuleDeclaration.ts @@ -1,4 +1,5 @@ import * as t from "@babel/types"; +import { semanticWriteTargets } from "./javascriptAstValues.js"; /** Declaration syntax that establishes no runtime value, regardless of export form. */ export const isErasedJavaScriptDeclaration = (node: t.Node): boolean => @@ -52,6 +53,36 @@ export const isTypeOnlyImportSpecifier = (node: t.ImportSpecifier): boolean => export const isTypeOnlyExportSpecifier = (node: t.ExportSpecifier): boolean => node.exportKind === "type"; +/** Runtime values published by a local ESM export; source re-exports have no local value. */ +export const semanticRuntimeExportValueSources = ( + node: t.Node, +): readonly t.Node[] => { + if (t.isExportDefaultDeclaration(node)) + return isErasedJavaScriptDeclaration(node.declaration) + ? [] + : [node.declaration]; + if ( + !t.isExportNamedDeclaration(node) || + node.source != null || + isTypeOnlyExportDeclaration(node) + ) + return []; + const values: t.Node[] = []; + if (t.isVariableDeclaration(node.declaration)) + for (const declaration of node.declaration.declarations) + values.push(...semanticWriteTargets(declaration.id)); + else if ( + (t.isFunctionDeclaration(node.declaration) || + t.isClassDeclaration(node.declaration)) && + node.declaration.id != null + ) + values.push(node.declaration.id); + for (const specifier of node.specifiers) + if (t.isExportSpecifier(specifier) && !isTypeOnlyExportSpecifier(specifier)) + values.push(specifier.local); + return values; +}; + /** A regular import whose bindings are all erased but may retain side effects. */ export const hasOnlyInlineTypeImportSpecifiers = ( node: t.ImportDeclaration, diff --git a/src/domain/javascript/javascriptModuleInterop.ts b/src/domain/javascript/javascriptModuleInterop.ts index 5df0252f81..e6326d8d30 100644 --- a/src/domain/javascript/javascriptModuleInterop.ts +++ b/src/domain/javascript/javascriptModuleInterop.ts @@ -29,6 +29,7 @@ import { runtimeFunctionParameters, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { semanticKnownArrayIterationAt } from "./javascriptSemanticReferenceSources.js"; // Match the complete Rolldown runtime operations, including its live getters. // Names and formatting are irrelevant; lexical roles, operators and keys are not. @@ -82,6 +83,8 @@ export const createSemanticModuleInterop = ( readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined; }, ) => { + const nativeArrayIteration = (array: t.ArrayExpression) => + semanticKnownArrayIterationAt(array, state); const helpers = new Map(); const origins = new Map(); const calls = new WeakMap(); @@ -272,7 +275,10 @@ export const createSemanticModuleInterop = ( const { root, members } = electronMemberPath(current.callee); let bindTarget = root; let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] = - expandJavaScriptInvocationArguments(current.arguments); + expandJavaScriptInvocationArguments( + current.arguments, + nativeArrayIteration, + ); let captured = capturedIntrinsic(current.callee); let terminal = captured?.path.at(-1) === "bind"; let path: readonly string[] | undefined = captured?.path.slice(0, -1); @@ -287,6 +293,7 @@ export const createSemanticModuleInterop = ( intrinsicPath, boundIntrinsic, intrinsicReadChanged, + nativeArrayIteration, ); if ( invocation?.kind !== "known" || @@ -618,12 +625,16 @@ export const createSemanticModuleInterop = ( origin, callOrigin, intrinsicPath, - intrinsicInvocation: (node: t.CallExpression | t.OptionalCallExpression) => + intrinsicInvocation: ( + node: t.CallExpression | t.OptionalCallExpression, + callablePath = intrinsicPath, + ) => resolveJavaScriptIntrinsicInvocation( node, - intrinsicPath, + callablePath, boundIntrinsic, intrinsicReadChanged, + nativeArrayIteration, ), boundIntrinsic, staticString, diff --git a/src/domain/javascript/javascriptModuleResolutionMode.ts b/src/domain/javascript/javascriptModuleResolutionMode.ts index ee2f6edaaf..129c572271 100644 --- a/src/domain/javascript/javascriptModuleResolutionMode.ts +++ b/src/domain/javascript/javascriptModuleResolutionMode.ts @@ -1,4 +1,12 @@ import { posix } from "node:path"; +import type { JavaScriptArtifactFileKind } from "./javascriptArtifactFiles.js"; + +/** JSON dispatch uses the exact loader filename, not the inventory's folded kind. */ +export const isJsonModuleTarget = ( + path: string, + kind: JavaScriptArtifactFileKind | undefined, +): boolean => + path.endsWith(".json") && (kind === "json" || kind === "package-json"); /** Loader evidence retained for a source-level module reference. */ export type JavaScriptModuleResolutionMode = diff --git a/src/domain/javascript/javascriptPackageImportAliases.test.ts b/src/domain/javascript/javascriptPackageImportAliases.test.ts index 6f64c1d650..6794324470 100644 --- a/src/domain/javascript/javascriptPackageImportAliases.test.ts +++ b/src/domain/javascript/javascriptPackageImportAliases.test.ts @@ -75,6 +75,48 @@ describe("exact package import lookup", () => { }); }); + it.each([ + ["#bar/*", "#foo", "missing-key"], + ["#internal/*.js", "#internal/file.cjs", "missing-key"], + ["#internal/*.js", "#internal/.js", "missing-key"], + ["#internal/*end", "#internal/end", "missing-key"], + ["#internal/*/*", "#internal/a/b", "missing-key"], + ["#internal/", "#internal/a", "missing-key"], + ["#internal/*.js", "#internal/a.js", "pattern-unresolved"], + ["#internal/*.js", "#internal/deep/a.js", "pattern-unresolved"], + ["#internal/*end", "#internal/aend", "pattern-unresolved"], + ] as const)( + "matches only a single nonempty wildcard between its prefix and suffix: %s, %s", + (pattern, specifier, kind) => { + expect( + lookupExactPackageImport( + { [pattern]: "./unsupported/*.js" }, + specifier, + ), + ).toEqual({ kind, declared: [pattern] }); + }, + ); + + it.each( + [null, { require: "./config.cjs" }, ["./config.cjs"]].map((target) => ({ + target, + })), + )( + "preserves an exact target $target ahead of a matching wildcard", + ({ target }) => { + expect( + lookupExactPackageImport( + { "#config": target, "#*": "./unsupported/*.js" }, + "#config", + ), + ).toEqual({ + kind: "found", + target, + declared: ["#config", "#*"], + }); + }, + ); + it("still selects an exact key when the same map also has patterns", () => { expect( lookupExactPackageImport( diff --git a/src/domain/javascript/javascriptPackageImportAliases.ts b/src/domain/javascript/javascriptPackageImportAliases.ts index 9eab77e100..b2fe877140 100644 --- a/src/domain/javascript/javascriptPackageImportAliases.ts +++ b/src/domain/javascript/javascriptPackageImportAliases.ts @@ -1,3 +1,5 @@ +import { packageSubpathPatternMatches } from "./javascriptPackageSubpathPatterns.js"; + /** * Exact package-private import-alias lookup. Node resolves `#` specifiers * against the importer's enclosing package `imports` map; this module owns the @@ -55,9 +57,8 @@ export const isPackageImportPatternKey = (key: string): boolean => /** * Look up one exact `#` key in a package `imports` value. - * A missing exact key is reported as missing when every declared key is also - * exact; if the map contains pattern keys, the miss stays pattern-unresolved - * rather than claiming the specifier is undefined. + * Only a matching wildcard keeps an exact miss unresolved. Unrelated patterns + * cannot select a target and therefore do not hide an undefined specifier. */ export const lookupExactPackageImport = ( imports: unknown, @@ -72,7 +73,7 @@ export const lookupExactPackageImport = ( target: Reflect.get(imports, key), declared, }; - return declared.some(isPackageImportPatternKey) + return declared.some((pattern) => packageSubpathPatternMatches(pattern, key)) ? { kind: "pattern-unresolved", declared } : { kind: "missing-key", declared }; }; diff --git a/src/domain/javascript/javascriptPackageSubpathPatterns.test.ts b/src/domain/javascript/javascriptPackageSubpathPatterns.test.ts new file mode 100644 index 0000000000..24a983c329 --- /dev/null +++ b/src/domain/javascript/javascriptPackageSubpathPatterns.test.ts @@ -0,0 +1,30 @@ +import { expect, it } from "vitest"; +import { + packageSubpathPatternMatches, + selectPackageSubpathPattern, +} from "./javascriptPackageSubpathPatterns.js"; + +it.each([ + ["./*", "./window", true], + ["./*end", "./end", false], + ["./*.js", "./nested/a.js", true], + ["./*.js", "./a.cjs", false], + ["./*/*", "./nested/a", false], + ["./other/*", "./window", false], +] as const)( + "matches only the selected package pattern %s against %s", + (pattern, subpath, expected) => { + expect(packageSubpathPatternMatches(pattern, subpath)).toBe(expected); + }, +); + +it.each([ + [["./*", "./private/*"], "./private/window", "./private/*"], + [["./*", "./*.hidden"], "./window.hidden", "./*.hidden"], + [["./private/*", "./*"], "./private/window", "./private/*"], +] as const)( + "selects the most specific pattern independently of declaration order: %j", + (patterns, subpath, expected) => { + expect(selectPackageSubpathPattern(patterns, subpath)).toBe(expected); + }, +); diff --git a/src/domain/javascript/javascriptPackageSubpathPatterns.ts b/src/domain/javascript/javascriptPackageSubpathPatterns.ts new file mode 100644 index 0000000000..1c8684b34e --- /dev/null +++ b/src/domain/javascript/javascriptPackageSubpathPatterns.ts @@ -0,0 +1,33 @@ +/** Node package patterns contain one star matching at least one character. */ +export const packageSubpathPatternMatches = ( + pattern: string, + subpath: string, +): boolean => { + const wildcard = pattern.indexOf("*"); + return ( + wildcard !== -1 && + pattern.lastIndexOf("*") === wildcard && + subpath.length >= pattern.length && + subpath.startsWith(pattern.slice(0, wildcard)) && + subpath.endsWith(pattern.slice(wildcard + 1)) + ); +}; + +/** Node chooses the longest prefix, then the longest complete pattern key. */ +export const selectPackageSubpathPattern = ( + patterns: readonly string[], + subpath: string, +): string | undefined => { + let selected: string | undefined; + for (const pattern of patterns) { + if (!packageSubpathPatternMatches(pattern, subpath)) continue; + if ( + selected === undefined || + pattern.indexOf("*") > selected.indexOf("*") || + (pattern.indexOf("*") === selected.indexOf("*") && + pattern.length > selected.length) + ) + selected = pattern; + } + return selected; +}; diff --git a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts index a7e1afa736..5c3ef04f69 100644 --- a/src/domain/javascript/javascriptRuntimeStaticCandidates.ts +++ b/src/domain/javascript/javascriptRuntimeStaticCandidates.ts @@ -199,6 +199,8 @@ const nodePaths = (node: ApplicationNode): string[] => { isRawApplicationFileObservation(observation); if (typeof observation.properties.path === "string") paths.push(observation.properties.path); + const applicationPath = ownedApplicationPath(observation); + if (applicationPath !== undefined) paths.push(applicationPath); if (rawApplicationFile) continue; if (typeof observation.properties.resolved_path === "string") paths.push(observation.properties.resolved_path); @@ -231,20 +233,44 @@ const hasOwnedArtifactPath = (node: ApplicationNode): boolean => ) || node.observations.some( (observation) => - !isRawApplicationFileObservation(observation) && - typeof observation.properties.resolved_path === "string", + ownedApplicationPath(observation) !== undefined || + (!isRawApplicationFileObservation(observation) && + typeof observation.properties.resolved_path === "string"), ); -// loadFile receives a raw literal here: the syntax producer does not retain -// computed __dirname expressions. Electron resolves this relative to the app -// root, so its source-relative inference is not an owned runtime target. +// Ownership of the application base survives missing target bytes. A confined +// candidate is a path identity, not proof that a static file exists. +const ownedApplicationPath = ( + observation: ApplicationNode["observations"][number], +): string | undefined => { + const properties = observation.properties; + if (properties.runtime_path_base !== "application-root") return undefined; + if ( + properties.resolution_status === "resolved" && + typeof properties.resolved_path === "string" + ) + return properties.resolved_path; + if ( + properties.runtime_path_base_proven === true && + (properties.resolution_status === "not-found" || + properties.resolution_status === "unavailable") && + typeof properties.candidate_path === "string" + ) + return properties.candidate_path; + return undefined; +}; + +// Native declaration matching is needed only without an owned logical path. const isRawApplicationFileObservation = ( observation: ApplicationNode["observations"][number], ): boolean => - observation.properties.resolution_context === "filesystem-expression" && - typeof observation.properties.mechanism === "string" && - observation.properties.mechanism.startsWith("call:") && - observation.properties.mechanism.endsWith("loadFile"); + observation.properties.runtime_path_base === "application-root" + ? ownedApplicationPath(observation) === undefined + : // Compatibility for captures reconstructed before the producer retained its base. + observation.properties.resolution_context === "filesystem-expression" && + typeof observation.properties.mechanism === "string" && + observation.properties.mechanism.startsWith("call:") && + observation.properties.mechanism.endsWith("loadFile"); const usesApplicationFileDeclaration = (node: ApplicationNode): boolean => node.kind === "electron-renderer" && diff --git a/src/domain/javascript/javascriptSemanticAliasSelections.ts b/src/domain/javascript/javascriptSemanticAliasSelections.ts new file mode 100644 index 0000000000..6a0b409433 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticAliasSelections.ts @@ -0,0 +1,126 @@ +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; + +type PropertyKey = JavaScriptSemanticPropertyPath[number]; + +/** Finite alternatives retain selected keys without admitting unrelated slots. */ +export type SemanticAliasSelection = + | PropertyKey + | { readonly alternatives: readonly PropertyKey[] }; + +export type SemanticAliasSelectionPath = readonly SemanticAliasSelection[]; + +export const semanticAliasSelectionChoices = ( + key: SemanticAliasSelection, +): readonly PropertyKey[] => + key !== null && typeof key === "object" && "alternatives" in key + ? key.alternatives + : [key]; + +const selection = (choices: readonly PropertyKey[]): SemanticAliasSelection => { + if (choices.includes(null)) return null; + const alternatives = [ + ...new Map(choices.map((choice) => [JSON.stringify(choice), choice])), + ] + .sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0)) + .map(([, choice]) => choice); + return alternatives.length === 1 ? alternatives[0]! : { alternatives }; +}; + +const selectionCovers = ( + prior: SemanticAliasSelection, + next: SemanticAliasSelection, +): boolean => { + if (prior === null) return true; + const choices = new Set( + semanticAliasSelectionChoices(prior).map((key) => JSON.stringify(key)), + ); + return semanticAliasSelectionChoices(next).every((key) => + choices.has(JSON.stringify(key)), + ); +}; + +const pathCovers = ( + prior: SemanticAliasSelectionPath, + next: SemanticAliasSelectionPath, +): boolean => + prior.every( + (key, index) => + next[index] !== undefined && selectionCovers(key, next[index]!), + ); + +/** Only one differing selector can be joined without inventing crossed paths. */ +const joinSelections = ( + prior: SemanticAliasSelectionPath, + next: SemanticAliasSelectionPath, +): SemanticAliasSelectionPath | undefined => { + let changed = -1; + for (let index = 0; index < prior.length; index++) { + if (JSON.stringify(prior[index]) === JSON.stringify(next[index])) continue; + if (changed !== -1) return undefined; + changed = index; + } + return prior.map((key, index) => + index === changed + ? selection([ + ...semanticAliasSelectionChoices(key), + ...semanticAliasSelectionChoices(next[index]!), + ]) + : key, + ); +}; + +/** Preserve correlated selector alternatives while coalescing complete grids. */ +export class SemanticAliasSelections { + #paths = new WeakMap>(); + #calls = 0; + #limited = false; + + constructor(readonly onLimit: () => void) {} + + get limited(): boolean { + return this.#limited; + } + + normalize( + node: object, + next: SemanticAliasSelectionPath, + ): SemanticAliasSelectionPath { + if (this.#limited) return next.map(() => null); + const existing = this.#paths.get(node); + const byDepth = existing ?? new Map(); + const paths = [...(byDepth.get(next.length) ?? [])]; + let candidate = next.map((key) => + selection(semanticAliasSelectionChoices(key)), + ); + const covered = paths.find((prior) => pathCovers(prior, candidate)); + if (covered !== undefined) return covered; + if ( + existing !== undefined && + ++this.#calls > SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + this.#limited = true; + this.#paths = new WeakMap(); + this.onLimit(); + return next.map(() => null); + } + for (let index = 0; index < paths.length;) { + const prior = paths[index]!; + if (pathCovers(prior, candidate)) return prior; + const joined = pathCovers(candidate, prior) + ? candidate + : joinSelections(prior, candidate); + if (joined === undefined) { + index++; + continue; + } + candidate = [...joined]; + paths.splice(index, 1); + index = 0; + } + paths.push(candidate); + byDepth.set(candidate.length, paths); + this.#paths.set(node, byDepth); + return candidate; + } +} diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts index a9832ac959..0d785dbef1 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.test.ts @@ -24,6 +24,32 @@ it("keeps wrapper module aliases distinct from global-object module properties", ]); }); +it.each([ + ["const copy={...module};consume(copy);", false], + ["const copy={...{child:module}};consume(copy);", true], + [ + "const original={child:module};const copy={...original};consume(copy);", + true, + ], +])( + "exposes copied children without escaping the source container: %s", + (body, moduleEscaped) => { + const parsed = parseJavaScriptSource(`function copy(){${body}}`); + if (parsed === null) throw new Error("Expected copied escape fixture"); + const state = completeSemanticSteps( + indexJavaScriptBindingsSteps(parsed.program), + ); + expect( + state.ambientEscapeEffects.some( + ({ path }) => + path.length === 2 && + path[0] === "commonjs-module" && + path[1] === null, + ), + ).toBe(moduleEscaped); + }, +); + it.each([ { name: "reconverging projections with different alias-cycle histories", diff --git a/src/domain/javascript/javascriptSemanticAmbientWrites.ts b/src/domain/javascript/javascriptSemanticAmbientWrites.ts index 54bccbfc26..a3e7c6d8ff 100644 --- a/src/domain/javascript/javascriptSemanticAmbientWrites.ts +++ b/src/domain/javascript/javascriptSemanticAmbientWrites.ts @@ -22,6 +22,11 @@ import { type SemanticIterationSource, } from "./javascriptSemanticIterationSources.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { + semanticCopiedReferencePath, + semanticReferenceIterationUnknown, + semanticReferenceSourcesInactive, +} from "./javascriptSemanticReferenceSources.js"; const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"]; @@ -236,30 +241,44 @@ export const createAmbientPathResolver = ( } const binding = resolveSemanticBindingState(state, root, root.name); if (binding === undefined) continue; - const stable = semanticStableInitializer(binding, state); + const stable = possibleWrites + ? undefined + : semanticStableInitializer(binding, state); const readAt = current.readsAtSelf ? root : capture; - const initializers = possibleWrites - ? readAt === undefined - ? binding.initializers + const selected = + !possibleWrites || readAt === undefined + ? binding : semanticBindingAtRead(binding, readAt, state.parentsByNode) - .binding.initializers + .binding; + const initializers = possibleWrites + ? [...selected.initializers, ...selected.referenceInitializers] : stable === undefined ? [] : [stable]; - for (const initializer of initializers) + for (const initializer of initializers) { + if (semanticReferenceSourcesInactive(initializer, state)) continue; + // Pattern assignments share the source selection owner with member + // effects: copying a container preserves its children, not itself. + const path = semanticCopiedReferencePath( + initializer, + suffix ?? [null], + escapedContents ? "escape" : "identity", + ); + if (path === null) continue; pending.push({ node: initializer.node, readsAtSelf: true, suffix: - suffix === undefined + suffix === undefined || + semanticReferenceIterationUnknown(initializer, state) ? undefined - : [ - ...initializer.projection.map((key) => - key === null ? null : String(key), - ), - ...suffix, - ], + : path.map((key) => + key === null || typeof key === "object" + ? null + : String(key), + ), }); + } continue; } if ( @@ -332,7 +351,8 @@ export const createAmbientPathResolver = ( if (t.isObjectExpression(root)) { for (const property of root.properties) if (t.isSpreadElement(property)) - continueWith(property.argument, []); + // A fresh copy exposes selected children, not its source container. + continueWith(property.argument, [null]); else if ( t.isObjectProperty(property) && t.isExpression(property.value) @@ -433,6 +453,7 @@ export function* indexJavaScriptAmbientWritesSteps( program: t.Program, state: JavaScriptSemanticAnalysisState, observeOpaqueInvocation?: (node: t.Node) => void, + observeWriteSite?: (node: t.Node) => void, ): Generator { const ambientReads = createAmbientPathResolver(state, false); const browserReceiverMethods = new Set(); @@ -482,6 +503,7 @@ export function* indexJavaScriptAmbientWritesSteps( program, (root, kind, writeSite) => { if (kind === "opaque-call") return []; + observeWriteSite?.(writeSite); // Reassigning a local alias changes the binding, not the referenced object. if (kind === "binding") return t.isIdentifier(root) && @@ -626,8 +648,11 @@ export function* indexJavaScriptAmbientWritesSteps( for (const { node, target, browserReceiver, projection } of escapes) for (const path of ambientWrites(target, node, true, projection)) // Only proven browser APIs keep their global receiver from user code. - if (!(browserReceiver && path.length === 1 && path[0] === "global")) - state.ambientEscapes.retain([...path, null]); + if (!(browserReceiver && path.length === 1 && path[0] === "global")) { + const escaped = [...path, null]; + state.ambientEscapes.retain(escaped); + state.ambientEscapeEffects.push({ path: escaped, node }); + } // A function may call a browser method replaced later in source order. if ( [...browserReceiverMethods].some((method) => diff --git a/src/domain/javascript/javascriptSemanticAnalysis.ts b/src/domain/javascript/javascriptSemanticAnalysis.ts index 239c0a79a6..c999c0cec9 100644 --- a/src/domain/javascript/javascriptSemanticAnalysis.ts +++ b/src/domain/javascript/javascriptSemanticAnalysis.ts @@ -58,7 +58,11 @@ import { completeSemanticSteps, traverseJavaScriptAstSteps, } from "./javascriptSemanticTraversal.js"; -import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js"; +import { + semanticRequireOrigin, + observePossibleCommonJsDelegateWrite, + invalidateSemanticRequireDelegateWrites, +} from "./javascriptSemanticRequireOrigin.js"; import { semanticBundledRequireOrigin } from "./javascriptBundledRequireOrigin.js"; import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js"; import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; @@ -104,9 +108,11 @@ import { type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; import { indexCommonJsExportReceiversSteps } from "./javascriptCommonJsExportReceivers.js"; +import { createSemanticElectronAppPathChangeQuery } from "./javascriptElectronAppPathChanges.js"; import type { JavaScriptModuleResolutionMode } from "./javascriptModuleResolutionMode.js"; interface BindPatternInput { + readonly iterationSources?: JavaScriptSemanticBindingState["initializers"][number]["iterationSources"]; readonly pattern: t.Node; readonly initializer: t.Node | null; readonly scope: JavaScriptSemanticScopeState; @@ -116,6 +122,7 @@ interface BindPatternInput { readonly kind?: JavaScriptSemanticDefinition["kind"]; readonly projection?: readonly (string | number | null)[]; readonly referenceOnly?: boolean; + readonly assignmentReference?: true; readonly copyKind?: "object-rest" | "array-rest"; readonly copyProjectionOffset?: number; readonly copyExcludedKeys?: readonly string[]; @@ -123,14 +130,21 @@ interface BindPatternInput { readonly fallbackSources?: readonly { readonly node: t.Node; readonly projection: readonly (string | number | null)[]; + readonly iterationSources?: NonNullable< + JavaScriptSemanticBindingState["initializers"][number]["iterationSources"] + >; }[]; readonly requiredSources?: readonly { readonly node: t.Node; readonly projection: readonly (string | number | null)[]; + readonly iterationSources?: NonNullable< + JavaScriptSemanticBindingState["initializers"][number]["iterationSources"] + >; }[]; } interface AddBindingInput { + readonly iterationSources?: JavaScriptSemanticBindingState["initializers"][number]["iterationSources"]; readonly state: JavaScriptSemanticAnalysisState; readonly scope: JavaScriptSemanticScopeState; readonly name: string; @@ -177,6 +191,7 @@ function* indexSemanticAmbientWritesSteps( state.program, state, createSemanticOpaqueGlobalObserver(state), + (node) => observePossibleCommonJsDelegateWrite(node, state), ); if (state.opaqueEffects.globalSites.length > 0) clearSemanticValueCaches(state); @@ -196,6 +211,7 @@ function* indexSemanticMemberMutationFactsSteps( referenceEffectsMayHaveChanged(state, site, reference, path), ); indexedMemberMutationStates.add(state); + invalidateSemanticRequireDelegateWrites(state); } /** One owner projects source-site effects for complete reports or early exit. */ @@ -420,6 +436,9 @@ export function* analyzeParsedJavaScriptSemanticsSteps( [...state.returnedReferenceWidenings].map(range), ), limitations: [ + ...(state.valueResourceLimits.has("alias-selections") + ? [semanticResourceLimitReason("alias-selections")] + : []), ...(state.returnedReferenceWidenings.size > 0 ? [RETURNED_REFERENCE_WIDENING_LIMITATION] : []), @@ -801,6 +820,10 @@ export interface JavaScriptElectronIdentity { readonly bindings: ReadonlyMap; readonly intactExport: (path: readonly (string | null)[]) => boolean; readonly exportPath: (root: t.Node) => readonly string[] | undefined; + readonly invocationPath: ( + node: t.CallExpression | t.OptionalCallExpression, + ) => readonly string[] | undefined; + readonly mayChangeAppPath: (node: t.Node) => boolean; } export function* indexJavaScriptElectronIdentitySteps( @@ -881,7 +904,35 @@ export function* indexJavaScriptElectronIdentitySteps( facts.set(root.start ?? -1, exported); }, }); - return { bindings: facts, intactExport, exportPath: rootExport }; + const invocationPath = ( + node: t.CallExpression | t.OptionalCallExpression, + ): readonly string[] | undefined => { + const invocation = interop.intrinsicInvocation(node, (callee) => { + const { root, members } = electronMemberPath(callee); + const exported = rootExport(root); + if ( + exported !== undefined && + members.every((member): member is string => member !== null) && + intactExport(["electron", ...exported, ...members]) + ) + return ["electron", ...exported, ...members]; + return interop.intrinsicPath(callee); + }); + return invocation?.path[0] === "electron" + ? invocation.path.slice(1) + : undefined; + }; + return { + bindings: facts, + intactExport, + exportPath: rootExport, + invocationPath, + mayChangeAppPath: createSemanticElectronAppPathChangeQuery( + state, + rootExport, + interop, + ), + }; } // Read import and require origins directly; general value evaluation is @@ -951,6 +1002,7 @@ const createState = ( bindings: new Map(), }; return { + arrayIterationUnknown: false, program, opaqueEffects: { mayReferenceGlobalEval: false, @@ -983,6 +1035,7 @@ const createState = ( ambientBindingWrites: new Map(), ambientWrites: new SemanticPropertyPathCoverage(), ambientEscapes: new SemanticPropertyPathCoverage(), + ambientEscapeEffects: [], ambientWriteEffects: [], }; }; @@ -1433,6 +1486,9 @@ const bindAssignment = ( ): void => { if (t.isAssignmentExpression(node)) { const left = unwrapJavaScriptExpression(node.left).node; + const selectedPattern = + node.operator === "=" && + (t.isObjectPattern(left) || t.isArrayPattern(left)); if (t.isIdentifier(left)) addAssignment( left, @@ -1446,17 +1502,28 @@ const bindAssignment = ( conditionallySkipped, node, ); - else + else { for (const identifier of semanticWriteTargets(node.left)) if (t.isIdentifier(identifier)) addAssignment( identifier, - node, + selectedPattern ? null : node, scope, state, conditionallySkipped, node, ); + if (selectedPattern) + bindPattern({ + pattern: left, + initializer: node.right, + scope, + state, + mutable: true, + referenceOnly: true, + assignmentReference: true, + }); + } } else if (t.isUpdateExpression(node)) { const argument = unwrapJavaScriptExpression(node.argument).node; if (t.isIdentifier(argument)) @@ -1677,12 +1744,14 @@ const bindPattern = (input: BindPatternInput): void => { kind = "variable", projection = [], referenceOnly = false, + assignmentReference, copyKind, copyProjectionOffset, copyExcludedKeys, copyStartIndex, fallbackSources, requiredSources, + iterationSources, } = input; if (t.isTSParameterProperty(pattern)) { bindPattern({ ...input, pattern: pattern.parameter }); @@ -1699,6 +1768,7 @@ const bindPattern = (input: BindPatternInput): void => { binding.referenceInitializers.push({ node: initializer, projection, + ...(assignmentReference === undefined ? {} : { assignment: true }), ...(copyKind === undefined ? {} : { copyKind }), ...(copyProjectionOffset === undefined ? {} @@ -1707,6 +1777,7 @@ const bindPattern = (input: BindPatternInput): void => { ...(copyStartIndex === undefined ? {} : { copyStartIndex }), ...(fallbackSources === undefined ? {} : { fallbackSources }), ...(requiredSources === undefined ? {} : { requiredSources }), + ...(iterationSources === undefined ? {} : { iterationSources }), }); return; } @@ -1720,6 +1791,7 @@ const bindPattern = (input: BindPatternInput): void => { definitionNode: pattern, initializer, projection, + ...(iterationSources === undefined ? {} : { iterationSources }), }); return; } @@ -1733,7 +1805,15 @@ const bindPattern = (input: BindPatternInput): void => { : (initializer ?? pattern.right), requiredSources: [ ...(requiredSources ?? []), - ...(initializer === null ? [] : [{ node: initializer, projection }]), + ...(initializer === null + ? [] + : [ + { + node: initializer, + projection, + ...(iterationSources === undefined ? {} : { iterationSources }), + }, + ]), ], }); bindPattern({ @@ -1745,9 +1825,18 @@ const bindPattern = (input: BindPatternInput): void => { initializer: pattern.right, projection: [], referenceOnly: true, + ...(assignmentReference === undefined ? {} : { assignmentReference }), fallbackSources: [ ...(fallbackSources ?? []), - ...(initializer === null ? [] : [{ node: initializer, projection }]), + ...(initializer === null + ? [] + : [ + { + node: initializer, + projection, + ...(iterationSources === undefined ? {} : { iterationSources }), + }, + ]), ], ...(requiredSources === undefined ? {} : { requiredSources }), }); @@ -1790,17 +1879,28 @@ const bindPattern = (input: BindPatternInput): void => { }); } } - else if (t.isArrayPattern(pattern)) + else if (t.isArrayPattern(pattern)) { + const arrayInput = { + ...input, + ...(initializer === null + ? {} + : { + iterationSources: [ + ...(iterationSources ?? []), + { node: initializer, projection }, + ], + }), + }; pattern.elements.forEach((element, index) => { if (element !== null) { bindPattern({ - ...input, + ...arrayInput, pattern: element, projection: [...projection, index], }); if (t.isRestElement(element)) bindPattern({ - ...input, + ...arrayInput, pattern: element.argument, projection: [...projection, null], referenceOnly: true, @@ -1810,6 +1910,7 @@ const bindPattern = (input: BindPatternInput): void => { }); } }); + } }; const addBinding = (input: AddBindingInput): void => { @@ -1825,6 +1926,7 @@ const addBinding = (input: AddBindingInput): void => { directOrigin, runtimeImport, projection = [], + iterationSources, } = input; let binding = scope.bindings.get(name); if (binding === undefined) { @@ -1838,7 +1940,11 @@ const addBinding = (input: AddBindingInput): void => { binding.declarationKinds.add(declarationKind); binding.definitions.push({ kind, location: range(definitionNode) }); if (initializer !== null) - binding.initializers.push({ node: initializer, projection }); + binding.initializers.push({ + node: initializer, + projection, + ...(iterationSources === undefined ? {} : { iterationSources }), + }); if (directOrigin !== undefined) binding.directOrigins.push( directOrigin.resolutionMode === undefined && @@ -1875,12 +1981,14 @@ const createBinding = ( const addAssignment = ( identifier: t.Identifier, - initializer: t.Node, + initializer: t.Node | null, scope: JavaScriptSemanticScopeState, state: JavaScriptSemanticAnalysisState, conditionallySkipped: boolean, writeSite?: t.Node, ): void => { + const mutation = writeSite ?? initializer; + if (mutation === null) return; const lookup = semanticBindingLookupFromScope(scope, identifier.name, state); if (lookup.kind === "captured") return; const binding = "binding" in lookup ? lookup.binding : undefined; @@ -1890,16 +1998,17 @@ const addAssignment = ( // Unbound identifiers can denote host wrapper bindings (such as require), // which are separate from properties written through the global object. const writes = state.ambientBindingWrites.get(identifier.name) ?? []; - writes.push(writeSite ?? initializer); + writes.push(mutation); state.ambientBindingWrites.set(identifier.name, writes); return; } const writes = state.bindingWrites.get(binding.bindingId) ?? []; - writes.push(writeSite ?? initializer); + writes.push(mutation); state.bindingWrites.set(binding.bindingId, writes); binding.mutable = true; binding.definitions.push({ kind: "assignment", location: range(identifier) }); - binding.initializers.push({ node: initializer, projection: [] }); + if (initializer !== null) + binding.initializers.push({ node: initializer, projection: [] }); if (conditionallySkipped) - state.conditionallySkippedExpressions.add(initializer); + state.conditionallySkippedExpressions.add(initializer ?? mutation); }; diff --git a/src/domain/javascript/javascriptSemanticCallResolution.test.ts b/src/domain/javascript/javascriptSemanticCallResolution.test.ts index 54e1dc0ef7..876bf4cde2 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.test.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.test.ts @@ -1,7 +1,10 @@ import * as t from "@babel/types"; import { expect, it, vi } from "vitest"; -import { indexJavaScriptBindingsSteps } from "./javascriptSemanticAnalysis.js"; +import { + analyzeJavaScriptSemantics, + indexJavaScriptBindingsSteps, +} from "./javascriptSemanticAnalysis.js"; import { resolveLocalCallables, type LocalCallableResolution, @@ -37,6 +40,88 @@ const indexed = (source: string) => { }; }; +it.each([ + "if(flag)({make}={make:replacement});", + "if(flag)[make]=[replacement];", + "({make}={make:replacement});", + "[make]=[replacement];", +])("does not prove the old callable after a projected write: %s", (write) => { + const analysis = analyzeJavaScriptSemantics( + `function original(){}function replacement(){}let make=original;make();${write}make();`, + ); + const original = analysis.callables.find(({ name }) => name === "original"); + expect(original).toBeDefined(); + const [before, after] = analysis.callSites; + expect(before).toMatchObject({ + resolution: "exact", + calleeCallableIds: [original?.callableId], + }); + expect(after).toBeDefined(); + expect(after?.resolution).not.toBe("exact"); + expect(analysis.frontiers).toContainEqual( + expect.objectContaining({ + kind: "dynamic-call", + location: after?.calleeLocation, + }), + ); +}); + +it.each([ + "if(flag)({make}={make:replacement});", + "if(flag)[make]=[replacement];", +])( + "retains known callable candidates without claiming completeness: %s", + (write) => { + const context = indexed( + `function original(){}function replacement(){}let make=original;${write}make();`, + ); + const last = context.state.program.body.at(-1); + if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression)) + throw new Error("Missing projected call"); + const original = context.state.callables.find( + ({ name }) => name === "original", + ); + expect(original).toBeDefined(); + expect(context.resolve(last.expression.callee)).toMatchObject({ + complete: false, + callableIds: [original?.callableId], + }); + }, +); + +it("retains selected callable keys without admitting an unrelated return", () => { + const context = indexed(` + const table = { x() { return {}; }, y() { return {}; }, z() { return module; } }; + const make = flag ? table.x : table.y; + make(); + `); + const last = context.state.program.body.at(-1); + if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression)) + throw new Error("Missing selected call"); + const returns = createSemanticReturnedReferences(context.state)( + last.expression.callee, + ).references; + expect(returns).toHaveLength(2); + expect(returns.every(({ node }) => t.isObjectExpression(node))).toBe(true); +}); + +it("retains correlations between selected callable path segments", () => { + const context = indexed(` + const table = { a: { b() { return {}; }, d() { return module; } }, + c: { d() { return {}; }, b() { return module; } } }; + const make = flag ? table.a.b : table.c.d; + make(); + `); + const last = context.state.program.body.at(-1); + if (!t.isExpressionStatement(last) || !t.isCallExpression(last.expression)) + throw new Error("Missing selected call"); + const returns = createSemanticReturnedReferences(context.state)( + last.expression.callee, + ).references; + expect(returns).toHaveLength(2); + expect(returns.every(({ node }) => t.isObjectExpression(node))).toBe(true); +}); + it("bounds shared partial callback alias expansion by AST size", () => { const measure = (depth: number) => { const context = diff --git a/src/domain/javascript/javascriptSemanticCallResolution.ts b/src/domain/javascript/javascriptSemanticCallResolution.ts index 4ca40cc9f6..25a9b9ecda 100644 --- a/src/domain/javascript/javascriptSemanticCallResolution.ts +++ b/src/domain/javascript/javascriptSemanticCallResolution.ts @@ -6,7 +6,10 @@ import type { } from "./javascriptSemanticIr.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; import { semanticCallableIdForNode } from "./javascriptSemanticProjection.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticEffectTiming, +} from "./javascriptSemanticMutationInitializers.js"; import { unwrapJavaScriptExpression, runtimeFunctionParameters, @@ -119,6 +122,7 @@ const resolveIdentifier = ( }; const candidates = resolveBindingCallables( read.binding, + node, selectsAll ? { ...context, @@ -168,6 +172,7 @@ const resolveAlternatives = ( const resolveBindingCallables = ( binding: JavaScriptSemanticBindingState, + site: t.Identifier, context: CallResolutionContext, ): LocalCallableResolution => { if (binding.initializers.length === 0) @@ -199,11 +204,21 @@ const resolveBindingCallables = ( const callableIds = uniqueCallableIds( candidates.flatMap((candidate) => candidate.callableIds), ); + // Pattern writes are owned reference facts, not ordinary initializers. A + // read may retain both an earlier callable and a conditional projected + // replacement. Until projected callable resolution is supported here, + // preserve known candidates without claiming they cover every target. + const complete = + !binding.referenceInitializers.some( + (initializer) => + initializer.assignment && + semanticEffectTiming(site, initializer.node, context.state) !== "after", + ) && candidates.every(({ complete }) => complete); const resolution = { callableIds, - complete: candidates.every(({ complete }) => complete), + complete, reason: - callableIds.length === 1 && candidates.every(({ complete }) => complete) + callableIds.length === 1 && complete ? "Unique local callable target." : `Call target ${binding.name} has ambiguous or unsupported assignments.`, ...(candidates.some((candidate) => candidate.cycleDependent) diff --git a/src/domain/javascript/javascriptSemanticCoverage.ts b/src/domain/javascript/javascriptSemanticCoverage.ts index cd1d55fb9d..6d73d3c5f3 100644 --- a/src/domain/javascript/javascriptSemanticCoverage.ts +++ b/src/domain/javascript/javascriptSemanticCoverage.ts @@ -4,6 +4,7 @@ import type { ApplicationCoverage } from "./javascriptApplicationEvidenceSchemas import { SEMANTIC_EXPRESSION_DEPTH_LIMIT, SEMANTIC_GETTER_EXPANSION_LIMIT, + SEMANTIC_ALIAS_SELECTION_LIMIT, SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT, SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT, } from "./javascriptSemanticResourceLimits.js"; @@ -91,6 +92,10 @@ const semanticResourceBounds: Record< value: SEMANTIC_GETTER_EXPANSION_LIMIT, unit: "items", }, + "alias-selections": { + value: SEMANTIC_ALIAS_SELECTION_LIMIT, + unit: "items", + }, }; /** Map semantic evaluator bounds to their caller-visible application coverage. */ diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index fcc1d0b99d..6c0f3c210d 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -161,22 +161,31 @@ export const builtinDataEffectMethod = ( state: JavaScriptSemanticAnalysisState, specifiers: readonly string[], ): string | null => { + const path = builtinDataEffectCallablePath(callee, state, specifiers); + return path?.length === 1 ? (path[0] ?? null) : null; +}; + +/** Recover the complete callable export path using the shared mutation proof. */ +export const builtinDataEffectCallablePath = ( + callee: t.Node, + state: JavaScriptSemanticAnalysisState, + specifiers: readonly string[], +): readonly string[] | null => { const expression = unwrapJavaScriptExpression(callee).node; if (expression !== callee) - return builtinDataEffectMethod(expression, state, specifiers); + return builtinDataEffectCallablePath(expression, state, specifiers); if (t.isSequenceExpression(expression)) { const final = expression.expressions.at(-1); return final === undefined ? null - : builtinDataEffectMethod(final, state, specifiers); + : builtinDataEffectCallablePath(final, state, specifiers); } const required = semanticRequireOrigin(callee, state); if (required !== undefined) { const origin = builtinNamespaceOrigin(required.origin); - return origin.importedPath.length === 1 && - specifiers.some( - (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, - ) && + return specifiers.some( + (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, + ) && !requiredModuleChanged( required.origin.specifier, required.origin.importedPath, @@ -184,7 +193,7 @@ export const builtinDataEffectMethod = ( state, true, ) - ? (origin.importedPath[0] ?? null) + ? origin.importedPath : null; } if (t.isIdentifier(callee)) { @@ -197,7 +206,7 @@ export const builtinDataEffectMethod = ( [], true, ); - return path?.length === 1 ? (path[0] ?? null) : null; + return path; } if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return null; @@ -227,27 +236,26 @@ export const builtinDataEffectMethod = ( callee, [...receiver.path, method], true, - )?.length === 0 - ? method + ) !== null + ? [...origin.importedPath, method] : null; } } const source = semanticExpressionModuleOrigin(callee.object, state); if (method === null || source === undefined) return null; const origin = builtinNamespaceOrigin(source); - return origin.importedPath.length === 0 && - specifiers.some( - (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, - ) && + return specifiers.some( + (specifier) => specifier.replace(/^node:/u, "") === origin.specifier, + ) && !requiredModuleChanged( origin.specifier, - [...origin.importedPath, method], + [...source.importedPath, method], callee, state, true, callee.object, ) - ? method + ? [...origin.importedPath, method] : null; } const binding = resolveSemanticBindingState( @@ -265,7 +273,7 @@ export const builtinDataEffectMethod = ( [method], true, ); - if (resolvedPath?.length === 0) return method; + if (resolvedPath !== null) return [...resolvedPath, method]; const initializer = binding?.initializers[0]; if ( initializer === undefined || @@ -298,7 +306,7 @@ export const builtinDataEffectMethod = ( true, initializer.node, ) - ? method + ? [method] : null; }; @@ -588,6 +596,9 @@ const affectsBuiltinPath = ( const BUILTIN_RECEIVER_METHODS: Readonly>> = { + path: new Set(["resolve", "join", "dirname"]), + "path/posix": new Set(["resolve", "join", "dirname"]), + url: new Set(["fileURLToPath"]), fs: new Set([ "readFile", "readFileSync", @@ -762,7 +773,7 @@ const computeOriginalBuiltinReceiver = ( ): boolean => { if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) return false; - const callee = node.callee; + const callee = unwrapJavaScriptExpression(node.callee).node; if (!t.isMemberExpression(callee) && !t.isOptionalMemberExpression(callee)) return false; const receiver = t.isIdentifier(callee.object) @@ -780,14 +791,21 @@ const computeOriginalBuiltinReceiver = ( semanticGlobalProcessOrigin(callee.object, state)); if (sourceOrigin === undefined) return false; const origin = builtinNamespaceOrigin(sourceOrigin); - if (origin.importedPath.length !== 0) return false; + const posixReceiver = + origin.specifier === "path" && + origin.importedPath.length === 1 && + origin.importedPath[0] === "posix"; + if (origin.importedPath.length !== 0 && !posixReceiver) return false; const method = semanticStaticPropertyKey(callee.property, callee.computed); const identity = semanticRequireExportsIdentity(origin.specifier, [ + ...origin.importedPath, method ?? "", ]); if ( method === null || - !BUILTIN_RECEIVER_METHODS[origin.specifier]?.has(method) || + !BUILTIN_RECEIVER_METHODS[ + posixReceiver ? "path/posix" : origin.specifier + ]?.has(method) || receiver?.memberWrites.some( ({ path, node: write }) => affectsBuiltinPath(path, [method]) && diff --git a/src/domain/javascript/javascriptSemanticDataEffects.ts b/src/domain/javascript/javascriptSemanticDataEffects.ts index a98ed5d729..69deae89cc 100644 --- a/src/domain/javascript/javascriptSemanticDataEffects.ts +++ b/src/domain/javascript/javascriptSemanticDataEffects.ts @@ -21,7 +21,10 @@ import { type JavaScriptSemanticAnalysisState, type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; +import { + semanticBindingAtRead, + semanticEffectTiming, +} from "./javascriptSemanticMutationInitializers.js"; import { builtinDataEffectMethod as builtinMethod, dataEffectArgumentBindingId as argumentBindingId, @@ -254,11 +257,35 @@ const soleWriteReachesRead = ( binding: JavaScriptSemanticBindingState | undefined, read: t.Node, context: TraversalContext, -): boolean => - binding?.initializers.length === 1 && - (semanticStableInitializer(binding, context.state) !== undefined || - semanticBindingAtRead(binding, read, context.state.parentsByNode) - .hasDefiniteInitializer); + request: t.Node | undefined, +): boolean => { + if (binding === undefined || request === undefined) return false; + const snapshot = semanticBindingAtRead( + binding, + read, + context.state.parentsByNode, + ); + const initializers = snapshot.binding.initializers.filter( + ({ node }) => semanticEffectTiming(read, node, context.state) !== "after", + ); + const [initializer] = initializers; + if (initializers.length !== 1 || initializer === undefined) return false; + // The result itself must be this request. Containing a request in a branch + // or child allocation does not prove the entire initializer is its response. + let source = unwrapJavaScriptExpression(initializer.node).node; + while (t.isAwaitExpression(source)) + source = unwrapJavaScriptExpression(source.argument).node; + return ( + source === request && + !snapshot.binding.referenceInitializers.some( + ({ assignment, node }) => + assignment && + semanticEffectTiming(read, node, context.state) !== "after", + ) && + (semanticStableInitializer(binding, context.state) !== undefined || + snapshot.hasDefiniteInitializer) + ); +}; const collectResponseConsumers = ( program: t.Program, @@ -308,7 +335,12 @@ const collectResponseConsumers = ( ) ? linked[0] : undefined; - const soleWrite = soleWriteReachesRead(binding, member.object, context); + const soleWrite = soleWriteReachesRead( + binding, + member.object, + context, + uniqueRequest?.node, + ); output.push({ requestId: `response:${String(node.start ?? -1)}:${String(node.end ?? -1)}`, kind: "response-consumer", diff --git a/src/domain/javascript/javascriptSemanticFunctionEscapes.test.ts b/src/domain/javascript/javascriptSemanticFunctionEscapes.test.ts new file mode 100644 index 0000000000..f203bff5a8 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticFunctionEscapes.test.ts @@ -0,0 +1,47 @@ +import { expect, it } from "vitest"; + +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; + +const indexedBindings = (body: string) => { + const parsed = parseJavaScriptSource( + `const shared={};const get=()=>shared;${body}`, + ); + if (parsed === null) throw new Error("Expected callback escape fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + return [...index.bindings.bindingsById.values()]; +}; + +it.each([ + ["consume(get);", true], + ["consume(()=>shared);", true], + ["const alias=get;consume(alias);", true], + ["consume(()=>({}));", false], + ["const local=()=>({});consume(local);", false], + ["const local=()=>({});const alias=local;consume(alias);", false], + ["get.own=1;", false], + ["const local=()=>({});get.call=local;consume(get);", true], + ["const local=()=>({});get.call=local;consume(get.call);", false], +])("preserves callback result escape sources through %s", (body, escaped) => { + const bindings = indexedBindings(body); + const shared = bindings.find(({ name }) => name === "shared"); + expect(shared).toBeDefined(); + expect((shared?.escapedPaths.length ?? 0) > 0).toBe(escaped); +}); + +it.each(["call", "apply", "other"])( + "keeps a function's own %s write separate from its returned object", + (member) => { + const bindings = indexedBindings(`get.${member}=()=>({});`); + const get = bindings.find(({ name }) => name === "get"); + const shared = bindings.find(({ name }) => name === "shared"); + expect(get?.memberWrites.map(({ path }) => path)).toContainEqual([member]); + expect(shared?.memberWrites).toEqual([]); + expect(shared?.escapedPaths).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptSemanticIterationSources.ts b/src/domain/javascript/javascriptSemanticIterationSources.ts index 0fc3962b26..debe56235e 100644 --- a/src/domain/javascript/javascriptSemanticIterationSources.ts +++ b/src/domain/javascript/javascriptSemanticIterationSources.ts @@ -4,20 +4,24 @@ import { semanticObjectPatternKeys, runtimeFunctionParameters, semanticStaticPropertyKey, + unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticArrayIndex, type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { semanticCopiedReferencePath } from "./javascriptSemanticReferenceSources.js"; interface IterationSourceValue { readonly node: t.Node; readonly projection: JavaScriptSemanticPropertyPath; + readonly iterationSources?: readonly IterationSourceValue[]; } /** An iterator receiver or property reference store, with default guards. */ export interface SemanticIterationSource extends IterationSourceValue { + readonly iterationSources?: readonly IterationSourceValue[]; readonly kind: "iteration" | "reference-store"; readonly mutation: t.Node; readonly target?: t.MemberExpression; @@ -30,7 +34,44 @@ interface PatternSource extends IterationSourceValue { readonly restStartIndex?: number; } +/** Select an assigned slot while retaining fresh rest-copy identity and offsets. */ +export const semanticStoredSourceSelection = ( + source: SemanticIterationSource, + selected: JavaScriptSemanticPropertyPath, +): { + readonly projection: JavaScriptSemanticPropertyPath; + readonly capturedSelectionDepth?: number; + readonly ownEnumerable?: true; +} | null => { + const copy = source.projection.at(-1); + if (copy === null || typeof copy !== "object") + return { projection: [...source.projection, ...selected] }; + const projection = semanticCopiedReferencePath( + { + projection: source.projection, + copyProjectionOffset: source.projection.length - 1, + copyKind: copy.startIndex === undefined ? "object-rest" : "array-rest", + copyExcludedKeys: copy.excludedKeys, + ...(copy.startIndex === undefined + ? {} + : { copyStartIndex: copy.startIndex }), + }, + selected, + "identity", + ); + return projection === null + ? null + : { + projection, + capturedSelectionDepth: source.projection.length, + ...(copy.startIndex === undefined + ? { ownEnumerable: true as const } + : {}), + }; +}; + interface SourceGuards { + readonly iterationSources?: readonly IterationSourceValue[]; readonly fallbackSources?: readonly IterationSourceValue[]; readonly requiredSources?: readonly IterationSourceValue[]; } @@ -79,6 +120,7 @@ const visit = ( source: PatternSource | undefined, guards: SourceGuards = {}, ): void => { + pattern = unwrapJavaScriptExpression(pattern).node; const { sources, mutation } = context; if (t.isMemberExpression(pattern)) { // Property stores have no lexical alias binding. Preserve the selected @@ -111,7 +153,16 @@ const visit = ( ...guards, requiredSources: [ ...(guards.requiredSources ?? []), - ...(source === undefined ? [] : [source]), + ...(source === undefined + ? [] + : [ + { + ...source, + ...(guards.iterationSources === undefined + ? {} + : { iterationSources: guards.iterationSources }), + }, + ]), ], }); visit( @@ -122,7 +173,16 @@ const visit = ( ...guards, fallbackSources: [ ...(guards.fallbackSources ?? []), - ...(source === undefined ? [] : [source]), + ...(source === undefined + ? [] + : [ + { + ...source, + ...(guards.iterationSources === undefined + ? {} + : { iterationSources: guards.iterationSources }), + }, + ]), ], }, ); @@ -168,6 +228,13 @@ const visit = ( if (t.isArrayPattern(pattern)) { if (source !== undefined && source.restStartIndex === undefined) add(sources, source, mutation, guards); + const selectedGuards = { + ...guards, + iterationSources: [ + ...(guards.iterationSources ?? []), + ...(source === undefined ? [] : [source]), + ], + }; pattern.elements.forEach((element, index) => { if (element === null) return; if (t.isRestElement(element)) @@ -181,9 +248,9 @@ const visit = ( projection: source.projection, restStartIndex: (source.restStartIndex ?? 0) + index, }, - guards, + selectedGuards, ); - else visit(context, element, project(source, index), guards); + else visit(context, element, project(source, index), selectedGuards); }); return; } diff --git a/src/domain/javascript/javascriptSemanticMemberMutations.ts b/src/domain/javascript/javascriptSemanticMemberMutations.ts index 872d8bb46c..fc26d58a63 100644 --- a/src/domain/javascript/javascriptSemanticMemberMutations.ts +++ b/src/domain/javascript/javascriptSemanticMemberMutations.ts @@ -8,6 +8,7 @@ import { semanticMutableBuiltinOrigin, semanticRequireExportsIdentity, semanticRequireOrigin, + hasSemanticCreateRequireFactoryOrigin, } from "./javascriptSemanticRequireOrigin.js"; import { @@ -23,16 +24,22 @@ import { } from "./javascriptSemanticValues.js"; import { semanticContainer } from "./javascriptSemanticSlots.js"; import { + semanticArrayPropertyReferences, semanticClassCandidates, semanticClassPropertyReferences, semanticObjectPropertyReferences, type JavaScriptSemanticObjectReference, } from "./javascriptSemanticObjectReferences.js"; -import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; +import { + semanticIterationSources, + type SemanticIterationSource, +} from "./javascriptSemanticIterationSources.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; import { semanticMutationInitializers } from "./javascriptSemanticMutationInitializers.js"; import { + semanticCopiedReferencePath, + semanticKnownArrayIteration, semanticReferenceSourcesInactive, semanticSelectedReferenceSlot, } from "./javascriptSemanticReferenceSources.js"; @@ -54,6 +61,23 @@ import { type PropertyPath = JavaScriptSemanticPropertyPath; type ValueEffect = "write" | "escape"; +/** Only replacing or possibly selecting an iterator-bearing prototype slot changes iteration. */ +const arrayPrototypeIterationMayChange = (path: PropertyPath): boolean => { + // Array.prototype and Object.prototype are on native arrays' prototype + // chain. Their default constructor.prototype pairs return to the same owner. + let offset = 0; + while (path[offset] === "constructor" && path[offset + 1] === "prototype") + offset += 2; + const key = path[offset]; + return ( + key === undefined || + key === null || + typeof key === "object" || + semanticArrayIndex(key) !== null || + key === "__proto__" + ); +}; + /** Preserve uncertainty from property writes and references exposed to calls, yielding during traversal and fixpoint iteration. */ /** Setter keys of one object literal; a computed setter key may match any key. */ @@ -198,7 +222,7 @@ export function* collectSemanticMemberMutationsSteps( recordRequiredEffect("node:process", path.slice(2), "write", node); const selections = new Map(); const coveringPaths = new Map(); - let arrayIterationUnknown = false; + state.arrayIterationUnknown = false; // Definition collection finishes before this phase, so a scope's bindings no // longer change and one node resolves to one binding. Repeated visits of the // same node otherwise repeat the same scope walk. @@ -278,11 +302,21 @@ export function* collectSemanticMemberMutationsSteps( source.node.start, source.node.end, source.projection, + source.iterationSources?.map((iterable) => [ + iterable.node.start, + iterable.node.end, + iterable.projection, + ]), ]), reference.fallbackSources?.map((source) => [ source.node.start, source.node.end, source.projection, + source.iterationSources?.map((iterable) => [ + iterable.node.start, + iterable.node.end, + iterable.projection, + ]), ]), [...reference.bindings].sort(compareUnicodeCodePoints), ]); @@ -300,6 +334,7 @@ export function* collectSemanticMemberMutationsSteps( originAt: t.Node | undefined = mutation, ownerInitializer?: t.Node, widenedGetterResult = false, + assignedSources?: readonly SemanticIterationSource[], ): void => { const pending: (ReferencedValue & { readonly bindings: ReadonlySet; @@ -351,15 +386,18 @@ export function* collectSemanticMemberMutationsSteps( mutation, ); if ( - (expression.name === "Array" && - (current.path[0] === "prototype" || current.path[0] === null)) || + (["Array", "Object"].includes(expression.name) && + (current.path[0] === "prototype" || current.path[0] === null) && + arrayPrototypeIterationMayChange(current.path.slice(1))) || (["globalThis", "global", "window", "self"].includes( expression.name, ) && - (current.path[0] === "Array" || current.path[0] === null) && - (current.path[1] === "prototype" || current.path[1] === null)) + (["Array", "Object"].includes(String(current.path[0])) || + current.path[0] === null) && + (current.path[1] === "prototype" || current.path[1] === null) && + arrayPrototypeIterationMayChange(current.path.slice(2))) ) - arrayIterationUnknown = true; + state.arrayIterationUnknown = true; continue; } // An alias restored from a saved reference can revisit the same binding @@ -487,7 +525,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.ownerInitializer?.end ?? -1}\u0001${widenedGetterResult}\u0001${assignedSources?.[0]?.target?.start ?? -1}`; let covering = coveringPaths.get(pathState); if (covering === undefined) { covering = new SemanticPropertyPathCoverage(); @@ -521,9 +559,29 @@ export function* collectSemanticMemberMutationsSteps( mutation, ); } + const receiverInitializers = [ + ...origins.initializers, + ...origins.referenceInitializers, + ]; if (effect === "escape") - binding.escapedPaths.push({ path, node: mutation }); - else binding.memberWrites.push({ path, node: mutation }); + binding.escapedPaths.push({ + path, + node: mutation, + receiverInitializers, + ...(current.originAt === undefined + ? {} + : { receiverAt: current.originAt }), + }); + else + binding.memberWrites.push({ + path, + node: mutation, + receiverInitializers, + ...(current.originAt === undefined + ? {} + : { receiverAt: current.originAt }), + ...(assignedSources === undefined ? {} : { assignedSources }), + }); clearSemanticValueCaches(state); for (const initializer of origins.initializers) pending.push({ @@ -534,7 +592,11 @@ export function* collectSemanticMemberMutationsSteps( }); } for (const initializer of origins.referenceInitializers) { - const copiedPath = copiedReferencePath(initializer, path, effect); + const copiedPath = semanticCopiedReferencePath( + initializer, + path, + effect, + ); if (copiedPath === null) continue; pendingReferences.push({ initializer, @@ -617,10 +679,14 @@ export function* collectSemanticMemberMutationsSteps( ): readonly ReferencedValue[] { if ( t.isArrayExpression(expression) && - ((path[0] === "__proto__" && (effect === "escape" || path.length > 1)) || - (path[0] === "constructor" && path[1] === "prototype")) + ((path[0] === "__proto__" && + (effect === "escape" || path.length > 1) && + arrayPrototypeIterationMayChange(path.slice(1))) || + (path[0] === "constructor" && + path[1] === "prototype" && + arrayPrototypeIterationMayChange(path.slice(2)))) ) - arrayIterationUnknown = true; + state.arrayIterationUnknown = true; const references = referencedValues( expression, path, @@ -732,6 +798,21 @@ export function* collectSemanticMemberMutationsSteps( markEscaped(expression.object, mutation); }; const markTarget = (node: t.Node, mutation: t.Node): void => { + const sources = semanticIterationSources( + mutation, + state.parentsByNode.get(mutation) ?? null, + ); + const sourcesByTarget = new Map< + t.MemberExpression | t.OptionalMemberExpression, + SemanticIterationSource[] + >(); + for (const source of sources) { + if (source.kind !== "reference-store" || source.target === undefined) + continue; + const selected = sourcesByTarget.get(source.target) ?? []; + selected.push(source); + sourcesByTarget.set(source.target, selected); + } for (const target of semanticWriteTargets(node)) if (!t.isIdentifier(target)) markValue( @@ -740,6 +821,10 @@ export function* collectSemanticMemberMutationsSteps( new Set(), "write", mutation, + mutation, + undefined, + false, + sourcesByTarget.get(target) ?? [], ); }; yield* traverseJavaScriptAstSteps(program, { @@ -798,7 +883,7 @@ export function* collectSemanticMemberMutationsSteps( ) { const effectsBefore = recordedEffectCount; const iterablesBefore = iterableReferences.length; - const iterationBefore: boolean = arrayIterationUnknown; + const iterationBefore: boolean = state.arrayIterationUnknown; for (const [index, reference] of pendingReferences.splice(0).entries()) { if (index % 64 === 0) yield; const { initializer } = reference; @@ -866,12 +951,7 @@ export function* collectSemanticMemberMutationsSteps( const slot = semanticSelectedReferenceSlot(reference, state); // Custom iteration can yield the iterable itself as well as any child. // Treat its root as escaped, including writes through yielded references. - const knownArray = - !arrayIterationUnknown && - slot?.value.status === "array" && - slot.value.items.every( - (item) => semanticArrayIndex(item.name) !== null, - ); + const knownArray = semanticKnownArrayIteration(slot?.value, state); if (knownArray && reference.iterationOnly) continue; markValue( reference.node, @@ -901,7 +981,7 @@ export function* collectSemanticMemberMutationsSteps( if ( recordedEffectCount === effectsBefore && iterableReferences.length === iterablesBefore && - arrayIterationUnknown === iterationBefore + state.arrayIterationUnknown === iterationBefore ) break; } @@ -922,14 +1002,12 @@ interface IterableReference extends ReferencedValue { readonly mutation?: t.Node; readonly originAt: t.Node | undefined; readonly iterationOnly?: boolean; - readonly requiredSources?: readonly { - readonly node: t.Node; - readonly projection: PropertyPath; - }[]; - readonly fallbackSources?: readonly { - readonly node: t.Node; - readonly projection: PropertyPath; - }[]; + readonly requiredSources?: NonNullable< + SemanticIterationSource["requiredSources"] + >; + readonly fallbackSources?: NonNullable< + SemanticIterationSource["fallbackSources"] + >; } const expandedIterationReferences = ( @@ -1004,7 +1082,7 @@ const referencedValues = ( if (effect === "write" && path.length < 2) return []; return t.isObjectExpression(node) ? semanticObjectPropertyReferences(node, path, state) - : arrayReferencedValues(node, path); + : semanticArrayPropertyReferences(node, path); } if (t.isClass(node) || t.isNewExpression(node)) return constructedReferenceValues( @@ -1081,6 +1159,7 @@ type MutationObservableOrigin = | "ambient" | "array" | "array-prototype" + | "function" | "opaque"; interface MutationObserverNode { @@ -1172,7 +1251,9 @@ const observableMutationPaths = ( // CommonJS namespace from `require("node:fs")`). Keep those writes at their // exact member paths instead of widening the first path to a wildcard. const hasParsedRequireOrigin = binding.initializers.some( - ({ node }) => semanticRequireOrigin(node, state) !== undefined, + ({ node }) => + semanticRequireOrigin(node, state) !== undefined || + hasSemanticCreateRequireFactoryOrigin(node, state), ); if ( binding.directOrigins.length > 0 || @@ -1205,6 +1286,10 @@ const observableMutationPaths = ( const selected = pendingExpressions.pop(); if (selected === undefined) break; const { expression, observer } = selected; + if (t.isFunction(expression)) + // Keep own-member writes precise without replacing the existing + // links to references handed to a function's eventual caller. + (observer.origins ??= new Set()).add("function"); // Keep class/getter projections distinct so cycle pruning does not // discard observable escapes through a shared getter result. if ( @@ -1386,98 +1471,3 @@ const observableMutationPaths = ( }; return { normalize, repeatsUnpruned }; }; - -const arrayReferencedValues = ( - node: t.ArrayExpression, - path: PropertyPath, -): readonly ReferencedValue[] => { - const [key, ...remaining] = path; - const selected = key ?? null; - const selectedIndex = - typeof selected === "object" ? null : semanticArrayIndex(selected); - if (typeof selected !== "object" && selectedIndex === null) return []; - const references: ReferencedValue[] = []; - let minimumIndex = 0; - let uncertainIndex = false; - for (const element of node.elements) { - if (t.isSpreadElement(element)) { - if (selectedIndex === null || selectedIndex >= minimumIndex) { - let sourceKey: PropertyPath[number] = null; - if (!uncertainIndex) { - if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex; - else if (selected !== null && typeof selected === "object") - sourceKey = { - excludedKeys: selected.excludedKeys.flatMap((name) => { - const index = semanticArrayIndex(name); - return index === null || index < minimumIndex - ? [] - : [String(index - minimumIndex)]; - }), - startIndex: Math.max( - 0, - (selected.startIndex ?? 0) - minimumIndex, - ), - }; - } - references.push({ - node: element.argument, - path: [sourceKey, ...remaining], - iterableFallbackPath: [], - }); - } - uncertainIndex = true; - continue; - } - if ( - element !== null && - (uncertainIndex - ? selectedIndex === null || selectedIndex >= minimumIndex - : semanticPropertyPathKeyMatches(selected, String(minimumIndex))) - ) - references.push({ node: element, path: remaining }); - minimumIndex++; - } - return references; -}; - -const copiedReferencePath = ( - initializer: JavaScriptSemanticBindingState["referenceInitializers"][number], - path: PropertyPath, - effect: ValueEffect, -): PropertyPath | null => { - const offset = initializer.copyProjectionOffset; - if (offset === undefined) return [...initializer.projection, ...path]; - const copiedPath = [...initializer.projection.slice(offset + 1), ...path]; - if (effect === "write" && copiedPath.length < 2) return null; - let [key, ...remaining] = copiedPath; - if (initializer.copyKind === "array-rest") { - const startIndex = initializer.copyStartIndex ?? 0; - if (key === undefined || key === null) - key = { excludedKeys: [], startIndex }; - else if (typeof key === "object") - key = { - excludedKeys: key.excludedKeys.flatMap((name) => { - const index = semanticArrayIndex(name); - return index === null ? [] : [String(index + startIndex)]; - }), - startIndex: (key.startIndex ?? 0) + startIndex, - }; - else { - const index = semanticArrayIndex(key); - if (index === null) return null; - key = index + startIndex; - } - } else { - const excludedKeys = initializer.copyExcludedKeys ?? []; - if (key === undefined || key === null) key = { excludedKeys }; - else if (typeof key === "object") - key = { - ...key, - excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort( - compareUnicodeCodePoints, - ), - }; - else if (excludedKeys.includes(String(key))) return null; - } - return [...initializer.projection.slice(0, offset), key, ...remaining]; -}; diff --git a/src/domain/javascript/javascriptSemanticMutationInitializers.ts b/src/domain/javascript/javascriptSemanticMutationInitializers.ts index c297caa13d..12bf68748e 100644 --- a/src/domain/javascript/javascriptSemanticMutationInitializers.ts +++ b/src/domain/javascript/javascriptSemanticMutationInitializers.ts @@ -471,6 +471,25 @@ const directMutationPosition = ( return computed; }; +/** Prove that an unconditional member replacement precedes this read. */ +export const semanticDefiniteEffectPrecedesReference = ( + effect: t.Node, + reference: t.Node, + parents: WeakMap, +): boolean => { + const before = directMutationPosition(effect, parents); + const after = bindingReadPosition(reference, parents); + if (before === null || after.position === null || after.repeating) + return false; + if (positionPrecedes(before, after.position)) return true; + return ( + before.body === after.position.body && + before.index === after.position.index && + before.declaratorIndex === after.position.declaratorIndex && + expressionEvaluationOrder(effect, reference, parents) === "before" + ); +}; + const computeReferenceInitializerPosition = ( node: t.Node, parents: WeakMap, @@ -481,6 +500,11 @@ const computeReferenceInitializerPosition = ( const direct = directInitializerPosition(current, parents); if (direct !== null) return direct; const parent = parents.get(current); + if ( + t.isAssignmentExpression(parent, { operator: "=" }) && + (t.isObjectPattern(parent.left) || t.isArrayPattern(parent.left)) + ) + return directMutationPosition(parent, parents); if (t.isFunction(parent)) return t.isBlockStatement(parent.body) ? { body: parent.body.body, index: -1, declaratorIndex: -1 } @@ -660,8 +684,15 @@ export const semanticBindingAtRead = ( // can cover entry even when another write's order remains unknown. const hasDefiniteInitializer = position !== null && - binding.initializers.some((initializer) => { - const source = initializerPosition(initializer, parents); + [ + ...binding.initializers, + ...binding.referenceInitializers.filter(({ assignment }) => assignment), + ].some((initializer) => { + const source = initializerPosition( + initializer, + parents, + binding.referenceInitializers.includes(initializer), + ); return ( source !== null && positionPrecedes(source, position) && diff --git a/src/domain/javascript/javascriptSemanticObjectReferences.ts b/src/domain/javascript/javascriptSemanticObjectReferences.ts index 41d79ffd4a..58a0756599 100644 --- a/src/domain/javascript/javascriptSemanticObjectReferences.ts +++ b/src/domain/javascript/javascriptSemanticObjectReferences.ts @@ -8,6 +8,7 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { + semanticArrayIndex, semanticPropertyPathKeyMatches, type JavaScriptSemanticPropertyPath as PropertyPath, } from "./javascriptSemanticPropertyPaths.js"; @@ -21,6 +22,12 @@ import { expandSemanticGetterResult } from "./javascriptSemanticGetterResults.js export interface JavaScriptSemanticObjectReference { readonly node: t.Node; readonly path: PropertyPath; + /** This selection crossed the object's prototype, rather than an own slot. */ + readonly inherited?: true; + /** A shallow copy selects this prefix now while retaining its child identity. */ + readonly capturedSelectionAt?: t.Node; + readonly ownEnumerable?: true; + readonly iterableFallbackPath?: PropertyPath; readonly escapes?: true; /** A retry omitted suffixes, so escaping constructed children must cover them. */ readonly widenedGetterResult?: true; @@ -638,6 +645,8 @@ class ObjectReferenceWalk { this.#references.push({ node: property.argument, path: [this.#spreadKey(), ...this.#remaining], + capturedSelectionAt: property.argument, + ownEnumerable: true, }); if ( selected !== null && @@ -672,6 +681,7 @@ class ObjectReferenceWalk { this.#references.push({ node: property.value, path: [selected, ...this.#remaining], + inherited: true, }); return true; } @@ -777,3 +787,57 @@ const objectExpressionDefinesKey = ( } return false; }; + +export const semanticArrayPropertyReferences = ( + node: t.ArrayExpression, + path: PropertyPath, +): readonly JavaScriptSemanticObjectReference[] => { + const [key, ...remaining] = path; + const selected = key ?? null; + const selectedIndex = + typeof selected === "object" ? null : semanticArrayIndex(selected); + if (typeof selected !== "object" && selectedIndex === null) return []; + const references: JavaScriptSemanticObjectReference[] = []; + let minimumIndex = 0; + let uncertainIndex = false; + for (const element of node.elements) { + if (t.isSpreadElement(element)) { + if (selectedIndex === null || selectedIndex >= minimumIndex) { + let sourceKey: PropertyPath[number] = null; + if (!uncertainIndex) { + if (selectedIndex !== null) sourceKey = selectedIndex - minimumIndex; + else if (selected !== null && typeof selected === "object") + sourceKey = { + excludedKeys: selected.excludedKeys.flatMap((name) => { + const index = semanticArrayIndex(name); + return index === null || index < minimumIndex + ? [] + : [String(index - minimumIndex)]; + }), + startIndex: Math.max( + 0, + (selected.startIndex ?? 0) - minimumIndex, + ), + }; + } + references.push({ + node: element.argument, + path: [sourceKey, ...remaining], + capturedSelectionAt: element.argument, + iterableFallbackPath: [], + }); + } + uncertainIndex = true; + continue; + } + if ( + element !== null && + (uncertainIndex + ? selectedIndex === null || selectedIndex >= minimumIndex + : semanticPropertyPathKeyMatches(selected, String(minimumIndex))) + ) + references.push({ node: element, path: remaining }); + minimumIndex++; + } + return references; +}; diff --git a/src/domain/javascript/javascriptSemanticProjectedSources.ts b/src/domain/javascript/javascriptSemanticProjectedSources.ts new file mode 100644 index 0000000000..722f6b41bd --- /dev/null +++ b/src/domain/javascript/javascriptSemanticProjectedSources.ts @@ -0,0 +1,403 @@ +import * as t from "@babel/types"; +import type { + JavaScriptSemanticAnalysisState, + JavaScriptSemanticBindingState, +} from "./javascriptSemanticState.js"; +import { + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import { + semanticObjectPropertyReferences, + semanticArrayPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { + semanticBindingAtRead, + semanticDefiniteEffectPrecedesReference, + semanticEffectTiming, + semanticTopLevelEffectFollowsCapture, +} from "./javascriptSemanticMutationInitializers.js"; +import { + semanticCopiedReferencePath, + semanticReferenceIterationUnknown, + semanticReferenceSourcesInactive, +} from "./javascriptSemanticReferenceSources.js"; +import { semanticStoredSourceSelection } from "./javascriptSemanticIterationSources.js"; +import { + semanticAliasSelectionChoices, + type SemanticAliasSelectionPath, +} from "./javascriptSemanticAliasSelections.js"; + +export interface SemanticCapturedSelection { + readonly at: t.Node; + readonly depth: number; + /** Object spread/rest reads only an own enumerable slot at this depth. */ + readonly ownEnumerable?: true; +} + +/** The next origin crossed owns a repeated copy boundary at the same depth. */ +export const semanticNormalizeCapturedSelections = ( + selections: readonly SemanticCapturedSelection[] | undefined, + pathLength?: number, +): readonly SemanticCapturedSelection[] | undefined => { + const byDepth = new Map(); + for (const capture of selections ?? []) + if ( + capture.depth > 0 && + (pathLength === undefined || capture.depth <= pathLength) + ) + byDepth.set(capture.depth, capture); + const normalized = [...byDepth.values()].sort( + (left, right) => left.depth - right.depth, + ); + return normalized.length ? normalized : undefined; +}; + +/** Keep capture boundaries aligned as a selected prefix is added or consumed. */ +export const semanticShiftCapturedSelections = ( + selections: readonly SemanticCapturedSelection[] | undefined, + delta: number, +): readonly SemanticCapturedSelection[] | undefined => { + const shifted = selections?.flatMap((selection) => + selection.depth + delta > 0 + ? [{ ...selection, depth: selection.depth + delta }] + : [], + ); + return semanticNormalizeCapturedSelections(shifted); +}; + +interface ProjectedSource { + readonly node: t.Node; + readonly path: SemanticAliasSelectionPath; + readonly capturedSelections?: readonly SemanticCapturedSelection[]; +} + +/** A fresh parent does not prove a descendant receiver is a fresh allocation. */ +const hasFreshProjectedReceiver = ( + root: t.Node, + path: JavaScriptSemanticPropertyPath, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const pending = [{ node: root, path }]; + const examined = new WeakMap>(); + let selections = 0; + while (pending.length > 0) { + const current = pending.pop()!; + const node = unwrapJavaScriptExpression(current.node).node; + const key = JSON.stringify(current.path); + const paths = examined.get(node) ?? new Set(); + if (paths.has(key)) return false; + paths.add(key); + examined.set(node, paths); + if (++selections > SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + return false; + } + if (current.path.length === 0) { + if ( + !t.isObjectExpression(node) && + !t.isArrayExpression(node) && + !t.isFunction(node) && + !t.isClassExpression(node) + ) + return false; + continue; + } + const [selected, ...remaining] = current.path; + const sources = t.isObjectExpression(node) + ? semanticObjectPropertyReferences(node, [selected!], state) + : t.isArrayExpression(node) + ? semanticArrayPropertyReferences(node, [selected!]) + : []; + if (sources.length === 0) return false; + if ( + selections + pending.length + sources.length > + SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + state.valueResourceLimits.add("alias-selections"); + return false; + } + pending.push( + ...sources.map((source) => ({ + node: source.node, + path: [...source.path, ...remaining], + })), + ); + } + return true; +}; + +/** Select retained assignment and copy sources at one projected member read. */ +export const semanticProjectedSourcesAtRead = ( + ...arguments_: Parameters +): readonly ProjectedSource[] | undefined => + semanticProjectedSourceFactsAtRead(...arguments_)?.sources; + +/** Retain the selected receiver's possible owners after its current value becomes opaque. */ +export const semanticProjectedSourceEligibilityAtRead = ( + binding: JavaScriptSemanticBindingState, + reference: t.Node, + path: SemanticAliasSelectionPath, + state: JavaScriptSemanticAnalysisState, + memberReadAt: t.Node = reference, + capturedSelections: readonly SemanticCapturedSelection[] = [], +): readonly ProjectedSource[] | undefined => + projectedSourceFactsAtRead( + binding, + reference, + path, + state, + false, + memberReadAt, + capturedSelections, + true, + )?.sources; + +/** Keep imported origins subject to the same selected writes as other values. */ +export const semanticProjectedSourceFactsAtRead = ( + ...arguments_: Parameters +): ReturnType => + projectedSourceFactsAtRead(...arguments_); + +const projectedSourceFactsAtRead = ( + binding: JavaScriptSemanticBindingState, + reference: t.Node, + path: SemanticAliasSelectionPath, + state: JavaScriptSemanticAnalysisState, + includeUnknown: boolean, + memberReadAt: t.Node = reference, + capturedSelections: readonly SemanticCapturedSelection[] = [], + retainEligibility = false, +): + | { + readonly sources: readonly ProjectedSource[]; + readonly retainsOrigin: boolean; + } + | undefined => { + const read = semanticBindingAtRead(binding, reference, state.parentsByNode); + const captured = [ + ...read.binding.initializers, + ...read.binding.referenceInitializers, + ]; + const normalizedCaptures = semanticNormalizeCapturedSelections( + capturedSelections, + path.length, + ); + const captureForDepth = (depth: number): t.Node | undefined => + normalizedCaptures?.find((capture) => capture.depth >= depth)?.at; + const selectedAt = (depth: number): t.Node => + captureForDepth(depth) ?? memberReadAt; + const samePrefix = ( + prefix: JavaScriptSemanticBindingState["memberWrites"][number], + effect: JavaScriptSemanticBindingState["memberWrites"][number], + ): boolean => + prefix.path.length < effect.path.length && + prefix.path.every( + (key, index) => + key !== null && + typeof key !== "object" && + effect.path[index] !== null && + typeof effect.path[index] !== "object" && + String(key) === String(effect.path[index]), + ); + const targetsReplacedPrefix = ( + effect: JavaScriptSemanticBindingState["memberWrites"][number], + ): boolean => + capturedSelections.length > 0 && + binding.memberWrites.some((replacement) => { + const capture = captureForDepth(replacement.path.length); + const receiverAt = effect.receiverAt ?? effect.node; + return ( + capture !== undefined && + receiverAt !== undefined && + t.isAssignmentExpression(replacement.node, { operator: "=" }) && + samePrefix(replacement, effect) && + semanticTopLevelEffectFollowsCapture( + capture, + replacement.node, + state.parentsByNode, + ) && + semanticDefiniteEffectPrecedesReference( + replacement.node, + receiverAt, + state.parentsByNode, + ) && + hasFreshProjectedReceiver( + replacement.node.right, + effect.path.slice(replacement.path.length, -1), + state, + ) + ); + }); + const matches = ( + effect: JavaScriptSemanticBindingState["memberWrites"][number], + ) => + (effect.receiverInitializers === undefined || + effect.receiverInitializers.length === 0 || + captured.length === 0 || + effect.receiverInitializers.some((receiver) => + captured.some( + (source) => + source.node === receiver.node && + JSON.stringify(source.projection) === + JSON.stringify(receiver.projection), + ), + )) && + path.length > 0 && + effect.path.length <= path.length && + effect.path.every((key, index) => { + const selected = path[index]; + return ( + selected !== undefined && + semanticAliasSelectionChoices(selected).some( + (choice) => + choice === null || + typeof choice === "object" || + semanticPropertyPathKeyMatches(key, String(choice)), + ) + ); + }) && + semanticEffectTiming(selectedAt(effect.path.length), effect.node, state) !== + "after" && + !targetsReplacedPrefix(effect); + const writes = binding.memberWrites.filter(matches); + let replacement: + | JavaScriptSemanticBindingState["memberWrites"][number] + | undefined; + for (const effect of writes) { + if ( + t.isAssignmentExpression(effect.node, { operator: "=" }) && + effect.path.every( + (key, index) => + key !== null && + typeof key !== "object" && + path[index] !== null && + typeof path[index] !== "object" && + String(key) === String(path[index]), + ) && + semanticDefiniteEffectPrecedesReference( + effect.node, + selectedAt(effect.path.length), + state.parentsByNode, + ) && + (replacement === undefined || + semanticDefiniteEffectPrecedesReference( + replacement.node!, + effect.node, + state.parentsByNode, + ) || + (replacement.node === effect.node && + (replacement.assignedSources?.[0]?.target?.start ?? -1) < + (effect.assignedSources?.[0]?.target?.start ?? -1))) + ) + replacement = effect; + } + const superseded = ( + effect: JavaScriptSemanticBindingState["memberWrites"][number], + ) => + replacement !== undefined && + effect.node !== undefined && + effect !== replacement && + (semanticEffectTiming(replacement.node!, effect.node, state) === "before" || + (effect.node === replacement.node && + (effect.assignedSources?.[0]?.target?.start ?? -1) < + (replacement.assignedSources?.[0]?.target?.start ?? -1))); + if ( + includeUnknown && + binding.escapedPaths.some( + (effect) => matches(effect) && !superseded(effect), + ) + ) + return undefined; + const sources: ProjectedSource[] = []; + for (const effect of writes) { + if (!retainEligibility && superseded(effect)) continue; + if ( + !t.isAssignmentExpression(effect.node) || + !["=", "||=", "&&=", "??="].includes(effect.node.operator) + ) { + if (includeUnknown) return undefined; + continue; + } + if (effect.assignedSources === undefined) { + if (includeUnknown) return undefined; + continue; + } + const remaining = path.slice(effect.path.length); + for (const source of effect.assignedSources) { + if (semanticReferenceIterationUnknown(source, state)) return undefined; + if (semanticReferenceSourcesInactive(source, state)) continue; + const [key, ...suffix] = remaining; + for (const choice of key === undefined + ? [undefined] + : semanticAliasSelectionChoices(key)) { + const selection = semanticStoredSourceSelection( + source, + choice === undefined ? [] : [choice], + ); + if (selection !== null) + sources.push({ + node: source.node, + path: [...selection.projection, ...suffix], + ...(selection.capturedSelectionDepth === undefined + ? {} + : { + capturedSelections: [ + { + at: source.node, + depth: selection.capturedSelectionDepth, + ...(selection.ownEnumerable + ? { ownEnumerable: true as const } + : {}), + }, + ], + }), + }); + } + } + } + if (!retainEligibility && replacement !== undefined) + return { sources, retainsOrigin: false }; + for (const initializer of read.binding.initializers) { + if (semanticReferenceIterationUnknown(initializer, state)) return undefined; + if (semanticReferenceSourcesInactive(initializer, state)) continue; + sources.push({ + node: initializer.node, + path: [...initializer.projection, ...path], + }); + } + for (const initializer of read.binding.referenceInitializers) { + if (semanticReferenceIterationUnknown(initializer, state)) return undefined; + if (semanticReferenceSourcesInactive(initializer, state)) continue; + const [key, ...suffix] = path; + for (const choice of key === undefined + ? [undefined] + : semanticAliasSelectionChoices(key)) { + const projection = semanticCopiedReferencePath( + initializer, + choice === undefined ? [] : [choice], + "identity", + ); + if (projection !== null) + sources.push({ + node: initializer.node, + path: [...projection, ...suffix], + capturedSelections: + initializer.copyProjectionOffset === undefined + ? [] + : [ + { + at: initializer.node, + depth: projection.length + suffix.length - path.length + 1, + ...(initializer.copyKind === "object-rest" + ? { ownEnumerable: true as const } + : {}), + }, + ], + }); + } + } + return { sources, retainsOrigin: true }; +}; diff --git a/src/domain/javascript/javascriptSemanticProjection.ts b/src/domain/javascript/javascriptSemanticProjection.ts index 25a065e57f..b7a723fea7 100644 --- a/src/domain/javascript/javascriptSemanticProjection.ts +++ b/src/domain/javascript/javascriptSemanticProjection.ts @@ -25,7 +25,6 @@ import { import { range } from "./javascriptStaticAnalysisHelpers.js"; import { propertyName, - semanticFinalObjectProperty, semanticStaticPropertyKey, semanticWrappedWriteSite, semanticWriteSiteReadsFirst, @@ -40,11 +39,11 @@ import { semanticImportEqualsSpecifier, } from "./javascriptModuleDeclaration.js"; import { - commonJsDefinedExport, - commonJsExportName, + commonJsRuntimeExportValues, type CommonJsExportReceiverEffect, type CommonJsExportSite, } from "./javascriptCommonJsExportReceivers.js"; +import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js"; interface CollectCallableInput { readonly node: t.Node; @@ -124,7 +123,7 @@ export const collectSemanticModuleLink = ( else if (t.isAssignmentExpression(node)) collectCommonJsExport(node, state, commonJsReceivers.get(node)); else if (t.isCallExpression(node)) { - collectDefinedCommonJsExport(node, state, commonJsReceivers.get(node)); + collectCommonJsExport(node, state, commonJsReceivers.get(node)); collectUnboundRequireLink(node, state); } }; @@ -472,34 +471,21 @@ const requirePatternBindings = ( }; const collectCommonJsExport = ( - node: t.AssignmentExpression, + node: CommonJsExportSite, state: JavaScriptSemanticAnalysisState, receiverEffect: CommonJsExportReceiverEffect | undefined, ): void => { - const exportedName = commonJsExportName(node.left, state); - if (exportedName === undefined) return; if (receiverEffect === "detached") return; // Compound and logical writes also depend on the previous value, so remain // unknown. - const value = - node.operator === "=" && receiverEffect !== "uncertain" ? node.right : null; - addCommonJsExport(node, state, exportedName, value); -}; - -const collectDefinedCommonJsExport = ( - node: t.CallExpression, - state: JavaScriptSemanticAnalysisState, - receiverEffect: CommonJsExportReceiverEffect | undefined, -): void => { - const defined = commonJsDefinedExport(node, state); - if (defined === undefined || receiverEffect === "detached") return; - // A data descriptor exports its value; an accessor's result stays unknown. - const descriptor = unwrapJavaScriptExpression(defined.descriptor).node; - const value = - receiverEffect !== "uncertain" && t.isObjectExpression(descriptor) - ? (semanticFinalObjectProperty(descriptor, "value") ?? null) - : null; - addCommonJsExport(node, state, defined.exportedName, value); + for (const exported of commonJsRuntimeExportValues(node, state)) + addCommonJsExport( + node, + state, + exported.exportedName, + receiverEffect === "uncertain" ? null : exported.value, + exported.projection, + ); }; const addCommonJsExport = ( @@ -507,6 +493,7 @@ const addCommonJsExport = ( state: JavaScriptSemanticAnalysisState, exportedName: string, exported: t.Node | null, + projection?: JavaScriptSemanticPropertyPath, ): void => { // Simple assignment chains yield their rightmost assigned value. let value = exported; @@ -514,21 +501,41 @@ const addCommonJsExport = ( value = value.right; const required = value === null ? undefined : semanticRequireOrigin(value, state); - const origin = required?.origin; + // Copies retain their module provenance, but do not export the source object + // itself. A wildcard with exclusions cannot express one module link. + const origin = projection?.some( + (key) => + typeof key === "object" && key !== null && key.excludedKeys.length > 0, + ) + ? undefined + : required?.origin; + const selectedName = projection?.at(-1); addModuleLink( state, { kind: "commonjs-export", specifier: origin?.specifier ?? null, - importedName: origin?.importedPath.at(-1) ?? null, - localName: value !== null && t.isIdentifier(value) ? value.name : null, + importedName: + origin === undefined + ? null + : typeof selectedName === "string" || typeof selectedName === "number" + ? String(selectedName) + : (origin.importedPath.at(-1) ?? null), + localName: + projection === undefined && value !== null && t.isIdentifier(value) + ? value.name + : null, exportedName, - callableId: value === null ? null : semanticCallableIdForNode(value), + callableId: + projection !== undefined || value === null + ? null + : semanticCallableIdForNode(value), location: range(node), }, node, ); - if (required !== undefined) state.linkedRequireCalls.add(required.call); + if (origin !== undefined && required !== undefined) + state.linkedRequireCalls.add(required.call); }; const addModuleLink = ( diff --git a/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts b/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts new file mode 100644 index 0000000000..6a26af7bd5 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticProvenanceAssignments.test.ts @@ -0,0 +1,87 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const provenance = (source: string, name: string) => { + const result = analyzeJavaScriptSemantics(source); + const binding = result.bindings.find((candidate) => candidate.name === name); + if (binding === undefined) throw new Error(`Missing binding ${name}`); + return binding.provenance; +}; + +it.each([ + "let api;({api}={api:require('node:fs')});", + "let api;[api]=[require('node:fs')];", + "let api;({api}={api:{readFileSync(){}}});", + "let api={};({api}={api:require('node:fs')});", + "let api;if(flag)({api}={api:require('node:fs')});", +])("keeps projected replacement provenance unresolved: %s", (source) => { + expect(provenance(source, "api")).toMatchObject({ + status: "unknown", + origins: [], + }); +}); + +it.each([ + "let api=require('node:fs');({api}={api:{}});", + "let api=require('node:fs');if(flag)({api}={api:{}});", + "let api=require('node:fs');[api]=[{}];", +])( + "retains possible earlier module origins without proving them current: %s", + (source) => { + expect(provenance(source, "api")).toMatchObject({ + status: "ambiguous", + origins: [{ specifier: "node:fs", importedPath: [] }], + }); + }, +); + +it.each([ + ["let api=require('node:fs');const saved=api;({api}={api:{}});", "module"], + ["let api=require('node:fs');({api}={api:{}});const saved=api;", "unknown"], + [ + "let api=require('node:fs');if(flag)({api}={api:{}});const saved=api;", + "ambiguous", + ], +])("selects provenance at the alias capture: %s", (source, status) => { + expect(provenance(source, "saved").status).toBe(status); + if (status === "module") + expect(provenance(source, "saved").origins).toEqual([ + { specifier: "node:fs", importedPath: [], resolutionMode: "require" }, + ]); +}); + +it.each([ + ["const {api=fallback}={};", "local"], + ["const {...api}=require('node:fs');", "local"], + ["const api=require('node:fs');", "module"], +])( + "preserves declaration and ordinary initializer policy: %s", + (source, status) => { + expect(provenance(source, "api").status).toBe(status); + }, +); + +it("retains an uncertain sqlite candidate instead of asserting a local constructor", () => { + const source = + "let Database;({Database}={Database:require('better-sqlite3')});new Database('x.db');"; + expect(provenance(source, "Database").status).toBe("unknown"); + expect(analyzeJavaScriptStaticSource(source).storage).toEqual([ + expect.objectContaining({ + kind: "sqlite", + interpretation: "candidate", + name: "x.db", + }), + ]); +}); + +it("preserves captured Electron origin and rejects certainty after replacement", () => { + const source = + "let electron=require('electron');const saved=electron;({electron}={electron:{}});const later=electron;"; + expect(provenance(source, "saved")).toMatchObject({ + status: "module", + origins: [{ specifier: "electron" }], + }); + expect(provenance(source, "later").status).toBe("unknown"); +}); diff --git a/src/domain/javascript/javascriptSemanticReferenceSources.ts b/src/domain/javascript/javascriptSemanticReferenceSources.ts index 54e1d3ba98..58b1307c00 100644 --- a/src/domain/javascript/javascriptSemanticReferenceSources.ts +++ b/src/domain/javascript/javascriptSemanticReferenceSources.ts @@ -1,17 +1,114 @@ import type * as t from "@babel/types"; import type { JavaScriptSemanticAnalysisState } from "./javascriptSemanticState.js"; -import type { JavaScriptSemanticPropertyPath as PropertyPath } from "./javascriptSemanticPropertyPaths.js"; -import { evaluateSemanticExpression } from "./javascriptSemanticValues.js"; +import { + semanticArrayIndex, + semanticPropertyPathKeyMatches, + type JavaScriptSemanticPropertyPath as PropertyPath, +} from "./javascriptSemanticPropertyPaths.js"; +import { compareUnicodeCodePoints } from "../unicodeCodePointOrder.js"; +import { + evaluateSemanticExpression, + semanticValueDefinitelyDefined, +} from "./javascriptSemanticValues.js"; import { semanticSlotAtPath } from "./javascriptSemanticSlots.js"; +import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js"; +import { semanticEffectTiming } from "./javascriptSemanticMutationInitializers.js"; + +/** + * Node's wrapper module inherits require. A copy cannot read that delegate + * unless an earlier own-slot write or opaque module escape may have exposed it. + */ +export const semanticCommonJsRequireOwnEnumerableAt = ( + capture: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => { + const selected = ["commonjs-module", "require"]; + const mayAffect = ( + effect: JavaScriptSemanticAnalysisState["ambientWriteEffects"][number], + ) => + effect.path.length <= selected.length && + effect.path.every((key, index) => + semanticPropertyPathKeyMatches(key, selected[index] ?? ""), + ) && + semanticEffectTiming(capture, effect.node, state) !== "after"; + return ( + state.ambientWriteEffects.some(mayAffect) || + state.ambientEscapeEffects.some(mayAffect) + ); +}; interface ReferenceSource { readonly node: t.Node; readonly projection: PropertyPath; + readonly iterationSources?: readonly ReferenceSource[]; } +/** Native array iteration preserves numeric slots only while its iterator is intact. */ +export const semanticKnownArrayIteration = ( + value: JavaScriptSemanticValue | undefined, + state: JavaScriptSemanticAnalysisState, +): boolean => + !state.arrayIterationUnknown && + value?.status === "array" && + value.items.every((item) => semanticArrayIndex(item.name) !== null); + +/** Invocation spreads use the same native-iteration proof at their source read. */ +export const semanticKnownArrayIterationAt = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + semanticKnownArrayIteration( + evaluateSemanticExpression(node, state, node), + state, + ); + +/** Numeric destructuring projections do not select slots of custom iterators. */ +export const semanticReferenceIterationUnknown = ( + source: { readonly iterationSources?: readonly ReferenceSource[] }, + state: JavaScriptSemanticAnalysisState, +): boolean => + source.iterationSources?.some( + (iterable) => !selectedReferenceIterationKnown(iterable, state), + ) === true; + +const selectedReferenceIterationKnown = ( + source: ReferenceSource, + state: JavaScriptSemanticAnalysisState, +): boolean => { + let values = [evaluateSemanticExpression(source.node, state, source.node)]; + for (const key of source.projection) { + if (typeof key === "object" && key !== null) return false; + const selected = new Set(); + for (const value of values) { + if (key === null) { + // for-of projects any position of a native array. Its finite slot + // inventory can prove every possible nested iterable without treating + // a computed object property or custom iterator as an own-index read. + if ( + !semanticKnownArrayIteration(value, state) || + value.status !== "array" || + value.unknownItems + ) + return false; + for (const slot of value.items) { + if (slot.presence !== "present") return false; + selected.add(slot.value); + } + } else { + const slot = semanticSlotAtPath(value, [String(key)]); + if (slot.presence !== "present") return false; + selected.add(slot.value); + } + } + values = [...selected]; + } + return values.every((value) => semanticKnownArrayIteration(value, state)); +}; + /** Exclude references whose source slot or destructuring fallback is absent. */ export const semanticReferenceSourcesInactive = ( reference: { + readonly iterationSources?: readonly ReferenceSource[]; readonly requiredSources?: readonly ReferenceSource[]; readonly fallbackSources?: readonly ReferenceSource[]; }, @@ -19,19 +116,18 @@ export const semanticReferenceSourcesInactive = ( ): boolean => reference.requiredSources?.some( (source) => + !semanticReferenceIterationUnknown(source, state) && semanticSelectedReferenceSlot(source, state, source.node)?.presence === - "absent", + "absent", ) === true || reference.fallbackSources?.some((source) => { // Selection precedes effects through the chosen reference. A later escape // cannot make a fallback that was excluded at destructuring reachable. const slot = semanticSelectedReferenceSlot(source, state, source.node); return ( + !semanticReferenceIterationUnknown(source, state) && slot?.presence === "present" && - (slot.value.status === "literal" || - slot.value.status === "union" || - slot.value.status === "object" || - slot.value.status === "array") + semanticValueDefinitelyDefined(slot.value) ); }) === true; @@ -51,3 +147,55 @@ export const semanticSelectedReferenceSlot = ( path, ); }; + +interface CopiedReferenceSource { + readonly projection: PropertyPath; + readonly copyKind?: "object-rest" | "array-rest"; + readonly copyProjectionOffset?: number; + readonly copyExcludedKeys?: readonly string[]; + readonly copyStartIndex?: number; +} + +/** Map copied child selections once; the fresh copy itself keeps its own identity. */ +export const semanticCopiedReferencePath = ( + initializer: CopiedReferenceSource, + path: PropertyPath, + effect: "write" | "escape" | "identity", +): PropertyPath | null => { + const offset = initializer.copyProjectionOffset; + if (offset === undefined) return [...initializer.projection, ...path]; + const copiedPath = [...initializer.projection.slice(offset + 1), ...path]; + if (effect === "identity" && copiedPath.length === 0) return null; + if (effect === "write" && copiedPath.length < 2) return null; + let [key, ...remaining] = copiedPath; + if (initializer.copyKind === "array-rest") { + const startIndex = initializer.copyStartIndex ?? 0; + if (key === undefined || key === null) + key = { excludedKeys: [], startIndex }; + else if (typeof key === "object") + key = { + excludedKeys: key.excludedKeys.flatMap((name) => { + const index = semanticArrayIndex(name); + return index === null ? [] : [String(index + startIndex)]; + }), + startIndex: (key.startIndex ?? 0) + startIndex, + }; + else { + const index = semanticArrayIndex(key); + if (index === null) return null; + key = index + startIndex; + } + } else { + const excludedKeys = initializer.copyExcludedKeys ?? []; + if (key === undefined || key === null) key = { excludedKeys }; + else if (typeof key === "object") + key = { + ...key, + excludedKeys: [...new Set([...key.excludedKeys, ...excludedKeys])].sort( + compareUnicodeCodePoints, + ), + }; + else if (excludedKeys.includes(String(key))) return null; + } + return [...initializer.projection.slice(0, offset), key, ...remaining]; +}; diff --git a/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts b/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts new file mode 100644 index 0000000000..ec7e04bc26 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticRequireCaptures.test.ts @@ -0,0 +1,151 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + ['const h={require:module.require};h.require("./shared.cjs");', true], + [ + 'const h={require:module.require};h.require.call(module,"./shared.cjs");', + true, + ], + [ + 'const h={require:module.require};h.require.apply(module,["./shared.cjs"]);', + true, + ], + [ + 'const h={inner:{require:module.require}};h.inner.require("./shared.cjs");', + true, + ], + ['const h={require(){}};h.require("./shared.cjs");', false], + ['const h={require(){}};h.require.call(module,"./shared.cjs");', false], + [ + 'const h={require:module.require};h.require=()=>{};h.require("./shared.cjs");', + false, + ], + [ + 'const h={require(){}};h.require=module.require;h.require("./shared.cjs");', + true, + ], +])( + "follows the actual named delegate as well as its receiver: %s", + (source, expected) => { + expect(analyzeJavaScriptStaticSource(source).references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ['(0,module).require("./shared.cjs");', true], + ['(module,{}).require("./shared.cjs");', false], + ['const get=()=>module;(0,get()).require("./shared.cjs");', true], + ['const loader=(0,module);loader.require("./shared.cjs");', true], + ['const load=(0,module.require);load("./shared.cjs");', true], + ['const load=(module.require,()=>{});load("./shared.cjs");', false], +])( + "selects only the final sequence value as a loader: %s", + (source, expected) => { + const result = analyzeJavaScriptStaticSource(source); + expect(result.parse_error_count).toBe(0); + expect(result.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + [ + 'const old={loader:module};const middle={...old};old.loader={};const box={...middle};middle.loader={};box.loader.require("./x");', + true, + ], + [ + 'const old={loader:{}};const middle={...old};old.loader=module;const box={...middle};middle.loader=module;box.loader.require("./x");', + false, + ], + [ + 'const old={loader:module};const middle={...old};old.loader={};const {...box}=middle;middle.loader={};box.loader.require("./x");', + true, + ], + [ + 'const old={loader:{}};const middle={...old};old.loader=module;const {...box}=middle;middle.loader=module;box.loader.require("./x");', + false, + ], + [ + 'const inner={loader:{}};const box={inner};inner.loader=module;box.inner.loader.require("./x");', + true, + ], + [ + 'const inner={loader:module};const box={inner};inner.loader={};box.inner.loader.require("./x");', + false, + ], + [ + 'let inner={loader:module};const box={inner};inner={loader:{}};inner.loader={};box.inner.loader.require("./x");', + true, + ], + [ + 'let inner={loader:{}};const box={inner};inner={loader:{}};inner.loader=module;box.inner.loader.require("./x");', + false, + ], + [ + 'const old={loader:module};const box={...old};old.loader={};box.loader.require("./x");', + true, + ], + [ + 'const old={loader:{}};const box={...old};old.loader=module;box.loader.require("./x");', + false, + ], + [ + 'const old={child:{loader:module}};const box={...old};old.child={loader:{}};box.child.loader.require("./x");', + true, + ], + [ + 'const old={child:{loader:module}};const box={...old};old.child.loader={};box.child.loader.require("./x");', + false, + ], + [ + 'const old={child:{loader:{}}};const box={...old};old.child.loader=module;box.child.loader.require("./x");', + true, + ], + [ + 'const old={child:{loader:{}}};const box={...old};old.child={loader:module};old.child.loader=module;box.child.loader.require("./x");', + false, + ], + [ + 'const old=[module];const box=[...old];old[0]={};box[0].require("./x");', + true, + ], + [ + 'const old=[{}];const box=[...old];old[0]=module;box[0].require("./x");', + false, + ], + [ + 'const old={loader:module};const {...box}=old;old.loader={};box.loader.require("./x");', + true, + ], + [ + 'const old={loader:{}};const {...box}=old;old.loader=module;box.loader.require("./x");', + false, + ], + [ + 'const old=[module];const [...box]=old;old[0]={};box[0].require("./x");', + true, + ], + [ + 'const old=[{}];const [...box]=old;old[0]=module;box[0].require("./x");', + false, + ], +])( + "keeps module receiver capture and later member selection separate: %s", + (source, expected) => { + const result = analyzeJavaScriptStaticSource(source); + expect( + result.references + .filter(({ kind }) => kind === "require") + .map(({ specifier }) => specifier), + ).toEqual(expected ? [null] : []); + }, +); diff --git a/src/domain/javascript/javascriptSemanticRequireOrigin.ts b/src/domain/javascript/javascriptSemanticRequireOrigin.ts index 825eefef1a..2c4477849c 100644 --- a/src/domain/javascript/javascriptSemanticRequireOrigin.ts +++ b/src/domain/javascript/javascriptSemanticRequireOrigin.ts @@ -1,3 +1,10 @@ +import { + semanticProjectedSourceFactsAtRead, + semanticProjectedSourceEligibilityAtRead, + semanticNormalizeCapturedSelections, + semanticShiftCapturedSelections, + type SemanticCapturedSelection, +} from "./javascriptSemanticProjectedSources.js"; import * as t from "@babel/types"; import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; @@ -9,6 +16,7 @@ import { commonJsWrapperBindingDeclaration, semanticResolutionBlocked, semanticStableInitializer, + semanticBindingHasPriorValueWrite, type JavaScriptSemanticAnalysisState, } from "./javascriptSemanticState.js"; import { @@ -19,9 +27,25 @@ import { semanticPropertyPathKeyMatches } from "./javascriptSemanticPropertyPath import { stringValue } from "./javascriptStaticAnalysisHelpers.js"; import { semanticEffectTiming, + semanticBindingAtRead, semanticTopLevelEffectFollowsCapture, } from "./javascriptSemanticMutationInitializers.js"; import { createSemanticModuleInterop } from "./javascriptModuleInterop.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; +import { semanticCommonJsRequireOwnEnumerableAt } from "./javascriptSemanticReferenceSources.js"; +import { + semanticArrayPropertyReferences, + semanticObjectPropertyReferences, +} from "./javascriptSemanticObjectReferences.js"; +import { + SemanticAliasSelections, + semanticAliasSelectionChoices, + type SemanticAliasSelectionPath, +} from "./javascriptSemanticAliasSelections.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { isUnmodifiedImportMetaUrl } from "./javascriptSemanticAmbientWrites.js"; +import { semanticReferenceSourcesInactive } from "./javascriptSemanticReferenceSources.js"; export interface JavaScriptRequireOrigin { readonly origin: JavaScriptModuleOrigin; @@ -35,46 +59,124 @@ export type JavaScriptRequireLoaderFact = | "local" | "unknown"; -/** Classify a bare `require(...)` using the shared lexical binding owner. */ +const possibleCommonJsDelegateWriteStates = + new WeakSet(); + +/** Source eligibility only; the shared write owner still proves receiver identity. */ +export const observePossibleCommonJsDelegateWrite = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): void => { + if (possibleCommonJsDelegateWriteStates.has(state)) return; + const target = t.isAssignmentExpression(node) + ? node.left + : t.isUpdateExpression(node) || + t.isUnaryExpression(node, { operator: "delete" }) + ? node.argument + : undefined; + if ( + semanticIterationSources(node, state.parentsByNode.get(node) ?? null).some( + (source) => source.kind === "reference-store", + ) + ) + possibleCommonJsDelegateWriteStates.add(state); + if ( + (t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) && + ["require", null].includes( + semanticStaticPropertyKey(target.property, target.computed), + ) + ) + possibleCommonJsDelegateWriteStates.add(state); + else if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) + possibleCommonJsDelegateWriteStates.add(state); +}; + +export const hasPossibleCommonJsDelegateWrite = ( + state: JavaScriptSemanticAnalysisState, +): boolean => possibleCommonJsDelegateWriteStates.has(state); + +/** Member effects settle at the shared indexing boundary, invalidating derived delegates. */ +export const invalidateSemanticRequireDelegateWrites = ( + state: JavaScriptSemanticAnalysisState, +): void => { + moduleRequireWrites.delete(state); + moduleReturnedReferences.delete(state); +}; + +const commonJsDelegateChangedAt = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): boolean => + writesModuleRequire(state).some( + (write) => semanticEffectTiming(node, write, state) !== "after", + ); + +const commonJsLoaderAt = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, +): JavaScriptRequireLoaderFact => + commonJsDelegateChangedAt(node, state) ? "unknown" : "commonjs-loader"; + +/** Associate wrapper lookups with their loader; this does not claim execution. */ export const semanticRequireLoaderAt = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, state: JavaScriptSemanticAnalysisState, factoryBindingIds: ReadonlySet, ): JavaScriptRequireLoaderFact => { const callee = unwrapJavaScriptExpression(node.callee).node; - const loader = t.isIdentifier(callee, { name: "require" }) - ? callee - : (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && - ["resolve", "main"].includes( - semanticStaticPropertyKey(callee.property, callee.computed) ?? "", - ) && - t.isIdentifier(callee.object, { name: "require" }) + const loader = + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + ["resolve", "main"].includes( + semanticStaticPropertyKey(callee.property, callee.computed) ?? "", + ) ? callee.object - : undefined; - if (loader === undefined) return "local"; - const binding = resolveSemanticBindingState(state, loader, "require"); + : callee; + return requireLoaderValueAt(loader, node, state, factoryBindingIds); +}; + +/** Classify Node's wrapper and exact factory bindings by their lexical owner. */ +const requireLoaderValueAt = ( + value: t.Node, + node: t.Node, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet, +): JavaScriptRequireLoaderFact => { + const callee = unwrapJavaScriptExpression(value).node; + // Only the selected require function is callable as the host loader. + // Its resolve/main members remain association facts in the wrapper above. + if (!t.isIdentifier(callee)) return "local"; + const loader = callee; + const binding = resolveSemanticBindingState(state, loader, loader.name); + if (binding !== undefined && factoryBindingIds.has(binding.bindingId)) + return semanticBindingHasPriorValueWrite(node, binding, state) + ? "unknown" + : "factory-loader"; + if (loader.name !== "require") return "local"; if (binding === undefined) { if (semanticResolutionBlocked(state, loader, "require")) return "unknown"; return (state.ambientBindingWrites.get("require") ?? []).some( (write) => semanticEffectTiming(node, write, state) !== "after", ) ? "unknown" - : "commonjs-loader"; + : commonJsLoaderAt(node, state); } - if (factoryBindingIds.has(binding.bindingId)) - return binding.initializers.length === 0 ? "factory-loader" : "unknown"; if ( binding.hostOrigin === "commonjs-wrapper" && [...binding.declarationKinds].every((kind) => kind === "var") ) { if (binding.definitions.some(({ kind }) => kind === "function")) return "local"; - return commonJsWrapperBindingHasPriorWrite(node, binding, state) + return semanticBindingHasPriorValueWrite(node, binding, state) ? "unknown" - : "commonjs-loader"; + : commonJsLoaderAt(node, state); } - if (isWrapperRequireInitializerCall(binding, node, state)) - return "commonjs-loader"; + if ( + (t.isCallExpression(node) || + t.isOptionalCallExpression(node) || + t.isNewExpression(node)) && + isWrapperRequireInitializerCall(binding, node, state) + ) + return commonJsLoaderAt(node, state); const wrapperBinding = commonJsWrapperBindingDeclaration( loader, "require", @@ -82,9 +184,9 @@ export const semanticRequireLoaderAt = ( ); if ( wrapperBinding !== undefined && - !commonJsWrapperBindingHasPriorWrite(node, wrapperBinding, state) + !semanticBindingHasPriorValueWrite(node, wrapperBinding, state) ) - return "commonjs-loader"; + return commonJsLoaderAt(node, state); if (isSemanticCreateRequireBinding(binding, state)) return "create-require"; const initializer = semanticStableInitializer(binding, state)?.node; if ( @@ -102,42 +204,6 @@ export const semanticRequireLoaderAt = ( return "unknown"; }; -const commonJsWrapperBindingHasPriorWrite = ( - site: t.Node, - binding: NonNullable>, - state: JavaScriptSemanticAnalysisState, -): boolean => { - const writes = state.bindingWrites.get(binding.bindingId) ?? []; - const declarationInitializers = binding.initializers - .map(({ node }) => node) - .filter( - (initializer) => - !writes.some((write) => semanticNodeWithin(initializer, write, state)), - ); - return ( - writes.some( - (write) => semanticEffectTiming(site, write, state) !== "after", - ) || - declarationInitializers.some( - (initializer) => - semanticEffectTiming(site, initializer, state) !== "after", - ) - ); -}; - -const semanticNodeWithin = ( - node: t.Node, - ancestor: t.Node, - state: JavaScriptSemanticAnalysisState, -): boolean => { - let current: t.Node | undefined = node; - while (current !== undefined) { - if (current === ancestor) return true; - current = state.parentsByNode.get(current); - } - return false; -}; - /** Classify a call through an exact recovered factory-loader binding. */ export const semanticFactoryRequireLoaderAt = ( node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, @@ -177,7 +243,9 @@ export const semanticFactoryLoaderMemberAt = ( const binding = resolveSemanticBindingState(state, loader, loader.name); if (binding === undefined || !bindingIds.has(binding.bindingId)) return undefined; - return binding.initializers.length === 0 ? "factory-loader" : "unknown"; + return semanticBindingHasPriorValueWrite(node, binding, state) + ? "unknown" + : "factory-loader"; }; const isSemanticCreateRequireBinding = ( @@ -186,40 +254,414 @@ const isSemanticCreateRequireBinding = ( ): boolean => { if (binding.initializers.length !== 1) return false; const initializer = semanticStableInitializer(binding, state)?.node; - if (!t.isCallExpression(initializer)) return false; - const callee = unwrapJavaScriptExpression(initializer.callee).node; - if (t.isIdentifier(callee)) { - const origins = resolveSemanticBindingState( - state, - callee, - callee.name, - )?.directOrigins; - if (origins?.length !== 1) return false; - const [origin] = origins; - return ( - origin?.importedPath.at(-1) === "createRequire" && - ["module", "node:module"].includes(origin.specifier) - ); - } - const chain = semanticMemberChain(callee); - if (chain === undefined || chain.members.at(-1) !== "createRequire") - return false; - if (!t.isNode(chain.root)) return false; - const root = unwrapJavaScriptExpression(chain.root).node; - if (!t.isIdentifier(root)) return false; - const origins = resolveSemanticBindingState( - state, - root, - root.name, - )?.directOrigins; - if (origins?.length !== 1) return false; - const [origin] = origins; return ( - origin?.importedPath.length === 0 && - ["module", "node:module"].includes(origin.specifier) + createRequireFactoryCallAt(initializer, state)?.loader === "create-require" ); }; +/** Retain the factory's actual certainty for both proof and eligibility consumers. */ +const createRequireFactoryCallAt = ( + initializer: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet = new Set(), +): Pick | undefined => + !t.isCallExpression(initializer) + ? undefined + : selectedRequireSourcesAt( + initializer.callee, + initializer, + state, + factoryBindingIds, + "factory", + ); + +/** Eligibility for observable loader slots; an unknown factory is not invocation proof. */ +export const hasSemanticCreateRequireFactoryOrigin = ( + initializer: t.Node | undefined, + state: JavaScriptSemanticAnalysisState, +): initializer is t.CallExpression => { + const factory = createRequireFactoryCallAt(initializer, state); + return factory !== undefined && factory.loader !== "local"; +}; + +const isCreateRequireOrigin = ( + origin: JavaScriptModuleOrigin, + selected: SemanticAliasSelectionPath, +): boolean => { + if (!["module", "node:module"].includes(origin.specifier)) return false; + const path = [...origin.importedPath, ...selected]; + return ( + (path.length === 1 && path[0] === "createRequire") || + ((origin.resolutionMode ?? "import") === "import" && + path.length === 2 && + path[0] === "default" && + path[1] === "createRequire") + ); +}; + +/** Assignment expressions expose their RHS; short-circuit stores may keep the old value. */ +export const assignmentValueSources = ( + node: t.AssignmentExpression, +): readonly t.Node[] => + node.operator === "=" + ? [node.right] + : ["||=", "&&=", "??="].includes(node.operator) + ? [node.left, node.right] + : []; + +/** Follow selected values to Node's captured loader or its creating factory. */ +const selectedRequireSourcesAt = ( + root: t.Node, + site: t.Node, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet, + target: "loader" | "factory" = "loader", +): Pick | undefined => { + const pending: ModuleObjectSource[] = [{ node: root, path: [] }]; + const seen = new WeakMap>(); + let count = 0; + let found = false; + let loaderBinding = false; + let selectedLoader: JavaScriptRequireLoaderFact | undefined; + let unknown = false; + let local = false; + let nativeMemberAlternative = false; + let sourceRelative = true; + while (pending.length > 0) { + const current = pending.pop()!; + const node = unwrapJavaScriptExpression(current.node).node; + const genericMember = + current.path.length === 1 && + semanticAliasSelectionChoices(current.path[0]!).every( + (key) => key === "call" || key === "apply", + ); + const key = JSON.stringify([current.path, loaderSourceContextKey(current)]); + const paths = seen.get(node) ?? new Set(); + if (paths.has(key)) continue; + paths.add(key); + seen.set(node, paths); + if (++count > SEMANTIC_ALIAS_SELECTION_LIMIT) { + state.valueResourceLimits.add("alias-selections"); + return { loader: "unknown", sourceRelative: false }; + } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + pending.push( + ...inheritLoaderSourceContext(current, [ + loaderMemberSource(current, node), + ]), + ); + } else if (t.isIdentifier(node)) { + if (target === "loader" && genericMember) + nativeMemberAlternative ||= + requireLoaderValueAt(node, node, state, factoryBindingIds) !== + "local"; + if (target === "loader" && current.path.length === 0) { + const captured = requireLoaderValueAt( + node, + node, + state, + factoryBindingIds, + ); + if (captured !== "local" && captured !== "create-require") { + const invoked = + captured === "commonjs-loader" + ? commonJsLoaderAt(site, state) + : captured; + found = true; + unknown ||= + invoked === "unknown" || + (selectedLoader !== undefined && selectedLoader !== invoked); + selectedLoader ??= invoked; + continue; + } + } + const binding = resolveSemanticBindingState(state, node, node.name); + if (binding === undefined) { + unknown = true; + continue; + } + // Eligibility is separate from the selected value: replacing a known + // loader with an opaque value keeps a gap, while a proven local function + // no longer claims the previous factory's base or identity. + loaderBinding ||= + target === "loader" && + binding.initializers.some( + (source) => + hasSemanticCreateRequireFactoryOrigin(source.node, state) || + (t.isIdentifier(source.node) && + requireLoaderValueAt( + source.node, + source.node, + state, + factoryBindingIds, + ) !== "local"), + ); + const facts = semanticProjectedSourceFactsAtRead( + binding, + node, + current.path, + state, + true, + current.readAt ?? node, + current.capturedSelections, + ); + if (target === "factory" && binding.directOrigins.length > 0) { + const native = binding.directOrigins.some((origin) => + isCreateRequireOrigin(origin, current.path), + ); + loaderBinding ||= native; + if (facts?.retainsOrigin) { + found ||= native; + local ||= !native; + if (native) selectedLoader = "create-require"; + } + } + if (facts === undefined) { + unknown = true; + const retained = semanticBindingAtRead( + binding, + node, + state.parentsByNode, + ).binding; + pending.push( + ...inheritLoaderSourceContext( + current, + projectedLoaderSources( + current, + [...retained.initializers, ...retained.referenceInitializers] + .filter( + (source) => !semanticReferenceSourcesInactive(source, state), + ) + .map((source) => ({ + node: source.node, + path: [...source.projection, ...current.path], + })), + ), + ), + ); + } else { + unknown ||= + facts.sources.length === 0 && + !( + target === "factory" && + facts.retainsOrigin && + binding.directOrigins.length > 0 + ); + pending.push( + ...inheritLoaderSourceContext( + current, + projectedLoaderSources(current, facts.sources), + ), + ); + } + } else if (t.isObjectExpression(node) || t.isArrayExpression(node)) { + if (current.path.length === 0) { + local = true; + continue; + } + const sources = loaderObjectPropertySources(current, node, state); + unknown ||= sources.length === 0; + pending.push(...inheritLoaderSourceContext(current, sources)); + } else if (t.isSequenceExpression(node)) { + const last = node.expressions.at(-1); + if (last !== undefined) pending.push({ ...current, node: last }); + } else if (t.isAssignmentExpression(node)) { + for (const source of assignmentValueSources(node)) + pending.push({ ...current, node: source }); + } else if (t.isConditionalExpression(node) || t.isLogicalExpression(node)) { + pending.push( + { + ...current, + node: t.isConditionalExpression(node) ? node.consequent : node.left, + }, + { + ...current, + node: t.isConditionalExpression(node) ? node.alternate : node.right, + }, + ); + } else if (t.isCallExpression(node)) { + if (target === "factory") { + const origin = semanticRequireOrigin(node, state)?.origin; + if ( + origin !== undefined && + isCreateRequireOrigin(origin, current.path) + ) { + found = true; + selectedLoader = "create-require"; + } else unknown = true; + } else if (current.path.length === 0 || genericMember) { + const factory = createRequireFactoryCallAt( + node, + state, + factoryBindingIds, + ); + if (factory !== undefined && factory.loader !== "local") { + if (genericMember) { + // A conditional own write retains this inherited native method. + // Alone it still belongs to the invocation owner's fallback. + nativeMemberAlternative = true; + if (factory.loader === "unknown") { + loaderBinding = true; + unknown = true; + } + } else { + found = true; + unknown ||= + factory.loader === "unknown" || + (selectedLoader !== undefined && + selectedLoader !== "create-require"); + selectedLoader ??= "create-require"; + unknown ||= state.conditionallySkippedExpressions.has(node); + sourceRelative &&= isUnmodifiedImportMetaUrl( + node.arguments[0], + state, + ); + } + } else if (factory?.loader === "local") local = true; + else unknown = true; + } else unknown = true; + } else if ( + current.path.length === 0 && + (t.isFunction(node) || t.isClassExpression(node) || t.isLiteral(node)) + ) { + local = true; + } else { + // Every selected expression contributes a possible value. An opaque + // alternative cannot leave another factory as the sole proven origin. + unknown = true; + } + } + return found || local || (loaderBinding && unknown) + ? { + loader: + (unknown && (found || loaderBinding)) || + (local && (found || nativeMemberAlternative)) + ? "unknown" + : found + ? (selectedLoader ?? "unknown") + : "local", + sourceRelative: found && sourceRelative && !unknown, + } + : undefined; +}; + +const loaderInvocationInterops = new WeakMap< + JavaScriptSemanticAnalysisState, + { + readonly writes: number; + readonly interop: ReturnType; + } +>(); + +const loaderInvocationInterop = (state: JavaScriptSemanticAnalysisState) => { + const prior = loaderInvocationInterops.get(state); + if (prior?.writes === state.ambientWriteEffects.length) return prior.interop; + const interop = createSemanticModuleInterop(state); + for (const effect of state.ambientWriteEffects) + if (effect.path[0] === "global") + interop.retainWrite(effect.node ?? state.program, [ + "intrinsic", + ...effect.path + .slice(1) + .map((key) => + key === null || typeof key === "object" ? null : String(key), + ), + ]); + loaderInvocationInterops.set(state, { + writes: state.ambientWriteEffects.length, + interop, + }); + return interop; +}; + +export interface JavaScriptRequireCallFact { + readonly loader: JavaScriptRequireLoaderFact; + readonly arguments: + | readonly t.CallExpression["arguments"][number][] + | undefined; + readonly sourceRelative: boolean; +} + +/** Classify the selected loader and normalize invocation arguments once. */ +export const semanticRequireCallAt = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + factoryBindingIds: ReadonlySet, +): JavaScriptRequireCallFact | undefined => { + if (semanticModuleRequireCallAt(node, state)) + return { loader: "unknown", arguments: undefined, sourceRelative: false }; + if (t.isNewExpression(node)) { + const loader = requireLoaderValueAt( + node.callee, + node, + state, + factoryBindingIds, + ); + const selected = selectedRequireSourcesAt( + node.callee, + node, + state, + factoryBindingIds, + ); + if (selected !== undefined || loader !== "local") + return { + loader: selected?.loader ?? loader, + arguments: node.arguments, + sourceRelative: selected?.sourceRelative ?? loader !== "create-require", + }; + } + const interop = loaderInvocationInterop(state); + if (!t.isNewExpression(node)) { + const invocation = interop.intrinsicInvocation(node, (callee) => { + const loader = requireLoaderValueAt( + callee, + node, + state, + factoryBindingIds, + ); + if (loader !== "local" && loader !== "create-require") + return ["require-loader", loader, "relative"]; + const selected = selectedRequireSourcesAt( + callee, + node, + state, + factoryBindingIds, + ); + if (selected !== undefined) + return [ + "require-loader", + selected.loader, + selected.sourceRelative ? "relative" : "unknown", + ]; + return interop.intrinsicPath(callee); + }); + if (invocation?.path[0] === "require-loader") + return { + loader: + invocation.path[1] === "local" + ? "local" + : invocation.kind === "opaque" + ? "unknown" + : (invocation.path[1] as JavaScriptRequireLoaderFact), + arguments: invocation.arguments, + sourceRelative: + invocation.kind === "known" && invocation.path[2] === "relative", + }; + } + const factory = semanticFactoryRequireLoaderAt( + node, + state, + factoryBindingIds, + ); + return factory === undefined + ? undefined + : { + loader: factory, + arguments: node.arguments.slice(t.isIdentifier(node.callee) ? 0 : 1), + sourceRelative: true, + }; +}; + /** The RHS call runs before its assignment replaces Node's wrapper loader. */ const isWrapperRequireInitializerCall = ( binding: NonNullable>, @@ -263,6 +705,494 @@ const moduleRequireWrites = new WeakMap< JavaScriptSemanticAnalysisState, readonly t.Node[] >(); +const moduleReturnedReferences = new WeakMap< + JavaScriptSemanticAnalysisState, + Map< + "direct-call" | "awaited-call", + ReturnType + > +>(); + +type ModuleObjectSource = { + readonly node: t.Node; + readonly path: SemanticAliasSelectionPath; + readonly eligibility?: true; + readonly readAt?: t.Node | undefined; + readonly capturedSelections?: + | readonly SemanticCapturedSelection[] + | undefined; +}; + +/** Both loader queries retain the same read and shallow-copy selection owner. */ +const loaderSourceContextKey = (source: ModuleObjectSource): string => + JSON.stringify([ + source.eligibility, + source.readAt?.start, + source.readAt?.end, + source.capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), + ]); + +const inheritLoaderSourceContext = ( + current: ModuleObjectSource, + sources: readonly ModuleObjectSource[], +): ModuleObjectSource[] => + sources.map((source) => ({ + ...(current.eligibility ? { eligibility: true as const } : {}), + ...(current.readAt === undefined ? {} : { readAt: current.readAt }), + ...source, + capturedSelections: semanticNormalizeCapturedSelections( + source.capturedSelections ?? current.capturedSelections, + source.path.length, + ), + })); + +const loaderMemberSource = ( + current: ModuleObjectSource, + member: t.MemberExpression | t.OptionalMemberExpression, +): ModuleObjectSource => ({ + node: member.object, + readAt: current.readAt ?? member, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + 1, + ), + path: [ + semanticStaticPropertyKey(member.property, member.computed), + ...current.path, + ], +}); + +const projectedLoaderSources = ( + current: ModuleObjectSource, + sources: readonly ModuleObjectSource[], +): ModuleObjectSource[] => + sources.map((source) => ({ + ...source, + readAt: + current.path.length === 0 ? undefined : (current.readAt ?? current.node), + capturedSelections: [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - current.path.length, + ) ?? []), + ...(source.capturedSelections ?? []), + ], + })); + +const loaderObjectPropertySources = ( + current: ModuleObjectSource, + object: t.ObjectExpression | t.ArrayExpression, + state: JavaScriptSemanticAnalysisState, +): ModuleObjectSource[] => { + const [key, ...remaining] = current.path; + if (key === undefined) return []; + return semanticAliasSelectionChoices(key).flatMap((choice) => + (t.isObjectExpression(object) + ? semanticObjectPropertyReferences(object, [choice], state) + : semanticArrayPropertyReferences(object, [choice]) + ) + .filter( + (source) => + !source.inherited || + !current.capturedSelections?.some( + (capture) => capture.depth === 1 && capture.ownEnumerable, + ), + ) + .map((source) => ({ + node: source.node, + path: [...source.path, ...remaining], + capturedSelections: [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - 1, + ) ?? []), + ...(source.capturedSelectionAt === undefined + ? [] + : [ + { + at: source.capturedSelectionAt, + depth: source.path.length, + ...(source.ownEnumerable + ? { ownEnumerable: true as const } + : {}), + }, + ]), + ], + })), + ); +}; + +const isCommonJsModuleObject = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, + includeUnknown = true, +): boolean => hasCommonJsModuleSource(node, state, includeUnknown, "module"); + +/** A captured delegate uses the same receiver and assignment ownership facts. */ +export const isCommonJsRequireDelegate = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, + includeUnknown = false, + readAt?: t.Node, +): boolean => + hasCommonJsModuleSource(node, state, includeUnknown, "delegate", readAt); + +const hasCommonJsModuleSource = ( + node: t.Node, + state: JavaScriptSemanticAnalysisState, + includeUnknown: boolean, + target: "module" | "delegate", + readAt?: t.Node, +): boolean => { + type Work = ModuleObjectSource & { + readonly ancestors: ReadonlySet; + /** Await occurs after this many requested outer property selections. */ + readonly awaitDepths: readonly number[]; + }; + const pending: Work[] = [ + { node, path: [], readAt, ancestors: new Set(), awaitDepths: [] }, + ]; + // Keep depths separate and join finite key alternatives at each source read. + const selections = new SemanticAliasSelections(() => { + state.valueResourceLimits.add("alias-selections"); + }); + const contexts = new WeakMap< + t.Node, + Map< + string, + { + readonly identities: Map; + } + > + >(); + const examined = new WeakSet(); + let additionalStates = 0; + for (let next = pending.pop(); next !== undefined; next = pending.pop()) { + const value = unwrapJavaScriptExpression(next.node).node; + const { awaitDepths } = next; + const byMode = + contexts.get(value) ?? + new Map }>(); + const modeKey = JSON.stringify([awaitDepths, loaderSourceContextKey(next)]); + let context = byMode.get(modeKey); + if (context === undefined) { + context = { identities: new Map() }; + byMode.set(modeKey, context); + contexts.set(value, byMode); + } + const path = selections.normalize(context, next.path); + if (selections.limited) return true; + const pathKey = JSON.stringify(path); + let identity = context.identities.get(pathKey); + if (identity === undefined) { + // Consumption contexts must not provide an unbounded second path to + // expansion beyond the shared selector budget. + if ( + (byMode.size > 1 || context.identities.size > 0) && + ++additionalStates > SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + state.valueResourceLimits.add("alias-selections"); + return true; + } + identity = {}; + context.identities.set(pathKey, identity); + } + if (next.ancestors.has(identity)) { + if (includeUnknown) return true; + continue; + } + if (examined.has(identity)) continue; + examined.add(identity); + const ancestors = new Set([...next.ancestors, identity]); + const enqueueAt = ( + depths: readonly number[], + ...sources: ModuleObjectSource[] + ) => { + pending.push( + ...inheritLoaderSourceContext(next, sources).map((source) => ({ + ...source, + ancestors, + awaitDepths: depths, + })), + ); + }; + const enqueue = (...sources: ModuleObjectSource[]) => { + enqueueAt(awaitDepths, ...sources); + }; + if (t.isAwaitExpression(value)) { + enqueueAt( + [...new Set([...awaitDepths, path.length])].sort( + (left, right) => left - right, + ), + { node: value.argument, path }, + ); + continue; + } + if (t.isMemberExpression(value) || t.isOptionalMemberExpression(value)) { + enqueue(loaderMemberSource({ ...next, path }, value)); + continue; + } + if ( + path.length > 0 && + (t.isObjectExpression(value) || t.isArrayExpression(value)) + ) { + enqueueAt( + // Awaiting a fresh container does not await one of its children. + awaitDepths.filter((depth) => depth !== path.length), + ...loaderObjectPropertySources({ ...next, path }, value, state), + ); + continue; + } + if (t.isConditionalExpression(value) || t.isLogicalExpression(value)) { + enqueue( + { + node: t.isConditionalExpression(value) + ? value.consequent + : value.left, + path, + }, + { + node: t.isConditionalExpression(value) + ? value.alternate + : value.right, + path, + }, + ); + continue; + } + if (t.isSequenceExpression(value)) { + const last = value.expressions.at(-1); + if (last !== undefined) enqueue({ node: last, path }); + continue; + } + if (t.isAssignmentExpression(value)) { + enqueue(...assignmentValueSources(value).map((node) => ({ node, path }))); + continue; + } + if ( + t.isCallExpression(value) || + t.isOptionalCallExpression(value) || + t.isNewExpression(value) + ) { + let factories = moduleReturnedReferences.get(state); + if (factories === undefined) { + factories = new Map(); + moduleReturnedReferences.set(state, factories); + } + const mode = awaitDepths.includes(path.length) + ? "awaited-call" + : "direct-call"; + let returned = factories.get(mode); + if (returned === undefined) { + returned = createSemanticReturnedReferences(state, mode); + factories.set(mode, returned); + } + const returnedResult = returned( + value.callee, + t.isNewExpression(value) ? undefined : value, + ); + if (returnedResult.unresolvedInvocation) return true; + enqueue( + ...returnedResult.references.map((source) => ({ + node: source.node, + path: [...source.projection, ...path], + capturedSelections: semanticShiftCapturedSelections( + next.capturedSelections, + source.projection.length, + ), + })), + ); + continue; + } + if (!t.isIdentifier(value)) continue; + const binding = resolveSemanticBindingState(state, value, value.name); + const processMainModulePath = + semanticGlobalProcessOrigin(value, state) !== undefined && + path.length === (target === "module" ? 1 : 2) && + semanticAliasSelectionChoices(path[0]!).some( + (key) => + key === null || semanticPropertyPathKeyMatches(key, "mainModule"), + ) && + (target === "module" || + semanticAliasSelectionChoices(path[1]!).some( + (key) => + key === null || semanticPropertyPathKeyMatches(key, "require"), + )); + if (processMainModulePath) return true; + const mainModulePath = + value.name === "require" && + state.program.sourceType === "script" && + path.length === (target === "module" ? 1 : 2) && + semanticAliasSelectionChoices(path[0]!).some( + (key) => key === null || semanticPropertyPathKeyMatches(key, "main"), + ) && + (target === "module" || + semanticAliasSelectionChoices(path[1]!).some( + (key) => + key === null || semanticPropertyPathKeyMatches(key, "require"), + )); + // Native wrapper require.main is a Module, with a process-relative loader. + // Its selected binding writes below can replace that source with local data. + if ( + mainModulePath && + binding === undefined && + !semanticResolutionBlocked(state, value, "require") + ) + return true; + const copyAdmitsModuleDelegate = + value.name !== "module" || + target !== "delegate" || + path.length !== 1 || + !next.capturedSelections?.some( + (capture) => + capture.depth === 1 && + capture.ownEnumerable && + !semanticCommonJsRequireOwnEnumerableAt(capture.at, state), + ); + if (value.name === "module" && binding === undefined) { + if ( + copyAdmitsModuleDelegate && + (target === "module" + ? path.length === 0 + : path.length === 1 && + semanticAliasSelectionChoices(path[0]!).some( + (key) => + key === null || semanticPropertyPathKeyMatches(key, "require"), + )) && + !semanticResolutionBlocked(state, value, "module") + ) + return true; + continue; + } + if (binding === undefined) continue; + const read = semanticBindingAtRead(binding, value, state.parentsByNode); + const retainsMainModule = + mainModulePath && + commonJsWrapperBindingDeclaration(value, "require", state) !== + undefined && + !read.hasDefiniteInitializer; + if ( + value.name === "module" && + copyAdmitsModuleDelegate && + (target === "module" + ? path.length === 0 + : path.length === 1 && + semanticAliasSelectionChoices(path[0]!).some( + (key) => + key === null || semanticPropertyPathKeyMatches(key, "require"), + )) && + commonJsWrapperBindingDeclaration(value, "module", state) !== undefined && + !read.hasDefiniteInitializer + ) + return true; + let eligibility = next.eligibility; + const facts = eligibility + ? undefined + : semanticProjectedSourceFactsAtRead( + binding, + value, + path, + state, + includeUnknown, + next.readAt ?? value, + next.capturedSelections, + ); + if (retainsMainModule && (eligibility || facts?.retainsOrigin)) return true; + let sources = eligibility + ? semanticProjectedSourceEligibilityAtRead( + binding, + value, + path, + state, + next.readAt ?? value, + next.capturedSelections, + ) + : facts?.sources; + if (sources === undefined) { + if (target !== "delegate") return true; + // An opaque member effect can obscure a selected native delegate, but + // cannot make every escaped callable a possible Node loader. Keep its + // receiver's retained sources as the eligibility boundary for that gap. + sources = semanticProjectedSourceEligibilityAtRead( + binding, + value, + path, + state, + next.readAt ?? value, + next.capturedSelections, + ); + if (sources === undefined) return true; + eligibility = true; + } + enqueue( + ...projectedLoaderSources({ ...next, node: value, path }, sources).map( + (source) => (eligibility ? { ...source, eligibility } : source), + ), + ); + } + return false; +}; + +/** Select an unchecked member-loader receiver before its identity is examined. */ +export const semanticModuleRequireReceiver = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, +): t.Node | undefined => { + let callee = unwrapJavaScriptExpression(node.callee).node; + while ( + (t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && + ["bind", "call", "apply"].includes( + semanticStaticPropertyKey(callee.property, callee.computed) ?? "", + ) + ) + // Capturing a bound delegate can load outside the visible module graph. + // Keep that possibility without asserting bind/call/apply is unmodified. + callee = unwrapJavaScriptExpression(callee.object).node; + return (t.isMemberExpression(callee) || + t.isOptionalMemberExpression(callee)) && + semanticStaticPropertyKey(callee.property, callee.computed) === "require" + ? callee.object + : undefined; +}; + +/** Recognize an unchecked module loader without asserting its current delegate. */ +export const semanticModuleRequireCallAt = ( + node: t.CallExpression | t.OptionalCallExpression | t.NewExpression, + state: JavaScriptSemanticAnalysisState, + includeUnknown = true, +): boolean => { + const receiver = semanticModuleRequireReceiver(node); + if ( + receiver !== undefined && + isCommonJsModuleObject(receiver, state, includeUnknown) + ) + return true; + let target = unwrapJavaScriptExpression(node.callee).node; + while ( + (t.isMemberExpression(target) || t.isOptionalMemberExpression(target)) && + ["bind", "call", "apply"].includes( + semanticStaticPropertyKey(target.property, target.computed) ?? "", + ) + ) + target = unwrapJavaScriptExpression(target.object).node; + // The callee is selected before the current invocation can escape or mutate + // its receiver. Earlier captured member reads retain their own source site. + if (isCommonJsRequireDelegate(target, state, includeUnknown, node)) + return true; + if (t.isNewExpression(node)) return false; + const interop = loaderInvocationInterop(state); + return ( + interop.intrinsicInvocation(node, (callee) => + isCommonJsRequireDelegate(callee, state, false, node) + ? ["commonjs-require-delegate"] + : interop.intrinsicPath(callee), + )?.path[0] === "commonjs-require-delegate" + ); +}; const writesModuleRequire = ( state: JavaScriptSemanticAnalysisState, @@ -274,24 +1204,10 @@ const writesModuleRequire = ( moduleRequireWrites.set(state, []); const writes: t.Node[] = []; const interop = createSemanticModuleInterop(state); - const isModuleObject = ( - node: t.Node, - visited = new Set(), - ): boolean => { - const value = unwrapJavaScriptExpression(node).node; - if (!t.isIdentifier(value)) return false; - if (value.name === "module") - return resolveSemanticBindingState(state, value, "module") === undefined - ? !semanticResolutionBlocked(state, value, "module") - : isUnmodifiedCommonJsWrapperBinding(value, "module", state); - const binding = resolveSemanticBindingState(state, value, value.name); - if (binding === undefined || visited.has(binding.bindingId)) return false; - const next = new Set(visited).add(binding.bindingId); - return ( - binding.initializers.length > 0 && - binding.initializers.every(({ node: init }) => isModuleObject(init, next)) - ); - }; + const isModuleObject = (node: t.Node): boolean => + // Only a retained module identity can revoke the host delegate. Escaping + // an unrelated local table is not evidence that it became the host module. + isCommonJsModuleObject(node, state, false); traverseJavaScriptAst(state.program, { enter: (node) => { const target = t.isAssignmentExpression(node) @@ -300,34 +1216,15 @@ const writesModuleRequire = ( t.isUnaryExpression(node, { operator: "delete" }) ? node.argument : undefined; - if (target !== undefined) { - const members: string[] = []; - let root: t.Node = target; - while ( - t.isMemberExpression(root) || - t.isOptionalMemberExpression(root) - ) { - const key = semanticStaticPropertyKey(root.property, root.computed); - if (key === null) break; - members.unshift(key); - root = root.object; - } + if ( + t.isMemberExpression(target) || + t.isOptionalMemberExpression(target) + ) { + const key = semanticStaticPropertyKey(target.property, target.computed); if ( - members.length === 1 && - members[0] === "require" && - isModuleObject(root) - ) - writes.push(node); - else if ( - members.length === 0 && - (t.isMemberExpression(target) || - t.isOptionalMemberExpression(target)) && - target.computed && - semanticStaticPropertyKey(target.property, true) === null && + (key === "require" || (key === null && target.computed)) && isModuleObject(target.object) ) - // An unknown computed key may be "require"; retain uncertainty rather - // than treating this direct delegate write as a proven unrelated slot. writes.push(node); } if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { @@ -508,17 +1405,7 @@ export const semanticRequireOrigin = ( !t.isIdentifier(current.callee, { name: "require" }) ) return undefined; - if ( - writesModuleRequire(state).some( - (write) => - !semanticTopLevelEffectFollowsCapture( - current, - write, - state.parentsByNode, - ), - ) - ) - return undefined; + if (commonJsDelegateChangedAt(current, state)) return undefined; const requireBinding = resolveSemanticBindingState( state, current.callee, @@ -553,13 +1440,25 @@ export const semanticRequireOrigin = ( }; }; -/** Node's fs.promises export and fs/promises require share one object. */ +/** Node's exported child objects retain their direct builtin-module identity. */ export const semanticRequireExportsIdentity = ( specifier: string, path: readonly Key[], ): { readonly specifier: string; readonly path: readonly (Key | string)[] } => { const name = specifier.replace(/^node:/u, ""); if (name === "fs/promises") return { specifier: name, path }; + if (["path", "path/posix", "path/win32"].includes(name)) { + let selected = name; + let remaining = path; + while ( + remaining.length > 1 && + (remaining[0] === "posix" || remaining[0] === "win32") + ) { + selected = `path/${remaining[0]}`; + remaining = remaining.slice(1); + } + return { specifier: selected, path: remaining }; + } return name === "fs" && path[0] === "promises" && path.length > 1 ? { specifier: "fs/promises", path: path.slice(1) } : { specifier: name, path }; @@ -572,6 +1471,10 @@ const MUTABLE_BUILTIN_DEFAULTS = new Set([ "child_process", "timers", "process", + "path", + "path/posix", + "path/win32", + "url", ]); /** Mutable default/object selections share CommonJS identity; namespace slots do not. */ diff --git a/src/domain/javascript/javascriptSemanticResourceLimits.ts b/src/domain/javascript/javascriptSemanticResourceLimits.ts index 52f836da24..5a94e90e80 100644 --- a/src/domain/javascript/javascriptSemanticResourceLimits.ts +++ b/src/domain/javascript/javascriptSemanticResourceLimits.ts @@ -9,6 +9,8 @@ export const SEMANTIC_PRIMITIVE_CANDIDATE_LIMIT = 256; export const SEMANTIC_EXPRESSION_DEPTH_LIMIT = 256; /** Getter jobs and local result work retained in one precise query. */ export const SEMANTIC_GETTER_EXPANSION_LIMIT = 512; +/** Additional selector states retained by one precise alias query. */ +export const SEMANTIC_ALIAS_SELECTION_LIMIT = 512; /** Bound normalized strings and transient keys independently of transport limits. */ export const SEMANTIC_PRIMITIVE_JSON_BYTES_LIMIT = 1_048_576; @@ -21,6 +23,8 @@ const RESOURCE_LIMIT_REASON: Record = { "Semantic expression depth budget exceeded (maximum 256 nested expressions).", "getter-expansion": "Getter expansion was conservatively bounded (maximum 512 scheduled states or a growing receiver cycle).", + "alias-selections": + "Alias selection expansion was conservatively bounded (maximum 512 additional selector states); selected paths remain unknown.", }; /** Check UTF-16 length against worst-case JSON escaping without flattening. */ diff --git a/src/domain/javascript/javascriptSemanticResponseAssignments.test.ts b/src/domain/javascript/javascriptSemanticResponseAssignments.test.ts new file mode 100644 index 0000000000..73b39fbdee --- /dev/null +++ b/src/domain/javascript/javascriptSemanticResponseAssignments.test.ts @@ -0,0 +1,81 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; + +it.each([ + ["response.json();", "/original"], + ["response=other;response.json();", null], + ["response.json();response=other;", "/original"], + ["if(flag)response=other;response.json();", null], + ["({response}={response:other});response.json();", null], + ["response.json();({response}={response:other});", "/original"], + ["if(flag)({response}={response:other});response.json();", null], + ["response.json();if(flag)({response}={response:other});", "/original"], + ["[response]=[other];response.json();", null], + ["response.json();[response]=[other];", "/original"], + ["if(flag)[response]=[other];response.json();", null], + ["response.json();if(flag)[response]=[other];", "/original"], +])( + "retains request endpoints only before response replacement: %s", + (body, endpoint) => { + const result = analyzeJavaScriptSemantics( + `let response=await fetch('/original');${body}`, + ); + const request = result.requestOperations.find( + (operation) => operation.kind === "request", + ); + expect(request?.endpoint).toBe("/original"); + const consumers = result.requestOperations.filter( + (operation) => operation.kind === "response-consumer", + ); + expect(consumers).toHaveLength(1); + expect(consumers[0]).toMatchObject({ endpoint, resolution: "complete" }); + expect(consumers[0]?.linkedRequestIds).toEqual([request?.requestId]); + }, +); + +it("does not prove an incoming response was initialized by a conditional request", () => { + const result = analyzeJavaScriptSemantics( + "let response;if(flag)response=await fetch('/original');response.json();", + ); + expect( + result.requestOperations.filter( + (operation) => operation.kind === "response-consumer", + ), + ).toEqual([ + expect.objectContaining({ endpoint: null, resolution: "complete" }), + ]); +}); + +it.each([ + "let response=flag?await fetch('/original'):other;response.json();", + "let response=other||(await fetch('/original'));response.json();", +])( + "does not invent a response link through conditional value combinations: %s", + (source) => { + const result = analyzeJavaScriptSemantics(source); + expect( + result.requestOperations.filter( + (operation) => operation.kind === "request", + ), + ).toEqual([expect.objectContaining({ endpoint: "/original" })]); + expect( + result.requestOperations.filter( + (operation) => operation.kind === "response-consumer", + ), + ).toEqual([]); + }, +); + +it("keeps the existing contract for a response linked to multiple requests", () => { + const result = analyzeJavaScriptSemantics( + "let response=await fetch('/original');response=await fetch('/other');response.json();", + ); + expect( + result.requestOperations.filter( + (operation) => operation.kind === "response-consumer", + ), + ).toEqual([ + expect.objectContaining({ endpoint: null, resolution: "partial" }), + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticRestAssignmentCaptures.test.ts b/src/domain/javascript/javascriptSemanticRestAssignmentCaptures.test.ts new file mode 100644 index 0000000000..9e8cce3189 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticRestAssignmentCaptures.test.ts @@ -0,0 +1,110 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; + +it.each([ + [ + "let box;const old={loader:module};({...box}=old);old.loader={};box.loader", + true, + ], + [ + "let box;const old={loader:{}};({...box}=old);old.loader=module;box.loader", + false, + ], + [ + "const holder={};const old={loader:module};({...holder.box}=old);old.loader={};holder.box.loader", + true, + ], + [ + "const holder={};const old={loader:{}};({...holder.box}=old);old.loader=module;holder.box.loader", + false, + ], + ["let box;const old=[module];([...box]=old);old[0]={};box[0]", true], + ["let box;const old=[{}];([...box]=old);old[0]=module;box[0]", false], + [ + "const holder={};const old=[module];([...holder.box]=old);old[0]={};holder.box[0]", + true, + ], + [ + "const holder={};const old=[{}];([...holder.box]=old);old[0]=module;holder.box[0]", + false, + ], + [ + "let box;const old={make:()=>module};({...box}=old);old.make=()=>({});box.make()", + true, + ], + [ + "let box;const old={make:()=>({})};({...box}=old);old.make=()=>module;box.make()", + false, + ], + [ + "const holder={};const old={make:()=>module};({...holder.box}=old);old.make=()=>({});holder.box.make()", + true, + ], + [ + "const holder={};const old={make:()=>({})};({...holder.box}=old);old.make=()=>module;holder.box.make()", + false, + ], + [ + "let box;const old=[()=>module];([...box]=old);old[0]=()=>({});box[0]()", + true, + ], + [ + "let box;const old=[()=>({})];([...box]=old);old[0]=()=>module;box[0]()", + false, + ], + [ + "const holder={};const old=[()=>module];([...holder.box]=old);old[0]=()=>({});holder.box[0]()", + true, + ], + [ + "const holder={};const old=[()=>({})];([...holder.box]=old);old[0]=()=>module;holder.box[0]()", + false, + ], + [ + "let box;const old={child:{loader:module}};({child:{...box}}=old);old.child={loader:{}};box.loader", + true, + ], + [ + "const holder={};const old={child:[module]};({child:[...holder.box]}=old);old.child=[{}];holder.box[0]", + true, + ], + [ + "const holder={};const old={child:{loader:module}};({...holder.box}=old);old.child.loader={};holder.box.child.loader", + false, + ], + ["let make=()=>module;({make}={make:()=>({})});make()", false], + ["let make;({make=()=>module}={});make()", true], + ["let make;({make=()=>module}={make:()=>({})});make()", false], + ["let make=()=>module;if(flag)({make}={make:()=>({})});make()", true], + [ + "let make=()=>module;const saved=make;({make}={make:()=>({})});saved()", + true, + ], +])( + "retains assigned rest-copy slots at their capture: %s", + (receiver, expected) => { + const result = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(result.parse_error_count).toBe(0); + expect(result.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each(["[value]=['new']", "({key:value}={key:'new'})"])( + "preserves whole-binding and read-value ambiguity after %s", + (assignment) => { + const result = analyzeJavaScriptSemantics( + `let value='old';${assignment};const observed=value;`, + ); + expect(topLevelBinding(result, "value").value.status).toBe("ambiguous"); + expect(topLevelBinding(result, "observed").value.status).toBe("ambiguous"); + }, +); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferences.ts b/src/domain/javascript/javascriptSemanticReturnedReferences.ts index 403a2c01a8..47e3f4c977 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferences.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferences.ts @@ -5,14 +5,43 @@ import { unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; import { semanticCallableResultExpressions } from "./javascriptSemanticCallableResults.js"; -import { semanticBindingAtRead } from "./javascriptSemanticMutationInitializers.js"; import { - semanticArrayIndex, + javaScriptSourceInvocationContext, + javaScriptGenericInvocationMethod, + resolveJavaScriptGenericInvocation, + type JavaScriptSourceInvocationContext, +} from "./javascriptIntrinsicInvocation.js"; +import { + semanticBindingAtRead, + semanticEffectTiming, +} from "./javascriptSemanticMutationInitializers.js"; +import { + semanticReferenceSourcesInactive, + semanticKnownArrayIteration, + semanticKnownArrayIterationAt, + semanticSelectedReferenceSlot, +} from "./javascriptSemanticReferenceSources.js"; +import { semanticArrayPropertyReferences } from "./javascriptSemanticObjectReferences.js"; +import { + semanticNormalizeCapturedSelections, + semanticProjectedSourcesAtRead, + semanticShiftCapturedSelections, + type SemanticCapturedSelection, +} from "./javascriptSemanticProjectedSources.js"; +import { SEMANTIC_ALIAS_SELECTION_LIMIT } from "./javascriptSemanticResourceLimits.js"; +import { + semanticPropertyPathKeyMatches, type JavaScriptSemanticPropertyPath, } from "./javascriptSemanticPropertyPaths.js"; +import { + SemanticAliasSelections, + semanticAliasSelectionChoices, + type SemanticAliasSelectionPath, +} from "./javascriptSemanticAliasSelections.js"; import { resolveSemanticBindingState, type JavaScriptSemanticAnalysisState, + type JavaScriptSemanticBindingState, } from "./javascriptSemanticState.js"; /** A reference potentially handed to a caller or generator consumer. */ @@ -24,34 +53,23 @@ export interface SemanticReturnedReference { interface Candidate { readonly node: t.Node; /** `undefined` means any projection depth after different projection depths merge. */ - readonly path: readonly (string | number | null)[] | undefined; + readonly path: SemanticAliasSelectionPath | undefined; + /** Member selection happens at its use, while a captured function keeps its read. */ + readonly readAt?: t.Node; + readonly capturedSelections?: + | readonly SemanticCapturedSelection[] + | undefined; + readonly invocation?: JavaScriptSourceInvocationContext; } export interface SemanticReturnedReferenceResult { readonly references: readonly SemanticReturnedReference[]; + /** A visible invocation-method mutation prevents a source-local result proof. */ + readonly unresolvedInvocation?: boolean; /** Both states close the supported source-local may-target graph. Widened joins projections without dropping represented targets; it does not claim runtime completeness. */ readonly precision: "exact" | "widened"; } -const joinProjection = ( - left: Candidate["path"], - right: Candidate["path"], -): Candidate["path"] => { - if (left === undefined || right === undefined || left.length !== right.length) - return undefined; - return left.map((key, index) => (key === right[index] ? key : null)); -}; - -const sameProjection = ( - left: Candidate["path"], - right: Candidate["path"], -): boolean => - left === undefined - ? right === undefined - : right !== undefined && - left.length === right.length && - left.every((key, index) => key === right[index]); - interface ObjectTargetIndex { readonly propertyByName: ReadonlyMap< string, @@ -71,49 +89,301 @@ interface ObjectTargetUnknown { /** Resolve local result references conservatively, without claiming call execution. */ export const createSemanticReturnedReferences = ( state: JavaScriptSemanticAnalysisState, -): ((callee: t.Node) => SemanticReturnedReferenceResult) => { - const results = new WeakMap(); + consumption: + | "potential-reference" + | "direct-call" + | "awaited-call" = "potential-reference", +): (( + callee: t.Node, + invocation?: t.CallExpression | t.OptionalCallExpression, +) => SemanticReturnedReferenceResult) => { + const results = new WeakMap< + t.Node, + { + readonly result: SemanticReturnedReferenceResult; + readonly invocation: t.Node | undefined; + readonly iterationUnknown: boolean; + readonly ambientWrites: number; + readonly dependencies: readonly { + readonly binding: JavaScriptSemanticBindingState; + readonly writes: number; + readonly escapes: number; + }[]; + } + >(); const objectTargets = new WeakMap(); - return (callee) => { + return (callee, invocation) => { const existing = results.get(callee); - if (existing !== undefined) return existing; + if ( + existing !== undefined && + existing.invocation === invocation && + existing.iterationUnknown === state.arrayIterationUnknown && + existing.ambientWrites === state.ambientWriteEffects.length && + existing.dependencies.every( + ({ binding, writes, escapes }) => + binding.memberWrites.length === writes && + binding.escapedPaths.length === escapes, + ) + ) + return existing.result; + const dependencies = new Map< + JavaScriptSemanticBindingState, + { readonly writes: number; readonly escapes: number } + >(); const functions = new Set(); - const work: t.Node[] = []; + const work: Candidate[] = []; // A transition depends on the source read and current projection, not the // initializer history used to reach it. Keeping history out of the key lets // shared diamonds and reassignment cycles converge to the graph fixed point. - const projections = new WeakMap(); - const queued = new WeakSet(); + const projectionDepths = new WeakMap< + t.Node, + Map + >(); + const expanded = new WeakMap>(); + let additionalContexts = 0; + let contextsWidened = false; const result: { references: readonly SemanticReturnedReference[]; precision: SemanticReturnedReferenceResult["precision"]; + unresolvedInvocation?: boolean; } = { references: [], precision: "exact" }; - const enqueue = (node: t.Node, path: Candidate["path"]): void => { - const source = unwrapJavaScriptExpression(node).node; - if (!projections.has(source)) { - projections.set(source, path); - } else { - const previous = projections.get(source); - const joined = joinProjection(previous, path); - if (sameProjection(previous, joined)) return; - projections.set(source, joined); - result.precision = "widened"; - } - if (!queued.has(source)) { - work.push(source); - queued.add(source); - } + // The invocation owner retains the actual callable receiver. A bare alias + // of fn.call has lost that receiver and cannot execute fn by itself. + const nativeArrayIteration = (array: t.ArrayExpression) => + semanticKnownArrayIterationAt(array, state); + const sourceInvocation = + invocation === undefined + ? undefined + : javaScriptSourceInvocationContext(invocation, nativeArrayIteration); + const sourceGenericInvocation = + invocation !== undefined && + javaScriptGenericInvocationMethod(invocation.callee) !== undefined; + const inheritedInvocationChanged = (method: "call" | "apply"): boolean => { + if (invocation === undefined) return false; + const inheritedPath = ["global", "Function", "prototype", method]; + return state.ambientWriteEffects.some( + (effect) => + effect.path.length <= inheritedPath.length && + effect.path.every( + (key, index) => + key === null || + semanticPropertyPathKeyMatches(key, inheritedPath[index] ?? ""), + ) && + semanticEffectTiming(invocation, effect.node, state) !== "after", + ); }; - enqueue(callee, []); + // Exact finite alternatives retain unrelated keys and cross-path + // correlations. Growing/shrinking cycles still converge at any depth; + // selector-budget widening retains the complete may-target closure. + const selections = new SemanticAliasSelections(() => { + state.valueResourceLimits.add("alias-selections"); + result.precision = "widened"; + }); + const enqueue = ( + node: t.Node, + path: Candidate["path"], + readAt?: t.Node, + capturedSelections?: readonly SemanticCapturedSelection[], + invocationContext?: JavaScriptSourceInvocationContext, + ): void => { + const source = unwrapJavaScriptExpression(node).node; + if (contextsWidened) { + path = undefined; + capturedSelections = undefined; + invocationContext = undefined; + } + const invocationKey = () => + invocationContext === undefined + ? null + : [ + invocationContext.receiver?.start, + invocationContext.receiver?.end, + invocationContext.receiverUnknown, + invocationContext.arguments?.map((argument) => [ + argument.start, + argument.end, + ]), + ]; + let readKey = JSON.stringify([ + readAt?.start ?? -1, + readAt?.end ?? -1, + invocationKey(), + ]); + const depths = projectionDepths.get(source) ?? new Map(); + projectionDepths.set(source, depths); + if (!depths.has(readKey)) { + depths.set(readKey, path?.length); + } else { + const previous = depths.get(readKey); + if (previous === undefined || previous !== path?.length) { + if (previous !== undefined) result.precision = "widened"; + depths.set(readKey, undefined); + path = undefined; + } + } + if (path !== undefined) path = selections.normalize(source, path); + capturedSelections = + path === undefined + ? undefined + : semanticNormalizeCapturedSelections( + capturedSelections, + path.length, + ); + const expansionKey = () => + JSON.stringify([ + readKey, + path ?? null, + capturedSelections?.map(({ at, depth, ownEnumerable }) => [ + at.start, + at.end, + depth, + ownEnumerable, + ]), + ]); + let key = expansionKey(); + const paths = expanded.get(source) ?? new Set(); + if (paths.has(key)) return; + // Copy/invocation contexts add selector dimensions. Bound them by the + // same query budget, then visit the complete unknown-projection closure. + if ( + !contextsWidened && + (capturedSelections !== undefined || invocationContext !== undefined) && + paths.size > 0 && + ++additionalContexts > SEMANTIC_ALIAS_SELECTION_LIMIT + ) { + contextsWidened = true; + state.valueResourceLimits.add("alias-selections"); + result.precision = "widened"; + // Generic transitions select only the source receiver and authored + // argument values. Keep that complete finite source closure when their + // precise positional contexts merge, including later callable receivers. + if (sourceInvocation !== undefined) { + if (sourceInvocation.receiver !== undefined) + enqueue(sourceInvocation.receiver, undefined); + for (const argument of sourceInvocation.arguments ?? []) + if (t.isExpression(argument)) enqueue(argument, undefined); + else if (t.isSpreadElement(argument)) + enqueue(argument.argument, undefined); + } + } + if (contextsWidened) { + path = undefined; + capturedSelections = undefined; + invocationContext = undefined; + readKey = JSON.stringify([ + readAt?.start ?? -1, + readAt?.end ?? -1, + null, + ]); + if (sourceGenericInvocation) result.unresolvedInvocation = true; + depths.set(readKey, undefined); + key = expansionKey(); + if (paths.has(key)) return; + } + paths.add(key); + expanded.set(source, paths); + work.push({ + node: source, + path, + ...(readAt === undefined ? {} : { readAt }), + ...(capturedSelections === undefined ? {} : { capturedSelections }), + ...(invocationContext === undefined + ? {} + : { invocation: invocationContext }), + }); + }; + enqueue(callee, [], undefined, undefined, sourceInvocation); while (work.length > 0) { - const node = work.pop(); - if (node === undefined) break; - queued.delete(node); - const current: Candidate = { node, path: projections.get(node) }; + const current = work.pop(); + if (current === undefined) break; + const { node } = current; + // An exact empty selection with an invocation is the value being called, + // not a container whose callable children are potential escape targets. + // Ordinary objects and arrays cannot supply that callable receiver. An + // unknown projection still visits the full closure after budget widening. + if ( + current.invocation !== undefined && + current.path?.length === 0 && + (t.isObjectExpression(node) || t.isArrayExpression(node)) + ) + continue; const pending: Candidate[] = []; if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(state, node, node.name); - if (binding === undefined) continue; + if (binding === undefined) { + if (sourceGenericInvocation && current.path?.length === 0) + result.unresolvedInvocation = true; + continue; + } + dependencies.set(binding, { + writes: binding.memberWrites.length, + escapes: binding.escapedPaths.length, + }); + if (current.path !== undefined) { + if ( + sourceGenericInvocation && + current.path.length > 0 && + semanticProjectedSourcesAtRead( + binding, + node, + current.path, + state, + true, + // The current invocation receives its callee before its own + // opaque effects; earlier captured member reads keep their site. + invocation !== undefined && + current.readAt === + unwrapJavaScriptExpression(invocation.callee).node + ? invocation + : (current.readAt ?? node), + current.capturedSelections, + ) === undefined + ) + result.unresolvedInvocation = true; + const selectedSources = semanticProjectedSourcesAtRead( + binding, + node, + current.path, + state, + false, + current.readAt ?? node, + current.capturedSelections, + ); + if (selectedSources !== undefined) { + for (const source of selectedSources) { + const capturedSelections = [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + source.path.length - current.path.length, + ) ?? []), + ...(source.capturedSelections ?? []), + ]; + pending.push({ + ...source, + ...(current.path.length === 0 + ? {} + : { readAt: current.readAt ?? node }), + ...(capturedSelections.length === 0 + ? {} + : { capturedSelections }), + }); + } + for (let index = pending.length - 1; index >= 0; index--) { + const child = pending[index]; + if (child !== undefined) + enqueue( + child.node, + child.path, + child.readAt, + child.capturedSelections, + child.invocation ?? current.invocation, + ); + } + continue; + } + result.precision = "widened"; + if (sourceGenericInvocation) result.unresolvedInvocation = true; + } const selected = semanticBindingAtRead( binding, node, @@ -123,14 +393,31 @@ export const createSemanticReturnedReferences = ( ...selected.initializers, ...selected.referenceInitializers, ]) { + if (semanticReferenceSourcesInactive(initializer, state)) continue; pending.push({ node: initializer.node, path: current.path === undefined ? undefined : [...initializer.projection, ...current.path], + ...(current.path?.length === 0 + ? {} + : { readAt: current.readAt ?? node }), + ...(current.capturedSelections === undefined + ? {} + : { + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + initializer.projection.length, + ), + }), }); } + if (current.path === undefined) + for (const effect of binding.memberWrites) + for (const source of effect.assignedSources ?? []) + if (!semanticReferenceSourcesInactive(source, state)) + pending.push({ node: source.node, path: undefined }); } else if ( t.isMemberExpression(node) || t.isOptionalMemberExpression(node) @@ -149,6 +436,15 @@ export const createSemanticReturnedReferences = ( pending.push({ ...current, node: receiver, + readAt: current.readAt ?? node, + ...(current.capturedSelections === undefined + ? {} + : { + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + path.length, + ), + }), path: current.path === undefined ? undefined @@ -171,19 +467,94 @@ export const createSemanticReturnedReferences = ( } else if (t.isSequenceExpression(node)) { const last = node.expressions.at(-1); if (last !== undefined) pending.push({ ...current, node: last }); - } else if ( - t.isFunction(node) && - (current.path === undefined || current.path.length === 0) - ) - functions.add(node); - else if (t.isObjectExpression(node)) + } else if (t.isFunction(node)) { + if (current.path === undefined || current.path.length === 0) + functions.add(node); + else if ( + current.invocation !== undefined && + current.path.every((key) => + semanticAliasSelectionChoices(key).every( + (choice) => choice === "call" || choice === "apply", + ), + ) + ) { + // Own member writes have already selected their actual RHS above. + // Only a remaining inherited invocation member reaches this branch. + const methods = semanticAliasSelectionChoices( + current.path.at(-1)!, + ).filter( + (method): method is "call" | "apply" => + method === "call" || method === "apply", + ); + for (const method of methods) { + const selected = resolveJavaScriptGenericInvocation( + method, + current.invocation, + nativeArrayIteration, + ); + if (selected !== undefined) + pending.push({ + node: selected.callable, + path: [], + invocation: selected.context, + }); + else if (current.invocation.receiverUnknown) + result.unresolvedInvocation = true; + if (inheritedInvocationChanged(method)) + result.unresolvedInvocation = true; + } + } + } else if (t.isObjectExpression(node)) selectObjectTargets(node, current, pending, objectTargets); else if (t.isArrayExpression(node)) { const [key, ...path] = current.path ?? []; - const index = - current.path === undefined || key === undefined || key === null - ? null - : semanticArrayIndex(key); + if (current.path !== undefined && key !== undefined) { + for (const choice of semanticAliasSelectionChoices(key)) + for (const source of semanticArrayPropertyReferences(node, [ + choice, + ])) { + let projection = source.path; + if ( + source.capturedSelectionAt !== undefined && + !semanticKnownArrayIteration( + semanticSelectedReferenceSlot( + { node: source.node, projection: [] }, + state, + source.capturedSelectionAt, + )?.value, + state, + ) + ) + projection = [null, ...source.path.slice(1)]; + const selectedPath = [...projection, ...path]; + pending.push({ + ...current, + node: source.node, + path: selectedPath, + capturedSelections: [ + ...(semanticShiftCapturedSelections( + current.capturedSelections, + selectedPath.length - current.path.length, + ) ?? []), + ...(source.capturedSelectionAt === undefined + ? [] + : [{ at: source.capturedSelectionAt, depth: 1 }]), + ], + }); + } + for (let index = pending.length - 1; index >= 0; index--) { + const child = pending[index]; + if (child !== undefined) + enqueue( + child.node, + child.path, + child.readAt, + child.capturedSelections, + child.invocation ?? current.invocation, + ); + } + continue; + } let uncertainOffset = false; node.elements.forEach((element, offset) => { if (element === null) return; @@ -193,12 +564,28 @@ export const createSemanticReturnedReferences = ( ...current, node: element.argument, path: current.path === undefined ? undefined : [null, ...path], + capturedSelections: [ + ...(current.capturedSelections ?? []), + { at: element.argument, depth: 1 }, + ], }); - } else if (index === null || uncertainOffset || index === offset) + } else if ( + current.path === undefined || + key === undefined || + uncertainOffset || + semanticAliasSelectionChoices(key).some((choice) => + semanticPropertyPathKeyMatches(choice, String(offset)), + ) + ) pending.push({ ...current, node: element, path: current.path === undefined ? undefined : path, + capturedSelections: + semanticShiftCapturedSelections( + current.capturedSelections, + -1, + ) ?? [], }); }); } else if (t.isNewExpression(node)) @@ -212,14 +599,25 @@ export const createSemanticReturnedReferences = ( member.kind === "constructor") || current.path === undefined || (key !== undefined && - (key === null || - semanticStaticPropertyKey(member.key, member.computed) === - String(key))) + semanticAliasSelectionChoices(key).some((choice) => { + const name = semanticStaticPropertyKey( + member.key, + member.computed, + ); + return ( + name === null || + semanticPropertyPathKeyMatches(choice, name) + ); + })) ) pending.push({ ...current, node: member, path: current.path === undefined ? undefined : path, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + -1, + ), }); } else if ( t.isClassProperty(member) && @@ -227,14 +625,26 @@ export const createSemanticReturnedReferences = ( member.value !== undefined && (key !== undefined || current.path === undefined) && (current.path === undefined || - key === null || - semanticStaticPropertyKey(member.key, member.computed) === - String(key)) + (key !== undefined && + semanticAliasSelectionChoices(key).some((choice) => { + const name = semanticStaticPropertyKey( + member.key, + member.computed, + ); + return ( + name === null || + semanticPropertyPathKeyMatches(choice, name) + ); + }))) ) pending.push({ ...current, node: member.value, path: current.path === undefined ? undefined : path, + capturedSelections: semanticShiftCapturedSelections( + current.capturedSelections, + -1, + ), }); } if (node.superClass !== null && node.superClass !== undefined) @@ -242,17 +652,45 @@ export const createSemanticReturnedReferences = ( } for (let index = pending.length - 1; index >= 0; index--) { const child = pending[index]; - if (child !== undefined) enqueue(child.node, child.path); + if (child !== undefined) + enqueue( + child.node, + child.path, + child.readAt, + child.capturedSelections, + child.invocation ?? current.invocation, + ); } } - const output = [...functions].flatMap((callable) => - semanticCallableResultExpressions(callable).flatMap( - ({ node, delegated }) => - node === null ? [] : [{ node, projection: delegated ? [null] : [] }], - ), - ); + const output = [...functions].flatMap((callable) => { + // A direct generator/async call produces a fresh iterator/Promise, not + // the yielded/resolved contents. Escape analysis still retains those + // potential references for their eventual consumers. + if ( + consumption !== "potential-reference" && + (callable.generator || + (consumption === "direct-call" && callable.async)) + ) + return []; + return semanticCallableResultExpressions(callable).flatMap( + ({ node, delegated, kind }) => + node === null || + (consumption !== "potential-reference" && kind !== "return") + ? [] + : [{ node, projection: delegated ? [null] : [] }], + ); + }); result.references = output; - results.set(callee, result); + results.set(callee, { + result, + invocation, + iterationUnknown: state.arrayIterationUnknown, + ambientWrites: state.ambientWriteEffects.length, + dependencies: [...dependencies].map(([binding, counts]) => ({ + binding, + ...counts, + })), + }); if (result.precision === "widened") state.returnedReferenceWidenings.add(callee); return result; @@ -267,7 +705,17 @@ const selectObjectTargets = ( ): void => { const [key, ...path] = item.path ?? []; const childPath = item.path === undefined ? undefined : path; - if (key !== null && key !== undefined) { + if (key !== null && typeof key === "object" && "alternatives" in key) { + for (const choice of semanticAliasSelectionChoices(key)) + selectObjectTargets( + node, + { ...item, path: [choice, ...path] }, + pending, + indexes, + ); + return; + } + if (key !== null && key !== undefined && typeof key !== "object") { const index = objectTargetIndex(node, indexes); const property = index.propertyByName.get(String(key)); if (property !== undefined) { @@ -287,11 +735,20 @@ const selectObjectTargets = ( ? unknown.value : unknown, ...(t.isSpreadElement(unknown) ? {} : { path: childPath }), + capturedSelections: t.isSpreadElement(unknown) + ? [ + ...(item.capturedSelections ?? []), + { at: unknown.argument, depth: 1, ownEnumerable: true }, + ] + : (semanticShiftCapturedSelections(item.capturedSelections, -1) ?? + []), }); pending.push({ ...item, node: t.isObjectProperty(property) ? property.value : property, path: childPath, + capturedSelections: + semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [], }); return; } @@ -300,7 +757,14 @@ const selectObjectTargets = ( for (const property of [...node.properties].reverse()) { if (replaced) break; if (t.isSpreadElement(property)) { - pending.push({ ...item, node: property.argument }); + pending.push({ + ...item, + node: property.argument, + capturedSelections: [ + ...(item.capturedSelections ?? []), + { at: property.argument, depth: 1, ownEnumerable: true }, + ], + }); continue; } const name = semanticStaticPropertyKey(property.key, property.computed); @@ -308,13 +772,31 @@ const selectObjectTargets = ( key !== null && key !== undefined && name !== null && - name !== String(key) + !semanticPropertyPathKeyMatches(key, name) ) continue; if (t.isObjectProperty(property)) - pending.push({ ...item, node: property.value, path: childPath }); - else pending.push({ ...item, node: property, path: childPath }); - if (key !== null && key !== undefined && name === String(key)) + pending.push({ + ...item, + node: property.value, + path: childPath, + capturedSelections: + semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [], + }); + else + pending.push({ + ...item, + node: property, + path: childPath, + capturedSelections: + semanticShiftCapturedSelections(item.capturedSelections, -1) ?? [], + }); + if ( + key !== null && + key !== undefined && + typeof key !== "object" && + name === String(key) + ) replaced = true; } }; diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts index e9aec4049a..00a3eee4e4 100644 --- a/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesBounded.test.ts @@ -123,3 +123,35 @@ it("bounds returned-reference expansion across a React-shaped switch mesh", () = expect(small.reads).toBeGreaterThan(0); expect(large.reads).toBeLessThan(4 * small.reads); }); + +it.each([ + ["{make:()=>module}", "{...box}", "box.make"], + ["[()=>module]", "[...box]", "box[0]"], +])( + "converges repeated copy boundaries without dropping callable results: %s", + (initial, replacement, target) => { + const state = indexed( + `let box=${initial};while(flag)box=${replacement};${target}();`, + ); + let callee: t.Node | undefined; + t.traverseFast(state.program, (node) => { + if (t.isCallExpression(node)) callee = node.callee; + }); + if (callee === undefined) throw new Error("Missing copied call"); + const lookups = state.scopes.map((scope) => + vi.spyOn(scope.bindings, "get"), + ); + try { + const result = createSemanticReturnedReferences(state)(callee); + expect(result.references).toContainEqual({ + node: expect.objectContaining({ type: "Identifier", name: "module" }), + projection: [], + }); + expect( + lookups.reduce((count, lookup) => count + lookup.mock.calls.length, 0), + ).toBeLessThan(100); + } finally { + for (const lookup of lookups) lookup.mockRestore(); + } + }, +); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts new file mode 100644 index 0000000000..f61bb7b7c3 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesCopies.test.ts @@ -0,0 +1,83 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + [ + "const old={child:{make:()=>module}};const box={...old};old.child={make:()=>({})};box.child.make()", + true, + ], + [ + "const old={child:{make:()=>module}};const box={...old};old.child.make=()=>({});box.child.make()", + false, + ], + [ + "const old={child:{make:()=>({})}};const box={...old};old.child.make=()=>module;box.child.make()", + true, + ], + [ + "const old={child:{make:()=>({})}};const box={...old};old.child={make:()=>module};old.child.make=()=>module;box.child.make()", + false, + ], + [ + "const old={child:{make:()=>module}};const {...box}=old;old.child={make:()=>({})};box.child.make()", + true, + ], + [ + "const old={child:{make:()=>module}};const {...box}=old;old.child.make=()=>({});box.child.make()", + false, + ], + [ + "let old={child:{make:()=>module}};const {...box}=old;old={child:{make:()=>({})}};box.child.make()", + true, + ], + [ + "const old=[{make:()=>module}];const box=[...old];old[0]={make:()=>({})};box[0].make()", + true, + ], + [ + "const old=[{make:()=>module}];const box=[...old];old[0].make=()=>({});box[0].make()", + false, + ], + [ + "const old=[{make:()=>module}];const [...box]=old;old[0]={make:()=>({})};box[0].make()", + true, + ], + [ + "const old=[{}, {make:()=>module}];const [skip,...box]=old;old[1]={make:()=>({})};box[0].make()", + true, + ], + [ + "const old=[{}, {make:()=>module}];const [skip,...box]=old;old[1].make=()=>({});box[0].make()", + false, + ], + [ + "const old={make:()=>module};const middle={...old};old.make=()=>({});const box={...middle};middle.make=()=>({});box.make()", + true, + ], + [ + "const old={make:()=>({})};const middle={...old};old.make=()=>module;const box={...middle};middle.make=()=>module;box.make()", + false, + ], + [ + "const old={child:{make:()=>module}};const middle={...old};old.child={make:()=>({})};const box={...middle};box.child.make()", + true, + ], + [ + "const old={make:()=>module};const middle={...old};old.make=()=>({});const {...box}=middle;box.make()", + true, + ], +])( + "preserves copied child identity and later writes through it: %s", + (receiver, expected) => { + const result = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(result.parse_error_count).toBe(0); + expect(result.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts b/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts new file mode 100644 index 0000000000..87e35c6dcb --- /dev/null +++ b/src/domain/javascript/javascriptSemanticReturnedReferencesWrites.test.ts @@ -0,0 +1,203 @@ +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { traverseJavaScriptAst } from "./javascriptSemanticTraversal.js"; + +it.each([ + [ + 'const box={make(){return {};}};box.make=()=>module;box.make().require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return module;}};box.make=()=>({});box.make().require("./shared.cjs");', + false, + ], + [ + 'const box={make(){return {};}};if(flag)box.make=()=>module;box.make().require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return module;}};if(flag)box.make=()=>({});box.make().require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return {};}};box.make().require("./shared.cjs");box.make=()=>module;', + false, + ], + [ + 'const box={make(){return {};}};const alias=box;box.make=()=>module;alias.make().require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return module;}};const alias=box;box.make=()=>({});alias.make().require("./shared.cjs");', + false, + ], + [ + 'const box={make(){return {};}};const make=box.make;box.make=()=>module;make().require("./shared.cjs");', + false, + ], + [ + 'const box={make(){return module;}};const make=box.make;box.make=()=>({});make().require("./shared.cjs");', + true, + ], + [ + 'const box={inner:{make(){return {};}}};box.inner.make=()=>module;box.inner.make().require("./shared.cjs");', + true, + ], + [ + 'const box={inner:{make(){return module;}}};box.inner={make(){return {};}};box.inner.make().require("./shared.cjs");', + false, + ], + [ + 'const box={};({make:box.make}={make:()=>module});box.make().require("./shared.cjs");', + true, + ], + [ + 'const box=[()=>({})];box[0]=()=>module;box[0]().require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return {};}};box.other=()=>module;box.make().require("./shared.cjs");', + false, + ], + [ + 'const box={make(){return {};}};box.make=async()=>module;box.make().require("./shared.cjs");', + false, + ], + [ + 'const box={make(){return {};}};box.make=async()=>module;(await box.make()).require("./shared.cjs");', + true, + ], + [ + 'const box={make(){return {};}};box.make=function*(){return module;};(await box.make()).require("./shared.cjs");', + false, + ], +] as const)( + "selects returned references at the member read: %s", + (source, expected) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ["let make=()=>module;const box={make};make=()=>({});box.make()", true], + ["let make=()=>({});const box={make};make=()=>module;box.make()", false], + ["let make=()=>module;const box={make:make};make=()=>({});box.make()", true], + ["let make=()=>module;const box=[make];make=()=>({});box[0]()", true], + ["let make=()=>({});const box=[make];make=()=>module;box[0]()", false], + [ + "let make=()=>module;const box={make};const saved=box.make;make=()=>({});saved()", + true, + ], + [ + "let make=()=>module;const box={};box.make=make;make=()=>({});box.make()", + true, + ], + [ + "let make=()=>({});const box={};box.make=make;make=()=>module;box.make()", + false, + ], + [ + "let make=()=>module;const box={};[box.make]=[make];make=()=>({});box.make()", + true, + ], + [ + "let make=()=>module;const old={make};const box={...old};make=()=>({});box.make()", + true, + ], + [ + "const old={make:()=>module};const box={...old};old.make=()=>({});box.make()", + true, + ], + [ + "const old={make:()=>({})};const box={...old};old.make=()=>module;box.make()", + false, + ], + [ + "const old={make:()=>module};const {...box}=old;old.make=()=>({});box.make()", + true, + ], + [ + "const old={make:()=>({})};const {...box}=old;old.make=()=>module;box.make()", + false, + ], + ["const old=[()=>module];const [...box]=old;old[0]=()=>({});box[0]()", true], + ["const old=[()=>({})];const [...box]=old;old[0]=()=>module;box[0]()", false], + [ + "let make=()=>module;const {make:saved=make}={};make=()=>({});saved()", + true, + ], + [ + "let inner={make:()=>module};const box={inner};inner={make:()=>({})};inner.make=()=>({});box.inner.make()", + true, + ], + [ + "let inner={make:()=>({})};const box={inner};inner={make:()=>module};inner.make=()=>module;box.inner.make()", + false, + ], + [ + "let inner={make:()=>({})};const box={inner};inner.make=()=>module;box.inner.make()", + true, + ], + [ + "let inner={make:()=>({})};const saved=inner;const box={inner};inner={make:()=>({})};saved.make=()=>module;box.inner.make()", + true, + ], +] as const)( + "retains captured values independently of later member reads: %s", + (expression, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${expression}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it("refreshes cached returned targets after the shared member index settles", () => { + const parsed = parseJavaScriptSource( + "const box={make(){return {};}};box.make=()=>module;box.make();", + "member-writes.cjs", + ); + if (parsed === null) throw new Error("Expected parsed member writes"); + const steps = indexJavaScriptBindingsWithReceiversSteps(parsed.program); + let step = steps.next(); + while (!step.done) step = steps.next(); + const index = step.value; + let callee: t.Node | undefined; + traverseJavaScriptAst(parsed.program, { + enter(node) { + if (t.isCallExpression(node)) callee = node.callee; + }, + }); + if (callee === undefined) throw new Error("Expected member call"); + const returned = createSemanticReturnedReferences( + index.bindings, + "direct-call", + ); + expect( + returned(callee).references.some(({ node }) => t.isIdentifier(node)), + ).toBe(false); + const effects = index.indexMemberMutationFactsSteps(); + let effect = effects.next(); + while (!effect.done) effect = effects.next(); + expect(returned(callee).references).toEqual([ + expect.objectContaining({ + node: expect.objectContaining({ name: "module" }), + }), + ]); +}); diff --git a/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts b/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts new file mode 100644 index 0000000000..48b7a405b0 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticSharedChildWrites.test.ts @@ -0,0 +1,223 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import * as t from "@babel/types"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { semanticProjectedSourcesAtRead } from "./javascriptSemanticProjectedSources.js"; + +const shapes = [ + { + before: "const old={child:{inner:INITIAL}};const box={...old};", + shared: "old.child={inner:box.child.inner};", + fresh: "old.child={inner:INITIAL};", + write: "old.child.inner.SLOT=NEXT;", + read: "box.child.inner.READ", + }, + { + before: "const old={child:{inner:INITIAL}};const {...box}=old;", + shared: "old.child={inner:box.child.inner};", + fresh: "old.child={inner:INITIAL};", + write: "old.child.inner.SLOT=NEXT;", + read: "box.child.inner.READ", + }, + { + before: "const old=[{inner:INITIAL}];const box=[...old];", + shared: "old[0]={inner:box[0].inner};", + fresh: "old[0]={inner:INITIAL};", + write: "old[0].inner.SLOT=NEXT;", + read: "box[0].inner.READ", + }, + { + before: "const old=[{inner:INITIAL}];const [...box]=old;", + shared: "old[0]={inner:box[0].inner};", + fresh: "old[0]={inner:INITIAL};", + write: "old[0].inner.SLOT=NEXT;", + read: "box[0].inner.READ", + }, + { + before: "const old={child:[INITIAL]};const box={...old};", + shared: "old.child=[box.child[0]];", + fresh: "old.child=[INITIAL];", + write: "old.child[0].SLOT=NEXT;", + read: "box.child[0].READ", + }, +] as const; +const values = [ + { + initial: "{make:()=>({})}", + next: "()=>module", + slot: "make", + read: "make().require('./shared.cjs')", + host: false, + }, + { + initial: "{make:()=>module}", + next: "()=>({})", + slot: "make", + read: "make().require('./shared.cjs')", + host: true, + }, + { + initial: "{loader:{}}", + next: "module", + slot: "loader", + read: "loader.require('./shared.cjs')", + host: false, + }, + { + initial: "{loader:module}", + next: "{}", + slot: "loader", + read: "loader.require('./shared.cjs')", + host: true, + }, + { + initial: "{load:()=>({})}", + next: "module.require", + slot: "load", + read: "load('./shared.cjs')", + host: false, + }, + { + initial: "{load:module.require}", + next: "()=>({})", + slot: "load", + read: "load('./shared.cjs')", + host: true, + }, +] as const; + +it.each( + shapes.flatMap((shape) => + values.flatMap((value) => + [true, false].map((shared) => ({ + source: ( + shape.before + + (shared ? shape.shared : shape.fresh) + + shape.write + + shape.read + + ";" + ) + .replaceAll("INITIAL", value.initial) + .replaceAll("NEXT", value.next) + .replaceAll("SLOT", value.slot) + .replaceAll("READ", value.read), + expression: shape.read.replaceAll("READ", value.read), + // The public module/delegate owner retains unknown after the copy's child + // escapes into the new parent. Factory result selection can exclude it. + expected: shared ? !value.host || value.slot !== "make" : value.host, + })), + ), + ), +)( + "distinguishes shared and newly allocated descendant receivers: $source", + ({ source, expression, expected }) => { + const result = analyzeJavaScriptStaticSource(source); + expect(result.parse_error_count).toBe(0); + expect( + result.references.filter( + (reference) => reference.expression === expression, + ), + ).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each(["module", "{}"])( + "selects the later write to a shared projected receiver: %s", + (next) => { + const parsed = parseJavaScriptSource( + `const old={child:{inner:{loader:module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.loader=${next};box.child.inner.loader.require('./shared.cjs');`, + "shared-child.cjs", + ); + if (parsed === null) throw new Error("Expected parsed fixture"); + const steps = indexJavaScriptBindingsWithReceiversSteps(parsed.program); + let step = steps.next(); + while (!step.done) step = steps.next(); + const index = step.value; + const effects = index.indexMemberMutationFactsSteps(); + let effect = effects.next(); + while (!effect.done) effect = effects.next(); + const binding = [...index.bindings.bindingsById.values()].find( + (candidate) => candidate.name === "old", + ); + let capture: t.Node | undefined; + let assignment: t.AssignmentExpression | undefined; + t.traverseFast(parsed.program, (node) => { + if (t.isSpreadElement(node)) capture = node.argument; + if (t.isAssignmentExpression(node)) assignment = node; + }); + if ( + binding === undefined || + capture === undefined || + assignment === undefined + ) + throw new Error("Missing selected receiver"); + const statement = parsed.program.body.at(-1)!; + if (!t.isExpressionStatement(statement)) + throw new Error("Missing receiver use"); + const use = statement.expression; + expect( + semanticProjectedSourcesAtRead( + binding, + capture, + ["child", "inner", "loader"], + index.bindings, + true, + use, + [{ at: capture, depth: 1 }], + ), + ).toEqual([{ node: assignment.right, path: [] }]); + }, +); + +it.each([ + "old.child={inner:flag?box.child.inner:{make:()=>({})}};", + "old.child={get inner(){return box.child.inner}};", + "old.child={...box.child};", +])( + "retains a possible shared receiver when identity is incomplete: %s", + (replacement) => { + const expression = "box.child.inner.make().require('./shared.cjs')"; + const result = analyzeJavaScriptStaticSource( + `const old={child:{inner:{make:()=>({})}}};const box={...old};${replacement}old.child.inner.make=()=>module;${expression};`, + ); + expect(result.parse_error_count).toBe(0); + expect(result.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null, expression }), + ); + }, +); + +it.each([16, 600])( + "bounds descendant receiver alternatives before expanding %i selections", + (count) => { + const properties = Array.from( + { length: count }, + (_, index) => `p${index}:{make:()=>({})}`, + ).join(","); + const expression = "box.child.inner.make().require('./shared.cjs')"; + const result = analyzeJavaScriptStaticSource( + `const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={${properties}};old.child[key].make=()=>module;${expression};`, + ); + expect(result.parse_error_count).toBe(0); + const limited = result.limitations.some((limitation) => + limitation.includes( + "Alias selection expansion was conservatively bounded", + ), + ); + expect(limited).toBe(count === 600); + const selected = result.references.filter( + (reference) => reference.expression === expression, + ); + expect(selected).toEqual( + count === 600 + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptSemanticState.ts b/src/domain/javascript/javascriptSemanticState.ts index e6e388280a..11fd41e3b4 100644 --- a/src/domain/javascript/javascriptSemanticState.ts +++ b/src/domain/javascript/javascriptSemanticState.ts @@ -1,9 +1,14 @@ import type { JavaScriptOpaqueScopeEffects } from "./javascriptSemanticOpaqueEffects.js"; +import type { SemanticIterationSource } from "./javascriptSemanticIterationSources.js"; import * as t from "@babel/types"; import { semanticStaticPropertyKey, unwrapJavaScriptExpression, } from "./javascriptAstValues.js"; +import { + semanticBindingAtRead, + semanticEffectTiming, +} from "./javascriptSemanticMutationInitializers.js"; import type { JavaScriptSemanticPropertyPath, @@ -29,10 +34,16 @@ export interface JavaScriptSemanticInitializer { readonly node: t.Node; readonly projection: readonly (string | number | null)[]; readonly entryBody?: t.BlockStatement; + readonly iterationSources?: readonly { + readonly node: t.Node; + readonly projection: JavaScriptSemanticPropertyPath; + }[]; } /** Additional references from destructuring copies and possible defaults. */ interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticInitializer { + /** This source is a value replacement, rather than a declaration fallback. */ + readonly assignment?: true; readonly copyKind?: "object-rest" | "array-rest"; readonly copyProjectionOffset?: number; readonly copyExcludedKeys?: readonly string[]; @@ -45,6 +56,12 @@ interface JavaScriptSemanticReferenceInitializer extends JavaScriptSemanticIniti export interface JavaScriptSemanticMemberEffect { readonly path: JavaScriptSemanticPropertyPath; readonly node: t.Node | undefined; + /** Selected RHS values retained by the shared pattern-store owner. */ + readonly assignedSources?: readonly SemanticIterationSource[]; + /** Binding values whose receiver identities were selected for this effect. */ + readonly receiverInitializers?: readonly JavaScriptSemanticInitializer[]; + /** Capture of this binding used while following an aliased effect receiver. */ + readonly receiverAt?: t.Node; } export type JavaScriptSemanticEffectTiming = "before" | "after" | "unknown"; @@ -101,6 +118,7 @@ export interface JavaScriptSemanticScopeState { /** Shared state for semantic collection and evaluation. */ export interface JavaScriptSemanticAnalysisState { + arrayIterationUnknown: boolean; readonly program: t.Program; readonly opaqueEffects: JavaScriptOpaqueScopeEffects; /** Effective loader for static imports in this source artifact. */ @@ -150,6 +168,8 @@ export interface JavaScriptSemanticAnalysisState { readonly ambientWrites: SemanticPropertyPathCoverage; /** Ambient object identities passed to calls whose effects are not evaluated. */ readonly ambientEscapes: SemanticPropertyPathCoverage; + /** Capture sites keep later opaque effects from changing earlier copies. */ + readonly ambientEscapeEffects: JavaScriptSemanticMemberEffect[]; /** Source sites distinguish replacement before an ambient identity capture from later writes. */ readonly ambientWriteEffects: JavaScriptSemanticMemberEffect[]; } @@ -537,7 +557,52 @@ export const isUnmodifiedCommonJsWrapperBinding = ( state: JavaScriptSemanticAnalysisState, ): boolean => { const binding = commonJsWrapperBindingDeclaration(node, name, state); - return binding !== undefined && binding.initializers.length === 0; + return ( + binding !== undefined && + !semanticBindingHasPriorValueWrite(node, binding, state) + ); +}; + +/** Retained value and pattern stores share one source-ordered write boundary. */ +export const semanticBindingHasPriorValueWrite = ( + site: t.Node, + binding: JavaScriptSemanticBindingState, + state: JavaScriptSemanticAnalysisState, +): boolean => { + if (binding.definitions.some(({ kind }) => kind === "function")) return true; + const writes = state.bindingWrites.get(binding.bindingId) ?? []; + const read = semanticBindingAtRead(binding, site, state.parentsByNode); + const retained = + read.status === "ordered" + ? new Set([ + ...read.binding.initializers, + ...read.binding.referenceInitializers, + ]) + : undefined; + const representedWrites = new Set(); + const retainedWrites = new Set(); + for (const source of [ + ...binding.initializers, + ...binding.referenceInitializers, + ]) { + let owner: t.Node | undefined = source.node; + while (owner !== undefined && !writes.includes(owner)) + owner = state.parentsByNode.get(owner); + if (owner !== undefined) { + representedWrites.add(owner); + if (retained === undefined || retained.has(source)) + retainedWrites.add(owner); + } else if ( + (retained === undefined || retained.has(source)) && + semanticEffectTiming(site, source.node, state) !== "after" + ) + return true; + } + return writes.some( + (write) => + (!representedWrites.has(write) || retainedWrites.has(write)) && + semanticEffectTiming(site, write, state) !== "after", + ); }; /** Program `var` redeclarations retain wrapper identity until a source write. */ @@ -550,7 +615,9 @@ export const commonJsWrapperBindingDeclaration = ( // specifier resolution mode does not establish that distinction. if ( state.program.sourceType !== "script" || - !["module", "exports", "require"].includes(name) || + !["module", "exports", "require", "__dirname", "__filename"].includes( + name, + ) || !t.isIdentifier(node, { name }) ) return undefined; diff --git a/src/domain/javascript/javascriptSemanticValueTypes.ts b/src/domain/javascript/javascriptSemanticValueTypes.ts index bb545d8662..41a698d4c6 100644 --- a/src/domain/javascript/javascriptSemanticValueTypes.ts +++ b/src/domain/javascript/javascriptSemanticValueTypes.ts @@ -6,7 +6,8 @@ export type JavaScriptSemanticResourceLimit = | "primitive-candidates" | "primitive-bytes" | "expression-depth" - | "getter-expansion"; + | "getter-expansion" + | "alias-selections"; type JavaScriptSemanticObjectValue = { readonly status: "object"; diff --git a/src/domain/javascript/javascriptSemanticValues.ts b/src/domain/javascript/javascriptSemanticValues.ts index 966c11a13e..50d0f8714a 100644 --- a/src/domain/javascript/javascriptSemanticValues.ts +++ b/src/domain/javascript/javascriptSemanticValues.ts @@ -63,6 +63,27 @@ import { semanticResourceLimitReason, } from "./javascriptSemanticResourceLimits.js"; +// Function identity is outside the public constant lattice. Retain its +// non-undefined proof with the immutable value rather than interpreting an +// "unknown" reason or recovering callable syntax in default consumers. +const definitelyDefinedValues = new WeakSet(); + +const definedNonPrimitiveValue = (reason: string): JavaScriptSemanticValue => { + const value: JavaScriptSemanticValue = { status: "unknown", reason }; + definitelyDefinedValues.add(value); + return value; +}; + +/** Definedness is independent of whether the constant lattice retains a value. */ +export const semanticValueDefinitelyDefined = ( + value: JavaScriptSemanticValue, +): boolean => + value.status === "literal" || + value.status === "union" || + value.status === "object" || + value.status === "array" || + definitelyDefinedValues.has(value); + interface MemoizedEvaluation { readonly value: T; readonly dependencies: SemanticDependencyProof; @@ -433,6 +454,16 @@ const evaluateBinding = ( const reusable = readMemoizedEvaluation(retained, context); if (reusable !== undefined) return reusable; } + if ( + binding.referenceInitializers.some(({ assignment }) => assignment) && + (binding.initializers.length > 0 || + (context.state.bindingsById.get(binding.bindingId)?.initializers.length ?? + 0) > 0) + ) + return { + status: "ambiguous", + reason: `Binding ${binding.name} has value and projected-reference assignments.`, + }; if (binding.initializers.length === 0) return { status: "unknown", @@ -615,6 +646,14 @@ const evaluateExpressionValue = ( } const literal = readExactJavaScriptLiteral(node); if (literal.found) return primitiveSet([literal.value]); + if ( + t.isFunctionExpression(node) || + t.isFunctionDeclaration(node) || + t.isArrowFunctionExpression(node) || + t.isClassExpression(node) || + t.isClassDeclaration(node) + ) + return definedNonPrimitiveValue(`Unsupported ${node.type} value.`); if (t.isIdentifier(node)) { const binding = resolveSemanticBindingState(context.state, node, node.name); if (binding === undefined) @@ -759,10 +798,14 @@ const evaluateObject = ( presence: "present", value: t.isObjectProperty(property) ? evaluateCapturedExpression(property.value, nestedContext(context)) - : { - status: "unknown", - reason: "Object method or accessor value is not a primitive.", - }, + : property.kind === "method" + ? definedNonPrimitiveValue( + "Object method or accessor value is not a primitive.", + ) + : { + status: "unknown", + reason: "Object method or accessor value is not a primitive.", + }, }); } const properties = [...propertiesByName.values()].sort((left, right) => @@ -1011,6 +1054,29 @@ const provenanceForBindingUncached = ( const provenanceForBindingValue = ( binding: JavaScriptSemanticBindingState, context: EvaluationContext, +): JavaScriptBindingProvenance => { + const provenance = provenanceForBindingInitializers(binding, context); + if (!binding.referenceInitializers.some(({ assignment }) => assignment)) + return provenance; + // Projected replacements are retained reference sources, not evidence of a + // local value or an unchanged earlier module. Apply this only to the selected + // read snapshot so a later replacement does not erase captured origins. + if (provenance.status === "local") + return semanticUnresolvedProvenance( + "unknown", + `Binding ${binding.name} has projected reference assignments whose module provenance is unresolved.`, + ); + return provenance.status === "module" + ? semanticAmbiguousProvenance( + provenance.origins, + `Binding ${binding.name} has projected reference assignments that may replace its module origin.`, + ) + : provenance; +}; + +const provenanceForBindingInitializers = ( + binding: JavaScriptSemanticBindingState, + context: EvaluationContext, ): JavaScriptBindingProvenance => { if (binding.kind === "parameter" || binding.kind === "catch") return semanticUnresolvedProvenance( @@ -1239,11 +1305,14 @@ const mergeValues = ( const primitives = values.flatMap( (value) => primitiveCandidates(value) ?? [], ); - return values.every( + const merged: JavaScriptSemanticValue = values.every( (value) => value.status === "union" || value.status === "literal", ) ? primitiveSet(primitives) : { status: "ambiguous", reason: "Branches have incompatible values." }; + if (values.length > 0 && values.every(semanticValueDefinitelyDefined)) + definitelyDefinedValues.add(merged); + return merged; }; const nestedContext = (context: EvaluationContext): EvaluationContext => ({ diff --git a/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts new file mode 100644 index 0000000000..d44bcb4a03 --- /dev/null +++ b/src/domain/javascript/javascriptSemanticWrapperAssignments.test.ts @@ -0,0 +1,119 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptSemantics } from "./javascriptSemanticAnalysis.js"; +import { topLevelBinding } from "./javascriptSemanticAnalysis.fixture.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + 'var require;require("./before.cjs");', + 'var require;require("./before.cjs");({require}={require:()=>({})});', + 'var require;require("./before.cjs");[require]=[()=>({})];', + 'var require;({require}={require:require("./before.cjs")});', + 'var require;[require]=[require("./before.cjs")];', +])("preserves the host loader at its earlier read: %s", (source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: "./before.cjs" }), + ); +}); + +it.each([ + "({require}={require:()=>({BrowserWindow:class{}})});", + "[require]=[()=>({BrowserWindow:class{}})];", + "({nested:{require}}={nested:{require:()=>({BrowserWindow:class{}})}});", + "({require=()=>({BrowserWindow:class{}})}={});", + "if(flag)({require}={require:()=>({BrowserWindow:class{}})});", +])( + "keeps projected loader writes out of proven Electron identity: %s", + (write) => { + const analysis = analyzeJavaScriptStaticSource( + `var require;${write}const {BrowserWindow}=require("electron");new BrowserWindow({webPreferences:{preload:"./unused.cjs"}});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); + expect(analysis.references).not.toContainEqual( + expect.objectContaining({ kind: "require", specifier: "electron" }), + ); + expect(analysis.electron.browser_windows).toEqual([]); + }, +); + +it.each([ + "({require}={require:()=>({BrowserWindow:class{}})});", + "[require]=[()=>({BrowserWindow:class{}})];", +])("preserves an Electron capture before the projected write: %s", (write) => { + const analysis = analyzeJavaScriptStaticSource( + `var require;const {BrowserWindow}=require("electron");${write}new BrowserWindow({webPreferences:{preload:"./before.cjs"}});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: "electron" }), + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "./before.cjs" }, + ]); +}); + +it.each([ + ["({module}={module:{require(){}}});", false], + ["[module]=[{require(){}}];", false], + ["if(flag)({module}={module:{require(){}}});", true], + ["if(flag)[module]=[{require(){}}];", true], +])( + "distinguishes definite and conditional wrapper replacement: %s", + (write, unknown) => { + const analysis = analyzeJavaScriptStaticSource( + `var module;${write}module.require("./unused.cjs");`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual(unknown ? [expect.objectContaining({ specifier: null })] : []); + }, +); + +it.each([ + ["({loader}={loader:()=>({})});loader('./unused.cjs');", null], + ["[loader]=[()=>({})];loader('./unused.cjs');", null], + ["if(flag)({loader}={loader:()=>({})});loader('./unused.cjs');", null], + ["loader('./before.cjs');({loader}={loader:()=>({})});", "./before.cjs"], + ["loader('./before.cjs');[loader]=[()=>({})];", "./before.cjs"], +])( + "uses the shared write facts for a factory loader: %s", + (body, specifier) => { + const analysis = analyzeJavaScriptStaticSource( + `globalThis.webpackChunkApp.push([[1],{entry:function(module,exports,loader){${body}}}]);`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual([expect.objectContaining({ specifier })]); + }, +); + +it.each([ + "({require}={require:()=>({BrowserWindow:class{}})});", + "if(flag)({require}={require:()=>({BrowserWindow:class{}})});", +])("retains ambient projected writes as unknown: %s", (write) => { + const analysis = analyzeJavaScriptStaticSource( + `${write}const {BrowserWindow}=require("electron");new BrowserWindow({});`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); + expect(analysis.electron.browser_windows).toEqual([]); +}); + +it("keeps projected reference provenance unknown until its sources are resolved", () => { + const analysis = analyzeJavaScriptSemantics( + 'let selected;({selected}={selected:require("electron")});', + ); + expect(topLevelBinding(analysis, "selected").provenance).toMatchObject({ + status: "unknown", + origins: [], + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index ebf69a2f0e..0c84220e0b 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -12,6 +12,9 @@ import { } from "./javascriptSemanticTraversal.js"; import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; +import { hasPossibleCommonJsDelegateWrite } from "./javascriptSemanticRequireOrigin.js"; +import { semanticResourceLimitReason } from "./javascriptSemanticResourceLimits.js"; +import { createStaticNodePathResolveQuery } from "./javascriptStaticPathIntrinsics.js"; import { finishElectronBrowserWindowsSteps } from "./electronStaticAnalysisBrowser.js"; import { detectVendors, @@ -24,6 +27,7 @@ import { addReference, addSourceMapDirectives, finishPartialNetworkEndpoints, + finishModuleRequireCalls, finishSqliteStorageCalls, inspectCall, inspectRouteProperty, @@ -89,20 +93,29 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( const index = yield* indexJavaScriptBindingsWithReceiversSteps(file.program); const { bindings, receivers } = index; yield* index.indexAmbientWritesSteps(); + if (hasPossibleCommonJsDelegateWrite(bindings)) + yield* index.indexMemberMutationFactsSteps(); const electronIdentity = yield* indexJavaScriptElectronIdentitySteps( file.program, bindings, ); const findings: JavaScriptFindingContext = { + nodePathResolve: createStaticNodePathResolveQuery(index), source, accumulator, vitePreloadDependencyTable: indexVitePreloadDependencyTable(file.program), openReceiverFacts: receivers.openReceivers, electronBindings: electronIdentity.bindings, + electronInvocationPath: electronIdentity.invocationPath, + electronAppPathChangePossible: electronIdentity.mayChangeAppPath, rpcScan: createJavaScriptRpcScan(source, receivers.rpcClientRoots, index), }; yield* traverseStaticSourceSteps(file, findings, bindings); yield* finishElectronBrowserWindowsSteps(findings, index); + if (accumulator.pendingModuleRequireCalls.length > 0) { + yield* index.indexMemberMutationFactsSteps(); + finishModuleRequireCalls(findings, bindings); + } if ( accumulator.pendingNetworkCalls.length > 0 || accumulator.pendingSqliteCalls.length > 0 @@ -145,6 +158,9 @@ const finalizeStaticAnalysis = ( ): JavaScriptStaticAnalysis => { const parserErrors = file.errors.length; const limitations = [ + ...(bindings.valueResourceLimits.has("alias-selections") + ? [semanticResourceLimitReason("alias-selections")] + : []), ...(bindings.returnedReferenceWidenings.size > 0 ? [RETURNED_REFERENCE_WIDENING_LIMITATION] : []), @@ -183,7 +199,9 @@ const finalizeStaticAnalysis = ( ]; return { parse_status: - parserErrors > 0 || accumulator.unknownFindings > 0 + parserErrors > 0 || + accumulator.unknownFindings > 0 || + bindings.valueResourceLimits.has("alias-selections") ? "partial" : "complete", parse_error_count: parserErrors, @@ -211,6 +229,14 @@ const finalizeStaticAnalysis = ( source_map_urls: accumulator.sourceMaps, vendors: detectVendors(source), electron: { + ...(accumulator.appPathChanges.length > 0 + ? { + app_path_changes: finalizeLocatedFindings( + accumulator.appPathChanges, + accumulator.moduleRangeIndex, + ), + } + : {}), ...(accumulator.electronCallPaths?.length ? { call_paths: accumulator.electronCallPaths } : {}), diff --git a/src/domain/javascript/javascriptStaticAnalysisAssignedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisAssignedRequire.test.ts new file mode 100644 index 0000000000..2f6c21f168 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisAssignedRequire.test.ts @@ -0,0 +1,158 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + [ + "const values={*[Symbol.iterator](){yield module;}};const h={};[h.loader]=values;h.loader", + true, + ], + [ + "const values=[{}];values[Symbol.iterator]=function*(){yield module;};const h={};[h.loader]=values;h.loader", + true, + ], + [ + "const values={*[Symbol.iterator](){yield module;}};const [api]=values;api", + true, + ], + [ + "const values={*[Symbol.iterator](){yield module;}};const [...api]=values;api[0]", + true, + ], + [ + "Array.prototype[Symbol.iterator]=function*(){yield module;};const [api]=[{}];api", + true, + ], + ["const h={};[h.loader]=[{}];h.loader", false], + ["const [api]=[{}];api", false], + ["const [api]=module;api", true], +])("keeps custom iterable selections unknown: %s", (source, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${source}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); +}); + +it.each(["??=", "||=", "&&="])( + "retains possible logical assignment module sources for %s", + (operator) => { + const analysis = analyzeJavaScriptStaticSource(` + const h={ loader: choose() }; + h.loader ${operator} module; + h.loader.require=replacement; + require('./shared.cjs'); + `); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); + }, +); + +it.each(["??=", "||=", "&&="])( + "keeps logical assignment local sources separate for %s", + (operator) => { + const analysis = analyzeJavaScriptStaticSource(` + const h={ loader: {} }; + h.loader ${operator} { require() {} }; + h.loader.require=replacement; + require('./shared.cjs'); + `); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); + }, +); + +it.each(["??=", "||=", "&&="])( + "keeps a load before a later logical module assignment for %s", + (operator) => { + const analysis = analyzeJavaScriptStaticSource(` + require('./shared.cjs'); + const h={ loader: choose() }; + h.loader ${operator} module; + h.loader.require=replacement; + `); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); + }, +); + +it.each([ + ["const {...h}={loader:module};h.loader", true], + ["const [...h]=[module];h[0]", true], + ["const [ignored,...h]=[{},module];h[0]", true], + ["const {ignored,...h}={ignored:module,loader:module};h.loader", true], + ["const {loader:ignored,...h}={loader:module};h.loader", false], + ["const {...h}=module;h", false], + ["const [...h]=[module];h", false], + ["const {...h}={loader:module.exports};h.loader", false], + ["const [...h]=[{}];h[0]", false], +])( + "preserves child identities of declaration rest copies: %s", + (source, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${source}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ["({x:h.loader}={x:module});h.loader", true], + ["[h.loader]=[module];h.loader", true], + ["({x:[{y:h.loader}]}={x:[{y:module}]});h.loader", true], + ["({x:h.loader=module}={});h.loader", true], + ["({x:{y:h.loader=module}={}}={});h.loader", true], + ["({[key]:h.loader}={x:module});h.loader", true], + ["({['x']:h.loader}={x:module});h.loader", true], + ["[ignore,...h.loader]=[{},module];h.loader[0]", true], + ["({...h.loader}={loader:module});h.loader.loader", true], + ["({exports:h.loader}=module);h.loader", false], + ["({...h.loader}=module);h.loader", false], + ["({x:ignore,...h.loader}={x:module});h.loader.x", false], + ["({x:h.loader=module}={x:{}});h.loader", false], + ["h.loader=module;({x:h.loader}={x:{}});h.loader", false], + ["({a:h.loader,b:h.loader}={a:module,b:{}});h.loader", false], + ["({a:h.loader,b:h.loader}={a:{},b:module});h.loader", true], + ["({x:h.a,y:h.b}={x:{},y:module});h.a", false], + ["({x:h.a,y:h.b}={x:{},y:module});h.b", true], +])("preserves selected assigned module sources: %s", (assignment, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `const h={};${assignment}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); +}); + +it("retains a destructured member delegate call", () => { + const analysis = analyzeJavaScriptStaticSource( + `const h={};({load:h.load}={load:module.require});h.load.call(module,'./shared.cjs');`, + ); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); +}); + +it("revokes the host delegate after a destructured module receiver write", () => { + const analysis = analyzeJavaScriptStaticSource( + `const h={};({x:h.loader}={x:module});h.loader.require=replacement;require('./shared.cjs');`, + ); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisAssignmentLoaders.test.ts b/src/domain/javascript/javascriptStaticAnalysisAssignmentLoaders.test.ts new file mode 100644 index 0000000000..95435f7ac0 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisAssignmentLoaders.test.ts @@ -0,0 +1,88 @@ +import { Module } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const cases = [ + { + name: "assigned host receiver", + source: "let holder;(holder=module).require('node:path');", + outcomes: ["host"], + possible: true, + }, + { + name: "assigned local receiver", + source: + "let holder;(holder={require:()=>({kind:'local'})}).require('node:path');", + outcomes: ["local"], + possible: false, + }, + { + name: "assigned delegate", + source: "let load;(load=module.require).call(module,'node:path');", + outcomes: ["host"], + possible: true, + }, + { + name: "conditional assignment value", + source: + "let holder;(holder=flag?module:{require:()=>({kind:'local'})}).require('node:path');", + outcomes: ["local", "host"], + possible: true, + }, + { + name: "logical or assignment", + source: + "let holder=flag?{require:()=>({kind:'local'})}:null;(holder||=module).require('node:path');", + outcomes: ["host", "local"], + possible: true, + }, + { + name: "logical and assignment", + source: "let holder=flag?{}:null;(holder&&=module).require('node:path');", + outcomes: ["throw", "host"], + possible: true, + }, + { + name: "nullish assignment", + source: + "let holder=flag?{require:()=>({kind:'local'})}:null;(holder??=module).require('node:path');", + outcomes: ["host", "local"], + possible: true, + }, + { + name: "logical assignment of local receivers", + source: + "let holder=flag?{require:()=>({kind:'local'})}:null;(holder||={require:()=>({kind:'local'})}).require('node:path');", + outcomes: ["local", "local"], + possible: false, + }, +]; + +it.each(cases)( + "retains the expression result for $name", + ({ source, outcomes, possible }) => { + for (const [index, expected] of outcomes.entries()) { + const execute = () => + runInNewContext(source, { + module: new Module("assignment-loader.cjs"), + flag: index === 1, + }); + if (expected === "throw") expect(execute).toThrow(); + else { + const result = execute(); + expect(result.join === path.join).toBe(expected === "host"); + if (expected === "local") expect(result.kind).toBe("local"); + } + } + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + possible + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisCallableReceivers.test.ts b/src/domain/javascript/javascriptStaticAnalysisCallableReceivers.test.ts new file mode 100644 index 0000000000..25d8f3a8bc --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisCallableReceivers.test.ts @@ -0,0 +1,99 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const generic = "const get=()=>({});const invoke=get.call;"; +const cases = [ + { + name: "plain object receiver", + source: `${generic}const holder={invoke,other:()=>module};holder.invoke(null)`, + callable: false, + }, + { + name: "sequence object receiver", + source: `${generic}const holder={invoke,other:()=>module};(0,holder).invoke(null)`, + callable: false, + }, + { + name: "spread object receiver", + source: `${generic}({...{invoke,other:()=>module}}).invoke(null)`, + callable: false, + }, + { + name: "array receiver", + source: `${generic}const holder=[invoke,()=>module];holder[0](null)`, + callable: false, + }, + { + name: "explicit object receiver", + source: `${generic}invoke.call({other:()=>module},null)`, + callable: false, + }, + { + name: "conditional object receiver", + source: `${generic}const holder=flag?{invoke,other:()=>module}:{invoke,other:()=>module};holder.invoke(null)`, + callable: false, + }, + { + name: "direct object callee", + source: "const holder={other:()=>module};holder(null)", + callable: false, + }, + { + name: "direct array callee", + source: "const holder=[()=>module];holder(null)", + callable: false, + }, + { + name: "callable receiver with an own invocation alias", + source: `${generic}const holder=()=>module;holder.invoke=invoke;holder.other=()=>({});holder.invoke(null)`, + callable: true, + }, + { + name: "explicit callable member receiver", + source: `${generic}const holder={invoke,other:()=>module};holder.invoke.call(holder.other,null)`, + callable: true, + }, + { + name: "selected object callable", + source: `${generic}const holder={other:()=>module};invoke.call(holder.other,null)`, + callable: true, + }, + { + name: "selected array callable", + source: `${generic}const holder=[()=>module];invoke.call(holder[0],null)`, + callable: true, + }, + { + name: "selected spread callable", + source: `${generic}const holder={...{other:()=>module}};invoke.call(holder.other,null)`, + callable: true, + }, +]; + +it.each(cases)( + "distinguishes the callable value from children for $name", + ({ source, callable }) => { + const invocation = `${source}.require('./shared.cjs');`; + expect( + runInNewContext( + `try{${invocation}}catch(error){error.name;}`, + { module: { require: () => "host" }, flag: true }, + { timeout: 1_000 }, + ), + ).toBe(callable ? "host" : "TypeError"); + const analysis = analyzeJavaScriptStaticSource(invocation); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + callable + ? [ + expect.objectContaining({ + kind: "require", + specifier: null, + }), + ] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts index dde8b84dbe..38c1859043 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalleeBoundary.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; const expressionWrappers = [ ["plain", (value: string) => value], @@ -9,6 +10,207 @@ const expressionWrappers = [ ["satisfies", (value: string) => `(${value} satisfies any)`], ] as const; +it.each([ + ["module.require = replacement;", true], + [ + "const holder = { loader: module }; holder.loader.require = replacement;", + true, + ], + [ + "const holder = {}; holder.loader = module; holder.loader.require = replacement;", + true, + ], + ["const holder = [module]; holder[0].require = replacement;", true], + ["Object.defineProperty(module, 'require', { value: replacement });", true], + ["const api = module; Object.assign(api, { require: replacement });", true], + ["const module = { require() {} }; module.require = replacement;", false], + ["const api = module.exports; api.require = replacement;", false], + [ + "const holder = { a: module }; const target = flag ? holder.a : holder; target.require = replacement;", + true, + ], + [ + "const table = { x: {}, y: {}, z: module }; const target = flag ? table.x : table.y; target.require = replacement;", + false, + ], +])("keeps CommonJS delegate replacement unknown: %s", (write, changed) => { + const analysis = analyzeJavaScriptStaticSource( + `${write} require("./shared.cjs");`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ + kind: "require", + specifier: changed ? null : "./shared.cjs", + }), + ]); +}); + +it("keeps a require capture before a later delegate replacement", () => { + const analysis = analyzeJavaScriptStaticSource( + 'require("./shared.cjs"); const holder = { loader: module }; holder.loader.require = replacement;', + ); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); +}); + +it.each([ + ["const holder = {}; holder.loader = { require() {} }; holder.loader", false], + [ + "const holder = { loader: module }; holder.loader = {}; holder.loader", + false, + ], + [ + "const holder = {}; if (flag) holder.loader = { require() {} }; holder.loader", + false, + ], + ["const holder = { loader: module }; holder.loader", true], + ["const holder = { inner: { loader: module } }; holder.inner.loader", true], + ["const holder = [module]; holder[0]", true], + [ + "const holder = { loader: module }; const { loader } = holder; loader", + true, + ], + [ + "const holder = { loader: module }; const loader = holder.loader; loader", + true, + ], + [ + "const holder = { loader: module }; const alias = { ...holder }; alias.loader", + true, + ], + ["const holder = { loader: module, loader: {} }; holder.loader", false], + ["const holder = { loader: {} }; holder.loader", false], + ["const holder = { loader: module.exports }; holder.loader", false], + ["const holder = { loader: module }; holder.missing", false], + [ + "const table = { a: { b: {}, d: module }, c: { d: {}, b: {} } }; const selected = flag ? table.a.b : table.c.d; selected", + false, + ], + ["let holder = {}; while (flag) holder = holder.slot; holder", true], + ["const holder = {}; holder.loader = module; holder.loader", true], + ["const holder = {}; if (flag) holder.loader = module; holder.loader", true], + [ + "const holder = {}; const alias = holder; alias.loader = module; holder.loader", + true, + ], +])( + "tracks the selected module-object container slot: %s", + (receiver, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${receiver}.require("./shared.cjs");`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it("bounds branching selector work and reports unknown ownership explicitly", () => { + const analysis = analyzeJavaScriptStaticSource(aliasSelectionBudgetSource()); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.parse_status).toBe("partial"); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); + expect(analysis.limitations).toContainEqual( + expect.stringMatching(/Alias selection expansion.*512/u), + ); +}); + +it.each([ + ['const load = module.require.bind(module); load("./shared.cjs");', true], + [ + 'const api = module; const load = api.require.bind(api); load("./shared.cjs");', + true, + ], + [ + 'const load = module["require"][`bind`](module); load("./shared.cjs");', + true, + ], + ['module.require.call(module, "./shared.cjs");', true], + ['module.require.apply(module, ["./shared.cjs"]);', true], + [ + 'const load = module.require.bind.call(module.require, module); load("./shared.cjs");', + true, + ], + [ + 'const module = { require() {} }; const load = module.require.bind(module); load("./shared.cjs");', + false, + ], + [ + 'const api = module.exports; const load = api.require.bind(api); load("./shared.cjs");', + false, + ], +])( + "retains unchecked module delegate invocation and capture: %s", + (source, possibleModule) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + possibleModule + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ["const { api = module } = options;", true], + ["const { api = module } = {};", true], + ["const { api = module } = { api: {} };", false], + ["const { api = module.exports } = options;", false], + ["const { ...api } = module;", false], +])( + "tracks active direct module defaults: %s", + (declaration, possibleModule) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration} api.require("./shared.cjs");`, + ); + expect(analysis.references).toEqual( + possibleModule + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + "const { exports: api } = module;", + "const { exports: { api } } = module;", + "const [api] = [module.exports];", +])( + "keeps projected module members separate from the loader: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource(`${declaration} + api.require = () => {}; api.require("./shared.cjs");`); + expect(analysis.references).toEqual([]); + }, +); + +it.each(["module", "local"])( + "examines a repeated conditional alias graph once per source read for %s", + (origin) => { + const source = [`const a0 = ${origin === "module" ? "module" : "{}"};`]; + for (let index = 1; index <= 128; index += 1) + source.push( + `let a${index} = a${index - 1}; if (flag) a${index} = a${index - 1};`, + ); + source.push('a128.require("./shared.cjs");'); + const analysis = analyzeJavaScriptStaticSource(source.join("\n")); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + origin === "module" + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + it("shares erased call-target names with endpoint consumers without inventing dynamic keys", () => { const analysis = analyzeJavaScriptStaticSource(` (client.get as typeof client.get)("/api/items"); diff --git a/src/domain/javascript/javascriptStaticAnalysisCalls.ts b/src/domain/javascript/javascriptStaticAnalysisCalls.ts index 3655e71bb0..8abf7a9354 100644 --- a/src/domain/javascript/javascriptStaticAnalysisCalls.ts +++ b/src/domain/javascript/javascriptStaticAnalysisCalls.ts @@ -50,8 +50,9 @@ import { import { semanticTopLevelEffectFollowsCapture } from "./javascriptSemanticMutationInitializers.js"; import { semanticFactoryLoaderMemberAt, - semanticFactoryRequireLoaderAt, - semanticRequireLoaderAt, + semanticRequireCallAt, + semanticModuleRequireCallAt, + semanticModuleRequireReceiver, } from "./javascriptSemanticRequireOrigin.js"; type JavaScriptCallLike = @@ -69,7 +70,13 @@ export const inspectCall = ( const { accumulator } = context; const name = calleeName(node.callee); const first = stringValue(node.arguments[0]); - const requireCall = requireCallSpecifier(node, bindings, context); + const deferredModuleRequire = + semanticModuleRequireReceiver(node) !== undefined || + semanticModuleRequireCallAt(node, bindings, false); + if (deferredModuleRequire) accumulator.pendingModuleRequireCalls.push(node); + const requireCall = deferredModuleRequire + ? null + : requireCallSpecifier(node, bindings, context); if (requireCall !== null) addReference(context, { node, @@ -249,43 +256,37 @@ const requireCallSpecifier = ( bindings: JavaScriptSemanticAnalysisState, context: FindingContext, ): { readonly specifier: string | undefined } | null => { - const bareRequire = t.isIdentifier(node.callee, { name: "require" }); - if (bareRequire) { - const loader = semanticRequireLoaderAt( - node, - bindings, - context.accumulator.factoryRequireBindingIds, - ); - if (loader === "local") return null; - if (loader === "unknown") context.accumulator.unknownFindings += 1; - return { - specifier: - loader === "unknown" ? undefined : argumentValue(node.arguments[0]), - }; - } - const factoryLoader = semanticFactoryRequireLoaderAt( + const call = semanticRequireCallAt( node, bindings, context.accumulator.factoryRequireBindingIds, ); - if (factoryLoader === "factory-loader") - return { - specifier: argumentValue( - node.arguments[ - t.isMemberExpression(unwrapJavaScriptExpression(node.callee).node) || - t.isOptionalMemberExpression( - unwrapJavaScriptExpression(node.callee).node, - ) - ? 1 - : 0 - ], - ), - }; - if (factoryLoader === "unknown") { + if (call === undefined || call.loader === "local") return null; + if (call.loader === "unknown" || !call.sourceRelative) { context.accumulator.unknownFindings += 1; return { specifier: undefined }; } - return null; + return { specifier: argumentValue(call.arguments?.[0]) }; +}; + +/** Examine member-loader receivers only after shared alias mutation facts settle. */ +export const finishModuleRequireCalls = ( + context: FindingContext, + bindings: JavaScriptSemanticAnalysisState, +): void => { + for (const node of context.accumulator.pendingModuleRequireCalls) { + const call = requireCallSpecifier(node, bindings, context); + if (call !== null) + addReference(context, { + node, + kind: "require", + specifier: call.specifier, + }); + } + context.accumulator.pendingModuleRequireCalls.length = 0; + context.accumulator.references.sort( + (left, right) => left.offset - right.offset, + ); }; const chunkLoadSpecifier = ( @@ -760,7 +761,7 @@ export const inspectRoleProperty = ( ): void => { if (semanticStaticPropertyName(node.key, node.computed) !== "preload") return; if (context.accumulator.mediaElementPreloads.has(node)) return; - const path = staticPath(node.value); + const path = staticPath(node.value, context.nodePathResolve); if (path === undefined) return; addLocatedFinding(context, { collection: context.accumulator.roles, @@ -769,7 +770,10 @@ export const inspectRoleProperty = ( value: { role: "preload", path, - resolution_context: staticPathResolutionContext(node.value), + resolution_context: staticPathResolutionContext( + node.value, + context.nodePathResolve, + ), mechanism: "property:preload", module_key: null, location: range(node), diff --git a/src/domain/javascript/javascriptStaticAnalysisCapturedGeneric.test.ts b/src/domain/javascript/javascriptStaticAnalysisCapturedGeneric.test.ts new file mode 100644 index 0000000000..b9f67c7c0c --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisCapturedGeneric.test.ts @@ -0,0 +1,98 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; +import * as t from "@babel/types"; + +import { capturedGenericInvocations } from "../../../tests/fixtures/javascriptCapturedGenericInvocations.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; + +it.each(capturedGenericInvocations)( + "retains the authored runtime receiver for $name", + ({ source, host, expression }) => { + expect( + runInNewContext( + source, + { + module: { require: () => "host" }, + }, + { timeout: 1_000 }, + ), + ).toBe(host ? "host" : "local"); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + host + ? [ + expect.objectContaining({ + kind: "require", + specifier: null, + expression, + }), + ] + : [], + ); + }, +); + +it.each([ + "const table={x:()=>({}),y:()=>({}),z:()=>module};const selected=flag?table.x:table.y;const get=()=>module;const invoke=get.call;invoke.call.call(selected,null).require('./unused.cjs');", + "const get=()=>({});const invoke=get.call;const other=function*(){return module;};invoke.call.call(other,null).require('./unused.cjs');", + "const get=()=>({});const invoke=get.call;const other=async()=>module;invoke.call.call(other,null).require('./unused.cjs');", +])("preserves unrelated, generator and promise receivers: %s", (source) => { + expect(analyzeJavaScriptStaticSource(source).references).toEqual([]); +}); + +it("does not reclassify an ordinary Electron construction as an unknown loader", () => { + const analysis = analyzeJavaScriptStaticSource( + "const {BrowserWindow}=require('electron');new BrowserWindow().loadFile('index.html');", + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "electron" }), + ]); +}); + +it.each( + [32, 550].flatMap((depth) => [true, false].map((host) => ({ depth, host }))), +)( + "retains generic receiver sources when invocation contexts converge at depth $depth (host=$host)", + ({ depth, host }) => { + const source = `const get=()=>({kind:'local'});const invoke=get.call;const other=()=>${host ? "module" : "({kind:'local'})"};invoke.call(${Array(depth).fill("invoke").join(",")},other,null);`; + expect( + runInNewContext(source, { module: { kind: "host" } }, { timeout: 1_000 }) + .kind, + ).toBe(host ? "host" : "local"); + const parsed = parseJavaScriptSource(source); + if (parsed === null) + throw new Error("Expected bounded generic invocation fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const statement = parsed.program.body.at(-1); + if ( + !t.isExpressionStatement(statement) || + !t.isCallExpression(statement.expression) + ) + throw new Error("Expected final generic invocation"); + const call = statement.expression; + const returned = createSemanticReturnedReferences( + index.bindings, + "direct-call", + )(call.callee, call); + expect( + returned.references.some(({ node }) => + t.isIdentifier(node, { name: "module" }), + ), + ).toBe(host); + expect(returned.precision).toBe(depth > 512 ? "widened" : "exact"); + expect(index.bindings.valueResourceLimits.has("alias-selections")).toBe( + depth > 512, + ); + if (depth > 512) expect(returned.unresolvedInvocation).toBe(true); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisCapturedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisCapturedRequire.test.ts new file mode 100644 index 0000000000..3065ba811b --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisCapturedRequire.test.ts @@ -0,0 +1,49 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + ['const load = module.require; load.call(module, "../shared.cjs");', true], + ['const load = module.require; load.apply(module, ["../shared.cjs"]);', true], + ['const load = module.require; load("../shared.cjs");', true], + [ + 'const load = module.require; const alias = load; alias.call(module, "../shared.cjs");', + true, + ], + [ + 'const { require: load } = module; load.call(module, "../shared.cjs");', + true, + ], + [ + 'const holder = { load: module.require }; holder.load.call(module, "../shared.cjs");', + true, + ], + ['const holder = [module.require]; holder[0]("../shared.cjs");', true], + [ + 'let load = () => {}; if (flag) load = module.require; load("../shared.cjs");', + true, + ], + [ + 'let load = module.require; load = () => {}; load.call(module, "../shared.cjs");', + false, + ], + [ + 'const api = { require() {} }; const load = api.require; load.call(api, "../shared.cjs");', + false, + ], + [ + 'const load = module.exports.require; load.call(module.exports, "../shared.cjs");', + false, + ], +])( + "retains captured CommonJS delegates by source identity: %s", + (source, expected) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisCopiedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisCopiedRequire.test.ts new file mode 100644 index 0000000000..e879964473 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisCopiedRequire.test.ts @@ -0,0 +1,75 @@ +import { Module } from "node:module"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("checks inherited and own enumerable require against a real Node Module", () => { + const host = new Module("copied-require-oracle.cjs"); + expect(Object.hasOwn(host, "require")).toBe(false); + expect(typeof host.require).toBe("function"); + const { ...rest } = host; + expect(Object.hasOwn(rest, "require")).toBe(false); + expect(Object.hasOwn({ ...host }, "require")).toBe(false); + + host.require = host.require; + expect(Object.getOwnPropertyDescriptor(host, "require")?.enumerable).toBe( + true, + ); + const { ...ownRest } = host; + expect(ownRest.require).toBe(host.require); + expect({ ...host }.require).toBe(host.require); +}); + +it("keeps copied child module identity without copying its prototype", () => { + const host = new Module("copied-child-oracle.cjs"); + const { ...copy } = { loader: host }; + expect(Object.hasOwn(copy, "loader")).toBe(true); + expect(copy.loader).toBe(host); + expect(Object.hasOwn(copy.loader, "require")).toBe(false); + expect(copy.loader.require).toBe(host.require); +}); + +it.each(["const {...h}=module;", "const h={...module};"])( + "keeps a copied inherited delegate absent inside a function: %s", + (declaration) => { + const result = analyzeJavaScriptStaticSource( + `function copy(){${declaration}h.require('./shared.cjs');}`, + ); + expect(result.references).toEqual([]); + }, +); + +it.each([ + ["const {...h}=module;h", false], + ["const holder={};({...holder.h}=module);holder.h", false], + ["const h={...module};h", false], + ["const source=module;const h={...source};h", false], + ["const source=module;const {...h}=source;h", false], + ["const h={...{loader:module}};h.loader", true], + ["const {...h}={loader:module};h.loader", true], + ["const h={...[module]};h[0]", true], + ["const source={require:module.require};const h={...source};h", true], + ["const source={require:module.require};const {...h}=source;h", true], + ["module.require=module.require;const h={...module};h", true], + ["module.require=module.require;const {...h}=module;h", true], + ["const h={...module};module.require=module.require;h", false], + ["const {...h}=module;module.require=module.require;h", false], + ["if(flag)module.require=module.require;const h={...module};h", true], + ["globalThis.module.require=unknown;const h={...module};h", false], + ["expose(module);const h={...module};h", true], + ["const h={...module};expose(module);h", false], + ["const module={require(){}};const h={...module};h", false], +])( + "selects only possibly own enumerable copied delegates: %s", + (receiver, expected) => { + const result = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(result.parse_error_count).toBe(0); + expect(result.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts b/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts new file mode 100644 index 0000000000..e022014567 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisDelegateEligibility.test.ts @@ -0,0 +1,60 @@ +import { Module } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("keeps repeated Node resolver calls separate from loader delegate eligibility", () => { + const expression = "path.resolve(runtimePath,__dirname,'preload.js')"; + const source = ` + const path=require('node:path'); + const {BrowserWindow,utilityProcess}=require('electron'); + new BrowserWindow({webPreferences:{preload:${expression}}}); + utilityProcess.fork(${expression}); + `; + const observed: string[] = []; + runInNewContext(source, { + runtimePath: "/runtime", + __dirname: "/fixture/app", + require: (specifier: string) => + specifier === "node:path" + ? path.posix + : { + BrowserWindow: class { + constructor(options: { webPreferences: { preload: string } }) { + observed.push(options.webPreferences.preload); + } + }, + utilityProcess: { fork: (file: string) => observed.push(file) }, + }, + }); + expect(observed).toEqual([ + "/fixture/app/preload.js", + "/fixture/app/preload.js", + ]); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_status).toBe("complete"); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "node:path" }), + expect.objectContaining({ kind: "require", specifier: "electron" }), + ]); + expect(analysis.electron.browser_windows[0]?.preload_path).toBe("preload.js"); + expect(analysis.electron.utility_processes[0]?.module_path).toBe( + "preload.js", + ); +}); + +it("retains an escaped selected native delegate as a possible loader", () => { + const source = + "const box={load:module.require};opaque(box);box.load.call(module,'node:path');"; + const module = new Module("delegate-eligibility.cjs"); + expect(runInNewContext(source, { module, opaque: () => {} }).join).toBe( + path.join, + ); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisFactoryAlternatives.test.ts b/src/domain/javascript/javascriptStaticAnalysisFactoryAlternatives.test.ts new file mode 100644 index 0000000000..dabe4c424b --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisFactoryAlternatives.test.ts @@ -0,0 +1,50 @@ +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("observes an authored factory returning a loader from another directory", () => { + const source = resolve("factory-oracle-source/main.cjs"); + const alternate = resolve("factory-oracle-other/main.cjs"); + for (const flag of [false, true]) { + const actual = runInNewContext( + "const make=flag?native:getFactory();const load=make(source);load.resolve.paths('./window.cjs')[0];", + { + native: createRequire, + getFactory: () => () => createRequire(alternate), + source, + flag, + }, + ); + expect(actual).toBe(dirname(flag ? source : alternate)); + } +}); + +it.each([ + ["conditional opaque factory", "flag?native:getFactory()", null], + ["logical opaque factory", "native||getFactory()", null], + ["optional opaque factory", "flag?native:getFactory?.()", null], + ["constructed opaque factory", "flag?native:new Factory()", null], + ["opaque member factory", "flag?native:getContainer().make", null], + ["unbound member factory", "flag?native:holder.make", null], + ["known native choices", "flag?native:native", "./window.cjs"], + ["only opaque factory", "getFactory()", undefined], + ["only local factory", "()=>()=>({})", undefined], +] as const)( + "retains every selected origin for %s", + (_name, choice, specifier) => { + const analysis = analyzeJavaScriptStaticSource( + `import{createRequire as native}from'node:module';const make=${choice};const load=make(import.meta.url);load('./window.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual( + specifier === undefined + ? [] + : [expect.objectContaining({ kind: "require", specifier })], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts index d1ee8b4cdb..8a5b2fab17 100644 --- a/src/domain/javascript/javascriptStaticAnalysisHelpers.ts +++ b/src/domain/javascript/javascriptStaticAnalysisHelpers.ts @@ -171,22 +171,31 @@ export const staticArrayValues = ( }; /** Classify whether inert path syntax is a module specifier or file expression. */ +export type JavaScriptStaticPathIntrinsicQuery = ( + node: t.Node, + fact?: "source-directory", +) => boolean; + export const staticPathResolutionContext = ( node: t.Node, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): JavaScriptStaticPathContext => - isFilesystemPathExpression(node) + isFilesystemPathExpression(node, nodePathResolve) ? "filesystem-expression" : "module-specifier"; -/** Resolve a path composed only from inert literal syntax. */ -export const staticPath = (node: t.Node): string | undefined => { +/** Resolve a path string composed only from inert literal syntax. */ +export const staticPath = ( + node: t.Node, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, +): string | undefined => { node = unwrapJavaScriptExpression(node).node; const value = stringValue(node); if (value !== undefined) return value; if (t.isBinaryExpression(node, { operator: "+" })) - return staticConcatenation(node); + return staticConcatenation(node, nodePathResolve); if (t.isCallExpression(node) || t.isNewExpression(node)) - return staticCallPath(node); + return staticCallPath(node, nodePathResolve); return undefined; }; @@ -195,18 +204,21 @@ export const staticPath = (node: t.Node): string | undefined => { * `__dirname + "/preload.js"` names a file inside it, while text without a * leading separator extends the directory's own name and stays unknown. */ -const staticConcatenation = (node: t.BinaryExpression): string | undefined => { +const staticConcatenation = ( + node: t.BinaryExpression, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, +): string | undefined => { const operands: t.Node[] = []; let current: t.Node = node; while (t.isBinaryExpression(current, { operator: "+" })) { operands.push(current.right); current = unwrapJavaScriptExpression(current.left).node; } - const anchored = isDirectoryPath(current); + const anchored = isDirectoryPath(current, nodePathResolve); if (!anchored) operands.push(current); let text = ""; for (const operand of operands.reverse()) { - const value = staticPath(operand); + const value = staticPath(operand, nodePathResolve); if (value === undefined) return undefined; text += value; } @@ -216,29 +228,27 @@ const staticConcatenation = (node: t.BinaryExpression): string | undefined => { const staticCallPath = ( node: t.CallExpression | t.NewExpression, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { + if (t.isCallExpression(node) && nodePathResolve?.(node.callee)) + return staticResolvedPath(node, nodePathResolve); const name = calleeName(node.callee); - if (name === "URL" || name.endsWith(".URL")) - return isFileIdentity(argumentNode(node.arguments[1])) - ? stringValue(node.arguments[0]) - : undefined; if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) { - return staticFileUrlPath(node); + return staticFileUrlPath(node, nodePathResolve); } - if (name.endsWith(".resolve")) - return name === "win32.resolve" || name.endsWith(".win32.resolve") - ? undefined - : staticResolvedPath(node); + if (name.endsWith(".resolve")) return undefined; if (!name.endsWith(".join") && name !== "join") return undefined; const parts: string[] = []; for (const argument of node.arguments) { - if (isDirectoryIdentity(argumentNode(argument))) { + if (isDirectoryPath(argumentNode(argument), nodePathResolve)) { // join never resets at an absolute segment, so `/preload.js` after the // source directory stays inside it. parts.push("."); continue; } - const value = t.isNode(argument) ? staticPath(argument) : undefined; + const value = t.isNode(argument) + ? staticPath(argument, nodePathResolve) + : undefined; if (value === undefined) return undefined; parts.push(value); } @@ -247,13 +257,14 @@ const staticCallPath = ( const staticFileUrlPath = ( node: t.CallExpression | t.NewExpression, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { if (node.arguments.length !== 1) return undefined; const rawArgument = argumentNode(node.arguments[0]); if (rawArgument === undefined) return undefined; const argument = unwrapJavaScriptExpression(rawArgument).node; if (isSourceFileUrl(argument)) return sourceRelativeFileUrlPath(argument); - const value = staticPath(argument); + const value = staticPath(argument, nodePathResolve); const rawPath = value?.split(/[?#]/u, 1)[0] ?? ""; if ( value === undefined || @@ -324,82 +335,86 @@ const isSourceFileUrl = ( const staticResolvedPath = ( node: t.CallExpression | t.NewExpression, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, ): string | undefined => { - const parts: (string | null)[] = []; - for (const argument of node.arguments) { - if (isDirectoryIdentity(argumentNode(argument))) { - parts.push(null); - continue; + const parts: string[] = []; + let anchored = false; + // Node walks right-to-left and never inspects arguments before its anchor. + for (let index = node.arguments.length - 1; index >= 0; index -= 1) { + const argument = node.arguments[index]; + if (isDirectoryPath(argumentNode(argument), nodePathResolve)) { + anchored = true; + break; } - const value = t.isNode(argument) ? staticPath(argument) : undefined; - // Validate every argument, including those before the last anchor. + const value = t.isNode(argument) + ? staticPath(argument, nodePathResolve) + : undefined; if (value === undefined) return undefined; parts.push(value); + if (posix.isAbsolute(value)) { + anchored = true; + break; + } } - const anchor = parts.findLastIndex( - (part) => part === null || posix.isAbsolute(part), - ); // Relative-only calls depend on the target process cwd, which is unknown. - if (anchor === -1) return undefined; - // A null anchor is the source directory; preserve its relative projection. - const path = posix.join( - ...parts.slice(anchor).filter((part) => part !== null), - ); + if (!anchored) return undefined; + const path = posix.join(...parts.reverse()); return path === "/" ? path : path.replace(/\/$/u, ""); }; -const isFilesystemPathExpression = (node: t.Node): boolean => { +const isFilesystemPathExpression = ( + node: t.Node, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, +): boolean => { node = unwrapJavaScriptExpression(node).node; const value = stringValue(node); if (value !== undefined) return value.startsWith("/"); - if (isFileIdentity(node) || isDirectoryIdentity(node)) return true; + if (isFileIdentity(node) || isDirectoryPath(node, nodePathResolve)) + return true; if (t.isBinaryExpression(node, { operator: "+" })) return ( - isFilesystemPathExpression(node.left) || - isFilesystemPathExpression(node.right) + isFilesystemPathExpression(node.left, nodePathResolve) || + isFilesystemPathExpression(node.right, nodePathResolve) ); if (t.isCallExpression(node) || t.isNewExpression(node)) { const name = calleeName(node.callee); - if (name.endsWith(".resolve") && staticCallPath(node) !== undefined) + if ( + nodePathResolve?.(node.callee) && + staticCallPath(node, nodePathResolve) !== undefined + ) return true; - if (name === "URL" || name.endsWith(".URL")) - return isFileIdentity(argumentNode(node.arguments[1])); + // A URL object is not a path string until fileURLToPath converts it. + if (name === "URL" || name.endsWith(".URL")) return false; if (name === "fileURLToPath" || name.endsWith(".fileURLToPath")) return ( - staticFileUrlPath(node) !== undefined || + staticFileUrlPath(node, nodePathResolve) !== undefined || node.arguments.some((argument) => { const value = argumentNode(argument); - return value !== undefined && isFilesystemPathExpression(value); + return ( + value !== undefined && + isFilesystemPathExpression(value, nodePathResolve) + ); }) ); return node.arguments.some((argument) => { const value = argumentNode(argument); - return value !== undefined && isFilesystemPathExpression(value); + return ( + value !== undefined && + isFilesystemPathExpression(value, nodePathResolve) + ); }); } return false; }; -const isDirectoryIdentity = (node: t.Node | undefined): boolean => { - if (node !== undefined) node = unwrapJavaScriptExpression(node).node; - if (isDirectoryPath(node)) return true; - if (!t.isCallExpression(node) && !t.isNewExpression(node)) return false; - const name = calleeName(node.callee); - return ( - (name === "URL" || name.endsWith(".URL")) && - isFileIdentity(argumentNode(node.arguments[1])) - ); -}; - /** `__dirname` or the dirname of the source file, as a string. */ -const isDirectoryPath = (node: t.Node | undefined): boolean => { +const isDirectoryPath = ( + node: t.Node | undefined, + nodePathResolve?: JavaScriptStaticPathIntrinsicQuery, +): boolean => { if (node !== undefined) node = unwrapJavaScriptExpression(node).node; - if (t.isIdentifier(node, { name: "__dirname" })) return true; - if (!t.isCallExpression(node)) return false; - const name = calleeName(node.callee); return ( - (name === "dirname" || name.endsWith(".dirname")) && - isFileIdentity(argumentNode(node.arguments[0])) + node !== undefined && nodePathResolve?.(node, "source-directory") === true ); }; diff --git a/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts b/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts new file mode 100644 index 0000000000..b09fa2a1b0 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisInvocationIteration.test.ts @@ -0,0 +1,122 @@ +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const changedIterator = + "Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};"; +const cases = [ + [ + "ordinary spread", + "", + "require(...['./inert.cjs'])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "ordinary nested spread", + "", + "require(...[...['./inert.cjs']])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "changed direct spread", + changedIterator, + "require(...['./inert.cjs'])", + "../shared/window.cjs", + null, + ], + [ + "changed nested spread", + changedIterator, + "require(...[...['./inert.cjs']])", + "../shared/window.cjs", + null, + ], + [ + "changed generic call spread", + changedIterator, + "require.call(null,...['./inert.cjs'])", + "../shared/window.cjs", + null, + ], + [ + "changed apply argument spread", + changedIterator, + "require.apply(null,[...['./inert.cjs']])", + "../shared/window.cjs", + null, + ], + [ + "changed reflected argument spread", + changedIterator, + "Reflect.apply(require,null,[...['./inert.cjs']])", + "../shared/window.cjs", + null, + ], + [ + "apply arraylike without spread", + changedIterator, + "require.apply(null,['./inert.cjs'])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "reflected arraylike without spread", + changedIterator, + "Reflect.apply(require,null,['./inert.cjs'])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "known argument before custom spread", + changedIterator, + "require('./inert.cjs',...[])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "shadowed Array mutation", + "const Array={prototype:{}};Array.prototype[Symbol.iterator]=function*(){yield '../shared/window.cjs'};", + "require(...['./inert.cjs'])", + "./inert.cjs", + "./inert.cjs", + ], + [ + "ordinary captured generic spread", + "const get=()=>module;const invoke=get.call;", + "invoke.call(...[get,null]).require('./inert.cjs')", + "./inert.cjs", + null, + ], + [ + "changed captured generic spread receiver", + "const get=()=>({});const invoke=get.call;Array.prototype[Symbol.iterator]=function*(){yield ()=>module;yield null;};", + "invoke.call(...[get,null]).require('./inert.cjs')", + "./inert.cjs", + null, + ], +] as const; + +it.each(cases)( + "retains invocation iterator authority for %s", + (_name, setup, call, actual, specifier) => { + const source = `${setup}${call};`; + expect( + runInNewContext( + source, + { + require: (request: string) => request, + module: { require: (request: string) => request }, + }, + { timeout: 1_000 }, + ), + ).toBe(actual); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier }), + ]); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts b/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts new file mode 100644 index 0000000000..abbf4637b7 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisInvocationReadSite.test.ts @@ -0,0 +1,56 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + "handlers[key]()", + "handlers[key].call(null)", + "handlers[key].apply(null,[])", +])("selects a local table callee before its own opaque effects: %s", (call) => { + const result = analyzeJavaScriptStaticSource( + `const handlers={first(){},second(){}};${call};`, + ); + expect(result.references).toEqual([]); + expect(result.parse_status).toBe("complete"); +}); + +it.each([ + 'handlers[key]=module.require;handlers[key].call(module,"./shared.cjs");', + 'handlers.first=module.require;handlers.first.call(module,"./shared.cjs");', +])("retains earlier uncertainty or a real delegate: %s", (use) => { + const result = analyzeJavaScriptStaticSource( + `const handlers={first(){},second(){}};${use}`, + ); + expect(result.references).toContainEqual( + expect.objectContaining({ kind: "require", specifier: null }), + ); + expect(result.parse_status).toBe("partial"); +}); + +it("retains complete coverage for an inline Electron factory export", () => { + const result = analyzeJavaScriptStaticSource(`(() => { + const modules={ + 5(module,exports){ + exports.self=exports; + exports.events=require("electron").ipcMain; + }, + 9(module,exports,load){ + const helper=load(5); + helper.self.events.addListener("test:copy",()=>{}); + } + }; + function load(id){ + const m={exports:{}}; + modules[id].call(m.exports,m,m.exports,load); + return m.exports; + } + load(9); + })();`); + expect(result.parse_status).toBe("complete"); + expect(result.references.every(({ specifier }) => specifier !== null)).toBe( + true, + ); + expect(result.electron.ipc).toContainEqual( + expect.objectContaining({ channel: "test:copy", side: "main" }), + ); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts new file mode 100644 index 0000000000..8841b2a1c9 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderInvocations.test.ts @@ -0,0 +1,290 @@ +import { Module, createRequire } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { unshadowedReferenceSourceRequireCalls } from "../referenceSourceRequireBindings.js"; + +it.each(["require", "require.resolve", "require.main"])( + "distinguishes invoked loading from reference association for %s", + (callee) => { + const source = `${callee}('node:path');`; + const execute = () => + runInNewContext(source, { require: createRequire(import.meta.url) }); + if (callee === "require") expect(execute().join).toBe(path.join); + else if (callee === "require.resolve") expect(execute()).toBe("node:path"); + else expect(execute).toThrow(); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + callee === "require" + ? [expect.objectContaining({ kind: "require", specifier: "node:path" })] + : [], + ); + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected loader association fixture"); + expect( + unshadowedReferenceSourceRequireCalls(parsed, "entry.cjs").size, + ).toBe(1); + }, +); + +const imported = "import {createRequire as make} from 'node:module';"; +it.each([ + [ + "proven factory", + `${imported}const require=make(import.meta.url);require('node:path');`, + 1, + "node:path", + ], + [ + "opaque replacement factory", + "import*as mod from'node:module';mod.default.createRequire=replacement;const require=mod.default.createRequire(import.meta.url);require('node:path');", + 0, + null, + ], +] as const)( + "uses invocation proof for external source association after a %s", + (_name, source, associated, specifier) => { + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected factory proof fixture"); + expect( + unshadowedReferenceSourceRequireCalls(parsed, "entry.mjs").size, + ).toBe(associated); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual([expect.objectContaining({ kind: "require", specifier })]); + }, +); + +it.each([ + [ + "captured wrapper", + "const load=require;load('node:path')", + "node:path", + true, + ], + [ + "wrapper member", + "const box={load:require};box.load('node:path')", + "node:path", + true, + ], + [ + "wrapper array", + "const box=[require];box[0]('node:path')", + "node:path", + true, + ], + [ + "projected wrapper", + "const {load}={load:require};load('node:path')", + "node:path", + true, + ], + [ + "captured before wrapper replacement", + "const load=require;require=()=>({});load('node:path')", + "node:path", + true, + ], + [ + "shadowed wrapper", + "const require=()=>({});const load=require;load('node:path')", + undefined, + false, + ], + [ + "delegate replaced after capture", + "const load=require;module.require=()=>({});load('node:path')", + null, + false, + ], +] as const)( + "retains the selected native %s value", + (_name, source, specifier, host) => { + const module = new Module("wrapper-alias.cjs"); + const result = runInNewContext(source, { + module, + require: (request: string) => module.require(request), + }); + expect(result.join === path.join).toBe(host); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + specifier === undefined + ? [] + : [expect.objectContaining({ kind: "require", specifier })], + ); + }, +); + +it.each([ + [ + "renamed loader", + "const load=make(import.meta.url);load('./shared.cjs')", + "./shared.cjs", + ], + [ + "captured renamed loader", + "const load=make(import.meta.url);const saved=load;saved('./shared.cjs')", + "./shared.cjs", + ], + [ + "object member loader", + "const box={load:make(import.meta.url)};box.load('./shared.cjs')", + "./shared.cjs", + ], + [ + "destructured loader", + "const {load}={load:make(import.meta.url)};load('./shared.cjs')", + "./shared.cjs", + ], + [ + "array member loader", + "const box=[make(import.meta.url)];box[0]('./shared.cjs')", + "./shared.cjs", + ], + [ + "generic loader invocation", + "const load=make(import.meta.url);load.call(null,'./shared.cjs')", + "./shared.cjs", + ], + [ + "reflected created loader", + "const load=make(import.meta.url);Reflect.apply(load,null,['./shared.cjs'])", + "./shared.cjs", + ], + [ + "unknown create base", + "const load=make('/elsewhere/entry.cjs');load('./shared.cjs')", + null, + ], + [ + "conditional create bases", + "const load=flag?make(import.meta.url):make('/elsewhere/entry.cjs');load('./shared.cjs')", + null, + ], + [ + "opaque loader replacement", + "let load=make(import.meta.url);load=replacement;load('./shared.cjs')", + null, + ], + [ + "copied child with a later leaf replacement", + "const box={child:{load:make(import.meta.url)}};const copy={...box};box.child.load=make('/elsewhere/entry.cjs');copy.child.load('./shared.cjs')", + null, + ], + [ + "copied child before a parent replacement", + "const box={child:{load:make(import.meta.url)}};const copy={...box};box.child={load:make('/elsewhere/entry.cjs')};copy.child.load('./shared.cjs')", + "./shared.cjs", + ], + [ + "spread excludes an inherited loader", + "const box={__proto__:{load:make(import.meta.url)}};const copy={...box};copy.load('./shared.cjs')", + undefined, + ], + [ + "local loader replacement", + "let load=make(import.meta.url);load=()=>({});load('./shared.cjs')", + undefined, + ], + ["local loader", "const load=()=>({});load('./shared.cjs')", undefined], + [ + "shadowed create factory", + "function f(make){const load=make(import.meta.url);load('./shared.cjs')}", + undefined, + ], +] as const)( + "classifies %s from the selected loader value", + (_name, body, specifier) => { + const result = analyzeJavaScriptStaticSource(`${imported}${body};`); + expect(result.parse_error_count).toBe(0); + const calls = result.references.filter(({ kind }) => kind === "require"); + expect(calls).toEqual( + specifier === undefined ? [] : [expect.objectContaining({ specifier })], + ); + }, +); + +it("preserves a renamed native createRequire callable", () => { + const load = createRequire(import.meta.url); + const saved = load; + expect(saved("node:path").join).toBe(path.join); +}); + +it.each(["leaf", "parent"])( + "keeps the native created loader identity after a copied child %s write", + (write) => { + const selected = runInNewContext( + `const original=make(base);const outside=make(outsideBase); + const box={child:{load:original}};const copy={...box}; + ${write === "leaf" ? "box.child.load=outside" : "box.child={load:outside}"}; + ({actual:copy.child.load,expected:${write === "leaf" ? "outside" : "original"}});`, + { + make: createRequire, + base: import.meta.url, + outsideBase: new URL("./elsewhere/entry.cjs", import.meta.url).href, + }, + ); + expect(selected.actual).toBe(selected.expected); + }, +); + +it.each([ + [ + "direct Reflect.apply", + "Reflect.apply(module.require,module,['node:path'])", + true, + ], + [ + "captured Reflect.apply", + "const invoke=Reflect.apply;invoke(module.require,module,['node:path'])", + true, + ], + [ + "generic Reflect.apply", + "Reflect.apply.call(null,module.require,module,['node:path'])", + true, + ], + [ + "Reflect.apply captured before replacement", + "const invoke=Reflect.apply;Reflect.apply=()=>({join:false});invoke(module.require,module,['node:path'])", + true, + ], + [ + "local Reflect", + "const Reflect={apply(){return {join:false}}};Reflect.apply(module.require,module,['node:path'])", + false, + ], + [ + "local callable", + "Reflect.apply(()=>({join:false}),module,['node:path'])", + false, + ], + [ + "local module", + "const module={require(){return {join:false}}};Reflect.apply(module.require,module,['node:path'])", + false, + ], +] as const)( + "preserves module delegate provenance through %s", + (_name, source, host) => { + const result = runInNewContext(source, { + module: new Module("reflected-loader.cjs"), + }); + expect(result.join === path.join).toBe(host); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + host + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderMayTargets.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderMayTargets.test.ts new file mode 100644 index 0000000000..aa5c9bc080 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderMayTargets.test.ts @@ -0,0 +1,70 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("keeps the actual CommonJS module export separate from an ESM synthetic default", () => { + const require = createRequire(import.meta.url); + const exported = require("node:module"); + expect(Object.hasOwn(exported, "default")).toBe(false); + expect(exported.default).toBeUndefined(); + expect(() => + runInNewContext( + "const load=require('node:module').default.createRequire(base);load('node:path');", + { require, base: import.meta.url }, + ), + ).toThrow(); + const analysis = analyzeJavaScriptStaticSource( + "const load=require('node:module').default.createRequire(import.meta.url);load('node:path');", + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "node:module" }), + ]); +}); + +it.each(["call", "apply"] as const)( + "retains both native and local created-loader %s alternatives", + (method) => { + const body = (base: string) => + `const load=make(${base});if(flag)load.${method}=()=>({kind:'local'});load.${method}(null,${method === "call" ? "'node:path'" : "['node:path']"});`; + for (const flag of [false, true]) { + const result = runInNewContext(body("base"), { + make: createRequire, + base: import.meta.url, + flag, + }); + expect(result.join === path.join).toBe(!flag); + if (flag) expect(result.kind).toBe("local"); + } + const analysis = analyzeJavaScriptStaticSource( + `import {createRequire as make} from 'node:module';${body("import.meta.url")}`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual([expect.objectContaining({ kind: "require", specifier: null })]); + }, +); + +it.each(["call", "apply"] as const)( + "retains both native and local captured-wrapper %s alternatives", + (method) => { + const body = `const load=require;if(flag)load.${method}=()=>({kind:'local'});load.${method}(null,${method === "call" ? "'node:path'" : "['node:path']"});`; + for (const flag of [false, true]) { + const result = runInNewContext(body, { + require: createRequire(import.meta.url), + flag, + }); + expect(result.join === path.join).toBe(!flag); + if (flag) expect(result.kind).toBe("local"); + } + const analysis = analyzeJavaScriptStaticSource(body); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: null }), + ]); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts b/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts new file mode 100644 index 0000000000..90388435c2 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisLoaderMutationControls.test.ts @@ -0,0 +1,79 @@ +import { createRequire } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const named = "import {createRequire as make} from 'node:module';"; +const namespace = "import * as mod from 'node:module';"; +const cases = [ + { + name: "own call replacement", + prefix: named, + body: (base: string) => + `const load=make(${base});load.call=()=>({kind:'local'});load.call(null,'node:path');`, + host: false, + }, + { + name: "own apply replacement", + prefix: named, + body: (base: string) => + `const load=make(${base});load.apply=()=>({kind:'local'});load.apply(null,['node:path']);`, + host: false, + }, + { + name: "callable captured before method replacement", + prefix: named, + body: (base: string) => + `const load=make(${base});const saved=load;load.call=()=>({kind:'local'});saved('node:path');`, + host: true, + }, + { + name: "mutable default factory replacement", + prefix: namespace, + body: (base: string) => + `mod.default.createRequire=()=>()=>({kind:'local'});const load=mod.default.createRequire(${base});load('node:path');`, + host: false, + }, + { + name: "factory captured before its default slot replacement", + prefix: namespace, + body: (base: string) => + `const make=mod.default.createRequire;mod.default.createRequire=()=>()=>({kind:'local'});const load=make(${base});load('node:path');`, + host: true, + }, + { + name: "loader created before its default factory replacement", + prefix: namespace, + body: (base: string) => + `const load=mod.default.createRequire(${base});mod.default.createRequire=()=>()=>({kind:'local'});load('node:path');`, + host: true, + }, +]; + +it.each(cases)( + "keeps loader and selected member identity for $name", + ({ prefix, body, host }) => { + // The mutable namespace default belongs to this VM fixture. Native created + // require functions retain their real Node behavior without patching builtins. + const result = runInNewContext(body("base"), { + make: createRequire, + mod: { default: { createRequire } }, + base: import.meta.url, + }); + expect(result.join === path.join).toBe(host); + if (!host) expect(result.kind).toBe("local"); + const analysis = analyzeJavaScriptStaticSource( + `${prefix}${body("import.meta.url")}`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual( + host + ? [expect.objectContaining({ kind: "require", specifier: "node:path" })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts b/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts new file mode 100644 index 0000000000..e1c405ca38 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisMainModule.test.ts @@ -0,0 +1,90 @@ +import { Module } from "node:module"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const cases = [ + ["native main", "require.main.require('node:path');", true, true], + [ + "captured main", + "const main=require.main;main.require('node:path');", + true, + true, + ], + [ + "captured delegate", + "const load=require.main.require;load.call(require.main,'node:path');", + true, + true, + ], + ["wrapper var", "var require;require.main.require('node:path');", true, true], + [ + "local require", + "const require={main:{require:()=>({kind:'local'})}};require.main.require('node:path');", + false, + false, + ], + [ + "local alias", + "const load={main:{require:()=>({kind:'local'})}};load.main.require('node:path');", + false, + false, + ], + [ + "wrapper main replacement stays conservative", + "var require;require.main={require:()=>({kind:'local'})};require.main.require('node:path');", + false, + true, + ], + [ + "conditional main replacement", + "var require;if(flag)require.main={require:()=>({kind:'local'})};require.main.require('node:path');", + false, + true, + ], + [ + "main captured before replacement", + "const main=require.main;require.main={require:()=>({kind:'local'})};main.require('node:path');", + true, + true, + ], + [ + "ambient main replacement stays conservative", + "require.main={require:()=>({kind:'local'})};require.main.require('node:path');", + false, + true, + ], + [ + "shared deep child local write", + "const old={child:{inner:{make:()=>module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>({require(){}});box.child.inner.make().require('node:path');", + false, + false, + ], + [ + "shared deep child host write", + "const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>module;box.child.inner.make().require('node:path');", + true, + true, + ], +] as const; + +it.each(cases)( + "retains main-module source for %s", + (_name, source, host, unknown) => { + const module = new Module("main-module-source.cjs"); + const require = Object.assign( + (request: string) => module.require(request), + { main: module }, + ); + const observed = runInNewContext(source, { module, require, flag: true }); + expect(typeof observed?.join === "function").toBe(host); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + unknown + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisProcessMainModule.test.ts b/src/domain/javascript/javascriptStaticAnalysisProcessMainModule.test.ts new file mode 100644 index 0000000000..fb31d16c03 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisProcessMainModule.test.ts @@ -0,0 +1,49 @@ +import { Module } from "node:module"; +import { runInNewContext } from "node:vm"; + +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const cases = [ + ["native process", "process.mainModule.require('node:path');", true], + [ + "captured main", + "const main=process.mainModule;main.require('node:path');", + true, + ], + [ + "captured delegate", + "const main=process.mainModule;const load=main.require;load.call(main,'node:path');", + true, + ], + [ + "local process", + "const process={mainModule:{require:()=>({})}};process.mainModule.require('node:path');", + false, + ], + [ + "replaced process", + "process={mainModule:{require:()=>({})}};process.mainModule.require('node:path');", + false, + ], + ["unrelated process member", "process.other.require('node:path');", false], +] as const; + +it.each(cases)( + "retains the native Module loader for %s", + (_name, source, possible) => { + const main = new Module("process-main-source.cjs"); + const observed = runInNewContext(source, { + process: { mainModule: main, other: { require: () => ({}) } }, + }); + expect(typeof observed?.join === "function").toBe(possible); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + possible + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisProjectedModuleWrites.test.ts b/src/domain/javascript/javascriptStaticAnalysisProjectedModuleWrites.test.ts new file mode 100644 index 0000000000..e9f8f3e962 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisProjectedModuleWrites.test.ts @@ -0,0 +1,144 @@ +import { Module } from "node:module"; +import * as path from "node:path"; +import { runInNewContext } from "node:vm"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +const projectedModuleWrites = [ + [ + "object assignment", + "let selected;({selected}={selected:module});selected.require=module.require;", + true, + ], + [ + "object declaration", + "const {selected}={selected:module};selected.require=module.require;", + true, + ], + [ + "array assignment", + "let selected;[selected]=[module];selected.require=module.require;", + true, + ], + [ + "array declaration", + "const [selected]=[module];selected.require=module.require;", + true, + ], + [ + "nested assignment", + "let selected;({nested:{selected}}={nested:{selected:module}});selected.require=module.require;", + true, + ], + [ + "assignment fallback", + "let selected;({selected=module}={});selected.require=module.require;", + true, + ], + [ + "nested fallback", + "let selected;({nested:{selected=module}}={nested:{}});selected.require=module.require;", + true, + ], + [ + "object rest child", + "let selected;({...selected}={loader:module});selected.loader.require=module.require;", + true, + ], + [ + "array rest child", + "let selected;[,...selected]=[0,module];selected[0].require=module.require;", + true, + ], + [ + "captured alias", + "let selected;({selected}={selected:module});const saved=selected;selected={};saved.require=module.require;", + true, + ], + [ + "captured source container", + "let source={selected:module};let selected;({selected}=source);source={selected:{}};selected.require=module.require;", + true, + ], + [ + "projected escape", + "let selected;({selected}={selected:module});expose(selected);", + true, + ], + [ + "copied child escape", + "let selected;({...selected}={loader:module});expose(selected.loader);", + true, + ], + [ + "inactive fallback", + "const {selected=module}={selected:{}};selected.require=module.require;", + false, + ], + [ + "local alias", + "let selected;({selected}={selected:{require(){}}});selected.require=module.require;", + false, + ], + [ + "object rest container", + "let selected;({...selected}=module);selected.require=module.require;", + false, + ], + [ + "rebound alias", + "let selected;({selected}={selected:module});selected={};selected.require=module.require;", + false, + ], + [ + "rebound fallback", + "let selected;({selected=module}={});selected={};selected.require=module.require;", + false, + ], + [ + "copied container escape", + "let selected;({...selected}=module);expose(selected);", + false, + ], +] as const; + +it.each(projectedModuleWrites)( + "tracks copied require after a projected module write: %s", + (_name, setup, expected) => { + const body = `${setup}const copy={...module};const load=copy.require;load.call(module,'node:path');`; + const host = new Module("projected-module-write-oracle.cjs"); + const context = { + module: host, + expose: (value: Module) => { + value.require = host.require; + }, + }; + if (expected) expect(runInNewContext(body, context).join).toBe(path.join); + else expect(() => runInNewContext(body, context)).toThrow(); + const result = analyzeJavaScriptStaticSource(body); + expect(result.parse_error_count).toBe(0); + expect(result.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each(["selected.require=module.require;", "expose(selected);"])( + "captures copied require before a later projected module effect: %s", + (effect) => { + const body = `let selected;({selected}={selected:module});const copy={...module};${effect}const load=copy.require;load.call(module,'node:path');`; + const host = new Module("late-module-effect.cjs"); + expect(() => + runInNewContext(body, { + module: host, + expose: (value: Module) => { + value.require = host.require; + }, + }), + ).toThrow(); + expect(analyzeJavaScriptStaticSource(body).references).toEqual([]); + }, +); diff --git a/src/domain/javascript/javascriptStaticAnalysisReturnedInvocation.test.ts b/src/domain/javascript/javascriptStaticAnalysisReturnedInvocation.test.ts new file mode 100644 index 0000000000..cdbd2b82cb --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisReturnedInvocation.test.ts @@ -0,0 +1,180 @@ +import { expect, it } from "vitest"; +import * as t from "@babel/types"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { indexJavaScriptBindingsWithReceiversSteps } from "./javascriptSemanticAnalysis.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { createSemanticReturnedReferences } from "./javascriptSemanticReturnedReferences.js"; + +it.each([ + ["const get=()=>module;get.call(null)", true], + ["const get=()=>module;get.apply(null,[])", true], + ["const get=()=>module;get['call'](null)", true], + ["const get=()=>module;const alias=get;alias.apply(null,[])", true], + ["const box={get(){return module;}};box.get.call(null)", true], + ["const get=()=>module;get.call.call(get,null)", true], + ["const get=()=>module;get.apply.call(get,null,[])", true], + ["const get=()=>module;const invoke=get.call;invoke.call(get,null)", true], + [ + "const get=()=>module;const invoke=get.call;const other=()=>({});invoke.call(other,null)", + false, + ], + [ + "const get=()=>({});const invoke=get.call;const other=()=>module;invoke.call(other,null)", + true, + ], + [ + "const get=()=>module;const invoke=get.apply;invoke.call(get,null,[])", + true, + ], + ["const get=()=>module;const invoke=get.call;invoke.apply(get,[null])", true], + ["const get=()=>({});get.call(null)", false], + ["const get=()=>({});get.apply(null,[])", false], + ["const get=()=>module.exports;get.call(null)", false], + ["const get=()=>module;get.call=()=>({require(){}});get.call(null)", false], + [ + "const get=()=>module;get.apply=()=>({require(){}});get.apply(null,[])", + false, + ], + ["const get=()=>({});get.call=()=>module;get.call(null)", true], + ["const get=()=>({});get.apply=()=>module;get.apply(null,[])", true], + ["const get=()=>module;const invoke=get.call;invoke(get)", false], + ["const get=()=>module;(0,get.call)(null)", false], + ["const get=()=>module;(0,get.apply)(null,[])", false], + [ + "const get=()=>module;const other=()=>({});get.call.call(other,null)", + false, + ], + ["const get=()=>({});const other=()=>module;get.call.call(other,null)", true], + ["function* get(){yield module;}get.call(null)", false], + ["async function get(){return module;}get.apply(null,[])", false], + ["async function get(){return module;}(await get.call(null))", true], + ["const get=()=>module;get.call=unknown;get.call(null)", true], + ["const get=()=>module;if(flag)get.call=()=>({});get.call(null)", true], + [ + "const get=()=>({});Object.setPrototypeOf(get,{call:()=>module});get.call(null)", + true, + ], + [ + "const get=()=>module;Object.setPrototypeOf(get,{call:()=>({})});get.call(null)", + true, + ], + [ + "const get=()=>1;Object.setPrototypeOf(get,{call:()=>module});get.call(null)", + true, + ], + [ + "const get=()=>({});Object.setPrototypeOf(get,{call:()=>module});get.call=()=>({});get.call(null)", + false, + ], + [ + "const get=()=>({});Object.setPrototypeOf(get,{call:()=>({})});get.call=()=>module;get.call(null)", + true, + ], +])( + "retains invocation results after selecting source-local member identity: %s", + (receiver, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ["Function.prototype.call=unknown;const get=()=>({});get.call(null)", true], + [ + "Function.prototype.apply=unknown;const get=()=>({});get.apply(null,[])", + true, + ], + [ + "Function.prototype.call=unknown;const get=()=>module;get.call=()=>({});get.call(null)", + false, + ], + ["Function.prototype.extra=unknown;const get=()=>({});get.call(null)", false], +])( + "retains inherited invocation uncertainty without revoking own slots: %s", + (receiver, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected ? [expect.objectContaining({ specifier: null })] : [], + ); + }, +); + +it("keeps source reads before later function-method replacement", () => { + const analysis = analyzeJavaScriptStaticSource(` + const get=()=>module; + get.call(null).require('./shared.cjs'); + get.call=()=>({}); + get.call(null).require('./unused.cjs'); + `); + expect(analysis.references).toEqual([ + expect.objectContaining({ specifier: null }), + ]); +}); + +it("keeps an earlier native invocation before a later prototype write", () => { + const analysis = analyzeJavaScriptStaticSource(` + const get=()=>({}); + get.call(null).require('./unused.cjs'); + Function.prototype.call=unknown; + `); + expect(analysis.references).toEqual([]); +}); + +it("does not treat a shadowed Function namespace as native prototype mutation", () => { + const analysis = analyzeJavaScriptStaticSource(` + function local(Function){ + Function.prototype.call=unknown; + const get=()=>({}); + get.call(null).require('./unused.cjs'); + } + `); + expect(analysis.references).toEqual([]); +}); + +it("retains a finite shared callable alias graph", () => { + const aliases = Array.from( + { length: 64 }, + (_, index) => `const get${index + 1}=flag?get${index}:get${index};`, + ).join(""); + const parsed = parseJavaScriptSource( + `const get0=()=>module;${aliases}get64.apply(null,[]);`, + ); + if (parsed === null) throw new Error("Expected valid shared alias fixture"); + const index = completeSemanticSteps( + indexJavaScriptBindingsWithReceiversSteps(parsed.program), + ); + completeSemanticSteps(index.indexAmbientWritesSteps()); + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const statement = parsed.program.body.at(-1); + if ( + !t.isExpressionStatement(statement) || + !t.isCallExpression(statement.expression) + ) + throw new Error("Expected final invocation"); + const call = statement.expression; + const result = createSemanticReturnedReferences( + index.bindings, + "direct-call", + )(call.callee, call); + expect(result).toEqual({ + references: [ + { + node: expect.objectContaining({ type: "Identifier", name: "module" }), + projection: [], + }, + ], + precision: "exact", + }); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisReturnedRequire.test.ts b/src/domain/javascript/javascriptStaticAnalysisReturnedRequire.test.ts new file mode 100644 index 0000000000..eed35ede1a --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisReturnedRequire.test.ts @@ -0,0 +1,285 @@ +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([ + ["async function get(){return module;}(await get())", true], + ["async function get(){return module;}const api=await get();api", true], + [ + "async function get(){return module;}const pending=get();const api=await pending;api", + true, + ], + ["function get(){return module;}const api=await get();api", true], + ["function* get(){yield module;}const api=await get();api", false], + ["function* get(){return module;}const api=await get();api", false], + ["async function* get(){yield module;}const api=await get();api", false], + [ + "async function get(){return {require(){}};}const api=await get();api", + false, + ], + ["async function get(){return module;}const api=get();api", false], + [ + "async function child(){return module;}async function get(){return {child:child()};}(await get()).child", + false, + ], + [ + "async function child(){return module;}function get(){return {child:child()};}(await get().child)", + true, + ], + [ + "async function child(){return module;}async function get(){return {child:child()};}const {child:pending}=await get();const api=await pending;api", + true, + ], + [ + "async function get(){return module;}const pending=get();const api=flag?pending:await pending;api", + true, + ], + [ + "async function get(){return module;}const pending=get();const api=flag?await pending:pending;api", + true, + ], + [ + "async function inner(){return module;}function get(){return inner();}(await get())", + true, + ], +])( + "preserves awaited result consumption through aliases and projections: %s", + (receiver, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + "const {make=host}={make:class{}};", + "class Local{}const {make=host}={make:Local};", + "const [make=host]=[class{}];", +])( + "excludes inactive defaults for known class constructors: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `function host(){return module;}${declaration}new make().require=()=>{};require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); + }, +); + +it.each([ + ["const {make=host}={make(){return {};}};", false], + ["const {make=host}={make:()=>({})};", false], + ["const [make=host]=[()=>({})];", false], + ["const {box:{make=host}={}}={box:{make(){return {};}}};", false], + ["const {make=host}={};", true], + ["const [make=host]=[];", true], + ["const {box:{make=host}={}}={};", true], + ["const {make=host}=unknown;", true], + ["const [make=host]=unknown;", true], + ["const {make=host}={make:unknown};", true], + ["const {make=host}={make:undefined};", true], + ["const [make=host]=[undefined];", true], + ["const {make=host}={get make(){return undefined;}};", true], + ["const {make=host}={make:flag?()=>({}):()=>({})};", false], + [ + "const values=[()=>({})];values[Symbol.iterator]=function*(){yield undefined;};const [make=host]=values;", + true, + ], + [ + "Array.prototype[Symbol.iterator]=function*(){yield undefined;};const [make=host]=[()=>({})];", + true, + ], + ["const {make=host}={make:host};", true], + ["const [make=host]=[host];", true], + ["const make=host;", true], + ["const make=()=>({});", false], +])( + "retains only applicable default callable sources: %s", + (declaration, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `function host(){return module;}${declaration}make().require=()=>{};require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references.at(-1)).toEqual( + expect.objectContaining({ + kind: "require", + specifier: expected ? null : "./shared.cjs", + }), + ); + }, +); + +it.each([ + "function* get(){yield module;}", + "function* get(){return module;}", + "async function get(){return module;}", + "async function* get(){yield module;}", + "const get=async()=>module;", +])( + "distinguishes direct wrapper objects from their eventual contents: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration}const api=get();api.require=()=>{};api.require('./unused.cjs');require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); + }, +); + +it.each([ + "const table={x(){return{};},y(){return{};},z(){return module;}};const get=flag?table.x:table.y;", + "const table={a:{b(){return{};},d(){return module;}},c:{d(){return{};},b(){return module;}}};const get=flag?table.a.b:table.c.d;", +])("retains precise selected direct-return receivers: %s", (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + `${declaration}get().require('./unused.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([]); +}); + +it.each([ + ["Object.prototype[0]=module;", true], + ["Object.prototype['0']=module;", true], + ["Object.prototype.constructor.prototype[0]=module;", true], + ["globalThis.Object.prototype[0]=module;", true], + ["global.Object.prototype[0]=module;", true], + ["window.Object.prototype[0]=module;", true], + ["self.Object.prototype[0]=module;", true], + ["Object.prototype['01']=module;", false], + ["Object.prototype.extra=module;", false], + ["Object.prototype.require=replacement;", false], + ["Array.prototype[0]=module;", true], + ["Array.prototype['0']=module;", true], + ["Array.prototype.constructor.prototype[0]=module;", true], + ["Array.prototype['01']=module;", false], + ["Array.prototype.extra=module;", false], + ["Array.prototype.require=replacement;", false], +])("retains inherited numeric array iteration slots: %s", (write, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${write}const [api]=[,];api.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); +}); + +it.each([ + "function local(Object){Object.prototype[0]=module;const[api]=[,];api.require('./shared.cjs');}", + "function local(globalThis){globalThis.Object.prototype[0]=module;const[api]=[,];api.require('./shared.cjs');}", +])("retains shadowed prototype controls: %s", (source) => { + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual([]); +}); + +it.each([ + [ + "Array.prototype.constructor.prototype[Symbol.iterator]=function*(){yield module;};", + true, + ], + [ + "Array.prototype.constructor.prototype.constructor.prototype[Symbol.iterator]=custom;", + true, + ], + ["Array.prototype.constructor.prototype.extra=1;", false], + ["Array.prototype.constructor.prototype.require=replacement;", false], + ["Array.prototype.require=replacement;", false], +])( + "tracks iterator changes through prototype constructor cycles: %s", + (write, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${write}const [selected]=[];selected.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); + +it.each([ + ["const get=()=>module;get()", true], + ["function get(){return module;}get()", true], + ["const get=()=>module;const alias=get;alias()", true], + ["const get=()=>({loader:module});get().loader", true], + ["const get=()=>flag?module:{};get()", true], + ["const inner=()=>module;const get=()=>inner();get()", true], + ["(()=>module)()", true], + ["const get=()=>({require(){}});get()", false], + ["const get=()=>module.exports;get()", false], + ["function get(module){return module;}get({require(){}})", false], + ["let get=()=>module;get=()=>({require(){}});get()", false], +])("retains possible returned module receivers: %s", (receiver, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${receiver}.require('./shared.cjs');`, + ); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + expected + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); +}); + +it.each([ + [ + "const [api]=[module];Array.prototype.extra=1;api.require=replacement;", + true, + ], + [ + "const [api]=[module];Array.prototype[Symbol.iterator]=custom;api.require=replacement;", + true, + ], + ["const [api]=[{}];Array.prototype.extra=1;api.require=replacement;", false], + [ + "const values={*[Symbol.iterator](){yield module;}};const [api]=values;api.require=replacement;", + true, + ], + [ + "const values=[{}];values[Symbol.iterator]=function*(){yield module;};const [api]=values;api.require=replacement;", + true, + ], + [ + "Array.prototype[Symbol.iterator]=custom;const [api]=[{}];api.require=replacement;", + true, + ], + ["const get=()=>module;get().require=replacement;", true], + ["const get=()=>({require(){}});get().require=replacement;", false], +])( + "retains host delegate uncertainty after possible receiver writes: %s", + (write, expected) => { + const analysis = analyzeJavaScriptStaticSource( + `${write}require('./shared.cjs');`, + ); + expect(analysis.references).toEqual([ + expect.objectContaining({ + kind: "require", + specifier: expected ? null : "./shared.cjs", + }), + ]); + }, +); + +it("preserves a require before later returned-receiver delegate writes", () => { + const analysis = analyzeJavaScriptStaticSource( + `require('./shared.cjs');const get=()=>module;get().require=replacement;`, + ); + expect(analysis.references).toEqual([ + expect.objectContaining({ kind: "require", specifier: "./shared.cjs" }), + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisSharedHelperWrites.test.ts b/src/domain/javascript/javascriptStaticAnalysisSharedHelperWrites.test.ts new file mode 100644 index 0000000000..5a07f99fe3 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisSharedHelperWrites.test.ts @@ -0,0 +1,83 @@ +import changedPath from "node:path"; +import path from "node:path"; +import posix from "node:path/posix"; +import changedUrl from "node:url"; +import url from "node:url"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it("observes that separate Node default imports share mutable helper slots", () => { + expect(changedPath).toBe(path); + expect(changedUrl).toBe(url); + expect(path.posix).toBe(posix); + const resolve = changedPath.resolve; + const decode = changedUrl.fileURLToPath; + try { + changedPath.resolve = (first) => first; + changedUrl.fileURLToPath = () => "/changed/main.mjs"; + expect(path.resolve("/runtime/actual.cjs", "/source", "decoy.cjs")).toBe( + "/runtime/actual.cjs", + ); + expect(url.fileURLToPath("file:///source/main.mjs")).toBe( + "/changed/main.mjs", + ); + } finally { + changedPath.resolve = resolve; + changedUrl.fileURLToPath = decode; + } +}); + +it.each([ + [ + "path resolve", + "import changed from 'node:path';import path from 'path';changed.resolve=first=>first;", + "path.resolve(runtimePath,__dirname,'decoy.cjs')", + ], + [ + "POSIX path resolve", + "import changed from 'node:path/posix';import path from 'path/posix';changed.resolve=first=>first;", + "path.resolve(runtimePath,__dirname,'decoy.cjs')", + ], + [ + "decoded URL source anchor", + "import changed from 'node:url';import url from 'url';import path from 'node:path';changed.fileURLToPath=()=>'/changed/main.mjs';", + "path.resolve(runtimePath,path.dirname(url.fileURLToPath(import.meta.url)),'decoy.cjs')", + ], + [ + "POSIX exported child identity", + "import changed from 'node:path/posix';import path from 'node:path';changed.resolve=first=>first;", + "path.posix.resolve(runtimePath,__dirname,'decoy.cjs')", + ], + [ + "direct POSIX module identity", + "import changed from 'node:path';import path from 'node:path/posix';changed.posix.resolve=first=>first;", + "path.resolve(runtimePath,__dirname,'decoy.cjs')", + ], +] as const)( + "retains shared default writes before proving %s", + (_name, setup, expression) => { + const analysis = analyzeJavaScriptStaticSource(`${setup} + import {BrowserWindow,utilityProcess} from 'electron'; + new BrowserWindow({webPreferences:{preload:${expression}}}); + utilityProcess.fork(${expression});`); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: null }, + ]); + }, +); + +it("distinguishes replacing a path child slot from changing its exported object", () => { + const analysis = analyzeJavaScriptStaticSource(` + import changed from 'node:path';import path from 'node:path/posix'; + changed.posix={resolve(first){return first}}; + import{BrowserWindow}from'electron'; + new BrowserWindow({webPreferences:{preload:path.resolve(runtimePath,__dirname,'decoy.cjs')}});`); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "decoy.cjs" }, + ]); +}); diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 25d06463ed..79438ec3ed 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -1,6 +1,7 @@ import type * as t from "@babel/types"; import type { JavaScriptOpenReceiverFact } from "./javascriptSemanticAnalysis.js"; import type { JavaScriptRpcScan } from "./javascriptRpcEndpoints.js"; +import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; import type { JavaScriptBundlerRegistration, @@ -11,6 +12,7 @@ import type { JavaScriptStaticStorage, } from "./javascriptStaticAnalysisTypes.js"; import type { + ElectronAppPathChangeFinding, ElectronCallPath, ElectronBrowserWindowFinding, ElectronContextBridgeFinding, @@ -38,6 +40,12 @@ export interface LocatedJavaScriptFinding { /** Mutable collections used during one inert AST traversal. */ export interface JavaScriptAnalysisAccumulator { + readonly pendingModuleRequireCalls: ( + | t.CallExpression + | t.OptionalCallExpression + | t.NewExpression + )[]; + readonly appPathChanges: LocatedJavaScriptFinding[]; electronCallPaths?: readonly ElectronCallPath[]; electronCallPathLimitReached?: boolean; readonly references: LocatedJavaScriptFinding[]; @@ -80,6 +88,8 @@ export interface JavaScriptAnalysisAccumulator { /** Shared source and accumulator for helper inspections. */ export interface JavaScriptFindingContext { + /** Node path.resolve identity established by shared callee and mutation owners. */ + readonly nodePathResolve?: JavaScriptStaticPathIntrinsicQuery; readonly rpcScan: JavaScriptRpcScan; readonly source: string; readonly accumulator: JavaScriptAnalysisAccumulator; @@ -87,6 +97,12 @@ export interface JavaScriptFindingContext { readonly openReceiverFacts?: ReadonlyMap; /** Proven Electron export path segments by root offset. */ readonly electronBindings?: ReadonlyMap; + /** Normalized invocation target from the shared Electron and intrinsic owners. */ + readonly electronInvocationPath?: ( + node: t.CallExpression | t.OptionalCallExpression, + ) => readonly string[] | undefined; + /** A possible shared-app mutation does not establish concrete API identity. */ + readonly electronAppPathChangePossible?: (node: t.Node) => boolean; } /** Candidate import, require, worker, or service-worker reference. */ @@ -120,6 +136,8 @@ export interface LocatedJavaScriptFindingInput< export const createJavaScriptAnalysisAccumulator = ( sourceLength: number, ): JavaScriptAnalysisAccumulator => ({ + pendingModuleRequireCalls: [], + appPathChanges: [], references: [], endpoints: [], pendingNetworkCalls: [], diff --git a/src/domain/javascript/javascriptStaticAnalysisTypedMemberStores.test.ts b/src/domain/javascript/javascriptStaticAnalysisTypedMemberStores.test.ts new file mode 100644 index 0000000000..333a852296 --- /dev/null +++ b/src/domain/javascript/javascriptStaticAnalysisTypedMemberStores.test.ts @@ -0,0 +1,55 @@ +import { Module, stripTypeScriptTypes } from "node:module"; +import { runInNewContext } from "node:vm"; +import * as t from "@babel/types"; +import { expect, it } from "vitest"; + +import { semanticWriteTargets } from "./javascriptAstValues.js"; +import { semanticIterationSources } from "./javascriptSemanticIterationSources.js"; +import { parseJavaScriptSource } from "./javascriptSourceParser.js"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each( + [false, true].flatMap((typed) => + [false, true].map((host) => ({ typed, host })), + ), +)( + "retains the selected member store with types=$typed and host=$host", + ({ typed, host }) => { + const target = typed ? "(box.make as any)" : "box.make"; + const source = `const box={make:()=>({require(){}})};${target}=()=>${host ? "module" : "({require(){}})"};box.make().require('./shared.cjs');`; + const parsed = parseJavaScriptSource(source); + if (parsed === null) throw new Error("Expected a parsed member store"); + const statement = parsed.program.body[1]; + if ( + !t.isExpressionStatement(statement) || + !t.isAssignmentExpression(statement.expression) + ) + throw new Error("Expected the member assignment"); + const assignment = statement.expression; + const stores = semanticIterationSources(assignment, statement).filter( + ({ kind }) => kind === "reference-store", + ); + expect(stores).toHaveLength(1); + expect(stores[0]?.target).toBe( + [...semanticWriteTargets(assignment.left)][0], + ); + expect(stores[0]?.node).toBe(assignment.right); + expect(stores[0]?.projection).toEqual([]); + + const requests: string[] = []; + const module = new Module("typed-member-store-oracle.cjs"); + module.require = (request) => { + requests.push(request); + return {}; + }; + runInNewContext(stripTypeScriptTypes(source), { module }); + expect(requests).toEqual(host ? ["./shared.cjs"] : []); + const analysis = analyzeJavaScriptStaticSource(source); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.references).toEqual( + host + ? [expect.objectContaining({ kind: "require", specifier: null })] + : [], + ); + }, +); diff --git a/src/domain/javascript/javascriptStaticPathIntrinsics.ts b/src/domain/javascript/javascriptStaticPathIntrinsics.ts new file mode 100644 index 0000000000..701ca98e7e --- /dev/null +++ b/src/domain/javascript/javascriptStaticPathIntrinsics.ts @@ -0,0 +1,73 @@ +import * as t from "@babel/types"; + +import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js"; +import type { JavaScriptBindingIndex } from "./javascriptSemanticState.js"; +import { completeSemanticSteps } from "./javascriptSemanticTraversal.js"; +import { evaluateSemanticExpressionProvenance } from "./javascriptSemanticValues.js"; +import { + isUnmodifiedGlobalReference, + isUnmodifiedImportMetaUrl, +} from "./javascriptSemanticAmbientWrites.js"; +import { unwrapJavaScriptExpression } from "./javascriptAstValues.js"; +import type { JavaScriptStaticPathIntrinsicQuery } from "./javascriptStaticAnalysisHelpers.js"; + +/** Prove Node's path resolver before applying its argument-selection semantics. */ +export const createStaticNodePathResolveQuery = ( + index: JavaScriptBindingIndex, +): JavaScriptStaticPathIntrinsicQuery => { + const facts = new WeakMap(); + const nativeMethod = ( + callee: t.Node, + method: string, + modules: readonly string[], + ): boolean => { + const provenance = evaluateSemanticExpressionProvenance( + callee, + index.bindings, + ); + if ( + provenance.status !== "module" || + provenance.origins[0].importedPath.at(-1) !== method || + !modules.includes(provenance.origins[0].specifier.replace(/^node:/u, "")) + ) + return false; + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + const path = builtinDataEffectCallablePath(callee, index.bindings, modules); + return ( + path !== null && + ((path.length === 1 && path[0] === method) || + (modules.includes("path") && + path.length === 2 && + path[0] === "posix" && + path[1] === method)) + ); + }; + const sourceFile = (node: t.Node | undefined): boolean => { + if (node === undefined) return false; + node = unwrapJavaScriptExpression(node).node; + if (t.isIdentifier(node, { name: "__filename" })) + return isUnmodifiedGlobalReference(node, index.bindings, "__filename"); + return ( + t.isCallExpression(node) && + node.arguments.length === 1 && + nativeMethod(node.callee, "fileURLToPath", ["url"]) && + isUnmodifiedImportMetaUrl(node.arguments[0], index.bindings) + ); + }; + return (callee, fact) => { + if (fact === "source-directory") { + callee = unwrapJavaScriptExpression(callee).node; + return t.isIdentifier(callee, { name: "__dirname" }) + ? isUnmodifiedGlobalReference(callee, index.bindings, "__dirname") + : t.isCallExpression(callee) && + callee.arguments.length === 1 && + nativeMethod(callee.callee, "dirname", ["path", "path/posix"]) && + sourceFile(callee.arguments[0]); + } + const cached = facts.get(callee); + if (cached !== undefined) return cached; + const proven = nativeMethod(callee, "resolve", ["path", "path/posix"]); + facts.set(callee, proven); + return proven; + }; +}; diff --git a/src/domain/javascript/javascriptStaticPathResolution.test.ts b/src/domain/javascript/javascriptStaticPathResolution.test.ts index 83d6daba8f..56b44c7c4c 100644 --- a/src/domain/javascript/javascriptStaticPathResolution.test.ts +++ b/src/domain/javascript/javascriptStaticPathResolution.test.ts @@ -7,6 +7,7 @@ import { describe, expect, it } from "vitest"; import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; import { parserPluginsForPath } from "./javascriptSourceParser.js"; import { + calleeName, staticPath, staticPathResolutionContext, } from "./javascriptStaticAnalysisHelpers.js"; @@ -27,6 +28,22 @@ const anchoredExpressions = [ ['path[`resolve`](__dirname, "preload.js")', "preload.js"], ['path.posix.resolve("ignored", __dirname, "preload.js")', "preload.js"], ['path.resolve("/ignored", __dirname, "preload.js")', "preload.js"], + ['path.resolve(runtimeBase, "/app/preload.js")', "/app/preload.js"], + ['path.resolve(runtimeBase, __dirname, "preload.js")', "preload.js"], + ['path.resolve(...parts, "/app/preload.js")', "/app/preload.js"], + ['path.resolve(42, "/app/preload.js")', "/app/preload.js"], + [ + 'path.resolve(new URL("./", import.meta.url), __dirname, "preload.js")', + "preload.js", + ], + [ + 'path.resolve(new URL("./", import.meta.url), dirname(fileURLToPath(import.meta.url)), "preload.js")', + "preload.js", + ], + [ + 'path.posix.resolve(new URL("./", import.meta.url), "/app", "preload.js")', + "/app/preload.js", + ], [ 'path.posix.resolve("ignored", dirname(fileURLToPath(import.meta.url)), "preload.js")', "preload.js", @@ -58,13 +75,9 @@ const unknownExpressions = [ "path.posix.resolve()", 'path.resolve("sub", "../preload.js")', 'path.resolve("", "")', - 'path.resolve(runtimeBase, "/app/preload.js")', 'path.resolve("/app", runtimeFile)', - 'path.resolve(runtimeBase, __dirname, "preload.js")', "path.resolve(__dirname, runtimeFile)", - 'path.resolve(...parts, "/app/preload.js")', 'path.resolve("/app", ...parts)', - 'path.resolve(42, "/app/preload.js")', 'path.resolve("/app", null)', "path.resolve(`/app/${runtimeFile}`)", 'path.win32.resolve("/app", "/shared/preload.js")', @@ -73,6 +86,12 @@ const unknownExpressions = [ 'resolve("/app", "preload.js")', '__dirname + "preload.js"', 'path.dirname(__filename) + "preload.js"', + 'new URL("./pre%2Fload.js", import.meta.url)', + 'path.join(new URL("./", import.meta.url), "preload.js")', + 'path.resolve(new URL("./", import.meta.url), "preload.js")', + 'path.join(__dirname, new URL("./pre%2Fload.js", import.meta.url))', + 'path.resolve(__dirname, new URL("./", import.meta.url), "preload.js")', + 'path.resolve("/app", new URL("./", import.meta.url), "preload.js")', ]; const pathValueExpressions = [ @@ -324,6 +343,36 @@ it.each([ ); }); +it("decodes a source-relative file URL once before composing a filesystem path", () => { + assertPathProjection( + 'fileURLToPath(new URL("./pre%252Fload.js", import.meta.url))', + "./pre%2Fload.js", + "filesystem-expression", + ); + assertPathProjection( + 'path.join(dirname(fileURLToPath(import.meta.url)), "pre%2Fload.js")', + "pre%2Fload.js", + "filesystem-expression", + ); +}); + +it("retains Worker URL references through their separate URL-aware producer", () => { + const specifier = "./worker%252Fentry.js?version=1#worker"; + const analysis = analyzeJavaScriptStaticSource(` + import { Worker } from "node:worker_threads"; + new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url)); + `); + + expect(analysis.parse_status).toBe("complete"); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references + .filter(({ kind }) => kind === "worker") + .map(({ specifier, expression }) => ({ specifier, expression })), + ).toEqual([{ specifier, expression: null }]); + expect(analysis.role_paths).toEqual([]); +}); + it.each([ 'fileURLToPath("file:///app/%2Fpreload.js")', 'fileURLToPath("file:///app/%5Cpreload.js")', @@ -344,7 +393,8 @@ const parsePathExpression = (expression: string) => const analyzePathConsumers = (expression: string) => { const property = `preload: ${expression}`; - const genericPrefix = "const options = { "; + const genericPrefix = + 'import path, {dirname} from "node:path"; import {fileURLToPath} from "node:url"; const options = { '; const windowPrefix = "new BrowserWindow({ webPreferences: { "; const windowExpression = `${windowPrefix}${property} } })`; const utilityExpression = `utilityProcess.fork(${expression})`; @@ -366,8 +416,24 @@ const assertPathProjection = ( context: JavaScriptStaticPathContext, ) => { const node = parsePathExpression(expression); - expect(staticPath(node)).toBe(path); - expect(staticPathResolutionContext(node)).toBe(context); + // This syntax matrix supplies identity explicitly; full-source cases prove it. + const nodePathResolve = ( + callee: import("@babel/types").Node, + fact?: "source-directory", + ) => { + if (fact === "source-directory") { + const name = calleeName( + callee.type === "CallExpression" ? callee.callee : callee, + ); + return ( + name === "__dirname" || name === "dirname" || name.endsWith(".dirname") + ); + } + const name = calleeName(callee); + return name.endsWith(".resolve") && !name.includes("win32"); + }; + expect(staticPath(node, nodePathResolve)).toBe(path); + expect(staticPathResolutionContext(node, nodePathResolve)).toBe(context); const { analysis, property, diff --git a/src/domain/javascript/javascriptStaticPathResolveOwners.test.ts b/src/domain/javascript/javascriptStaticPathResolveOwners.test.ts new file mode 100644 index 0000000000..324ba9033d --- /dev/null +++ b/src/domain/javascript/javascriptStaticPathResolveOwners.test.ts @@ -0,0 +1,166 @@ +import { posix } from "node:path"; + +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each(["/other", "relative"])( + "keeps a shadowed source-directory parameter dynamic: %s", + (directory) => { + const actual = posix.resolve("/runtime/base", directory, "decoy.cjs"); + expect(actual).not.toBe("/source/app/decoy.cjs"); + const analysis = analyzeJavaScriptStaticSource(` + const path = require('node:path'); + const {BrowserWindow, utilityProcess} = require('electron'); + function build(__dirname) { + new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,__dirname,'decoy.cjs')}}); + utilityProcess.fork(path.resolve(runtimeBase,__dirname,'decoy.cjs')); + } + build(${JSON.stringify(directory)}); + `); + expect(analysis.role_paths).toEqual([]); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null, preload_resolution_context: null }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: null, module_resolution_context: null }, + ]); + }, +); + +const localResolvers = [ + [ + "local object", + "const helper = {resolve(first) {return first;}};", + "helper.resolve", + ], + [ + "familiar name", + "const path = {resolve(first) {return first;}};", + "path.resolve", + ], + [ + "shadowed require", + "const require = () => ({resolve(first) {return first;}}); const helper = require('node:path');", + "helper.resolve", + ], + [ + "replaced Node method", + "const helper = require('node:path'); helper.resolve = first => first;", + "helper.resolve", + ], + [ + "ambiguous owner", + "const helper = flag ? require('node:path') : {resolve(first) {return first;}};", + "helper.resolve", + ], +] as const; + +it.each(localResolvers)( + "keeps %s resolve calls dynamic", + (_name, setup, callee) => { + const expression = `${callee}(runtimePath, __dirname, "decoy.cjs")`; + const native = new Function( + "require", + "runtimePath", + "__dirname", + "flag", + `{ ${setup} return ${expression}; }`, + ); + expect( + native( + () => ({ ...posix }), + "/actual/runtime.cjs", + "/fixture/app", + false, + ), + ).toBe("/actual/runtime.cjs"); + const analysis = analyzeJavaScriptStaticSource(` + import {BrowserWindow, utilityProcess} from 'electron'; + ${setup} + new BrowserWindow({webPreferences:{preload:${expression}}}); + utilityProcess.fork(${expression}); + `); + expect(analysis.role_paths).toEqual([]); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null, preload_resolution_context: null }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: null, module_resolution_context: null }, + ]); + }, +); + +const nodeResolvers = [ + ["require", "const helper = require('node:path');", "helper.resolve"], + [ + "destructured alias", + "const {resolve: select} = require('path');", + "select", + ], + [ + "POSIX namespace", + "const helper = require('node:path');", + "helper.posix.resolve", + ], + [ + "POSIX alias", + "const {posix: helper} = require('node:path');", + "helper.resolve", + ], + [ + "POSIX module", + "const helper = require('node:path/posix');", + "helper.resolve", + ], + ["default import", "import helper from 'node:path';", "helper.resolve"], + [ + "namespace import", + "import * as helper from 'node:path';", + "helper.posix.resolve", + ], + ["named import", "import {resolve as select} from 'node:path';", "select"], +] as const; + +it("does not treat a constructed Node resolver as a path call", () => { + expect(() => Reflect.construct(posix.resolve, ["/app", "decoy.cjs"])).toThrow( + TypeError, + ); + const analysis = analyzeJavaScriptStaticSource(` + const helper = require('node:path'); + const {BrowserWindow} = require('electron'); + new BrowserWindow({webPreferences:{preload:new helper.resolve(runtimePath,__dirname,'decoy.cjs')}}); + `); + expect(analysis.role_paths).toEqual([]); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); +}); + +it.each(nodeResolvers)( + "retains proven Node %s right-to-left anchoring", + (_name, setup, callee) => { + expect( + posix.resolve("/actual/runtime.cjs", "/fixture/app", "decoy.cjs"), + ).toBe("/fixture/app/decoy.cjs"); + const expression = `${callee}(runtimePath, __dirname, "decoy.cjs")`; + const analysis = analyzeJavaScriptStaticSource(` + const {BrowserWindow, utilityProcess} = require('electron'); + ${setup} + new BrowserWindow({webPreferences:{preload:${expression}}}); + utilityProcess.fork(${expression}); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { + preload_path: "decoy.cjs", + preload_resolution_context: "filesystem-expression", + }, + ]); + expect(analysis.electron.utility_processes).toMatchObject([ + { + module_path: "decoy.cjs", + module_resolution_context: "filesystem-expression", + }, + ]); + }, +); diff --git a/src/domain/javascript/javascriptStaticPathSourceAnchors.test.ts b/src/domain/javascript/javascriptStaticPathSourceAnchors.test.ts new file mode 100644 index 0000000000..6566050d82 --- /dev/null +++ b/src/domain/javascript/javascriptStaticPathSourceAnchors.test.ts @@ -0,0 +1,121 @@ +import { createRequire, wrap } from "node:module"; +import { posix } from "node:path"; +import { fileURLToPath } from "node:url"; +import { runInThisContext } from "node:vm"; + +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +it.each([false, true])( + "preserves only untouched authored CommonJS dirname vars: assigned=%s", + (assigned) => { + const setup = `const path=require('node:path/posix');var __dirname;${assigned ? '__dirname="relative";' : ""}`; + const fixture = { exports: "" }; + const run = runInThisContext( + wrap(`${setup}module.exports=path.join(__dirname,'preload.cjs');`), + ); + run( + fixture.exports, + createRequire(import.meta.url), + fixture, + "/source/app/main.cjs", + "/source/app", + ); + expect(fixture.exports).toBe( + assigned ? "relative/preload.cjs" : "/source/app/preload.cjs", + ); + const analysis = analyzeJavaScriptStaticSource( + `${setup}const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.join(__dirname,'preload.cjs')}});`, + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: assigned ? null : "preload.cjs" }, + ]); + }, +); + +it("keeps local dirname methods from proving a source anchor", () => { + const helper = { dirname: (_url: string) => "/other" }; + expect( + posix.resolve( + "/runtime/base", + helper.dirname("file:///source/app/main.mjs"), + "decoy.cjs", + ), + ).toBe("/other/decoy.cjs"); + const analysis = analyzeJavaScriptStaticSource( + `import path from 'node:path';const helper={dirname:()=>'/other'};const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,helper.dirname(import.meta.url),'decoy.cjs')}});`, + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); +}); + +it("retains a proven Node dirname of the decoded module URL", () => { + expect( + posix.dirname( + fileURLToPath("file:///source/app/main.mjs", { windows: false }), + ), + ).toBe("/source/app"); + const analysis = analyzeJavaScriptStaticSource( + `import path from 'node:path';import{fileURLToPath}from'node:url';const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,path.dirname(fileURLToPath(import.meta.url)),'preload.cjs')}});`, + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: "preload.cjs" }, + ]); +}); + +it.each([false, true])( + "preserves a decoded module URL through its real dirname owner: local=%s", + (local) => { + const decoded = fileURLToPath("file:///source/app/main.mjs", { + windows: false, + }); + const helper = { dirname: (_file: string) => "/other" }; + expect(local ? helper.dirname(decoded) : posix.dirname(decoded)).toBe( + local ? "/other" : "/source/app", + ); + const analysis = analyzeJavaScriptStaticSource(` + import path from 'node:path';import*as url from 'node:url'; + const helper={dirname:()=>'/other'};const{BrowserWindow}=require('electron'); + new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,${local ? "helper" : "path"}.dirname(url.fileURLToPath(import.meta.url)),'preload.cjs')}}); + `); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: local ? null : "preload.cjs" }, + ]); + }, +); + +it("does not use a URL string as a filesystem source-file anchor", () => { + expect(posix.dirname("file:///source/app/main.mjs")).not.toBe("/source/app"); + const analysis = analyzeJavaScriptStaticSource( + `import path from 'node:path';const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,path.dirname(import.meta.url),'decoy.cjs')}});`, + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: null }, + ]); +}); + +it.each([false, true])( + "proves the CommonJS filename dirname owner: local=%s", + (local) => { + const analysis = analyzeJavaScriptStaticSource( + `const path=require('node:path');const helper={dirname:()=>'.'};const{BrowserWindow}=require('electron');new BrowserWindow({webPreferences:{preload:path.resolve(runtimeBase,${local ? "helper" : "path"}.dirname(__filename),'decoy.cjs')}});`, + ); + expect(analysis.electron.browser_windows).toMatchObject([ + { preload_path: local ? null : "decoy.cjs" }, + ]); + }, +); + +it.each([false, true])( + "preserves the utility-process dirname parameter until assigned: assigned=%s", + (assigned) => { + const analysis = analyzeJavaScriptStaticSource( + `var __dirname;${assigned ? "__dirname='relative';" : ""}const path=require('node:path');const{utilityProcess}=require('electron');utilityProcess.fork(path.join(__dirname,'worker.cjs'));`, + ); + expect(analysis.electron.utility_processes).toMatchObject([ + { module_path: assigned ? null : "worker.cjs" }, + ]); + }, +); diff --git a/src/domain/referenceSourceRequireBindings.ts b/src/domain/referenceSourceRequireBindings.ts index a7ea10a97a..773c052324 100644 --- a/src/domain/referenceSourceRequireBindings.ts +++ b/src/domain/referenceSourceRequireBindings.ts @@ -39,11 +39,13 @@ export const unshadowedReferenceSourceRequireCalls = ( if (!t.isCallExpression(node)) return; const identifier = requireCalleeIdentifier(node.callee); if (identifier === undefined) return; - const fact = semanticRequireLoaderAt( - node, - index.bindings, - factoryBindings, - ); + let fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings); + if (fact === "create-require") { + // Definitions identify a possible factory. Its mutable member reads + // need the shared write index before they can prove an association. + completeSemanticSteps(index.indexMemberMutationFactsSteps()); + fact = semanticRequireLoaderAt(node, index.bindings, factoryBindings); + } if ( fact === "create-require" || (fact === "commonjs-loader" && wrapperMode !== "absent") diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts index c20ee54d93..c4601eadf6 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.test.ts @@ -8,6 +8,41 @@ import { } from "./JavaScriptAnalysisWorkerSchemas.js"; import { analyzeJavaScriptSemantics } from "../../domain/javascript/javascriptSemanticAnalysis.js"; import { getterExpansionSource } from "../../../tests/fixtures/javascriptGetterExpansion.js"; +import { aliasSelectionBudgetSource } from "../../../tests/fixtures/javascriptAliasSelectionBudget.js"; + +it("transfers bounded alias selection coverage across the worker boundary", () => { + const source = aliasSelectionBudgetSource(); + const facts = analyzeJavaScriptStaticSource(source); + const ir = analyzeJavaScriptSemantics(source); + const header = { coverage: ir.coverage, limitations: ir.limitations }; + expect(ir.coverage).toMatchObject({ + status: "partial", + resourceLimits: ["alias-selections"], + }); + expect( + javaScriptWorkerModuleHeaderSchema.parse( + JSON.parse(JSON.stringify(header)) as unknown, + ), + ).toEqual(header); + expect( + javaScriptWorkerStaticAnalysisSchema.parse( + JSON.parse(JSON.stringify(facts)) as unknown, + ), + ).toEqual(facts); +}); + +it("retains application path change identity and provenance across the worker boundary", () => { + const facts = analyzeJavaScriptStaticSource(` + const { app } = require("electron"); + app.setAppPath("./other"); + `); + expect(facts.electron.app_path_changes).toHaveLength(1); + expect( + javaScriptWorkerStaticAnalysisSchema.parse( + JSON.parse(JSON.stringify(facts)) as unknown, + ), + ).toEqual(facts); +}); it("transfers getter expansion limits and unknown bindings across the worker boundary", () => { const ir = analyzeJavaScriptSemantics(getterExpansionSource()); diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 2ac16ad355..30bb2c176b 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -155,6 +155,9 @@ const bridgeShape = { const utilityShape = { ...findingContext, service_name: z.string().nullable() }; const electron = z .strictObject({ + app_path_changes: z + .array(z.strictObject(findingContext).transform(withRuntime)) + .optional(), call_paths: z .array( z.strictObject({ @@ -285,8 +288,9 @@ const electron = z }), ), }) - .transform(({ call_paths, ...rest }) => ({ + .transform(({ call_paths, app_path_changes, ...rest }) => ({ ...rest, + ...(app_path_changes === undefined ? {} : { app_path_changes }), ...(call_paths === undefined ? {} : { call_paths }), })); const referenceShape = { @@ -422,6 +426,7 @@ const resourceLimit = z.enum([ "primitive-bytes", "expression-depth", "getter-expansion", + "alias-selections", ]); const primitive = z.union([z.string(), z.number(), z.boolean(), z.null()]); const property: z.ZodType = z.lazy(() => diff --git a/src/process/ProcessOwnership.baselineAbsence.test.ts b/src/process/ProcessOwnership.baselineAbsence.test.ts new file mode 100644 index 0000000000..3621fa9d91 --- /dev/null +++ b/src/process/ProcessOwnership.baselineAbsence.test.ts @@ -0,0 +1,124 @@ +import { expect, it, vi } from "vitest"; + +import { + verifyNoTokenOwnedProcesses, + type ProcessIdentityObservation, + type ProcessOwnershipHost, + type ProcessTableEntry, +} from "./ProcessOwnership.js"; +import { ProcessOwnershipEvidenceOwner } from "./ProcessOwnershipEvidence.js"; + +const row: ProcessTableEntry = { + pid: 200, + parentPid: 100, + processGroupId: 100, + state: "S", + command: "listed-before-native-exit", +}; + +const absent = (platform: "linux" | "darwin"): ProcessIdentityObservation => ({ + state: "absent", + reason: + platform === "linux" + ? "not-found-in-caller-pid-namespace" + : "not-in-snapshot", +}); +const unavailable: ProcessIdentityObservation = { + state: "unavailable", + reason: "native identity could not be read", +}; + +const fixture = ( + platform: "linux" | "darwin", + observe: () => ProcessIdentityObservation, +) => { + const evidenceOwner = new ProcessOwnershipEvidenceOwner(); + const signalGroup = vi.fn(); + let listed = true; + const host: ProcessOwnershipHost = { + platform, + listProcesses: async () => (listed ? [row] : []), + environment: async () => ({}), + runTokens: async (entries) => + new Map( + entries.map(({ pid }) => [ + pid, + { state: "readable", runId: undefined } as const, + ]), + ), + processIdentities: async (entries) => + new Map(entries.map(({ pid }) => [pid, observe()])), + signalGroup, + }; + return { + evidenceOwner, + signalGroup, + omitRow: () => { + listed = false; + }, + verify: (identity: string | null) => + verifyNoTokenOwnedProcesses( + "run-token", + host, + [{ pid: row.pid, identity }], + { leaderPid: 100, processGroupId: 100, evidenceOwner }, + ), + }; +}; + +it.each( + (["linux", "darwin"] as const).flatMap((platform) => + (["old-start", null] as const).flatMap((baseline) => + (["unavailable", "absent"] as const).map((first) => ({ + platform, + baseline, + first, + })), + ), + ), +)( + "settles baseline $baseline after $first becomes native absence on $platform", + async ({ platform, baseline, first }) => { + let reads = 0; + const owned = fixture(platform, () => + ++reads === 1 && first === "unavailable" ? unavailable : absent(platform), + ); + await expect(owned.verify(baseline)).resolves.toEqual({ + cleaned: true, + signaled: false, + }); + expect(owned.evidenceOwner.candidates).toEqual([]); + expect(owned.signalGroup).not.toHaveBeenCalled(); + }, +); + +it.each(["linux", "darwin"] as const)( + "retains an unavailable generation after process-table omission on %s", + async (platform) => { + const owned = fixture(platform, () => unavailable); + const expected = { + cleaned: false, + reason: "process identity could not be revalidated", + failures: [ + expect.objectContaining({ + pid: row.pid, + reason: "process-identity-unavailable", + }), + ], + }; + await expect(owned.verify(null)).resolves.toMatchObject(expected); + expect(owned.evidenceOwner.candidates).toEqual([ + expect.objectContaining({ + pid: row.pid, + applicability: "identity-unresolved", + }), + ]); + owned.omitRow(); + await expect(owned.verify(null)).resolves.toMatchObject(expected); + expect(owned.evidenceOwner.candidates).toEqual([ + expect.objectContaining({ pid: row.pid }), + ]); + expect(owned.evidenceOwner.coverage.retainedOnlyPids).toBe(1); + expect(owned.signalGroup).not.toHaveBeenCalled(); + }, +); diff --git a/src/process/capture/ProcessCaptureLifecycle.test.ts b/src/process/capture/ProcessCaptureLifecycle.test.ts index abad38ac2c..cd6dc19a4c 100644 --- a/src/process/capture/ProcessCaptureLifecycle.test.ts +++ b/src/process/capture/ProcessCaptureLifecycle.test.ts @@ -1,4 +1,4 @@ -import { expect, it } from "vitest"; +import { expect, it, vi } from "vitest"; import type { IPty } from "@lydell/node-pty"; import { parseProcessScenario } from "../../domain/process/processScenario.js"; @@ -93,6 +93,36 @@ const start = ( recordEvent: () => undefined, }); +it("uses the monotonic running deadline after the wall clock moves backward", async () => { + const fake = fakeTerminal(); + const controller = new AbortController(); + const wallStart = Date.now(); + let reads = 0; + const wall = vi.spyOn(Date, "now").mockImplementation(() => { + reads += 1; + return reads === 1 ? wallStart : wallStart - 60_000 + reads; + }); + const kill = vi.spyOn(fake.terminal, "kill").mockImplementation(() => { + queueMicrotask(() => fake.deliverExit({ exitCode: 0, signal: 9 })); + }); + const fallback = setTimeout(() => controller.abort(), 1_500); + try { + const monotonicStarted = performance.now(); + const exit = observed( + await start(fake, { timeout_ms: 100 }, controller.signal), + ); + // The production clock rounds down to milliseconds, losing at most 1 ms. + expect(performance.now() - monotonicStarted).toBeGreaterThanOrEqual(99); + expect(exit.reason).toBe("timeout"); + expect(controller.signal.aborted).toBe(false); + expect(kill).toHaveBeenCalledOnce(); + } finally { + clearTimeout(fallback); + kill.mockRestore(); + wall.mockRestore(); + } +}); + it("records attempts when a self-exit notification arrives after escalation", async () => { const fake = fakeTerminal(); const pending = start(fake, { timeout_ms: 100, finalization_ms: 100 }); diff --git a/src/process/capture/ProcessCaptureObservation.test.ts b/src/process/capture/ProcessCaptureObservation.test.ts index ace89ca938..de071301b0 100644 --- a/src/process/capture/ProcessCaptureObservation.test.ts +++ b/src/process/capture/ProcessCaptureObservation.test.ts @@ -1,6 +1,9 @@ import { expect, it } from "vitest"; -import { snapshotRoots } from "./FilesystemSnapshot.js"; +import { + ProcessCaptureSnapshotFailure, + snapshotRoots, +} from "./FilesystemSnapshot.js"; import { buildCaptureResult } from "./ProcessCaptureLifecycle.js"; import { isInitializedPtyRoot } from "./ProcessSampling.js"; import { readLinuxChildren } from "./LinuxProcessSampling.js"; @@ -271,17 +274,33 @@ it("admits PTY samples only after stable session and token setup", () => { it("cancels filesystem snapshots before traversing declared roots", async () => { const controller = new AbortController(); controller.abort(); - await expect( - snapshotRoots( - parseProcessScenario({ - ...base, - filesystem_observation_paths: ["/tmp"], - }), - controller.signal, - ), - ).rejects.toMatchObject({ + const failure: unknown = await snapshotRoots( + parseProcessScenario({ + ...base, + filesystem_observation_paths: ["/tmp"], + }), + controller.signal, + ).catch((cause: unknown) => cause); + expect(failure).toBeInstanceOf(ProcessCaptureSnapshotFailure); + expect(failure).toMatchObject({ + _tag: "ProcessCaptureError", reason: "cancelled", - cause: controller.signal.reason, - snapshot: { files: [], truncated: true }, + userCategory: "cancelled", + cause: expect.objectContaining({ name: "AbortError" }), + snapshot: { + files: [], + truncated: true, + completeRoots: [], + coverage: { + hashed_bytes: 0, + enumeration_omissions: [], + hash_omissions: [], + }, + }, + cleanupLeases: [], + readFailures: [], }); + expect(failure instanceof Error ? failure.cause : undefined).toBe( + controller.signal.reason, + ); }); diff --git a/tests/acceptance/applications/javascriptCapturedGenericInvocation.test.ts b/tests/acceptance/applications/javascriptCapturedGenericInvocation.test.ts new file mode 100644 index 0000000000..d5dae895ae --- /dev/null +++ b/tests/acceptance/applications/javascriptCapturedGenericInvocation.test.ts @@ -0,0 +1,69 @@ +import { expect } from "vitest"; + +import { capturedGenericInvocations } from "../../fixtures/javascriptCapturedGenericInvocations.js"; +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + capturedGenericInvocations + .filter(({ chain }) => chain === "call.call" || chain === "apply.call") + .map((testCase) => ({ surface, ...testCase })), + ), +)( + "retains competing application owners for $name through $surface", + async (testCase, { cli }) => { + const root = await createTestTempDirectory("rea-captured-generic-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": "require('../shared/window.cjs');", + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "b/main.cjs": testCase.source, + "b/index.html": "second application page", + "shared/window.cjs": + "const {BrowserWindow}=require('electron');new BrowserWindow().loadFile('index.html');", + "shared/index.html": "source-directory decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + testCase.surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const renderer = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(renderer).toHaveLength(1); + expect(renderer[0]?.observations).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: testCase.host ? null : "a/index.html", + resolution_status: testCase.host ? "unavailable" : "resolved", + }), + }), + ); + const unknownLoads = graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === null, + ); + expect(unknownLoads.length > 0).toBe(testCase.host); + if (testCase.host) + expect(unknownLoads).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + expression: testCase.expression, + }), + }), + ); + const mapped = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === renderer[0]?.node_id, + ); + expect(mapped.map(({ properties }) => properties.resolved_path)).toEqual( + testCase.host ? [] : ["a/index.html"], + ); + }, +); diff --git a/tests/acceptance/applications/javascriptDependencyMainHostPaths.test.ts b/tests/acceptance/applications/javascriptDependencyMainHostPaths.test.ts new file mode 100644 index 0000000000..b2b29fa1ca --- /dev/null +++ b/tests/acceptance/applications/javascriptDependencyMainHostPaths.test.ts @@ -0,0 +1,68 @@ +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [ + { main: "/shared/window.cjs", status: "unavailable" }, + { main: "../../shared/window.cjs", status: "resolved" }, + { main: "sub\\entry.cjs", status: "unavailable" }, + ].map((testCase) => ({ surface, ...testCase })), + ), +)( + "keeps dependency main $main in its lookup domain through $surface", + async ({ surface, main, status }, { cli }) => { + const root = await createTestTempDirectory("rea-dependency-host-main-"); + await writeFixtureFiles(root, { + "a/package.json": '{"main":"main.cjs"}', + "a/main.cjs": + 'throw new Error("Static fixture must never execute");require("fixture");', + "a/index.html": "first application page", + "b/package.json": '{"main":"main.cjs"}', + "b/main.cjs": + 'throw new Error("Static fixture must never execute");require("../shared/window.cjs");', + "b/index.html": "second application page", + "node_modules/fixture/package.json": JSON.stringify({ main }), + ...(main.includes("\\") + ? { + "node_modules/fixture/sub/entry.cjs": + 'throw new Error("Static fixture must never execute");require("../../../shared/window.cjs");', + } + : {}), + "shared/window.cjs": + 'throw new Error("Static fixture must never execute");const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");', + "shared/index.html": "source-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const imports = graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "fixture", + ); + expect(imports.length).toBeGreaterThan(0); + for (const edge of imports) + expect(edge.properties).toMatchObject({ + resolution_status: status, + resolved_path: status === "resolved" ? "shared/window.cjs" : null, + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect( + graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ), + ).toEqual([]); + }, +); diff --git a/tests/acceptance/applications/javascriptEscapedArtifactImports.test.ts b/tests/acceptance/applications/javascriptEscapedArtifactImports.test.ts new file mode 100644 index 0000000000..94595dc593 --- /dev/null +++ b/tests/acceptance/applications/javascriptEscapedArtifactImports.test.ts @@ -0,0 +1,63 @@ +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [ + { + request: "../../rea-artifact/shared/window.cjs", + status: "rejected", + }, + { request: "../shared/window.cjs", status: "resolved" }, + ].map((testCase) => ({ surface, ...testCase })), + ), +)( + "keeps ESM request $request within its real artifact boundary through $surface", + async ({ surface, request, status }, { cli }) => { + const root = await createTestTempDirectory("rea-esm-artifact-escape-"); + await writeFixtureFiles(root, { + "a/package.json": '{"main":"main.mjs"}', + "a/main.mjs": `throw new Error('Static fixture must never execute');import ${JSON.stringify(request)};`, + "a/index.html": "first application page", + "b/package.json": '{"main":"main.mjs"}', + "b/main.mjs": + 'throw new Error("Static fixture must never execute");import "../shared/window.cjs";', + "b/index.html": "second application page", + "shared/window.cjs": + 'throw new Error("Static fixture must never execute");const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");', + "shared/index.html": "source-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const imports = graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === request, + ); + expect(imports.length).toBeGreaterThan(0); + for (const edge of imports) + expect(edge.properties).toMatchObject({ + resolution_status: status, + resolved_path: status === "resolved" ? "shared/window.cjs" : null, + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const mapped = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ); + expect(mapped).toHaveLength(status === "rejected" ? 1 : 0); + if (status === "rejected") + expect(mapped[0]?.properties.resolved_path).toBe("b/index.html"); + }, +); diff --git a/tests/acceptance/applications/javascriptExportedAppPathChanges.test.ts b/tests/acceptance/applications/javascriptExportedAppPathChanges.test.ts new file mode 100644 index 0000000000..8ca0672b05 --- /dev/null +++ b/tests/acceptance/applications/javascriptExportedAppPathChanges.test.ts @@ -0,0 +1,81 @@ +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"; + +const cases = [ + ...(["ESM", "CommonJS", "Object.assign"] as const).flatMap((format) => + [false, true].map((replaced) => ({ format, replaced })), + ), + { format: "local exports Object.assign", replaced: false }, + { format: "detached exports Object.assign", replaced: false }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((testCase) => ({ surface, ...testCase })), + ), +)( + "retains $format exported app setter uncertainty (replaced=$replaced) through $surface", + async ({ surface, format, replaced }, { cli }) => { + const root = await createTestTempDirectory("rea-exported-app-path-"); + const connected = + format !== "local exports Object.assign" && + format !== "detached exports Object.assign"; + const possible = connected && !replaced; + const publication = + format === "CommonJS" + ? "module.exports={change,app};" + : format === "local exports Object.assign" + ? "function publish(exports){Object.assign(exports,{change,app})}publish({});" + : `${format === "detached exports Object.assign" ? "module.exports={};" : ""}Object.assign(exports,{change,app});`; + const files: Record = + format !== "ESM" + ? { + "a/package.json": '{"main":"main.cjs"}', + "a/setter.cjs": `throw new Error('Static fixture must never execute');const {app}=require('electron');${replaced ? "app.setAppPath=()=>{};" : ""}const change=app.setAppPath;${publication}`, + "a/main.cjs": `throw new Error('Static fixture must never execute');const {change,app}=require('./setter.cjs');const path=require('node:path');${connected ? "" : "if(typeof change==='function')"}change.call(app,path.join(__dirname,'lib'));require('./win.cjs');`, + "a/win.cjs": `throw new Error('Static fixture must never execute');const {BrowserWindow}=require('electron');new BrowserWindow().loadFile('index.html');`, + } + : { + "a/package.json": '{"main":"main.mjs"}', + "a/main.mjs": `throw new Error('Static fixture must never execute');import {app,change} from '../shared/change.mjs';import {BrowserWindow} from 'electron';change.call(app,'./lib');new BrowserWindow().loadFile('index.html');`, + "shared/change.mjs": `throw new Error('Static fixture must never execute');import {app} from 'electron';${replaced ? "app.setAppPath=()=>{};" : ""}const change=app.setAppPath;export {app,change};`, + }; + await writeFixtureFiles(root, { + ...files, + "a/index.html": "application page", + "a/lib/index.html": "changed application page", + "shared/index.html": "source-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: possible ? "unavailable" : "resolved", + }), + }), + ]), + ); + const mapped = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ); + expect(mapped).toHaveLength(possible ? 0 : 1); + if (!possible) + expect(mapped[0]?.properties.resolved_path).toBe("a/index.html"); + }, +); diff --git a/tests/acceptance/applications/javascriptFactoryCompletion.test.ts b/tests/acceptance/applications/javascriptFactoryCompletion.test.ts index 80b46ccc9f..3aa44d3d7f 100644 --- a/tests/acceptance/applications/javascriptFactoryCompletion.test.ts +++ b/tests/acceptance/applications/javascriptFactoryCompletion.test.ts @@ -378,7 +378,12 @@ cliTest( input_path: directory, format: "directory", }); - expect(response.isError, scenario.name).not.toBe(true); + expect( + response.isError, + response.isError + ? `${scenario.name}: ${JSON.stringify(response.content)}` + : scenario.name, + ).not.toBe(true); const evidence = contract.outputSchema.parse(response.structuredContent); verifyApplication( javascriptApplicationAnalysisResultSchema.parse( diff --git a/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts b/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts new file mode 100644 index 0000000000..7725003d7f --- /dev/null +++ b/tests/acceptance/applications/javascriptIndirectLoaderOwners.test.ts @@ -0,0 +1,280 @@ +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"; + +const cases = [ + { + name: "typed member store retains returned module ownership", + mainFile: "main.ts", + load: 'const box={make:()=>({require(){}})};(box.make as any)=()=>module;box.make().require("../shared/window.cjs");', + possible: true, + }, + { + name: "typed local member store stays inert", + mainFile: "main.ts", + load: 'const box={make:()=>module};(box.make as any)=()=>({require(){}});box.make().require("../shared/window.cjs");', + possible: false, + }, + { + name: "assignment result retains module receiver", + load: 'let holder;(holder=module).require("../shared/window.cjs");', + possible: true, + }, + { + name: "assignment result retains local receiver", + load: 'let holder;(holder={require(){}}).require("../shared/window.cjs");', + possible: false, + }, + { + name: "captured wrapper require retains loader ownership", + load: 'const load=require;load("../shared/window.cjs");', + possible: true, + }, + { + name: "captured local callable stays inert", + load: 'const local=()=>{};const load=local;load("../shared/window.cjs");', + possible: false, + }, + { + name: "require.resolve does not execute its resolved module", + load: 'require.resolve("../shared/window.cjs");', + possible: false, + }, + { + name: "require.main is not an implicit module loader", + load: 'require.main("../shared/window.cjs");', + possible: false, + }, + { + name: "createRequire alias retains its source module base", + mainFile: "main.mjs", + load: 'import {createRequire} from "node:module";const load=createRequire(import.meta.url);load("../shared/window.cjs");', + possible: true, + }, + { + name: "conditional opaque factory retains competing loader ownership", + mainFile: "main.mjs", + load: 'import {createRequire as native} from "node:module";const make=flag?native:getFactory();const load=make(import.meta.url);load("./window.cjs");', + possible: true, + }, + { + name: "only local returned factory stays inert", + mainFile: "main.mjs", + load: 'const getFactory=()=>()=>()=>({});const make=getFactory();const load=make(import.meta.url);load("../shared/window.cjs");', + possible: false, + }, + { + name: "local createRequire name stays inert", + mainFile: "main.mjs", + load: 'const createRequire=()=>()=>{};const load=createRequire(import.meta.url);load("../shared/window.cjs");', + possible: false, + }, + { + name: "replaced createRequire factory stays inert", + mainFile: "main.mjs", + load: 'import * as mod from "node:module";mod.default.createRequire=()=>()=>({});const load=mod.default.createRequire(import.meta.url);load("../shared/window.cjs");', + possible: false, + }, + { + name: "CommonJS module has no synthetic default factory", + load: 'const load=require("node:module").default.createRequire(import.meta.url);load("../shared/window.cjs");', + possible: false, + }, + { + name: "conditional call replacement retains native loader alternative", + mainFile: "main.mjs", + load: 'import {createRequire as make} from "node:module";const load=make(import.meta.url);if(flag)load.call=()=>{};load.call(null,"../shared/window.cjs");', + possible: true, + }, + { + name: "conditional apply replacement retains native loader alternative", + mainFile: "main.mjs", + load: 'import {createRequire as make} from "node:module";const load=make(import.meta.url);if(flag)load.apply=()=>{};load.apply(null,["../shared/window.cjs"]);', + possible: true, + }, + { + name: "replaced created-loader call stays inert", + mainFile: "main.mjs", + load: 'import {createRequire as make} from "node:module";const load=make(import.meta.url);load.call=()=>{};load.call(null,"../shared/window.cjs");', + possible: false, + }, + { + name: "changed Function call retains loader argument uncertainty", + mainFile: "main.mjs", + load: 'import {createRequire as make} from "node:module";const load=make(import.meta.url);Function.prototype.call=()=>({});load.call(null,"./inert.cjs");', + possible: true, + }, + { + name: "changed Function apply retains loader argument uncertainty", + mainFile: "main.mjs", + load: 'import {createRequire as make} from "node:module";const load=make(import.meta.url);Function.prototype.apply=()=>({});load.apply(null,["./inert.cjs"]);', + possible: true, + }, + { + name: "custom array iterator preserves loader ownership", + load: 'Array.prototype[Symbol.iterator]=function*(){yield "../shared/window.cjs"};require(...["./inert.cjs"]);', + possible: true, + }, + { + name: "Reflect.apply retains module delegate ownership", + load: 'Reflect.apply(module.require,module,["../shared/window.cjs"]);', + possible: true, + }, + { + name: "local Reflect.apply stays inert", + load: 'const Reflect={apply(){}};Reflect.apply(module.require,module,["../shared/window.cjs"]);', + possible: false, + }, + { + name: "noncallable generic receiver excludes callable children", + load: 'const get=()=>({});const invoke=get.call;const holder={invoke,other:()=>module};holder.invoke(null).require("../shared/window.cjs");', + possible: false, + }, + { + name: "callable generic receiver retains its return", + load: 'const get=()=>({});const invoke=get.call;const holder=()=>module;holder.invoke=invoke;holder.other=()=>({});holder.invoke(null).require("../shared/window.cjs");', + possible: true, + }, + { + name: "destructured module alias retains own copied delegate", + load: 'let selected;({selected}={selected:module});selected.require=module.require;const copy={...module};const load=copy.require;load.call(module,"../shared/window.cjs");', + possible: true, + }, + { + name: "destructured local alias does not change module copies", + load: 'let selected;({selected}={selected:{require(){}}});selected.require=module.require;const copy={...module};const load=copy.require;load.call(module,"../shared/window.cjs");', + possible: false, + }, + { + name: "inherited delegate excluded from rest", + load: 'const {...h}=module;h.require.call(module,"../shared/window.cjs");', + possible: false, + }, + { + name: "own delegate retained in spread", + load: 'module.require=module.require;const h={...module};h.require.call(module,"../shared/window.cjs");', + possible: true, + }, + { + name: "nested module retained in spread", + load: 'const h={...{loader:module}};h.loader.require("../shared/window.cjs");', + possible: true, + }, + { + name: "require-named delegate", + load: 'const h={require:module.require};h.require.call(module,"../shared/window.cjs");', + possible: true, + }, + { + name: "require-named local", + load: 'const h={require(){}};h.require.call(module,"../shared/window.cjs");', + possible: false, + }, + { + name: "require-named applied delegate", + load: 'const h={require:module.require};h.require.apply(module,["../shared/window.cjs"]);', + possible: true, + }, + { + name: "require-named applied local", + load: 'const h={require(){}};h.require.apply(module,["../shared/window.cjs"]);', + possible: false, + }, + { + name: "module receiver", + load: '(0,module).require("../shared/window.cjs");', + possible: true, + }, + { + name: "main module receiver", + load: 'require.main.require("../shared/window.cjs");', + possible: true, + }, + { + name: "process main module receiver", + load: 'process.mainModule.require("../shared/window.cjs");', + possible: true, + }, + { + name: "local process main module receiver", + load: 'const process={mainModule:{require(){}}};process.mainModule.require("../shared/window.cjs");', + possible: false, + }, + { + name: "local main module receiver", + load: 'const require={main:{require(){}}};require.main.require("../shared/window.cjs");', + possible: false, + }, + { + name: "captured delegate", + load: 'const load=(0,module.require);load("../shared/window.cjs");', + possible: true, + }, + { + name: "local receiver", + load: '(module,{require(){}}).require("../shared/window.cjs");', + possible: false, + }, + { + name: "local final delegate", + load: 'const load=(module.require,()=>{});load("../shared/window.cjs");', + possible: false, + }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((test) => ({ ...test, surface })), + ), +)( + "preserves $name loader owner uncertainty through $surface", + async (testCase, { cli }) => { + const { surface, load, possible } = testCase; + const mainFile = "mainFile" in testCase ? testCase.mainFile : "main.cjs"; + const root = await createTestTempDirectory("rea-sequence-loader-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: mainFile }), + [`a/${mainFile}`]: `throw new Error('Static fixture must never execute');${load}`, + "a/inert.cjs": + "throw new Error('Static inert decoy must never execute');", + "a/window.cjs": + "throw new Error('Static factory-base decoy must never execute');", + "a/index.html": "possible page", + "b/package.json": '{"main":"main.cjs"}', + "b/main.cjs": `throw new Error('Static fixture must never execute');require('../shared/window.cjs');`, + "b/index.html": "proven page", + "shared/window.cjs": `throw new Error('Static fixture must never execute');const {BrowserWindow}=require('electron');new BrowserWindow().loadFile('index.html');`, + "shared/index.html": "source-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: possible ? "unavailable" : "resolved", + }), + }), + ]), + ); + const mapped = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ); + expect(mapped).toHaveLength(possible ? 0 : 1); + if (!possible) + expect(mapped[0]?.properties.resolved_path).toBe("b/index.html"); + }, +); diff --git a/tests/acceptance/applications/javascriptLiteralPercentPaths.test.ts b/tests/acceptance/applications/javascriptLiteralPercentPaths.test.ts new file mode 100644 index 0000000000..31fb7ae6df --- /dev/null +++ b/tests/acceptance/applications/javascriptLiteralPercentPaths.test.ts @@ -0,0 +1,1403 @@ +import { expect } from "vitest"; +import { copyFile } from "node:fs/promises"; +import { join } from "node:path"; +import { createPackageWithOptions } from "@electron/asar"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { aliasSelectionBudgetSource } from "../../fixtures/javascriptAliasSelectionBudget.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const fixtureImports = (loader: string) => { + switch (loader) { + case "pattern alias": + return { "#window/*": "./proxy/*.cjs" }; + case "recursive alias": + return { "#entry": "#window", "#window": "./proxy/window.cjs" }; + case "external-package alias": + return { "#entry": "fixture-window" }; + case "builtin alias": + return { "#entry": "fs" }; + case "electron alias": + return { "#entry": "electron" }; + case "electron main alias": + return { "#entry": "electron/main" }; + default: + return { "#entry": null }; + } +}; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [ + "direct", + "alias", + "call", + "apply", + "bind", + "reflect", + "captured reflect", + "local", + ].map((setter) => ({ + surface, + setter, + })), + ), +)( + "keeps loadFile roots unknown after an Electron application path setter through $surface ($setter)", + async ({ surface, setter }, { cli }) => { + const root = await createTestTempDirectory("rea-app-path-setter-"); + const change = + setter === "direct" + ? 'const { app } = require("electron"); app.setAppPath("/runtime/changed");' + : setter === "alias" + ? 'const { app: runtime } = require("electron/main"); const current = runtime; current["setAppPath"]("/runtime/changed");' + : setter === "call" + ? 'const { app } = require("electron"); app.setAppPath.call(app, "/runtime/changed");' + : setter === "apply" + ? 'const { app } = require("electron"); app.setAppPath.apply(app, ["/runtime/changed"]);' + : setter === "bind" + ? 'const { app } = require("electron"); const change = app.setAppPath.bind(app); change("/runtime/changed");' + : setter === "reflect" + ? 'const { app } = require("electron"); Reflect.apply(app.setAppPath, app, ["/runtime/changed"]);' + : setter === "captured reflect" + ? 'const { app } = require("electron"); const invoke = Reflect.apply; invoke(app.setAppPath, app, ["/runtime/changed"]);' + : 'const app = { setAppPath() {} }; app.setAppPath("/runtime/changed");'; + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": `${change} require("./window.cjs");`, + "a/window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "a/index.html": "package-root page", + "index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + const target = setter === "local" ? "a/index.html" : null; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: target === null ? "unavailable" : "resolved", + resolved_path: target, + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + }, +); + +const unprovenLoaderSources: Readonly> = { + "custom iterator assignment module loader": + 'const values = { *[Symbol.iterator]() { yield module; } }; const holder = {}; [holder.loader] = values; holder.loader.require("../shared/window.cjs");', + "replaced iterator assignment module loader": + 'const values = [{}]; values[Symbol.iterator] = function* () { yield module; }; const holder = {}; [holder.loader] = values; holder.loader.require("../shared/window.cjs");', + "custom iterator rest declaration module loader": + 'const values = { *[Symbol.iterator]() { yield module; } }; const [...holder] = values; holder[0].require("../shared/window.cjs");', + "native array assignment local loader": + 'const holder = { loader: module }; [holder.loader] = [{ require() {} }]; holder.loader.require("../shared/window.cjs");', + "logical rewrite assignment module loader": + 'const holder = {}; holder.loader ??= module; holder.loader.require = replacement; require("../shared/window.cjs");', + "object rest declaration module loader": + 'const { ...holder } = { loader: module }; holder.loader.require("../shared/window.cjs");', + "array rest declaration module loader": + 'const [...holder] = [module]; holder[0].require("../shared/window.cjs");', + "object assignment module loader": + 'const holder = {}; ({ loader: holder.loader } = { loader: module }); holder.loader.require("../shared/window.cjs");', + "array assignment module loader": + 'const holder = {}; [holder.loader] = [module]; holder.loader.require("../shared/window.cjs");', + "nested assignment module loader": + 'const holder = {}; ({ inner: { loader: holder.loader } } = { inner: { loader: module } }); holder.loader.require("../shared/window.cjs");', + "default assignment module loader": + 'const holder = {}; ({ loader: holder.loader = module } = {}); holder.loader.require("../shared/window.cjs");', + "rest array assignment module loader": + 'const holder = {}; let ignored; [ignored, ...holder.loaders] = [{}, module]; holder.loaders[0].require("../shared/window.cjs");', + "delegate assignment module loader": + 'const holder = {}; ({ load: holder.load } = { load: module.require }); holder.load.call(module, "../shared/window.cjs");', + "rewrite assignment module loader": + 'const holder = {}; ({ loader: holder.loader } = { loader: module }); holder.loader.require = replacement; require("../shared/window.cjs");', + "exports assignment local loader": + 'const holder = {}; ({ exports: holder.loader } = module); holder.loader.require("../shared/window.cjs");', + "rest assignment local loader": + 'const holder = {}; ({ ...holder.loader } = module); holder.loader.require("../shared/window.cjs");', + "excluded assignment local loader": + 'const holder = {}; let ignored; ({ loader: ignored, ...holder.copy } = { loader: module }); holder.copy.loader.require("../shared/window.cjs");', + "inactive default assignment local loader": + 'const holder = {}; ({ loader: holder.loader = module } = { loader: { require() {} } }); holder.loader.require("../shared/window.cjs");', + "overwritten assignment local loader": + 'const holder = { loader: module }; ({ loader: holder.loader } = { loader: { require() {} } }); holder.loader.require("../shared/window.cjs");', + "bounded alias selection": aliasSelectionBudgetSource(), + "captured module delegate": + 'const load = module.require; load.call(module, "../shared/window.cjs");', + "destructured module delegate": + 'const { require: load } = module; load.apply(module, ["../shared/window.cjs"]);', + "selected local delegate slots": + 'const table = { x: {}, y: {}, z: module }; const target = flag ? table.x : table.y; target.require = replacement; require("../shared/window.cjs");', + "local assigned loader": + 'const holder = {}; holder.loader = { require() {} }; holder.loader.require("../shared/window.cjs");', + "overwritten local loader": + 'const holder = { loader: module }; holder.loader = { require() {} }; holder.loader.require("../shared/window.cjs");', + "normalized absolute ESM": 'import "/discard%2F/../shared/window.cjs";', + "normalized absolute dynamic import": + 'import("/discard%5C/../shared/window.cjs");', + "rewritten CommonJS delegate": + 'module.require = replacement; require("../shared/window.cjs");', + "nested rewritten CommonJS delegate": + 'const holder = { loader: module }; holder.loader.require = replacement; require("../shared/window.cjs");', + "assigned rewritten CommonJS delegate": + 'const holder = {}; holder.loader = module; holder.loader.require = replacement; require("../shared/window.cjs");', + "assigned module loader": + 'const holder = {}; holder.loader = module; holder.loader.require("../shared/window.cjs");', + "nested module loader": + 'const holder = { loader: module }; holder.loader.require("../shared/window.cjs");', + "destructured module loader": + 'const holder = { loader: module }; const { loader } = holder; loader.require("../shared/window.cjs");', + "array module loader": + 'const holder = [module]; holder[0].require("../shared/window.cjs");', + "bound module loader": + 'const load = module.require.bind(module); load("../shared/window.cjs");', + "bound module alias loader": + 'const current = module; const load = current.require.bind(current); load("../shared/window.cjs");', + "computed bound module loader": + 'const load = module["require"].bind(module); load("../shared/window.cjs");', + "fallback module alias": + 'const { loader = module } = options; loader.require("../shared/window.cjs");', + "inactive fallback": + 'const { loader = module } = { loader: {} }; loader.require("../shared/window.cjs");', + "absolute ESM": 'import "/shared/window.cjs";', + "invalid absolute ESM": 'import "/shared%2Fwindow.cjs";', + "invalid absolute dynamic import": 'import("/shared%5Cwindow.cjs");', + "absolute dynamic import": 'import("/shared/window.cjs");', + "absolute file URL": 'import "file:///shared/window.cjs";', + "dynamic file URL": 'import("file:///shared/window.cjs");', + "createRequire base": + 'import { createRequire } from "node:module"; const require = createRequire(new URL("../other/nested/main.cjs", import.meta.url)); require("../shared/window.cjs");', +}; + +const uncheckedModuleLoaders = new Set([ + "custom iterator assignment module loader", + "replaced iterator assignment module loader", + "custom iterator rest declaration module loader", + "logical rewrite assignment module loader", + "object rest declaration module loader", + "array rest declaration module loader", + "object assignment module loader", + "array assignment module loader", + "nested assignment module loader", + "default assignment module loader", + "rest array assignment module loader", + "delegate assignment module loader", + "rewrite assignment module loader", + "bounded alias selection", + "captured module delegate", + "destructured module delegate", + "rewritten CommonJS delegate", + "nested rewritten CommonJS delegate", + "assigned rewritten CommonJS delegate", + "assigned module loader", + "module.require", + "computed module.require", + "module alias", + "later reassigned module alias", + "conditional module alias", + "fallback module alias", + "bound module loader", + "bound module alias loader", + "computed bound module loader", + "nested module loader", + "destructured module loader", + "array module loader", +]); + +const fixtureMainPath = (loader: string): string => { + switch (loader) { + case "absolute ESM": + case "normalized absolute ESM": + case "normalized absolute dynamic import": + case "invalid absolute ESM": + case "invalid absolute dynamic import": + case "absolute dynamic import": + case "absolute file URL": + case "dynamic file URL": + case "createRequire base": + return "main.mjs"; + case "absolute main": + return "/opt/app/main.cjs"; + case "Windows relative main": + return "sub\\main.cjs"; + case "Windows drive-relative main": + return "C:main.cjs"; + case "percent main": + case "single percent main": + return "main%2Fentry.cjs"; + default: + return "main.cjs"; + } +}; + +const fixtureMainInventoryPath = (loader: string, main: string): string => { + if (loader === "absolute main") return "opt/app/main.cjs"; + if (loader === "Windows relative main") return "b/sub/main.cjs"; + if (loader === "Windows drive-relative main") return "b/main.cjs"; + return `b/${main}`; +}; + +const fixtureLoaderSource = (loader: string): string => + unprovenLoaderSources[loader] ?? + (loader === "resolve-only" + ? 'require.resolve("../shared/window.cjs");' + : loader === "missing wildcard alias" + ? 'require("#missing/*");' + : loader === "absolute require" + ? 'require("/shared/window.cjs");' + : loader === "projected module" + ? 'const { exports: api } = module; api.require = () => {}; api.require("../shared/window.cjs");' + : loader === "absolute main" + ? 'require("../../shared/window.cjs");' + : loader === "later reassigned module alias" + ? 'let current = module; current.require("../shared/window.cjs"); current = other;' + : loader === "earlier reassigned module alias" + ? 'let current = module; current = { require() {} }; current.require("../shared/window.cjs");' + : loader === "conditional module alias" + ? 'let current = module; if (flag) current = other; current.require("../shared/window.cjs");' + : loader === "module.require" + ? 'module.require("../shared/window.cjs");' + : loader === "computed module.require" + ? 'module["require"]("../shared/window.cjs");' + : loader === "module alias" + ? 'const current = module; current.require("../shared/window.cjs");' + : loader === "local module" + ? 'function local(module) { module.require("../shared/window.cjs"); }' + : loader === "pattern alias" + ? 'require("#window/window");' + : loader.endsWith("alias") + ? 'require("#entry");' + : 'require("../shared/window.cjs");'); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [ + "module.require", + "computed module.require", + "module alias", + "later reassigned module alias", + "earlier reassigned module alias", + "conditional module alias", + "absolute main", + "Windows relative main", + "Windows drive-relative main", + "percent main", + "pattern alias", + "recursive alias", + "external-package alias", + "builtin alias", + "electron alias", + "electron main alias", + "blocked alias", + "local module", + "projected module", + "resolve-only", + "missing wildcard alias", + "absolute require", + ...Object.keys(unprovenLoaderSources), + "single percent main", + ].map((loader) => ({ surface, loader })), + ), +)( + "preserves competing loader ownership for $loader through $surface", + async ({ surface, loader }, { cli }) => { + const root = await createTestTempDirectory("rea-loader-owner-"); + const single = loader === "single percent main"; + const inert = [ + "native array assignment local loader", + "exports assignment local loader", + "rest assignment local loader", + "excluded assignment local loader", + "inactive default assignment local loader", + "overwritten assignment local loader", + "local assigned loader", + "overwritten local loader", + "invalid absolute ESM", + "invalid absolute dynamic import", + "builtin alias", + "electron alias", + "electron main alias", + "inactive fallback", + "blocked alias", + "local module", + "projected module", + "resolve-only", + "missing wildcard alias", + "earlier reassigned module alias", + ].includes(loader); + const absolute = loader === "absolute main"; + const main = fixtureMainPath(loader); + const imports = fixtureImports(loader); + const load = fixtureLoaderSource(loader); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": + single || absolute + ? "module.exports = 1;" + : 'require("../shared/window.cjs");', + "b/package.json": JSON.stringify({ main, imports }), + [fixtureMainInventoryPath(loader, main)]: load, + "b/proxy/window.cjs": 'require("../../shared/window.cjs");', + "b/node_modules/fixture-window/package.json": JSON.stringify({ + main: "index.cjs", + }), + "b/node_modules/fixture-window/index.cjs": + 'require("../../../shared/window.cjs");', + "shared/window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "a/index.html": "first application page", + "b/index.html": "second application page", + "shared/index.html": "source-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + if (loader.startsWith("Windows ")) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ + declared_path: main, + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + if ( + ["absolute require", "absolute ESM", "absolute dynamic import"].includes( + loader, + ) + ) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "/shared/window.cjs", + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + if (loader === "createRequire base") + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + specifier: null, + resolved_path: null, + }), + }), + ]), + ); + if (loader.endsWith("alias") && !inert && !loader.includes("module alias")) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: + loader === "pattern alias" ? "#window/window" : "#entry", + resolution_status: "unavailable", + }), + }), + ]), + ); + if (uncheckedModuleLoaders.has(loader)) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + specifier: null, + resolved_path: null, + }), + }), + ]), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + const target = single ? "b/index.html" : inert ? "a/index.html" : null; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: target === null ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "propagates the application owner into an inventoried ASAR through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-asar-inward-"); + const source = join(root, "source"); + const application = join(root, "application"); + await writeFixtureFiles(source, { + "window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "index.html": "archive-root decoy", + }); + await writeFixtureFiles(application, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": 'require("../resources/code.asar/window.cjs");', + "a/index.html": "proven application page", + }); + await createPackageWithOptions( + source, + join(application, "resources/code.asar"), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "resolved", + resolved_path: "a/index.html", + }), + }), + ]), + ); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "../resources/code.asar/window.cjs", + resolution_status: "resolved", + resolved_path: "resources/code.asar/window.cjs", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(["a/index.html"]); + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [false, true].map((dynamic) => ({ surface, dynamic })), + ), +)( + "retains possible owners for a module across a nested ASAR boundary through $surface (dynamic=$dynamic)", + async ({ surface, dynamic }, { cli }) => { + const root = await createTestTempDirectory("rea-asar-module-gap-"); + const source = join(root, "source"); + const application = join(root, "application"); + await writeFixtureFiles(source, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": dynamic + ? "require(process.env.WINDOW_MODULE);" + : 'require("../window.cjs");', + }); + await writeFixtureFiles(application, { + "other/package.json": JSON.stringify({ main: "main.cjs" }), + "other/main.cjs": 'require("../resources/window.cjs");', + "resources/window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "other/index.html": "false unique owner decoy", + }); + await createPackageWithOptions( + source, + join(application, "resources/app.asar"), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: dynamic ? null : "../window.cjs", + ...(dynamic ? {} : { resolution_status: "resolved" }), + resolved_path: dynamic ? null : "resources/window.cjs", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ), + ).toEqual([]); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "resolves a proven application page across a nested ASAR boundary through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-asar-outer-page-"); + const source = join(root, "source"); + const application = join(root, "application"); + await writeFixtureFiles(source, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("../page.html");', + }); + await writeFixtureFiles(application, { + "resources/page.html": "owned outer page", + }); + await createPackageWithOptions( + source, + join(application, "resources/app.asar"), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: "resources/page.html", + resolution_status: "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(["resources/page.html"]); + }, +); + +const cases = [ + ...[ + "C:/app/preload.cjs", + "C:\\app\\preload.cjs", + "\\\\server\\share\\preload.cjs", + ].map((path, index) => ({ + directory: `windows-absolute-${index}`, + expression: JSON.stringify(path), + target: null, + status: "unavailable", + })), + { + directory: "absolute-runtime", + expression: '"/outside%2Fname.cjs"', + target: null, + status: "unavailable", + }, + { + directory: "literal", + expression: 'path.join(__dirname, "preload%2F%5C%2E.cjs")', + target: "preload%2F%5C%2E.cjs", + status: "resolved", + }, + { + directory: "converted", + expression: + 'fileURLToPath(new URL("./preload%252Fname.cjs", import.meta.url))', + target: "preload%2Fname.cjs", + status: "resolved", + }, + { + directory: "escaped", + expression: 'path.join(__dirname, "../../outside%2Fname.cjs")', + target: null, + status: "rejected", + }, + { + directory: "ignored-prefix", + expression: + 'path.resolve(new URL("./", import.meta.url), path.dirname(fileURLToPath(import.meta.url)), "preload%2Fname.cjs")', + target: "preload%2Fname.cjs", + status: "resolved", + }, +] as const; + +const writeRuntimeFixture = async (root: string): Promise => { + const inert = 'throw new Error("fixture source must never execute");'; + const loadFileSource = `const { BrowserWindow } = require("electron"); + const win = new BrowserWindow(); + win.loadFile("dist%2Findex.html"); + win.loadFile("plain.html"); + win.loadFile("absent.html");`; + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ private: true, workspaces: ["app"] }), + ...Object.fromEntries( + cases.map(({ directory, expression }) => [ + `${directory}/main.mjs`, + `import { BrowserWindow, utilityProcess } from "electron"; + import path from "node:path"; + import { fileURLToPath } from "node:url"; + new BrowserWindow({ webPreferences: { preload: ${expression} } }); + utilityProcess.fork(${expression});`, + ]), + ), + "literal/preload%2F%5C%2E.cjs": inert, + "converted/preload%2Fname.cjs": inert, + "converted/preload%252Fname.cjs": inert, + "converted/preload/name.cjs": inert, + "outside%2Fname.cjs": inert, + "ignored-prefix/preload%2Fname.cjs": inert, + "sub/main.cjs": loadFileSource, + "dist%2Findex.html": "application target", + "plain.html": "application target", + "sub/dist%2Findex.html": "nested decoy", + "sub/plain.html": "nested decoy", + "sub/absent.html": "nested-only decoy", + "app/package.json": JSON.stringify({ main: "sub/main.cjs" }), + "app/sub/package.json": JSON.stringify({ type: "commonjs" }), + "app/sub/main.cjs": loadFileSource, + "app/dist%2Findex.html": "wrapped application target", + "app/plain.html": "wrapped application target", + "app/sub/dist%2Findex.html": "wrapped source decoy", + "app/sub/plain.html": "wrapped source decoy", + "app/sub/absent.html": "wrapped missing-target decoy", + "external-app/package.json": JSON.stringify({ main: "../shared/main.cjs" }), + "shared/main.cjs": loadFileSource, + "external-app/dist%2Findex.html": "external-main application target", + "external-app/plain.html": "external-main application target", + "competition/package.json": JSON.stringify({ main: "main.cjs" }), + "competition/main.cjs": 'require("./bridge.cjs");', + "competition/bridge.cjs": 'require("./nested/main.cjs");', + "competition/nested/package.json": JSON.stringify({ main: "main.cjs" }), + "competition/nested/main.cjs": `${loadFileSource}\nrequire("../main.cjs");`, + "competition/dist%2Findex.html": "running application page", + "competition/nested/dist%2Findex.html": "competing application page", + "import-owner/package.json": JSON.stringify({ + main: "../imported/start.cjs", + }), + "imported/start.cjs": 'require("./child.cjs");', + "imported/child.cjs": loadFileSource, + "import-owner/dist%2Findex.html": "imported application target", + "import-owner/plain.html": "imported application target", + "default-app/package.json": JSON.stringify({ name: "default-entry-app" }), + "default-app/index.js": `${loadFileSource}\nrequire("./nested/main.cjs");`, + "default-app/nested/package.json": JSON.stringify({ main: "main.cjs" }), + "default-app/nested/main.cjs": loadFileSource, + "default-app/nested/dist%2Findex.html": "competing default-entry page", + "default-app/dist%2Findex.html": "default-entry application target", + "default-app/plain.html": "default-entry application target", + "ambiguous/package.json": JSON.stringify({ main: "sub/main.cjs" }), + "ambiguous/sub/package.json": JSON.stringify({ main: "main.cjs" }), + "ambiguous/third/package.json": JSON.stringify({ main: "../sub/main.cjs" }), + "ambiguous/sub/main.cjs": loadFileSource, + "ambiguous/dist%2Findex.html": "unproven application target", + "ambiguous/sub/dist%2Findex.html": "unproven nested application target", + }); + const archiveSource = await createTestTempDirectory("rea-loadfile-asar-"); + await writeFixtureFiles(archiveSource, { + "package.json": JSON.stringify({ main: "sub/main.cjs" }), + "sub/package.json": JSON.stringify({ type: "commonjs" }), + "sub/main.cjs": loadFileSource, + "dist%2Findex.html": "application target", + "plain.html": "application target", + "sub/dist%2Findex.html": "nested decoy", + "sub/plain.html": "nested decoy", + "sub/absent.html": "nested-only decoy", + "external-app/package.json": JSON.stringify({ main: "../shared/main.cjs" }), + "shared/main.cjs": loadFileSource, + "external-app/dist%2Findex.html": "external-main application target", + "external-app/plain.html": "external-main application target", + }); + await createPackageWithOptions(archiveSource, join(root, "a.asar"), {}); + // Equal container digests must not select the other archive occurrence. + await copyFile(join(root, "a.asar"), join(root, "b.asar")); +}; + +cliTest.for(["CLI", "stdio MCP"] as const)( + "does not invent a nested default-entry owner without index.js through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-empty-default-owner-"); + for (const [directory, metadata] of [ + ["empty", { main: "" }], + ["absent", {}], + ] as const) + await writeFixtureFiles(root, { + [`${directory}/package.json`]: JSON.stringify(metadata), + [`${directory}/helper.cjs`]: + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("index.html");', + [`${directory}/index.html`]: "nested decoy without launch entry", + "index.html": "artifact-root page", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(2); + for (const role of roles) { + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "resolved", + resolved_path: "index.html", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(["index.html"]); + } + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains non-string main evidence while following Node index entries through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-invalid-main-owner-"); + for (const [index, main] of [123, false, null, {}, []].entries()) { + await writeFixtureFiles(root, { + [`a${index}/package.json`]: JSON.stringify({ + name: `invalid-${index}`, + main, + }), + [`a${index}/index.js`]: `require("../shared/window${index}.cjs");`, + [`shared/window${index}.cjs`]: + 'const { BrowserWindow } = require("electron"); const win = new BrowserWindow(); win.loadFile("index.html");', + [`a${index}/index.html`]: "package index launch page", + "index.html": "artifact root decoy", + }); + } + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const packages = graph.nodes.filter(({ kind }) => kind === "package"); + expect(packages).toHaveLength(5); + for (const node of packages) + expect(node.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + main: null, + main_status: "invalid", + parse_status: "included", + name: expect.stringMatching(/^invalid-\d$/u), + }), + }), + ]), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(5); + for (const role of roles) { + const location = role.observations[0]?.evidence.location; + const source = + location?.available && location.value.kind === "source-range" + ? location.value.source + : null; + const match = /^shared\/window(\d)\.cjs$/u.exec(source ?? ""); + expect(match).not.toBeNull(); + const target = `a${match?.[1]}/index.html`; + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual([target]); + } + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => [ + { surface, source: "app/sub/main.cjs", selectedOwner: false }, + { surface, source: "shared/main.cjs", selectedOwner: false }, + { surface, source: "shared/main.cjs", selectedOwner: true }, + ]), +)( + "keeps unselected package ownership unknown through $surface for $source (selected owner: $selectedOwner)", + async ({ surface, source, selectedOwner }, { cli }) => { + const root = await createTestTempDirectory("rea-selected-loadfile-"); + await writeRuntimeFixture(root); + if (selectedOwner) + await writeFixtureFiles(root, { + "other-owner/package.json": JSON.stringify({ + main: "../shared/main.cjs", + }), + "other-owner/dist%2Findex.html": "unselected competing page", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths: [ + "package.json", + ...(selectedOwner ? ["external-app/package.json"] : []), + source, + ], + }); + const roles = graph.nodes.filter( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties }) => + properties.runtime_path_base === "application-root", + ), + ); + expect(roles).toHaveLength(3); + for (const role of roles) { + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ), + ).toEqual([]); + } + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps omitted launch owners unknown between identical ASAR occurrences through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-selected-asar-owner-"); + await writeRuntimeFixture(root); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths: [ + "a.asar/package.json", + "a.asar/sub/package.json", + "a.asar/external-app/package.json", + "a.asar/sub/main.cjs", + "a.asar/shared/main.cjs", + ], + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(6); + for (const role of roles) { + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + evidence: expect.objectContaining({ + location: expect.objectContaining({ + available: true, + value: expect.objectContaining({ + source: expect.stringMatching(/^a\.asar\//u), + }), + }), + }), + }), + ]), + ); + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual([]); + } + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "retains a selected entry owner and confines loadFile after applying its root through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-proven-partial-owner-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "main.cjs" }), + "app/main.cjs": + 'const { BrowserWindow } = require("electron"); const win = new BrowserWindow(); win.loadFile("index.html"); win.loadFile("../shared/index.html"); win.loadFile("../../escape.html");', + "app/helper.cjs": 'module.exports = "unrelated omitted module";', + "index.html": "artifact-root decoy", + "app/index.html": "proven application page", + "shared/index.html": "valid sibling page", + "escape.html": "escape decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths: ["app/package.json", "app/main.cjs"], + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(3); + for (const [declaration, target, status] of [ + ["index.html", "app/index.html", "resolved"], + ["../shared/index.html", "shared/index.html", "resolved"], + ["../../escape.html", null, "rejected"], + ] as const) { + const role = roles.find(({ observations }) => + observations.some( + ({ properties }) => properties.declared_path === declaration, + ), + ); + expect(role).toBeDefined(); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: status, + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + } + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps absolute loadFile host paths unknown through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-loadfile-absolute-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "main.cjs" }), + "app/main.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("/dist/index.html");', + "app/dist/index.html": "application-relative decoy", + "dist/index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + if (role === undefined) throw new Error("Missing renderer declaration"); + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: "/dist/index.html", + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ), + ).toEqual([]); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "preserves literal-percent runtime paths through %s without executing source", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-runtime-percent-"); + await writeRuntimeFixture(root); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + for (const [sourcePath, targetRoot, declared] of ( + [ + ["sub/main.cjs", ""], + ["app/sub/main.cjs", "app/"], + ["shared/main.cjs", "external-app/"], + ["default-app/index.js", "default-app/"], + ["default-app/nested/main.cjs", null], + ["ambiguous/sub/main.cjs", null], + ["competition/nested/main.cjs", null], + ["imported/child.cjs", "import-owner/"], + ["a.asar/sub/main.cjs", "a.asar/"], + ["b.asar/sub/main.cjs", "b.asar/"], + ["a.asar/shared/main.cjs", "a.asar/external-app/"], + ["b.asar/shared/main.cjs", "b.asar/external-app/"], + ] as const + ).flatMap(([sourcePath, targetRoot]) => + ["dist%2Findex.html", "plain.html", "absent.html"].map( + (declared) => [sourcePath, targetRoot, declared] as const, + ), + )) { + const role = graph.nodes.find( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties, evidence }) => + properties.declared_path === declared && + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === sourcePath, + ), + ); + expect(role).toBeDefined(); + if (role === undefined) throw new Error(`Missing loadFile ${declared}`); + const expected = + declared === "absent.html" || targetRoot === null + ? null + : `${targetRoot}${declared}`; + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: declared, + runtime_path_base: "application-root", + resolved_path: expected, + resolution_status: + targetRoot === null + ? "unavailable" + : expected === null + ? "not-found" + : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(expected === null ? [] : [expected]); + } + for (const { directory, target, status } of cases) { + for (const kind of ["electron-preload", "electron-utility"] as const) { + const nodes = graph.nodes.filter( + ({ kind: nodeKind, observations }) => + nodeKind === kind && + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === `${directory}/main.mjs`, + ), + ); + expect(nodes).toHaveLength(1); + const node = nodes[0]; + if (node === undefined) throw new Error(`Missing ${directory} ${kind}`); + const resolutions = node.observations.filter( + ({ properties }) => "resolution_status" in properties, + ); + expect(resolutions).not.toHaveLength(0); + for (const { properties } of resolutions) { + expect(properties).toMatchObject({ + resolved_path: target === null ? null : `${directory}/${target}`, + resolution_status: status, + }); + } + const mappedPaths = graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === node.node_id, + ) + .map(({ properties }) => properties.resolved_path); + expect([...new Set(mappedPaths)]).toEqual( + target === null ? [] : [`${directory}/${target}`], + ); + } + } + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + ["unread", "included-inert", "included-competing"].map((owner) => ({ + surface, + owner, + })), + ), +)( + "retains unread competing launch owners across an ASAR boundary through $surface ($owner)", + async ({ surface, owner }, { cli }) => { + const root = await createTestTempDirectory("rea-unread-asar-owner-"); + const source = join(root, "source"); + const application = join(root, "application"); + await writeFixtureFiles(source, { + "window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "index.html": "archive-root decoy", + }); + await writeFixtureFiles(application, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": 'require("../resources/shared.asar/window.cjs");', + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "b/main.cjs": + owner === "included-inert" + ? "module.exports = 1;" + : 'require("../resources/shared.asar/window.cjs");', + "b/index.html": "competing application page", + }); + await createPackageWithOptions( + source, + join(application, "resources/shared.asar"), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + { + sourcePaths: [ + "a/package.json", + "a/main.cjs", + "a/index.html", + "resources/shared.asar/window.cjs", + "resources/shared.asar/index.html", + ...(owner === "unread" + ? [] + : ["b/package.json", "b/main.cjs", "b/index.html"]), + ], + }, + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + const target = owner === "included-inert" ? "a/index.html" : null; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: target === null ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "../resources/shared.asar/window.cjs", + resolution_status: "resolved", + resolved_path: "resources/shared.asar/window.cjs", + }), + }), + ]), + ); + }, +); + +cliTest.for(["CLI", "stdio MCP"] as const)( + "keeps every application root unknown when three owners import one setter through %s", + async (surface, { cli }) => { + const root = await createTestTempDirectory("rea-shared-app-setter-"); + await writeFixtureFiles(root, { + "shared/change.cjs": + 'const { app } = require("electron"); app.setAppPath("/runtime/changed");', + ...Object.fromEntries( + ["a", "b", "c"].flatMap((owner) => [ + [`${owner}/package.json`, JSON.stringify({ main: "main.cjs" })], + [ + `${owner}/main.cjs`, + 'require("../shared/change.cjs"); require("./window.cjs");', + ], + [ + `${owner}/window.cjs`, + `// Application ${owner}\nconst { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");`, + ], + [`${owner}/index.html`, `application ${owner} page`], + ]), + ), + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(3); + for (const role of roles) { + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role.node_id, + ), + ).toEqual([]); + } + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + ["relative", "bare"].map((request) => ({ surface, request })), + ), +)( + "retains package main ownership after an ASAR escape through $surface ($request)", + async ({ surface, request }, { cli }) => { + const root = await createTestTempDirectory("rea-asar-metadata-"); + const source = join(root, "source"); + const application = join(root, "application"); + const directory = + request === "bare" ? "resources/node_modules/fixture" : "resources/pkg"; + await writeFixtureFiles(source, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": + request === "bare" ? 'require("fixture");' : 'require("../pkg");', + }); + await writeFixtureFiles(application, { + [`${directory}/package.json`]: JSON.stringify({ main: "actual.cjs" }), + [`${directory}/actual.cjs`]: + request === "bare" + ? 'require("../../window.cjs");' + : 'require("../window.cjs");', + [`${directory}/index.js`]: "module.exports = 1;", + "other/package.json": JSON.stringify({ main: "main.cjs" }), + "other/main.cjs": 'require("../resources/window.cjs");', + "resources/window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "other/index.html": "false unique owner decoy", + }); + await createPackageWithOptions( + source, + join(application, "resources/app.asar"), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: request === "bare" ? "fixture" : "../pkg", + resolution_status: "resolved", + resolved_path: `${directory}/actual.cjs`, + }), + }), + ]), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ), + ).toEqual([]); + }, +); diff --git a/tests/acceptance/applications/javascriptLoaderReviewBoundaries.test.ts b/tests/acceptance/applications/javascriptLoaderReviewBoundaries.test.ts new file mode 100644 index 0000000000..f0cd68ee54 --- /dev/null +++ b/tests/acceptance/applications/javascriptLoaderReviewBoundaries.test.ts @@ -0,0 +1,594 @@ +import { createPackageWithOptions } from "@electron/asar"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { + findSourceModule, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; + +const windowSource = + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");'; + +const expectRendererTarget = ( + graph: JavaScriptApplicationGraph, + target: string | null, +): void => { + const roles = graph.nodes.filter(({ kind }) => kind === "electron-renderer"); + expect(roles).toHaveLength(1); + const role = roles[0]; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: target === null ? "unavailable" : "resolved", + resolved_path: target, + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); +}; + +const loaderCases = [ + { + name: "returned module", + source: 'const get=()=>module;get().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "returned local", + source: + 'const get=()=>({require(){}});get().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "returned module call wrapper", + source: + 'const get=()=>module;get.call(null).require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + requiredUnknownExpression: 'get.call(null).require("../shared/window.cjs")', + }, + { + name: "returned module apply wrapper", + source: + 'const get=()=>module;get.apply(null,[]).require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + requiredUnknownExpression: + 'get.apply(null,[]).require("../shared/window.cjs")', + }, + { + name: "local call wrapper override", + source: + 'const get=()=>module;get.call=()=>({require(){}});get.call(null).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "local apply wrapper override", + source: + 'const get=()=>module;get.apply=()=>({require(){}});get.apply(null,[]).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "lost call wrapper receiver", + source: + 'const get=()=>module;(0,get.call)(null).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "lost apply wrapper receiver", + source: + 'const get=()=>module;(0,get.apply)(null,[]).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "captured call wrapper host receiver", + source: + 'const get=()=>module;const invoke=get.call;invoke.call(get,null).require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + requiredUnknownExpression: + 'invoke.call(get,null).require("../shared/window.cjs")', + }, + { + name: "captured apply wrapper host receiver", + source: + 'const get=()=>module;const invoke=get.apply;invoke.call(get,null,[]).require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + requiredUnknownExpression: + 'invoke.call(get,null,[]).require("../shared/window.cjs")', + }, + { + name: "captured call wrapper local receiver", + source: + 'const get=()=>module;const local=()=>({require(){}});const invoke=get.call;invoke.call(local,null).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "captured apply wrapper local receiver", + source: + 'const get=()=>module;const local=()=>({require(){}});const invoke=get.apply;invoke.call(local,null,[]).require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "inactive callable default", + source: + 'function host(){return module;}const {make=host}={make(){return {};}};make().require=()=>{};require("./local.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "active callable default", + source: + 'function host(){return module;}const {make=host}={};make().require=()=>{};require("./local.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "inactive array callable default", + source: + 'function host(){return module;}const [make=host]=[()=>({})];make().require=()=>{};require("./local.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "iterator callable default", + source: + 'function host(){return module;}const values=[()=>({})];values[Symbol.iterator]=function*(){yield undefined;};const [make=host]=values;make().require=()=>{};require("./local.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "generator local result", + source: + 'function* get(){yield module;}const api=get();api.require=()=>{};api.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "async local result", + source: + 'async function get(){return module;}const api=get();api.require=()=>{};api.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "awaited module result", + source: + '(async()=>{const get=async()=>module;(await get()).require("../shared/window.cjs");})();', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "awaited pending alias", + source: + '(async()=>{const get=async()=>module;const pending=get();const api=await pending;api.require("../shared/window.cjs");})();', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "awaited generator local", + source: + '(async()=>{function* get(){yield module;}const api=await get();api.require=()=>{};api.require("../shared/window.cjs");})();', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "awaited async local", + source: + '(async()=>{const get=async()=>({require(){}});const api=await get();api.require("../shared/window.cjs");})();', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "prototype extra host write", + source: + 'const [api]=[module];Array.prototype.extra=1;api.require=replacement;require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "prototype extra local write", + source: + 'const [api]=[{}];Array.prototype.extra=1;api.require=()=>{};api.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "custom iterator host write", + source: + 'const values={*[Symbol.iterator](){yield module;}};const [api]=values;api.require=replacement;require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "constructor cycle iterator", + source: + 'Array.prototype.constructor.prototype[Symbol.iterator]=function*(){yield module;};const [api]=[];api.require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "inherited array hole", + source: + 'Array.prototype[0]=module;const [api]=[,];api.require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "Object prototype array hole", + source: + 'Object.prototype[0]=module;const [api]=[,];api.require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "Object prototype extra local", + source: + 'Object.prototype.extra=module;const [api]=[{require(){}}];api.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "replaced host method", + source: + 'const box={make(){return {};}};box.make=()=>module;box.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "replaced local method", + source: + 'const box={make(){return module;}};box.make=()=>({require(){}});box.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "captured factory host", + source: + 'let make=()=>module;const box={make};make=()=>({});box.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "captured factory local", + source: + 'let make=()=>({require(){}});const box={make};make=()=>module;box.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "captured array factory", + source: + 'let make=()=>module;const values=[make];make=()=>({});values[0]().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "captured stored factory", + source: + 'let make=()=>module;const box={};box.make=make;make=()=>({});box.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "copied host factory", + source: + 'const old={make:()=>module};const box={...old};old.make=()=>({});box.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "copied local factory", + source: + 'const old={make:()=>({require(){}})};const box={...old};old.make=()=>module;box.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "wrapper before pattern replacement", + source: + 'var require;require("../shared/window.cjs");({require}={require:()=>({})});', + unknownOwner: true, + unknownLoad: false, + }, + { + name: "wrapper after pattern replacement", + source: + 'var require;({require}={require:()=>({})});require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + noProvenLoad: true, + }, + { + name: "shared deep child host write", + source: + 'const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>module;box.child.inner.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + requiredUnknownExpression: + 'box.child.inner.make().require("../shared/window.cjs")', + }, + { + name: "shared deep child local write", + source: + 'const old={child:{inner:{make:()=>module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>({require(){}});box.child.inner.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + absentUnknownExpression: + 'box.child.inner.make().require("../shared/window.cjs")', + }, + { + name: "fresh deep child local capture", + source: + 'const old={child:{inner:{make:()=>({require(){}})}}};const box={...old};old.child={inner:{make:()=>module}};old.child.inner.make=()=>module;box.child.inner.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "fresh deep child host capture", + source: + 'const old={child:{inner:{make:()=>module}}};const box={...old};old.child={inner:{make:()=>({})}};old.child.inner.make=()=>({});box.child.inner.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "twice copied host factory", + source: + 'const old={make:()=>module};const middle={...old};old.make=()=>({});const box={...middle};middle.make=()=>({});box.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "twice copied local factory", + source: + 'const old={make:()=>({require(){}})};const middle={...old};old.make=()=>module;const box={...middle};middle.make=()=>module;box.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "assigned rest captured module", + source: + 'const holder={};const old={loader:module};({...holder.box}=old);old.loader={};holder.box.loader.require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "assigned rest captured local", + source: + 'const holder={};const old={loader:{require(){}}};({...holder.box}=old);old.loader=module;holder.box.loader.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "copied child later host factory", + source: + 'const old={child:{make:()=>({})}};const box={...old};old.child.make=()=>module;box.child.make().require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "copied child later local factory", + source: + 'const old={child:{make:()=>module}};const box={...old};old.child.make=()=>({require(){}});box.child.make().require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "captured child later module", + source: + 'const inner={loader:{}};const box={inner};inner.loader=module;box.inner.loader.require("../shared/window.cjs");', + unknownOwner: true, + unknownLoad: true, + }, + { + name: "captured child later local", + source: + 'const inner={loader:module};const box={inner};inner.loader={require(){}};box.inner.loader.require("../shared/window.cjs");', + unknownOwner: false, + unknownLoad: false, + }, + { + name: "Windows relative require", + source: `require(${JSON.stringify("..\\shared\\window.cjs")});`, + unknownOwner: true, + unknownLoad: false, + }, + { + name: "invalid builtin backslash", + source: `require(${JSON.stringify("node:\\fs")});`, + unknownOwner: false, + unknownLoad: false, + }, + { + name: "unrelated import pattern", + source: 'require("#foo");', + imports: { "#bar/*": "./proxy/*.cjs" }, + unknownOwner: false, + unknownLoad: false, + }, + { + name: "matching import pattern", + source: 'require("#foo/window");', + imports: { "#foo/*": "./proxy/*.cjs" }, + unknownOwner: true, + unknownLoad: false, + }, + { + name: "nonmatching import suffix", + source: 'require("#foo/window.js");', + imports: { "#foo/*.cjs": "./proxy/*.cjs" }, + unknownOwner: false, + unknownLoad: false, + }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + loaderCases.map((testCase) => ({ surface, ...testCase })), + ), +)( + "preserves loader boundaries: $name through $surface", + async (testCase, { cli }) => { + const root = await createTestTempDirectory("rea-loader-review-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": 'require("../shared/window.cjs");', + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ + main: "main.cjs", + ...("imports" in testCase ? { imports: testCase.imports } : {}), + }), + "b/main.cjs": testCase.source, + "b/local.cjs": "module.exports = {};", + "b/proxy/window.cjs": 'require("../../shared/window.cjs");', + "b/index.html": "second application page", + "shared/window.cjs": windowSource, + "shared/index.html": "source-directory decoy", + "index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory( + testCase.surface, + cli, + root, + ); + expectRendererTarget(graph, testCase.unknownOwner ? null : "a/index.html"); + const unknownLoads = graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === null, + ); + expect(unknownLoads.length > 0).toBe(testCase.unknownLoad); + if ("requiredUnknownExpression" in testCase) + expect(unknownLoads).toContainEqual( + expect.objectContaining({ + properties: expect.objectContaining({ + expression: testCase.requiredUnknownExpression, + }), + }), + ); + if ("absentUnknownExpression" in testCase) + expect( + unknownLoads.filter( + ({ properties }) => + properties.expression === testCase.absentUnknownExpression, + ), + ).toEqual([]); + if ("noProvenLoad" in testCase) { + const source = findSourceModule(graph, "b/main.cjs"); + expect(source).toBeDefined(); + expect( + graph.edges.filter( + ({ relation, source_node_id, properties }) => + relation === "imports" && + source_node_id === source?.node_id && + properties.specifier === "../shared/window.cjs", + ), + ).toEqual([]); + } + if (testCase.name === "Windows relative require") + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: "..\\shared\\window.cjs", + resolution_status: "unavailable", + resolved_path: null, + }), + }), + ]), + ); + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + (["app.asar", "APP.ASAR", "app.AsAr"] as const).flatMap((archive) => + (["inward", "outward"] as const).map((direction) => ({ + surface, + archive, + direction, + })), + ), + ), +)( + "preserves $direction ASAR ownership for $archive through $surface", + async ({ surface, archive, direction }, { cli }) => { + const root = await createTestTempDirectory("rea-asar-case-"); + const source = join(root, "source"); + const application = join(root, "application"); + const inward = direction === "inward"; + await writeFixtureFiles( + source, + inward + ? { "window.cjs": windowSource, "index.html": "archive-root decoy" } + : { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": 'require("../window.cjs");', + "index.html": "archive application page", + }, + ); + await writeFixtureFiles( + application, + inward + ? { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": `require(${JSON.stringify(`../resources/${archive}/window.cjs`)});`, + "a/index.html": "outer application page", + } + : { "resources/window.cjs": windowSource }, + ); + await createPackageWithOptions( + source, + join(application, `resources/${archive}`), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + expectRendererTarget( + graph, + inward ? "a/index.html" : `resources/${archive}/index.html`, + ); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + specifier: inward + ? `../resources/${archive}/window.cjs` + : "../window.cjs", + resolution_status: "resolved", + resolved_path: inward + ? `resources/${archive}/window.cjs` + : "resources/window.cjs", + }), + }), + ]), + ); + }, +); diff --git a/tests/acceptance/applications/javascriptNonStringMainControls.test.ts b/tests/acceptance/applications/javascriptNonStringMainControls.test.ts new file mode 100644 index 0000000000..ca5aab58e0 --- /dev/null +++ b/tests/acceptance/applications/javascriptNonStringMainControls.test.ts @@ -0,0 +1,67 @@ +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [false, true].map((invalidJson) => ({ surface, invalidJson })), + ), +)( + "separates an absent default main from malformed package JSON through $surface (invalidJson=$invalidJson)", + async ({ surface, invalidJson }, { cli }) => { + const root = await createTestTempDirectory("rea-nonstring-main-controls-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": [ + 'throw new Error("Static fixture must never execute");', + 'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");', + ].join("\n"), + "a/index.html": "known first application page", + "b/package.json": invalidJson ? "{" : JSON.stringify({ main: false }), + ...(invalidJson ? { "b/index.js": "module.exports={};" } : {}), + "index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const packages = graph.nodes.filter(({ kind }) => kind === "package"); + expect(packages).toContainEqual( + expect.objectContaining({ + observations: expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + path: "b/package.json", + main_status: invalidJson ? "unavailable" : "invalid", + parse_status: invalidJson ? "invalid" : "included", + }), + }), + ]), + }), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + const target = invalidJson ? null : "a/index.html"; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: invalidJson ? "unavailable" : "resolved", + resolved_path: target, + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + }, +); diff --git a/tests/acceptance/applications/javascriptPackageExportPatternOwners.test.ts b/tests/acceptance/applications/javascriptPackageExportPatternOwners.test.ts new file mode 100644 index 0000000000..fe969f05f6 --- /dev/null +++ b/tests/acceptance/applications/javascriptPackageExportPatternOwners.test.ts @@ -0,0 +1,121 @@ +import { expect } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const cases = [ + { + name: "matching wildcard", + exports: { "./*": "./*.cjs" }, + status: "unavailable", + possibleOwner: true, + nodeAllowed: true, + }, + { + name: "unrelated wildcard", + exports: { "./other/*": "./*.cjs" }, + status: "external", + possibleOwner: false, + nodeAllowed: false, + }, + { + name: "exact key before wildcard", + exports: { "./*": "./*.cjs", "./window": "./window.cjs" }, + status: "resolved", + possibleOwner: true, + nodeAllowed: true, + }, + { + name: "exact null before wildcard", + exports: { "./*": "./*.cjs", "./window": null }, + status: "external", + possibleOwner: false, + nodeAllowed: false, + }, + { + name: "specific null before broader wildcard", + exports: { "./*": "./*.cjs", "./w*": null }, + status: "external", + possibleOwner: false, + nodeAllowed: false, + }, +] as const; +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((test) => ({ ...test, surface })), + ), +)( + "retains $name dependency ownership through $surface", + async ({ surface, exports, status, possibleOwner, nodeAllowed }, { cli }) => { + const root = await createTestTempDirectory("rea-exports-pattern-owner-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": + 'throw new Error("Static fixture must never execute");require("fixture/window");', + "a/index.html": "first app decoy", + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "b/main.cjs": + 'throw new Error("Static fixture must never execute");require("../node_modules/fixture/window.cjs");', + "b/index.html": "second application page", + "node_modules/fixture/package.json": JSON.stringify({ exports }), + "node_modules/fixture/window.cjs": + 'throw new Error("Static fixture must never execute");const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");', + "index.html": "artifact root decoy", + "oracle.cjs": + 'const {createRequire}=require("node:module");const loader=createRequire(require("node:path").join(__dirname,"a/main.cjs"));try{console.log(JSON.stringify({path:loader.resolve("fixture/window").split(require("node:path").sep).join("/")}));}catch(error){console.log(JSON.stringify({code:error.code}));}', + }); + // This authored control asks the native resolver for a path; it never + // executes the Electron source whose ownership is under analysis. + const oracle = await runNodeJsonControl(root, "oracle.cjs"); + expect(oracle).toEqual( + nodeAllowed + ? { path: expect.stringContaining("/node_modules/fixture/window.cjs") } + : { code: "ERR_PACKAGE_PATH_NOT_EXPORTED" }, + ); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths: [ + "a/package.json", + "a/main.cjs", + "b/package.json", + "b/main.cjs", + "node_modules/fixture/package.json", + "node_modules/fixture/window.cjs", + ], + }); + const refs = graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && properties.specifier === "fixture/window", + ); + expect(refs.length).toBeGreaterThan(0); + for (const ref of refs) + expect(ref.properties.resolution_status).toBe(status); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const target = possibleOwner ? null : "b/index.html"; + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: possibleOwner ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + }, +); diff --git a/tests/acceptance/applications/javascriptPackageLaunchBoundaries.test.ts b/tests/acceptance/applications/javascriptPackageLaunchBoundaries.test.ts new file mode 100644 index 0000000000..c7a3c05892 --- /dev/null +++ b/tests/acceptance/applications/javascriptPackageLaunchBoundaries.test.ts @@ -0,0 +1,165 @@ +import { createPackageWithOptions } from "@electron/asar"; +import { join } from "node:path"; +import { expect } from "vitest"; + +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; +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"; + +const windowSource = + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");'; + +const expectRendererOwner = ( + graph: JavaScriptApplicationGraph, + competing: boolean, +): void => { + const renderers = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(renderers).toHaveLength(1); + const role = renderers[0]; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: competing ? "unavailable" : "resolved", + resolved_path: competing ? null : "a/index.html", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(competing ? [] : ["a/index.html"]); +}; + +const launchCases = [ + { + name: "package index fallback", + main: "missing.cjs", + files: { "b/index.js": 'require("../shared/window.cjs");' }, + competing: true, + entry: "b/index.js", + }, + { + name: "absent package index", + main: "missing.cjs", + files: {}, + competing: false, + }, + { + name: "main before package index", + main: "main.cjs", + files: { + "b/main.cjs": "module.exports = {};", + "b/index.js": 'require("../shared/window.cjs");', + }, + competing: false, + entry: "b/main.cjs", + }, + { + name: "inert JSON index fallback", + main: "missing.cjs", + files: { "b/index.json": "{}" }, + competing: false, + }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + launchCases.map((testCase) => ({ surface, ...testCase })), + ), +)( + "keeps launch ownership for $name through $surface", + async (testCase, { cli }) => { + const root = await createTestTempDirectory("rea-package-launch-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": 'require("../shared/window.cjs");', + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main: testCase.main }), + "b/index.html": "second application page", + "shared/window.cjs": windowSource, + "shared/index.html": "source-directory decoy", + ...testCase.files, + }); + const { graph } = await analyzeJavaScriptDirectory( + testCase.surface, + cli, + root, + ); + expectRendererOwner(graph, testCase.competing); + if ("entry" in testCase) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ + declared_path: testCase.main, + resolved_path: testCase.entry, + resolution_status: "resolved", + }), + }), + ]), + ); + }, +); + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + ["code.asar", "APP.ASAR", "app.AsAr"].map((archive) => ({ + surface, + archive, + })), + ), +)( + "keeps competing package launch owners across $archive through $surface", + async ({ surface, archive }, { cli }) => { + const root = await createTestTempDirectory("rea-package-archive-main-"); + const source = join(root, "source"); + const application = join(root, "application"); + const declaredMain = `../resources/${archive}/window.cjs`; + await writeFixtureFiles(source, { + "window.cjs": windowSource, + "index.html": "archive-root decoy", + }); + await writeFixtureFiles(application, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": `require(${JSON.stringify(declaredMain)});`, + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main: declaredMain }), + "b/index.js": "module.exports = {};", + "b/index.html": "second application page", + }); + await createPackageWithOptions( + source, + join(application, `resources/${archive}`), + {}, + ); + const { graph } = await analyzeJavaScriptDirectory( + surface, + cli, + application, + ); + expectRendererOwner(graph, true); + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ + declared_path: declaredMain, + resolved_path: `resources/${archive}/window.cjs`, + resolution_status: "resolved", + }), + }), + ]), + ); + }, +); diff --git a/tests/acceptance/applications/javascriptPossibleSetterOwners.test.ts b/tests/acceptance/applications/javascriptPossibleSetterOwners.test.ts new file mode 100644 index 0000000000..7609414d26 --- /dev/null +++ b/tests/acceptance/applications/javascriptPossibleSetterOwners.test.ts @@ -0,0 +1,68 @@ +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [false, true].map((unknownLoader) => ({ surface, unknownLoader })), + ), +)( + "retains possible application path setter owners through $surface (unknown loader: $unknownLoader)", + async ({ surface, unknownLoader }, { cli }) => { + const root = await createTestTempDirectory("rea-possible-setter-owner-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": 'require("../shared/change.cjs");', + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "b/main.cjs": `${unknownLoader ? "require(process.env.LOADER);" : ""} require("./window.cjs");`, + "b/window.cjs": + 'const { BrowserWindow } = require("electron"); new BrowserWindow().loadFile("index.html");', + "b/index.html": "second application page", + "shared/change.cjs": + 'const { app } = require("electron"); app.setAppPath("/runtime/changed");', + "shared/index.html": "setter-relative decoy", + "index.html": "artifact-root decoy", + }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + const target = unknownLoader ? null : "b/index.html"; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: unknownLoader ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + if (unknownLoader) + expect(graph.edges).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + relation: "imports", + properties: expect.objectContaining({ + kind: "require", + specifier: null, + resolved_path: null, + }), + }), + ]), + ); + }, +); diff --git a/tests/acceptance/applications/javascriptPreloadResolveOwners.test.ts b/tests/acceptance/applications/javascriptPreloadResolveOwners.test.ts new file mode 100644 index 0000000000..17ea57552c --- /dev/null +++ b/tests/acceptance/applications/javascriptPreloadResolveOwners.test.ts @@ -0,0 +1,126 @@ +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.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + [ + { + name: "local helper", + declaration: "const helper={resolve(value){return value}};", + expression: 'helper.resolve(runtimePath,__dirname,"decoy.cjs")', + preload: null, + }, + { + name: "Node path resolver", + declaration: 'const path=require("node:path");', + expression: 'path.resolve(runtimePath,__dirname,"decoy.cjs")', + preload: "decoy.cjs", + }, + { + name: "untouched CommonJS directory parameter", + declaration: 'var __dirname;const path=require("node:path");', + expression: 'path.join(__dirname,"decoy.cjs")', + preload: "decoy.cjs", + }, + { + name: "local dirname helper", + declaration: + 'import path from "node:path";const helper={dirname(){return "/local"}};', + expression: + 'path.resolve(runtimePath,helper.dirname(import.meta.url),"decoy.cjs")', + preload: null, + mainFile: "main.mjs", + }, + { + name: "Node dirname and source URL", + declaration: + 'import path from "node:path";import {fileURLToPath} from "node:url";', + expression: + 'path.resolve(runtimePath,path.dirname(fileURLToPath(import.meta.url)),"decoy.cjs")', + preload: "decoy.cjs", + mainFile: "main.mjs", + }, + { + name: "separate default path write", + declaration: + 'import changed from "node:path";import path from "path";changed.resolve=first=>first;', + expression: 'path.resolve(runtimePath,__dirname,"decoy.cjs")', + preload: null, + mainFile: "main.mjs", + }, + { + name: "separate default URL write", + declaration: + 'import changed from "node:url";import url from "url";import path from "node:path";changed.fileURLToPath=()=>"/changed/main.mjs";', + expression: + 'path.resolve(runtimePath,path.dirname(url.fileURLToPath(import.meta.url)),"decoy.cjs")', + preload: null, + mainFile: "main.mjs", + }, + { + name: "shadowed directory", + declaration: 'const path=require("node:path");', + expression: 'path.resolve(runtimePath,__dirname,"decoy.cjs")', + preload: null, + scopeStart: "function build(__dirname){", + scopeEnd: "}build('local-directory');", + }, + ].map((testCase) => ({ + surface, + scopeStart: "", + scopeEnd: "", + mainFile: "main.cjs", + ...testCase, + })), + ), +)( + "requires the $name owner for preload resolve semantics through $surface", + async ( + { + surface, + declaration, + expression, + preload, + scopeStart, + scopeEnd, + mainFile, + }, + { cli }, + ) => { + const root = await createTestTempDirectory("rea-preload-resolve-owner-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: mainFile }), + [mainFile]: `throw new Error('Static fixture must never execute');${mainFile.endsWith(".mjs") ? "import {BrowserWindow} from 'electron';" : "const {BrowserWindow}=require('electron');"}${declaration}${scopeStart}new BrowserWindow({webPreferences:{preload:${expression}}});${scopeEnd}`, + "decoy.cjs": "throw new Error('Static preload must never execute');", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const windows = graph.nodes.filter(({ kind }) => kind === "browser-window"); + expect(windows).toHaveLength(1); + expect(windows[0]?.observations[0]?.properties.preload_path).toBe(preload); + const associations = graph.edges.filter( + ({ source_node_id, evidence }) => + source_node_id === windows[0]?.node_id && + evidence.extractor.operation === "associate-browser-window-preload", + ); + if (preload === null) expect(associations).toEqual([]); + else + expect(associations).toEqual([ + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ + resolved_path: preload, + resolution_status: "resolved", + }), + }), + ]); + }, +); diff --git a/tests/acceptance/applications/javascriptPublishedAppOwners.test.ts b/tests/acceptance/applications/javascriptPublishedAppOwners.test.ts new file mode 100644 index 0000000000..7bcabac108 --- /dev/null +++ b/tests/acceptance/applications/javascriptPublishedAppOwners.test.ts @@ -0,0 +1,99 @@ +import { expect } from "vitest"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; +import { + analyzeJavaScriptDirectory, + runNodeJsonControl, +} from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js"; + +const cases = [ + { + name: "whole app ESM", + body: "export {app};", + esm: true, + changed: true, + oracle: "module.exports={app};", + }, + { name: "whole app CommonJS", body: "module.exports={app};", changed: true }, + { + name: "later local assign", + body: "Object.assign(exports,{change:app.setAppPath},{change(){}});", + changed: false, + }, + { + name: "later native assign", + body: "Object.assign(exports,{change(){}},{change:app.setAppPath});", + changed: true, + }, + { + name: "inherited setter", + body: "const source={__proto__:{change:app.setAppPath}};Object.assign(exports,source);", + changed: false, + }, + { + name: "own setter", + body: "const source={__proto__:{change(){}},change:app.setAppPath};Object.assign(exports,source);", + changed: true, + }, +] as const; +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((test) => ({ ...test, surface })), + ), +)( + "retains only the actually published $name app capability through $surface", + async (test, { cli }) => { + const { surface, body, changed } = test; + const esm = "esm" in test && test.esm; + const root = await createTestTempDirectory("rea-published-app-owner-"); + const prefix = esm + ? 'import {app} from "electron";' + : 'const {app}=require("electron");'; + const helper = `shared/helper.${esm ? "mjs" : "cjs"}`; + const oracleBody = "oracle" in test ? test.oracle : body; + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ + main: esm ? "main.mjs" : "main.cjs", + }), + [`app/main.${esm ? "mjs" : "cjs"}`]: esm + ? 'import * as api from "../shared/helper.mjs";import {BrowserWindow} from "electron";api.app.setAppPath("./other");new BrowserWindow().loadFile("index.html");' + : 'const api=require("../shared/helper.cjs");const {BrowserWindow}=require("electron");if(api.app)api.app.setAppPath("./other");else if(api.change)api.change("./other");new BrowserWindow().loadFile("index.html");', + [helper]: prefix + body, + "app/index.html": "package root decoy for native setters", + "shared/index.html": "helper-relative decoy", + "oracle.cjs": `const vm=require('node:vm');let changed=0;const app={setAppPath(){changed++}};const fixtureModule={exports:{}};vm.runInNewContext(${JSON.stringify(oracleBody)},{app,module:fixtureModule,exports:fixtureModule.exports});const value=fixtureModule.exports;if(value.app)value.app.setAppPath('./other');else if(value.change)value.change.call(app,'./other');console.log(JSON.stringify({changed:changed>0}));`, + }); + expect(await runNodeJsonControl(root, "oracle.cjs")).toEqual({ changed }); + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths: [ + "app/package.json", + `app/main.${esm ? "mjs" : "cjs"}`, + helper, + ], + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const target = changed ? null : "app/index.html"; + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: target, + resolution_status: changed ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === roles[0]?.node_id, + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(target === null ? [] : [target]); + }, +); diff --git a/tests/acceptance/applications/javascriptReturnedAppPathChanges.test.ts b/tests/acceptance/applications/javascriptReturnedAppPathChanges.test.ts new file mode 100644 index 0000000000..de571a4f65 --- /dev/null +++ b/tests/acceptance/applications/javascriptReturnedAppPathChanges.test.ts @@ -0,0 +1,124 @@ +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"; + +const cases = [ + { + name: "returned app", + setter: "const getApp=()=>app;getApp().setAppPath('./lib');", + owners: 1, + changed: true, + }, + { + name: "awaited app", + setter: "const getApp=async()=>app;(await getApp()).setAppPath('./lib');", + owners: 1, + changed: true, + }, + { + name: "local app", + setter: "const getApp=()=>({setAppPath(){}});getApp().setAppPath('./lib');", + owners: 1, + changed: false, + }, + { + name: "replaced app setter", + setter: "app.setAppPath=()=>{};app.setAppPath('/changed');", + owners: 1, + changed: false, + }, + { + name: "conditional setter replacement", + setter: "if(flag)app.setAppPath=()=>{};app.setAppPath('/changed');", + owners: 1, + changed: true, + }, + { + name: "original setter captured before replacement", + setter: + "const change=app.setAppPath;app.setAppPath=()=>{};change('/changed');", + owners: 1, + changed: true, + }, + { + name: "local setter captured after replacement", + setter: + "app.setAppPath=()=>{};const change=app.setAppPath;change('/changed');", + owners: 1, + changed: false, + }, + { + name: "assigned app", + setter: "let runtime;(runtime=app).setAppPath('/changed');", + owners: 1, + changed: true, + }, + { + name: "assigned local app", + setter: "let runtime;(runtime={setAppPath(){}}).setAppPath('/changed');", + owners: 1, + changed: false, + }, + { + name: "shared returned app", + setter: "const getApp=()=>app;getApp().setAppPath('./lib');", + owners: 3, + changed: true, + }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((test) => ({ ...test, surface })), + ), +)( + "preserves $name path uncertainty for $owners application owners through $surface", + async ({ surface, setter, owners, changed }, { cli }) => { + const root = await createTestTempDirectory("rea-returned-app-path-"); + const files: Record = { + "shared/change.mjs": `throw new Error('Static fixture must never execute');import {app} from 'electron';${setter}`, + "shared/index.html": "setter-relative decoy", + "index.html": "artifact-root decoy", + }; + for (let index = 0; index < owners; index++) { + const directory = `app-${index}`; + files[`${directory}/package.json`] = JSON.stringify({ main: "main.mjs" }); + files[`${directory}/main.mjs`] = + `throw new Error('Static fixture must never execute');import '../shared/change.mjs';import {BrowserWindow} from 'electron';new BrowserWindow().loadFile('index.html');`; + files[`${directory}/index.html`] = `page-${index}`; + } + await writeFixtureFiles(root, files); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(owners); + for (const role of roles) { + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: changed ? "unavailable" : "resolved", + ...(changed ? { resolved_path: null } : {}), + }), + }), + ]), + ); + const mapped = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "maps_to" && source_node_id === role.node_id, + ); + expect(mapped).toHaveLength(changed ? 0 : 1); + if (!changed) + expect(mapped[0]?.properties.resolved_path).toBe("app-0/index.html"); + } + }, +); diff --git a/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts b/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts index 981806e4dd..74b7a8a478 100644 --- a/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts +++ b/tests/acceptance/applications/javascriptReturnedReferenceCycles.test.ts @@ -7,13 +7,14 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; +import { findSourceModule } from "../../support/javascriptApplicationFixture.js"; const verify = (value: unknown) => { const result = javascriptApplicationAnalysisResultSchema.parse( parseEvidence(value).normalized_result, ); expect(result.statistics).toMatchObject({ - parsed_javascript_files: 3, + parsed_javascript_files: 5, skipped_javascript_files: 0, parse_failures: 0, }); @@ -38,6 +39,28 @@ const verify = (value: unknown) => { interpretation: "candidate", }); expect(endpoints.some(({ value }) => value === "/intrinsic")).toBe(false); + expect(endpoints).toContainEqual({ + value: "/copied-factory", + interpretation: undefined, + }); + expect(endpoints).toContainEqual({ + value: "/copied-module", + interpretation: undefined, + }); + for (const [path, expression] of [ + ["copied-factory.cjs", 'box.make().require("./possible.cjs")'], + ["copied-module.cjs", 'box.loader.require("./possible.cjs")'], + ] as const) { + const source = findSourceModule(result.graph, path); + expect(source).toBeDefined(); + expect(result.graph.edges).toContainEqual( + expect.objectContaining({ + source_node_id: source?.node_id, + relation: "imports", + properties: expect.objectContaining({ specifier: null, expression }), + }), + ); + } expect(result.semantic_graph.unknowns).toContainEqual( expect.objectContaining({ reason: "dynamic-property", @@ -79,6 +102,20 @@ function walk(flag) { patch(cursor.target()); } new Map().get("/growing");`, + ); + await writeFile( + join(root, "copied-factory.cjs"), + `let box={make:()=>module}; +while(flag) box={...box}; +box.make().require("./possible.cjs"); +fetch("/copied-factory");`, + ); + await writeFile( + join(root, "copied-module.cjs"), + `let box={loader:module}; +while(flag) box={...box}; +box.loader.require("./possible.cjs"); +fetch("/copied-module");`, ); const response = await cli.run({ arguments: [ diff --git a/tests/acceptance/applications/javascriptUnreadAppPathChanges.test.ts b/tests/acceptance/applications/javascriptUnreadAppPathChanges.test.ts new file mode 100644 index 0000000000..95b915ba9d --- /dev/null +++ b/tests/acceptance/applications/javascriptUnreadAppPathChanges.test.ts @@ -0,0 +1,109 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +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"; + +const cases = [ + "unselected", + "transitive-unselected", + "invalid-utf8", + "parse-failed", + "read-inert", + "unreferenced", + "json", +] as const; +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((gap) => ({ surface, gap })), + ), +)( + "retains possible app path changes from a reachable $gap module through $surface", + async ({ surface, gap }, { cli }) => { + const root = await createTestTempDirectory("rea-unread-app-path-change-"); + const window = + 'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");'; + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": `${gap === "unreferenced" ? "" : gap === "transitive-unselected" ? 'require("./middle.cjs");' : `require("./change.${gap === "json" ? "json" : "cjs"}");`}${window}`, + "a/change.cjs": + gap === "parse-failed" + ? "const = ;" + : gap === "read-inert" + ? "module.exports=42;" + : 'const {app}=require("electron");app.setAppPath("/changed");', + "a/change.json": '{"inert":true}', + "a/middle.cjs": 'require("./change.cjs");', + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "b/main.cjs": window, + "b/index.html": "unrelated second application page", + "index.html": "artifact root decoy", + }); + if (gap === "invalid-utf8") + await writeFile( + join(root, "a/change.cjs"), + Buffer.from([0xff, 0xfe, 0x80]), + ); + const sourcePaths = [ + "a/package.json", + "a/main.cjs", + "b/package.json", + "b/main.cjs", + ...(gap === "unselected" || + gap === "unreferenced" || + gap === "transitive-unselected" + ? [] + : [`a/change.${gap === "json" ? "json" : "cjs"}`]), + ...(gap === "transitive-unselected" ? ["a/middle.cjs"] : []), + ]; + const { graph } = await analyzeJavaScriptDirectory(surface, cli, root, { + sourcePaths, + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(2); + for (const app of ["a", "b"]) { + const role = roles.find(({ observations }) => + observations.some( + ({ evidence }) => + evidence.location.available && + evidence.location.value.kind === "source-range" && + evidence.location.value.source === `${app}/main.cjs`, + ), + ); + // The unread source could itself import b/main, so the existing loader + // owner policy also retains competition for that module. In contrast an + // unreferenced omitted source must not invalidate either proven root. + const changed = [ + "unselected", + "transitive-unselected", + "invalid-utf8", + "parse-failed", + ].includes(gap); + const expected = changed ? null : `${app}/index.html`; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: expected, + resolution_status: changed ? "unavailable" : "resolved", + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(expected === null ? [] : [expected]); + } + }, +); diff --git a/tests/acceptance/applications/javascriptWindowsLoadFilePaths.test.ts b/tests/acceptance/applications/javascriptWindowsLoadFilePaths.test.ts new file mode 100644 index 0000000000..c000c602fa --- /dev/null +++ b/tests/acceptance/applications/javascriptWindowsLoadFilePaths.test.ts @@ -0,0 +1,68 @@ +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"; + +const cases = [ + { declared: "sub\\index.html", status: "unavailable", path: null }, + { declared: "C:index.html", status: "unavailable", path: null }, + { + declared: "sub/index.html", + status: "resolved", + path: "app/sub/index.html", + }, + { declared: "sub\\index\0.html", status: "rejected", path: null }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + cases.map((testCase) => ({ surface, ...testCase })), + ), +)( + "preserves loadFile host path semantics through $surface: $declared", + async ({ surface, declared, status, path }, { cli }) => { + const root = await createTestTempDirectory("rea-windows-loadfile-path-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "main.cjs" }), + "app/main.cjs": [ + 'throw new Error("Static fixture must never execute");', + 'const {BrowserWindow}=require("electron");', + `new BrowserWindow().loadFile(${JSON.stringify(declared)});`, + ].join("\n"), + "app/sub/index.html": "application page", + "sub/index.html": "artifact-root decoy", + }); + const { graph, statistics } = await analyzeJavaScriptDirectory( + surface, + cli, + root, + ); + expect(statistics.parse_failures).toBe(0); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + declared_path: declared, + resolution_status: status, + resolved_path: path, + }), + }), + ]), + ); + expect( + graph.edges + .filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ) + .map(({ properties }) => properties.resolved_path), + ).toEqual(path === null ? [] : [path]); + }, +); diff --git a/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts b/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts new file mode 100644 index 0000000000..fb85848f99 --- /dev/null +++ b/tests/acceptance/applications/javascriptWrapperElectronCaptures.test.ts @@ -0,0 +1,100 @@ +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 { + findSourceModule, + writeFixtureFiles, +} from "../../support/javascriptApplicationFixture.js"; + +const writes = [ + { + name: "object pattern", + source: "({require}={require:()=>({BrowserWindow:LocalBrowserWindow})});", + }, + { + name: "array pattern", + source: "[require]=[()=>({BrowserWindow:LocalBrowserWindow})];", + }, +] as const; + +cliTest.for( + (["CLI", "stdio MCP"] as const).flatMap((surface) => + writes.flatMap((write) => + ([false, true] as const).map((capturedBeforeWrite) => ({ + surface, + ...write, + capturedBeforeWrite, + })), + ), + ), +)( + "preserves Electron capture ownership through $surface after $name with capturedBeforeWrite=$capturedBeforeWrite", + async ({ surface, source, capturedBeforeWrite }, { cli }) => { + const root = await createTestTempDirectory("rea-wrapper-electron-capture-"); + const capture = 'const {BrowserWindow}=require("electron");'; + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": [ + 'throw new Error("Static fixture must never execute");', + "class LocalBrowserWindow{}", + "var require;", + ...(capturedBeforeWrite ? [capture, source] : [source, capture]), + 'new BrowserWindow({webPreferences:{preload:"./bridge.cjs"}});', + ].join("\n"), + "bridge.cjs": 'throw new Error("Preload fixture must never execute");', + }); + const result = await analyzeJavaScriptDirectory(surface, cli, root); + expect(result.statistics.parse_failures).toBe(0); + const expectedRoles = capturedBeforeWrite ? 1 : 0; + expect(result.summary).toMatchObject({ + browser_windows: expectedRoles, + preload_entrypoints: expectedRoles, + }); + const { graph } = result; + const windows = graph.nodes.filter(({ kind }) => kind === "browser-window"); + expect(windows).toHaveLength(expectedRoles); + // Generic `property:preload` discovery is separate from a proven + // BrowserWindow identity; only the latter may create this association. + expect( + graph.nodes.filter( + ({ kind, observations }) => + kind === "electron-preload" && + observations.some( + ({ properties }) => + properties.mechanism === "BrowserWindow:webPreferences.preload", + ), + ), + ).toHaveLength(expectedRoles); + const main = findSourceModule(graph, "main.cjs"); + expect(main).toBeDefined(); + const imports = graph.edges.filter( + ({ relation, source_node_id }) => + relation === "imports" && source_node_id === main?.node_id, + ); + expect( + imports.some(({ properties }) => properties.specifier === "electron"), + ).toBe(capturedBeforeWrite); + expect( + imports.some(({ properties }) => properties.specifier === null), + ).toBe(!capturedBeforeWrite); + const preloads = graph.edges.filter( + ({ evidence }) => + evidence.extractor.operation === "associate-browser-window-preload", + ); + if (capturedBeforeWrite) + expect(preloads).toMatchObject([ + { + relation: "loads", + source_node_id: windows[0]?.node_id, + properties: { + declared_path: "./bridge.cjs", + resolved_path: "bridge.cjs", + resolution_status: "resolved", + }, + }, + ]); + else expect(preloads).toEqual([]); + }, +); diff --git a/tests/acceptance/applications/packageSubpathsCli.test.ts b/tests/acceptance/applications/packageSubpathsCli.test.ts index 869a8deba7..1230ec44bf 100644 --- a/tests/acceptance/applications/packageSubpathsCli.test.ts +++ b/tests/acceptance/applications/packageSubpathsCli.test.ts @@ -1,7 +1,6 @@ import { createHash } from "node:crypto"; -import { mkdir, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, join, relative } from "node:path"; +import { join, relative } from "node:path"; import { pathToFileURL } from "node:url"; import { expect } from "vitest"; @@ -11,6 +10,7 @@ import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/j import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { cliTest } from "../../support/cli/cliFixture.js"; import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js"; +import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; const createPackageSubpathFixture = async () => { const root = await createTestTempDirectory("rea-package-subpaths-"); @@ -85,13 +85,7 @@ const createPackageSubpathFixture = async () => { "node_modules/scalar.js": "module.exports = 'scalar sibling';", "node_modules/scalar/package.json": JSON.stringify({ exports: false }), }; - await Promise.all( - Object.entries(files).map(async ([path, content]) => { - const absolute = join(root, path); - await mkdir(dirname(absolute), { recursive: true }); - await writeFile(absolute, content); - }), - ); + await writeFixtureFiles(root, files); return { root, source, selfSource, importSource }; }; @@ -189,8 +183,8 @@ cliTest.for(["CLI", "stdio MCP"] as const)( properties: expect.objectContaining({ module_link_kind: "import", specifier: "FILE:///src/uri-dep.cjs?token=%2F#entry", - resolved_path: "src/uri-dep.cjs", - resolution_status: "resolved", + resolved_path: null, + resolution_status: "unavailable", }), evidence: sourceEvidence("src/imports.mjs", importSource), }), diff --git a/tests/acceptance/applications/packageUrlUnicodeCli.test.ts b/tests/acceptance/applications/packageUrlUnicodeCli.test.ts index a50b4c837d..f7447feae4 100644 --- a/tests/acceptance/applications/packageUrlUnicodeCli.test.ts +++ b/tests/acceptance/applications/packageUrlUnicodeCli.test.ts @@ -32,10 +32,10 @@ const targets = { }, external: { value: "another-package", - imports: "external", + imports: "unavailable", exports: "rejected", }, - recursive: { value: "#another", imports: "external", exports: "rejected" }, + recursive: { value: "#another", imports: "unavailable", exports: "rejected" }, invalid: { value: "../feature.js", imports: "rejected", exports: "rejected" }, scalar: { value: false, imports: "rejected", exports: "rejected" }, numeric: { value: { 0: null }, imports: "rejected", exports: "rejected" }, @@ -47,7 +47,7 @@ const targets = { }, arrayExternal: { value: [false, "another-package"], - imports: "external", + imports: "unavailable", exports: "rejected", }, arrayRelative: { @@ -62,7 +62,7 @@ const targets = { }, escapeExternal: { value: [escapingTarget, "another-package"], - imports: "external", + imports: "unavailable", exports: "rejected", }, escapeNull: { @@ -82,7 +82,7 @@ const targets = { }, escapeNested: { value: [{ node: escapingTarget }, "another-package"], - imports: "external", + imports: "unavailable", exports: "rejected", }, escapeConfig: { @@ -92,12 +92,12 @@ const targets = { }, wtf8Reentry: { value: ["./%2\te%2\te/%ED%A0%80/feature.js", "another-package"], - imports: "external", + imports: "unavailable", exports: "rejected", }, opaqueReentry: { value: ["./%2\te%2\te/rea-op\taque-0/feature.js", "another-package"], - imports: "external", + imports: "unavailable", exports: "rejected", }, structuralReentry: { @@ -246,7 +246,7 @@ cliTest.for(["CLI", "stdio MCP"] as const)( }), evidence: expect.objectContaining({ limitations: - status === "unavailable" + status === "unavailable" && request.status !== "unavailable" ? expect.arrayContaining([ expect.stringContaining("unpaired UTF-16 surrogate"), ]) diff --git a/tests/boundary/cli/cliIntegerArguments.test.ts b/tests/boundary/cli/cliIntegerArguments.test.ts index 9f07e5777b..202d0898a9 100644 --- a/tests/boundary/cli/cliIntegerArguments.test.ts +++ b/tests/boundary/cli/cliIntegerArguments.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect } from "vitest"; -import { serveCli } from "./cliServeHarness.js"; +import { cliTest, type CliResult } from "../../support/cli/cliFixture.js"; const SESSION_ID = "27d3e3f1-f1e5-49ae-91ec-95af1f343a5a"; const NON_INTEGER_TEXT = [ @@ -17,10 +17,7 @@ const NON_INTEGER_TEXT = [ ]; const NON_INTEGER_OPTION_TEXT = [...NON_INTEGER_TEXT, "-0"]; -const expectCliValidationError = ( - result: Awaited>, - field: string, -): void => { +const expectCliValidationError = (result: CliResult, field: string): void => { expect(result.exitCode).toBe(1); expect(JSON.parse(result.stdout)).toMatchObject({ code: "VALIDATION_ERROR", @@ -31,65 +28,70 @@ const expectCliValidationError = ( }; describe("CLI integer arguments", () => { - it.each(NON_INTEGER_TEXT)( + cliTest.for(NON_INTEGER_TEXT)( "rejects %j as a Cutter expected generation", - async (value) => { - const result = await serveCli([ - "cutter-command", - SESSION_ID, - value, - "pdi", - "--json", - ]); + async (value, { cli }) => { + const result = await cli.run({ + arguments: ["cutter-command", SESSION_ID, value, "pdi", "--json"], + }); expectCliValidationError(result, "expectedGeneration"); }, ); - it.each(NON_INTEGER_TEXT)( + cliTest.for(NON_INTEGER_TEXT)( "rejects %j as a web module script index", - async (value) => { - const result = await serveCli([ - "trace-web-module-imports", - "/nonexistent/manifest.json", - value, - "--json", - ]); + async (value, { cli }) => { + const result = await cli.run({ + arguments: [ + "trace-web-module-imports", + "/nonexistent/manifest.json", + value, + "--json", + ], + }); expectCliValidationError(result, "scriptIndex"); }, ); - it.each(NON_INTEGER_OPTION_TEXT)( + cliTest.for(NON_INTEGER_OPTION_TEXT)( "rejects %j in a repeated web capture record selector", - async (value) => { - const result = await serveCli([ - "inspect-web-network-capture", - "/nonexistent/capture.qlog", - "qlog", - "--record", - "0", - "--record", - value, - "--json", - ]); + async (value, { cli }) => { + const result = await cli.run({ + arguments: [ + "inspect-web-network-capture", + "/nonexistent/capture.qlog", + "qlog", + "--record", + "0", + "--record", + value, + "--json", + ], + }); expectCliValidationError(result, "record"); }, ); - it("keeps the one-based source line lower bound at the CLI boundary", async () => { - const result = await serveCli([ - "trace-web-source-location", - "/nonexistent/manifest.json", - "0", - "/nonexistent/app.js.map", - "https://example.test/app.js.map", - "0", - "0", - "--json", - ]); + cliTest( + "keeps the one-based source line lower bound at the CLI boundary", + async ({ cli }) => { + const result = await cli.run({ + arguments: [ + "trace-web-source-location", + "/nonexistent/manifest.json", + "0", + "/nonexistent/app.js.map", + "https://example.test/app.js.map", + "0", + "0", + "--json", + ], + }); - expectCliValidationError(result, "line"); - }); + expectCliValidationError(result, "line"); + }, + ); }); diff --git a/tests/boundary/cli/electronWrappedCallPaths.test.ts b/tests/boundary/cli/electronWrappedCallPaths.test.ts index 89b024be37..2a712e7874 100644 --- a/tests/boundary/cli/electronWrappedCallPaths.test.ts +++ b/tests/boundary/cli/electronWrappedCallPaths.test.ts @@ -281,7 +281,7 @@ cliTest( surface, cli, root, - connection?.call, + { mcpCall: connection?.call }, ); expect(application.input_path).toBe(root); expect(artifacts.has(application.root_artifact_sha256)).toBe(false); @@ -521,7 +521,7 @@ for (const surface of ["CLI", "stdio MCP"] as const) { surface, cli, root, - connection?.call, + { mcpCall: connection?.call }, ); expect(application.input_path).toBe(root); return application; diff --git a/tests/boundary/filesystem/artifactLiteralBackslash.test.ts b/tests/boundary/filesystem/artifactLiteralBackslash.test.ts index 02105ad604..b56bdb6327 100644 --- a/tests/boundary/filesystem/artifactLiteralBackslash.test.ts +++ b/tests/boundary/filesystem/artifactLiteralBackslash.test.ts @@ -1,7 +1,10 @@ import { createHash } from "node:crypto"; +import { execFileSync } from "node:child_process"; import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises"; import { dirname, join } from "node:path"; import { Readable } from "node:stream"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; import { createPackage } from "@electron/asar"; import { expect, it } from "vitest"; @@ -15,6 +18,44 @@ import { import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { analyzeJavaScriptApplication } from "../../support/javascriptApplicationScope.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js"; +import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js"; + +const loaderImports = (graph: JavaScriptApplicationGraph) => + graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && + properties.specifier === "./b.js" && + (properties.kind === "static-import" || + properties.kind === "dynamic-import" || + properties.kind === "require"), + ); + +const forwardedExports = (graph: JavaScriptApplicationGraph) => + graph.edges.filter( + ({ relation, properties }) => + relation === "imports" && + properties.specifier === "./b.js" && + (properties.module_link_kind === "export" || + properties.module_link_kind === "commonjs-export"), + ); + +const writeLoaderExportFixture = async ( + source: string, + directory: string, + targetBody: string, +): Promise => { + await mkdir(join(source, directory), { recursive: true }); + for (const [name, body] of [ + [ + "a.mjs", + "import { marker } from './b.js'; export { marker }; import('./b.js');\n", + ], + ["a.cjs", "module.exports = require('./b.js');\n"], + ["a.ts", "import { marker } from './b.js'; export { marker };\n"], + ["b.js", targetBody], + ] as const) + await writeFile(join(source, directory, name), body); +}; const NAMES = [ "literal\\name.txt", @@ -167,24 +208,98 @@ it.skipIf(process.platform === "win32").each(["directory", "asar"] as const)( }, ); +it("rejects inherited encoded separators with the real Node ESM loader before file lookup", async () => { + const root = await createTestTempDirectory("rea-backslash-esm-url-"); + const source = pathToFileURL(join(root, "a.mjs")); + source.pathname = source.pathname.replace(/a\.mjs$/u, "literal%5Cdir/a.mjs"); + const target = new URL("./b.mjs", source); + expect(target.pathname).toContain("literal%5Cdir/b.mjs"); + // Use the actual Node loader, outside Vitest's module runner. + const observed = execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + "try { await import(process.argv[1]); console.log(JSON.stringify({loaded:true})); } catch(error) { console.log(JSON.stringify({code:error.code})); }", + target.href, + ], + { encoding: "utf8", timeout: 5_000 }, + ); + expect(JSON.parse(observed) as unknown).toEqual({ + code: "ERR_INVALID_MODULE_SPECIFIER", + }); +}); + +it("keeps loader modes separate from forwarded export relationships", async () => { + const root = await createTestTempDirectory("rea-loader-export-contract-"); + const source = join(root, "source"); + await writeLoaderExportFixture( + source, + "ordinary", + "export const marker = 'target';\n", + ); + const result = await analyzeJavaScriptApplication({ + input_path: source, + format: "directory", + }); + if (!result.ok) throw result.error; + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + result.value.normalized_result, + ); + const imports = loaderImports(graph); + expect(imports).toHaveLength(4); + expect( + imports.map(({ properties }) => [ + properties.kind, + properties.resolution_mode, + ]), + ).toEqual( + expect.arrayContaining([ + ["require", "require"], + ["dynamic-import", "import"], + ["static-import", "import"], + ["static-import", "compiler-dependent"], + ]), + ); + const exports = forwardedExports(graph); + expect(exports).toHaveLength(3); + expect( + exports.map(({ properties }) => [ + properties.module_link_kind, + properties.exported_name, + properties.resolution_mode, + ]), + ).toEqual( + expect.arrayContaining([ + ["export", "marker", undefined], + ["export", "marker", "compiler-dependent"], + ["commonjs-export", "default", undefined], + ]), + ); + for (const { properties } of [...imports, ...exports]) + expect(properties).toMatchObject({ + resolved_path: "ordinary/b.js", + resolution_status: "resolved", + }); +}); + it.skipIf(process.platform === "win32").each(["directory", "asar"] as const)( - "resolves ordinary relative imports inside a literal POSIX directory from %s", + "preserves loader-specific relative paths inside a literal POSIX directory from %s", async (format) => { const root = await createTestTempDirectory("rea-backslash-import-base-"); const source = join(root, "source"); const literal = "literal\\dir"; - await mkdir(join(source, literal), { recursive: true }); await mkdir(join(source, "literal", "dir"), { recursive: true }); const body = "export const marker = 'literal';\n"; - await writeFile( - join(source, literal, "a.js"), - "import { marker } from './b.js'; export { marker };\n", - ); - await writeFile(join(source, literal, "b.js"), body); + await writeLoaderExportFixture(source, literal, body); await writeFile( join(source, "literal", "dir", "b.js"), "export const marker = 'decoy';\n", ); + // Node's CommonJS resolver uses the literal filesystem base on POSIX. + expect( + createRequire(join(source, literal, "a.cjs")).resolve("./b.js"), + ).toBe(join(source, literal, "b.js")); const input = format === "directory" ? source : join(root, "input.asar"); if (format === "asar") await createPackage(source, input); const result = await analyzeJavaScriptApplication({ @@ -195,13 +310,60 @@ it.skipIf(process.platform === "win32").each(["directory", "asar"] as const)( const parsed = javascriptApplicationAnalysisResultSchema.parse( result.value.normalized_result, ); - const imports = parsed.graph.edges.filter( - ({ properties }) => properties.specifier === "./b.js", - ); - expect(imports.length).toBeGreaterThan(0); - expect(imports.map(({ properties }) => properties.resolved_path)).toEqual( - imports.map(() => `${literal}/b.js`), - ); + const imports = loaderImports(parsed.graph); + expect(imports).toHaveLength(4); + expect( + new Set(imports.map(({ properties }) => properties.resolution_mode)), + ).toEqual(new Set(["require", "import", "compiler-dependent"])); + for (const { properties } of imports) { + const mode = properties.resolution_mode; + expect(properties).toMatchObject({ + resolved_path: mode === "require" ? `${literal}/b.js` : null, + resolution_status: + mode === "require" + ? "resolved" + : mode === "import" + ? "rejected" + : "unavailable", + }); + if (mode === "compiler-dependent") + expect(properties.conditional_resolutions).toEqual([ + expect.objectContaining({ + mode: "import", + resolution_status: "rejected", + resolved_path: null, + }), + expect.objectContaining({ + mode: "require", + resolution_status: "resolved", + resolved_path: `${literal}/b.js`, + }), + ]); + } + const exports = forwardedExports(parsed.graph); + expect(exports).toHaveLength(3); + for (const exported of exports) { + const sourceNode = parsed.graph.nodes.find( + ({ node_id }) => node_id === exported.source_node_id, + ); + const path = sourceNode?.observations.find( + ({ properties }) => typeof properties.module_path === "string", + )?.properties.module_path; + expect([ + `${literal}/a.cjs`, + `${literal}/a.mjs`, + `${literal}/a.ts`, + ]).toContain(path); + expect(exported.properties).toMatchObject({ + resolved_path: path === `${literal}/a.cjs` ? `${literal}/b.js` : null, + resolution_status: + path === `${literal}/a.cjs` + ? "resolved" + : path === `${literal}/a.mjs` + ? "rejected" + : "unavailable", + }); + } expect(parsed.graph.nodes).toEqual( expect.arrayContaining([ expect.objectContaining({ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts index 8d355d8c1f..798cc3258c 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptArtifactReconstruction.test.ts @@ -50,6 +50,404 @@ import { } from "../../../../src/domain/javascript/javascriptSemanticGraph.js"; import { parseJavaScriptSemanticGraph } from "../../../../src/domain/javascript/javascriptSemanticGraphSerialization.js"; import { writeJavaScriptArtifactFixture } from "../../../fixtures/javascriptArtifactApplication.js"; +import { writeFixtureFiles } from "../../../support/javascriptApplicationFixture.js"; + +it("does not let a known-absent optional import erase another application's page", async () => { + const root = await createTestTempDirectory("rea-absent-optional-owner-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "b/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("index.html");', + "b/main.cjs": 'try { require("./optional.cjs"); } catch {}', + "a/index.html": "valid page", + "index.html": "root decoy", + }); + const result = await reconstructJavaScriptArtifact({ + input_path: root, + format: "directory", + }); + const role = result.graph.nodes.find( + ({ kind }) => kind === "electron-renderer", + ); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: "a/index.html", + resolution_status: "resolved", + }), + }), + ]), + ); + expect( + result.graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role?.node_id && relation === "maps_to", + ), + ).toHaveLength(1); +}); + +it.each([ + "unselected", + "unavailable", + "dynamic", + "unavailable-json", + "unavailable-package-json", + "unavailable-uppercase-json", + "unavailable-main", + "unavailable-default-main", + "unavailable-json-main", + "resolved-html", + "resolved-uppercase-json", + "resolved-json", + "resolved-package-json", + "resolved-html-main", + "unavailable-package-metadata", + "unavailable-directory-main", +] as const)( + "distinguishes hidden imports from inert data through %s", + async (omission) => { + const root = await createTestTempDirectory("rea-competing-gap-"); + const targetPaths: Partial> = { + "unavailable-default-main": "b/index.js", + "unavailable-json-main": "b/config.json", + "unavailable-main": "b/main.cjs", + "unavailable-json": "b/config.json", + "unavailable-package-json": "b/node_modules/pkg/package.json", + "unavailable-uppercase-json": "shared/bridge.JSON", + "resolved-html": "shared/bridge.html", + "resolved-uppercase-json": "shared/bridge.JSON", + "resolved-json": "b/config.json", + "resolved-package-json": "b/node_modules/pkg/package.json", + "resolved-html-main": "b/main.html", + "unavailable-package-metadata": "b/node_modules/pkg/package.json", + "unavailable-directory-main": "b/node_modules/pkg/package.json", + }; + const targetPath = targetPaths[omission] ?? "shared/bridge.cjs"; + const inertJson = [ + "unavailable-json", + "unavailable-package-json", + "unavailable-json-main", + "resolved-json", + "resolved-package-json", + ].includes(omission); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "b/package.json": JSON.stringify( + omission === "unavailable-default-main" + ? {} + : { + main: + omission === "unavailable-directory-main" + ? "node_modules/pkg" + : omission === "resolved-html-main" + ? "main.html" + : omission === "unavailable-json-main" + ? "config.json" + : "main.cjs", + }, + ), + "b/index.js": 'require("../shared/window.cjs");', + "b/main.html": 'require("../shared/window.cjs");', + "a/main.cjs": 'require("../shared/window.cjs");', + "b/main.cjs": + omission === "unavailable-package-metadata" + ? 'require("pkg");' + : omission === "unavailable-main" + ? 'require("../shared/window.cjs");' + : omission === "dynamic" + ? "require(process.env.CHILD);" + : `require(${JSON.stringify(targetPath.startsWith("b/") ? `./${targetPath.slice(2)}` : `../${targetPath}`)});`, + "b/config.json": "{}", + "b/node_modules/pkg/package.json": "{}", + "b/node_modules/pkg/index.js": 'require("../../../shared/window.cjs");', + "shared/bridge.JSON": 'require("./window.cjs");', + "shared/bridge.html": 'require("./window.cjs");', + "shared/bridge.cjs": 'require("./window.cjs");', + "shared/window.cjs": + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("index.html");', + "a/index.html": "wrong unique owner", + "b/index.html": "competing owner", + "index.html": "root decoy", + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles( + reader, + snapshot, + undefined, + omission === "unselected" + ? [ + "a/package.json", + "b/package.json", + "a/main.cjs", + "b/main.cjs", + "shared/window.cjs", + ] + : undefined, + ); + const bridge = files.files.find(({ path }) => path === targetPath); + if (bridge === undefined) throw new Error("Missing bridge"); + const partial = !omission.startsWith("unavailable") + ? files + : { + ...files, + files: files.files.filter(({ path }) => path !== bridge.path), + unavailable_relevant_files: [ + ...files.unavailable_relevant_files, + { + path: bridge.path, + container_sha256: bridge.container_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: bridge.inventory_artifact_id, + kind: bridge.kind, + hash_status: "unavailable" as const, + }, + ], + }; + const graph = buildJavaScriptArtifactGraph( + snapshot, + partial, + analyzeJavaScriptArtifactFiles(partial), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: inertJson ? "a/index.html" : null, + resolution_status: inertJson ? "resolved" : "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === roles[0]?.node_id && relation === "maps_to", + ), + ).toHaveLength(inertJson ? 1 : 0); + } finally { + await reader.close(); + } + }, +); + +it.each(["unselected", "unavailable"] as const)( + "keeps imported loadFile ownership unknown across an %s module", + async (omission) => { + const root = await createTestTempDirectory("rea-missing-module-owner-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "../shared/main.cjs" }), + "shared/main.cjs": 'require("./window.cjs");', + "shared/window.cjs": + 'const { BrowserWindow } = require("electron"); const win = new BrowserWindow(); win.loadFile("index.html");', + "index.html": "artifact-root decoy", + "app/index.html": "unproven application page", + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles( + reader, + snapshot, + undefined, + omission === "unselected" + ? ["app/package.json", "shared/window.cjs"] + : undefined, + ); + const entry = files.files.find(({ path }) => path === "shared/main.cjs"); + if (entry === undefined) throw new Error("Missing inventoried entry"); + // The unavailable variant injects the reader boundary, not an actual I/O failure. + const partial = + omission === "unselected" + ? files + : { + ...files, + files: files.files.filter(({ path }) => path !== entry.path), + unavailable_relevant_files: [ + ...files.unavailable_relevant_files, + { + path: entry.path, + container_sha256: entry.container_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: entry.inventory_artifact_id, + kind: entry.kind, + hash_status: "unavailable" as const, + }, + ], + }; + const graph = buildJavaScriptArtifactGraph( + snapshot, + partial, + analyzeJavaScriptArtifactFiles(partial), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + if (role === undefined) throw new Error("Missing renderer"); + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ), + ).toEqual([]); + } finally { + await reader.close(); + } + }, +); + +it("keeps imported loadFile ownership unknown across an application projection omission", async () => { + const root = await createTestTempDirectory("rea-omitted-app-owner-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "../shared/main.cjs" }), + "shared/main.cjs": 'require("./window.cjs");', + "shared/window.cjs": + 'const { BrowserWindow } = require("electron"); const win = new BrowserWindow(); win.loadFile("index.html");', + "index.html": "artifact-root decoy", + "app/index.html": "unproven application page", + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + const entry = analysis.files.find( + ({ file }) => file.path === "shared/main.cjs", + ); + expect( + entry?.javascript?.references.some( + ({ specifier }) => specifier === "./window.cjs", + ), + ).toBe(true); + // Exercise the production omission boundary without bypassing its memory guard. + const graph = buildJavaScriptArtifactGraph(snapshot, files, { + ...analysis, + files: analysis.files.map((file) => + file.file.path === "shared/main.cjs" + ? { + ...file, + application_projection_failure: { + reason: "fixture projection omitted", + error: null, + limits: null, + }, + } + : file, + ), + }); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + if (role === undefined) throw new Error("Missing renderer"); + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ), + ).toEqual([]); + } finally { + await reader.close(); + } +}); + +it("keeps a loadFile owner unknown when a sibling manifest is unavailable in the inventory projection", async () => { + const root = await createTestTempDirectory("rea-unavailable-app-owner-"); + await writeFixtureFiles(root, { + "app/package.json": JSON.stringify({ main: "../shared/main.cjs" }), + "shared/main.cjs": + 'const { BrowserWindow } = require("electron"); const win = new BrowserWindow(); win.loadFile("index.html");', + "index.html": "artifact-root decoy", + "app/index.html": "unproven app page", + }); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const manifest = files.files.find( + ({ path }) => path === "app/package.json", + ); + if (manifest === undefined) throw new Error("Missing inventoried manifest"); + // Inject the reader boundary's unavailable projection, not a fabricated graph. + const partial = { + ...files, + files: files.files.filter(({ path }) => path !== manifest.path), + unavailable_relevant_files: [ + ...files.unavailable_relevant_files, + { + path: manifest.path, + container_sha256: manifest.container_sha256, + sha256: null, + bytes: null, + inventory_artifact_id: manifest.inventory_artifact_id, + kind: manifest.kind, + hash_status: "unavailable" as const, + }, + ], + }; + const graph = buildJavaScriptArtifactGraph( + snapshot, + partial, + analyzeJavaScriptArtifactFiles(partial), + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + const role = roles[0]; + if (role === undefined) throw new Error("Missing renderer"); + expect(role.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolved_path: null, + resolution_status: "unavailable", + }), + }), + ]), + ); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === role.node_id && relation === "maps_to", + ), + ).toEqual([]); + } finally { + await reader.close(); + } +}); it("keeps excluded non-JavaScript files out of both semantic construction paths", async () => { const root = await createTestTempDirectory("rea-js-semantic-applicability-"); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts index 89c582c508..e2cf14aa05 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptFileUrlPaths.test.ts @@ -8,10 +8,12 @@ import { describe, expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { staticPath } from "../../../../src/domain/javascript/javascriptStaticAnalysisHelpers.js"; +import { analyzeJavaScriptStaticSource } from "../../../../src/domain/javascript/javascriptStaticAnalysis.js"; import { analyzeJavaScriptApplication } from "../../../support/javascriptApplicationScope.js"; const urlCases = [ ["./preload%20name.cjs", "preload name.cjs", "main.mjs"], + ["./preload%252Fname.cjs", "preload%2Fname.cjs", "main.mjs"], ["./pr%C3%A9load.cjs", "préload.cjs", "main.mjs"], ["./preload%3F%23.cjs", "preload?#.cjs", "main.mjs"], ["./preload.cjs?cache=1#v2", "preload.cjs", "main.mjs"], @@ -60,7 +62,7 @@ describe("JavaScript file URL paths", () => { expect(await readFile(nativePath, "utf8")).toContain("real preload"); await writeFile( sourceFile, - `import { fileURLToPath } from "node:url";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath(new URL(${JSON.stringify(reference)}, import.meta.url)) } });\n`, + `import { fileURLToPath } from "node:url";\nimport { BrowserWindow } from "electron";\nnew BrowserWindow({ webPreferences: { preload: fileURLToPath(new URL(${JSON.stringify(reference)}, import.meta.url)) } });\n`, ); const result = await analyzeJavaScriptApplication({ @@ -85,7 +87,7 @@ describe("JavaScript file URL paths", () => { }, ); - it("resolves a decoded literal file URL at the filesystem boundary", async () => { + it("keeps a decoded absolute file URL unmapped without host ownership", async () => { const root = await createTestTempDirectory("rea-literal-file-url-"); await mkdir(join(root, "app"), { recursive: true }); await writeFile( @@ -109,10 +111,16 @@ describe("JavaScript file URL paths", () => { expect(preload?.observations).not.toHaveLength(0); for (const observation of preload?.observations ?? []) expect(observation.properties).toMatchObject({ - resolved_path: "app/preload name.cjs", - resolution_status: "resolved", + resolved_path: null, + resolution_status: "unavailable", resolution_context: "filesystem-expression", }); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === preload?.node_id && relation === "maps_to", + ), + ).toEqual([]); }); it.each(unknownExpressions)("leaves unsupported %s unknown", (expression) => { @@ -122,9 +130,15 @@ describe("JavaScript file URL paths", () => { }); it("does not URL-decode literal filesystem punctuation", () => { - const expression = 'path.join(__dirname, "preload%20?#.cjs")'; - expect( - staticPath(parseExpression(expression, { sourceType: "module" })), - ).toBe("preload%20?#.cjs"); + const analysis = analyzeJavaScriptStaticSource(` + const path = require('node:path'); + const {BrowserWindow} = require('electron'); + new BrowserWindow({webPreferences:{preload:path.join(__dirname,"preload%20?#.cjs")}}); + `); + expect(analysis.parse_error_count).toBe(0); + expect(analysis.electron.browser_windows).toHaveLength(1); + expect(analysis.electron.browser_windows[0]?.preload_path).toBe( + "preload%20?#.cjs", + ); }); }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts index 3e45278733..9a89e3d81b 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleRelationships.test.ts @@ -38,6 +38,59 @@ const relationshipGraph = async (root: string) => { } }; +it.each(["browser", "module", "renderer", "main"] as const)( + "distinguishes %s package metadata from an imported module's application owner", + async (field) => { + const root = await createTestTempDirectory("rea-frontend-package-owner-"); + await writeFixtureFiles(root, { + "package.json": JSON.stringify({ main: "main.cjs" }), + "main.cjs": 'require("./lib/window.cjs");', + "lib/package.json": JSON.stringify({ [field]: "window.cjs" }), + "lib/window.cjs": + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("index.html");', + "lib/orphan.cjs": + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("orphan.html");', + "index.html": "root application page", + "lib/index.html": "frontend package decoy", + "orphan.html": "root application fallback", + "lib/orphan.html": "frontend package fallback decoy", + }); + const graph = await relationshipGraph(root); + const actualMain = field === "main"; + for (const declaredPath of ["index.html", "orphan.html"]) { + const renderer = graph.nodes.find( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties }) => properties.declared_path === declaredPath, + ), + ); + expect(renderer?.observations[0]?.properties).toMatchObject({ + resolved_path: actualMain ? null : declaredPath, + resolution_status: actualMain ? "unavailable" : "resolved", + }); + expect( + graph.edges.filter( + ({ source_node_id, relation }) => + source_node_id === renderer?.node_id && relation === "maps_to", + ), + ).toHaveLength(actualMain ? 0 : 1); + } + if (!actualMain) + expect( + graph.nodes.some( + ({ kind, observations }) => + kind === "electron-renderer" && + observations.some( + ({ properties }) => + properties.mechanism === "package.json:renderer" && + properties.resolved_path === "lib/window.cjs", + ), + ), + ).toBe(true); + }, +); + it.each([ ["getter-expansion", getterExpansionSource()], [ diff --git a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts index 5e62327d0f..d8a86f0da6 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptModuleSpecifierPaths.test.ts @@ -398,15 +398,32 @@ describe("package exports URL path rejections", () => { ...crossContainer, container_sha256: "c".repeat(64), }); - expect( + const resolveTarget = () => resolveArtifactPathByContext({ declaredPath: "fixture", sourcePath: "app/main.js", context: "module-specifier", moduleKind, files, - }), - ).toMatchObject({ resolved_path: null, resolution_status: "not-found" }); + }); + expect(resolveTarget()).toMatchObject({ + resolved_path: null, + resolution_status: "unavailable", + candidate_path: target, + limitations: [ + expect.stringContaining("crosses an artifact container boundary"), + ], + }); + files.delete(target); + expect(resolveTarget()).toMatchObject({ + resolved_path: null, + resolution_status: "not-found", + }); + files.set(target, crossContainer); + expect(resolveTarget()).toMatchObject({ + resolved_path: target, + resolution_status: "resolved", + }); }, ); }); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts new file mode 100644 index 0000000000..cbf0fb1f21 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/javascriptNonStringPackageMain.test.ts @@ -0,0 +1,141 @@ +import { createRequire } from "node:module"; +import { join } from "node:path"; + +import { expect, it } from "vitest"; + +import { analyzeJavaScriptArtifactFiles } from "../../../../src/application/javascript/JavaScriptArtifactAnalysis.js"; +import { buildJavaScriptArtifactGraph } from "../../../../src/application/javascript/JavaScriptArtifactGraphBuilder.js"; +import { resolveArtifactPathByContext } from "../../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import { createJavaScriptArtifactReader } from "../../../../src/artifacts/javascript/JavaScriptArtifactReader.js"; +import { scanCanonicalArtifactInventory } from "../../../fixtures/artifactInventory.js"; +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { readJavaScriptArtifactFiles } from "../../../support/javascriptApplicationScope.js"; +import { writeFixtureFiles } from "../../../support/javascriptApplicationFixture.js"; + +const windowSource = + 'const {BrowserWindow}=require("electron");new BrowserWindow().loadFile("index.html");'; + +it.each( + [null, false, 123, {}, []].flatMap((main) => + [false, true].map((index) => ({ main, index, request: "./b" })), + ), +)( + "follows Node index admission for main=$main with index=$index", + async ({ main, index, request }) => { + const root = await createTestTempDirectory("rea-nonstring-package-main-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": index ? "module.exports={};" : windowSource, + "a/index.html": "first application page", + "b/package.json": JSON.stringify({ main }), + ...(index ? { "b/index.js": windowSource } : {}), + "b/index.html": "second application page", + "index.html": "artifact-root decoy", + }); + const require = createRequire(join(root, "node-control.cjs")); + if (index) expect(require.resolve(request)).toBe(join(root, "b/index.js")); + else + expect(() => require.resolve(request)).toThrow( + expect.objectContaining({ code: "MODULE_NOT_FOUND" }), + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + expect( + analysis.packages.find(({ path }) => path === "b/package.json"), + ).toMatchObject({ main_status: "invalid", main_entrypoint: "" }); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect(roles[0]?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: "resolved", + resolved_path: index ? "b/index.html" : "a/index.html", + }), + }), + ]), + ); + } finally { + await reader.close(); + } + }, +); + +it.each( + ( + [ + ["{", false], + ["[]", false], + ["[{}]", false], + ["123", false], + ['"root"', false], + ["null", false], + ["false", false], + ["{}", true], + ['{"main":[]}', true], + ] as const + ).map(([text, valid]) => ({ text, valid, request: "./b" })), +)( + "distinguishes malformed package root $text from a valid index fallback", + async ({ text, valid, request }) => { + const root = await createTestTempDirectory("rea-invalid-package-main-"); + await writeFixtureFiles(root, { + "a/package.json": JSON.stringify({ main: "main.cjs" }), + "a/main.cjs": windowSource, + "a/index.html": "first application page", + "b/package.json": text, + "b/index.js": "module.exports={};", + "index.html": "artifact-root decoy", + }); + const require = createRequire(join(root, "node-control.cjs")); + if (valid) expect(require.resolve(request)).toBe(join(root, "b/index.js")); + else + expect(() => require.resolve(request)).toThrow( + expect.objectContaining({ code: "ERR_INVALID_PACKAGE_CONFIG" }), + ); + const snapshot = await scanCanonicalArtifactInventory(root, {}); + const reader = createJavaScriptArtifactReader(root, "directory"); + try { + const files = await readJavaScriptArtifactFiles(reader, snapshot); + const analysis = analyzeJavaScriptArtifactFiles(files); + expect( + analysis.packages.find(({ path }) => path === "b/package.json"), + ).toMatchObject({ + status: valid ? "included" : "invalid", + main_entrypoint: valid ? "" : null, + }); + expect( + resolveArtifactPathByContext({ + sourcePath: "a/main.cjs", + declaredPath: "../b", + context: "module-specifier", + moduleKind: "require", + files: new Map(files.files.map((file) => [file.path, file])), + }), + ).toMatchObject({ + resolution_status: valid ? "resolved" : "unavailable", + resolved_path: valid ? "b/index.js" : null, + }); + const graph = buildJavaScriptArtifactGraph(snapshot, files, analysis); + const role = graph.nodes.find(({ kind }) => kind === "electron-renderer"); + expect(role?.observations).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + properties: expect.objectContaining({ + resolution_status: valid ? "resolved" : "unavailable", + resolved_path: valid ? "a/index.html" : null, + }), + }), + ]), + ); + } finally { + await reader.close(); + } + }, +); diff --git a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts index 643b8dcdd4..d6e25854a2 100644 --- a/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts +++ b/tests/boundary/filesystem/javascript-applications/javascriptRuntimeReconciliation.test.ts @@ -6,6 +6,7 @@ import { expect, it } from "vitest"; import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; import { createElectronActiveEvidence } from "../../../../src/application/javascript/ElectronActiveEvidence.js"; +import { javascriptApplicationAnalysisResultSchema } from "../../../../src/domain/javascript/javascriptApplicationAnalysis.js"; import { reconcileJavaScriptRuntimeEvidence } from "../../../../src/application/javascript/JavaScriptRuntimeReconciliationService.js"; import { createEvidence } from "../../../../src/domain/evidence.js"; import { mappedRuntimePaths } from "../../../../src/domain/javascript/javascriptRuntimeStaticCandidates.js"; @@ -728,6 +729,80 @@ it.skipIf(process.platform === "win32")( }, ); +it.each([false, true])( + "merges equivalent owned page declarations when target presence is %s", + async (present) => { + const root = await createTestTempDirectory("rea-generated-page-runtime-"); + await mkdir(join(root, "app"), { recursive: true }); + await writeFile( + join(root, "app", "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "app", "main.cjs"), + 'const {BrowserWindow} = require("electron"); const w = new BrowserWindow(); w.loadFile("generated.html"); w.loadFile("./generated.html");', + ); + if (present) + await writeFile(join(root, "app", "generated.html"), "generated page"); + const analysis = await analyzeFixture(root); + const { graph } = javascriptApplicationAnalysisResultSchema.parse( + analysis.normalized_result, + ); + const roles = graph.nodes.filter( + ({ kind }) => kind === "electron-renderer", + ); + expect(roles).toHaveLength(1); + expect( + roles[0]?.observations + .map(({ properties }) => properties.declared_path) + .sort(), + ).toEqual(["./generated.html", "generated.html"]); + const reconcileTarget = (path: string) => + reconcileInput({ + static_layers: [{ analysis, role: "application" }], + runtime_observations: [rendererCapture(root, path)], + }).reconciliations.find(({ entity_kind }) => entity_kind === "target"); + expect(reconcileTarget(join(root, "app", "generated.html"))).toMatchObject({ + status: "matched", + candidate_static_count: 1, + }); + expect(reconcileTarget(join(root, "generated.html"))).toMatchObject({ + status: "unmatched", + }); + }, +); + +it("matches a resolved nested application path without an operator mapping", async () => { + const root = await createTestTempDirectory("rea-owned-loadfile-runtime-"); + await mkdir(join(root, "app", "dist"), { recursive: true }); + await mkdir(join(root, "dist"), { recursive: true }); + await writeFile( + join(root, "app", "package.json"), + JSON.stringify({ main: "main.cjs" }), + ); + await writeFile( + join(root, "app", "main.cjs"), + "const { BrowserWindow } = require('electron'); const win = new BrowserWindow(); win.loadFile('dist/index.html');", + ); + await writeFile( + join(root, "app", "dist", "index.html"), + "application target", + ); + await writeFile(join(root, "dist", "index.html"), "root decoy"); + const analysis = await analyzeFixture(root); + const reconcileTarget = (path: string) => + reconcileInput({ + static_layers: [{ analysis, role: "application" }], + runtime_observations: [rendererCapture(root, path)], + }).reconciliations.find(({ entity_kind }) => entity_kind === "target"); + expect( + reconcileTarget(join(root, "app", "dist", "index.html")), + ).toMatchObject({ status: "matched" }); + expect(reconcileTarget(join(root, "dist", "index.html"))).toMatchObject({ + status: "unmatched", + }); +}); + it("keeps a raw nested loadFile target application-relative when the producer also resolves a nested decoy", async () => { const root = await createTestTempDirectory("rea-raw-loadfile-ownership-"); await mkdir(join(root, "dist")); diff --git a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts index afccab83de..4ec345dab7 100644 --- a/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts +++ b/tests/boundary/filesystem/javascriptArtifactGraphObservationLookup.test.ts @@ -62,6 +62,8 @@ it("uses the first structured observation when paths repeat", async () => { { ...packageObservation, status: "invalid", + main_status: "unavailable", + main_entrypoint: null, name: null, version: null, main: null, diff --git a/tests/boundary/javascript/loaderInvocationMethodAuthority.test.ts b/tests/boundary/javascript/loaderInvocationMethodAuthority.test.ts new file mode 100644 index 0000000000..216f57fbab --- /dev/null +++ b/tests/boundary/javascript/loaderInvocationMethodAuthority.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { expect, it } from "vitest"; + +import { analyzeJavaScriptStaticSource } from "../../../src/domain/javascript/javascriptStaticAnalysis.js"; + +it.each( + (["call", "apply"] as const).flatMap((method) => + (["unmodified", "mutated", "shadowed"] as const).map((authority) => ({ + method, + authority, + })), + ), +)( + "preserves native $method authority when Function is $authority", + ({ method, authority }) => { + const setup = + "import {createRequire as make} from 'node:module';const load=make(import.meta.url);"; + const mutation = + authority === "unmodified" + ? "" + : `${authority === "shadowed" ? "const Function={prototype:{}};" : ""}Function.prototype.${method}=()=>({kind:'local'});`; + const invocation = `load.${method}(null,${method === "call" ? "'node:path'" : "['node:path']"})`; + const observed = execFileSync( + process.execPath, + [ + "--input-type=module", + "--eval", + `${setup}const original=globalThis.Function.prototype.${method};${mutation}let out;try{out=${invocation};}finally{globalThis.Function.prototype.${method}=original;}console.log(JSON.stringify({native:typeof out.join==='function',local:out.kind==='local'}));`, + ], + { encoding: "utf8", timeout: 5_000 }, + ); + // Native createRequire and Function.prototype belong to the same child + // realm; injecting a host function into a VM would test another prototype. + expect(JSON.parse(observed) as unknown).toEqual({ + native: authority !== "mutated", + local: authority === "mutated", + }); + const analysis = analyzeJavaScriptStaticSource( + `${setup}${mutation}${invocation};`, + ); + expect(analysis.parse_error_count).toBe(0); + expect( + analysis.references.filter(({ kind }) => kind === "require"), + ).toEqual([ + expect.objectContaining({ + specifier: authority === "mutated" ? null : "node:path", + }), + ]); + }, +); diff --git a/tests/boundary/javascript/mainModuleSource.test.ts b/tests/boundary/javascript/mainModuleSource.test.ts new file mode 100644 index 0000000000..2e1528ec1c --- /dev/null +++ b/tests/boundary/javascript/mainModuleSource.test.ts @@ -0,0 +1,50 @@ +import { execFileSync } from "node:child_process"; +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; + +it.each([ + ["native main", "require.main.require('node:path');", 1], + ["captured main", "const main=require.main;main.require('node:path');", 1], + ["native process main", "process.mainModule.require('node:path');", 1], + [ + "captured process main", + "const main=process.mainModule;main.require('node:path');", + 1, + ], + [ + "captured process delegate", + "const main=process.mainModule;const load=main.require;load.call(main,'node:path');", + 1, + ], + [ + "shared deep child local write", + "const old={child:{inner:{make:()=>module}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>({require(){}});box.child.inner.make().require('node:path');", + 0, + ], + [ + "shared deep child host write", + "const old={child:{inner:{make:()=>({})}}};const box={...old};old.child={inner:box.child.inner};old.child.inner.make=()=>module;box.child.inner.make().require('node:path');", + 1, + ], +] as const)( + "observes real CommonJS Module loading for %s", + async (_name, source, loads) => { + const root = await createTestTempDirectory("rea-main-module-source-"); + const entry = join(root, "entry.cjs"); + await writeFile( + entry, + `if(process.mainModule!==module||require.main!==module)throw Error('Native main-module identity changed');const original=module.require;let loads=0;module.require=function(...args){loads++;return Reflect.apply(original,this,args)};const out=eval(${JSON.stringify(source)});process.stdout.write(JSON.stringify({loads,native:typeof out?.join==='function'}));`, + ); + const observed = execFileSync(process.execPath, [entry], { + encoding: "utf8", + timeout: 5_000, + }); + expect(JSON.parse(observed) as unknown).toEqual({ + loads, + native: loads === 1, + }); + }, +); diff --git a/tests/boundary/javascript/packageConditionalExports.test.ts b/tests/boundary/javascript/packageConditionalExports.test.ts index 64b762072b..e7512b847c 100644 --- a/tests/boundary/javascript/packageConditionalExports.test.ts +++ b/tests/boundary/javascript/packageConditionalExports.test.ts @@ -66,6 +66,7 @@ const compareWithNode = async ( readonly specifier?: string; readonly main?: string; readonly metadataText?: string; + readonly metadataAbsent?: boolean; readonly sourceWithinPackage?: string; readonly packageFiles?: Readonly>; } = {}, @@ -91,11 +92,11 @@ const compareWithNode = async ( exports: exportsMap, main: options.main, }); - await writeFile(join(packageDirectory, "package.json"), metadata); - const contents = new Map([ - [sourcePath, ""], - [`node_modules/${packageName}/package.json`, metadata], - ]); + const contents = new Map([[sourcePath, ""]]); + if (!options.metadataAbsent) { + await writeFile(join(packageDirectory, "package.json"), metadata); + contents.set(`node_modules/${packageName}/package.json`, metadata); + } if (options.siblingFile === true) { const filename = `${packageName}.js`; const source = `module.exports = ${JSON.stringify(filename)};`; @@ -320,13 +321,23 @@ describe("package exports before sibling files", () => { }, ); - it("does not let another container's exports override a local sibling file", async () => { - const { files } = await compareWithNode( - "./default.cjs", - "require", - TARGETS, - { siblingFile: true }, - ); + it("keeps an unmapped package container unknown despite a local sibling file", async () => { + const { + node, + node_error, + outcome: mapped, + files, + } = await compareWithNode("./default.cjs", "require", TARGETS, { + siblingFile: true, + }); + expect(node).toBe("default.cjs"); + expect(node_error).toBeNull(); + expect(mapped).toMatchObject({ + resolved_path: "node_modules/example/default.cjs", + resolution_status: "resolved", + }); + // The Node control has one runtime directory. A different inventoried + // container without its loader mapping cannot prove the metadata absent. const outcome = resolveArtifactPathByContext({ declaredPath: "example", sourcePath: "main.js", @@ -342,10 +353,37 @@ describe("package exports before sibling files", () => { ), }); expect(outcome).toMatchObject({ - resolved_path: "node_modules/example.js", - resolution_status: "resolved", + resolved_path: null, + resolution_status: "unavailable", + limitations: [ + expect.stringContaining( + "without an established runtime loader mapping", + ), + ], }); }); + + it.each([undefined, "main.js"])( + "uses the real sibling when metadata is absent with package-local source %s", + async (sourceWithinPackage) => { + const { node, node_error, outcome } = await compareWithNode( + "./default.cjs", + "require", + TARGETS, + { + siblingFile: true, + metadataAbsent: true, + ...(sourceWithinPackage === undefined ? {} : { sourceWithinPackage }), + }, + ); + expect(node).toBe("example.js"); + expect(node_error).toBeNull(); + expect(outcome).toMatchObject({ + resolved_path: "node_modules/example.js", + resolution_status: "resolved", + }); + }, + ); }); describe("scalar package exports metadata", () => { it.each([false, true, 0, 42])( @@ -564,13 +602,22 @@ describe("package self-reference scope boundaries", () => { }); }); - it("does not apply self exports from another container", async () => { - const { files } = await compareWithNode( - "./specific.cjs", - "require", - TARGETS, - { sourceWithinPackage: "main.js", siblingFile: true }, - ); + it("keeps unmapped self metadata unknown rather than selecting a sibling", async () => { + const { + node, + node_error, + outcome: mapped, + files, + } = await compareWithNode("./specific.cjs", "require", TARGETS, { + sourceWithinPackage: "main.js", + siblingFile: true, + }); + expect(node).toBe("specific.cjs"); + expect(node_error).toBeNull(); + expect(mapped).toMatchObject({ + resolved_path: "node_modules/example/specific.cjs", + resolution_status: "resolved", + }); const outcome = resolveArtifactPathByContext({ declaredPath: "example", sourcePath: "node_modules/example/main.js", @@ -586,8 +633,13 @@ describe("package self-reference scope boundaries", () => { ), }); expect(outcome).toMatchObject({ - resolved_path: "node_modules/example.js", - resolution_status: "resolved", + resolved_path: null, + resolution_status: "unavailable", + limitations: [ + expect.stringContaining( + "without an established runtime loader mapping", + ), + ], }); }); }); diff --git a/tests/boundary/javascript/packageImportAliases.test.ts b/tests/boundary/javascript/packageImportAliases.test.ts index 64da6c235b..3743c4f3ec 100644 --- a/tests/boundary/javascript/packageImportAliases.test.ts +++ b/tests/boundary/javascript/packageImportAliases.test.ts @@ -1,7 +1,6 @@ import { execFile } from "node:child_process"; -import { mkdir, writeFile } from "node:fs/promises"; import { createRequire } from "node:module"; -import { dirname, join, relative } from "node:path"; +import { join, relative } from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; @@ -14,6 +13,7 @@ import { } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; const execute = promisify(execFile); const nodeOutcomeSchema = z.strictObject({ @@ -47,16 +47,8 @@ const writeTree = async ( root: string, files: Readonly>, ): Promise> => { - const contents = new Map(); - await Promise.all( - Object.entries(files).map(async ([path, value]) => { - const absolute = join(root, path); - await mkdir(dirname(absolute), { recursive: true }); - await writeFile(absolute, value); - contents.set(path, value); - }), - ); - return contents; + await writeFixtureFiles(root, files); + return new Map(Object.entries(files)); }; const nodeRequireTarget = async ( @@ -445,7 +437,7 @@ describe("package import target arrays compared with Node", () => { moduleKind, ); expect(result).toMatchObject({ - resolution_status: "external", + resolution_status: "unavailable", resolved_path: null, }); expect(result.limitations.join(" ")).toContain( @@ -458,7 +450,9 @@ describe("package import target arrays compared with Node", () => { describe("package import target URL normalization compared with Node", () => { it("normalizes targets before package-root validation and array selection", async () => { const root = await createTestTempDirectory("rea-package-target-url-"); - const directory = "app space#?%λ"; + // Windows forbids '?' in filenames; the suffix target below covers URL queries. + const directory = + process.platform === "win32" ? "app space#%λ" : "app space#?%λ"; const imports = { "#tab-escape": ["./.\t./outside.cjs", "./fallback.cjs"], "#newline-escape": ["./.%\n2e/outside.cjs", "./fallback.cjs"], @@ -710,9 +704,6 @@ describe("package import alias refusals compared with Node", () => { imports: { "#config": "./config.cjs", "#blocked": null, - "#": "./config.cjs", - "#/": "./config.cjs", - "#config/": "./config.cjs", }, }), "main.cjs": "module.exports = 1;", diff --git a/tests/boundary/javascript/packageMainHostPaths.test.ts b/tests/boundary/javascript/packageMainHostPaths.test.ts new file mode 100644 index 0000000000..5f5577e4a9 --- /dev/null +++ b/tests/boundary/javascript/packageMainHostPaths.test.ts @@ -0,0 +1,71 @@ +import { createRequire } from "node:module"; +import { join } from "node:path"; +import { expect, it } from "vitest"; + +import { resolveArtifactPathByContext } from "../../../src/application/javascript/JavaScriptArtifactPathResolution.js"; +import type { JavaScriptArtifactFile } from "../../../src/domain/javascript/javascriptArtifactFiles.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { writeFixtureFiles } from "../../support/workspace/writeFixtureFiles.js"; + +it.each(["absolute-host", "relative-package"] as const)( + "compares %s package main with the native Node resolver", + async (kind) => { + const root = await createTestTempDirectory("rea-package-main-host-"); + const artifact = join(root, "artifact"); + const host = join(root, "host/window.cjs"); + const main = kind === "absolute-host" ? host : "entry.cjs"; + const contents = { + "app/main.cjs": "throw new Error('Analyzed input must never execute');", + "app/node_modules/fixture/package.json": JSON.stringify({ main }), + "app/node_modules/fixture/entry.cjs": + "throw new Error('Package input must never execute');", + "app/node_modules/fixture/index.js": + "throw new Error('Index decoy must never execute');", + "shared/window.cjs": + "throw new Error('Artifact-root decoy must never execute');", + }; + await writeFixtureFiles(root, { + "host/window.cjs": "throw new Error('Host input must never execute');", + ...Object.fromEntries( + Object.entries(contents).map(([path, value]) => [ + `artifact/${path}`, + value, + ]), + ), + }); + const loader = createRequire(join(artifact, "app/main.cjs")); + expect(loader.resolve("fixture")).toBe( + kind === "absolute-host" + ? host + : join(artifact, "app/node_modules/fixture/entry.cjs"), + ); + const files = new Map( + Object.entries(contents).map(([path, value]) => [ + path, + { + path, + container_sha256: "a".repeat(64), + sha256: "b".repeat(64), + bytes: Buffer.byteLength(value), + inventory_artifact_id: `fixture-${path}`, + kind: path.endsWith("package.json") ? "package-json" : "javascript", + unpacked: false, + text: { included: true, value }, + }, + ]), + ); + expect( + resolveArtifactPathByContext({ + declaredPath: "fixture", + sourcePath: "app/main.cjs", + context: "module-specifier", + moduleKind: "require", + files, + }), + ).toMatchObject({ + resolution_status: kind === "absolute-host" ? "unavailable" : "resolved", + resolved_path: + kind === "absolute-host" ? null : "app/node_modules/fixture/entry.cjs", + }); + }, +); diff --git a/tests/boundary/process/processCaptureLifecycle.test.ts b/tests/boundary/process/processCaptureLifecycle.test.ts index 8775431b00..58e3c39ee8 100644 --- a/tests/boundary/process/processCaptureLifecycle.test.ts +++ b/tests/boundary/process/processCaptureLifecycle.test.ts @@ -11,7 +11,7 @@ import { import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; -import { expect, vi } from "vitest"; +import { expect, onTestFinished, vi } from "vitest"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { itWithCaptureCapability } from "./processCaptureCapability.js"; @@ -181,6 +181,16 @@ itWithCaptureCapability( const controller = new AbortController(); let fallback: ReturnType | undefined; let reachedRunning = false; + let fallbackFired = false; + const clearFallback = () => { + clearTimeout(fallback); + fallback = undefined; + }; + onTestFinished(() => { + controller.abort(); + clearFallback(); + now.mockRestore(); + }); try { const result = await captureProcessScenario( parseProcessScenario({ @@ -200,20 +210,30 @@ itWithCaptureCapability( if (update.phase === "running" && !reachedRunning) { reachedRunning = true; wallNow -= 60_000; - fallback = setTimeout(() => controller.abort(), 1_500); + // The deadline starts after native ownership preparation and + // executable inspection. Bound only the same running interval. + fallback = setTimeout(() => { + fallbackFired = true; + controller.abort(); + }, 1_500); } else if ( update.phase === "settling" || update.phase === "cleanup" ) - clearTimeout(fallback); + clearFallback(); }, }, ); expect(reachedRunning).toBe(true); + expect( + fallbackFired, + "the monotonic deadline precedes the caller safety abort", + ).toBe(false); const { capture } = captureObservations(result); expect(capture.exit.reason).toBe("timeout"); } finally { - clearTimeout(fallback); + controller.abort(); + clearFallback(); now.mockRestore(); } }, diff --git a/tests/fixtures/javascriptAliasSelectionBudget.ts b/tests/fixtures/javascriptAliasSelectionBudget.ts new file mode 100644 index 0000000000..16289b6875 --- /dev/null +++ b/tests/fixtures/javascriptAliasSelectionBudget.ts @@ -0,0 +1,10 @@ +/** Branching inert selectors exceed precise alias work without target execution. */ +export const aliasSelectionBudgetSource = (): string => { + const source = ["const a0 = {};"]; + for (let index = 1; index <= 30; index++) + source.push( + `const a${index} = flag ? a${index - 1}?.x : a${index - 1}?.y;`, + ); + source.push('a30?.require("./shared.cjs");'); + return source.join("\n"); +}; diff --git a/tests/fixtures/javascriptCapturedGenericInvocations.ts b/tests/fixtures/javascriptCapturedGenericInvocations.ts new file mode 100644 index 0000000000..45994226e8 --- /dev/null +++ b/tests/fixtures/javascriptCapturedGenericInvocations.ts @@ -0,0 +1,26 @@ +const callChains = [ + ["call.call", "other,null"], + ["apply.call", "other,null,[]"], + ["call.apply", "other,[null]"], + ["apply.apply", "other,[null,[]]"], + ["call.call.call", "other,null"], +] as const; + +/** Authored fixtures only; runtime execution is confined to their unit oracle. */ +export const capturedGenericInvocations = (["call", "apply"] as const).flatMap( + (captured) => + callChains.flatMap(([chain, arguments_]) => + [true, false].map((host) => { + const local = "({require(){return 'local';}})"; + const expression = `invoke.${chain}(${arguments_}).require('../shared/window.cjs')`; + return { + name: `captured ${captured} via ${chain} to ${host ? "host" : "local"}`, + captured, + chain, + host, + expression, + source: `const get=()=>${host ? local : "module"};const invoke=get.${captured};const other=()=>${host ? "module" : local};${expression};`, + }; + }), + ), +); diff --git a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts index 8f13b7af2a..3370fa4d42 100644 --- a/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts +++ b/tests/process-global/filesystemSnapshotFallbackCancellation.test.ts @@ -101,7 +101,12 @@ it.each(["hash", "snapshot"] as const)( }), controller.signal, ); - const failure = await result.catch((cause: unknown) => cause); + const failure: unknown = await result.catch((cause: unknown) => cause); + expect(failure).toMatchObject({ + _tag: "ProcessCaptureError", + reason: "cancelled", + userCategory: "cancelled", + }); if (operation === "hash") { expect(failure).toBeInstanceOf(ProcessCaptureReadFailure); const read = failure as ProcessCaptureReadFailure; diff --git a/tests/process-global/filesystemSnapshotRaces.test.ts b/tests/process-global/filesystemSnapshotRaces.test.ts index b684cf371d..bc216dd2e8 100644 --- a/tests/process-global/filesystemSnapshotRaces.test.ts +++ b/tests/process-global/filesystemSnapshotRaces.test.ts @@ -34,9 +34,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { return original.readlink(...args); } if (path === race.target && race.mode === "readlink_eio") - throw Object.assign(new Error("injected unexpected readlink failure"), { - code: "EIO", - }); + throw race.reason; if (path === race.target && race.mode === "readlink_eacces") throw Object.assign(new Error("injected denied readlink"), { code: "EACCES", @@ -407,11 +405,17 @@ it.skipIf(process.platform === "win32")( }); race.mode = "readlink_eio"; race.target = link; + const reason = Object.assign( + new Error("injected unexpected readlink failure"), + { code: "EIO" }, + ); + race.reason = reason; const cause = await snapshotRoots(scenario).catch( (failure: unknown) => failure, ); expect(cause).toBeInstanceOf(ProcessCaptureSnapshotFailure); const failure = cause as ProcessCaptureSnapshotFailure; + expect(failure.cause).toBe(reason); expect(failure.cause).toMatchObject({ code: "EIO" }); expect(failure.userCategory).toBeUndefined(); expect(failure.snapshot).toMatchObject({ diff --git a/tests/process-global/javascriptRuntimeObservationCli.test.ts b/tests/process-global/javascriptRuntimeObservationCli.test.ts index 531d6e1c90..0490195e80 100644 --- a/tests/process-global/javascriptRuntimeObservationCli.test.ts +++ b/tests/process-global/javascriptRuntimeObservationCli.test.ts @@ -157,7 +157,7 @@ describe("JavaScript runtime observation CLI parity", () => { { exit: () => {}, stdout: (chunk) => (stdout += chunk) }, ); - expect(closeAttempts).toBe(1); + expect(closeAttempts, stdout).toBe(1); expect(JSON.parse(stdout)).toMatchObject({ code: "cleanup_incomplete", details: { diff --git a/tests/process-global/processSnapshotCleanupMcp.test.ts b/tests/process-global/processSnapshotCleanupMcp.test.ts index c277bdb381..7d5ef6ce8f 100644 --- a/tests/process-global/processSnapshotCleanupMcp.test.ts +++ b/tests/process-global/processSnapshotCleanupMcp.test.ts @@ -1,5 +1,4 @@ -import { mkdir, mkdtemp, open, rm, writeFile } from "node:fs/promises"; -import { tmpdir } from "node:os"; +import { mkdir, open, rm, writeFile } from "node:fs/promises"; import { join, resolve } from "node:path"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; @@ -32,6 +31,7 @@ vi.mock("node:fs/promises", async (importOriginal) => { }; }); +import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { createTestBinarySession } from "../fixtures/binarySession.js"; import { parseMcpToolError } from "../fixtures/mcpToolError.js"; import type { AnalysisClient } from "../../src/application/AnalysisProvider.js"; @@ -62,7 +62,7 @@ const connectSession = async () => { }; it("retains and retries the exact snapshot file handle through public MCP capture", async () => { - const root = await mkdtemp(join(tmpdir(), "rea-process-snapshot-cleanup-")); + const root = await createTestTempDirectory("rea-process-snapshot-cleanup-"); const observedRoot = join(root, "observed"); const filePath = join(observedRoot, "input.txt"); await mkdir(observedRoot); @@ -87,7 +87,7 @@ it("retains and retries the exact snapshot file handle through public MCP captur try { const failed = parseMcpToolError(await capture()); - expect(injected.opens).toBe(1); + expect(injected.opens, JSON.stringify(failed)).toBe(1); expect(injected.handle?.fd).toBeGreaterThanOrEqual(0); expect(failed.error).toMatchObject({ code: "cleanup_incomplete", @@ -120,7 +120,7 @@ it("retains and retries the exact snapshot file handle through public MCP captur expect(retryBlocked.error.details).toMatchObject({ resources: [injected.path], }); - expect(injected.opens).toBe(1); + expect(injected.opens, JSON.stringify(retryBlocked)).toBe(1); expect(injected.handle?.fd).toBeGreaterThanOrEqual(0); injected.failClose = false; diff --git a/tests/support/cli/javascriptApplication.ts b/tests/support/cli/javascriptApplication.ts index 0ec600e172..59bb317f2d 100644 --- a/tests/support/cli/javascriptApplication.ts +++ b/tests/support/cli/javascriptApplication.ts @@ -26,12 +26,26 @@ export const analyzeJavaScriptDirectory = async ( surface: "CLI" | "stdio MCP", cli: TestCli, root: string, - mcpCall?: Awaited>["call"], + { + sourcePaths = [], + mcpCall, + }: { + readonly sourcePaths?: readonly string[]; + readonly mcpCall?: + | Awaited>["call"] + | undefined; + } = {}, ) => { let document: unknown; if (surface === "CLI") { const response = await cli.run({ - arguments: ["analyze-javascript-application", root, "--format", "json"], + arguments: [ + "analyze-javascript-application", + root, + ...sourcePaths.flatMap((path) => ["--source-path", path]), + "--format", + "json", + ], environment: { REA_LOG_LEVEL: "silent" }, }); expect(response.exitCode, response.stderr).toBe(0); @@ -41,6 +55,7 @@ export const analyzeJavaScriptDirectory = async ( const response = await call("analyze_javascript_application", { input_path: root, format: "directory", + ...(sourcePaths.length === 0 ? {} : { source_paths: sourcePaths }), }); expect(response.isError, JSON.stringify(response)).not.toBe(true); document = response.structuredContent; diff --git a/tests/support/javascriptApplicationFixture.ts b/tests/support/javascriptApplicationFixture.ts index 45afb3a82f..b8f4704c08 100644 --- a/tests/support/javascriptApplicationFixture.ts +++ b/tests/support/javascriptApplicationFixture.ts @@ -1,6 +1,3 @@ -import { mkdir, writeFile } from "node:fs/promises"; -import { dirname, join } from "node:path"; - import { reconstructJavaScriptArtifact } from "./javascriptApplicationScope.js"; import type { ApplicationEdge, @@ -8,19 +5,7 @@ import type { } from "../../src/domain/javascript/javascriptApplicationGraphSchemas.js"; import type { JavaScriptApplicationGraph } from "../../src/domain/javascript/javascriptApplicationGraph.js"; -/** Write text fixture files, creating parent directories as needed. */ -export const writeFixtureFiles = async ( - root: string, - files: Readonly>, -): Promise => { - await Promise.all( - Object.entries(files).map(async ([relative, contents]) => { - const path = join(root, relative); - await mkdir(dirname(path), { recursive: true }); - await writeFile(path, contents); - }), - ); -}; +export { writeFixtureFiles } from "./workspace/writeFixtureFiles.js"; /** Reconstruct one JavaScript artifact fixture directory. */ export const reconstructJavaScriptFixture = ( diff --git a/tests/support/workspace/writeFixtureFiles.ts b/tests/support/workspace/writeFixtureFiles.ts new file mode 100644 index 0000000000..4d0c3715c8 --- /dev/null +++ b/tests/support/workspace/writeFixtureFiles.ts @@ -0,0 +1,20 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; + +/** Write text fixture files, creating parent directories as needed. */ +export const writeFixtureFiles = async ( + root: string, + files: Readonly>, +): Promise => { + const entries = Object.entries(files).map(([relative, contents]) => ({ + path: join(root, relative), + contents, + })); + // Concurrent recursive mkdir calls can race for shared ancestors on Windows. + for (const directory of new Set(entries.map(({ path }) => dirname(path)))) { + await mkdir(directory, { recursive: true }); + } + await Promise.all( + entries.map(({ path, contents }) => writeFile(path, contents)), + ); +};