mirror of
https://github.com/morluto/rea.git
synced 2026-11-14 10:43:30 +00:00
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>
This commit is contained in:
21 files changed
+982
-4
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
? []
|
||||
: [
|
||||
|
||||
@@ -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";
|
||||
@@ -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) ??
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
@@ -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<void, void> {
|
||||
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<void, void> {
|
||||
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(<path>)` 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;
|
||||
};
|
||||
@@ -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[];
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -725,6 +725,7 @@ const BUILTIN_RECEIVER_METHODS: Readonly<Record<string, ReadonlySet<string>>> =
|
||||
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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<ElectronProtocolFinding>[];
|
||||
readonly dynamicCode: LocatedJavaScriptFinding<ElectronDynamicCodeFinding>[];
|
||||
readonly pendingDynamicCode: (t.NewExpression | t.CallExpression)[];
|
||||
readonly moduleRangeIndex: JavaScriptModuleRangeIndex;
|
||||
/** Binding identities proven as third-parameter loaders of recovered factories. */
|
||||
readonly factoryRequireBindingIds: Set<string>;
|
||||
@@ -201,6 +204,8 @@ export const createJavaScriptAnalysisAccumulator = (
|
||||
nativeAddonBindings: [],
|
||||
pendingNativeAddonBindings: [],
|
||||
protocols: [],
|
||||
dynamicCode: [],
|
||||
pendingDynamicCode: [],
|
||||
moduleRangeIndex: new JavaScriptModuleRangeIndex(sourceLength),
|
||||
factoryRequireBindingIds: new Set(),
|
||||
seen: new Set(),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string> => {
|
||||
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 },
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user