From 2c27c2a43b522b873be598d6ce19a3e6d7f77e02 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Mon, 9 Nov 2026 20:16:18 +0800 Subject: [PATCH] feat(electron): report V8 cachedData compilation boundaries (#9501) * feat(electron): report V8 cachedData execution boundaries analyze-javascript-application treated new vm.Script(source, {cachedData}) and vm.compileFunction(..., {cachedData}) as ordinary source. V8 deserializes previously compiled bytecode for those calls, so an Electron preload wrapper that delegates its whole contextBridge and IPC surface to a .cache/.jsc snapshot appeared to have been fully analyzed with zero exposed APIs. Record each vm compilation with its inert cache path when the cachedData value traces to a readFileSync/readFile of path syntax, and project a cached-data execution unknown plus a loads edge from the owning source. Compilations without a cache stay recorded as no-cached-data so absence remains distinguishable from an unparsed snapshot. Count cachedData executions in the Electron summary and state the coverage limit in the graph limitations. * style(cli): wrap the native data type path description The `inspect-native-data-type` argument description line is 82 columns, over the repository's 80-column `.oxfmtrc.json` limit, so `npm run format:check` fails on main and on every branch merged from it. It came in with #9465 and the following commits to this file did not touch it. Reformat the one call the formatter objects to; no behaviour change. * style(domain): reformat the pending dynamic-code accumulator field `npm run format:check` fails on this branch, which is what `Static checks` reports: oxfmt wants the `pendingDynamicCode` union on one line. Applying it is the whole change; no behaviour is touched. * fix(electron): preserve VM callable and cache source uncertainty * docs(javascript): specify cached-data compilation boundaries * fix(electron): exclude disabled caches from recovered boundaries --------- Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com> Co-authored-by: morluto <76467478+morluto@users.noreply.github.com> --- .../javascript-artifact-reconstruction.md | 19 ++ docs/specs/javascript/v8-cached-data/plan.md | 10 + docs/specs/javascript/v8-cached-data/spec.md | 43 ++++ docs/specs/javascript/v8-cached-data/tasks.md | 10 + .../javascript/ElectronBoundaryAnalysis.ts | 14 +- .../javascript/ElectronBoundaryGraph.ts | 2 + .../ElectronBoundaryGraphDynamicCode.ts | 156 ++++++++++++ .../javascript/JavaScriptArtifactAnalysis.ts | 3 +- .../JavaScriptArtifactGraphBuilder.ts | 10 + .../javascript/electronDynamicCodeFacts.ts | 6 + .../javascript/electronStaticAnalysis.ts | 2 + .../electronStaticAnalysisDynamicCode.test.ts | 187 ++++++++++++++ .../electronStaticAnalysisDynamicCode.ts | 239 ++++++++++++++++++ .../javascript/electronStaticAnalysisTypes.ts | 42 +++ .../javascriptApplicationAnalysis.ts | 2 + ...avascriptApplicationProjectionResources.ts | 3 +- .../javascriptSemanticDataEffectHelpers.ts | 1 + .../javascript/javascriptStaticAnalysis.ts | 10 + .../javascriptStaticAnalysisState.ts | 5 + .../JavaScriptAnalysisWorkerSchemas.ts | 33 ++- .../electronVmCachedData.test.ts | 189 ++++++++++++++ 21 files changed, 982 insertions(+), 4 deletions(-) create mode 100644 docs/specs/javascript/v8-cached-data/plan.md create mode 100644 docs/specs/javascript/v8-cached-data/spec.md create mode 100644 docs/specs/javascript/v8-cached-data/tasks.md create mode 100644 src/application/javascript/ElectronBoundaryGraphDynamicCode.ts create mode 100644 src/domain/javascript/electronDynamicCodeFacts.ts create mode 100644 src/domain/javascript/electronStaticAnalysisDynamicCode.test.ts create mode 100644 src/domain/javascript/electronStaticAnalysisDynamicCode.ts create mode 100644 tests/boundary/filesystem/javascript-applications/electronVmCachedData.test.ts diff --git a/docs/guides/javascript/javascript-artifact-reconstruction.md b/docs/guides/javascript/javascript-artifact-reconstruction.md index 68a773a18f..74960c2e9b 100644 --- a/docs/guides/javascript/javascript-artifact-reconstruction.md +++ b/docs/guides/javascript/javascript-artifact-reconstruction.md @@ -526,6 +526,25 @@ If fingerprint construction reaches its structural bound, the fingerprint is unavailable and its status is `truncated`; a bounded prefix is never presented as a complete stable fingerprint. +## Cached-data compilation boundaries + +The [feature specification](../../specs/javascript/v8-cached-data/spec.md) defines +the static admission and uncertainty contract. + +A `node:vm` compilation reports whether `cachedData` is declared, absent or +unknown. Explicitly supplied undefined bytes are treated as absent; shadowed +identifiers remain declarations. Source syntax alone does not establish valid cache bytes, V8 acceptance +or execution. The summary counts declared compilations, not executions. + +A proven synchronous filesystem read or awaited promise-based read supplies a +cache path. The producer retains its path base: source-directory expressions +can name inventoried artifacts, while relative literal paths depend on the +target working directory and remain unresolved. Other supplied values retain +their bounded source expression. Declared paths, exact-file lookup +status, candidate paths and resolution limitations remain separate; an unresolved +path never becomes a resolved file. Opaque inventoried cache bytes can remain +unavailable to the JavaScript resolver even when their occurrence is known. + ## Authority and unknowns API identity is a derivation: a familiar method or property name does not diff --git a/docs/specs/javascript/v8-cached-data/plan.md b/docs/specs/javascript/v8-cached-data/plan.md new file mode 100644 index 0000000000..3b3bb2c17c --- /dev/null +++ b/docs/specs/javascript/v8-cached-data/plan.md @@ -0,0 +1,10 @@ +# Implementation plan + +1. Derive VM callable identity and intact option objects from existing semantic + owners after mutation indexing; retain unresolved spreads as unknown. +2. Retain proven cache reads and their parsed bases through the named worker + contract. Resolve only source-directory paths against admitted inventory. +3. Project declarations into explicit graph unknowns and compilation summaries, + preserving source ranges, unresolved paths and execution limitations. +4. Verify negative mutation/spread controls and public filesystem workflows; + document runtime qualification separately from static analysis. diff --git a/docs/specs/javascript/v8-cached-data/spec.md b/docs/specs/javascript/v8-cached-data/spec.md new file mode 100644 index 0000000000..90d74ba9d5 --- /dev/null +++ b/docs/specs/javascript/v8-cached-data/spec.md @@ -0,0 +1,43 @@ +# V8 cached-data compilation boundaries + +## Analyst question + +Which source-visible Node VM compilation sites declare or may receive cached +bytes, and which admitted artifact can be associated with those bytes? + +## Scope and authority + +Static application analysis recognizes member calls to `vm.Script` and +`vm.compileFunction` when the shared built-in callable owner establishes their +identity. Analysis reads admitted source and inventory only. It does not invoke +the target, validate V8 cache bytes, establish cache acceptance, or execute code. +Node separates compilation, cache rejection and subsequent execution; see the +[Node VM contract](https://nodejs.org/docs/latest-v24.x/api/vm.html). + +## Owning facts + +- Resolve complete callable identity after shared mutation facts settle. ESM + default/namespace imports and proven CommonJS bindings use the same owner. + Replaced methods do not establish VM authority. +- `cached_data_status` is `declared` for an explicit cachedData property, + `absent` for proven omission or a proven undefined cache value, and `unknown` for unresolved options or argument + spreads that can supply or shift the options position. Declaration does not + prove a valid runtime value or successful cache use. +- A proven `fs.readFileSync` or awaited `fs/promises.readFile` can identify a + cache path. Unawaited promises and unproven readers retain bounded source + expressions. A supplied string alone is not proof that a file was read. +- Preserve the parsed path base through the worker boundary. Only a proven + source-directory base permits an application inventory lookup. Relative + literal paths depend on the unknown target working directory; source paths + and unresolved expressions stay separate from resolved artifact identity. +- Graph cache boundaries remain explicit unknowns with source provenance. + Absent sites do not add cache-boundary uncertainty. Summary counts describe + declared compilations, never observed executions. + +## Compatibility and verification + +Existing source declarations and inventory remain available. Optional path-base +facts may be absent in historical analyses; omission does not prove a base. +Public application workflows verify worker serialization, summary and graph +projection with local files. No test that merely supplies cache-shaped bytes +establishes real V8 acceptance or target execution. diff --git a/docs/specs/javascript/v8-cached-data/tasks.md b/docs/specs/javascript/v8-cached-data/tasks.md new file mode 100644 index 0000000000..5ce42c9495 --- /dev/null +++ b/docs/specs/javascript/v8-cached-data/tasks.md @@ -0,0 +1,10 @@ +# Tasks + +- [x] Model declared, absent and unknown cached-data sites. +- [x] Reuse built-in callable and option-object ownership. +- [x] Retain synchronous and awaited promise read provenance and path bases. +- [x] Carry facts through worker serialization to summary and graph consumers. +- [x] Exercise malformed authority, argument spreads and unresolved paths. +- [x] Verify public filesystem application workflows and document limitations. + +Real V8 acceptance and execution remain outside this static feature's scope. diff --git a/src/application/javascript/ElectronBoundaryAnalysis.ts b/src/application/javascript/ElectronBoundaryAnalysis.ts index fd73320870..b98bdbaaa9 100644 --- a/src/application/javascript/ElectronBoundaryAnalysis.ts +++ b/src/application/javascript/ElectronBoundaryAnalysis.ts @@ -1,3 +1,4 @@ +import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js"; import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js"; import { compareUnicodeCodePoints } from "../../domain/unicodeCodePointOrder.js"; import type { ElectronIpcFinding } from "../../domain/javascript/electronStaticAnalysisTypes.js"; @@ -200,7 +201,10 @@ export const electronBoundaryFactCount = ( javascript.electron.native_addon_bindings.filter( provenElectronFinding, ).length + - javascript.electron.protocols.filter(provenElectronFinding).length), + javascript.electron.protocols.filter(provenElectronFinding).length + + (javascript.electron.dynamic_code ?? []).filter( + isPossibleCachedDataCompilation, + ).length), 0, ); @@ -398,6 +402,14 @@ export const summarizeElectronBoundaries = ( }), ).length, native_addon_bindings: nativeBindings.length, + v8_cached_data_compilations: javascript.reduce( + (count, { electron }) => + count + + (electron.dynamic_code ?? []).filter( + ({ cached_data_status }) => cached_data_status === "declared", + ).length, + 0, + ), resolved_native_addon_bindings: nativeBindings.filter(({ file, finding }) => resolvesToFile({ file, diff --git a/src/application/javascript/ElectronBoundaryGraph.ts b/src/application/javascript/ElectronBoundaryGraph.ts index f9d6589885..4ea2654bf2 100644 --- a/src/application/javascript/ElectronBoundaryGraph.ts +++ b/src/application/javascript/ElectronBoundaryGraph.ts @@ -3,6 +3,7 @@ import type { JavaScriptArtifactGraphContext } from "./JavaScriptArtifactGraphCo import { addElectronIpcBoundaries } from "./ElectronBoundaryGraphIpc.js"; import { addElectronNativeBoundaries } from "./ElectronBoundaryGraphNative.js"; import { addElectronProtocolBoundaries } from "./ElectronBoundaryGraphProtocols.js"; +import { addElectronDynamicCodeBoundaries } from "./ElectronBoundaryGraphDynamicCode.js"; import { addElectronWindowBoundaries } from "./ElectronBoundaryGraphWindows.js"; import { addElectronCallPaths } from "./ElectronBoundaryGraphCallPaths.js"; @@ -15,5 +16,6 @@ export const addElectronBoundaries = ( addElectronIpcBoundaries(context, ipc); addElectronNativeBoundaries(context); addElectronProtocolBoundaries(context); + addElectronDynamicCodeBoundaries(context); addElectronCallPaths(context, ipc); }; diff --git a/src/application/javascript/ElectronBoundaryGraphDynamicCode.ts b/src/application/javascript/ElectronBoundaryGraphDynamicCode.ts new file mode 100644 index 0000000000..5cad31a206 --- /dev/null +++ b/src/application/javascript/ElectronBoundaryGraphDynamicCode.ts @@ -0,0 +1,156 @@ +import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js"; +import { javascriptSourceRangeKey } from "../../domain/javascript/javascriptSourceCoordinates.js"; +import type { + ElectronDynamicCodeFinding, + ElectronDynamicCodeSource, +} from "../../domain/javascript/electronStaticAnalysisTypes.js"; +import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js"; +import { + javascriptAnalysisCoverage, + type JavaScriptArtifactGraphContext, + type JavaScriptArtifactGraphCoverage, +} from "./JavaScriptArtifactGraphContext.js"; +import { astObservationEvidence } from "./JavaScriptArtifactGraphEvidence.js"; +import { compositeKey } from "../../domain/unicodeCodePointOrder.js"; +import { + addElectronInferenceEdge, + electronFindingSourceNode, + electronObservationIdentity, +} from "./ElectronBoundaryGraphContext.js"; +import { resolveArtifactPathByContext } from "./JavaScriptArtifactPathResolution.js"; + +interface DynamicCodeInput { + readonly context: JavaScriptArtifactGraphContext; + readonly file: JavaScriptArtifactFile; + readonly value: ElectronDynamicCodeFinding; + readonly coverage: JavaScriptArtifactGraphCoverage; +} + +const CACHED_DATA_COMPILATION_LIMITATION = + "Source syntax declares or may supply cachedData to a V8 compilation API. Static analysis does not establish valid cache bytes, V8 acceptance, or execution; logic inside a possible cache remains unknown."; + +/** + * Project `node:vm` compilations. A `cachedData` handoff becomes an explicit + * unknown carrying the inventoried cache artifact, so a thin wrapper whose + * preload logic lives in a bytecode cache is not read as an empty preload. + */ +export const addElectronDynamicCodeBoundaries = ( + context: JavaScriptArtifactGraphContext, +): void => { + for (const analyzed of context.analysis.files) { + const { file, javascript } = analyzed; + if (javascript === null) continue; + const coverage = javascriptAnalysisCoverage(javascript); + for (const value of javascript.electron.dynamic_code ?? []) + addDynamicCodeSite({ context, file, value, coverage }); + } +}; + +const addDynamicCodeSite = (input: DynamicCodeInput): void => { + const { context, file, value, coverage } = input; + if (!isPossibleCachedDataCompilation(value)) return; + const source = electronFindingSourceNode( + context, + file, + value.module_key, + value.module_runtime, + ); + if (source === undefined) return; + const cache = value.cached_data; + const resolved = resolveCache(context, file, cache); + const node = context.accumulator.addNode({ + kind: "unknown", + identity: electronObservationIdentity( + context, + "javascript-v8-cached-data", + compositeKey([ + file.path, + value.api, + cache.source_path, + cache.source_expression, + javascriptSourceRangeKey(value.location), + ]), + ), + observations: [ + { + label: cache.source_path ?? cache.source_expression, + properties: { + api: value.api, + cached_data_status: value.cached_data_status, + cache_path: cache.source_path, + cache_expression: cache.source_expression, + cache_path_base: + cache.source_path === null + ? null + : (cache.source_path_base ?? null), + resolved_cache_path: resolved?.resolved_path ?? null, + resolution_status: resolved?.resolution_status ?? null, + resolution_context: resolved?.resolution_context ?? null, + candidate_cache_path: resolved?.candidate_path ?? null, + resolution_limitations: resolved?.limitations ?? [], + source_text: value.source_text.value, + source_expression: value.source_text.expression, + compilation_boundary: "v8-cached-data", + }, + evidence: astObservationEvidence({ + sha256: file.sha256, + path: file.path, + range: value.location, + operation: "observe-v8-cached-data-compilation", + coverage, + limitations: [CACHED_DATA_COMPILATION_LIMITATION], + }), + }, + ], + }); + addElectronInferenceEdge(context, { + source, + target: node, + file, + range: value.location, + coverage, + relation: "loads", + operation: "associate-v8-cached-data-compilation", + properties: { + api: value.api, + cache_path: cache.source_path, + cache_expression: cache.source_expression, + cache_path_base: + cache.source_path === null ? null : (cache.source_path_base ?? null), + resolved_cache_path: resolved?.resolved_path ?? null, + resolution_status: resolved?.resolution_status ?? null, + resolution_context: resolved?.resolution_context ?? null, + candidate_cache_path: resolved?.candidate_path ?? null, + resolution_limitations: resolved?.limitations ?? [], + }, + limitations: [CACHED_DATA_COMPILATION_LIMITATION], + }); +}; + +/** + * The cache artifact the handoff names, when the path is inert syntax. The + * cache often is not a file this analysis reads, so lookup uses the full + * inventory map rather than the relevant-file projection. + */ +const resolveCache = ( + context: JavaScriptArtifactGraphContext, + file: JavaScriptArtifactFile, + cache: ElectronDynamicCodeSource, +) => { + if ( + cache.source_path === null || + cache.source_path_base !== "source-directory" + ) + return null; + const resolved = resolveArtifactPathByContext({ + declaredPath: cache.source_path, + sourcePath: file.path, + context: cache.source_resolution_context, + files: context.filesByPath, + jsonDocuments: context.analysis.jsonDocuments, + directoryPaths: context.directoryPaths, + unanalyzedFiles: context.unanalyzedFiles, + runtimePathKind: "exact-file", + }); + return resolved; +}; diff --git a/src/application/javascript/JavaScriptArtifactAnalysis.ts b/src/application/javascript/JavaScriptArtifactAnalysis.ts index 0af3bcdedb..b48f50851a 100644 --- a/src/application/javascript/JavaScriptArtifactAnalysis.ts +++ b/src/application/javascript/JavaScriptArtifactAnalysis.ts @@ -775,7 +775,8 @@ const findingCount = (analysis: JavaScriptStaticAnalysis): number => analysis.electron.sender_validations.length + analysis.electron.utility_processes.length + analysis.electron.native_addon_bindings.length + - analysis.electron.protocols.length; + analysis.electron.protocols.length + + (analysis.electron.dynamic_code ?? []).length; const optionalString = (value: unknown): string | null => typeof value === "string" && value.length > 0 ? value : null; diff --git a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts index 0aaa217388..926e6e35fb 100644 --- a/src/application/javascript/JavaScriptArtifactGraphBuilder.ts +++ b/src/application/javascript/JavaScriptArtifactGraphBuilder.ts @@ -1,3 +1,4 @@ +import { isPossibleCachedDataCompilation } from "../../domain/javascript/electronDynamicCodeFacts.js"; import type { ElectronBoundarySummary } from "../../domain/javascript/javascriptApplicationAnalysis.js"; import { electronIncompleteSourceCount, @@ -434,6 +435,15 @@ const graphLimitations = ( "Native member names are requested by JavaScript syntax and are not verified binary exports in this workflow.", ] : []), + ...(context.analysis.files.some(({ javascript }) => + (javascript?.electron.dynamic_code ?? []).some( + isPossibleCachedDataCompilation, + ), + ) + ? [ + "A node:vm compilation may receive cachedData; static syntax does not establish cache acceptance or execution, and possible cached logic remains unknown rather than absent.", + ] + : []), ...(coverage === "complete" ? [] : [ diff --git a/src/domain/javascript/electronDynamicCodeFacts.ts b/src/domain/javascript/electronDynamicCodeFacts.ts new file mode 100644 index 0000000000..f6816c2fc6 --- /dev/null +++ b/src/domain/javascript/electronDynamicCodeFacts.ts @@ -0,0 +1,6 @@ +import type { ElectronDynamicCodeFinding } from "./electronStaticAnalysisTypes.js"; + +/** A source-only compilation cannot establish an opaque cache boundary. */ +export const isPossibleCachedDataCompilation = ( + finding: ElectronDynamicCodeFinding, +): boolean => finding.cached_data_status !== "absent"; diff --git a/src/domain/javascript/electronStaticAnalysis.ts b/src/domain/javascript/electronStaticAnalysis.ts index bae0a5c47b..525108c06a 100644 --- a/src/domain/javascript/electronStaticAnalysis.ts +++ b/src/domain/javascript/electronStaticAnalysis.ts @@ -1,6 +1,7 @@ import * as t from "@babel/types"; import { inspectElectronBrowserNode } from "./electronStaticAnalysisBrowser.js"; +import { deferElectronDynamicCode } from "./electronStaticAnalysisDynamicCode.js"; import { inspectElectronIpcNode } from "./electronStaticAnalysisIpc.js"; import { inspectElectronNativeNode } from "./electronStaticAnalysisNative.js"; import { inspectElectronProtocolNode } from "./electronStaticAnalysisProtocol.js"; @@ -20,6 +21,7 @@ export const inspectElectronStaticNode = ( inspectElectronBrowserNode(node, context); inspectElectronIpcNode(node, context); inspectElectronNativeNode(node, context); + deferElectronDynamicCode(node, context); const path = t.isCallExpression(node) || t.isOptionalCallExpression(node) ? (electronCalleePath(node.callee, context) ?? diff --git a/src/domain/javascript/electronStaticAnalysisDynamicCode.test.ts b/src/domain/javascript/electronStaticAnalysisDynamicCode.test.ts new file mode 100644 index 0000000000..c2b0912b2c --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisDynamicCode.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "vitest"; +import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js"; + +import { analyzeCommonJsStaticFixture } from "../../../tests/fixtures/javascriptCommonJsAnalysis.js"; + +// A `.js` fixture parses as ESM, and then every `require` call keeps only +// candidate loader authority. These cases describe CommonJS packaging, so they +// use the CommonJS fixture that the sibling Electron paths also rely on. +const analyze = (source: string) => analyzeCommonJsStaticFixture(source); + +describe("node:vm bytecode cache boundaries", () => { + it("records the artifact named by an inert readFileSync cache argument", () => { + const analysis = analyze(` + const fs = require("node:fs"); + const path = require("node:path"); + const vm = require("node:vm"); + const cachedData = fs.readFileSync(path.join(__dirname, "preload.bundle.cache")); + new vm.Script(sourcePlaceholder, { cachedData }).runInThisContext(); + `); + expect(analysis.electron.dynamic_code).toEqual([ + expect.objectContaining({ + api: "vm.Script", + cached_data_status: "declared", + cached_data: { + source_path: "preload.bundle.cache", + source_path_base: "source-directory", + source_resolution_context: "filesystem-expression", + source_expression: null, + }, + source_text: expect.objectContaining({ + expression: "sourcePlaceholder", + }), + }), + ]); + }); + + it("resolves compileFunction's positional cachedData argument", () => { + const analysis = analyze(` + const vm = require("node:vm"); + vm.compileFunction(text, [], { cachedData: loadCache() }); + `); + expect(analysis.electron.dynamic_code).toEqual([ + expect.objectContaining({ + api: "vm.compileFunction", + cached_data_status: "declared", + cached_data: expect.objectContaining({ + source_path: null, + source_expression: "loadCache()", + }), + }), + ]); + }); + + it("keeps compilations without a bytecode cache as compilations without declared cachedData", () => { + const analysis = analyze(` + const vm = require("node:vm"); + new vm.Script("./main.js"); + `); + expect(analysis.electron.dynamic_code).toEqual([ + expect.objectContaining({ + api: "vm.Script", + cached_data_status: "absent", + }), + ]); + }); + + // A local object named `vm` is not the module, so its members name no VM API. + it("ignores an unrelated local object that shares the module name", () => { + const analysis = analyze(` + const vm = { Script: class Script {} }; + new vm.Script("./main.js", { cachedData: "x" }); + `); + expect(analysis.electron.dynamic_code ?? []).toEqual([]); + }); +}); + +it("preserves unknown options and conservative constant-option reads without claiming execution", () => { + for (const [source, status] of [ + ['new vm.Script("x", options);', "unknown"], + [ + 'const options = { cachedData: "./cache" }; new vm.Script("x", options);', + "declared", + ], + ['new vm.Script("x", { cachedData: value, ...options });', "unknown"], + ['new vm.Script("x", { ...options, cachedData: value });', "declared"], + ]) { + const result = analyze('const vm = require("node:vm"); ' + source); + expect(result.electron.dynamic_code?.[0], source).toMatchObject({ + cached_data_status: status, + cached_data: { source_path: null }, + }); + } +}); + +it("keeps cache read authority unknown after overwriting the filesystem method", () => { + const analysis = analyze(` + const fs = require("node:fs"); + const vm = require("node:vm"); + fs.readFileSync = fakeReader; + new vm.Script(source, { cachedData: fs.readFileSync("./not-a-native-cache") }); + `); + expect(analysis.electron.dynamic_code?.[0]?.cached_data).toMatchObject({ + source_path: null, + source_resolution_context: null, + }); +}); + +it.each([ + "new vm.Script(...args);", + "new vm.Script(source, ...options);", + "vm.compileFunction(...args);", + "vm.compileFunction(source, ...args);", + "vm.compileFunction(source, [], ...options);", +])( + "does not infer cachedData absence through argument spreads: %s", + (source) => { + const analysis = analyze('const vm = require("node:vm"); ' + source); + expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe( + "unknown", + ); + }, +); + +it.each(['import * as vm from "node:vm";', 'import vm from "node:vm";'])( + "recognizes ESM VM callable authority: %s", + (declaration) => { + const analysis = analyzeJavaScriptStaticSource( + declaration + " new vm.Script(source, { cachedData });", + ); + expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe( + "declared", + ); + }, +); + +it("does not report a replaced VM method as a built-in compilation", () => { + const analysis = analyze( + 'const vm = require("node:vm"); vm.Script = Fake; new vm.Script(source, { cachedData });', + ); + expect(analysis.electron.dynamic_code ?? []).toEqual([]); +}); + +it("retains awaited promise reads without treating an unawaited promise as cache bytes", () => { + for (const [awaited, expected] of [ + ["await ", "preload.bundle.cache"], + ["", null], + ] as const) { + const analysis = analyze(` + const fs = require("node:fs/promises"); + const path = require("node:path"); + const vm = require("node:vm"); + async function load() { + const cachedData = ${awaited}fs.readFile(path.join(__dirname, "preload.bundle.cache")); + new vm.Script(source, { cachedData }); + } + `); + expect(analysis.electron.dynamic_code?.[0]?.cached_data.source_path).toBe( + expected, + ); + } +}); + +it.each(["void 0", "undefined"])( + "keeps disabled cache value %s out of possible cache boundaries", + (value) => { + const analysis = analyze( + `const vm = require("node:vm"); new vm.Script(source, { cachedData: ${value} }); vm.compileFunction(source, [], { cachedData: ${value} });`, + ); + expect( + analysis.electron.dynamic_code?.map( + ({ cached_data_status }) => cached_data_status, + ), + ).toEqual(["absent", "absent"]); + expect( + analysis.electron.dynamic_code?.[0]?.cached_data.source_expression, + ).toBe(value); + }, +); + +it("does not confuse a shadowed undefined value with a disabled cache", () => { + const analysis = analyze( + 'const vm = require("node:vm"); function load(undefined) { new vm.Script(source, { cachedData: undefined }); }', + ); + expect(analysis.electron.dynamic_code?.[0]?.cached_data_status).toBe( + "declared", + ); +}); diff --git a/src/domain/javascript/electronStaticAnalysisDynamicCode.ts b/src/domain/javascript/electronStaticAnalysisDynamicCode.ts new file mode 100644 index 0000000000..1a1578b442 --- /dev/null +++ b/src/domain/javascript/electronStaticAnalysisDynamicCode.ts @@ -0,0 +1,239 @@ +import { resolveElectronOptionObject } from "./electronStaticOptionObjects.js"; +import * as t from "@babel/types"; + +import { compositeKey } from "../unicodeCodePointOrder.js"; +import type { + ElectronDynamicCodeFinding, + ElectronDynamicCodeSource, +} from "./electronStaticAnalysisTypes.js"; +import { + boundedExpression, + electronStaticValue, +} from "./electronStaticAnalysisValues.js"; +import { builtinDataEffectCallablePath } from "./javascriptSemanticDataEffectHelpers.js"; +import { + semanticStaticPropertyKey, + staticObjectProperty, + unwrapJavaScriptExpression, +} from "./javascriptAstValues.js"; +import { addLocatedFinding } from "./javascriptStaticAnalysisFindings.js"; +import { + argumentNode, + range, + staticFileExpressionPath, +} from "./javascriptStaticAnalysisHelpers.js"; +import type { JavaScriptFindingContext } from "./javascriptStaticAnalysisState.js"; +import { + semanticStableInitializer, + type JavaScriptBindingIndex, +} from "./javascriptSemanticState.js"; + +interface DynamicCodeInput { + readonly context: JavaScriptFindingContext; + readonly index: JavaScriptBindingIndex; + readonly node: t.NewExpression | t.CallExpression; + readonly api: ElectronDynamicCodeFinding["api"]; + readonly cachedDataArgument: "named" | "positional"; +} + +/** + * Record `vm.Script`/`vm.compileFunction` sites during the shared traversal. + * Option objects may be rebound, so the `cachedData` lookup is deferred to the + * same post-traversal step that settles the other Electron findings. + */ +export const deferElectronDynamicCode = ( + node: t.Node, + context: JavaScriptFindingContext, +): void => { + // Identity waits for the settled index; this admits only the two syntax shapes + // whose callee could name a `vm` member. + const candidate = + (t.isNewExpression(node) && syntacticMember(node.callee) === "Script") || + (t.isCallExpression(node) && + syntacticMember(node.callee) === "compileFunction"); + if (candidate) context.accumulator.pendingDynamicCode.push(node); +}; + +const syntacticMember = (callee: t.Node): string | null => { + const node = unwrapJavaScriptExpression(callee).node; + return t.isMemberExpression(node) || t.isOptionalMemberExpression(node) + ? semanticStaticPropertyKey(node.property, node.computed) + : null; +}; + +/** Resolve the deferred `node:vm` compilations against the settled index. */ +export function* finishElectronDynamicCodeSteps( + context: JavaScriptFindingContext, + index: JavaScriptBindingIndex, +): Generator { + const pending = context.accumulator.pendingDynamicCode; + if (pending.length === 0) return; + yield* index.indexMemberMutationFactsSteps(); + for (const node of pending) { + const api = t.isNewExpression(node) + ? vmMemberAt(node.callee, index) === "Script" + ? "vm.Script" + : undefined + : vmMemberAt(node.callee, index) === "compileFunction" + ? "vm.compileFunction" + : undefined; + if (api === undefined) continue; + const input: DynamicCodeInput = { + context, + index, + node, + api, + cachedDataArgument: api === "vm.Script" ? "named" : "positional", + }; + yield* addCompilation(input); + } + pending.length = 0; +} + +/** Resolve the complete callable through the shared built-in identity owner. */ +const vmMemberAt = ( + callee: t.Node, + index: JavaScriptBindingIndex, +): string | undefined => { + const path = builtinDataEffectCallablePath(callee, index.bindings, ["vm"]); + return path?.length === 1 ? path[0] : undefined; +}; + +function* addCompilation(input: DynamicCodeInput): Generator { + const { context, node, api, cachedDataArgument } = input; + const optionsPosition = cachedDataArgument === "named" ? 1 : 2; + const spreadBeforeOptions = node.arguments + .slice(0, optionsPosition + 1) + .some((argument) => t.isSpreadElement(argument)); + const options = argumentNode(node.arguments[optionsPosition]); + const lookup = spreadBeforeOptions + ? ({ status: "dynamic" } as const) + : options === undefined + ? ({ status: "missing" } as const) + : (() => { + const resolved = resolveElectronOptionObject( + options, + options, + input.index, + ["cachedData"], + ); + return resolved === undefined + ? { status: "dynamic" as const } + : staticObjectProperty(resolved, "cachedData"); + })(); + const disabled = + lookup.status === "explicit" && + !t.isNullLiteral(unwrapJavaScriptExpression(lookup.value).node) && + context.expressionShapes.isNullish(lookup.value); + const cachedData: ElectronDynamicCodeSource = + lookup.status === "explicit" + ? sourceOf(input, lookup.value) + : { + source_path: null, + source_resolution_context: null, + source_expression: + lookup.status === "dynamic" + ? "cachedData: [dynamic-options]" + : "[absent]", + }; + addLocatedFinding(context, { + collection: context.accumulator.dynamicCode, + key: compositeKey([ + "electron-dynamic-code", + api, + cachedData.source_path, + cachedData.source_expression, + ]), + node, + value: { + api, + cached_data_status: + lookup.status === "explicit" + ? disabled + ? "absent" + : "declared" + : lookup.status === "missing" + ? "absent" + : "unknown", + cached_data: cachedData, + source_text: electronStaticValue( + context.source, + argumentNode(node.arguments[0]), + ), + module_key: null, + location: range(node), + }, + }); + yield; +} + +const sourceOf = ( + input: DynamicCodeInput, + node: t.Node, +): ElectronDynamicCodeSource => { + // `cachedData = fs.readFileSync()` names the cache through the read's + // own argument; the call itself is not a path expression. + const path = cachePathExpression( + input.index, + stableExpression(input.index, node) ?? node, + ); + const fact = + path === undefined + ? undefined + : staticFileExpressionPath(path, input.context.pathIntrinsics); + return fact === undefined + ? { + source_path: null, + source_resolution_context: null, + source_expression: boundedExpression(input.context.source, node), + } + : { + source_path: fact.path, + source_path_base: fact.base, + source_resolution_context: "filesystem-expression", + source_expression: null, + }; +}; + +/** The path argument of a proven `fs` read that produced the cache value. */ +const cachePathExpression = ( + index: JavaScriptBindingIndex, + node: t.Node, +): t.Node | undefined => { + const value = unwrapJavaScriptExpression(node).node; + const awaited = t.isAwaitExpression(value); + const expression = awaited + ? unwrapJavaScriptExpression(value.argument).node + : value; + if (!t.isCallExpression(expression)) return undefined; + const read = builtinDataEffectCallablePath( + expression.callee, + index.bindings, + awaited ? ["fs/promises"] : ["fs"], + ); + const target = + read?.length === 1 && read[0] === (awaited ? "readFile" : "readFileSync") + ? argumentNode(expression.arguments[0]) + : undefined; + return target === undefined + ? undefined + : unwrapJavaScriptExpression(target).node; +}; + +/** + * Follow one unrebound `const` to its sole initializer so a cache read into a + * local name still names the artifact, exactly as the option-object owner does. + */ +const stableExpression = ( + index: JavaScriptBindingIndex, + node: t.Node, +): t.Node | undefined => { + const expression = unwrapJavaScriptExpression(node).node; + if (!t.isIdentifier(expression)) return undefined; + const binding = index.bindingForReferenceAt(expression); + if (binding?.mutable !== false) return undefined; + const initializer = semanticStableInitializer(binding, index.bindings); + return initializer === undefined + ? undefined + : unwrapJavaScriptExpression(initializer.node).node; +}; diff --git a/src/domain/javascript/electronStaticAnalysisTypes.ts b/src/domain/javascript/electronStaticAnalysisTypes.ts index 4f8977c46c..c9151f9f48 100644 --- a/src/domain/javascript/electronStaticAnalysisTypes.ts +++ b/src/domain/javascript/electronStaticAnalysisTypes.ts @@ -276,6 +276,46 @@ export interface ElectronProtocolFinding { readonly location: JavaScriptSourceRange; } +/** Source of a script's bytes: an inert path expression or dynamic syntax. */ +export type ElectronDynamicCodeSource = + | { + readonly source_path: string; + readonly source_path_base?: + | "source-directory" + | "literal-path" + | undefined; + readonly source_resolution_context: JavaScriptStaticPathContext; + readonly source_expression: null; + } + | { + readonly source_path: null; + readonly source_resolution_context: null; + readonly source_expression: string; + }; + +/** + * Source syntax for one node:vm compilation. Cache declaration does not establish + * accepted bytes or runtime execution. + */ +export interface ElectronDynamicCodeFinding { + readonly api: "vm.Script" | "vm.compileFunction"; + /** + * Whether cachedData is declared, proven absent, or unresolved in options. + */ + readonly cached_data_status: "declared" | "absent" | "unknown"; + /** The option or positional argument naming the backing cache artifact. */ + readonly cached_data: ElectronDynamicCodeSource; + /** + * The source text argument when it is inert syntax, which is the case a + * bytecode-cache handoff replaces at runtime. + */ + readonly source_text: ElectronStaticValue; + readonly module_key: string | null; + /** Recovered factory runtime; absent in older or unbundled findings. */ + readonly module_runtime?: string; + readonly location: JavaScriptSourceRange; +} + /** All Electron-specific facts collected during one shared AST traversal. */ export interface ElectronStaticFindings { /** Calls that may replace the application path used by Electron loadFile. */ @@ -287,6 +327,8 @@ export interface ElectronStaticFindings { readonly utility_processes: readonly ElectronUtilityProcessFinding[]; readonly native_addon_bindings: readonly ElectronNativeAddonBindingFinding[]; readonly protocols: readonly ElectronProtocolFinding[]; + /** `node:vm` compilations whose code may come from an opaque bytecode cache. */ + readonly dynamic_code?: readonly ElectronDynamicCodeFinding[]; /** Source-bound candidate paths through bundled exports and IPC closures. */ readonly call_paths?: readonly ElectronCallPath[]; } diff --git a/src/domain/javascript/javascriptApplicationAnalysis.ts b/src/domain/javascript/javascriptApplicationAnalysis.ts index d55e345c94..12adcafe0b 100644 --- a/src/domain/javascript/javascriptApplicationAnalysis.ts +++ b/src/domain/javascript/javascriptApplicationAnalysis.ts @@ -58,6 +58,8 @@ const electronBoundarySummarySchema = z.strictObject({ resolved_native_addon_bindings: countSchema, /** Absent in evidence produced before protocol registrations were reported. */ protocol_registrations: countSchema.exactOptional(), + /** `node:vm` compilations whose logic may live in an opaque bytecode cache. */ + v8_cached_data_compilations: countSchema.exactOptional(), /** * Whether the Electron boundary surface was actually recovered. `empty` means * an analyzed source names Electron but no boundary fact was recovered, so the diff --git a/src/domain/javascript/javascriptApplicationProjectionResources.ts b/src/domain/javascript/javascriptApplicationProjectionResources.ts index eb00d04894..cbe779d396 100644 --- a/src/domain/javascript/javascriptApplicationProjectionResources.ts +++ b/src/domain/javascript/javascriptApplicationProjectionResources.ts @@ -22,7 +22,8 @@ export const estimateJavaScriptStaticApplicationProjection = ( facts.electron.sender_validations.length + facts.electron.utility_processes.length + facts.electron.native_addon_bindings.length + - facts.electron.protocols.length; + facts.electron.protocols.length + + (facts.electron.dynamic_code ?? []).length; let records = findings * 8; let repeatedBytes = 0; for (const registration of facts.bundler_registrations) { diff --git a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts index c0757a8c61..88f0e0a8a8 100644 --- a/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts +++ b/src/domain/javascript/javascriptSemanticDataEffectHelpers.ts @@ -725,6 +725,7 @@ const BUILTIN_RECEIVER_METHODS: Readonly>> = path: new Set(["resolve", "join", "dirname"]), "path/posix": new Set(["resolve", "join", "dirname"]), url: new Set(["fileURLToPath"]), + vm: new Set(["compileFunction"]), fs: new Set([ "readFile", "readFileSync", diff --git a/src/domain/javascript/javascriptStaticAnalysis.ts b/src/domain/javascript/javascriptStaticAnalysis.ts index 40602371b2..b7f611af88 100644 --- a/src/domain/javascript/javascriptStaticAnalysis.ts +++ b/src/domain/javascript/javascriptStaticAnalysis.ts @@ -21,6 +21,7 @@ import { resolveElectronBrowserWindowReceiver, } from "./electronStaticRenderer.js"; import { finishElectronNativeBindingsSteps } from "./electronStaticAnalysisNative.js"; +import { finishElectronDynamicCodeSteps } from "./electronStaticAnalysisDynamicCode.js"; import { inspectElectronStaticNode } from "./electronStaticAnalysis.js"; import { hasPossibleCommonJsDelegateWrite, @@ -151,6 +152,7 @@ export function* analyzeParsedJavaScriptStaticSourceSteps( finishBundlerExports(accumulator, index); yield* finishElectronNativeBindingsSteps(findings, index); yield* finishElectronBrowserWindowsSteps(findings, index); + yield* finishElectronDynamicCodeSteps(findings, index); if (accumulator.pendingModuleRequireCalls.length > 0) { yield* index.indexMemberMutationFactsSteps(); finishModuleRequireCalls(findings, index); @@ -365,6 +367,14 @@ const finalizeStaticAnalysis = ( accumulator.nativeAddonBindings, accumulator.moduleRangeIndex, ), + ...(accumulator.dynamicCode.length > 0 + ? { + dynamic_code: finalizeLocatedFindings( + accumulator.dynamicCode, + accumulator.moduleRangeIndex, + ), + } + : {}), protocols: finalizeLocatedFindings( accumulator.protocols, accumulator.moduleRangeIndex, diff --git a/src/domain/javascript/javascriptStaticAnalysisState.ts b/src/domain/javascript/javascriptStaticAnalysisState.ts index 35fb8a684b..1ef15fcbf7 100644 --- a/src/domain/javascript/javascriptStaticAnalysisState.ts +++ b/src/domain/javascript/javascriptStaticAnalysisState.ts @@ -22,6 +22,7 @@ import type { ElectronNativeAddonBindingFinding, ElectronSenderValidationFinding, ElectronProtocolFinding, + ElectronDynamicCodeFinding, ElectronUtilityProcessFinding, ElectronWindowConstructor, } from "./electronStaticAnalysisTypes.js"; @@ -95,6 +96,8 @@ export interface JavaScriptAnalysisAccumulator { | t.AssignmentExpression )[]; readonly protocols: LocatedJavaScriptFinding[]; + readonly dynamicCode: LocatedJavaScriptFinding[]; + readonly pendingDynamicCode: (t.NewExpression | t.CallExpression)[]; readonly moduleRangeIndex: JavaScriptModuleRangeIndex; /** Binding identities proven as third-parameter loaders of recovered factories. */ readonly factoryRequireBindingIds: Set; @@ -201,6 +204,8 @@ export const createJavaScriptAnalysisAccumulator = ( nativeAddonBindings: [], pendingNativeAddonBindings: [], protocols: [], + dynamicCode: [], + pendingDynamicCode: [], moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength), factoryRequireBindingIds: new Set(), seen: new Set(), diff --git a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts index 413b8abf16..17b7e7ca14 100644 --- a/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts +++ b/src/javascript/analysis/JavaScriptAnalysisWorkerSchemas.ts @@ -304,6 +304,36 @@ const electron = z dynamic_member_access: z.boolean().optional(), }), ), + dynamic_code: z + .array( + z + .strictObject({ + ...findingContext, + api: z.enum(["vm.Script", "vm.compileFunction"]), + cached_data_status: z.enum(["declared", "absent", "unknown"]), + cached_data: z.union([ + z.strictObject({ + source_path: z.string(), + source_path_base: z + .enum(["source-directory", "literal-path"]) + .optional(), + source_resolution_context: z.enum([ + "filesystem-expression", + "module-specifier", + ]), + source_expression: z.null(), + }), + z.strictObject({ + source_path: z.null(), + source_resolution_context: z.null(), + source_expression: z.string(), + }), + ]), + source_text: staticValue, + }) + .transform(withRuntime), + ) + .optional(), protocols: z.array( z .strictObject({ @@ -322,10 +352,11 @@ const electron = z .transform(withRuntime), ), }) - .transform(({ call_paths, app_path_changes, ...rest }) => ({ + .transform(({ call_paths, app_path_changes, dynamic_code, ...rest }) => ({ ...rest, ...(app_path_changes === undefined ? {} : { app_path_changes }), ...(call_paths === undefined ? {} : { call_paths }), + ...(dynamic_code === undefined ? {} : { dynamic_code }), })); const referenceShape = { ...findingContext, diff --git a/tests/boundary/filesystem/javascript-applications/electronVmCachedData.test.ts b/tests/boundary/filesystem/javascript-applications/electronVmCachedData.test.ts new file mode 100644 index 0000000000..ef6dd48743 --- /dev/null +++ b/tests/boundary/filesystem/javascript-applications/electronVmCachedData.test.ts @@ -0,0 +1,189 @@ +import { writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect, it } from "vitest"; + +import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js"; +import { + analyzeJavaScriptApplication, + reconstructJavaScriptArtifact, +} from "../../../support/javascriptApplicationScope.js"; +import { parseEvidence } from "../../../../src/domain/evidence.js"; +import { parseJavaScriptApplicationGraph } from "../../../../src/domain/javascript/javascriptApplicationGraph.js"; + +const WRAPPER = String.raw` +const fs = require("node:fs"); +const path = require("node:path"); +const vm = require("node:vm"); +const cachedData = fs.readFileSync(path.join(__dirname, "preload.bundle.cache")); +new vm.Script(sourcePlaceholder, { cachedData }).runInThisContext(); +`; + +const vmFixture = async (source: string): Promise => { + const root = await createTestTempDirectory("rea-electron-vm-cache-"); + await Promise.all([ + writeFile( + join(root, "package.json"), + JSON.stringify({ name: "rea-electron-vm-cache", main: "main.cjs" }), + ), + writeFile(join(root, "main.cjs"), source), + writeFile(join(root, "preload.bundle.cache"), Buffer.from([0, 1, 2, 255])), + ]); + return root; +}; + +describe("V8 cached data compilation boundaries", () => { + it("projects an explicit unknown for the bytecode handoff", async () => { + const result = await reconstructJavaScriptArtifact({ + input_path: await vmFixture(WRAPPER), + }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const unknown = graph.nodes.filter( + ({ kind, observations }) => + kind === "unknown" && + observations.some( + ({ properties }) => + properties.compilation_boundary === "v8-cached-data", + ), + ); + expect(unknown).toHaveLength(1); + expect(unknown[0]?.observations[0]?.properties).toMatchObject({ + api: "vm.Script", + cached_data_status: "declared", + cache_path: "preload.bundle.cache", + resolved_cache_path: null, + candidate_cache_path: "preload.bundle.cache", + resolution_status: "unavailable", + source_expression: "sourcePlaceholder", + }); + expect(unknown[0]?.observations[0]?.evidence.limitations).toEqual( + expect.arrayContaining([ + expect.stringContaining("does not establish valid cache bytes"), + ]), + ); + expect( + graph.edges.filter(({ target_node_id: target }) => + unknown.some(({ node_id }) => node_id === target), + ), + ).toEqual([ + expect.objectContaining({ + relation: "loads", + properties: expect.objectContaining({ api: "vm.Script" }), + }), + ]); + }); + + it("stays silent when compilation has no bytecode cache", async () => { + const result = await reconstructJavaScriptArtifact({ + input_path: await vmFixture(String.raw` + const vm = require("node:vm"); + new vm.Script("require('node:fs')").runInThisContext(); + vm.compileFunction("return 1", [], {}); + `), + }); + const graph = parseJavaScriptApplicationGraph(result.graph); + expect( + graph.nodes.filter(({ observations }) => + observations.some( + ({ properties }) => + properties.compilation_boundary === "v8-cached-data", + ), + ), + ).toEqual([]); + expect(graph.limitations.some((note) => note.includes("cachedData"))).toBe( + false, + ); + }); +}); + +describe("V8 cached data summary", () => { + it("counts cachedData declarations without claiming an empty preload", async () => { + const result = await analyzeJavaScriptApplication({ + input_path: await vmFixture(WRAPPER), + }); + if (!result.ok) throw result.error; + expect(parseEvidence(result.value)).toMatchObject({ + normalized_result: { + summary: expect.objectContaining({ v8_cached_data_compilations: 1 }), + }, + }); + }); +}); + +it("keeps missing, dynamic and arbitrary cachedData sources distinct", async () => { + for (const [options, status, path] of [ + [ + '{ cachedData: fs.readFileSync("./missing.cache") }', + "declared", + "./missing.cache", + ], + ["getOptions()", "unknown", null], + ['{ cachedData: "./preload.bundle.cache" }', "declared", null], + ] as const) { + const root = await vmFixture( + `const fs = require("node:fs"); const vm = require("node:vm"); new vm.Script("void 0", ${options});`, + ); + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const observation = graph.nodes + .flatMap((node) => node.observations) + .find( + ({ properties }) => + properties.compilation_boundary === "v8-cached-data", + ); + if (options.includes('cachedData: "')) + expect(observation?.properties.cache_expression).toBe( + '"./preload.bundle.cache"', + ); + expect(observation?.properties).toMatchObject({ + cached_data_status: status, + cache_path: path, + resolved_cache_path: null, + }); + } +}); + +it("does not resolve a cwd-relative filesystem cache to an inventoried source sibling", async () => { + const root = await vmFixture( + `const fs = require("node:fs"); const vm = require("node:vm"); new vm.Script(source, { cachedData: fs.readFileSync("./preload.bundle.cache") });`, + ); + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + const observation = graph.nodes + .flatMap(({ observations }) => observations) + .find( + ({ properties }) => properties.compilation_boundary === "v8-cached-data", + ); + expect(observation?.properties).toMatchObject({ + cache_path: "./preload.bundle.cache", + cache_path_base: "literal-path", + resolved_cache_path: null, + }); +}); + +it("omits opaque-cache graph boundaries for proven undefined cache bytes", async () => { + const root = await vmFixture( + 'const vm = require("node:vm"); new vm.Script(source, { cachedData: void 0 });', + ); + const result = await reconstructJavaScriptArtifact({ input_path: root }); + const graph = parseJavaScriptApplicationGraph(result.graph); + expect( + graph.nodes.some(({ observations }) => + observations.some( + ({ properties }) => + properties.compilation_boundary === "v8-cached-data", + ), + ), + ).toBe(false); +}); + +it("does not count source-only VM compilation as recovered Electron semantics", async () => { + const root = await vmFixture( + 'const electron = require("electron"); const vm = require("node:vm"); new vm.Script(source);', + ); + const result = await analyzeJavaScriptApplication({ input_path: root }); + if (!result.ok) throw result.error; + expect(parseEvidence(result.value).normalized_result).toMatchObject({ + summary: { electron_semantics: "empty", v8_cached_data_compilations: 0 }, + }); +});