chore(verification): sync Hopper validation with latest main
Integrate main653067d7, preserving the tested Hopper corrections. The incoming changes are JavaScript/Apple implementation and regression updates; Hopper provider, verifier and dependency bytes are unchanged. Real Hopper runs remain bound to exact head97b76aa2and tested checkoutf379bc38. This final integration has no new native execution claim. 26 focused tests, full static check and docs:check passed on this tree.
This commit is contained in:
commit
794debe565
|
|
@ -236,6 +236,8 @@ try {
|
|||
await exec("/usr/bin/xcrun", [
|
||||
"swiftc",
|
||||
"-emit-library",
|
||||
"-module-name",
|
||||
"ReaWitnessFixture",
|
||||
"-O",
|
||||
...mode.flags
|
||||
.map((flag) => flag.replace(/^-Wl,/u, "-Xlinker\0"))
|
||||
|
|
|
|||
|
|
@ -376,10 +376,14 @@ const resolveReference = (
|
|||
): ResolvedReference | null => {
|
||||
const specifier = reference.specifier;
|
||||
if (specifier === null) return null;
|
||||
const chunk = resolveBundlerChunkReference(context, sourcePath, reference);
|
||||
const isWorker =
|
||||
reference.kind === "worker" || reference.kind === "service-worker";
|
||||
const chunk = isWorker
|
||||
? null
|
||||
: resolveBundlerChunkReference(context, sourcePath, reference);
|
||||
if (chunk !== null) return chunk;
|
||||
const module =
|
||||
reference.module_runtime === undefined
|
||||
isWorker || reference.module_runtime === undefined
|
||||
? undefined
|
||||
: context.moduleNodes.get(
|
||||
moduleLookupKey(sourcePath, reference.module_runtime, specifier),
|
||||
|
|
@ -391,24 +395,23 @@ const resolveReference = (
|
|||
: { path: `${sourcePath}#module:${specifier}`, file, node: module };
|
||||
}
|
||||
// Worker scripts are URLs relative to the script, not module specifiers.
|
||||
const path =
|
||||
reference.kind === "worker" || reference.kind === "service-worker"
|
||||
? resolveArtifactPathByContext({
|
||||
declaredPath: specifier,
|
||||
sourcePath,
|
||||
context: "url-reference",
|
||||
files: context.filesByPath,
|
||||
}).resolved_path
|
||||
: resolveArtifactPath(
|
||||
specifier,
|
||||
sourcePath,
|
||||
context.filesByPath,
|
||||
reference.kind === "require" ||
|
||||
reference.kind === "static-import" ||
|
||||
reference.kind === "dynamic-import"
|
||||
? moduleKindForJavaScriptReference(sourcePath, reference.kind)
|
||||
: undefined,
|
||||
);
|
||||
const path = isWorker
|
||||
? resolveArtifactPathByContext({
|
||||
declaredPath: specifier,
|
||||
sourcePath,
|
||||
context: "url-reference",
|
||||
files: context.filesByPath,
|
||||
}).resolved_path
|
||||
: resolveArtifactPath(
|
||||
specifier,
|
||||
sourcePath,
|
||||
context.filesByPath,
|
||||
reference.kind === "require" ||
|
||||
reference.kind === "static-import" ||
|
||||
reference.kind === "dynamic-import"
|
||||
? moduleKindForJavaScriptReference(sourcePath, reference.kind)
|
||||
: undefined,
|
||||
);
|
||||
if (path === null) return null;
|
||||
const file = context.filesByPath.get(path);
|
||||
const node = context.fileNodes.get(path);
|
||||
|
|
|
|||
|
|
@ -1979,15 +1979,143 @@ describe("TypeScript NodeNext emitted-extension resolution", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("url-reference directory and empty-segment semantics", () => {
|
||||
it.each([
|
||||
"./worker.js/.",
|
||||
"./worker.js/child/..",
|
||||
"./worker.js/",
|
||||
"/dist/worker.js/.",
|
||||
"./worker.js/.?cache=1#entry",
|
||||
])("keeps directory-form URLs distinct from files: %s", (declaredPath) => {
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
files: fileMap([file("dist/worker.js", "root")]),
|
||||
}),
|
||||
).toMatchObject({
|
||||
declared_path: declaredPath,
|
||||
resolution_status: "not-found",
|
||||
resolved_path: null,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["./nested//worker.js", "./nested//../worker.js"])(
|
||||
"does not collapse empty URL path segments: %s",
|
||||
(declaredPath) => {
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
files: fileMap([
|
||||
file("dist/worker.js", "root"),
|
||||
file("dist/nested/worker.js", "root"),
|
||||
]),
|
||||
}),
|
||||
).toMatchObject({ resolved_path: null });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["./nested/.././worker.js?cache=1#entry", "dist/worker.js"],
|
||||
["./.worker.js", "dist/.worker.js"],
|
||||
])("resolves exact file URLs with dots: %s", (declaredPath, resolvedPath) => {
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
files: fileMap([file(resolvedPath, "root")]),
|
||||
}),
|
||||
).toMatchObject({
|
||||
resolution_status: "resolved",
|
||||
resolved_path: resolvedPath,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("url-reference JavaScript artifact path resolution", () => {
|
||||
it.each([
|
||||
["C|?cache=1#entry", "dist/C|"],
|
||||
["/c:/worker.js", "c:/worker.js"],
|
||||
["/./c|/worker.js", "c|/worker.js"],
|
||||
["../c|/worker.js", "c|/worker.js"],
|
||||
["/nested/../c|/worker.js", "c|/worker.js"],
|
||||
["c|/../worker.js", "dist/worker.js"],
|
||||
])("leaves drive-letter URL paths unresolved: %s", (declaredPath, decoy) => {
|
||||
const resolution = resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
files: fileMap([file(decoy, "root")]),
|
||||
});
|
||||
expect(resolution).toMatchObject({
|
||||
declared_path: declaredPath,
|
||||
resolution_status: "unavailable",
|
||||
resolved_path: null,
|
||||
});
|
||||
expect(resolution.limitations.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["./c|/worker.js", "dist/c|/worker.js"],
|
||||
["c|name/worker.js", "dist/c|name/worker.js"],
|
||||
["/%63|/worker.js", "c|/worker.js"],
|
||||
["/c%3A/worker.js", "c:/worker.js"],
|
||||
])(
|
||||
"retains non-drive URL path identities: %s",
|
||||
(declaredPath, resolvedPath) => {
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.mjs",
|
||||
context: "url-reference",
|
||||
files: fileMap([file(resolvedPath, "root")]),
|
||||
}),
|
||||
).toMatchObject({
|
||||
declared_path: declaredPath,
|
||||
resolution_status: "resolved",
|
||||
resolved_path: resolvedPath,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["?value=", "#value="])(
|
||||
"admits the pathname independently of its %s suffix",
|
||||
(suffix) => {
|
||||
for (const value of ["%2F", "%2E", "%5C", "%00", "%FF", "\\", "\0"]) {
|
||||
const declaredPath = `./worker.js${suffix}${value}`;
|
||||
expect(
|
||||
resolve({
|
||||
declaredPath,
|
||||
sourcePath: "dist/app.js",
|
||||
context: "url-reference",
|
||||
files: fileMap([file("dist/worker.js", "root")]),
|
||||
}),
|
||||
declaredPath,
|
||||
).toMatchObject({
|
||||
declared_path: declaredPath,
|
||||
resolution_status: "resolved",
|
||||
resolved_path: "dist/worker.js",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"app\0.js.map?valid=1",
|
||||
"nested\\worker.js#valid",
|
||||
"app%00.js.map",
|
||||
"app%FF.js.map",
|
||||
"app%C0%AF.js.map",
|
||||
"app%2ejs.map",
|
||||
"nested%2fworker.js",
|
||||
"nested%5cworker.js",
|
||||
"h\nttp://[",
|
||||
"../../outside.js.map",
|
||||
"../../outside/.",
|
||||
])("rejects an inadmissible URL path: %s", (declaredPath) => {
|
||||
const resolution = resolve({
|
||||
declaredPath,
|
||||
|
|
|
|||
|
|
@ -153,17 +153,15 @@ const resolvePath = (input: ResolveArtifactPathInput): CandidateResolution => {
|
|||
input.declaredPath.startsWith("#")
|
||||
)
|
||||
return resolvePackageImportAlias(input, input.declaredPath);
|
||||
const rejected = rejectDeclaration(input.declaredPath);
|
||||
if (rejected !== null) return rejected;
|
||||
const candidate = contextualCandidate(input);
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const confined = confineCandidate(candidate);
|
||||
if (typeof confined !== "string") return confined;
|
||||
if (input.context === "html-reference")
|
||||
if (input.context === "html-reference" || input.context === "url-reference")
|
||||
return (
|
||||
resolveFileCandidates(input, [confined]) ??
|
||||
unresolvedCandidate("not-found", [
|
||||
`The exact HTML resource ${confined} was not found among the selected application files.`,
|
||||
`The exact ${input.context === "html-reference" ? "HTML" : "URL"} resource ${confined} was not found among the selected application files.`,
|
||||
])
|
||||
);
|
||||
const isModuleUrlPath =
|
||||
|
|
@ -201,10 +199,14 @@ const contextualCandidate = (
|
|||
input: ResolveArtifactPathInput,
|
||||
): string | CandidateResolution => {
|
||||
const { context } = input;
|
||||
// URL suffixes are evidence, not pathname bytes. Admit only the path while
|
||||
// retaining the complete declaration on the resolution result.
|
||||
let declared =
|
||||
context === "html-reference" || context === "url-reference"
|
||||
context === "url-reference"
|
||||
? stripQueryAndFragment(input.declaredPath)
|
||||
: input.declaredPath;
|
||||
const rejected = rejectDeclaration(declared);
|
||||
if (rejected !== null) return rejected;
|
||||
if (context === "html-reference") return htmlCandidate(input);
|
||||
if (context === "module-specifier") {
|
||||
const fileUrl = fileUrlPath(declared);
|
||||
|
|
@ -230,12 +232,39 @@ const contextualCandidate = (
|
|||
"URL schemes and protocol-relative URLs are outside this local artifact path context.",
|
||||
]);
|
||||
if (context === "url-reference") {
|
||||
// A file base gives raw drive-letter segments special meaning, including
|
||||
// after dot segments reach the root. Parse before percent decoding so an
|
||||
// encoded drive-like filename stays literal. The synthetic base establishes
|
||||
// only this ambiguity; it does not establish the source's runtime scheme.
|
||||
const sourceUrlPath = input.sourcePath
|
||||
.split("/")
|
||||
.map(encodeURIComponent)
|
||||
.join("/");
|
||||
const fileReference = URL.parse(declared, `file:///${sourceUrlPath}`);
|
||||
if (fileReference === null)
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The URL reference cannot be parsed as a URL.",
|
||||
]);
|
||||
if (/^\/[A-Za-z]:(?:\/|$)/u.test(fileReference.pathname))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Drive-letter URL paths cannot be mapped without the source URL scheme and filesystem root.",
|
||||
]);
|
||||
const decoded = percentDecodeUrlPath(declared);
|
||||
if (decoded === null)
|
||||
return unresolvedCandidate("rejected", [
|
||||
"The URL reference path percent-decodes to NUL or to bytes that are not UTF-8.",
|
||||
]);
|
||||
declared = decoded;
|
||||
// Empty URL segments are significant; canonical filesystem paths cannot
|
||||
// preserve them, including their interaction with following dot segments.
|
||||
if (decoded.includes("//"))
|
||||
return unresolvedCandidate("unavailable", [
|
||||
"Repeated URL path separators cannot be mapped to an exact canonical artifact file.",
|
||||
]);
|
||||
return exactUrlPathCandidate(
|
||||
decoded,
|
||||
relativeCandidate(input.sourcePath, decoded),
|
||||
"URL",
|
||||
);
|
||||
}
|
||||
return relativeCandidate(input.sourcePath, declared);
|
||||
};
|
||||
|
|
@ -689,37 +718,34 @@ const htmlCandidate = (
|
|||
const basePath = decodedBase.startsWith("/")
|
||||
? decodedBase.slice(1)
|
||||
: posix.join(posix.dirname(input.sourcePath), decodedBase);
|
||||
if (declaredPath === "") return emptyHtmlReference(decodedBase, basePath);
|
||||
if (declaredPath === "")
|
||||
return exactUrlPathCandidate(decodedBase, basePath, "HTML");
|
||||
return posix.join(htmlBaseDirectory(decodedBase, basePath), declaredPath);
|
||||
};
|
||||
|
||||
const emptyHtmlReference = (
|
||||
base: string,
|
||||
basePath: string,
|
||||
const exactUrlPathCandidate = (
|
||||
declared: string,
|
||||
candidate: string,
|
||||
kind: "HTML" | "URL",
|
||||
): string | CandidateResolution => {
|
||||
if (!htmlBaseIsDirectory(base)) return basePath;
|
||||
const confined = confineCandidate(basePath);
|
||||
if (!urlPathIsDirectory(declared)) return candidate;
|
||||
const confined = confineCandidate(candidate);
|
||||
if (typeof confined !== "string") return confined;
|
||||
return unresolvedCandidate("not-found", [
|
||||
`The HTML reference resolves to directory ${confined || "."}, not an exact selected application file.`,
|
||||
`The ${kind} reference resolves to directory ${confined || "."}, not an exact selected application file.`,
|
||||
]);
|
||||
};
|
||||
|
||||
/**
|
||||
* The directory a relative HTML reference resolves against. A base path
|
||||
* ending in "/" or in a "." or ".." segment is already a directory, because a
|
||||
* relative reference resolves against the base URL's directory and those
|
||||
* segments are dropped rather than stepped through.
|
||||
*/
|
||||
const htmlBaseIsDirectory = (base: string): boolean =>
|
||||
base.endsWith("/") ||
|
||||
base.endsWith("/.") ||
|
||||
base.endsWith("/..") ||
|
||||
base === "." ||
|
||||
base === "..";
|
||||
/** Whether a URL path retains directory form after dot-segment normalization. */
|
||||
const urlPathIsDirectory = (path: string): boolean =>
|
||||
path.endsWith("/") ||
|
||||
path.endsWith("/.") ||
|
||||
path.endsWith("/..") ||
|
||||
path === "." ||
|
||||
path === "..";
|
||||
|
||||
const htmlBaseDirectory = (base: string, basePath: string): string =>
|
||||
htmlBaseIsDirectory(base) ? basePath : posix.dirname(basePath);
|
||||
urlPathIsDirectory(base) ? basePath : posix.dirname(basePath);
|
||||
|
||||
const confineCandidate = (candidate: string): string | CandidateResolution => {
|
||||
const normalized = normalizeJoinedLogicalPath(candidate);
|
||||
|
|
@ -862,8 +888,6 @@ const resolveDirectory = (
|
|||
files: input.files,
|
||||
...(input.moduleKind === undefined ? {} : { moduleKind: input.moduleKind }),
|
||||
};
|
||||
const rejected = rejectDeclaration(entryInput.declaredPath);
|
||||
if (rejected !== null) return rejected;
|
||||
const candidate = contextualCandidate(entryInput);
|
||||
if (typeof candidate !== "string") return candidate;
|
||||
const confined = confineCandidate(candidate);
|
||||
|
|
|
|||
|
|
@ -52,13 +52,17 @@ export interface JavaScriptIntrinsicInvocation {
|
|||
| undefined;
|
||||
}
|
||||
|
||||
/** Index member and binding writes through caller-proven lexical identities. */
|
||||
/**
|
||||
* Index writes through possible lexical object identities. Resolve the complete
|
||||
* object before appending the written key: self-reference properties can denote
|
||||
* the same object, while replacing that property is a distinct write.
|
||||
*/
|
||||
export function* collectJavaScriptIdentityWritesSteps(
|
||||
program: t.Program,
|
||||
origin: (
|
||||
root: t.Node,
|
||||
kind: "binding" | "member" | "opaque-call",
|
||||
) => readonly string[] | undefined,
|
||||
) => readonly (readonly (string | null)[])[],
|
||||
intrinsicInvocation: (
|
||||
node: t.CallExpression | t.OptionalCallExpression,
|
||||
) => JavaScriptIntrinsicInvocation | undefined,
|
||||
|
|
@ -84,8 +88,7 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) {
|
||||
const invocation = intrinsicInvocation(node);
|
||||
if (invocation?.kind === "opaque") {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
for (const scope of origin(node, "opaque-call")) retain(node, scope);
|
||||
return;
|
||||
}
|
||||
const callee = invocation?.path;
|
||||
|
|
@ -114,20 +117,20 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
// A known mutator with an opaque argument list can overwrite any
|
||||
// captured intrinsic. Do not turn an unexamined target into proof.
|
||||
if (target === undefined || t.isSpreadElement(target)) {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
for (const scope of origin(node, "opaque-call"))
|
||||
retain(node, scope);
|
||||
}
|
||||
if (t.isNode(target) && !t.isSpreadElement(target)) {
|
||||
const { root, members } = electronMemberPath(target);
|
||||
const exported = origin(root, "member");
|
||||
if (exported !== undefined) {
|
||||
const identities = origin(target, "member");
|
||||
if (identities.length > 0) {
|
||||
const key = keyedMutators.has(method ?? "")
|
||||
? [staticString(arguments_?.[1]) ?? null]
|
||||
: [];
|
||||
retain(node, [...exported, ...members, ...key]);
|
||||
for (const identity of identities)
|
||||
retain(node, [...identity, ...key]);
|
||||
} else if (!freshObject(target)) {
|
||||
const scope = origin(node, "opaque-call");
|
||||
if (scope !== undefined) retain(node, scope);
|
||||
for (const scope of origin(node, "opaque-call"))
|
||||
retain(node, scope);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -145,13 +148,19 @@ export function* collectJavaScriptIdentityWritesSteps(
|
|||
if (target === undefined) return;
|
||||
for (const leaf of semanticWriteTargets(target)) {
|
||||
if (t.isIdentifier(leaf)) {
|
||||
const exported = origin(leaf, "binding");
|
||||
if (exported !== undefined) retain(node, exported);
|
||||
for (const identity of origin(leaf, "binding"))
|
||||
retain(node, identity);
|
||||
continue;
|
||||
}
|
||||
const { root, members } = electronMemberPath(leaf);
|
||||
const exported = origin(root, "member");
|
||||
if (exported !== undefined) retain(node, [...exported, ...members]);
|
||||
const member = unwrapJavaScriptExpression(leaf).node;
|
||||
if (
|
||||
!t.isMemberExpression(member) &&
|
||||
!t.isOptionalMemberExpression(member)
|
||||
)
|
||||
continue;
|
||||
const key = semanticStaticPropertyKey(member.property, member.computed);
|
||||
for (const identity of origin(member.object, "member"))
|
||||
retain(node, [...identity, key]);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,13 +6,52 @@ import {
|
|||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
|
||||
const arrayArguments = (array: t.Node | undefined) =>
|
||||
t.isArrayExpression(array) &&
|
||||
array.elements.every((element) => t.isExpression(element))
|
||||
? array.elements.filter((element): element is t.Expression =>
|
||||
/** Expand only literal array spreads whose positional arguments are all known. */
|
||||
export const expandJavaScriptInvocationArguments = (
|
||||
arguments_: readonly t.CallExpression["arguments"][number][],
|
||||
): t.CallExpression["arguments"] => {
|
||||
const expanded: t.CallExpression["arguments"] = [];
|
||||
const pending = [...arguments_].reverse();
|
||||
while (pending.length > 0) {
|
||||
const argument = pending.pop();
|
||||
if (argument === undefined) break;
|
||||
const array = t.isSpreadElement(argument)
|
||||
? unwrapJavaScriptExpression(argument.argument).node
|
||||
: undefined;
|
||||
if (
|
||||
t.isArrayExpression(array) &&
|
||||
array.elements.every(
|
||||
(element) => t.isExpression(element) || t.isSpreadElement(element),
|
||||
)
|
||||
)
|
||||
for (let index = array.elements.length - 1; index >= 0; index -= 1) {
|
||||
const element = array.elements[index];
|
||||
if (element !== null && element !== undefined) pending.push(element);
|
||||
}
|
||||
else expanded.push(argument);
|
||||
}
|
||||
return expanded;
|
||||
};
|
||||
|
||||
const arrayArguments = (node: t.Node | undefined) => {
|
||||
const array =
|
||||
node === undefined ? undefined : unwrapJavaScriptExpression(node).node;
|
||||
if (
|
||||
!t.isArrayExpression(array) ||
|
||||
array.elements.some((element) => element === null)
|
||||
)
|
||||
return undefined;
|
||||
const expanded = expandJavaScriptInvocationArguments(
|
||||
array.elements.filter(
|
||||
(element): element is t.Expression | t.SpreadElement => element !== null,
|
||||
),
|
||||
);
|
||||
return expanded.every((element) => t.isExpression(element))
|
||||
? expanded.filter((element): element is t.Expression =>
|
||||
t.isExpression(element),
|
||||
)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const invocationArguments = (
|
||||
method: "call" | "apply",
|
||||
|
|
@ -36,7 +75,8 @@ export const resolveJavaScriptIntrinsicInvocation = (
|
|||
): JavaScriptIntrinsicInvocation | undefined => {
|
||||
const visited = new Set<t.Node>();
|
||||
let callee: t.Node = node.callee;
|
||||
let arguments_: JavaScriptIntrinsicInvocation["arguments"] = node.arguments;
|
||||
let arguments_: JavaScriptIntrinsicInvocation["arguments"] =
|
||||
expandJavaScriptInvocationArguments(node.arguments);
|
||||
let receiver: t.Node | undefined;
|
||||
let explicitReceiver = false;
|
||||
for (;;) {
|
||||
|
|
|
|||
|
|
@ -19,7 +19,10 @@ import {
|
|||
electronMemberPath,
|
||||
type JavaScriptIntrinsicInvocation,
|
||||
} from "./javascriptElectronMemberWrites.js";
|
||||
import { resolveJavaScriptIntrinsicInvocation } from "./javascriptIntrinsicInvocation.js";
|
||||
import {
|
||||
expandJavaScriptInvocationArguments,
|
||||
resolveJavaScriptIntrinsicInvocation,
|
||||
} from "./javascriptIntrinsicInvocation.js";
|
||||
import { SemanticPropertyPathCoverage } from "./javascriptSemanticPropertyPaths.js";
|
||||
import {
|
||||
readExactJavaScriptLiteral,
|
||||
|
|
@ -74,6 +77,9 @@ interface InteropHelper {
|
|||
/** Prove one inert namespace conversion without relaxing general assignment semantics. */
|
||||
export const createSemanticModuleInterop = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
options?: {
|
||||
readonly intrinsicPath: (node: t.Node) => readonly string[] | undefined;
|
||||
},
|
||||
) => {
|
||||
const helpers = new Map<string, InteropHelper | null>();
|
||||
const origins = new Map<string, InteropOrigin>();
|
||||
|
|
@ -96,55 +102,57 @@ export const createSemanticModuleInterop = (
|
|||
return ["intrinsic"];
|
||||
};
|
||||
|
||||
const intrinsicPath = (node: t.Node): readonly string[] | undefined => {
|
||||
let current = node;
|
||||
const visited = new Set<string>();
|
||||
const pending: { id: string; suffixLength: number }[] = [];
|
||||
let suffix: readonly string[] = [];
|
||||
const finish = (
|
||||
path: readonly string[] | undefined,
|
||||
): readonly string[] | undefined => {
|
||||
for (const binding of pending)
|
||||
intrinsicBindings.set(
|
||||
binding.id,
|
||||
path === undefined
|
||||
? null
|
||||
: path.slice(0, path.length - binding.suffixLength),
|
||||
);
|
||||
return path;
|
||||
};
|
||||
for (;;) {
|
||||
const { root, members } = electronMemberPath(current);
|
||||
if (members.some((member) => member === null)) return finish(undefined);
|
||||
suffix = [...(members as readonly string[]), ...suffix];
|
||||
for (const global of ["Object", "Function", "Reflect"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", global, ...suffix]);
|
||||
for (const global of ["globalThis", "global", "window", "self"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", ...suffix]);
|
||||
if (!t.isIdentifier(root)) return finish(undefined);
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
const cached =
|
||||
binding === undefined
|
||||
? undefined
|
||||
: intrinsicBindings.get(binding.bindingId);
|
||||
if (cached !== undefined)
|
||||
return finish(cached === null ? undefined : [...cached, ...suffix]);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.some((segment) => segment === null) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return finish(undefined);
|
||||
visited.add(binding.bindingId);
|
||||
pending.push({ id: binding.bindingId, suffixLength: suffix.length });
|
||||
suffix = [...initializer.projection.map(String), ...suffix];
|
||||
current = initializer.node;
|
||||
}
|
||||
};
|
||||
const intrinsicPath =
|
||||
options?.intrinsicPath ??
|
||||
((node: t.Node): readonly string[] | undefined => {
|
||||
let current = node;
|
||||
const visited = new Set<string>();
|
||||
const pending: { id: string; suffixLength: number }[] = [];
|
||||
let suffix: readonly string[] = [];
|
||||
const finish = (
|
||||
path: readonly string[] | undefined,
|
||||
): readonly string[] | undefined => {
|
||||
for (const binding of pending)
|
||||
intrinsicBindings.set(
|
||||
binding.id,
|
||||
path === undefined
|
||||
? null
|
||||
: path.slice(0, path.length - binding.suffixLength),
|
||||
);
|
||||
return path;
|
||||
};
|
||||
for (;;) {
|
||||
const { root, members } = electronMemberPath(current);
|
||||
if (members.some((member) => member === null)) return finish(undefined);
|
||||
suffix = [...(members as readonly string[]), ...suffix];
|
||||
for (const global of ["Object", "Function", "Reflect"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", global, ...suffix]);
|
||||
for (const global of ["globalThis", "global", "window", "self"])
|
||||
if (isUnshadowedGlobal(root, state, global))
|
||||
return finish(["intrinsic", ...suffix]);
|
||||
if (!t.isIdentifier(root)) return finish(undefined);
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
const cached =
|
||||
binding === undefined
|
||||
? undefined
|
||||
: intrinsicBindings.get(binding.bindingId);
|
||||
if (cached !== undefined)
|
||||
return finish(cached === null ? undefined : [...cached, ...suffix]);
|
||||
const initializer = semanticStableInitializer(binding, state);
|
||||
if (
|
||||
binding === undefined ||
|
||||
initializer === undefined ||
|
||||
initializer.projection.some((segment) => segment === null) ||
|
||||
visited.has(binding.bindingId)
|
||||
)
|
||||
return finish(undefined);
|
||||
visited.add(binding.bindingId);
|
||||
pending.push({ id: binding.bindingId, suffixLength: suffix.length });
|
||||
suffix = [...initializer.projection.map(String), ...suffix];
|
||||
current = initializer.node;
|
||||
}
|
||||
});
|
||||
|
||||
// A nominal alias path is sufficient for conservative mutation lookup, but
|
||||
// helper proof also needs each read to follow initialization. An alias copy
|
||||
|
|
@ -263,7 +271,7 @@ export const createSemanticModuleInterop = (
|
|||
const { root, members } = electronMemberPath(current.callee);
|
||||
let bindTarget = root;
|
||||
let bindingArguments: JavaScriptIntrinsicInvocation["arguments"] =
|
||||
current.arguments;
|
||||
expandJavaScriptInvocationArguments(current.arguments);
|
||||
let captured = capturedIntrinsic(current.callee);
|
||||
let terminal = captured?.path.at(-1) === "bind";
|
||||
let path: readonly string[] | undefined = captured?.path.slice(0, -1);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,57 @@
|
|||
import { expect, it } from "vitest";
|
||||
|
||||
import { analyzeJavaScriptStaticSource } from "./javascriptStaticAnalysis.js";
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "reconverging projections with different alias-cycle histories",
|
||||
setup: `var x; const a = { foo: x, bar: globalThis }; x = a.bar;
|
||||
const y = flag ? x : a.foo; y.URL = replacement;`,
|
||||
specifier: null,
|
||||
},
|
||||
{
|
||||
name: "growing self-reference projections with an ambient initializer",
|
||||
setup: `let alias = globalThis; alias = alias.globalThis;
|
||||
alias.URL = replacement;`,
|
||||
specifier: null,
|
||||
},
|
||||
{
|
||||
name: "distinct properties selecting both ambient identities",
|
||||
setup: `const box = { left: globalThis, right: import.meta };
|
||||
const selected = flag ? box.left : box.right; selected.URL = replacement;`,
|
||||
specifier: null,
|
||||
},
|
||||
{
|
||||
name: "cyclic containers with a reachable ambient property",
|
||||
setup: `var alias; const box = { next: alias, target: import.meta };
|
||||
alias = box; const selected = flag ? alias.target : alias.next.target;
|
||||
selected.url = "file:///other/entry.mjs";`,
|
||||
specifier: null,
|
||||
},
|
||||
{
|
||||
name: "cycles containing no ambient identity",
|
||||
setup: `var box; box = { next: box };
|
||||
const selected = flag ? box : box.next; selected.URL = replacement;`,
|
||||
specifier: "./worker.js",
|
||||
},
|
||||
{
|
||||
name: "unrelated writes through widened self-reference projections",
|
||||
setup: `let alias = globalThis; alias = alias.globalThis;
|
||||
alias.unrelated = replacement;`,
|
||||
specifier: "./worker.js",
|
||||
},
|
||||
{
|
||||
name: "an exact empty projection into a shallow copy",
|
||||
setup: `const copy = { ...globalThis };
|
||||
const selected = flag ? copy : copy; selected.URL = replacement;`,
|
||||
specifier: "./worker.js",
|
||||
},
|
||||
])("preserves $name", ({ setup, specifier }) => {
|
||||
const source = `import { Worker } from "node:worker_threads";
|
||||
${setup}
|
||||
new Worker(new URL("./worker.js", import.meta.url));`;
|
||||
const worker = analyzeJavaScriptStaticSource(source).references.find(
|
||||
(reference) => reference.kind === "worker",
|
||||
);
|
||||
expect(worker?.specifier).toBe(specifier);
|
||||
});
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
semanticStaticPropertyKey,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import {
|
||||
collectJavaScriptIdentityWritesSteps,
|
||||
electronMemberPath,
|
||||
} from "./javascriptElectronMemberWrites.js";
|
||||
import { createSemanticModuleInterop } from "./javascriptModuleInterop.js";
|
||||
import {
|
||||
isUnshadowedGlobal,
|
||||
resolveSemanticBindingState,
|
||||
semanticStableInitializer,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import { isImportMetaUrl } from "./javascriptStaticAnalysisHelpers.js";
|
||||
|
||||
const GLOBAL_OBJECT_NAMES = ["globalThis", "global", "window", "self"];
|
||||
|
||||
const globalObjectName = (
|
||||
node: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined =>
|
||||
GLOBAL_OBJECT_NAMES.find((name) => isUnshadowedGlobal(node, state, name));
|
||||
|
||||
/** Global self references retain object identity, rather than adding a property. */
|
||||
const canonicalAmbientPath = (
|
||||
path: readonly (string | null)[],
|
||||
): readonly (string | null)[] => {
|
||||
if (path[0] !== "global") return path;
|
||||
let firstProperty = 1;
|
||||
while (GLOBAL_OBJECT_NAMES.includes(path[firstProperty] ?? ""))
|
||||
firstProperty += 1;
|
||||
return ["global", ...path.slice(firstProperty)];
|
||||
};
|
||||
|
||||
type AmbientPath = readonly (string | null)[];
|
||||
|
||||
/** Undefined means a cyclic or conflicting projection can select any depth. */
|
||||
type AmbientProjection = AmbientPath | undefined;
|
||||
|
||||
/**
|
||||
* Follow local object identities without equating shallow copies with their
|
||||
* sources. Write lookup retains every visible initializer: reassigning an alias
|
||||
* later cannot erase a possible earlier mutation. Intrinsic reads stay strict.
|
||||
*/
|
||||
const createAmbientPathResolver = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
possibleWrites: boolean,
|
||||
) => {
|
||||
const cached = new WeakMap<t.Node, readonly AmbientPath[]>();
|
||||
return (node: t.Node): readonly AmbientPath[] => {
|
||||
const prior = cached.get(node);
|
||||
if (prior !== undefined) return prior;
|
||||
const result = new Map<string, AmbientPath>();
|
||||
const pending: { node: t.Node; suffix: AmbientProjection }[] = [
|
||||
{ node, suffix: [] },
|
||||
];
|
||||
// Join projections at their producing expression, rather than enumerating
|
||||
// alias histories. Each key can only widen to a wildcard, and conflicting
|
||||
// depths widen to unknown, so diamonds and cycles have finite state.
|
||||
const expanded = new WeakMap<t.Node, AmbientProjection>();
|
||||
const retain = (path: AmbientPath) => {
|
||||
let canonical = canonicalAmbientPath(path);
|
||||
for (;;) {
|
||||
result.set(JSON.stringify(canonical), canonical);
|
||||
// An unknown key may select a global self reference as well as a
|
||||
// distinct property. Retain both possible object identities for writes.
|
||||
if (
|
||||
!possibleWrites ||
|
||||
canonical[0] !== "global" ||
|
||||
canonical[1] !== null
|
||||
)
|
||||
break;
|
||||
canonical = canonicalAmbientPath(["global", ...canonical.slice(2)]);
|
||||
}
|
||||
};
|
||||
while (pending.length > 0) {
|
||||
const current = pending.pop();
|
||||
if (current === undefined) break;
|
||||
const { root, members } = electronMemberPath(current.node);
|
||||
let suffix: AmbientProjection =
|
||||
current.suffix === undefined
|
||||
? undefined
|
||||
: [...members, ...current.suffix];
|
||||
if (expanded.has(root)) {
|
||||
const previous = expanded.get(root);
|
||||
if (previous === undefined) continue;
|
||||
if (suffix !== undefined && previous.length === suffix.length) {
|
||||
const joined: AmbientPath = previous.map((key, index) =>
|
||||
key === suffix?.[index] ? key : null,
|
||||
);
|
||||
if (joined.every((key, index) => key === previous[index])) continue;
|
||||
suffix = joined;
|
||||
} else suffix = undefined;
|
||||
if (!possibleWrites) {
|
||||
cached.set(node, []);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
expanded.set(root, suffix);
|
||||
// A widened projection is a possible write, never proof of an intrinsic.
|
||||
if (suffix === undefined && !possibleWrites) continue;
|
||||
const continueWith = (node: t.Node, path: AmbientProjection = suffix) =>
|
||||
pending.push({ node, suffix: path });
|
||||
if (
|
||||
t.isMetaProperty(root) &&
|
||||
root.meta.name === "import" &&
|
||||
root.property.name === "meta"
|
||||
) {
|
||||
retain(["import-meta", ...(suffix ?? [])]);
|
||||
continue;
|
||||
}
|
||||
if (t.isIdentifier(root)) {
|
||||
if (globalObjectName(root, state) !== undefined) {
|
||||
retain(["global", ...(suffix ?? [])]);
|
||||
continue;
|
||||
}
|
||||
if (isUnshadowedGlobal(root, state, root.name)) {
|
||||
retain(["global", root.name, ...(suffix ?? [])]);
|
||||
continue;
|
||||
}
|
||||
const binding = resolveSemanticBindingState(state, root, root.name);
|
||||
if (binding === undefined) continue;
|
||||
const stable = semanticStableInitializer(binding, state);
|
||||
const initializers = possibleWrites
|
||||
? binding.initializers
|
||||
: stable === undefined
|
||||
? []
|
||||
: [stable];
|
||||
for (const initializer of initializers)
|
||||
pending.push({
|
||||
node: initializer.node,
|
||||
suffix:
|
||||
suffix === undefined
|
||||
? undefined
|
||||
: [
|
||||
...initializer.projection.map((key) =>
|
||||
key === null ? null : String(key),
|
||||
),
|
||||
...suffix,
|
||||
],
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (possibleWrites && t.isConditionalExpression(root)) {
|
||||
continueWith(root.consequent);
|
||||
continueWith(root.alternate);
|
||||
continue;
|
||||
}
|
||||
if (possibleWrites && t.isLogicalExpression(root)) {
|
||||
continueWith(root.left);
|
||||
continueWith(root.right);
|
||||
continue;
|
||||
}
|
||||
const key = suffix?.[0];
|
||||
if (suffix !== undefined && key === undefined) continue;
|
||||
if (t.isObjectExpression(root)) {
|
||||
if (possibleWrites) {
|
||||
for (const property of root.properties) {
|
||||
if (t.isSpreadElement(property)) continueWith(property.argument);
|
||||
else if (
|
||||
t.isObjectProperty(property) &&
|
||||
t.isExpression(property.value)
|
||||
) {
|
||||
const ownKey = semanticStaticPropertyKey(
|
||||
property.key,
|
||||
property.computed,
|
||||
);
|
||||
if (
|
||||
suffix === undefined ||
|
||||
key === null ||
|
||||
ownKey === null ||
|
||||
ownKey === key
|
||||
)
|
||||
continueWith(property.value, suffix?.slice(1));
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
suffix !== undefined &&
|
||||
key !== null &&
|
||||
root.properties.every(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) !==
|
||||
null,
|
||||
)
|
||||
) {
|
||||
const property = root.properties.findLast(
|
||||
(property) =>
|
||||
t.isObjectProperty(property) &&
|
||||
semanticStaticPropertyKey(property.key, property.computed) ===
|
||||
key,
|
||||
);
|
||||
if (t.isObjectProperty(property) && t.isExpression(property.value))
|
||||
continueWith(property.value, suffix.slice(1));
|
||||
}
|
||||
} else if (t.isArrayExpression(root)) {
|
||||
if (suffix === undefined) {
|
||||
for (const element of root.elements)
|
||||
if (t.isSpreadElement(element)) continueWith(element.argument);
|
||||
else if (t.isExpression(element)) continueWith(element);
|
||||
} else if (!root.elements.some(t.isSpreadElement)) {
|
||||
const index = Number(key);
|
||||
if (key === null && possibleWrites) {
|
||||
for (const element of root.elements)
|
||||
if (t.isExpression(element))
|
||||
continueWith(element, suffix.slice(1));
|
||||
} else if (
|
||||
key !== null &&
|
||||
Number.isInteger(index) &&
|
||||
index >= 0 &&
|
||||
String(index) === key
|
||||
) {
|
||||
const element = root.elements[index];
|
||||
if (t.isExpression(element)) continueWith(element, suffix.slice(1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const paths = [...result.values()];
|
||||
cached.set(node, paths);
|
||||
return paths;
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Index source-local writes through ambient identities and stable aliases.
|
||||
* The shared invocation owner resolves intrinsic aliases, bind/call/apply and
|
||||
* literal argument spreads. Runtime reachability and arbitrary call effects
|
||||
* remain unevaluated; a visible possible write is sufficient.
|
||||
*/
|
||||
export function* indexJavaScriptAmbientWritesSteps(
|
||||
program: t.Program,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): Generator<void> {
|
||||
const ambientReads = createAmbientPathResolver(state, false);
|
||||
const ambientWrites = createAmbientPathResolver(state, true);
|
||||
const interop = createSemanticModuleInterop(state, {
|
||||
intrinsicPath: (node) => {
|
||||
const [path] = ambientReads(node);
|
||||
return path?.[0] === "global" && path.every((key) => key !== null)
|
||||
? ["intrinsic", ...(path.slice(1) as readonly string[])]
|
||||
: undefined;
|
||||
},
|
||||
});
|
||||
yield* collectJavaScriptIdentityWritesSteps(
|
||||
program,
|
||||
(root, kind) => {
|
||||
if (kind === "opaque-call") return [];
|
||||
// Reassigning a local alias changes the binding, not the referenced object.
|
||||
if (kind === "binding")
|
||||
return t.isIdentifier(root) &&
|
||||
isUnshadowedGlobal(root, state, root.name)
|
||||
? [["global", root.name]]
|
||||
: [];
|
||||
return ambientWrites(root);
|
||||
},
|
||||
interop.intrinsicInvocation,
|
||||
interop.staticString,
|
||||
interop.isFreshObject,
|
||||
(_node, path) => {
|
||||
state.ambientWrites.retain(path);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Recognize an ambient name or direct global-object property without a visible
|
||||
* source-local overwrite. This is a syntax-local identity fact, not proof that
|
||||
* the runtime's globals have never been patched by other code.
|
||||
*/
|
||||
export const isUnmodifiedGlobalReference = (
|
||||
callee: t.Node,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
name: string,
|
||||
): boolean => {
|
||||
const node = unwrapJavaScriptExpression(callee).node;
|
||||
if (state.ambientWrites.covers(["global", name])) return false;
|
||||
if (isUnshadowedGlobal(node, state, name)) return true;
|
||||
if (
|
||||
!t.isMemberExpression(node) ||
|
||||
semanticStaticPropertyKey(node.property, node.computed) !== name
|
||||
)
|
||||
return false;
|
||||
const root = unwrapJavaScriptExpression(node.object).node;
|
||||
const namespace = globalObjectName(root, state);
|
||||
return (
|
||||
namespace !== undefined &&
|
||||
!state.ambientWrites.covers(["global", namespace])
|
||||
);
|
||||
};
|
||||
|
||||
/** A module URL base is known only while its property has no visible write. */
|
||||
export const isUnmodifiedImportMetaUrl = (
|
||||
node: t.Node | undefined,
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): boolean =>
|
||||
isImportMetaUrl(node) && !state.ambientWrites.covers(["import-meta", "url"]);
|
||||
|
|
@ -42,6 +42,7 @@ import {
|
|||
} from "./javascriptSemanticTraversal.js";
|
||||
import { semanticRequireOrigin } from "./javascriptSemanticRequireOrigin.js";
|
||||
import { collectSemanticMemberMutationsSteps } from "./javascriptSemanticMemberMutations.js";
|
||||
import { indexJavaScriptAmbientWritesSteps } from "./javascriptSemanticAmbientWrites.js";
|
||||
import {
|
||||
collectSemanticReturns,
|
||||
resolveSemanticModuleCallables,
|
||||
|
|
@ -99,6 +100,7 @@ interface AddBindingInput {
|
|||
readonly definitionNode: t.Node;
|
||||
readonly initializer: t.Node | null;
|
||||
readonly directOrigin?: JavaScriptModuleOrigin;
|
||||
readonly runtimeImport?: boolean;
|
||||
readonly projection?: readonly (string | number | null)[];
|
||||
}
|
||||
|
||||
|
|
@ -128,6 +130,7 @@ export function* indexJavaScriptBindingsSteps(
|
|||
): Generator<void, JavaScriptSemanticAnalysisState> {
|
||||
const state = createState(program);
|
||||
yield* collectDefinitionsSteps(program, state);
|
||||
yield* indexJavaScriptAmbientWritesSteps(program, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
|
|
@ -436,15 +439,18 @@ export function* indexJavaScriptElectronIdentitySteps(
|
|||
};
|
||||
yield* collectJavaScriptIdentityWritesSteps(
|
||||
program,
|
||||
(root, kind) => {
|
||||
if (kind === "opaque-call") return interop.opaqueMutationPath(root);
|
||||
(node, kind) => {
|
||||
if (kind === "opaque-call") return [interop.opaqueMutationPath(node)];
|
||||
const { root, members } = electronMemberPath(node);
|
||||
const exported = kind === "binding" ? undefined : rootExport(root);
|
||||
return exported === undefined
|
||||
? (interop.intrinsicPath(root) ??
|
||||
const path =
|
||||
exported === undefined
|
||||
? (interop.intrinsicPath(root) ??
|
||||
(kind === "member"
|
||||
? interop.boundIntrinsic(root)?.path
|
||||
: undefined))
|
||||
: ["electron", ...exported];
|
||||
: ["electron", ...exported];
|
||||
return path === undefined ? [] : [[...path, ...members]];
|
||||
},
|
||||
interop.intrinsicInvocation,
|
||||
interop.staticString,
|
||||
|
|
@ -550,6 +556,7 @@ const createState = (
|
|||
linkedRequireCalls: new WeakSet(),
|
||||
moduleLinkBindings: new WeakMap(),
|
||||
conditionalInitializers: new WeakSet(),
|
||||
ambientWrites: new SemanticPropertyPathCoverage(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
@ -1004,6 +1011,12 @@ const bindImports = (
|
|||
definitionNode: specifier.local,
|
||||
initializer: null,
|
||||
directOrigin: { specifier: node.source.value, importedPath },
|
||||
runtimeImport:
|
||||
node.importKind !== "type" &&
|
||||
node.importKind !== "typeof" &&
|
||||
(!t.isImportSpecifier(specifier) ||
|
||||
(specifier.importKind !== "type" &&
|
||||
specifier.importKind !== "typeof")),
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -1180,6 +1193,7 @@ const addBinding = (input: AddBindingInput): void => {
|
|||
definitionNode,
|
||||
initializer,
|
||||
directOrigin,
|
||||
runtimeImport,
|
||||
projection = [],
|
||||
} = input;
|
||||
let binding = scope.bindings.get(name);
|
||||
|
|
@ -1200,6 +1214,7 @@ const addBinding = (input: AddBindingInput): void => {
|
|||
? { ...directOrigin, resolutionMode: "require" }
|
||||
: directOrigin,
|
||||
);
|
||||
binding.runtimeImport ||= runtimeImport === true;
|
||||
};
|
||||
|
||||
const createBinding = (
|
||||
|
|
@ -1219,6 +1234,7 @@ const createBinding = (
|
|||
initializers: [],
|
||||
referenceInitializers: [],
|
||||
directOrigins: [],
|
||||
runtimeImport: false,
|
||||
});
|
||||
|
||||
const addAssignment = (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,81 @@
|
|||
/** An immutable persistent binary trie over one state's binding identities. */
|
||||
export type SemanticDependencyProof =
|
||||
| true
|
||||
| {
|
||||
readonly zero: SemanticDependencyProof;
|
||||
readonly one: SemanticDependencyProof;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
/**
|
||||
* Identity layout belongs to the indexed state, not to a query or its effects.
|
||||
* Every proof has the same height. Union shares unchanged branches, so aliases
|
||||
* add a path rather than copying their dependency's transitive membership set.
|
||||
*/
|
||||
export class SemanticDependencyProofs {
|
||||
private readonly indices = new Map<string, number>();
|
||||
private readonly singletons = new Map<string, SemanticDependencyProof>();
|
||||
private readonly height: number;
|
||||
|
||||
constructor(bindings: Iterable<string>) {
|
||||
for (const binding of bindings)
|
||||
this.indices.set(binding, this.indices.size);
|
||||
this.height = Math.ceil(Math.log2(Math.max(1, this.indices.size)));
|
||||
}
|
||||
|
||||
binding(binding: string): SemanticDependencyProof {
|
||||
const prior = this.singletons.get(binding);
|
||||
if (prior !== undefined) return prior;
|
||||
const index = this.index(binding);
|
||||
let proof: SemanticDependencyProof = true;
|
||||
for (let bit = 0; bit < this.height; bit++) {
|
||||
const one = Math.floor(index / 2 ** bit) % 2 === 1;
|
||||
proof = Object.freeze({
|
||||
zero: one ? undefined : proof,
|
||||
one: one ? proof : undefined,
|
||||
});
|
||||
}
|
||||
this.singletons.set(binding, proof);
|
||||
return proof;
|
||||
}
|
||||
|
||||
union(
|
||||
first: SemanticDependencyProof,
|
||||
second: SemanticDependencyProof,
|
||||
): SemanticDependencyProof {
|
||||
if (first === second || second === undefined) return first;
|
||||
if (first === undefined) return second;
|
||||
if (first === true || second === true) return true;
|
||||
const zero = this.union(first.zero, second.zero);
|
||||
const one = this.union(first.one, second.one);
|
||||
if (zero === first.zero && one === first.one) return first;
|
||||
if (zero === second.zero && one === second.one) return second;
|
||||
return Object.freeze({ zero, one });
|
||||
}
|
||||
|
||||
intersects(
|
||||
proof: SemanticDependencyProof,
|
||||
active: ReadonlyMap<string, number>,
|
||||
): boolean {
|
||||
for (const binding of active.keys())
|
||||
if (this.has(proof, binding)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
has(proof: SemanticDependencyProof, binding: string): boolean {
|
||||
const index = this.index(binding);
|
||||
for (let bit = this.height - 1; bit >= 0; bit--) {
|
||||
if (proof === undefined) return false;
|
||||
if (proof === true) return true;
|
||||
proof = Math.floor(index / 2 ** bit) % 2 === 1 ? proof.one : proof.zero;
|
||||
}
|
||||
return proof === true;
|
||||
}
|
||||
|
||||
private index(binding: string): number {
|
||||
const index = this.indices.get(binding);
|
||||
if (index === undefined)
|
||||
throw new TypeError(`Unindexed semantic dependency: ${binding}`);
|
||||
return index;
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
clearSemanticPrimitiveBindingValues,
|
||||
clearSemanticValueCaches,
|
||||
evaluateSemanticBinding,
|
||||
evaluateSemanticExpression,
|
||||
} from "./javascriptSemanticValues.js";
|
||||
|
|
@ -256,7 +256,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
if (effect === "escape")
|
||||
binding.escapedPaths.push({ path, node: mutation });
|
||||
else binding.mutatedPaths.push(path);
|
||||
clearSemanticPrimitiveBindingValues(state);
|
||||
clearSemanticValueCaches(state);
|
||||
for (const initializer of origins.initializers)
|
||||
pending.push({
|
||||
node: initializer.node,
|
||||
|
|
@ -548,7 +548,7 @@ export function* collectSemanticMemberMutationsSteps(
|
|||
}
|
||||
// Gathering effects can evaluate a primitive projection before its object
|
||||
// escapes. Final values must use the completed mutation state.
|
||||
clearSemanticPrimitiveBindingValues(state);
|
||||
clearSemanticValueCaches(state);
|
||||
}
|
||||
|
||||
interface ReferencedValue {
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ describe("parameter references copied into function body bindings", () => {
|
|||
initializers: [entry, assigned],
|
||||
referenceInitializers: [],
|
||||
directOrigins: [],
|
||||
runtimeImport: false,
|
||||
};
|
||||
const origins = semanticMutationInitializers(binding, mutation, parents);
|
||||
expect(origins.initializers.includes(entry)).toBe(retained);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,9 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import type { JavaScriptSemanticPropertyPath } from "./javascriptSemanticPropertyPaths.js";
|
||||
import type {
|
||||
JavaScriptSemanticPropertyPath,
|
||||
SemanticPropertyPathCoverage,
|
||||
} from "./javascriptSemanticPropertyPaths.js";
|
||||
|
||||
import type {
|
||||
JavaScriptModuleOrigin,
|
||||
|
|
@ -43,6 +46,8 @@ export interface JavaScriptSemanticBindingState {
|
|||
readonly initializers: JavaScriptSemanticInitializer[];
|
||||
readonly referenceInitializers: JavaScriptSemanticReferenceInitializer[];
|
||||
readonly directOrigins: JavaScriptModuleOrigin[];
|
||||
/** An ECMAScript import that exists at runtime, excluding type-only and import-equals syntax. */
|
||||
runtimeImport: boolean;
|
||||
}
|
||||
|
||||
/** Mutable lexical-scope state used only during AST recovery. */
|
||||
|
|
@ -75,6 +80,8 @@ export interface JavaScriptSemanticAnalysisState {
|
|||
JavaScriptSemanticBindingState
|
||||
>;
|
||||
readonly conditionalInitializers: WeakSet<t.Node>;
|
||||
/** Source-local writes, tagged by global-object or import.meta identity. */
|
||||
readonly ambientWrites: SemanticPropertyPathCoverage;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,227 @@
|
|||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
analyzeJavaScriptSemantics,
|
||||
indexJavaScriptBindingsSteps,
|
||||
} from "./javascriptSemanticAnalysis.js";
|
||||
import { SemanticDependencyProofs } from "./javascriptSemanticDependencies.js";
|
||||
import type { JavaScriptSemanticValue } from "./javascriptSemanticValueTypes.js";
|
||||
import {
|
||||
clearSemanticValueCaches,
|
||||
evaluateSemanticBinding,
|
||||
evaluateSemanticProvenance,
|
||||
} from "./javascriptSemanticValues.js";
|
||||
import { parseJavaScriptSource } from "./javascriptSourceParser.js";
|
||||
|
||||
const property = (
|
||||
value: JavaScriptSemanticValue,
|
||||
name: string,
|
||||
): JavaScriptSemanticValue => {
|
||||
if (value.status !== "object")
|
||||
throw new Error(`Expected object at ${name}, got ${value.status}`);
|
||||
const result = value.properties.find((entry) => entry.name === name)?.value;
|
||||
if (result === undefined) throw new Error(`Missing ${name}`);
|
||||
return result;
|
||||
};
|
||||
|
||||
it.each([false, true])(
|
||||
"recomputes a shared cyclic value under a different active ancestor (prewarmed: %s)",
|
||||
(prewarmed) => {
|
||||
const parsed = parseJavaScriptSource(
|
||||
`
|
||||
const a = { shared: common, own: "a" };
|
||||
const b = { shared: common, own: "b" };
|
||||
const common = { a, b };
|
||||
const result = { first: a, second: b };
|
||||
`,
|
||||
"cycles.js",
|
||||
);
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const steps = indexJavaScriptBindingsSteps(parsed.program);
|
||||
let step = steps.next();
|
||||
while (step.done !== true) step = steps.next();
|
||||
const state = step.value;
|
||||
const binding = [...state.bindingsById.values()].find(
|
||||
(entry) => entry.name === "result",
|
||||
);
|
||||
if (binding === undefined) throw new Error("Missing result binding");
|
||||
if (prewarmed)
|
||||
for (const entry of state.bindingsById.values())
|
||||
if (entry.name === "a" || entry.name === "b")
|
||||
evaluateSemanticBinding(entry, state);
|
||||
const value = evaluateSemanticBinding(binding, state);
|
||||
const firstCommon = property(property(value, "first"), "shared");
|
||||
const secondCommon = property(property(value, "second"), "shared");
|
||||
expect(property(firstCommon, "a").status).toBe("cycle");
|
||||
expect(property(property(firstCommon, "b"), "own")).toEqual({
|
||||
status: "literal",
|
||||
value: "b",
|
||||
});
|
||||
expect(property(secondCommon, "b").status).toBe("cycle");
|
||||
expect(property(property(secondCommon, "a"), "own")).toEqual({
|
||||
status: "literal",
|
||||
value: "a",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("invalidates retained alias values at the producing mutation boundary", () => {
|
||||
const parsed = parseJavaScriptSource(
|
||||
"const target = { value: 1 }; const alias = target; const root = { alias };",
|
||||
"mutation.js",
|
||||
);
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const steps = indexJavaScriptBindingsSteps(parsed.program);
|
||||
let step = steps.next();
|
||||
while (step.done !== true) step = steps.next();
|
||||
const state = step.value;
|
||||
const target = [...state.bindingsById.values()].find(
|
||||
({ name }) => name === "target",
|
||||
);
|
||||
const root = [...state.bindingsById.values()].find(
|
||||
({ name }) => name === "root",
|
||||
);
|
||||
if (target === undefined || root === undefined)
|
||||
throw new Error("Missing binding");
|
||||
expect(
|
||||
property(property(evaluateSemanticBinding(root, state), "alias"), "value"),
|
||||
).toEqual({ status: "literal", value: 1 });
|
||||
target.mutatedPaths.push(["value"]);
|
||||
clearSemanticValueCaches(state);
|
||||
expect(
|
||||
property(property(evaluateSemanticBinding(root, state), "alias"), "value")
|
||||
.status,
|
||||
).toBe("unknown");
|
||||
});
|
||||
|
||||
const balancedOr = (names: string[]): string => {
|
||||
if (names.length === 1) return names[0]!;
|
||||
const middle = Math.floor(names.length / 2);
|
||||
return `(${balancedOr(names.slice(0, middle))} || ${balancedOr(names.slice(middle))})`;
|
||||
};
|
||||
|
||||
it("shares dependency proofs across a shallow wide reconvergent graph", () => {
|
||||
const measure = (width: number) => {
|
||||
const imports = Array.from({ length: width }, (_, index) => `l${index}`);
|
||||
const aliases = Array.from({ length: width }, (_, index) => `a${index}`);
|
||||
const source = `${imports.map((name) => `import * as ${name} from "fs";`).join("\n")}
|
||||
const shared = ${balancedOr(imports)};
|
||||
function consumer() {
|
||||
${aliases.map((name) => `const ${name} = shared;`).join("\n")}
|
||||
const root = ${balancedOr(aliases)};
|
||||
}`;
|
||||
const parsed = parseJavaScriptSource(source, "wide.js");
|
||||
if (parsed === null) throw new Error("Expected parsed source");
|
||||
const steps = indexJavaScriptBindingsSteps(parsed.program);
|
||||
let step = steps.next();
|
||||
while (step.done !== true) step = steps.next();
|
||||
const state = step.value;
|
||||
const rootBinding = [...state.bindingsById.values()].find(
|
||||
({ name }) => name === "root",
|
||||
);
|
||||
if (rootBinding === undefined) throw new Error("Missing root binding");
|
||||
// One root query simultaneously retains every alias memo in this family.
|
||||
// Separate per-binding provenance queries have baseline repeated traversal;
|
||||
// they must not be confused with this cache's retained closure expansion.
|
||||
// Count representation construction and union work, not elapsed time/heap.
|
||||
// Binding-ID additions also discriminate the superseded flattened Sets.
|
||||
let bindingMemberships = 0;
|
||||
let proofBranches = 0;
|
||||
let unionCalls = 0;
|
||||
const add = Set.prototype.add;
|
||||
const freeze = Object.freeze;
|
||||
const union = SemanticDependencyProofs.prototype.union;
|
||||
const addSpy = vi.spyOn(Set.prototype, "add").mockImplementation(function (
|
||||
this: Set<unknown>,
|
||||
value: unknown,
|
||||
) {
|
||||
if (typeof value === "string" && value.includes("binding:"))
|
||||
bindingMemberships++;
|
||||
return add.call(this, value);
|
||||
});
|
||||
const freezeSpy = vi.spyOn(Object, "freeze").mockImplementation((value) => {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
Object.hasOwn(value, "zero") &&
|
||||
Object.hasOwn(value, "one") &&
|
||||
Object.keys(value).length === 2
|
||||
)
|
||||
proofBranches++;
|
||||
return freeze(value);
|
||||
});
|
||||
const unionSpy = vi
|
||||
.spyOn(SemanticDependencyProofs.prototype, "union")
|
||||
.mockImplementation(function (
|
||||
this: SemanticDependencyProofs,
|
||||
first,
|
||||
second,
|
||||
) {
|
||||
unionCalls++;
|
||||
return union.call(this, first, second);
|
||||
});
|
||||
try {
|
||||
expect(evaluateSemanticBinding(rootBinding, state).status).toBe(
|
||||
"ambiguous",
|
||||
);
|
||||
expect(evaluateSemanticProvenance(rootBinding, state).origins).toEqual([
|
||||
{ specifier: "fs", importedPath: [] },
|
||||
]);
|
||||
} finally {
|
||||
unionSpy.mockRestore();
|
||||
freezeSpy.mockRestore();
|
||||
addSpy.mockRestore();
|
||||
}
|
||||
const result = analyzeJavaScriptSemantics(source);
|
||||
expect(result.coverage).toEqual({ status: "complete", omittedCount: 0 });
|
||||
for (const name of [...imports, ...aliases, "shared", "root"])
|
||||
expect(
|
||||
result.bindings.filter((entry) => entry.name === name),
|
||||
).toHaveLength(1);
|
||||
const root = result.bindings.find((entry) => entry.name === "root");
|
||||
expect(root?.value.status).toBe("ambiguous");
|
||||
expect(root?.provenance.origins).toEqual([
|
||||
{ specifier: "fs", importedPath: [] },
|
||||
]);
|
||||
return {
|
||||
storage: bindingMemberships + proofBranches,
|
||||
work: bindingMemberships + unionCalls,
|
||||
};
|
||||
};
|
||||
// Balanced depths remain small; doubling both independent widths must not
|
||||
// quadruple proof construction/work while all meaningful aliases survive.
|
||||
const small = measure(32);
|
||||
const large = measure(64);
|
||||
expect(small.storage).toBeGreaterThan(0);
|
||||
expect(small.work).toBeGreaterThan(0);
|
||||
expect(large.storage).toBeLessThan(small.storage * 3);
|
||||
expect(large.work).toBeLessThan(small.work * 3);
|
||||
});
|
||||
|
||||
it("uses immutable exact identity proofs and shares unchanged union branches", () => {
|
||||
const proofs = new SemanticDependencyProofs(["a", "b", "c", "d", "e"]);
|
||||
const a = proofs.binding("a");
|
||||
const b = proofs.binding("b");
|
||||
const c = proofs.binding("c");
|
||||
const ab = proofs.union(a, b);
|
||||
const abc = proofs.union(ab, c);
|
||||
expect(proofs.union(abc, ab)).toBe(abc);
|
||||
expect(proofs.binding("a")).toBe(a);
|
||||
expect(Object.isFrozen(abc)).toBe(true);
|
||||
expect(["a", "b", "c", "d", "e"].map((id) => proofs.has(abc, id))).toEqual([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(proofs.has(undefined, "a")).toBe(false);
|
||||
expect(proofs.intersects(abc, new Map([["d", 0]]))).toBe(false);
|
||||
expect(proofs.intersects(abc, new Map([["c", 0]]))).toBe(true);
|
||||
const single = new SemanticDependencyProofs(["only"]);
|
||||
expect(single.has(single.binding("only"), "only")).toBe(true);
|
||||
expect(single.has(undefined, "only")).toBe(false);
|
||||
expect(() => proofs.binding("unindexed")).toThrow(
|
||||
"Unindexed semantic dependency",
|
||||
);
|
||||
});
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import {
|
||||
SemanticDependencyProofs,
|
||||
type SemanticDependencyProof,
|
||||
} from "./javascriptSemanticDependencies.js";
|
||||
|
||||
import {
|
||||
semanticCapturePosition,
|
||||
semanticEscapeFollowsCapture,
|
||||
|
|
@ -51,6 +56,39 @@ import {
|
|||
semanticResourceLimitReason,
|
||||
} from "./javascriptSemanticResourceLimits.js";
|
||||
|
||||
interface MemoizedEvaluation<T> {
|
||||
readonly value: T;
|
||||
readonly dependencies: SemanticDependencyProof;
|
||||
}
|
||||
|
||||
/** Provenance lives for one query; live values belong to the effects-aware state. */
|
||||
interface EvaluationMemo {
|
||||
readonly provenance: Map<
|
||||
string,
|
||||
Map<number, MemoizedEvaluation<JavaScriptBindingProvenance>>
|
||||
>;
|
||||
readonly dependencyFrames: { proof: SemanticDependencyProof }[];
|
||||
readonly dependencies: SemanticDependencyProofs;
|
||||
}
|
||||
|
||||
// Indexing owns stable binding identities; effects invalidate values, not this
|
||||
// identity-only layout. No query result or dependency closure is retained here.
|
||||
const dependencyProofsByState = new WeakMap<
|
||||
JavaScriptSemanticAnalysisState,
|
||||
SemanticDependencyProofs
|
||||
>();
|
||||
|
||||
const createEvaluationMemo = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): EvaluationMemo => {
|
||||
let dependencies = dependencyProofsByState.get(state);
|
||||
if (dependencies === undefined) {
|
||||
dependencies = new SemanticDependencyProofs(state.bindingsById.keys());
|
||||
dependencyProofsByState.set(state, dependencies);
|
||||
}
|
||||
return { provenance: new Map(), dependencyFrames: [], dependencies };
|
||||
};
|
||||
|
||||
interface EvaluationContext {
|
||||
readonly state: JavaScriptSemanticAnalysisState;
|
||||
/** Bindings under evaluation, by their position on the active stack. */
|
||||
|
|
@ -59,6 +97,7 @@ interface EvaluationContext {
|
|||
readonly capturePoint?: t.Node;
|
||||
readonly primitiveBindingValues: Map<string, JavaScriptSemanticValue>;
|
||||
readonly dependence: EvaluationDependence;
|
||||
readonly memo: EvaluationMemo;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -76,8 +115,7 @@ const CALLER_INDEPENDENT = Number.POSITIVE_INFINITY;
|
|||
const EVERY_CALLER = -1;
|
||||
|
||||
/** A value no caller changes, and the deepest evaluation known to yield it. */
|
||||
interface CallerIndependentBindingValue {
|
||||
readonly value: JavaScriptSemanticValue;
|
||||
interface CallerIndependentBindingValue extends MemoizedEvaluation<JavaScriptSemanticValue> {
|
||||
readonly expressionDepth: number;
|
||||
}
|
||||
|
||||
|
|
@ -101,8 +139,8 @@ const capturedBindingValuesByState = new WeakMap<
|
|||
WeakMap<t.Node, Map<string, JavaScriptSemanticValue>>
|
||||
>();
|
||||
|
||||
/** Discard primitive projections evaluated before mutation collection finished. */
|
||||
export const clearSemanticPrimitiveBindingValues = (
|
||||
/** Discard every state-owned value derived before the effects boundary changed. */
|
||||
export const clearSemanticValueCaches = (
|
||||
state: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
primitiveBindingValuesByState.delete(state);
|
||||
|
|
@ -151,6 +189,7 @@ export const evaluateSemanticBinding = (
|
|||
expressionDepth: 0,
|
||||
primitiveBindingValues: primitiveBindingValuesFor(state),
|
||||
dependence: { lowest: CALLER_INDEPENDENT },
|
||||
memo: createEvaluationMemo(state),
|
||||
});
|
||||
|
||||
/** Evaluate an inert expression, optionally at an earlier reference capture. */
|
||||
|
|
@ -165,6 +204,7 @@ export const evaluateSemanticExpression = (
|
|||
expressionDepth: 0,
|
||||
primitiveBindingValues: primitiveBindingValuesFor(state),
|
||||
dependence: { lowest: CALLER_INDEPENDENT },
|
||||
memo: createEvaluationMemo(state),
|
||||
};
|
||||
return evaluateExpression(
|
||||
node,
|
||||
|
|
@ -185,12 +225,60 @@ export const evaluateSemanticProvenance = (
|
|||
expressionDepth: 0,
|
||||
primitiveBindingValues: primitiveBindingValuesFor(state),
|
||||
dependence: { lowest: CALLER_INDEPENDENT },
|
||||
memo: createEvaluationMemo(state),
|
||||
});
|
||||
|
||||
const readMemoizedEvaluation = <T>(
|
||||
entry: MemoizedEvaluation<T> | undefined,
|
||||
context: EvaluationContext,
|
||||
): T | undefined => {
|
||||
if (
|
||||
entry === undefined ||
|
||||
context.memo.dependencies.intersects(entry.dependencies, context.bindings)
|
||||
)
|
||||
return undefined;
|
||||
retainDependencyProof(entry.dependencies, context);
|
||||
return entry.value;
|
||||
};
|
||||
|
||||
const retainDependencyProof = (
|
||||
proof: SemanticDependencyProof,
|
||||
context: EvaluationContext,
|
||||
): void => {
|
||||
const parent = context.memo.dependencyFrames.at(-1);
|
||||
if (parent !== undefined)
|
||||
parent.proof = context.memo.dependencies.union(parent.proof, proof);
|
||||
};
|
||||
|
||||
const recordBindingDependency = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
): void =>
|
||||
retainDependencyProof(
|
||||
context.memo.dependencies.binding(binding.bindingId),
|
||||
context,
|
||||
);
|
||||
|
||||
const collectEvaluationDependencies = <T>(
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
evaluate: () => T,
|
||||
): MemoizedEvaluation<T> => {
|
||||
const frame = { proof: context.memo.dependencies.binding(binding.bindingId) };
|
||||
context.memo.dependencyFrames.push(frame);
|
||||
try {
|
||||
return { value: evaluate(), dependencies: frame.proof };
|
||||
} finally {
|
||||
context.memo.dependencyFrames.pop();
|
||||
retainDependencyProof(frame.proof, context);
|
||||
}
|
||||
};
|
||||
|
||||
const evaluateBinding = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptSemanticValue => {
|
||||
recordBindingDependency(binding, context);
|
||||
const cycleHead = context.bindings.get(binding.bindingId);
|
||||
if (cycleHead !== undefined) {
|
||||
context.dependence.lowest = Math.min(context.dependence.lowest, cycleHead);
|
||||
|
|
@ -224,8 +312,10 @@ const evaluateBinding = (
|
|||
if (
|
||||
retained !== undefined &&
|
||||
context.expressionDepth <= retained.expressionDepth
|
||||
)
|
||||
return retained.value;
|
||||
) {
|
||||
const reusable = readMemoizedEvaluation(retained, context);
|
||||
if (reusable !== undefined) return reusable;
|
||||
}
|
||||
if (binding.initializers.length === 0)
|
||||
return {
|
||||
status: "unknown",
|
||||
|
|
@ -260,22 +350,30 @@ const evaluateBinding = (
|
|||
context.bindings.set(binding.bindingId, position);
|
||||
let value: JavaScriptSemanticValue;
|
||||
let callerIndependent = false;
|
||||
let evaluated: MemoizedEvaluation<JavaScriptSemanticValue>;
|
||||
try {
|
||||
const nested = nestedContext(context);
|
||||
value = projectValue(
|
||||
evaluateExpression(initializer.node, nested),
|
||||
initializer.projection,
|
||||
);
|
||||
if (!isPrimitive(value)) {
|
||||
const captureContext = primitiveCaptureContext(initializer.node, nested);
|
||||
if (captureContext !== undefined) {
|
||||
const captured = projectValue(
|
||||
evaluateExpression(initializer.node, captureContext),
|
||||
initializer.projection,
|
||||
evaluated = collectEvaluationDependencies(binding, context, () => {
|
||||
const nested = nestedContext(context);
|
||||
let value = projectValue(
|
||||
evaluateExpression(initializer.node, nested),
|
||||
initializer.projection,
|
||||
);
|
||||
if (!isPrimitive(value)) {
|
||||
const captureContext = primitiveCaptureContext(
|
||||
initializer.node,
|
||||
nested,
|
||||
);
|
||||
if (isPrimitive(captured)) value = captured;
|
||||
if (captureContext !== undefined) {
|
||||
const captured = projectValue(
|
||||
evaluateExpression(initializer.node, captureContext),
|
||||
initializer.projection,
|
||||
);
|
||||
if (isPrimitive(captured)) value = captured;
|
||||
}
|
||||
}
|
||||
}
|
||||
return value;
|
||||
});
|
||||
value = evaluated.value;
|
||||
// A cycle among bindings this one reads is part of its value. A cycle
|
||||
// through this binding is cut elsewhere when a caller holds another member.
|
||||
callerIndependent = context.dependence.lowest > position;
|
||||
|
|
@ -309,6 +407,7 @@ const evaluateBinding = (
|
|||
)
|
||||
independent.set(binding.bindingId, {
|
||||
value: projected,
|
||||
dependencies: evaluated.dependencies,
|
||||
expressionDepth: context.expressionDepth,
|
||||
});
|
||||
// Point-local memoization also bounds failed speculative captures. Only a
|
||||
|
|
@ -649,6 +748,50 @@ const projectValue = (
|
|||
const provenanceForBinding = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptBindingProvenance => {
|
||||
recordBindingDependency(binding, context);
|
||||
const cycleHead = context.bindings.get(binding.bindingId);
|
||||
if (cycleHead !== undefined) {
|
||||
context.dependence.lowest = Math.min(context.dependence.lowest, cycleHead);
|
||||
return semanticUnresolvedProvenance(
|
||||
"cycle",
|
||||
`Alias cycle at ${binding.name}.`,
|
||||
);
|
||||
}
|
||||
let depths = context.memo.provenance.get(binding.bindingId);
|
||||
const prior = readMemoizedEvaluation(
|
||||
depths?.get(context.expressionDepth),
|
||||
context,
|
||||
);
|
||||
if (prior !== undefined) return prior;
|
||||
const position = context.bindings.size;
|
||||
const callerDependence = context.dependence.lowest;
|
||||
context.dependence.lowest = CALLER_INDEPENDENT;
|
||||
let result: MemoizedEvaluation<JavaScriptBindingProvenance>;
|
||||
let callerIndependent = false;
|
||||
try {
|
||||
result = collectEvaluationDependencies(binding, context, () =>
|
||||
provenanceForBindingUncached(binding, context),
|
||||
);
|
||||
callerIndependent = context.dependence.lowest > position;
|
||||
} finally {
|
||||
context.dependence.lowest = callerIndependent
|
||||
? callerDependence
|
||||
: Math.min(callerDependence, context.dependence.lowest);
|
||||
}
|
||||
if (callerIndependent) {
|
||||
if (depths === undefined) {
|
||||
depths = new Map();
|
||||
context.memo.provenance.set(binding.bindingId, depths);
|
||||
}
|
||||
depths.set(context.expressionDepth, result);
|
||||
}
|
||||
return result.value;
|
||||
};
|
||||
|
||||
const provenanceForBindingUncached = (
|
||||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptBindingProvenance => {
|
||||
const provenance = provenanceForBindingValue(binding, context);
|
||||
const assigned = binding.definitions.some(
|
||||
|
|
@ -674,11 +817,6 @@ const provenanceForBindingValue = (
|
|||
binding: JavaScriptSemanticBindingState,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptBindingProvenance => {
|
||||
if (context.bindings.has(binding.bindingId))
|
||||
return semanticUnresolvedProvenance(
|
||||
"cycle",
|
||||
`Alias cycle at ${binding.name}.`,
|
||||
);
|
||||
if (binding.directOrigins.length > 0)
|
||||
return semanticOriginsProvenance(binding.directOrigins);
|
||||
if (binding.initializers.length === 0) return semanticLocalProvenance();
|
||||
|
|
@ -725,21 +863,25 @@ const provenanceForExpression = (
|
|||
node: t.Node,
|
||||
context: EvaluationContext,
|
||||
): JavaScriptBindingProvenance => {
|
||||
if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT)
|
||||
if (context.expressionDepth > SEMANTIC_EXPRESSION_DEPTH_LIMIT) {
|
||||
context.dependence.lowest = EVERY_CALLER;
|
||||
return semanticUnresolvedProvenance(
|
||||
"unknown",
|
||||
semanticResourceLimitReason("expression-depth"),
|
||||
);
|
||||
}
|
||||
const unwrapped = unwrapJavaScriptExpression(node);
|
||||
if (unwrapped.depth > 0) {
|
||||
if (
|
||||
context.expressionDepth + unwrapped.depth >
|
||||
SEMANTIC_EXPRESSION_DEPTH_LIMIT
|
||||
)
|
||||
) {
|
||||
context.dependence.lowest = EVERY_CALLER;
|
||||
return semanticUnresolvedProvenance(
|
||||
"unknown",
|
||||
semanticResourceLimitReason("expression-depth"),
|
||||
);
|
||||
}
|
||||
return provenanceForExpression(unwrapped.node, {
|
||||
...context,
|
||||
expressionDepth: context.expressionDepth + unwrapped.depth,
|
||||
|
|
@ -846,6 +988,7 @@ const nestedContext = (context: EvaluationContext): EvaluationContext => ({
|
|||
expressionDepth: context.expressionDepth + 1,
|
||||
bindings: context.bindings,
|
||||
dependence: context.dependence,
|
||||
memo: context.memo,
|
||||
});
|
||||
|
||||
const isPrimitive = (value: JavaScriptSemanticValue): boolean =>
|
||||
|
|
|
|||
|
|
@ -235,10 +235,11 @@ const inspectNode = (
|
|||
inspectBundlerBootstrap(source, node, accumulator, bindings);
|
||||
inspectBundlerRegistration(source, node, accumulator, bindings);
|
||||
inspectEsbuildWrapper(source, node, accumulator, bindings);
|
||||
inspectCall(source, node, findings);
|
||||
inspectCall(source, node, findings, bindings);
|
||||
} else if (t.isOptionalCallExpression(node)) {
|
||||
inspectCall(source, node, findings);
|
||||
} else if (t.isNewExpression(node)) inspectCall(source, node, findings);
|
||||
inspectCall(source, node, findings, bindings);
|
||||
} else if (t.isNewExpression(node))
|
||||
inspectCall(source, node, findings, bindings);
|
||||
if (t.isTSImportEqualsDeclaration(node)) {
|
||||
const specifier = semanticImportEqualsSpecifier(node);
|
||||
if (specifier !== null)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as t from "@babel/types";
|
||||
|
||||
import { htmlUrlText, stripQueryAndFragment } from "../artifactPathSyntax.js";
|
||||
import { sanitizeEndpointCandidate } from "../browserObservation.js";
|
||||
import {
|
||||
addFindingOnce,
|
||||
|
|
@ -7,6 +8,7 @@ import {
|
|||
} from "./javascriptStaticAnalysisFindings.js";
|
||||
import {
|
||||
STATIC_ROUTE_CALL_NAMES,
|
||||
argumentNode,
|
||||
argumentValue,
|
||||
calleeName,
|
||||
endpointArgument as endpointArgumentHelper,
|
||||
|
|
@ -19,13 +21,24 @@ import {
|
|||
storageKind,
|
||||
stringValue,
|
||||
} from "./javascriptStaticAnalysisHelpers.js";
|
||||
import { semanticStaticPropertyName } from "./javascriptAstValues.js";
|
||||
import {
|
||||
semanticStaticPropertyName,
|
||||
unwrapJavaScriptExpression,
|
||||
} from "./javascriptAstValues.js";
|
||||
import type {
|
||||
JavaScriptAnalysisAccumulator as AnalysisAccumulator,
|
||||
JavaScriptEndpointInput as EndpointInput,
|
||||
JavaScriptFindingContext as FindingContext,
|
||||
JavaScriptReferenceInput as ReferenceInput,
|
||||
} from "./javascriptStaticAnalysisState.js";
|
||||
import {
|
||||
resolveSemanticBindingState,
|
||||
type JavaScriptSemanticAnalysisState,
|
||||
} from "./javascriptSemanticState.js";
|
||||
import {
|
||||
isUnmodifiedGlobalReference,
|
||||
isUnmodifiedImportMetaUrl,
|
||||
} from "./javascriptSemanticAmbientWrites.js";
|
||||
|
||||
type JavaScriptCallLike =
|
||||
| t.CallExpression
|
||||
|
|
@ -37,6 +50,7 @@ export const inspectCall = (
|
|||
source: string,
|
||||
node: JavaScriptCallLike,
|
||||
context: FindingContext,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): void => {
|
||||
const { accumulator } = context;
|
||||
const name = calleeName(node.callee);
|
||||
|
|
@ -59,8 +73,27 @@ export const inspectCall = (
|
|||
});
|
||||
for (const specifier of vitePreloadDependencySpecifiers(source, node, name))
|
||||
addReference(context, { node, kind: "dynamic-import", specifier });
|
||||
if (name === "Worker" || name.endsWith(".Worker"))
|
||||
addReference(context, { node, kind: "worker", specifier: first });
|
||||
const worker = t.isNewExpression(node) ? node : undefined;
|
||||
const workerConstructor =
|
||||
worker === undefined
|
||||
? undefined
|
||||
: workerConstructorKind(worker.callee, bindings);
|
||||
if (
|
||||
workerConstructor !== undefined ||
|
||||
name === "Worker" ||
|
||||
name.endsWith(".Worker")
|
||||
)
|
||||
addReference(context, {
|
||||
node,
|
||||
kind: "worker",
|
||||
// Node string paths use the process cwd or filesystem root, neither of
|
||||
// which is established by the analyzed source's artifact location.
|
||||
specifier:
|
||||
(workerConstructor === "node" ? undefined : first) ??
|
||||
(workerConstructor === undefined || worker === undefined
|
||||
? undefined
|
||||
: workerUrlSpecifier(worker, bindings)),
|
||||
});
|
||||
if (name.endsWith("serviceWorker.register"))
|
||||
addReference(context, { node, kind: "service-worker", specifier: first });
|
||||
inspectEndpointCall(node, name, first, context);
|
||||
|
|
@ -97,6 +130,69 @@ export const inspectCall = (
|
|||
});
|
||||
};
|
||||
|
||||
const workerUrlSpecifier = (
|
||||
worker: t.NewExpression,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): string | undefined => {
|
||||
const argumentExpression = argumentNode(worker.arguments[0]);
|
||||
if (argumentExpression === undefined) return undefined;
|
||||
const node = unwrapJavaScriptExpression(argumentExpression).node;
|
||||
if (!t.isNewExpression(node)) return undefined;
|
||||
const callee = unwrapJavaScriptExpression(node.callee).node;
|
||||
if (
|
||||
!isUnmodifiedGlobalReference(callee, bindings, "URL") ||
|
||||
!isUnmodifiedImportMetaUrl(argumentNode(node.arguments[1]), bindings)
|
||||
)
|
||||
return undefined;
|
||||
const specifier = stringValue(node.arguments[0]);
|
||||
if (specifier === undefined) return undefined;
|
||||
const pathname = stripQueryAndFragment(specifier);
|
||||
// The module URL's scheme and filesystem/origin root are unavailable. Only
|
||||
// relative pathnames can establish a location within the artifact; retain
|
||||
// rooted paths and paths requiring URL text normalization as expressions.
|
||||
// Malformed percent escapes survive HTTP URL parsing but cannot be converted
|
||||
// from a file URL to a Node Worker path. Without the base scheme, keep that
|
||||
// ambiguity unknown; query and fragment bytes do not affect pathname lookup.
|
||||
return pathname.length > 0 &&
|
||||
!pathname.startsWith("/") &&
|
||||
!/%(?![0-9a-f]{2})/iu.test(pathname) &&
|
||||
stripQueryAndFragment(htmlUrlText(specifier)) === pathname
|
||||
? specifier
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const workerConstructorKind = (
|
||||
callee: t.Node,
|
||||
bindings: JavaScriptSemanticAnalysisState,
|
||||
): "browser" | "node" | undefined => {
|
||||
const node = unwrapJavaScriptExpression(callee).node;
|
||||
const member =
|
||||
t.isMemberExpression(node) &&
|
||||
semanticStaticPropertyName(node.property, node.computed) === "Worker";
|
||||
const root = unwrapJavaScriptExpression(member ? node.object : node).node;
|
||||
if (!t.isIdentifier(root)) return undefined;
|
||||
const browserGlobal = member
|
||||
? ["globalThis", "window", "self"].includes(root.name)
|
||||
: root.name === "Worker";
|
||||
if (browserGlobal && isUnmodifiedGlobalReference(node, bindings, "Worker"))
|
||||
return "browser";
|
||||
const binding = resolveSemanticBindingState(bindings, root, root.name);
|
||||
const [origin] = binding?.directOrigins ?? [];
|
||||
// Named ESM exports and module namespace exports are read-only. Default
|
||||
// imports and CommonJS objects can replace Worker, so their identity is unknown.
|
||||
return binding?.runtimeImport === true &&
|
||||
binding.definitions.length === 1 &&
|
||||
binding.directOrigins.length === 1 &&
|
||||
origin !== undefined &&
|
||||
(origin.specifier === "worker_threads" ||
|
||||
origin.specifier === "node:worker_threads") &&
|
||||
(member
|
||||
? origin.importedPath.length === 0
|
||||
: origin.importedPath.length === 1 && origin.importedPath[0] === "Worker")
|
||||
? "node"
|
||||
: undefined;
|
||||
};
|
||||
|
||||
// Element factories such as jsx("audio", props) and createElement("video",
|
||||
// props) carry the HTML media `preload` hint ("none", "metadata", "auto"),
|
||||
// which never names an Electron preload script.
|
||||
|
|
|
|||
|
|
@ -342,16 +342,23 @@ const isDirectoryIdentity = (node: t.Node | undefined): boolean => {
|
|||
);
|
||||
};
|
||||
|
||||
const isFileIdentity = (node: t.Node | undefined): boolean => {
|
||||
if (t.isIdentifier(node, { name: "__filename" })) return true;
|
||||
/** Whether the expression denotes the current module's URL. */
|
||||
export const isImportMetaUrl = (node: t.Node | undefined): boolean => {
|
||||
if (node === undefined) return false;
|
||||
if (
|
||||
t.isMemberExpression(node) &&
|
||||
t.isMetaProperty(node.object) &&
|
||||
node.object.meta.name === "import" &&
|
||||
node.object.property.name === "meta" &&
|
||||
semanticStaticPropertyName(node.property, node.computed) === "url"
|
||||
)
|
||||
const expression = unwrapJavaScriptExpression(node).node;
|
||||
if (!t.isMemberExpression(expression)) return false;
|
||||
const object = unwrapJavaScriptExpression(expression.object).node;
|
||||
return (
|
||||
t.isMetaProperty(object) &&
|
||||
object.meta.name === "import" &&
|
||||
object.property.name === "meta" &&
|
||||
semanticStaticPropertyName(expression.property, expression.computed) ===
|
||||
"url"
|
||||
);
|
||||
};
|
||||
|
||||
const isFileIdentity = (node: t.Node | undefined): boolean => {
|
||||
if (t.isIdentifier(node, { name: "__filename" }) || isImportMetaUrl(node))
|
||||
return true;
|
||||
if (!t.isCallExpression(node)) return false;
|
||||
const name = calleeName(node.callee);
|
||||
|
|
|
|||
|
|
@ -89,6 +89,40 @@ const fixture = (relative = false) => {
|
|||
return bytes;
|
||||
};
|
||||
const provenance = { path: "/fixture/app", sha256: "a".repeat(64) };
|
||||
describe("chained Apple dispatch binary metadata", () => {
|
||||
it("keeps absolute metadata pointers between declared chained fixups", () => {
|
||||
const bytes = fixture();
|
||||
bytes.writeUInt32LE(2, 16);
|
||||
bytes.writeUInt32LE(168, 20);
|
||||
bytes.writeUInt32LE(0x80000034, 184);
|
||||
bytes.writeUInt32LE(16, 188);
|
||||
bytes.writeUInt32LE(0xa00, 192);
|
||||
bytes.writeUInt32LE(64, 196);
|
||||
// Only the class-list pointer needs rebasing. All remaining metadata
|
||||
// pointers carry their stored absolute values, outside the single chain.
|
||||
bytes.writeBigUInt64LE(0x200n, 0x180);
|
||||
bytes.writeUInt32LE(28, 0xa04);
|
||||
bytes.writeUInt32LE(60, 0xa08);
|
||||
bytes.writeUInt32LE(60, 0xa0c);
|
||||
bytes.writeUInt32LE(1, 0xa14);
|
||||
bytes.writeUInt32LE(1, 0xa1c);
|
||||
bytes.writeUInt32LE(8, 0xa20);
|
||||
bytes.writeUInt32LE(24, 0xa24);
|
||||
bytes.writeUInt16LE(0x1000, 0xa28);
|
||||
bytes.writeUInt16LE(6, 0xa2a);
|
||||
bytes.writeUInt16LE(1, 0xa38);
|
||||
bytes.writeUInt16LE(0x180, 0xa3a);
|
||||
const result = decodeAppleDispatchMetadata(bytes, 100, provenance);
|
||||
expect(result.objc_classes[0]).toMatchObject({ name: "Fixture" });
|
||||
expect(result.objc_dispatch_implementations[0]).toMatchObject({
|
||||
selector: "performAction:",
|
||||
implementation_address: "0x100000700",
|
||||
});
|
||||
expect(
|
||||
result.coverage.find(({ facet }) => facet === "pointer_fixups"),
|
||||
).toMatchObject({ status: "complete" });
|
||||
});
|
||||
});
|
||||
describe("Apple dispatch binary metadata", () => {
|
||||
it.each([false, true])(
|
||||
"decodes stripped absolute/relative method lists (relative=%s) with exact offsets",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
decodeChainedPointer,
|
||||
|
|
@ -127,6 +127,9 @@ const chainedData = (format: 1 | 2 | 3): Buffer => {
|
|||
starts.writeUInt32LE(24, 12);
|
||||
starts.writeUInt16LE(0x4000, 16);
|
||||
starts.writeUInt16LE(6, 18);
|
||||
starts.writeBigUInt64LE(0x4000n, 20);
|
||||
starts.writeUInt16LE(1, 32);
|
||||
starts.writeUInt16LE(8, 34);
|
||||
const header = Buffer.alloc(28);
|
||||
header.writeUInt32LE(0, 0);
|
||||
header.writeUInt32LE(28, 4);
|
||||
|
|
@ -135,7 +138,10 @@ const chainedData = (format: 1 | 2 | 3): Buffer => {
|
|||
header.writeUInt32LE(2, 16);
|
||||
header.writeUInt32LE(format, 20);
|
||||
header.writeUInt32LE(0, 24);
|
||||
return Buffer.concat([header, starts, imports, symbols]);
|
||||
const data = Buffer.alloc(0x8000);
|
||||
Buffer.concat([header, starts, imports, symbols]).copy(data);
|
||||
data.writeBigUInt64LE(1n | field(1n, 63), 0x4008);
|
||||
return data;
|
||||
};
|
||||
|
||||
const SEGMENTS: Segment[] = [
|
||||
|
|
@ -182,22 +188,31 @@ describe("pointer fixup tables", () => {
|
|||
|
||||
it("does not decode unreferenced imports while constructing fixup facts", () => {
|
||||
const data = chainedData(1);
|
||||
// The second import is outside the table bytes; only the first is needed
|
||||
// to construct the fixup reader, so its malformed symbol remains lazy.
|
||||
data.writeUInt32LE(0xffff_ff00, 72);
|
||||
expect(() =>
|
||||
parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: data.length },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
),
|
||||
).not.toThrow();
|
||||
// The second import's name leaves the pool. Preserve the first import
|
||||
// and defer that distinct failure until its pointer is examined.
|
||||
data.writeUInt32LE(0xffff_ff00, data.readUInt32LE(8) + 4);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x4008n, field(1n, 63))).toMatchObject({
|
||||
kind: "bind",
|
||||
symbol: "_OBJC_CLASS_$_NSObject",
|
||||
});
|
||||
expect(fixups.issues).toEqual([]);
|
||||
expect(fixups.decode(BASE + 0x4008n, 1n | field(1n, 63))).toMatchObject({
|
||||
kind: "unsupported",
|
||||
});
|
||||
expect(fixups.issues).toMatchObject([
|
||||
{ code: "chained_pointer_decode_failed" },
|
||||
]);
|
||||
});
|
||||
|
||||
for (const format of [1, 2, 3] as const)
|
||||
|
|
@ -231,7 +246,540 @@ describe("pointer fixup tables", () => {
|
|||
target: 0x1234n,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("parsed chained pointer membership", () => {
|
||||
it("parses a dense chain once and reuses its membership for every lookup", () => {
|
||||
const data = chainedData(1);
|
||||
const count = 512;
|
||||
const raw = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => BigInt(index + 1) | field(index + 1 < count ? 2n : 0n, 51),
|
||||
);
|
||||
for (const [index, pointer] of raw.entries())
|
||||
data.writeBigUInt64LE(pointer, 0x4008 + index * 8);
|
||||
const read = vi.spyOn(data, "readBigUInt64LE");
|
||||
try {
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
const readsAfterParse = read.mock.calls.length;
|
||||
for (const [index, pointer] of raw.entries())
|
||||
expect(
|
||||
fixups.decode(BASE + 0x4008n + BigInt(index * 8), pointer),
|
||||
).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE + BigInt(index + 1),
|
||||
});
|
||||
expect(read.mock.calls.length).toBe(readsAfterParse);
|
||||
expect(fixups.issues).toEqual([]);
|
||||
} finally {
|
||||
read.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves byte-exact unaligned producer locations", () => {
|
||||
const data = chainedData(1);
|
||||
data.writeUInt16LE(9, 62);
|
||||
data.writeBigUInt64LE(0x1234n | field(2n, 51), 0x4009);
|
||||
data.writeBigUInt64LE(0x4321n, 0x4011);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{ chained: { offset: 0, size: 128 }, binds: [], rebases: [], dylibs: [] },
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x4009n, data.readBigUInt64LE(0x4009))).toEqual(
|
||||
{
|
||||
kind: "rebase",
|
||||
target: BASE + 0x1234n,
|
||||
},
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x4011n, data.readBigUInt64LE(0x4011))).toEqual(
|
||||
{
|
||||
kind: "rebase",
|
||||
target: BASE + 0x4321n,
|
||||
},
|
||||
);
|
||||
const plain = data.readBigUInt64LE(0x400b);
|
||||
expect(fixups.decode(BASE + 0x400bn, plain)).toEqual({
|
||||
kind: "rebase",
|
||||
target: plain,
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["shared", "exhausted"])(
|
||||
"preserves physical page facts with %s retention",
|
||||
(mode) => {
|
||||
const data = Buffer.alloc(0x8000);
|
||||
const count = 5;
|
||||
const starts = 28;
|
||||
const table = starts + 4 + count * 4;
|
||||
const imports = table + count * 24;
|
||||
data.writeUInt32LE(starts, 4);
|
||||
data.writeUInt32LE(imports, 8);
|
||||
data.writeUInt32LE(imports, 12);
|
||||
data.writeUInt32LE(1, 20);
|
||||
data.writeUInt32LE(count, starts);
|
||||
const segments: Segment[] = [];
|
||||
for (let index = 0; index < count; index++) {
|
||||
const info = table + index * 24;
|
||||
const address = BASE + BigInt((index + 1) * 0x4000);
|
||||
data.writeUInt32LE(info - starts, starts + 4 + index * 4);
|
||||
data.writeUInt32LE(24, info);
|
||||
data.writeUInt16LE(0x4000, info + 4);
|
||||
data.writeUInt16LE(6, info + 6);
|
||||
data.writeBigUInt64LE(address - BASE, info + 8);
|
||||
data.writeUInt16LE(1, info + 20);
|
||||
data.writeUInt16LE(8, info + 22);
|
||||
segments.push({
|
||||
address,
|
||||
size: BigInt(0x4000 - (mode === "exhausted" ? index * 8 : 0)),
|
||||
offset: 0x4000 + (mode === "exhausted" ? index * 8 : 0),
|
||||
executable: false,
|
||||
});
|
||||
data.writeBigUInt64LE(
|
||||
0x1234n,
|
||||
0x4008 + (mode === "exhausted" ? index * 8 : 0),
|
||||
);
|
||||
}
|
||||
const reads = vi.spyOn(data, "readBigUInt64LE");
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: imports },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
segments,
|
||||
BASE,
|
||||
);
|
||||
for (const segment of segments)
|
||||
expect(fixups.decode(segment.address + 8n, 0x1234n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE + 0x1234n,
|
||||
});
|
||||
expect(fixups.issues).toEqual([]);
|
||||
expect(reads.mock.calls.filter(([at]) => at === 0x4008)).toHaveLength(1);
|
||||
if (mode === "exhausted") {
|
||||
// Five different physical views exceed ceil(32KiB / 8) bitmap bytes.
|
||||
// The final view remains readable, with recomputation instead of rejection.
|
||||
expect(reads.mock.calls.filter(([at]) => at === 0x4028)).toHaveLength(
|
||||
2,
|
||||
);
|
||||
}
|
||||
reads.mockRestore();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
/** Small malformed carriers exercise expansion without allocating exploit-sized data. */
|
||||
const aliasedStarts = (format: number, count = 32, pages = 128) => {
|
||||
const data = Buffer.alloc(0x8000);
|
||||
const starts = 28;
|
||||
const info = starts + 4 + count * 4;
|
||||
const end = info + 22 + pages * 2;
|
||||
data.writeUInt32LE(starts, 4);
|
||||
data.writeUInt32LE(end, 8);
|
||||
data.writeUInt32LE(end, 12);
|
||||
data.writeUInt32LE(1, 20);
|
||||
data.writeUInt32LE(count, starts);
|
||||
data.writeUInt32LE(22 + pages * 2, info);
|
||||
data.writeUInt16LE(0x1000, info + 4);
|
||||
data.writeUInt16LE(format, info + 6);
|
||||
data.writeBigUInt64LE(0x4000n, info + 8);
|
||||
data.writeUInt16LE(pages, info + 20);
|
||||
const segments = Array.from({ length: count }, (_, index) => {
|
||||
data.writeUInt32LE(info - starts, starts + 4 + index * 4);
|
||||
return {
|
||||
address: BASE + 0x4000n,
|
||||
size: 0x4000n,
|
||||
offset: 0x4000,
|
||||
executable: false,
|
||||
};
|
||||
});
|
||||
return {
|
||||
data,
|
||||
segments,
|
||||
pageStarts: info + 22,
|
||||
commands: {
|
||||
chained: { offset: 0, size: end },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
describe("bounded chained page facts", () => {
|
||||
it("does not expand unsupported non-NONE aliased declarations", () => {
|
||||
const { data, segments, pageStarts, commands } = aliasedStarts(99);
|
||||
const reads = vi.spyOn(data, "readUInt16LE");
|
||||
const fixups = parsePointerFixups(data, commands, segments, BASE);
|
||||
// All 128 encoded starts are zero (not NONE); the former per-segment maps
|
||||
// read 32 * 128 starts before any bitmap budget could apply.
|
||||
expect(
|
||||
reads.mock.calls.filter(([at]) => at !== undefined && at >= pageStarts),
|
||||
).toHaveLength(0);
|
||||
expect(fixups.formats).toEqual(["format-99"]);
|
||||
expect(fixups.issues).toHaveLength(32);
|
||||
expect(fixups.decode(BASE + 0x4000n, 0n)).toEqual({
|
||||
kind: "unsupported",
|
||||
reason: "Unsupported chained pointer format 99",
|
||||
});
|
||||
reads.mockRestore();
|
||||
});
|
||||
|
||||
it("bounds failed alias diagnostics and leaves later pages independently queryable", () => {
|
||||
const { data, segments, pageStarts, commands } = aliasedStarts(6);
|
||||
data.writeBigUInt64LE(field(0xfffn, 51), 0x4000);
|
||||
data.writeBigUInt64LE(0x1234n, 0x5000);
|
||||
const reads = vi.spyOn(data, "readUInt16LE");
|
||||
const fixups = parsePointerFixups(data, commands, segments, BASE);
|
||||
expect(
|
||||
reads.mock.calls.filter(([at]) => at !== undefined && at >= pageStarts),
|
||||
).toHaveLength(1);
|
||||
expect(fixups.issues).toEqual([
|
||||
{
|
||||
facet: "pointer_fixups",
|
||||
code: "chained_pointer_walk_failed",
|
||||
message: "Chained pointer link leaves its file-backed page",
|
||||
location: "0x100004000",
|
||||
},
|
||||
]);
|
||||
expect(fixups.decode(BASE + 0x4000n, data.readBigUInt64LE(0x4000))).toEqual(
|
||||
{
|
||||
kind: "rebase",
|
||||
target: BASE,
|
||||
},
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x5000n, 0x1234n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE + 0x1234n,
|
||||
});
|
||||
reads.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe("chained metadata retention", () => {
|
||||
it("bounds cache records even when malformed facts have no bitmap bytes", () => {
|
||||
const count = 8;
|
||||
const pages = 4;
|
||||
const { data, commands } = aliasedStarts(6, count, pages);
|
||||
const starts = 28;
|
||||
const table = starts + 4 + count * 4;
|
||||
const infoSize = 22 + pages * 2;
|
||||
const end = table + count * infoSize;
|
||||
data.writeUInt32LE(end, 8);
|
||||
data.writeUInt32LE(end, 12);
|
||||
const segments = Array.from({ length: count }, (_, index) => {
|
||||
const info = table + index * infoSize;
|
||||
data.copy(data, info, table, table + infoSize);
|
||||
data.writeUInt32LE(info - starts, starts + 4 + index * 4);
|
||||
const address = BASE + BigInt((index + 1) * 0x4000);
|
||||
data.writeBigUInt64LE(address - BASE, info + 8);
|
||||
for (let page = 0; page < pages; page++)
|
||||
data.writeUInt16LE(0x8000 + index, info + 22 + page * 2);
|
||||
return { address, offset: 0x4000, size: 0x4000n, executable: false };
|
||||
});
|
||||
const retained: unknown[] = [];
|
||||
const set = Map.prototype.set;
|
||||
const writes = vi.spyOn(Map.prototype, "set").mockImplementation(function (
|
||||
this: Map<unknown, unknown>,
|
||||
key,
|
||||
value,
|
||||
) {
|
||||
if (typeof value === "object" && value !== null && "locations" in value)
|
||||
retained.push(value);
|
||||
return set.call(this, key, value);
|
||||
});
|
||||
try {
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{ ...commands, chained: { offset: 0, size: end } },
|
||||
segments,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.issues).toHaveLength(count);
|
||||
expect(fixups.issues.map(({ location }) => location)).toEqual(
|
||||
segments.map(({ address }) => `0x${address.toString(16)}`),
|
||||
);
|
||||
for (const segment of segments)
|
||||
for (let page = 0; page < pages; page++)
|
||||
expect(
|
||||
fixups.decode(segment.address + BigInt(page * 0x1000), 0n),
|
||||
).toMatchObject({ kind: "unsupported" });
|
||||
} finally {
|
||||
writes.mockRestore();
|
||||
}
|
||||
// All facts have zero bitmap bytes. Entry count must independently bound
|
||||
// 8 * 4 distinct physical start/format/pageEnd keys to the derived 24 slots.
|
||||
expect(retained).toHaveLength(Math.ceil(data.length / 0x1000) + 2 * count);
|
||||
});
|
||||
});
|
||||
|
||||
describe("chained pointer membership", () => {
|
||||
it("retains a valid later page after an earlier chain fails", () => {
|
||||
const data = chainedData(1);
|
||||
data.copyWithin(66, 64, 128);
|
||||
data.writeUInt32LE(66, 8);
|
||||
data.writeUInt32LE(74, 12);
|
||||
data.writeUInt32LE(26, 40);
|
||||
data.writeUInt16LE(0x1000, 44);
|
||||
data.writeUInt16LE(2, 60);
|
||||
data.writeUInt16LE(8, 64);
|
||||
data.writeBigUInt64LE(field(0xfffn, 51), 0x4008);
|
||||
data.writeBigUInt64LE(0x1234n, 0x5008);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 160 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.issues).toMatchObject([
|
||||
{ code: "chained_pointer_walk_failed" },
|
||||
]);
|
||||
expect(fixups.decode(BASE + 0x5008n, data.readBigUInt64LE(0x5008))).toEqual(
|
||||
{ kind: "rebase", target: BASE + 0x1234n },
|
||||
);
|
||||
});
|
||||
it.each([2, 6, 1, 9, 12])(
|
||||
"decodes only chain members for pointer format %i",
|
||||
(pointerFormat) => {
|
||||
const data = chainedData(1);
|
||||
data.writeUInt16LE(pointerFormat, 46);
|
||||
const generic = pointerFormat === 2 || pointerFormat === 6;
|
||||
const bindBit = generic ? 63 : 62;
|
||||
data.writeBigUInt64LE(field(4n, 51) | field(1n, bindBit), 0x4008);
|
||||
const second = generic ? 0x4018 : 0x4028;
|
||||
data.writeBigUInt64LE(1n | field(1n, bindBit), second);
|
||||
const plain = BASE + 0x1234n;
|
||||
data.writeBigUInt64LE(plain, 0x4010);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.issues).toEqual([]);
|
||||
expect(
|
||||
fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
|
||||
).toMatchObject({
|
||||
kind: "bind",
|
||||
symbol: "_OBJC_CLASS_$_NSObject",
|
||||
});
|
||||
expect(
|
||||
fixups.decode(BASE + BigInt(second), data.readBigUInt64LE(second)),
|
||||
).toMatchObject({
|
||||
kind: "bind",
|
||||
symbol: "_free",
|
||||
});
|
||||
expect(fixups.decode(BASE + 0x4010n, plain)).toEqual({
|
||||
kind: "rebase",
|
||||
target: plain,
|
||||
});
|
||||
expect(fixups.decode(BASE + 0x4030n, 0n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: 0n,
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps NONE pages and pages absent from the table plain, and decodes a chained zero", () => {
|
||||
const data = chainedData(1);
|
||||
data.writeBigUInt64LE(0n, 0x4008);
|
||||
const commands = {
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
} as const;
|
||||
const fixups = parsePointerFixups(data, commands, SEGMENTS, BASE);
|
||||
expect(fixups.decode(BASE + 0x4008n, 0n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE,
|
||||
});
|
||||
data.writeUInt16LE(0xffff, 62);
|
||||
data.writeUInt16LE(99, 46);
|
||||
data.writeUInt16LE(0x1000, 44);
|
||||
const unknown = parsePointerFixups(data, commands, SEGMENTS, BASE);
|
||||
expect(unknown.decode(BASE + 0x4008n, BASE + 8n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE + 8n,
|
||||
});
|
||||
expect(unknown.decode(BASE + 0x5008n, BASE + 16n)).toEqual({
|
||||
kind: "rebase",
|
||||
target: BASE + 16n,
|
||||
});
|
||||
});
|
||||
});
|
||||
describe("malformed chained pointer coverage", () => {
|
||||
it("locates a failed later page without discarding established pointer facts", () => {
|
||||
const data = chainedData(1);
|
||||
data.copyWithin(66, 64, 128);
|
||||
data.writeUInt32LE(66, 8);
|
||||
data.writeUInt32LE(74, 12);
|
||||
data.writeUInt32LE(26, 40);
|
||||
data.writeUInt16LE(0x1000, 44);
|
||||
data.writeUInt16LE(2, 60);
|
||||
data.writeUInt16LE(8, 64);
|
||||
data.writeBigUInt64LE(0x1234n, 0x4008);
|
||||
data.writeBigUInt64LE(field(0xfffn, 51), 0x5008);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{ chained: { offset: 0, size: 160 }, binds: [], rebases: [], dylibs: [] },
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.issues).toEqual([
|
||||
{
|
||||
facet: "pointer_fixups",
|
||||
code: "chained_pointer_walk_failed",
|
||||
message: "Chained pointer link leaves its file-backed page",
|
||||
location: "0x100005000",
|
||||
},
|
||||
]);
|
||||
expect(fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008))).toEqual(
|
||||
{
|
||||
kind: "rebase",
|
||||
target: BASE + 0x1234n,
|
||||
},
|
||||
);
|
||||
expect(fixups.decode(BASE + 0x5008n, data.readBigUInt64LE(0x5008))).toEqual(
|
||||
{
|
||||
kind: "rebase",
|
||||
target: BASE,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it.each(["link", "multi"])(
|
||||
"reports malformed %s chains without claiming complete fixups",
|
||||
(malformed) => {
|
||||
const data = chainedData(1);
|
||||
if (malformed === "link")
|
||||
data.writeBigUInt64LE(field(0xfffn, 51), 0x4008);
|
||||
else data.writeUInt16LE(0x8000, 62);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(fixups.issues).toMatchObject([
|
||||
{ code: "chained_pointer_walk_failed" },
|
||||
]);
|
||||
if (malformed === "link")
|
||||
expect(
|
||||
fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
|
||||
).toEqual({ kind: "rebase", target: BASE });
|
||||
expect(fixups.decode(BASE + 0x4010n, 0n)).toMatchObject({
|
||||
kind: "unsupported",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
[0, 1, "header version"],
|
||||
[4, 126, "starts table"],
|
||||
[8, 63, "starts table"],
|
||||
[40, 22, "page starts"],
|
||||
[48, 0x1000, "segment offset"],
|
||||
] as const)(
|
||||
"rejects malformed chained structure at byte %i",
|
||||
(at, value, message) => {
|
||||
const data = chainedData(1);
|
||||
data.writeUInt32LE(value, at);
|
||||
expect(() =>
|
||||
parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 128 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: [],
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
),
|
||||
).toThrow(message);
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("chained import library identity", () => {
|
||||
it.each([1, 2, 3] as const)(
|
||||
"retains positive and special library ordinals for import format %i",
|
||||
(format) => {
|
||||
const data = chainedData(format);
|
||||
const importAt = data.readUInt32LE(8);
|
||||
const width = format === 3 ? 16 : 8;
|
||||
const maximum = format === 3 ? 0xfff0 : 0xf0;
|
||||
const libraries = Array.from(
|
||||
{ length: maximum },
|
||||
(_, index) => `/library-${index + 1}`,
|
||||
);
|
||||
for (const ordinal of [
|
||||
1 << (width - 1),
|
||||
maximum,
|
||||
maximum + 1,
|
||||
(1 << width) - 1,
|
||||
]) {
|
||||
if (format === 3)
|
||||
data.writeBigUInt64LE(
|
||||
(data.readBigUInt64LE(importAt) & ~0xffffn) | BigInt(ordinal),
|
||||
importAt,
|
||||
);
|
||||
else
|
||||
data.writeUInt32LE(
|
||||
(data.readUInt32LE(importAt) & ~0xff) | ordinal,
|
||||
importAt,
|
||||
);
|
||||
data.writeBigUInt64LE(field(1n, 63), 0x4008);
|
||||
const fixups = parsePointerFixups(
|
||||
data,
|
||||
{
|
||||
chained: { offset: 0, size: 160 },
|
||||
binds: [],
|
||||
rebases: [],
|
||||
dylibs: libraries,
|
||||
},
|
||||
SEGMENTS,
|
||||
BASE,
|
||||
);
|
||||
expect(
|
||||
fixups.decode(BASE + 0x4008n, data.readBigUInt64LE(0x4008)),
|
||||
).toMatchObject({
|
||||
kind: "bind",
|
||||
library: ordinal <= maximum ? libraries[ordinal - 1] : null,
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
describe("legacy pointer fixup streams", () => {
|
||||
it("interprets legacy bind, weak-bind and lazy-bind opcodes", () => {
|
||||
const opcodes = Buffer.from([
|
||||
0x11, // SET_DYLIB_ORDINAL_IMM 1
|
||||
|
|
|
|||
|
|
@ -121,7 +121,9 @@ const readImport = (
|
|||
const at = base + header.offset + index * size;
|
||||
if (header.format === 3) {
|
||||
const word = bytes.readBigUInt64LE(at);
|
||||
const ordinal = Number(signed(bits(word, 0, 16), 16));
|
||||
const encodedOrdinal = Number(bits(word, 0, 16));
|
||||
const ordinal =
|
||||
encodedOrdinal > 0xfff0 ? encodedOrdinal - 0x10000 : encodedOrdinal;
|
||||
return {
|
||||
symbol: cString(
|
||||
bytes,
|
||||
|
|
@ -134,7 +136,9 @@ const readImport = (
|
|||
};
|
||||
}
|
||||
const word = bytes.readUInt32LE(at);
|
||||
const ordinal = Number(signed(BigInt(word & 0xff), 8));
|
||||
const encodedOrdinal = word & 0xff;
|
||||
const ordinal =
|
||||
encodedOrdinal > 0xf0 ? encodedOrdinal - 0x100 : encodedOrdinal;
|
||||
return {
|
||||
symbol: cString(bytes, base + header.symbols + (word >>> 9), end),
|
||||
library: libraryName(ordinal, dylibs),
|
||||
|
|
@ -193,6 +197,66 @@ interface FixupImage {
|
|||
readonly baseAddress: bigint;
|
||||
}
|
||||
|
||||
interface ChainedPage {
|
||||
/** One bit per byte offset, preserving even unaligned producer locations. */
|
||||
readonly locations: Uint8Array;
|
||||
readonly lastOffset: number | undefined;
|
||||
readonly failure: string | undefined;
|
||||
}
|
||||
|
||||
interface ChainedSegment {
|
||||
readonly segment: Segment;
|
||||
readonly format: number;
|
||||
readonly pageSize: number;
|
||||
/** Compact producer-owned table coordinates, never one map per segment. */
|
||||
readonly pageCount: number;
|
||||
readonly pageStarts: number;
|
||||
}
|
||||
|
||||
interface ChainedPageSource {
|
||||
readonly offset: number;
|
||||
readonly size: number;
|
||||
readonly format: number;
|
||||
readonly start: number;
|
||||
}
|
||||
|
||||
/** Parse one physical page; a failed tail retains established locations. */
|
||||
const readChainedPage = (
|
||||
bytes: Buffer,
|
||||
source: ChainedPageSource,
|
||||
): ChainedPage => {
|
||||
let locations = new Uint8Array(0);
|
||||
let lastOffset: number | undefined;
|
||||
let failure: string | undefined;
|
||||
try {
|
||||
if ((source.start & 0x8000) !== 0)
|
||||
throw new RangeError(
|
||||
"Multiple chained starts are not valid for 64-bit pointers",
|
||||
);
|
||||
if (source.start + 8 > source.size)
|
||||
throw new RangeError("Chained pointer leaves its file-backed page");
|
||||
// At most one eighth of this file-backed page, not one object per fixup.
|
||||
locations = new Uint8Array(Math.ceil(source.size / 8));
|
||||
const generic = source.format === 2 || source.format === 6;
|
||||
let at = source.start;
|
||||
while (true) {
|
||||
locations[at >>> 3] = (locations[at >>> 3] ?? 0) | (1 << (at & 7));
|
||||
lastOffset = at;
|
||||
const raw = bytes.readBigUInt64LE(source.offset + at);
|
||||
const next = Number(bits(raw, 51, generic ? 12 : 11)) * (generic ? 4 : 8);
|
||||
if (next === 0) break;
|
||||
if (at + next + 8 > source.size)
|
||||
throw new RangeError(
|
||||
"Chained pointer link leaves its file-backed page",
|
||||
);
|
||||
at += next;
|
||||
}
|
||||
} catch (cause: unknown) {
|
||||
failure = cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
return { locations, lastOffset, failure };
|
||||
};
|
||||
|
||||
const chainedFixups = (
|
||||
image: FixupImage,
|
||||
range: { readonly offset: number; readonly size: number },
|
||||
|
|
@ -200,8 +264,16 @@ const chainedFixups = (
|
|||
const { bytes, segments, dylibs, baseAddress } = image;
|
||||
const base = range.offset;
|
||||
const end = range.offset + range.size;
|
||||
if (range.size < 28 || end > bytes.length)
|
||||
if (
|
||||
!Number.isSafeInteger(base) ||
|
||||
base < 0 ||
|
||||
!Number.isSafeInteger(range.size) ||
|
||||
range.size < 28 ||
|
||||
end > bytes.length
|
||||
)
|
||||
throw new RangeError("Chained fixup header exceeds the target");
|
||||
if (bytes.readUInt32LE(base) !== 0)
|
||||
throw new RangeError("Unsupported chained fixup header version");
|
||||
const startsOffset = bytes.readUInt32LE(base + 4);
|
||||
const importHeader = {
|
||||
offset: bytes.readUInt32LE(base + 8),
|
||||
|
|
@ -217,7 +289,12 @@ const chainedFixups = (
|
|||
throw new RangeError(
|
||||
`Unsupported chained import format ${importHeader.format}`,
|
||||
);
|
||||
if (!Number.isSafeInteger(importHeader.count) || importTableEnd > end)
|
||||
if (
|
||||
importHeader.offset < 28 ||
|
||||
importHeader.symbols < importHeader.offset ||
|
||||
importTableEnd > base + importHeader.symbols ||
|
||||
base + importHeader.symbols > end
|
||||
)
|
||||
throw new RangeError("Chained import table exceeds the fixup data");
|
||||
const imports = (index: number) =>
|
||||
readImport({ bytes, base, end, dylibs }, importHeader, index);
|
||||
|
|
@ -226,22 +303,84 @@ const chainedFixups = (
|
|||
"Compressed chained-fixup symbol names are not supported",
|
||||
);
|
||||
const starts = base + startsOffset;
|
||||
const startsEnd = base + importHeader.offset;
|
||||
const requireStartsRange = (at: number, size: number): void => {
|
||||
if (at < base + 28 || at > startsEnd - size)
|
||||
throw new RangeError("Chained starts table exceeds its fixup data");
|
||||
};
|
||||
requireStartsRange(starts, 4);
|
||||
const segmentCount = bytes.readUInt32LE(starts);
|
||||
const ranges: {
|
||||
readonly from: bigint;
|
||||
readonly to: bigint;
|
||||
readonly format: number;
|
||||
}[] = [];
|
||||
if (segmentCount > segments.length)
|
||||
throw new RangeError(
|
||||
"Chained starts segment count exceeds the Mach-O layout",
|
||||
);
|
||||
requireStartsRange(starts, 4 + segmentCount * 4);
|
||||
const ranges: ChainedSegment[] = [];
|
||||
// A non-overlapping file view has at most file-size/minimum-page-size
|
||||
// pages plus two partial pages per segment. Keep both metadata entries and
|
||||
// bitmap bytes within that format-derived working set. Aliased/exhausted
|
||||
// views remain readable by transient recomputation, not input rejection.
|
||||
const maximumCachedPages =
|
||||
Math.ceil(bytes.length / 0x1000) + 2 * segmentCount;
|
||||
let remainingLocationBytes = Math.ceil(bytes.length / 8);
|
||||
const cachedPages = new Map<string, ChainedPage>();
|
||||
const pageFacts = (
|
||||
chain: ChainedSegment,
|
||||
page: number,
|
||||
start: number,
|
||||
): ChainedPage => {
|
||||
const pageOffset = page * chain.pageSize;
|
||||
const source = {
|
||||
offset: chain.segment.offset + pageOffset,
|
||||
size: Math.min(
|
||||
chain.pageSize,
|
||||
Number(chain.segment.size) - pageOffset,
|
||||
bytes.length - chain.segment.offset - pageOffset,
|
||||
),
|
||||
format: chain.format,
|
||||
start,
|
||||
};
|
||||
const key = `${source.offset}:${source.size}:${source.format}:${source.start}`;
|
||||
const cached = cachedPages.get(key);
|
||||
if (cached !== undefined) return cached;
|
||||
// Decide retention before allocating the page facts. An exhausted cache
|
||||
// uses only one transient, format-bounded page, preserving decode behavior.
|
||||
const retain =
|
||||
cachedPages.size < maximumCachedPages &&
|
||||
Math.ceil(Math.max(source.size, 0) / 8) <= remainingLocationBytes;
|
||||
const facts = readChainedPage(bytes, source);
|
||||
if (retain) {
|
||||
remainingLocationBytes -= facts.locations.byteLength;
|
||||
cachedPages.set(key, facts);
|
||||
}
|
||||
return facts;
|
||||
};
|
||||
for (let index = 0; index < segmentCount; index++) {
|
||||
const infoOffset = bytes.readUInt32LE(starts + 4 + index * 4);
|
||||
if (infoOffset === 0) continue;
|
||||
const segment = segments[index];
|
||||
if (segment === undefined || starts + infoOffset + 22 > end)
|
||||
if (segment === undefined)
|
||||
throw new RangeError("Chained fixup segment table is out of range");
|
||||
const info = starts + infoOffset;
|
||||
requireStartsRange(info, 22);
|
||||
const infoSize = bytes.readUInt32LE(info);
|
||||
const pageSize = bytes.readUInt16LE(info + 4);
|
||||
const pageCount = bytes.readUInt16LE(info + 20);
|
||||
if (infoSize < 22 + pageCount * 2)
|
||||
throw new RangeError("Chained page starts exceed the segment table size");
|
||||
requireStartsRange(info, infoSize);
|
||||
if (pageSize !== 0x1000 && pageSize !== 0x4000)
|
||||
throw new RangeError("Chained page size must be 4096 or 16384 bytes");
|
||||
if (bytes.readBigUInt64LE(info + 8) !== segment.address - baseAddress)
|
||||
throw new RangeError(
|
||||
"Chained segment offset disagrees with its Mach-O layout",
|
||||
);
|
||||
ranges.push({
|
||||
from: segment.address,
|
||||
to: segment.address + segment.size,
|
||||
format: bytes.readUInt16LE(starts + infoOffset + 6),
|
||||
segment,
|
||||
format: bytes.readUInt16LE(info + 6),
|
||||
pageSize,
|
||||
pageCount,
|
||||
pageStarts: info + 22,
|
||||
});
|
||||
}
|
||||
const formats = [
|
||||
|
|
@ -249,38 +388,106 @@ const chainedFixups = (
|
|||
ranges.map(({ format }) => POINTER_FORMATS[format] ?? `format-${format}`),
|
||||
),
|
||||
];
|
||||
const issues = ranges.flatMap(({ format, from }) =>
|
||||
const issues = ranges.flatMap(({ format, segment }) =>
|
||||
POINTER_FORMATS[format] === undefined
|
||||
? [
|
||||
issue(
|
||||
"pointer_fixups",
|
||||
"unsupported_pointer_format",
|
||||
`Unsupported chained pointer format ${format}`,
|
||||
`0x${from.toString(16)}`,
|
||||
`0x${segment.address.toString(16)}`,
|
||||
),
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const examinedViews = new Set<string>();
|
||||
for (const chain of ranges) {
|
||||
// Unknown formats retain their compact starts table; only a queried page
|
||||
// needs its NONE/presence value. Do not expand unsupported declarations.
|
||||
if (POINTER_FORMATS[chain.format] === undefined) continue;
|
||||
const view = `${chain.pageStarts}:${chain.segment.offset}:${chain.segment.size}:${chain.segment.address}`;
|
||||
if (examinedViews.has(view)) continue;
|
||||
examinedViews.add(view);
|
||||
for (let page = 0; page < chain.pageCount; page++) {
|
||||
const start = bytes.readUInt16LE(chain.pageStarts + page * 2);
|
||||
if (start === 0xffff) continue;
|
||||
const facts = pageFacts(chain, page, start);
|
||||
if (facts.failure !== undefined) {
|
||||
issues.push(
|
||||
issue(
|
||||
"pointer_fixups",
|
||||
"chained_pointer_walk_failed",
|
||||
facts.failure,
|
||||
`0x${(chain.segment.address + BigInt(page * chain.pageSize)).toString(16)}`,
|
||||
),
|
||||
);
|
||||
// Coverage is partial after this failure, not exhaustive. Keep the
|
||||
// unexpanded descriptor: later pages are still independently readable
|
||||
// through pageFacts when metadata requests them.
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const failures = issues.map(({ message }) => message);
|
||||
return {
|
||||
kind: "chained",
|
||||
formats,
|
||||
failures: issues.map(({ message }) => message),
|
||||
failures,
|
||||
issues,
|
||||
examined: Math.max(ranges.length, 1),
|
||||
unreadRebaseBytes: 0,
|
||||
decode(address, raw) {
|
||||
const segment = ranges.find(
|
||||
({ from, to }) => address >= from && address < to,
|
||||
const chain = ranges.find(
|
||||
({ segment }) =>
|
||||
address >= segment.address &&
|
||||
address < segment.address + segment.size,
|
||||
);
|
||||
// Pointers outside fixup segments, such as in __TEXT, are stored plainly.
|
||||
if (segment === undefined) return { kind: "rebase", target: raw };
|
||||
if (raw === 0n) return { kind: "rebase", target: 0n };
|
||||
if (POINTER_FORMATS[segment.format] === undefined)
|
||||
if (chain === undefined) return { kind: "rebase", target: raw };
|
||||
const within = Number(address - chain.segment.address);
|
||||
const page = Math.floor(within / chain.pageSize);
|
||||
if (page >= chain.pageCount) return { kind: "rebase", target: raw };
|
||||
const start = bytes.readUInt16LE(chain.pageStarts + page * 2);
|
||||
if (start === 0xffff) return { kind: "rebase", target: raw };
|
||||
if (POINTER_FORMATS[chain.format] === undefined)
|
||||
return {
|
||||
kind: "unsupported",
|
||||
reason: `Unsupported chained pointer format ${segment.format}`,
|
||||
reason: `Unsupported chained pointer format ${chain.format}`,
|
||||
};
|
||||
return decodeChainedPointer(raw, segment.format, baseAddress, imports);
|
||||
try {
|
||||
const facts = pageFacts(chain, page, start);
|
||||
const at = within % chain.pageSize;
|
||||
const locations = facts.locations;
|
||||
if (((locations[at >>> 3] ?? 0) & (1 << (at & 7))) !== 0)
|
||||
return decodeChainedPointer(raw, chain.format, baseAddress, imports);
|
||||
if (
|
||||
facts.failure !== undefined &&
|
||||
(facts.lastOffset === undefined || at > facts.lastOffset)
|
||||
)
|
||||
throw new RangeError(facts.failure);
|
||||
return { kind: "rebase", target: raw };
|
||||
} catch (cause: unknown) {
|
||||
const reason = cause instanceof Error ? cause.message : String(cause);
|
||||
const location = `0x${address.toString(16)}`;
|
||||
if (
|
||||
!issues.some(
|
||||
(entry) =>
|
||||
entry.code === "chained_pointer_decode_failed" &&
|
||||
entry.location === location,
|
||||
)
|
||||
) {
|
||||
issues.push(
|
||||
issue(
|
||||
"pointer_fixups",
|
||||
"chained_pointer_decode_failed",
|
||||
reason,
|
||||
location,
|
||||
),
|
||||
);
|
||||
failures.push(reason);
|
||||
}
|
||||
return { kind: "unsupported", reason };
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,626 @@
|
|||
import { expect } from "vitest";
|
||||
|
||||
import type { JavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import { analyzeJavaScriptDirectory } from "../../support/cli/javascriptApplication.js";
|
||||
import { writeFixtureFiles } from "../../support/javascriptApplicationFixture.js";
|
||||
|
||||
const workerReferences = (graph: JavaScriptApplicationGraph) =>
|
||||
graph.nodes
|
||||
.filter(({ kind }) => kind === "worker")
|
||||
.flatMap((node) =>
|
||||
node.observations.map(({ properties, evidence }) => ({
|
||||
source:
|
||||
evidence.location.available &&
|
||||
evidence.location.value.kind === "source-range"
|
||||
? evidence.location.value.source
|
||||
: null,
|
||||
declared_specifier: properties.declared_specifier,
|
||||
expression: properties.expression,
|
||||
resolved_path: properties.resolved_path,
|
||||
mapped_paths: graph.edges
|
||||
.filter(
|
||||
({ relation, source_node_id }) =>
|
||||
relation === "maps_to" && source_node_id === node.node_id,
|
||||
)
|
||||
.map(({ properties: edge }) => edge.resolved_path),
|
||||
})),
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"promotes Worker URLs only for known constructor bindings through the CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-identity-");
|
||||
const identityCases = [
|
||||
...["globalThis", "window", "self"].map((namespace) => ({
|
||||
prefix: "",
|
||||
constructor: `new ${namespace}.Worker`,
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
})),
|
||||
...["worker_threads", "node:worker_threads"].flatMap((specifier) => [
|
||||
{
|
||||
prefix: `import { Worker } from "${specifier}";`,
|
||||
constructor: "new Worker",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
{
|
||||
prefix: `import * as threads from "${specifier}";`,
|
||||
constructor: "new threads.Worker",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
]),
|
||||
{
|
||||
prefix: 'import { Worker as ThreadWorker } from "node:worker_threads";',
|
||||
constructor: "new ThreadWorker",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
{
|
||||
prefix: 'import { Worker } from "node:worker_threads";',
|
||||
constructor: "new (Worker as typeof Worker)",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
{
|
||||
prefix: 'import * as threads from "node:worker_threads";',
|
||||
constructor: "new ((threads as typeof threads).Worker!)",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
{
|
||||
prefix:
|
||||
'import { Worker } from "node:worker_threads"; function start(globalThis) { globalThis.URL = Fake;',
|
||||
constructor: "new Worker",
|
||||
suffix: "}",
|
||||
resolves: true,
|
||||
},
|
||||
{
|
||||
prefix: 'globalThis.unrelated = Fake; URL.extra = "metadata";',
|
||||
constructor: "new Worker",
|
||||
suffix: "",
|
||||
resolves: true,
|
||||
},
|
||||
...[
|
||||
["class Worker {}", "new Worker", ""],
|
||||
["globalThis.Worker = Fake;", "new Worker", ""],
|
||||
["globalThis.Worker = Fake;", "new window.Worker", ""],
|
||||
["globalThis = replacement;", "new globalThis.Worker", ""],
|
||||
["function start(Worker) {", "new Worker", "}"],
|
||||
['import { Worker } from "./custom.js";', "new Worker", ""],
|
||||
["const custom = { Worker: class {} };", "new custom.Worker", ""],
|
||||
["function start(window) {", "new window.Worker", "}"],
|
||||
["function start(globalThis) {", "new globalThis.Worker", "}"],
|
||||
[
|
||||
'import type { Worker } from "node:worker_threads";',
|
||||
"new Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'import { type Worker } from "node:worker_threads";',
|
||||
"new Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'import type * as threads from "node:worker_threads";',
|
||||
"new threads.Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'import threads from "node:worker_threads"; threads.Worker = Fake;',
|
||||
"new threads.Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'const threads = require("node:worker_threads"); threads.Worker = Fake;',
|
||||
"new threads.Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'import threads = require("node:worker_threads"); threads.Worker = Fake;',
|
||||
"new threads.Worker",
|
||||
"",
|
||||
],
|
||||
[
|
||||
'import { Worker } from "node:worker_threads"; Worker = Fake;',
|
||||
"new Worker",
|
||||
"",
|
||||
],
|
||||
["", "Worker", ""],
|
||||
].map(([prefix, constructor, suffix]) => ({
|
||||
prefix,
|
||||
constructor,
|
||||
suffix,
|
||||
resolves: false,
|
||||
})),
|
||||
].map(({ prefix, constructor, suffix, resolves }, index) => ({
|
||||
source: `identity-${index}.ts`,
|
||||
prefix,
|
||||
expression: `${constructor}(new URL("./worker.js", import.meta.url))`,
|
||||
suffix,
|
||||
resolves,
|
||||
}));
|
||||
await writeFixtureFiles(root, {
|
||||
...Object.fromEntries(
|
||||
identityCases.map(({ source, prefix, expression, suffix }) => [
|
||||
source,
|
||||
`${prefix}\n${expression};${suffix}`,
|
||||
]),
|
||||
),
|
||||
"worker.js": "self.postMessage('existing worker file');",
|
||||
"custom.js": "export class Worker {}",
|
||||
"custom-alias.mjs":
|
||||
'import { Worker as ThreadWorker } from "./custom.js"; new ThreadWorker(new URL("./worker.js", import.meta.url));',
|
||||
});
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
const references = workerReferences(result.graph);
|
||||
expect(references).toHaveLength(identityCases.length);
|
||||
expect(references.some(({ source }) => source === "custom-alias.mjs")).toBe(
|
||||
false,
|
||||
);
|
||||
for (const { source, expression, resolves } of identityCases)
|
||||
expect(references).toContainEqual({
|
||||
source,
|
||||
declared_specifier: resolves ? "./worker.js" : null,
|
||||
expression: resolves ? null : expression,
|
||||
resolved_path: resolves ? "worker.js" : null,
|
||||
mapped_paths: resolves ? ["worker.js"] : [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"resolves module-relative Worker URL constructors through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-module-url-");
|
||||
const cases = [
|
||||
{
|
||||
source: "nested/relative.mjs",
|
||||
expression: 'new Worker(new URL("./worker.js", import.meta.url));',
|
||||
specifier: "./worker.js",
|
||||
resolved: "nested/worker.js",
|
||||
},
|
||||
...[
|
||||
'import.meta.unrelated = "metadata";',
|
||||
'globalThis["import.meta"] = { url: "file:///other/entry.mjs" };',
|
||||
'const meta = import.meta; meta.unrelated = "metadata";',
|
||||
'const ambient = globalThis; ambient.unrelated = "metadata";',
|
||||
'const copy = { ...import.meta }; Object.defineProperty(copy, "url", { value: "file:///other/entry.mjs" });',
|
||||
"const copy = { ...globalThis }; copy.URL = replacement;",
|
||||
"function update(globalThis) { const ambient = globalThis; ambient.URL = replacement; } update({});",
|
||||
"const define = Object.defineProperty.bind(Object);",
|
||||
'Object.defineProperty(...[import.meta, "unrelated", { value: "metadata" }]);',
|
||||
'let meta = import.meta; meta = {}; let base = import.meta.url; base = "file:///other/entry.mjs";',
|
||||
].map((mutation, index) => ({
|
||||
source: `nested/unrelated-meta-${index}.mjs`,
|
||||
expression: `import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
|
||||
specifier: "./worker.js",
|
||||
resolved: "nested/worker.js",
|
||||
})),
|
||||
{
|
||||
source: "nested/parent.mjs",
|
||||
expression:
|
||||
'new Worker(new URL("../parent-worker.js", import.meta.url));',
|
||||
specifier: "../parent-worker.js",
|
||||
resolved: "parent-worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/dot-segments.mjs",
|
||||
expression:
|
||||
'new Worker(new URL("./child/.././worker.js", import.meta.url));',
|
||||
specifier: "./child/.././worker.js",
|
||||
resolved: "nested/worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/template.mjs",
|
||||
expression:
|
||||
"new Worker(new URL(`./template-worker.js`, import.meta.url));",
|
||||
specifier: "./template-worker.js",
|
||||
resolved: "nested/template-worker.js",
|
||||
},
|
||||
...[
|
||||
'new URL("./worker.js", import.meta.url) as URL',
|
||||
'<URL>new URL("./worker.js", import.meta.url)',
|
||||
'new URL("./worker.js", import.meta.url)!',
|
||||
'new URL("./worker.js", import.meta.url) satisfies URL',
|
||||
'((new URL("./worker.js", import.meta.url) as URL)!) satisfies URL',
|
||||
'new (URL as typeof URL)("./worker.js", import.meta.url)',
|
||||
'new URL("./worker.js" as string, import.meta.url as string)',
|
||||
'new URL("./worker.js", (import.meta as ImportMeta).url)',
|
||||
'new globalThis.URL("./worker.js", import.meta.url)',
|
||||
'new window.URL("./worker.js", import.meta.url)',
|
||||
'new self.URL("./worker.js", import.meta.url)',
|
||||
'new global.URL("./worker.js", import.meta.url)',
|
||||
'new ((globalThis as typeof globalThis)["URL"]!)("./worker.js", import.meta.url)',
|
||||
].map((urlExpression, index) => ({
|
||||
source: `nested/typed-${index}.ts`,
|
||||
expression: `new Worker(${urlExpression});`,
|
||||
specifier: "./worker.js",
|
||||
resolved: "nested/worker.js",
|
||||
})),
|
||||
{
|
||||
source: "nested/space.mjs",
|
||||
expression:
|
||||
'new Worker(new URL("./space%20worker.js?worker=1#entry", import.meta.url));',
|
||||
specifier: "./space%20worker.js?worker=1#entry",
|
||||
resolved: "nested/space worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/percent.mjs",
|
||||
expression:
|
||||
'new Worker(new URL("./percent%2520worker.js", import.meta.url));',
|
||||
specifier: "./percent%2520worker.js",
|
||||
resolved: "nested/percent%20worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/literal-percent.mjs",
|
||||
expression:
|
||||
'new Worker(new URL("./percent%25worker.js", import.meta.url));',
|
||||
specifier: "./percent%25worker.js",
|
||||
resolved: "nested/percent%worker.js",
|
||||
},
|
||||
...["?value=", "#value="].map((suffix, index) => {
|
||||
const specifier = `./worker.js${suffix}%2F%2E%5C%00%FF%GG%2%\\\0`;
|
||||
return {
|
||||
source: `nested/suffix-${index}.mjs`,
|
||||
expression: `new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`,
|
||||
specifier,
|
||||
resolved: "nested/worker.js",
|
||||
};
|
||||
}),
|
||||
{
|
||||
source: "bundled/entry.mjs",
|
||||
expression:
|
||||
'self.webpackChunkdemo.push([[1], {1: function(module, exports, require) { new Worker(new URL("./worker.js", import.meta.url)); }, "./worker.js": function(module) { module.exports = 42; }}]);',
|
||||
specifier: "./worker.js",
|
||||
resolved: "bundled/worker.js",
|
||||
},
|
||||
{
|
||||
source: "nested/literal.mjs",
|
||||
expression: 'new Worker("./literal-worker.js");',
|
||||
specifier: "./literal-worker.js",
|
||||
resolved: "nested/literal-worker.js",
|
||||
},
|
||||
];
|
||||
await writeFixtureFiles(root, {
|
||||
...Object.fromEntries(
|
||||
cases.flatMap(({ source, expression, resolved }) => [
|
||||
[source, expression],
|
||||
[resolved, `self.postMessage(${JSON.stringify(resolved)});`],
|
||||
]),
|
||||
),
|
||||
"nested/percent worker.js": "self.postMessage('double-decoding decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
expect(workerReferences(result.graph)).toEqual(
|
||||
expect.arrayContaining(
|
||||
cases.map(({ source, specifier, resolved }) => ({
|
||||
source,
|
||||
declared_specifier: specifier,
|
||||
expression: null,
|
||||
resolved_path: resolved,
|
||||
mapped_paths: [resolved],
|
||||
})),
|
||||
),
|
||||
);
|
||||
expect(workerReferences(result.graph)).toHaveLength(cases.length);
|
||||
expect(result.graph.nodes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
kind: "javascript-module",
|
||||
observations: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
properties: expect.objectContaining({ module_key: "./worker.js" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps unsupported Worker URL expressions and missing exact files unresolved through the CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-url-unknown-");
|
||||
const malformedPercentPaths = [
|
||||
["./worker%.js", "worker%.js"],
|
||||
["./worker%2.js", "worker%2.js"],
|
||||
["./worker%GG.js", "worker%GG.js"],
|
||||
["./space%20worker%GG.js", "space worker%GG.js"],
|
||||
];
|
||||
const expressions = {
|
||||
"dynamic-path.mjs": "new Worker(new URL(path, import.meta.url));",
|
||||
"absolute.mjs":
|
||||
'import { Worker } from "node:worker_threads"; new Worker(new URL("/worker.js", import.meta.url));',
|
||||
"typed-apply-meta.ts":
|
||||
'import { Worker } from "node:worker_threads"; Object.defineProperty.apply(Object, ([import.meta, "url", { value: "file:///other.mjs" }] as const)); new Worker(new URL("./worker.js", import.meta.url));',
|
||||
"overwritten-qualified-url.mjs":
|
||||
'globalThis.URL = replacement; new Worker(new globalThis.URL("./worker.js", import.meta.url));',
|
||||
"shadowed-qualified-url.mjs":
|
||||
'function start(window) { new Worker(new window.URL("./worker.js", import.meta.url)); }',
|
||||
...Object.fromEntries(
|
||||
["globalThis.URL", "URL", 'globalThis["URL"]'].map((target, index) => [
|
||||
`overwritten-url-${index}.mjs`,
|
||||
`import { Worker } from "node:worker_threads"; const RealURL = URL; ${target} = class { constructor() { return new RealURL("./other.js", import.meta.url); } }; new Worker(new URL("./worker.js", import.meta.url));`,
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
[
|
||||
"delete globalThis.URL;",
|
||||
"({ URL: globalThis.URL } = replacements);",
|
||||
"globalThis[key] = replacement;",
|
||||
'Object.defineProperty(globalThis, "URL", { value: replacement });',
|
||||
'Reflect.set(globalThis, "URL", replacement);',
|
||||
"globalThis.globalThis.URL = replacement;",
|
||||
"globalThis.global.URL = replacement;",
|
||||
"const ambient = globalThis; ambient.URL = replacement;",
|
||||
"const { globalThis: ambient } = globalThis; ambient.URL = replacement;",
|
||||
].map((mutation, index) => [
|
||||
`mutated-url-${index}.mjs`,
|
||||
`import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
[
|
||||
'import.meta.url = "file:///other/entry.mjs";',
|
||||
"delete import.meta.url;",
|
||||
'Object.defineProperty(import.meta, "url", { value: "file:///other/entry.mjs" });',
|
||||
"import.meta[key] = replacement;",
|
||||
'const meta = import.meta; const alias = meta; alias.url = "file:///other/entry.mjs";',
|
||||
'let meta = import.meta; meta.url = "file:///other/entry.mjs"; meta = {};',
|
||||
'const define = Object.defineProperty; define(import.meta, "url", { value: "file:///other/entry.mjs" });',
|
||||
'const set = Reflect.set.bind(Reflect); set(import.meta, "url", "file:///other/entry.mjs");',
|
||||
'Object.defineProperty.bind(Object, import.meta, "url")({ value: "file:///other/entry.mjs" });',
|
||||
'Object.assign(...[import.meta, { url: "file:///other/entry.mjs" }]);',
|
||||
'Object.defineProperty(...[import.meta, "url", { value: "file:///other/entry.mjs" }]);',
|
||||
].map((mutation, index) => [
|
||||
`mutated-import-meta-${index}.mjs`,
|
||||
`import { Worker } from "node:worker_threads"; ${mutation} new Worker(new URL("./worker.js", import.meta.url));`,
|
||||
]),
|
||||
),
|
||||
...Object.fromEntries(
|
||||
malformedPercentPaths.map(([specifier], index) => [
|
||||
`malformed-percent-${index}.mjs`,
|
||||
`import { Worker } from "node:worker_threads"; new Worker(new URL(${JSON.stringify(specifier)}, import.meta.url));`,
|
||||
]),
|
||||
),
|
||||
"dynamic-base.mjs": 'new Worker(new URL("./worker.js", base));',
|
||||
"shadowed.mjs":
|
||||
'function start(URL) { return new Worker(new URL("./worker.js", import.meta.url)); }',
|
||||
"typed-shadowed.ts":
|
||||
'function start(URL: typeof globalThis.URL) { return new Worker((new (URL as typeof globalThis.URL)("./worker.js", import.meta.url) as URL)!); }',
|
||||
"typed-dynamic-path.ts":
|
||||
"new Worker((new URL(path as string, import.meta.url) as URL)!);",
|
||||
"typed-dynamic-base.ts":
|
||||
'new Worker((new URL("./worker.js", base as string) as URL)!);',
|
||||
"call.mjs": 'new Worker(URL("./worker.js", import.meta.url));',
|
||||
"custom.mjs":
|
||||
'new Worker(new custom.URL("./worker.js", import.meta.url));',
|
||||
"extension.mjs":
|
||||
'new Worker(new URL("./extensionless", import.meta.url));',
|
||||
"directory.mjs": 'new Worker(new URL("./directory", import.meta.url));',
|
||||
"directory-dot.mjs":
|
||||
'new Worker(new URL("./worker.js/.", import.meta.url));',
|
||||
"directory-parent.mjs":
|
||||
'new Worker(new URL("./worker.js/child/..", import.meta.url));',
|
||||
"empty-segment.mjs":
|
||||
'new Worker(new URL("./child//../worker.js", import.meta.url));',
|
||||
"package.mjs":
|
||||
'new Worker(new URL("./package-worker", import.meta.url));',
|
||||
"external.mjs":
|
||||
'new Worker(new URL("https://example.test/worker.js", import.meta.url));',
|
||||
"empty.mjs": 'new Worker(new URL("", import.meta.url));',
|
||||
"query.mjs": 'new Worker(new URL("?worker=1", import.meta.url));',
|
||||
"whitespace.mjs":
|
||||
'new Worker(new URL(" ./worker.js ", import.meta.url));',
|
||||
"control.mjs": 'new Worker(new URL("./worker\\t.js", import.meta.url));',
|
||||
"leading-control.mjs":
|
||||
'new Worker(new URL("\\u0001./worker.js", import.meta.url));',
|
||||
};
|
||||
const knownSpecifiers: Readonly<Record<string, string>> = {
|
||||
"extension.mjs": "./extensionless",
|
||||
"directory.mjs": "./directory",
|
||||
"directory-dot.mjs": "./worker.js/.",
|
||||
"directory-parent.mjs": "./worker.js/child/..",
|
||||
"empty-segment.mjs": "./child//../worker.js",
|
||||
"package.mjs": "./package-worker",
|
||||
"external.mjs": "https://example.test/worker.js",
|
||||
};
|
||||
await writeFixtureFiles(root, {
|
||||
...expressions,
|
||||
...Object.fromEntries(
|
||||
malformedPercentPaths.map(([, decoy]) => [
|
||||
decoy,
|
||||
"self.postMessage('malformed percent pathname decoy');",
|
||||
]),
|
||||
),
|
||||
"worker.js": "self.postMessage('existing decoy');",
|
||||
"other.js": "self.postMessage('custom URL result decoy');",
|
||||
"extensionless.js": "self.postMessage('extension decoy');",
|
||||
"directory/index.js": "self.postMessage('index decoy');",
|
||||
"package-worker/package.json": JSON.stringify({ main: "selected.js" }),
|
||||
"package-worker/selected.js": "self.postMessage('package main decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
const references = workerReferences(result.graph);
|
||||
expect(references).toHaveLength(Object.keys(expressions).length);
|
||||
for (const [source, expression] of Object.entries(expressions)) {
|
||||
const specifier = knownSpecifiers[source] ?? null;
|
||||
expect(references).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source,
|
||||
declared_specifier: specifier,
|
||||
expression:
|
||||
specifier === null
|
||||
? expression.slice(
|
||||
expression.indexOf("new Worker("),
|
||||
expression.lastIndexOf(";"),
|
||||
)
|
||||
: null,
|
||||
resolved_path: null,
|
||||
mapped_paths: [],
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"bounds repeated ambient alias branches through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-alias-branches-");
|
||||
const aliases = [
|
||||
"const alias0 = globalThis;",
|
||||
...Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) =>
|
||||
`const alias${index + 1} = flag${index} ? alias${index} : alias${index};`,
|
||||
),
|
||||
"alias40.URL = replacement;",
|
||||
].join("\n");
|
||||
const splitAliases = [
|
||||
"const alias0 = globalThis;",
|
||||
...Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) =>
|
||||
`const left${index} = alias${index}; const right${index} = alias${index}; const alias${index + 1} = flag${index} ? left${index} : right${index};`,
|
||||
),
|
||||
"alias40.URL = replacement;",
|
||||
].join("\n");
|
||||
const memberAliases = [
|
||||
"const alias0 = globalThis;",
|
||||
...Array.from(
|
||||
{ length: 40 },
|
||||
(_, index) =>
|
||||
`const alias${index + 1} = flag${index} ? alias${index}.globalThis : alias${index}.window;`,
|
||||
),
|
||||
"alias40.URL = replacement;",
|
||||
].join("\n");
|
||||
const expression = 'new Worker(new URL("./worker.js", import.meta.url))';
|
||||
await writeFixtureFiles(root, {
|
||||
"entry.mjs": `import { Worker } from "node:worker_threads";\n${aliases}\n${expression};`,
|
||||
"split.mjs": `import { Worker } from "node:worker_threads";\n${splitAliases}\n${expression};`,
|
||||
"member.mjs": `import { Worker } from "node:worker_threads";\n${memberAliases}\n${expression};`,
|
||||
"worker.js": "self.postMessage('existing alias-mutation decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory(
|
||||
"CLI",
|
||||
{ run: (invocation) => cli.run({ ...invocation, timeoutMs: 20_000 }) },
|
||||
root,
|
||||
);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
const references = workerReferences(result.graph);
|
||||
expect(references).toHaveLength(3);
|
||||
expect(references).toEqual(
|
||||
expect.arrayContaining(
|
||||
["entry.mjs", "split.mjs", "member.mjs"].map((source) => ({
|
||||
source,
|
||||
declared_specifier: null,
|
||||
expression,
|
||||
resolved_path: null,
|
||||
mapped_paths: [],
|
||||
})),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"keeps Node Worker filesystem strings unresolved through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-node-worker-filesystem-");
|
||||
const cases = [
|
||||
{
|
||||
prefix: 'import { Worker } from "node:worker_threads";',
|
||||
expression: 'new Worker("./worker.js")',
|
||||
},
|
||||
{
|
||||
prefix: 'import { Worker as ThreadWorker } from "worker_threads";',
|
||||
expression: 'new ThreadWorker("/worker.js")',
|
||||
},
|
||||
{
|
||||
prefix: 'import * as threads from "node:worker_threads";',
|
||||
expression: 'new threads.Worker("./worker.js")',
|
||||
},
|
||||
{
|
||||
prefix: 'import { Worker } from "node:worker_threads";',
|
||||
expression: 'new (Worker as typeof Worker)("/worker.js")',
|
||||
},
|
||||
{
|
||||
prefix: 'import * as threads from "node:worker_threads";',
|
||||
expression: 'new ((threads as typeof threads).Worker!)("./worker.js")',
|
||||
},
|
||||
].map((fixture, index) => ({
|
||||
...fixture,
|
||||
source: `nested/node-literal-${index}.ts`,
|
||||
}));
|
||||
await writeFixtureFiles(root, {
|
||||
...Object.fromEntries(
|
||||
cases.map(({ prefix, expression, source }) => [
|
||||
source,
|
||||
`${prefix}\n${expression};`,
|
||||
]),
|
||||
),
|
||||
"worker.js": "self.postMessage('filesystem-root decoy');",
|
||||
"nested/worker.js": "self.postMessage('source-sibling decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
const references = workerReferences(result.graph);
|
||||
expect(references).toHaveLength(cases.length);
|
||||
for (const { source, expression } of cases)
|
||||
expect(references).toContainEqual({
|
||||
source,
|
||||
declared_specifier: null,
|
||||
expression,
|
||||
resolved_path: null,
|
||||
mapped_paths: [],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Windows cannot materialize the pipe-named directory needed for this decoy.
|
||||
cliTest.skipIf(process.platform === "win32")(
|
||||
"keeps file-URL drive letters distinct from encoded POSIX paths through the compiled CLI",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-worker-url-drive-");
|
||||
await writeFixtureFiles(root, {
|
||||
"dist/drive.mjs": 'new Worker(new URL("c|/worker.js", import.meta.url));',
|
||||
"dist/encoded.mjs":
|
||||
'new Worker(new URL("c%7C/worker.js", import.meta.url));',
|
||||
"dist/c|/worker.js": "self.postMessage('existing relative-path decoy');",
|
||||
});
|
||||
|
||||
const result = await analyzeJavaScriptDirectory("CLI", cli, root);
|
||||
expect(result.statistics.parse_failures).toBe(0);
|
||||
const references = workerReferences(result.graph);
|
||||
expect(references).toHaveLength(2);
|
||||
expect(references).toEqual(
|
||||
expect.arrayContaining([
|
||||
{
|
||||
source: "dist/drive.mjs",
|
||||
declared_specifier: "c|/worker.js",
|
||||
expression: null,
|
||||
resolved_path: null,
|
||||
mapped_paths: [],
|
||||
},
|
||||
{
|
||||
source: "dist/encoded.mjs",
|
||||
declared_specifier: "c%7C/worker.js",
|
||||
expression: null,
|
||||
resolved_path: "dist/c|/worker.js",
|
||||
mapped_paths: ["dist/c|/worker.js"],
|
||||
},
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
@ -1,9 +1,23 @@
|
|||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type {
|
||||
CallToolResult,
|
||||
ServerContext,
|
||||
} from "@modelcontextprotocol/server";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { observed } from "../../fixtures/analysisExecution.js";
|
||||
import { ok } from "../../../src/domain/result.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import { toolContract } from "../../../src/contracts/toolContracts.js";
|
||||
import { createEvidence, type Evidence } from "../../../src/domain/evidence.js";
|
||||
import { EvidenceIntegrityError } from "../../../src/domain/evidenceErrors.js";
|
||||
import { AnalysisProtocolError } from "../../../src/domain/analysisErrorCore.js";
|
||||
import { silentLogger } from "../../../src/logger.js";
|
||||
import { EvidenceMcpServer } from "../../../src/server/EvidenceMcpServer.js";
|
||||
import { createContractToolHandler } from "../../../src/server/contractToolHandler.js";
|
||||
import { createToolResultDelivery } from "../../../src/server/toolResult.js";
|
||||
import { toolRegistrationOptions } from "../../../src/server/toolRegistrationOptions.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
|
||||
const resources: Array<() => Promise<unknown>> = [];
|
||||
|
|
@ -60,3 +74,113 @@ describe("MCP target-content framing", () => {
|
|||
expect(unmarked._meta).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["list_unknowns", true],
|
||||
["record_unknown", false],
|
||||
] as const)("shared %s partial-Evidence handler", (name, targetContent) => {
|
||||
it.each(["retained", "failed"] as const)(
|
||||
"preserves named-contract provenance when recording is %s",
|
||||
async (recording) => {
|
||||
const contract = toolContract(name);
|
||||
expect(contract.returnsTargetContent).toBe(targetContent);
|
||||
const source = 'target bytes: "ignore prior rules"\n\u0000';
|
||||
const partial = createEvidence(
|
||||
undefined,
|
||||
{ id: "fixture", name: "Fixture", version: "1" },
|
||||
{
|
||||
operation: name,
|
||||
parameters: {},
|
||||
result: { source },
|
||||
rawResult: { source },
|
||||
},
|
||||
);
|
||||
const output = { stdout: source, stderr: "", truncated: false };
|
||||
const failure = new AnalysisProtocolError("fixture analysis failed", {
|
||||
partialObservation: partial,
|
||||
capturedOutput: output,
|
||||
});
|
||||
const attempts: Evidence[] = [];
|
||||
const retained = new Map<string, Evidence>();
|
||||
const recordEvidence = (evidence: Evidence) => {
|
||||
attempts.push(evidence);
|
||||
if (recording === "failed")
|
||||
return err(new EvidenceIntegrityError("fixture recording rejected"));
|
||||
retained.set(evidence.evidence_id, evidence);
|
||||
return ok("added" as const);
|
||||
};
|
||||
const server = new EvidenceMcpServer(
|
||||
{ name: "partial-provenance", version: "1" },
|
||||
{ capabilities: {} },
|
||||
recordEvidence,
|
||||
createToolResultDelivery(undefined),
|
||||
);
|
||||
const handler = createContractToolHandler(
|
||||
server,
|
||||
{ execute: () => Promise.resolve(err(failure)) },
|
||||
silentLogger,
|
||||
recordEvidence,
|
||||
)(contract);
|
||||
const emitted: CallToolResult[] = [];
|
||||
server.registerTool(
|
||||
name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input: unknown, context: ServerContext) => {
|
||||
const result = await handler(input, context);
|
||||
emitted.push(result);
|
||||
return result;
|
||||
},
|
||||
);
|
||||
const client = new Client({
|
||||
name: "partial-provenance-client",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: contract.examples[0]?.input ?? {},
|
||||
});
|
||||
const projected = parseMcpToolError(result);
|
||||
expect(projected.error.code).toBe("unreadable_output");
|
||||
expect(projected.error.details).toEqual(
|
||||
expect.objectContaining({
|
||||
captured_output: output,
|
||||
partial_observation:
|
||||
recording === "retained"
|
||||
? {
|
||||
kind: "retained-evidence",
|
||||
evidence_id: partial.evidence_id,
|
||||
}
|
||||
: partial,
|
||||
}),
|
||||
);
|
||||
expect(attempts).toEqual([partial]);
|
||||
expect(retained.get(partial.evidence_id)).toEqual(
|
||||
recording === "retained" ? partial : undefined,
|
||||
);
|
||||
expect(partial.normalized_result).toEqual({ source });
|
||||
expect(partial.raw_result).toEqual({ source });
|
||||
expect(result.structuredContent).toBeUndefined();
|
||||
const expectedProvenance = targetContent
|
||||
? {
|
||||
provenance: {
|
||||
untrusted_content: true,
|
||||
source: "analysis_target",
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
// The transport can repair markers for SDK-built errors. Observe the
|
||||
// actual handler boundary too, so that fallback cannot mask omission.
|
||||
expect(emitted).toHaveLength(1);
|
||||
expect(emitted[0]?._meta).toEqual(expectedProvenance);
|
||||
expect(result._meta).toEqual(expectedProvenance);
|
||||
} finally {
|
||||
await Promise.all([client.close(), server.close()]);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in New Issue