Merge remote-tracking branch 'origin/main'

This commit is contained in:
morluto 2026-10-13 14:51:59 +08:00
commit 55e21a2eec
36 changed files with 1047 additions and 96 deletions

View File

@ -93,7 +93,7 @@ def project_record(state, secrets):
safe_bytes, credential_url = transport_value(value, path, transport_url)
content_base64 = base64.b64encode(value).decode("ascii")
digest = hashlib.sha256(value).hexdigest()
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest for secret in secrets)
hidden = any(secret in value or secret in safe_bytes for secret in byte_secrets) or any(secret in content_base64 or secret in digest or secret in str(len(value)) for secret in secrets)
safe_text = None
try:
safe_text = safe_bytes.decode("utf-8", errors="strict")

View File

@ -174,7 +174,7 @@ Resolution follows dyld:
- `@rpath` tries each `LC_RPATH` of the loading image first, then those of the
image that loaded it, up to the process root.
- Dependencies load dependents-first in load-command order, and each image
loads once per process. A later request whose install name matches an
loads once per process. A later @rpath request whose install name matches an
already loaded image reuses it.
- Symlinks such as `Versions/Current` are followed inside the analyzed root.
An escape through `..` or an absolute link is `escapes-target`.

View File

@ -153,8 +153,12 @@ the private runtime, isolating Ghidra preferences, logs, and caches.
Process creation assigns a suspended child atomically to an owned Job Object.
Membership and kill-on-owner-close policy are verified before resume.
Breakaway is disabled. Only the child's default file owner is normalized;
the caller token stays unchanged. Caller CPU affinity and priority are
preserved. Close, cancellation, timeout, startup failure, and provider failure
the caller token stays unchanged. Caller priority is preserved, and caller CPU
affinity whenever the caller and the child share a processor group. A child that
Windows places in another group keeps that group's default when the caller asked
for no bound of its own; a caller pinned to fewer processors than its group
holds is refused there rather than widened.
Close, cancellation, timeout, startup failure, and provider failure
terminate the job and verify all members have settled before runtime cleanup.
Cleanup removes admitted objects by handle and unlinks reparse entries without
following their targets. Abrupt owner death kills the job but can leave

View File

@ -69,10 +69,22 @@ without this lane reports Windows native controls as unavailable.
limits only for its JavaScript helpers when running on Bun, which ignores
their V8 heap flags; Node.js launches are unchanged.
- Only the suspended child's default file owner is normalized. Caller token
privileges and ownership remain unchanged. Caller CPU affinity and process
priority are preserved in the child. Environment names are sorted and
deduplicated using Windows ordinal comparison, preserving their native Unicode
semantics and caller-selected values.
privileges and ownership remain unchanged. Caller process priority is
preserved in the child, and caller CPU affinity whenever both occupy the same
processor group. Windows chooses a new process's group itself and rejects a
mask belonging to another group, so a child placed elsewhere keeps that
group's default when the caller asked for no bound of its own. A caller
deliberately pinned to fewer processors than its group holds is a constraint
such a child cannot express, and is refused rather than widened to that
group's default. A mask covering its group's whole active set does not
distinguish a caller Windows placed in that group from one that selected the
group deliberately, so such a child can still be placed elsewhere: its
processor count stays bounded, its group placement is not preserved, and
neither that case nor the refusal above has a verification lane, because both
need a host whose processors span more than one group.
Environment names are sorted and deduplicated using Windows
ordinal comparison, preserving their native Unicode semantics and
caller-selected values.
- Cleanup walks locked directory objects, removes files by handle, and unlinks
reparse entries without traversing their targets. Failed removal retains the
root and ancestor leases for retry; a partially removed runtime accepts only

View File

