Merge remote-tracking branch 'origin/main'
This commit is contained in:
commit
55e21a2eec
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({});
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -154,6 +154,7 @@ export const redactExplicitFailure = (
|
|||
...(error.userMessage === undefined
|
||||
? {}
|
||||
: { userMessage: text(error.userMessage) }),
|
||||
...(error.retryable ? { retryable: true } : {}),
|
||||
},
|
||||
);
|
||||
if (error instanceof AnalysisResourceConstraintError)
|
||||
|
|
|
|||
|
|
@ -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 === "" ||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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 }));
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -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]);
|
||||
|
|
|
|||
Loading…
Reference in New Issue