@ -68,6 +68,18 @@ static ULONGLONG jobMemoryLimit(napi_env env, napi_value value) {
return limit;
}
// An affinity mask is meaningful only inside one processor group, so only a
// process occupying exactly one group has a group a child can inherit. A
// process whose threads span groups, or whose membership cannot be read, has no
// single group.
static bool singleProcessorGroup(HANDLE process, USHORT& group) {
USHORT count = 1;
std::array<USHORT, 1> groups{};
if (!GetProcessGroupAffinity(process, &count, groups.data()) || count != 1) return false;
group = groups[0];
return true;
}
static void pipe(Handle& read, Handle& write, bool parentReads = true) {
SECURITY_ATTRIBUTES attributes{sizeof(SECURITY_ATTRIBUTES), nullptr, TRUE};
HANDLE rawRead, rawWrite;
@ -168,11 +180,27 @@ std::unique_ptr<Process> spawnProcess(const std::wstring& command, const std::ws
TOKEN_OWNER owner{reinterpret_cast<TOKEN_USER*>(tokenUser.data())->User.Sid};
require(SetTokenInformation(token.get(), TokenOwner, &owner, sizeof(owner)),
"Set suspended process file owner failed", command);
// A mask names processors inside one processor group, and Windows assigns a
// new process to a group of its own choosing, so a mask belonging to another
// group is rejected (ERROR_INVALID_PARAMETER). An unconstrained caller, whose
// mask is its group's whole active set, asks for no bound the child's own
// group cannot give, so such a child keeps that group's default and the spawn
// proceeds. A narrower mask is a deliberate CPU constraint that a child in
// another group cannot express, and silently widening it would hand the
// provider tree the whole group, so that spawn fails naming the constraint.
DWORD_PTR affinity = 0, systemAffinity = 0;
require(GetProcessAffinityMask(GetCurrentProcess(), &affinity, &systemAffinity),
"Read caller process affinity failed", command);
if (affinity != 0)
USHORT callerGroup = 0, childGroup = 0;
const bool sharedGroup = singleProcessorGroup(GetCurrentProcess(), callerGroup) &&
singleProcessorGroup(result->process.get(), childGroup) &&
callerGroup == childGroup;
if (affinity != 0 && sharedGroup)
require(SetProcessAffinityMask(result->process.get(), affinity), "Preserve caller process affinity failed", command);
else
require(affinity == 0 || affinity == systemAffinity,
"Caller CPU affinity cannot bound an owned process in another processor group", command,
ERROR_INVALID_PARAMETER);
const DWORD priority = GetPriorityClass(GetCurrentProcess());
require(priority != 0 && SetPriorityClass(result->process.get(), priority),
"Preserve caller process priority failed", command);

View File

@ -45,6 +45,20 @@ class Generator:
assert "ows-secret" not in json.dumps(projected), projected
if isinstance(value, bytes):
assert projected["binary_fields"][-1]["state"] == "redacted", projected
body = b"A" * 12345
state = {"response": {"content": body}}
control = adapter.project_record(state, [])
field = control["binary_fields"][0]
assert field["state"] == "retained" and field["bytes"] == len(body), control
for literal in ["12345", "2345"]:
# Only the generated byte count matches this exclusion.
assert literal not in control["reported"]["response"]["content"]
assert literal not in field["content_base64"] and literal not in field["sha256"]
projected = adapter.project_record(state, [literal])
assert projected["reported"]["response"]["content"] is None, projected
assert projected["binary_fields"] == [{"pointer": "/response/content", "representation": "producer-bytes", "state": "redacted", "content_base64": None, "bytes": None, "sha256": None}], projected
assert {"pointer": "/response/content", "reason": "explicit-sensitive-value"} in projected["redactions"], projected
assert literal not in json.dumps(projected), projected
(root / "native-error-classifications.json").write_text(json.dumps({"passed": len(classifications) + 2}))
first = http.HTTPFlow(connection.Client(peername=("127.0.0.1", 1), sockname=("127.0.0.1", 2)), connection.Server(address=("example.test", 80)))
first.id = "original-producer-id"

View File

@ -662,6 +662,42 @@ setInterval(()=>{},1000);\n`,
unicodeNamesPreserved: true,
});
report.processes.windowsEnvironmentSemantics = true;
// Observed through this process's own id: a separate probe process would
// get its own processor group, so it cannot stand in for the process that
// spawns the child.
const callerAffinity = JSON.parse(
execFileSync(boundaryFixture, ["affinity", String(process.pid)], {
encoding: "utf8",
}),
);
const affinityChild = native.call("process_spawn", [
boundaryFixture,
`${quote(boundaryFixture)} affinity`,
workspace,
environment,
]);
const observedAffinity = await wait(affinityChild);
native.call("process_close", [affinityChild.handle]);
assert.equal(observedAffinity.exitCode, 0);
const childAffinity = JSON.parse(observedAffinity.output);
// Masks cross as hexadecimal strings because a mask can exceed the
// safe-integer range. Sharing one processor group makes the caller's mask
// applicable, so it must be inherited exactly. Windows placing the child in
// another group is only permitted when the caller asked for no bound of its
// own, which its group's whole active set establishes; a narrower mask has
// to fail the spawn rather than widen to that group's default.
const sharedGroup =
callerAffinity.singleProcessorGroup &&
childAffinity.singleProcessorGroup &&
callerAffinity.processorGroup === childAffinity.processorGroup;
if (sharedGroup)
assert.equal(childAffinity.affinityMask, callerAffinity.affinityMask);
else assert.equal(callerAffinity.affinityMask, callerAffinity.systemMask);
report.processes.callerAffinityBoundsOwnedProcessOrRefusesToWiden = true;
report.callerAffinity = {
...callerAffinity,
sharedGroupWithChild: sharedGroup,
};
}
const ownerFixture = join(workspace, "owner.mjs");
const ownerPidFile = join(workspace, "crash-child.pid");

View File

@ -144,7 +144,8 @@ const projectHar = (
(literal) =>
item.includes(literal) ||
contentBase64.includes(literal) ||
sha256.includes(literal),
sha256.includes(literal) ||
String(bytes.length).includes(literal),
);
binary_fields.push(
hidden

View File

@ -3,6 +3,18 @@ import { describe, expect, it } from "vitest";
import { parseConfig } from "./config/parseConfig.js";
import { projectAnalysisError } from "./domain/analysisErrorProjection.js";
it("reports a configuration failure for a NUL in Hopper loader arguments", () => {
const result = parseConfig({
HOPPER_LOADER_ARGS_JSON: JSON.stringify(["--option", "value\0suffix"]),
});
if (result.ok)
throw new Error("NUL argv cannot be passed to a child process");
expect(result.error).toMatchObject({
settings: [{ setting: "HOPPER_LOADER_ARGS_JSON" }],
});
expect(result.error.message).toContain("NUL");
});
describe("runtime configuration", () => {
it("allows target-free startup and applies runtime defaults", () => {
const empty = parseConfig({});

View File

@ -1,6 +1,7 @@
import { z } from "zod";
import { ConfigurationError } from "../domain/configurationErrors.js";
import { nulFreeStringSchema } from "../domain/localPath.js";
import { err, ok, type Result } from "../domain/result.js";
import { safeParseJson } from "../domain/safeJson.js";
@ -20,7 +21,8 @@ export const parseLoaderArgs = (
return parseConfiguredStringArray(
encoded,
"HOPPER_LOADER_ARGS_JSON",
z.string(),
nulFreeStringSchema,
"must encode an array of strings without NUL bytes",
);
};
@ -28,6 +30,7 @@ const parseConfiguredStringArray = (
encoded: string,
setting: string,
itemSchema: z.ZodString,
requirement = "must encode an array of strings",
): Result<readonly string[], ConfigurationError> => {
const decoded = safeParseJson(encoded);
if (!decoded.ok)
@ -37,13 +40,7 @@ const parseConfiguredStringArray = (
const parsed = z.array(itemSchema).safeParse(decoded.value);
return parsed.success
? ok(parsed.data)
: err(
configuredArrayError(
setting,
"must encode an array of strings",
parsed.error,
),
);
: err(configuredArrayError(setting, requirement, parsed.error));
};
const configuredArrayError = (

View File

@ -597,6 +597,136 @@ describe("dyld load order", () => {
});
});
describe("loader-relative dylib resolution", () => {
it("does not reuse already loaded images by install name for @loader_path dependencies", async () => {
const pluginA = "Contents/PlugIns/A/pluginA.dylib";
const helperA = "Contents/PlugIns/A/libhelper.dylib";
const pluginB = "Contents/PlugIns/B/pluginB.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [
dependency("@executable_path/../PlugIns/A/pluginA.dylib"),
dependency("@executable_path/../PlugIns/B/pluginB.dylib"),
],
}),
[pluginA]: parsed(
slice({
dependencies: [dependency("@loader_path/libhelper.dylib")],
}),
),
[helperA]: parsed(
slice({
install_name: "@loader_path/libhelper.dylib",
}),
),
[pluginB]: parsed(
slice({
dependencies: [dependency("@loader_path/libhelper.dylib")],
}),
),
}),
{ roots: [MAIN] },
);
const edgeA = edgeFor(trace, pluginA, "@loader_path/libhelper.dylib");
expect(edgeA.resolution).toEqual({
status: "resolved",
image: helperA,
});
expect(edgeA.candidates).toEqual([
expect.objectContaining({
source: "loader_path",
outcome: "resolved",
resolved_path: helperA,
}),
]);
const edgeB = edgeFor(trace, pluginB, "@loader_path/libhelper.dylib");
expect(edgeB.resolution).toEqual({
status: "unresolved",
image: null,
});
expect(edgeB.candidates).toEqual([
expect.objectContaining({
path: "Contents/PlugIns/B/libhelper.dylib",
source: "loader_path",
outcome: "absent",
resolved_path: null,
}),
]);
expect(trace.findings).toContainEqual(
expect.objectContaining({
kind: "required-load-unresolved",
image: pluginB,
}),
);
});
it("resolves @loader_path dependencies to the loader's own directory when multiple helpers exist", async () => {
const pluginA = "Contents/PlugIns/A/pluginA.dylib";
const helperA = "Contents/PlugIns/A/libhelper.dylib";
const pluginB = "Contents/PlugIns/B/pluginB.dylib";
const helperB = "Contents/PlugIns/B/libhelper.dylib";
const trace = await traceDylibLoading(
memoryView({
[MAIN]: executable({
dependencies: [
dependency("@executable_path/../PlugIns/A/pluginA.dylib"),
dependency("@executable_path/../PlugIns/B/pluginB.dylib"),
],
}),
[pluginA]: parsed(
slice({
dependencies: [dependency("@loader_path/libhelper.dylib")],
}),
),
[helperA]: parsed(
slice({
install_name: "@loader_path/libhelper.dylib",
}),
),
[pluginB]: parsed(
slice({
dependencies: [dependency("@loader_path/libhelper.dylib")],
}),
),
[helperB]: parsed(
slice({
install_name: "@loader_path/libhelper.dylib",
}),
),
}),
{ roots: [MAIN] },
);
expect(
edgeFor(trace, pluginA, "@loader_path/libhelper.dylib"),
).toMatchObject({
resolution: { status: "resolved", image: helperA },
candidates: [
expect.objectContaining({
source: "loader_path",
outcome: "resolved",
resolved_path: helperA,
}),
],
});
expect(
edgeFor(trace, pluginB, "@loader_path/libhelper.dylib"),
).toMatchObject({
resolution: { status: "resolved", image: helperB },
candidates: [
expect.objectContaining({
source: "loader_path",
outcome: "resolved",
resolved_path: helperB,
}),
],
});
});
});
describe("dyld slice compatibility and coverage", () => {
it("lets an x86_64h process load generic x86_64 but not arm64e load arm64", async () => {
const files = {

View File

@ -427,7 +427,9 @@ const resolveDependency = async (
): Promise<Edge> => {
const current = context.loaded.get(loader);
const chain = current?.chain ?? [loader];
const loaded = context.byInstallName.get(dependency.install_name);
const loaded = dependency.install_name.startsWith("@rpath/")
? context.byInstallName.get(dependency.install_name)
: undefined;
const candidates: Candidate[] = [];
if (loaded !== undefined)
candidates.push({
@ -506,7 +508,10 @@ const traceProcess = async (
chain: [context.root],
conditional: false,
});
if (rootSlice.install_name !== null)
if (
rootSlice.install_name !== null &&
rootSlice.install_name.startsWith("@rpath/")
)
context.byInstallName.set(rootSlice.install_name, context.root);
const pending = [context.root];
while (pending.length > 0) {
@ -537,6 +542,7 @@ const traceProcess = async (
});
if (
slice.install_name !== null &&
slice.install_name.startsWith("@rpath/") &&
!context.byInstallName.has(slice.install_name)
)
context.byInstallName.set(slice.install_name, image);

View File

@ -91,7 +91,7 @@ export const DYLIB_RESOLUTION_LIMITATIONS = [
"Slices are matched by dyld's graded architectures (an x86_64h process also loads x86_64). arm64e processes that disable pointer authentication can also load arm64 slices; that fallback is not modeled.",
"Absolute install names and rpaths are outside the analyzed root and are not evaluated, including /System and /usr/lib libraries that the dyld shared cache usually provides.",
"Leaf and relative install names depend on dyld fallback paths, DYLD_* variables, and the working directory; they are undetermined.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later request whose install name matches an already loaded image reuses it.",
"Load order follows dyld's dependents-first traversal in load-command order. An image reached through several chains is resolved once per process, with the rpath stack and conditionality of the first chain; a later @rpath request whose install name matches an already loaded image reuses it.",
"@loader_path uses each image's symlink-resolved path within the analyzed root.",
"Code-signing checks that can reject a found image, such as library validation and the hardened runtime, are not evaluated.",
"Dylib compatibility versions are not compared. Apple's linker documentation describes a load-time rejection when an image's compatibility version is older than the loader requires, but dyld on macOS 26 was observed to load such an image.",

View File

@ -116,6 +116,24 @@ it("keeps unsupported profile reasons distinct from malformed input", () => {
expect(JSON.stringify(projectAnalysisError(result))).not.toContain("private");
});
it("preserves a retryable capability failure through explicit-value redaction", () => {
// Retryability is a semantic field of the error, not presentation: dropping
// it turns a transient admission failure into a permanent one and changes
// the remediation the caller is shown.
const error = new AnalysisCapabilityUnavailableError(
"mitmproxy",
"inspect_web_network_capture",
"Runtime admission for /targets/private/capture failed transiently",
{ retryable: true },
);
expect(projectAnalysisError(error).retryable).toBe(true);
const result = redactExplicitFailure(error, ["private"]);
expect(result).toBeInstanceOf(AnalysisCapabilityUnavailableError);
expect((result as AnalysisCapabilityUnavailableError).retryable).toBe(true);
expect(projectAnalysisError(result).retryable).toBe(true);
expect(JSON.stringify(projectAnalysisError(result))).not.toContain("private");
});
it("coarsens format issue pointers without creating replacement coordinates", () => {
const error = new AnalysisInputError(
"inspect_web_network_capture",

View File

@ -154,6 +154,7 @@ export const redactExplicitFailure = (
...(error.userMessage === undefined
? {}
: { userMessage: text(error.userMessage) }),
...(error.retryable ? { retryable: true } : {}),
},
);
if (error instanceof AnalysisResourceConstraintError)

View File

@ -1,4 +1,4 @@
import { posix, relative, win32 } from "node:path";
import { posix, win32 } from "node:path";
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
@ -216,7 +216,7 @@ const pathBelowRoot = (root: string, value: string): string | null => {
const remainder =
rootStyle === "windows"
? win32.relative(root, value)
: relative(root, value);
: posix.relative(root, value);
const portableRemainder = remainder.replaceAll("\\", "/");
if (
remainder === "" ||

View File

@ -37,10 +37,19 @@ export const parserPluginsForPath = (sourcePath?: string): ParserPlugin[] => {
: ["decorators-legacy", typescript];
};
/** `.mjs` and `.mts` are modules by extension; other paths leave the mode to syntax. */
/** `.mjs` and `.mts` explicitly select module mode. */
export const isExplicitModuleSourcePath = (path?: string): boolean =>
/\.m[jt]s$/iu.test(path ?? "");
const runtimeSourceTypeForPath = (
path?: string,
): "commonjs" | "module" | "unambiguous" =>
/\.cjs$/iu.test(path ?? "")
? "commonjs"
: isExplicitModuleSourcePath(path)
? "module"
: "unambiguous";
export interface RecoveredJavaScriptParse {
readonly file: ParsedJavaScriptSource;
/** Errors that apply only if a source of unknown mode is an ES module. */
@ -58,25 +67,25 @@ export const parseRecoveredJavaScript = (
sourcePath: string | undefined,
options: Omit<ParserOptions, "sourceType" | "errorRecovery">,
): RecoveredJavaScriptParse => {
// `.cjs` is CommonJS by extension, so module-only errors cannot apply.
if (/\.cjs$/iu.test(sourcePath ?? ""))
const sourceType = runtimeSourceTypeForPath(sourcePath);
if (sourceType !== "unambiguous") {
const normalizedOptions = { ...options };
if (sourceType === "commonjs") {
// Babel owns these wrapper semantics in CommonJS mode and rejects
// explicit script overrides, including the reference-import options.
delete normalizedOptions.allowAwaitOutsideFunction;
delete normalizedOptions.allowReturnOutsideFunction;
delete normalizedOptions.allowNewTargetOutsideFunction;
}
return {
file: parse(source, {
...options,
sourceType: "script",
errorRecovery: true,
}),
moduleOnlyErrors: [],
};
if (isExplicitModuleSourcePath(sourcePath))
return {
file: parse(source, {
...options,
sourceType: "module",
...normalizedOptions,
sourceType,
errorRecovery: true,
}),
moduleOnlyErrors: [],
};
}
const file = parse(source, {
...options,
sourceType: "unambiguous",
@ -98,18 +107,34 @@ export const parseRecoveredJavaScript = (
};
};
/** Parse JavaScript or TypeScript once without attaching comments to AST nodes. */
/** Parse inert JavaScript or TypeScript facts without attaching comments to AST nodes. */
export const parseJavaScriptSource = (
source: string,
sourcePath?: string,
): ParsedJavaScriptSource | null => {
try {
return parse(source, {
sourceType: "unambiguous",
const runtimeSourceType = runtimeSourceTypeForPath(sourcePath);
const commonjs = runtimeSourceType === "commonjs";
const options = {
errorRecovery: true,
attachComment: false,
plugins: parserPluginsForPath(sourcePath),
};
const file = parse(source, {
...options,
// Inert artifact analysis retains module declarations even in .cjs assets.
// Infer their syntax here; reference imports keep filename-mode diagnostics.
sourceType: commonjs ? "unambiguous" : runtimeSourceType,
allowReturnOutsideFunction: commonjs,
allowNewTargetOutsideFunction: commonjs,
});
// Unambiguous mode retains module-only diagnostics after choosing a script.
// The .cjs extension establishes wrapper mode, so obtain its own diagnostics.
return commonjs &&
file.program.sourceType === "script" &&
file.errors.length > 0
? parse(source, { ...options, sourceType: "commonjs" })
: file;
} catch (cause: unknown) {
// Unparseable source is represented by the null return.
void cause;

View File

@ -33,3 +33,24 @@ it("rejects incomplete headers, invalid signatures and zero dimensions", () => {
expect(readPngHeaderDimensions(zero)).toBeUndefined();
}
});
it.each([0, 12, 14, 0xffff_ffff])("rejects IHDR data length %s", (length) => {
const bytes = Buffer.from(png);
bytes.writeUInt32BE(length, 8);
expect(readPngHeaderDimensions(bytes)).toBeUndefined();
});
it.each([16, 20])("rejects out-of-range PNG dimension at %s", (offset) => {
const bytes = Buffer.from(png);
bytes.writeUInt32BE(0x8000_0000, offset);
expect(readPngHeaderDimensions(bytes)).toBeUndefined();
});
it("accepts the largest dimension representable by the PNG specification", () => {
const bytes = Buffer.from(png);
bytes.writeUInt32BE(0x7fff_ffff, 16);
expect(readPngHeaderDimensions(bytes)).toEqual({
width: 0x7fff_ffff,
height: 1,
});
});

View File

@ -8,10 +8,16 @@ export const readPngHeaderDimensions = (
if (
bytes.byteLength < 24 ||
!bytes.subarray(0, 8).equals(PNG_SIGNATURE) ||
bytes.readUInt32BE(8) !== 13 ||
!bytes.subarray(12, 16).equals(IHDR_TYPE)
)
return undefined;
const width = bytes.readUInt32BE(16);
const height = bytes.readUInt32BE(20);
return width === 0 || height === 0 ? undefined : { width, height };
return width === 0 ||
height === 0 ||
width > 0x7fff_ffff ||
height > 0x7fff_ffff
? undefined
: { width, height };
};

View File

@ -1,6 +1,7 @@
import { describe, expect, it } from "vitest";
import { parseProcessCapture } from "./processCaptureParsing.js";
import type { ProcessTraceSpecification } from "./processTraceSpecification.js";
import { processTraceSpecificationSchema as specificationSchema } from "./processTraceSpecification.js";
import { processTraceComparisonResultSchema } from "./processTraceEvaluation.js";
import {
capture,
@ -100,6 +101,33 @@ describe("generic capture trace comparison", () => {
right: { matched_variant: "two" },
});
});
it("compares a declared identifier the comparison result can represent", () => {
// The specification owns the trace identifier vocabulary and the result
// echoes those identifiers verbatim, so both must accept the same values
// rather than handing the caller a raw schema error for its own input.
const eventId = `e${"0".repeat(64)}`;
const specification: ProcessTraceSpecification = {
events: [
{
id: eventId,
source: "terminal_raw",
exact: terminal,
cardinality: { kind: "required" },
},
],
language: {
kind: "finite_traces",
variants: [{ id: "only", trace: [eventId] }],
},
};
expect(specificationSchema.safeParse(specification).success).toBe(true);
const left = capture(values(["terminal", "process"]));
expect(compareProcessTraces(left, left, specification)).toMatchObject({
verdict: "equivalent",
left: { matched_variant: "only", raw_trace: [{ event_id: eventId }] },
});
});
});
describe("process trace constraints and evidence sufficiency", () => {

View File

@ -13,13 +13,12 @@ import {
import {
canonicalTraceJson,
processTraceCardinalityBounds,
processTraceIdentifierSchema,
type ProcessTraceSpecification,
} from "./processTraceSpecification.js";
const identifierSchema = z.string().regex(/^[A-Za-z][A-Za-z0-9._\x2d]{0,63}$/u);
const matchedEventSchema = z.strictObject({
event_id: identifierSchema,
event_id: processTraceIdentifierSchema,
location: processObservationLocationSchema,
});
@ -32,7 +31,7 @@ const sideResultSchema = z.union([
z.strictObject({
...sideResultShape,
status: z.literal("pass"),
matched_variant: identifierSchema.nullable(),
matched_variant: processTraceIdentifierSchema.nullable(),
}),
z.strictObject({
...sideResultShape,
@ -53,7 +52,7 @@ const diagnosticSchema = z.strictObject({
]),
side: z.enum(["left", "right"]),
message: z.string(),
event_ids: z.array(identifierSchema),
event_ids: z.array(processTraceIdentifierSchema),
locations: z.array(processObservationLocationSchema),
});

View File

@ -9,7 +9,16 @@ import {
processObservationSourceSchema,
} from "./processObservation.js";
const identifierSchema = z.string().regex(IDENTIFIER_PATTERN);
/**
* A declared trace, event or variant identifier. The specification owns this
* vocabulary and the comparison result echoes those identifiers verbatim, so
* the result module must reuse this schema rather than restate the pattern:
* two bounds on the same identifier would reject the result of an accepted
* specification.
*/
export const processTraceIdentifierSchema = z
.string()
.regex(IDENTIFIER_PATTERN);
const cardinalitySchema = z.discriminatedUnion("kind", [
z.strictObject({ kind: z.literal("required") }),
@ -31,7 +40,7 @@ const cardinalitySchema = z.discriminatedUnion("kind", [
]);
const eventDeclarationSchema = z.strictObject({
id: identifierSchema,
id: processTraceIdentifierSchema,
source: processObservationSourceSchema,
exact: jsonValueSchema,
ignore_fields: z
@ -56,29 +65,31 @@ const partialOrderLanguageSchema = z.strictObject({
happens_before: z
.array(
z.strictObject({
before: identifierSchema,
after: identifierSchema,
before: processTraceIdentifierSchema,
after: processTraceIdentifierSchema,
}),
)
.default([]),
not_before: z
.array(
z.strictObject({
event: identifierSchema,
anchor: identifierSchema,
event: processTraceIdentifierSchema,
anchor: processTraceIdentifierSchema,
}),
)
.default([]),
unordered_groups: z
.array(z.strictObject({ events: z.array(identifierSchema).min(2) }))
.array(
z.strictObject({ events: z.array(processTraceIdentifierSchema).min(2) }),
)
.default([]),
prefix: z.array(identifierSchema).default([]),
suffix: z.array(identifierSchema).default([]),
prefix: z.array(processTraceIdentifierSchema).default([]),
suffix: z.array(processTraceIdentifierSchema).default([]),
});
const finiteTraceTokenSchema = z.union([
identifierSchema,
z.strictObject({ unordered: z.array(identifierSchema).min(2) }),
processTraceIdentifierSchema,
z.strictObject({ unordered: z.array(processTraceIdentifierSchema).min(2) }),
]);
const finiteTraceLanguageSchema = z.strictObject({
@ -86,7 +97,7 @@ const finiteTraceLanguageSchema = z.strictObject({
variants: z
.array(
z.strictObject({
id: identifierSchema,
id: processTraceIdentifierSchema,
trace: z.array(finiteTraceTokenSchema),
}),
)

View File

@ -23,6 +23,7 @@ import {
AnalysisUnsupportedTargetError,
} from "../domain/analysisErrorCore.js";
import { AnalysisProtocolError } from "../domain/analysisErrorCore.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { err, ok, type Result } from "../domain/result.js";
import {
@ -759,7 +760,7 @@ export class FlutterBuildProvider {
toolchain_capped: value.libflutter.toolchainCapped,
},
}))
.sort((left, right) => left.abi.localeCompare(right.abi));
.sort((left, right) => compareUnicodeCodePoints(left.abi, right.abi));
const flutterDetected = abis.some((entry) => entry.libapp.present);
const partial =
abis.some(
@ -871,7 +872,7 @@ export class FlutterBuildProvider {
(left, right) =>
(preference.indexOf(left.abi) + 1 || preference.length + 1) -
(preference.indexOf(right.abi) + 1 || preference.length + 1) ||
left.abi.localeCompare(right.abi),
compareUnicodeCodePoints(left.abi, right.abi),
)[0]!
: candidates.find((candidate) => candidate.abi === abi);
if (selected === undefined)

View File

@ -85,8 +85,10 @@ export class IdaWorkspace {
slice === undefined
? position + bytes.length
: Math.min(position + bytes.length, slice.offset + slice.size);
const admitted = bytes.subarray(start - position, end - position);
if (admitted.length > 0) {
// A chunk after the slice has end < position, and a negative subarray
// bound would count from the chunk's end instead of being empty.
if (end > start) {
const admitted = bytes.subarray(start - position, end - position);
inputHash.update(admitted);
await input.write(admitted);
}

View File

@ -5,7 +5,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { parseBinaryTarget } from "../application/BinaryTargetResolver.js";
import { parseExecutableHeader } from "../domain/binaryTarget.js";
import { fat } from "../domain/binaryTarget.fixture.js";
import { fat, thinMach } from "../domain/binaryTarget.fixture.js";
import {
createIdaTarget,
RecordingIdaMcp,
@ -35,6 +35,45 @@ const universalTarget = async (hostArchitecture: "arm64" | "x64") => {
return { bytes, target: resolved.value };
};
/** One slice of a synthetic universal file: CPU type, offset and size. */
interface Layout {
readonly cpu: number;
readonly offset: number;
readonly size: number;
}
/**
* Write a universal file whose slices span more than one 64 KiB read, each
* filled with its own byte so bytes outside the selected slice are visible.
*/
const largeUniversalTarget = async (
layout: readonly Layout[],
hostArchitecture: "arm64" | "x64",
) => {
const directory = await createTestTempDirectory("rea-ida-thin-large-");
roots.push(directory);
const path = join(directory, "universal");
const total = Math.max(...layout.map(({ offset, size }) => offset + size));
const bytes = Buffer.alloc(total);
bytes.writeUInt32BE(0xcafebabe, 0);
bytes.writeUInt32BE(layout.length, 4);
layout.forEach(({ cpu, offset, size }, index) => {
const entry = 8 + index * 20;
bytes.writeUInt32BE(cpu, entry);
bytes.writeUInt32BE(offset, entry + 8);
bytes.writeUInt32BE(size, entry + 12);
bytes.writeUInt32BE(14, entry + 16);
bytes.fill(0x11 * (index + 1), offset, offset + size);
thinMach(0xfeedfacf, cpu).copy(bytes, offset);
});
await writeFile(path, bytes);
const resolved = await parseBinaryTarget(path, { hostArchitecture });
if (!resolved.ok) throw resolved.error;
if (resolved.value.format !== "mach-o-universal")
throw new Error("the universal fixture did not resolve as a FAT container");
return { bytes, target: resolved.value };
};
describe("IDA workspace input thinning", () => {
it.skipIf(process.platform === "win32")(
"thins the selected arm64 slice into the private headless input",
@ -78,6 +117,58 @@ describe("IDA workspace input thinning", () => {
},
);
it.skipIf(process.platform === "win32")(
"copies exactly the selected slice when later slices follow it across read boundaries",
async () => {
const { bytes, target } = await largeUniversalTarget(
[
{ cpu: 0x01000007, offset: 4096, size: 8480 },
{ cpu: 0x0100000c, offset: 16384, size: 33464 },
{ cpu: 0x00000007, offset: 65536, size: 33464 },
],
"arm64",
);
const workspace = await IdaWorkspace.allocate(target);
roots.push(workspace.directory);
await workspace.copyInput();
const input = await readFile(workspace.inputPath);
const { offset, size } = target.universalSlice;
expect(size, "the arm64 slice is selected").toBe(33464);
expect(input.length, "no byte after the slice is copied").toBe(size);
expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true);
expect(workspace.inputSha256).toBe(
createHash("sha256")
.update(bytes.subarray(offset, offset + size))
.digest("hex"),
);
await workspace.remove();
},
);
it.skipIf(process.platform === "win32")(
"copies exactly the first slice when it is selected and others follow",
async () => {
const { bytes, target } = await largeUniversalTarget(
[
{ cpu: 0x01000007, offset: 16384, size: 37456 },
{ cpu: 0x0100000c, offset: 65536, size: 33464 },
],
"x64",
);
const workspace = await IdaWorkspace.allocate(target);
roots.push(workspace.directory);
await workspace.copyInput();
const input = await readFile(workspace.inputPath);
const { offset, size } = target.universalSlice;
expect(size, "the x86_64 slice is selected").toBe(37456);
expect(input.length, "no byte after the slice is copied").toBe(size);
expect(input.equals(bytes.subarray(offset, offset + size))).toBe(true);
await workspace.remove();
},
);
it("keeps a whole-file copy for thin targets", async () => {
const { root, target } = await createIdaTarget("arm64");
roots.push(root);

View File

@ -35,6 +35,37 @@ const validPng = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=",
"base64",
);
it.each([
{ offset: 8, value: 12 },
{ offset: 16, value: 0x8000_0000 },
{ offset: 20, value: 0x8000_0000 },
])(
"rejects invalid IHDR metadata with a matching digest",
async ({ offset, value }) => {
const bytes = Buffer.from(validPng);
bytes.writeUInt32BE(value, offset);
const screenshot = {
mime_type: "image/png",
base64: bytes.toString("base64"),
sha256: createHash("sha256").update(bytes).digest("hex"),
width: bytes.readUInt32BE(16),
height: bytes.readUInt32BE(20),
};
const result = await observeNativeUi(
target,
"observe_native_ui",
{ ...scope, screenshot: true },
{
environment: {},
invoke: async () => ({
ok: true,
result: { ...snapshot, screenshot },
}),
},
);
expect(result.ok).toBe(false);
},
);
describe("native UI screenshot validation", () => {
it("rejects malformed or mismatched screenshot bytes from the helper", async () => {
const screenshot = {

View File

@ -83,6 +83,24 @@ it("counts IPv6 endpoints once per packet and preserves every examined fact", as
expect(result.partial).toBe(false);
});
it("orders equal-count endpoints and protocols by code point, not host locale", async () => {
// Locale collation puts "fe80::1" first and, under da-DK, "aarp" after "zz".
const result = await parseTsharkJsonOutput(
capture([
{ "ipv6.src": ["fe80::1"], "frame.protocols": ["eth:llc:zz"] },
{ "ipv6.src": ["fe80:1::1"], "frame.protocols": ["eth:llc:aarp"] },
]),
);
expect(result.endpoints.map(({ address }) => address)).toEqual([
"fe80:1::1",
"fe80::1",
]);
expect(result.protocolDistribution.map(({ protocol }) => protocol)).toEqual([
"eth:llc:aarp",
"eth:llc:zz",
]);
});
it("preserves all DNS addresses and their exact examined count", async () => {
const result = await parseTsharkJsonOutput(
capture(

View File

@ -4,6 +4,7 @@ import {
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { parseRecordedTransportUrl } from "../domain/recordedTransportUrl.js";
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
const OPERATION = "inspect_pcap_capture";
/** Bounds JSON decoding plus its expanded object graph before JSON.parse. */
@ -284,7 +285,7 @@ export const parseTsharkJsonOutput = async (
signal?.throwIfAborted();
return {
endpoints: [...endpoints]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1] || compareUnicodeCodePoints(a[0], b[0]))
.map(([address, packet_count]) => ({
address,
port: null,
@ -300,7 +301,7 @@ export const parseTsharkJsonOutput = async (
tlsSniValues: [...sni],
tlsSniCount: sni.size,
protocolDistribution: [...protocols]
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
.sort((a, b) => b[1] - a[1] || compareUnicodeCodePoints(a[0], b[0]))
.map(([protocol, packet_count]) => ({ protocol, packet_count })),
protocolCount: protocols.size,
httpRequestUris: [...uris],

View File

@ -12,6 +12,8 @@ import { err, ok } from "../domain/result.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import { inspectEvidenceBundle } from "../application/investigation/InspectEvidenceBundle.js";
import { toolRegistrationOptions } from "./toolRegistrationOptions.js";
import { withAdmittedAnalysis } from "./analysisAdmission.js";
import { runAdmittedToolOperation } from "./admittedToolOperation.js";
interface EvidenceToolRegistration {
readonly server: EvidenceMcpServer;
@ -92,40 +94,54 @@ const registerImportEvidenceTool = ({
resources,
importContract,
}: EvidenceToolRegistration): void => {
const admission = withAdmittedAnalysis({ kind: "session", session });
server.registerTool(
importContract.name,
toolRegistrationOptions(importContract),
async (input, context) => {
const path = input.path;
const signal = context.mcpReq.signal;
const loaded = await readImmutableEvidenceBundle(path, signal, resources);
if (!loaded.ok)
return server.delivery.toCallToolResult(loaded, importContract);
const retainedUnknownRevisions = new Set(
bundleForSerialization(session).unknowns.map((unknown) =>
unknownRevisionKey(unknown),
),
);
if (signal.aborted)
return server.delivery.toCallToolResult(
err(new AnalysisCancelledError("import_evidence_bundle")),
importContract,
);
const imported = session.importEvidenceBundle(loaded.value);
return imported.ok
? server.delivery.toCallToolResult(
ok({
imported: imported.value,
unknowns_added: loaded.value.unknowns.filter(
(unknown) =>
!retainedUnknownRevisions.has(unknownRevisionKey(unknown)),
).length,
total: bundleForSerialization(session).records.length,
}),
importContract,
)
: server.delivery.toCallToolResult(imported, importContract);
},
async (input, context) =>
runAdmittedToolOperation(
server,
admission,
importContract.name,
context.mcpReq.signal,
async () => {
const path = input.path;
const signal = context.mcpReq.signal;
const loaded = await readImmutableEvidenceBundle(
path,
signal,
resources,
);
if (!loaded.ok)
return server.delivery.toCallToolResult(loaded, importContract);
const retainedUnknownRevisions = new Set(
bundleForSerialization(session).unknowns.map((unknown) =>
unknownRevisionKey(unknown),
),
);
if (signal.aborted)
return server.delivery.toCallToolResult(
err(new AnalysisCancelledError("import_evidence_bundle")),
importContract,
);
const imported = session.importEvidenceBundle(loaded.value);
return imported.ok
? server.delivery.toCallToolResult(
ok({
imported: imported.value,
unknowns_added: loaded.value.unknowns.filter(
(unknown) =>
!retainedUnknownRevisions.has(
unknownRevisionKey(unknown),
),
).length,
total: bundleForSerialization(session).records.length,
}),
importContract,
)
: server.delivery.toCallToolResult(imported, importContract);
},
),
);
};

View File

@ -3,6 +3,37 @@ import { CdpBrowserProvider } from "../../../src/browser/CdpBrowserProvider.js";
import { captureWebScreenshotInputSchema } from "../../../src/domain/webScreenshot.js";
import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js";
describe("screenshot committed document identity", () => {
it.each([
{ offset: 8, value: 14 },
{ offset: 16, value: 0x8000_0000 },
{ offset: 20, value: 0x8000_0000 },
])("rejects invalid screenshot IHDR metadata", async ({ offset, value }) => {
const bytes = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=",
"base64",
);
bytes.writeUInt32BE(value, offset);
const browser = await startFakeCdpBrowser({
commandResult: ({ method }) =>
method === "Page.captureScreenshot"
? { data: bytes.toString("base64") }
: undefined,
});
onTestFinished(async () => {
await browser.close();
});
const result = await new CdpBrowserProvider().captureScreenshot(
captureWebScreenshotInputSchema.parse({
cdp_endpoint: browser.endpoint,
allowed_origins: [browser.allowedOrigin],
target_id: "allowed-page",
}),
);
expect(result).toMatchObject({
ok: false,
error: { reason: "protocol_error" },
});
});
it.each(["loader-main", "loader-reloaded"])(
"distinguishes %s for the same URL",
async (screenshotDocumentLoader) => {

View File

@ -7,6 +7,9 @@ import { execFile } from "node:child_process";
import { fileURLToPath } from "node:url";
import { promisify } from "node:util";
import { parseEvidence } from "../../../src/domain/evidence.js";
import { webNetworkCaptureSchema } from "../../../src/domain/webNetworkCapture.js";
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const entrypoint = fileURLToPath(
new URL("../../../scripts/rea.mjs", import.meta.url),
);
@ -41,6 +44,91 @@ const cli = async (
};
import { historicalHar } from "../../fixtures/historicalHar.js";
it("excludes declared binary lengths through CLI, MCP and retained capture pages", async () => {
const root = await createTestTempDirectory("rea-har-byte-length-");
const path = join(root, "capture.har");
const fixture = historicalHar();
const entry = fixture.log.entries[0];
if (entry === undefined) throw new Error("fixture missing");
const body = Buffer.alloc(12_345, "A");
const content = body.toString("base64");
const digest = createHash("sha256").update(body).digest("hex");
await writeFile(
path,
JSON.stringify({
log: {
...fixture.log,
entries: [
{
...entry,
response: {
...entry.response,
content: {
size: 99,
mimeType: "text/plain",
encoding: "base64",
text: content,
},
},
},
],
},
}),
);
const { call } = await connectLocalToolsMcp();
const args = { capture_path: path, format: "har" };
const control = await call("inspect_web_network_capture", args);
expect(control.isError).not.toBe(true);
const retained = parseEvidence(control.structuredContent);
const controlCapture = webNetworkCaptureSchema.parse(
retained.normalized_result,
);
expect(controlCapture.records[0]?.binary_fields[0]).toMatchObject({
state: "retained",
bytes: body.length,
content_base64: content,
sha256: digest,
});
for (const literal of ["12345", "2345"]) {
// Only the derived count matches, so this cannot pass via another exclusion.
expect(content).not.toContain(literal);
expect(digest).not.toContain(literal);
const fromCli = await cli(path, "har", ["--sensitive-value", literal]);
expect(fromCli.ok, fromCli.stdout).toBe(true);
const evidence = parseEvidence(JSON.parse(fromCli.stdout));
const response = await call("inspect_web_network_capture", {
...args,
sensitive_values: [literal],
});
expect(response.isError).not.toBe(true);
const fromMcp = parseEvidence(response.structuredContent);
expect(fromMcp.normalized_result).toEqual(evidence.normalized_result);
const capture = webNetworkCaptureSchema.parse(evidence.normalized_result);
expect(capture.records[0]?.reported).toMatchObject({
response: { content: { size: 99, text: null } },
});
expect(capture.records[0]?.binary_fields[0]).toEqual({
pointer: "/response/content/text",
representation: "har-base64-content",
state: "redacted",
bytes: null,
content_base64: null,
sha256: null,
});
for (const detail of ["preview", "complete"]) {
const page = await call("inspect_analysis_view", {
source: { kind: "retained-evidence", evidence_id: fromMcp.evidence_id },
view: { kind: "capture", detail },
});
expect(page.isError).not.toBe(true);
const result = parseEvidence(page.structuredContent);
expect(result.normalized_result).toMatchObject({
items: [{ binary_fields: [{ state: "redacted", bytes: null }] }],
});
}
}
}, 60_000);
it("reads actual upstream HAR packages in an owned process and retains selected producer order", async () => {
const root = await mkdtemp(join(tmpdir(), "rea-history-har-"));
onTestFinished(() => rm(root, { recursive: true, force: true }));

View File

@ -21,6 +21,7 @@ import {
type ApplicationVersionComparisonResult,
} from "../../../src/domain/javascript/javascriptApplicationVersionComparisonSchemas.js";
import { javascriptRuntimeReconciliationResultSchema } from "../../../src/domain/javascript/javascriptRuntimeReconciliationSchemas.js";
import { electronPageInspectionSchema } from "../../../src/domain/javascript/electronObservation.js";
import { compareJavaScriptApplicationVersions } from "../../../src/domain/javascript/javascriptApplicationVersionComparison.js";
import { createJavaScriptApplicationGraph } from "../../../src/domain/javascript/javascriptApplicationGraph.js";
import { traceApplicationFeature } from "../../../src/domain/javascript/javascriptFeatureTrace.js";
@ -444,6 +445,73 @@ describe("JavaScript application workflow error diagnostics", () => {
});
describe("JavaScript runtime reconciliation local paths", () => {
it.each([
{
name: "case-sensitive POSIX input root",
root: null,
path: "/applications/Example.app/Contents/Resources/app/renderer.js",
status: "unknown",
},
{
name: "case-sensitive POSIX mapping root",
root: "/runtime/App",
path: "/runtime/app/renderer.js",
status: "unknown",
},
{
name: "matching POSIX mapping root",
root: "/runtime/App",
path: "/runtime/App/renderer.js",
status: "matched",
},
{
name: "case-insensitive Windows mapping root",
root: String.raw`C:\runtime\App`,
path: String.raw`c:\runtime\app\renderer.js`,
status: "matched",
},
])("uses $name on every host", ({ root, path, status }) => {
const base = JAVASCRIPT_RUNTIME_RECONCILIATION_EXAMPLE;
const original = base.runtime_observations[0]!;
const inspection = electronPageInspectionSchema.parse(
original.normalized_result,
);
const runtime = createEvidence(undefined, original.provider, {
predicateType: original.predicate_type,
operation: original.operation,
parameters: { ...original.parameters, include_script_sources: false },
result: {
...inspection,
scripts: {
...inspection.scripts,
items: inspection.scripts.items.map((script) => ({
...script,
file_path: path,
source: { included: false, reason: "not-requested" },
})),
},
},
confidence: original.confidence,
authority: original.authority,
});
const reconciled = reconcileJavaScriptRuntimeEvidence({
static_layers: base.static_layers.map((layer) => ({
...layer,
runtime_mappings: root === null ? [] : [{ kind: "file-root", root }],
})),
runtime_observations: [runtime],
});
if (!reconciled.ok) throw reconciled.error;
const result = javascriptRuntimeReconciliationResultSchema.parse(
reconciled.value.normalized_result,
);
expect(
result.reconciliations.find(
({ entity_kind }) => entity_kind === "script",
),
).toMatchObject({ status });
});
it("resolves relative file mapping roots before deriving result Evidence", () => {
const absoluteRoot = resolve(process.cwd(), "runtime-cache-fixture");
const relativeRoot = relative(process.cwd(), absoluteRoot);

View File

@ -6,6 +6,31 @@ import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { importReferenceSource } from "../../support/referenceSourceResourceScope.js";
it("imports valid CommonJS wrapper syntax without inventing a parse failure", async () => {
const root = await createTestTempDirectory("rea-reference-commonjs-mode-");
await Promise.all([
writeFile(
join(root, "main.cjs"),
'void new.target; require("./dep.cjs"); return;\n',
),
writeFile(join(root, "dep.cjs"), "module.exports = 1;\n"),
]);
const imported = await importReferenceSource({
root,
caller: "native-commonjs-source",
policy: { secretPatterns: [] },
});
if (!imported.ok) throw imported.error;
expect(imported.value.parse_failures).toEqual([]);
expect(imported.value.relationships).toContainEqual({
from_path: "main.cjs",
to: "dep.cjs",
kind: "requires",
resolution: "internal",
parse_state: "parsed",
});
});
it.each([
["main.ts", "dep.js", "dep.ts"],
["main.mts", "dep.mjs", "dep.mts"],

View File

@ -0,0 +1,155 @@
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it, onTestFinished, vi } from "vitest";
import { createEvidence } from "../../../src/domain/evidence.js";
import { createEvidenceBundle } from "../../../src/domain/evidenceBundle.js";
import { OwnedFileHandle } from "../../../src/filesystem/OwnedFileHandle.js";
import { createServer } from "../../../src/server/createServer.js";
import {
createDeferred,
createTestBinarySession,
} from "../../fixtures/binarySession.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const connectImportSession = async () => {
const root = await createTestTempDirectory("rea-mcp-import-lifecycle-");
const session = createTestBinarySession(() => {
throw new Error("Evidence import must not start a binary provider");
});
const server = createServer({ kind: "session", session });
const client = new Client({
name: "evidence-import-lifecycle",
version: "1",
});
const readCompleted = createDeferred<void>();
const releaseRead = createDeferred<void>();
const originalFileClose = OwnedFileHandle.prototype.close;
vi.spyOn(OwnedFileHandle.prototype, "close").mockImplementationOnce(
async function (this: OwnedFileHandle) {
await originalFileClose.call(this);
readCompleted.resolve();
await releaseRead.promise;
},
);
const closeStarted = createDeferred<void>();
const originalSessionClose = session.close.bind(session);
const events: string[] = [];
vi.spyOn(session, "close").mockImplementation((...args) => {
const closing = originalSessionClose(...args);
closeStarted.resolve();
return closing.then((result) => {
events.push("close");
return result;
});
});
const originalImport = session.importEvidenceBundle.bind(session);
vi.spyOn(session, "importEvidenceBundle").mockImplementation((bundle) => {
const imported = originalImport(bundle);
events.push("import");
return imported;
});
onTestFinished(async () => {
releaseRead.resolve();
vi.restoreAllMocks();
await client.close();
await server.close();
await session.close();
});
const [clientTransport, serverTransport] =
InMemoryTransport.createLinkedPair();
const laterImportDispatched = createDeferred<void>();
let importsDispatched = 0;
const send = clientTransport.send.bind(clientTransport);
vi.spyOn(clientTransport, "send").mockImplementation(
async (message, ...args) => {
await send(message, ...args);
if (
"method" in message &&
message.method === "tools/call" &&
message.params?.name === "import_evidence_bundle"
) {
importsDispatched += 1;
if (importsDispatched === 2) laterImportDispatched.resolve();
}
},
);
await server.connect(serverTransport);
await client.connect(clientTransport);
const importBundle = async (operation: string) => {
const record = createEvidence(
undefined,
{ id: "fixture", name: "Fixture", version: "1" },
{ operation, parameters: {}, result: { retained: operation } },
);
const path = join(root, `${operation}.json`);
await writeFile(path, JSON.stringify(createEvidenceBundle([record])));
return client.callTool({
name: "import_evidence_bundle",
arguments: { path },
});
};
return {
client,
session,
events,
importBundle,
readCompleted: readCompleted.promise,
closeStarted: closeStarted.promise,
laterImportDispatched: laterImportDispatched.promise,
releaseRead: () => releaseRead.resolve(),
};
};
it("drains a file import before close_binary clears the session", async () => {
const fixture = await connectImportSession();
const importing = fixture.importBundle("before_close");
await fixture.readCompleted;
const closing = fixture.client.callTool({
name: "close_binary",
arguments: {},
});
await fixture.closeStarted;
await fixture.client.ping();
fixture.releaseRead();
const [imported, closed] = await Promise.all([importing, closing]);
expect(imported.isError, JSON.stringify(imported)).not.toBe(true);
expect(imported.structuredContent).toMatchObject({
result: { imported: 1, unknowns_added: 0, total: 1 },
});
expect(closed.isError, JSON.stringify(closed)).not.toBe(true);
expect(fixture.events).toEqual(["import", "close"]);
expect(fixture.session.exportEvidenceBundle().records).toEqual([]);
await fixture.client.ping();
});
it("admits a later import after close_binary without retaining the earlier bundle", async () => {
const fixture = await connectImportSession();
const earlier = fixture.importBundle("before_close");
await fixture.readCompleted;
const closing = fixture.client.callTool({
name: "close_binary",
arguments: {},
});
await fixture.closeStarted;
const later = fixture.importBundle("after_close");
await fixture.laterImportDispatched;
await fixture.client.ping();
fixture.releaseRead();
const results = await Promise.all([earlier, closing, later]);
for (const result of results)
expect(result.isError, JSON.stringify(result)).not.toBe(true);
expect(results[2]?.structuredContent).toMatchObject({
result: { imported: 1, unknowns_added: 0, total: 1 },
});
expect(fixture.events).toEqual(["import", "close", "import"]);
expect(
fixture.session
.exportEvidenceBundle()
.records.map((record) => record.operation),
).toEqual(["after_close"]);
await fixture.client.ping();
});

View File

@ -0,0 +1,39 @@
import { expect } from "vitest";
import { parseJavaScriptSource } from "../../../src/domain/javascript/javascriptSourceParser.js";
import { processTest } from "../../support/process/processFixture.js";
for (const source of [
"return;",
"void new.target;",
"var await = 1; return;",
"with ({ value: 1 }) { void value; } void new.target;",
]) {
processTest(
`accepts Node CommonJS syntax while retaining module errors: ${source}`,
async ({ processes }) => {
const commonjs = await processes.run(
process.execPath,
["--check", "--input-type=commonjs"],
{ input: source, timeoutMs: 5_000 },
);
expect(commonjs.exitCode).toBe(0);
const module = await processes.run(
process.execPath,
["--check", "--input-type=module"],
{ input: source, timeoutMs: 5_000 },
);
expect(module.exitCode).not.toBe(0);
const parsed = parseJavaScriptSource(source, "main.cjs");
expect(parsed).not.toBeNull();
expect(parsed?.errors).toEqual([]);
expect(
parseJavaScriptSource(source, "main.mjs")?.errors.length,
).toBeGreaterThan(0);
expect(
parseJavaScriptSource(source, "main.mts")?.errors.length,
).toBeGreaterThan(0);
},
);
}

View File

@ -4,6 +4,7 @@
#include <winioctl.h>
#include <cstdio>
#include <cstring>
#include <cwchar>
#include <string>
#include <vector>
@ -33,6 +34,41 @@ int wmain(int count, wchar_t** args) {
value(L"REA_\u00df") == expectedSharp && value(L"REA_SS") == L"double" ? "true" : "false");
return 0;
}
if ((count == 2 || count == 3) && std::wstring(args[1]) == L"affinity") {
// Without a process id this observes itself, which is what an owned child
// reports. With one it observes that process, so a caller's own affinity is
// read from the process that spawns the child rather than from a separate
// probe Windows may place in another processor group.
HANDLE opened = nullptr;
if (count == 3) {
opened = OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, FALSE,
static_cast<DWORD>(std::wcstoul(args[2], nullptr, 10)));
if (!opened) return 9;
}
const HANDLE target = opened ? opened : GetCurrentProcess();
DWORD_PTR affinity = 0, systemAffinity = 0;
USHORT groupCount = 1;
USHORT groups[1]{};
const bool read = GetProcessAffinityMask(target, &affinity, &systemAffinity) != 0;
const bool single = read && GetProcessGroupAffinity(target, &groupCount, groups) && groupCount == 1;
if (opened) CloseHandle(opened);
if (!read) return 8;
// A mask can set bits above JavaScript's safe-integer range, so masks cross
// as fixed-width hexadecimal strings and are compared without rounding. The
// group's own active set distinguishes an unconstrained process from one
// deliberately pinned to fewer processors.
auto mask = [](DWORD_PTR value, char* text) {
std::snprintf(text, 19, "0x%08lX%08lX", static_cast<unsigned long>(value >> 32),
static_cast<unsigned long>(value & 0xFFFFFFFFu));
return text;
};
char affinityText[19]{}, systemText[19]{};
std::printf("{\"affinityMask\":\"%s\",\"systemMask\":\"%s\","
"\"singleProcessorGroup\":%s,\"processorGroup\":%u}\n",
mask(affinity, affinityText), mask(systemAffinity, systemText),
single ? "true" : "false", single ? static_cast<unsigned>(groups[0]) : 0u);
return 0;
}
if (count == 4 && std::wstring(args[1]) == L"reparse-directory") {
// Only the conformance lane's disposable, nonempty source directory is used.
const auto substitute = L"\\??\\" + std::wstring(args[3]);