chore: sync current main into the CI baseline repair
Validate the formatting and reconstruction fixture repairs against the latest merged feature and correctness changes. Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
This commit is contained in:
commit
00915f484f
33 files changed
+1306
-474
No files matched your search
@@ -271,9 +271,11 @@ Evidence remain complete inline, and both input forms pass the same semantic,
|
||||
identity, authority, and provenance checks without running the producer again.
|
||||
|
||||
References belong to the current connection's Evidence ledger. Opening another
|
||||
target preserves retained records; `close_binary` clears them, even when no
|
||||
binary is active. A fresh connection has its own ledger. A missing reference
|
||||
reports its exact ID and `details.reason: "missing"`; the server cannot infer
|
||||
target or restarting the current target preserves retained records, including
|
||||
when provider cleanup fails; records remain available for export and retry.
|
||||
`close_binary` clears them, even when no binary is active. A fresh connection
|
||||
has its own ledger. A missing reference reports its exact ID and
|
||||
`details.reason: "missing"`; the server cannot infer
|
||||
whether it was never recorded, cleared, or retained by another connection.
|
||||
Supply complete inline Evidence, repeat its producer, or import an exported
|
||||
Evidence bundle before referencing that imported record. Export a bundle before
|
||||
|
||||
@@ -89,6 +89,30 @@ check(
|
||||
["src/domain/javascript/javascriptSemanticTraversal.ts"],
|
||||
);
|
||||
|
||||
// A producer that pins both the soft and hard ceiling of every resource must take
|
||||
// the vector from one owner. Audited 2026-10-13 against the other prlimit lanes:
|
||||
// src/pcap/PcapNativeBound.ts pairs soft and hard but budgets 300 CPU seconds and
|
||||
// has no resident-input ceiling; src/evm/EvmoleInterfaceProvider.ts,
|
||||
// src/firmware/FirmwareCommand.ts and src/javascript/recovery/WakaruCommand.ts
|
||||
// spell soft-only forms (`--core=0:`, `--core=0`). Those four stay as they are;
|
||||
// a new paired producer calls prlimitArgumentVector instead of spelling options.
|
||||
check(
|
||||
"inline paired prlimit ceilings (use prlimitArgumentVector from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/--core=0:0/,
|
||||
["src/process/LinuxResourceLimits.ts", "src/pcap/PcapNativeBound.ts"],
|
||||
);
|
||||
|
||||
// Census is spelling-shaped, like the rules above: a lane that expresses the same
|
||||
// ceilings differently (`1024 ** 3`, camelCase fields) is not caught here, which is
|
||||
// why the four lanes above are listed rather than left to a future reader.
|
||||
check(
|
||||
"inline resident-input budget (use BOUNDED_PRODUCER_LIMITS from src/process/LinuxResourceLimits)",
|
||||
files,
|
||||
/input_bytes:\s*32 \* 1024 \* 1024/,
|
||||
["src/process/LinuxResourceLimits.ts"],
|
||||
);
|
||||
|
||||
// Fixed-arity backtick keys audited 2026-10-10: every prefix field is NUL-free
|
||||
// (hex digests, file paths, enums, numbers, or compositeKey JSON which escapes
|
||||
// NUL), with free-form source strings only in final position. New backtick-\0
|
||||
|
||||
@@ -8,6 +8,7 @@ import { CordisExpression, DshCordisYaml } from "./DshCordisYaml.js";
|
||||
export const DSH_ENTRY_ID = "rea-mcp";
|
||||
export const DSH_CLIENT_PACKAGE = "@deepseek-ai/dsh-mcp-client";
|
||||
const LAUNCHER_KEYS = new Set(["serverName", "transport", "command", "args"]);
|
||||
const SELECTOR_KEYS = new Set(["id", "name"]);
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
@@ -295,10 +296,15 @@ export class DshCordisDocument {
|
||||
carried = true;
|
||||
return [this.#entry(desired, invalidated)] as typeof inserted.items;
|
||||
});
|
||||
replacements.set(
|
||||
op.source,
|
||||
inserted.items.length === 0 ? undefined : copy,
|
||||
);
|
||||
if (inserted.items.length === 0) copy.delete("insert");
|
||||
// An emptied operation keeps caller fields unless it would patch REA's row.
|
||||
const kept =
|
||||
inserted.items.length > 0 ||
|
||||
(this.#id(op.map) !== DSH_ENTRY_ID &&
|
||||
copy.items.some(
|
||||
(pair) => !SELECTOR_KEYS.has(this.#yaml.value(pair.key) as string),
|
||||
));
|
||||
replacements.set(op.source, kept ? copy : undefined);
|
||||
}
|
||||
const output = this.#yaml.document.clone();
|
||||
const outputRoot = output.contents;
|
||||
|
||||
@@ -283,7 +283,9 @@ export class BinarySession
|
||||
previous.route.identity.id,
|
||||
);
|
||||
if (!closed.ok) {
|
||||
this.clearSessionRecords();
|
||||
// A failed open retires the provider cache, not the investigation.
|
||||
// Preserve Evidence and Unknown history for export and recovery.
|
||||
this.clearSnapshot();
|
||||
this.#clearRuntimeAvailability();
|
||||
return closed;
|
||||
}
|
||||
|
||||
@@ -199,6 +199,49 @@ const testAnalysis = (
|
||||
});
|
||||
|
||||
describe("native UI action trace", () => {
|
||||
it("retains Hopper targetless call evidence alongside fallback callee routes", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
procedure_address: () => execution("procedure_address", "0x1000"),
|
||||
procedure_references: (parameters) =>
|
||||
ok(
|
||||
createAnalysisExecution(
|
||||
{
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls:
|
||||
parameters.procedure === "0x1000"
|
||||
? [{ address: "0x1008", reason: "No static target" }]
|
||||
: [],
|
||||
},
|
||||
{ id: "hopper", name: "Hopper", version: "fixture" },
|
||||
),
|
||||
),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "0x1000" });
|
||||
if (!result.ok) throw result.error;
|
||||
const trace = nativeInvestigationTraceSchema.parse(result.value);
|
||||
expect(trace.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "provider_call",
|
||||
to: "native:function:0x2000",
|
||||
}),
|
||||
);
|
||||
expect(trace.unresolved).toContainEqual(
|
||||
expect.objectContaining({
|
||||
from: "native:function:0x1000",
|
||||
to: null,
|
||||
reason: "No static target",
|
||||
evidence: [
|
||||
expect.objectContaining({
|
||||
description: "Hopper reported an unresolved call: No static target",
|
||||
location: { address: "0x1008", file_offset: null },
|
||||
artifact_sha256: target,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
it("accepts an explicit native seed without an authored UI connection", async () => {
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
|
||||
@@ -7,6 +7,59 @@ import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const provider = { id: "fixture", name: "Fixture", version: "1" };
|
||||
describe("untyped native call route fallback", () => {
|
||||
it.each([{ callees: [] }, { callees: ["0x2000"] }])(
|
||||
"preserves targetless calls and their provider when fallback callees are $callees",
|
||||
async ({ callees }) => {
|
||||
const calls: string[] = [];
|
||||
const referenceProvider = { ...provider, id: "hopper", name: "Hopper" };
|
||||
const fallbackProvider = { ...provider, id: "fallback" };
|
||||
const signal = new AbortController().signal;
|
||||
const unknown = { address: "0x1008", reason: "No static target" };
|
||||
const result = await readNativeCallRoutes(
|
||||
{
|
||||
execute: async (operation, parameters, options) => {
|
||||
calls.push(operation);
|
||||
expect(parameters.procedure).toBe("0x1000");
|
||||
expect(options).toEqual({ signal, selectorOrigin: "provider" });
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_references"
|
||||
? {
|
||||
reference_kinds_available: false,
|
||||
references: [],
|
||||
unresolved_calls: [unknown],
|
||||
}
|
||||
: callees,
|
||||
operation === "procedure_references"
|
||||
? referenceProvider
|
||||
: fallbackProvider,
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
"0x1000",
|
||||
signal,
|
||||
"provider",
|
||||
);
|
||||
expect(calls).toEqual(["procedure_references", "procedure_callees"]);
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
value: {
|
||||
provider: fallbackProvider,
|
||||
routes: callees.map((address) => ({
|
||||
address,
|
||||
site: "0x1000",
|
||||
relation: "provider_call",
|
||||
resolution: "inferred",
|
||||
})),
|
||||
unknowns: [{ ...unknown, provider: referenceProvider }],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("typed native call routes", () => {
|
||||
it("preserves computed targets, ambiguity and targetless call sites", async () => {
|
||||
const reference = jsonValueSchema.parse(ghidraReferenceEdge());
|
||||
|
||||
@@ -2,12 +2,12 @@ import { z } from "zod";
|
||||
import type {
|
||||
AnalysisOperationPort,
|
||||
ExecutionOptions,
|
||||
ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import {
|
||||
parseRelatedAddresses,
|
||||
referenceKindSchema,
|
||||
} from "../../domain/hopperValues.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisOutputError } from "../../domain/analysisErrorCore.js";
|
||||
|
||||
const referencesSchema = z.object({
|
||||
@@ -40,15 +40,29 @@ export const readNativeCallRoutes = async (
|
||||
{ procedure, direction: "outgoing" },
|
||||
options,
|
||||
);
|
||||
let unknowns: Array<{
|
||||
address: string;
|
||||
reason: string;
|
||||
provider: ProviderIdentity;
|
||||
}> = [];
|
||||
if (execution.ok) {
|
||||
const parsed = referencesSchema.safeParse(execution.value.result);
|
||||
if (!parsed.success)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
return {
|
||||
ok: false as const,
|
||||
error: new AnalysisOutputError(
|
||||
"procedure_references",
|
||||
"Invalid typed call references",
|
||||
),
|
||||
);
|
||||
unknowns,
|
||||
};
|
||||
// Missing detailed reference flags do not invalidate observed targetless
|
||||
// calls. Keep their producing provider even if another provider supplies
|
||||
// the fallback callee inventory.
|
||||
unknowns = parsed.data.unresolved_calls.map((call) => ({
|
||||
...call,
|
||||
provider: execution.value.provider,
|
||||
}));
|
||||
const references = parsed.data.references;
|
||||
if (
|
||||
parsed.data.reference_kinds_available === true ||
|
||||
@@ -64,45 +78,52 @@ export const readNativeCallRoutes = async (
|
||||
targets.add(call.target_address);
|
||||
countBySite.set(call.source_address, targets);
|
||||
}
|
||||
return ok({
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns: parsed.data.unresolved_calls,
|
||||
});
|
||||
return {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: execution.value.provider,
|
||||
routes: calls.map((call) => ({
|
||||
address: call.target_procedure?.address ?? call.target_address,
|
||||
site: call.source_address,
|
||||
relation:
|
||||
call.kind.available && (call.kind.computed || call.kind.indirect)
|
||||
? ("indirect_call" as const)
|
||||
: ("direct_call" as const),
|
||||
resolution:
|
||||
(countBySite.get(call.source_address)?.size ?? 0) > 1
|
||||
? ("ambiguous" as const)
|
||||
: call.kind.available &&
|
||||
(call.kind.computed || call.kind.indirect)
|
||||
? ("resolved" as const)
|
||||
: ("observed" as const),
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
};
|
||||
}
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
return err(execution.error);
|
||||
return { ok: false as const, error: execution.error, unknowns };
|
||||
const callees = await analysis.execute(
|
||||
"procedure_callees",
|
||||
{ procedure },
|
||||
options,
|
||||
);
|
||||
if (!callees.ok) return callees;
|
||||
if (!callees.ok)
|
||||
return { ok: false as const, error: callees.error, unknowns };
|
||||
const parsed = parseRelatedAddresses(callees.value.result, "callees");
|
||||
return parsed.ok
|
||||
? ok({
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns: [],
|
||||
})
|
||||
: parsed;
|
||||
? {
|
||||
ok: true as const,
|
||||
value: {
|
||||
provider: callees.value.provider,
|
||||
routes: parsed.value.map((address) => ({
|
||||
address,
|
||||
site: procedure,
|
||||
relation: "provider_call" as const,
|
||||
resolution: "inferred" as const,
|
||||
})),
|
||||
unknowns,
|
||||
},
|
||||
}
|
||||
: { ok: false as const, error: parsed.error, unknowns };
|
||||
};
|
||||
@@ -414,9 +414,44 @@ const addDirectCallees = async (input: {
|
||||
"provider",
|
||||
);
|
||||
examined += 1;
|
||||
if (!execution.ok && execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
const unknowns = execution.ok
|
||||
? execution.value.unknowns
|
||||
: execution.unknowns;
|
||||
for (const [index, unknown] of unknowns.entries()) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += unknowns.length - index;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
provider: unknown.provider,
|
||||
description: `${unknown.provider.name} reported an unresolved call: ${unknown.reason}`,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
if (!execution.ok) {
|
||||
if (execution.error._tag === "AnalysisCancelledError")
|
||||
return { error: execution.error };
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted += 1;
|
||||
continue;
|
||||
}
|
||||
const id = `unknown-callees:${current.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
@@ -432,32 +467,6 @@ const addDirectCallees = async (input: {
|
||||
});
|
||||
continue;
|
||||
}
|
||||
for (const unknown of execution.value.unknowns) {
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
truncated = true;
|
||||
omitted++;
|
||||
break;
|
||||
}
|
||||
const id = `unknown-dispatch:${current.address}:${unknown.address}`;
|
||||
edges.set(id, {
|
||||
id,
|
||||
from: current.node.id,
|
||||
to: null,
|
||||
relation: "indirect_call",
|
||||
resolution: "unresolved",
|
||||
reason: unknown.reason,
|
||||
evidence: [
|
||||
{
|
||||
kind: "reference",
|
||||
description: unknown.reason,
|
||||
location: { address: unknown.address, file_offset: null },
|
||||
artifact_path: null,
|
||||
artifact_sha256: input.targetSha256,
|
||||
},
|
||||
],
|
||||
limitations: [],
|
||||
});
|
||||
}
|
||||
for (const [index, route] of execution.value.routes.entries()) {
|
||||
const address = route.address;
|
||||
if (edges.size >= input.input.max_edges) {
|
||||
@@ -469,6 +478,7 @@ const addDirectCallees = async (input: {
|
||||
const evidence = [
|
||||
{
|
||||
kind: "reference" as const,
|
||||
provider: execution.value.provider,
|
||||
description: `${execution.value.provider.name} reported ${route.relation} from ${current.address} to ${address}`,
|
||||
location: { address: route.site, file_offset: null },
|
||||
artifact_path: null,
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { orderAsarListing } from "./AsarArtifactReader.js";
|
||||
|
||||
describe("orderAsarListing", () => {
|
||||
it("orders host-spelled listings by their portable archive path", () => {
|
||||
const posix = orderAsarListing(["/a0", "/a", "/a/b"], "/");
|
||||
const windows = orderAsarListing(["\\a0", "\\a", "\\a\\b"], "\\");
|
||||
|
||||
// "/" (0x2F) precedes "0" (0x30); "\" (0x5C) would follow it.
|
||||
expect(posix.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ path }) => path)).toEqual(["a", "a/b", "a0"]);
|
||||
expect(windows.map(({ listed }) => listed)).toEqual([
|
||||
"\\a",
|
||||
"\\a\\b",
|
||||
"\\a0",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps POSIX backslashes as literal filename characters", () => {
|
||||
expect(orderAsarListing(["/a\\b"], "/")).toEqual([
|
||||
{ listed: "/a\\b", path: "a\\b" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -13,7 +13,7 @@ import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { listPackage, statFile, uncache } from "@electron/asar";
|
||||
import { getRawHeader, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
@@ -26,7 +26,11 @@ import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
import {
|
||||
readAsarListing,
|
||||
type AsarListedEntry,
|
||||
type AsarMetadata,
|
||||
} from "./AsarListing.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
@@ -51,7 +55,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
) {}
|
||||
|
||||
async *entries(signal?: AbortSignal): AsyncIterable<ArtifactEntry> {
|
||||
let paths: readonly AsarListedPath[];
|
||||
let paths: readonly AsarListedEntry[];
|
||||
try {
|
||||
await this.prepareContainer(undefined, signal);
|
||||
paths = await this.#withSnapshotOperation(async () => {
|
||||
@@ -62,8 +66,9 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
this.#archiveSize = admitted.archiveBytes;
|
||||
this.#headerSize = admitted.headerBytes;
|
||||
abortIfNeeded(signal);
|
||||
const ordered = orderAsarListing(
|
||||
listPackage(snapshotPath, { isPack: false }),
|
||||
const ordered = await readAsarListing(
|
||||
getRawHeader(snapshotPath).header,
|
||||
signal,
|
||||
);
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
@@ -77,34 +82,25 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, "inventory", cause);
|
||||
}
|
||||
for (const { listed, path } of paths) {
|
||||
for (const { metadata, path } of paths) {
|
||||
abortIfNeeded(signal);
|
||||
if (path.length === 0) continue;
|
||||
const providerPath = listed.startsWith(sep)
|
||||
? listed.slice(sep.length)
|
||||
: listed;
|
||||
let metadata: ReturnType<typeof statFile>;
|
||||
try {
|
||||
metadata = statFile(this.#snapshotPath(), providerPath, false);
|
||||
} catch (cause: unknown) {
|
||||
throw asarFailure(this.path, `stat ${path}`, cause);
|
||||
}
|
||||
const providerPath = path.split("/").join(sep);
|
||||
const kind =
|
||||
"files" in metadata
|
||||
metadata.files !== undefined
|
||||
? "directory"
|
||||
: "link" in metadata
|
||||
: metadata.link !== undefined
|
||||
? "symlink"
|
||||
: "file";
|
||||
const entry: ArtifactEntry = {
|
||||
path,
|
||||
kind,
|
||||
declaredSize: "size" in metadata ? metadata.size : null,
|
||||
declaredSize: metadata.size ?? null,
|
||||
compressedSize: null,
|
||||
executable: "executable" in metadata && metadata.executable,
|
||||
executable: metadata.executable === true,
|
||||
encrypted: false,
|
||||
byteOffset: null,
|
||||
declaredSha256:
|
||||
"integrity" in metadata &&
|
||||
metadata.integrity !== undefined &&
|
||||
metadata.integrity.algorithm === "SHA256" &&
|
||||
/^[a-f0-9]{64}$/u.test(metadata.integrity.hash)
|
||||
? metadata.integrity.hash
|
||||
@@ -603,7 +599,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
type AsarFileMetadata = Extract<ReturnType<typeof statFile>, { size: number }>;
|
||||
type AsarFileMetadata = AsarMetadata & { readonly size: number };
|
||||
type AsarEntryState = {
|
||||
readonly metadata: AsarFileMetadata;
|
||||
readonly entry: ArtifactEntry;
|
||||
@@ -652,12 +648,11 @@ const asarUnpackedEntryFailure = (
|
||||
};
|
||||
|
||||
const isAsarFileMetadata = (
|
||||
metadata: ReturnType<typeof statFile>,
|
||||
metadata: AsarMetadata,
|
||||
): metadata is AsarFileMetadata =>
|
||||
"size" in metadata &&
|
||||
typeof metadata.size === "number" &&
|
||||
!("files" in metadata) &&
|
||||
!("link" in metadata);
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined;
|
||||
|
||||
const parseArchiveOffset = (value: string): number | undefined => {
|
||||
if (!/^(?:0|[1-9][0-9]*)$/u.test(value)) return undefined;
|
||||
@@ -699,33 +694,6 @@ const waitForOperation = async (
|
||||
});
|
||||
};
|
||||
|
||||
interface AsarListedPath {
|
||||
readonly listed: string;
|
||||
readonly path: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pair each host-spelled listing with its portable archive path, ordered by
|
||||
* the portable path so Windows and POSIX hosts agree.
|
||||
*
|
||||
* @electron/asar returns paths assembled with the host path module. Keep that
|
||||
* spelling for its API calls, but expose archive paths with portable
|
||||
* separators. On POSIX, backslashes can be literal filename characters and
|
||||
* must remain untouched.
|
||||
*/
|
||||
export const orderAsarListing = (
|
||||
listed: readonly string[],
|
||||
separator: string = sep,
|
||||
): AsarListedPath[] =>
|
||||
listed
|
||||
.map((entry) => ({ listed: entry, path: toArtifactPath(entry, separator) }))
|
||||
.sort((left, right) => compareUnicodeCodePoints(left.path, right.path));
|
||||
|
||||
const toArtifactPath = (listed: string, separator: string): string => {
|
||||
const portable = separator === "\\" ? listed.replaceAll("\\", "/") : listed;
|
||||
return portable.replace(/^\/+|\/+$/gu, "");
|
||||
};
|
||||
|
||||
const asarFailure = (
|
||||
path: string,
|
||||
operation: string,
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import { compareUnicodeCodePoints } from "../domain/unicodeCodePointOrder.js";
|
||||
|
||||
/** Capacity for expanded paths and listing records, independent of packed bytes. */
|
||||
const MAX_LISTING_BYTES = 64 * 1024 * 1024;
|
||||
const nodeSchema = z.looseObject({
|
||||
files: z
|
||||
.custom<Record<string, unknown>>(
|
||||
(value) =>
|
||||
value !== null && typeof value === "object" && !Array.isArray(value),
|
||||
)
|
||||
.optional(),
|
||||
link: z.string().optional(),
|
||||
size: z.number().nonnegative().safe().optional(),
|
||||
offset: z.string().optional(),
|
||||
unpacked: z.boolean().optional(),
|
||||
executable: z.boolean().optional(),
|
||||
integrity: z
|
||||
.looseObject({ algorithm: z.string(), hash: z.string() })
|
||||
.optional(),
|
||||
});
|
||||
export type AsarMetadata = z.infer<typeof nodeSchema>;
|
||||
export interface AsarListedEntry {
|
||||
readonly path: string;
|
||||
readonly metadata: AsarMetadata;
|
||||
}
|
||||
|
||||
function* ownChildren(files: Record<string, unknown>) {
|
||||
for (const name in files)
|
||||
if (Object.hasOwn(files, name)) yield [name, files[name]] as const;
|
||||
}
|
||||
|
||||
/** Decode each node once; admit expanded paths before concatenation and retain no recursive lookup. */
|
||||
export const readAsarListing = async (
|
||||
header: unknown,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AsarListedEntry[]> => {
|
||||
const root = nodeSchema.parse(header);
|
||||
if (root.files === undefined)
|
||||
throw new ArtifactReaderFailure("format", "ASAR root has no files table");
|
||||
const entries: AsarListedEntry[] = [];
|
||||
const pending = [{ parent: "", children: ownChildren(root.files) }];
|
||||
let retainedBytes = 0;
|
||||
let examined = 0;
|
||||
while (pending.length > 0) {
|
||||
if (examined++ % 128 === 0) {
|
||||
await setImmediate();
|
||||
if (signal?.aborted)
|
||||
throw new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"ASAR operation cancelled",
|
||||
);
|
||||
}
|
||||
const frame = pending.at(-1);
|
||||
if (frame === undefined) break;
|
||||
const next = frame.children.next();
|
||||
if (next.done) {
|
||||
pending.pop();
|
||||
continue;
|
||||
}
|
||||
const [name, raw] = next.value;
|
||||
// A header member is one component, not a host path to normalize. POSIX
|
||||
// backslashes remain literal; Windows cannot represent them as components.
|
||||
if (
|
||||
name.length === 0 ||
|
||||
name === "." ||
|
||||
name === ".." ||
|
||||
name.includes("/") ||
|
||||
name.includes("\0") ||
|
||||
(process.platform === "win32" && name.includes("\\"))
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
"ASAR header has an unsafe path component",
|
||||
);
|
||||
const units =
|
||||
frame.parent.length + name.length + (frame.parent.length === 0 ? 0 : 1);
|
||||
// UTF-16 storage, plus records and traversal bookkeeping. Charge every
|
||||
// occurrence even when the engine can share a string prefix.
|
||||
retainedBytes += units * 2 + 256;
|
||||
if (retainedBytes > MAX_LISTING_BYTES)
|
||||
throw new ArtifactReaderFailure(
|
||||
"limit",
|
||||
"ASAR expanded listing exceeds its 64 MiB representation budget",
|
||||
);
|
||||
const path = frame.parent.length === 0 ? name : `${frame.parent}/${name}`;
|
||||
const metadata = nodeSchema.parse(raw);
|
||||
if (
|
||||
metadata.files === undefined &&
|
||||
metadata.link === undefined &&
|
||||
metadata.size === undefined
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR entry has no directory, link or file metadata",
|
||||
);
|
||||
entries.push({ path, metadata });
|
||||
if (metadata.files !== undefined)
|
||||
pending.push({
|
||||
parent: path,
|
||||
children: ownChildren(metadata.files),
|
||||
});
|
||||
}
|
||||
return entries.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.path, right.path),
|
||||
);
|
||||
};
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
FIELD_RECORD_BUDGET,
|
||||
} from "./parseFieldDiscovery.js";
|
||||
import {
|
||||
BOUNDED_PRODUCER_LIMITS as LIMITS,
|
||||
prlimitArgumentVector,
|
||||
readLinuxResourceLimits,
|
||||
type LinuxResourceLimits,
|
||||
} from "../process/LinuxResourceLimits.js";
|
||||
@@ -60,15 +62,6 @@ export const TSHARK_PROVIDER_IDENTITY = {
|
||||
name: "REA TShark capture adapter",
|
||||
version: "tshark@4",
|
||||
} as const;
|
||||
// Bound the resident input copy and retained text before allocation/collection.
|
||||
const LIMITS = {
|
||||
input_bytes: 32 * 1024 * 1024,
|
||||
output_bytes: 4 * 1024 * 1024,
|
||||
command_timeout_ms: 30_000,
|
||||
address_space_bytes: 1024 * 1024 * 1024,
|
||||
file_bytes: 64 * 1024 * 1024,
|
||||
cpu_seconds: 30,
|
||||
} as const;
|
||||
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
|
||||
|
||||
/** Inspect selected capture files only, retaining cleanup ownership across failures. */
|
||||
@@ -165,14 +158,7 @@ export class TsharkCaptureProvider implements PacketCapturePort {
|
||||
options?.signal,
|
||||
);
|
||||
effectiveLimits = await this.#resources.readLimits(limits);
|
||||
const limitArguments = [
|
||||
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
|
||||
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
|
||||
`--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`,
|
||||
"--core=0:0",
|
||||
"--",
|
||||
command,
|
||||
];
|
||||
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined);
|
||||
|
||||
@@ -225,7 +225,7 @@ const registerInspectionCommand = (
|
||||
description:
|
||||
"Inspect one artifact and return all available observations and next probes",
|
||||
args: z.object({
|
||||
path: z.string().describe("Application or package path"),
|
||||
path: z.string().min(1).describe("Application or package path"),
|
||||
}),
|
||||
options: z.object({
|
||||
integrityPolicy: z
|
||||
|
||||
@@ -48,7 +48,10 @@ const registerOverviewCommands = (
|
||||
cli.command(CLI_COMMANDS.analyze, {
|
||||
description: "Get an overview of an app",
|
||||
args: z.object({
|
||||
path: z.string().describe("App, program, or analysis database path"),
|
||||
path: z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe("App, program, or analysis database path"),
|
||||
}),
|
||||
options: overviewOptions.extend({
|
||||
integrityPolicy: javascriptApplicationOptions.shape.integrityPolicy,
|
||||
|
||||
@@ -429,7 +429,12 @@ export const addSourceMapDirectives = (
|
||||
end === null
|
||||
)
|
||||
continue;
|
||||
const commentSource = source.slice(start, end);
|
||||
// The parser owns comment boundaries. Exclude only the closing block
|
||||
// delimiter; an asterisk elsewhere remains part of the declared URL.
|
||||
const commentSource = source.slice(
|
||||
start,
|
||||
comment.type === "CommentBlock" ? end - 2 : end,
|
||||
);
|
||||
if (
|
||||
!(commentSource.startsWith("//") || commentSource.startsWith("/*")) ||
|
||||
!(commentSource[2] === "#" || commentSource[2] === "@")
|
||||
@@ -449,9 +454,7 @@ export const addSourceMapDirectives = (
|
||||
const urlOffset = commentSource.slice(equalsStart + 1).search(/\S/u);
|
||||
if (urlOffset === -1) continue;
|
||||
const declaredStart = equalsStart + 1 + urlOffset;
|
||||
const terminator = commentSource
|
||||
.slice(declaredStart)
|
||||
.search(commentSource.startsWith("//") ? /\s/u : /[\s*]/u);
|
||||
const terminator = commentSource.slice(declaredStart).search(/\s/u);
|
||||
const declaredEnd =
|
||||
terminator === -1 ? commentSource.length : declaredStart + terminator;
|
||||
if (declaredEnd === declaredStart) continue;
|
||||
|
||||
@@ -72,10 +72,10 @@ describe("source map directives", () => {
|
||||
it("preserves Unicode whitespace and URL terminator boundaries", () => {
|
||||
const source =
|
||||
"ğ/*@\u00a0sourceMappingURL\u2003=\ufeffapp.js.map* ignored */";
|
||||
const declaredEnd = source.indexOf("* ignored");
|
||||
const declaredEnd = source.indexOf(" ignored");
|
||||
expect(analyzeJavaScriptStaticSource(source).source_map_urls).toEqual([
|
||||
{
|
||||
declared_url: "app.js.map",
|
||||
declared_url: "app.js.map*",
|
||||
location: {
|
||||
start: { line: 1, column: 1 },
|
||||
end: { line: 1, column: declaredEnd },
|
||||
@@ -84,6 +84,29 @@ describe("source map directives", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["/*#", "app*part.js.map", " */"],
|
||||
["/*@", "*part.js.map", "*/"],
|
||||
["/*#", "app.js.map*", "*/"],
|
||||
["/*#", "**part.js.map", " */"],
|
||||
["/*#", "app.js.map", "*/"],
|
||||
["//#", "app*part.js.map", ""],
|
||||
])("preserves the URL and range in %s %s%s", (marker, url, suffix) => {
|
||||
const directive = marker + " sourceMappingURL=" + url;
|
||||
const result = analyzeJavaScriptStaticSource(
|
||||
"export const value = 1;\n" + directive + suffix,
|
||||
);
|
||||
expect(result.source_map_urls).toEqual([
|
||||
{
|
||||
declared_url: url,
|
||||
location: {
|
||||
start: { line: 2, column: 0 },
|
||||
end: { line: 2, column: directive.length },
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports nothing when no directive is present", () => {
|
||||
expect(sourceMapUrls("const value = 1;")).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { z } from "zod";
|
||||
import { providerSchema } from "../evidence.js";
|
||||
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
@@ -115,6 +116,7 @@ export const nativeMetadataEvidenceSchema = z.strictObject({
|
||||
"decompiler",
|
||||
]),
|
||||
description: z.string().min(1),
|
||||
provider: providerSchema.optional(),
|
||||
location: nativeMetadataLocationSchema,
|
||||
artifact_path: z.string().min(1).nullable().default(null),
|
||||
artifact_sha256: z
|
||||
|
||||
@@ -143,3 +143,13 @@ export const completeReconstructionCoverageData = () => {
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
/** Complete workspace with its first verifier result and boundary narrowed once. */
|
||||
export const completeReconstructionCoverageFixture = () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
const boundary = workspace.boundaries[0];
|
||||
if (result === undefined || boundary === undefined)
|
||||
throw new Error("Expected complete reconstruction coverage fixture");
|
||||
return { workspace, result, boundary };
|
||||
};
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
completeReconstructionCoverageData,
|
||||
completeReconstructionCoverageFixture,
|
||||
RECONSTRUCTION_COVERAGE_NOW,
|
||||
reconstructionCoverageEvidenceId,
|
||||
} from "./reconstructionCoverage.fixture.js";
|
||||
@@ -288,11 +289,8 @@ describe("reconstruction coverage verifier selection", () => {
|
||||
|
||||
describe("reconstruction coverage conflicting latest verifier observations", () => {
|
||||
it("keeps conflicting tied assessments unknown regardless of input order", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
const boundary = workspace.boundaries[0];
|
||||
if (result === undefined || boundary === undefined)
|
||||
throw new Error("Expected complete reconstruction coverage fixture");
|
||||
const { workspace, result, boundary } =
|
||||
completeReconstructionCoverageFixture();
|
||||
const failed = {
|
||||
...result,
|
||||
status: "fail" as const,
|
||||
@@ -338,11 +336,8 @@ describe("reconstruction coverage conflicting latest verifier observations", ()
|
||||
|
||||
describe("reconstruction coverage equivalent latest verifier observations", () => {
|
||||
it("coalesces equivalent equal-instant observations and retains all evidence", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
const boundary = workspace.boundaries[0];
|
||||
if (result === undefined || boundary === undefined)
|
||||
throw new Error("Expected complete reconstruction coverage fixture");
|
||||
const { workspace, result, boundary } =
|
||||
completeReconstructionCoverageFixture();
|
||||
const first = {
|
||||
...result,
|
||||
observed_at: "2026-07-16T11:00:00.123456789012345678900Z",
|
||||
@@ -388,11 +383,8 @@ describe("reconstruction coverage equivalent latest verifier observations", () =
|
||||
|
||||
describe("reconstruction coverage verifier timestamp selection", () => {
|
||||
it("lets a later unambiguous observation supersede an older tie", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
const boundary = workspace.boundaries[0];
|
||||
if (result === undefined || boundary === undefined)
|
||||
throw new Error("Expected complete reconstruction coverage fixture");
|
||||
const { workspace, result, boundary } =
|
||||
completeReconstructionCoverageFixture();
|
||||
const failed = { ...result, status: "fail" as const };
|
||||
const latestPass = {
|
||||
...result,
|
||||
@@ -412,50 +404,14 @@ describe("reconstruction coverage verifier timestamp selection", () => {
|
||||
).toMatchObject({ status: "ready", reasons: [] });
|
||||
});
|
||||
|
||||
it("orders offset-bearing verifier timestamps chronologically", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
if (result === undefined)
|
||||
throw new Error("Expected fixture verifier result");
|
||||
const semantic = workspace;
|
||||
const changed = createReconstructionCoverageData({
|
||||
...semantic,
|
||||
verifier_results: [
|
||||
{ ...result, observed_at: "2026-07-16T08:00:00.000Z" },
|
||||
{
|
||||
...result,
|
||||
observed_at: "2026-07-16T04:00:00.000-05:00",
|
||||
status: "fail",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateReconstructionClosure(
|
||||
changed,
|
||||
"replacement.cli",
|
||||
RECONSTRUCTION_COVERAGE_NOW,
|
||||
),
|
||||
).toMatchObject({
|
||||
status: "failed",
|
||||
reasons: [expect.objectContaining({ code: "verifier-failed" })],
|
||||
});
|
||||
});
|
||||
|
||||
it("selects the later result when timestamps differ below millisecond precision", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
if (result === undefined)
|
||||
throw new Error("Expected fixture verifier result");
|
||||
/** Evaluate a workspace whose newest verifier observation fails the closure. */
|
||||
const expectNewestObservationFails = (passedAt: string, failedAt: string) => {
|
||||
const { workspace, result } = completeReconstructionCoverageFixture();
|
||||
const changed = createReconstructionCoverageData({
|
||||
...workspace,
|
||||
verifier_results: [
|
||||
{ ...result, observed_at: "2026-07-16T11:00:00.1231Z" },
|
||||
{
|
||||
...result,
|
||||
observed_at: "2026-07-16T11:00:00.1239Z",
|
||||
status: "fail",
|
||||
},
|
||||
{ ...result, observed_at: passedAt },
|
||||
{ ...result, observed_at: failedAt, status: "fail" },
|
||||
],
|
||||
});
|
||||
|
||||
@@ -469,15 +425,26 @@ describe("reconstruction coverage verifier timestamp selection", () => {
|
||||
status: "failed",
|
||||
reasons: [expect.objectContaining({ code: "verifier-failed" })],
|
||||
});
|
||||
};
|
||||
|
||||
it("orders offset-bearing verifier timestamps chronologically", () => {
|
||||
expectNewestObservationFails(
|
||||
"2026-07-16T08:00:00.000Z",
|
||||
"2026-07-16T04:00:00.000-05:00",
|
||||
);
|
||||
});
|
||||
|
||||
it("selects the later result when timestamps differ below millisecond precision", () => {
|
||||
expectNewestObservationFails(
|
||||
"2026-07-16T11:00:00.1231Z",
|
||||
"2026-07-16T11:00:00.1239Z",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reconstruction coverage verifier future timestamps", () => {
|
||||
it("treats an observation beyond millisecond-precision now as future", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
if (result === undefined)
|
||||
throw new Error("Expected fixture verifier result");
|
||||
const { workspace, result } = completeReconstructionCoverageFixture();
|
||||
const changed = createReconstructionCoverageData({
|
||||
...workspace,
|
||||
verifier_results: [
|
||||
@@ -505,10 +472,7 @@ describe("reconstruction coverage verifier future timestamps", () => {
|
||||
});
|
||||
|
||||
it("rejects verifier observations from the future", () => {
|
||||
const workspace = completeReconstructionCoverageData();
|
||||
const result = workspace.verifier_results[0];
|
||||
if (result === undefined)
|
||||
throw new Error("Expected fixture verifier result");
|
||||
const { workspace, result } = completeReconstructionCoverageFixture();
|
||||
const semantic = workspace;
|
||||
const changed = createReconstructionCoverageData({
|
||||
...semantic,
|
||||
|
||||
@@ -35,6 +35,28 @@ const validPng = Buffer.from(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jv4sAAAAASUVORK5CYII=",
|
||||
"base64",
|
||||
);
|
||||
|
||||
/** Screenshot payload as the native helper reports one PNG buffer. */
|
||||
const screenshotOf = (bytes: Buffer) => ({
|
||||
mime_type: "image/png",
|
||||
base64: bytes.toString("base64"),
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
width: bytes.readUInt32BE(16),
|
||||
height: bytes.readUInt32BE(20),
|
||||
});
|
||||
|
||||
/** Observe the fixture snapshot carrying one screenshot payload. */
|
||||
const observeWithScreenshot = (screenshot: Record<string, unknown>) =>
|
||||
observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, screenshot: true },
|
||||
{
|
||||
environment: {},
|
||||
invoke: async () => ({ ok: true, result: { ...snapshot, screenshot } }),
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ offset: 8, value: 12 },
|
||||
{ offset: 16, value: 0x8000_0000 },
|
||||
@@ -44,49 +66,16 @@ it.each([
|
||||
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 },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = await observeWithScreenshot(screenshotOf(bytes));
|
||||
expect(result.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
describe("native UI screenshot validation", () => {
|
||||
it("rejects malformed or mismatched screenshot bytes from the helper", async () => {
|
||||
const screenshot = {
|
||||
mime_type: "image/png",
|
||||
base64: validPng.toString("base64"),
|
||||
const result = await observeWithScreenshot({
|
||||
...screenshotOf(validPng),
|
||||
sha256: "0".repeat(64),
|
||||
width: 1,
|
||||
height: 1,
|
||||
};
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, screenshot: true },
|
||||
{
|
||||
environment: {},
|
||||
invoke: async () => ({
|
||||
ok: true,
|
||||
result: { ...snapshot, screenshot },
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
@@ -97,26 +86,10 @@ describe("native UI screenshot validation", () => {
|
||||
])(
|
||||
"rejects invalid screenshot field variants from the helper",
|
||||
async (change) => {
|
||||
const screenshot = {
|
||||
mime_type: "image/png",
|
||||
base64: validPng.toString("base64"),
|
||||
sha256: createHash("sha256").update(validPng).digest("hex"),
|
||||
width: 1,
|
||||
height: 1,
|
||||
const result = await observeWithScreenshot({
|
||||
...screenshotOf(validPng),
|
||||
...change,
|
||||
};
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, screenshot: true },
|
||||
{
|
||||
environment: {},
|
||||
invoke: async () => ({
|
||||
ok: true,
|
||||
result: { ...snapshot, screenshot },
|
||||
}),
|
||||
},
|
||||
);
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
},
|
||||
);
|
||||
@@ -126,49 +99,12 @@ describe("native UI screenshot validation", () => {
|
||||
const type = bytes.subarray(12, 16);
|
||||
type[0] = (type[0] ?? 0) | 0x80;
|
||||
expect(type.toString("ascii")).toBe("IHDR");
|
||||
const screenshot = {
|
||||
mime_type: "image/png",
|
||||
base64: bytes.toString("base64"),
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
width: 1,
|
||||
height: 1,
|
||||
};
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, screenshot: true },
|
||||
{
|
||||
environment: {},
|
||||
invoke: async () => ({
|
||||
ok: true,
|
||||
result: { ...snapshot, screenshot },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = await observeWithScreenshot(screenshotOf(bytes));
|
||||
expect(result.ok).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts a self-consistent PNG screenshot from the helper", async () => {
|
||||
const bytes = validPng;
|
||||
const screenshot = {
|
||||
mime_type: "image/png",
|
||||
base64: bytes.toString("base64"),
|
||||
sha256: createHash("sha256").update(bytes).digest("hex"),
|
||||
width: 1,
|
||||
height: 1,
|
||||
};
|
||||
const result = await observeNativeUi(
|
||||
target,
|
||||
"observe_native_ui",
|
||||
{ ...scope, screenshot: true },
|
||||
{
|
||||
environment: {},
|
||||
invoke: async () => ({
|
||||
ok: true,
|
||||
result: { ...snapshot, screenshot },
|
||||
}),
|
||||
},
|
||||
);
|
||||
const result = await observeWithScreenshot(screenshotOf(validPng));
|
||||
expect(result.ok).toBe(true);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { clampLinuxResourceLimits } from "./LinuxResourceLimits.js";
|
||||
import {
|
||||
BOUNDED_PRODUCER_LIMITS,
|
||||
clampLinuxResourceLimits,
|
||||
prlimitArgumentVector,
|
||||
} from "./LinuxResourceLimits.js";
|
||||
const configured = {
|
||||
address_space_bytes: 1073741824,
|
||||
file_bytes: 67108864,
|
||||
@@ -41,3 +45,38 @@ it("refuses missing, ambiguous or malformed kernel limits before launch", () =>
|
||||
])
|
||||
expect(() => clampLinuxResourceLimits(configured, value)).toThrow();
|
||||
});
|
||||
it("pins the shared producer budget in one place", () => {
|
||||
expect(BOUNDED_PRODUCER_LIMITS).toEqual({
|
||||
input_bytes: 33554432,
|
||||
output_bytes: 4194304,
|
||||
command_timeout_ms: 30000,
|
||||
address_space_bytes: 1073741824,
|
||||
file_bytes: 67108864,
|
||||
cpu_seconds: 30,
|
||||
});
|
||||
});
|
||||
it("pairs each effective ceiling with itself before the producer argv", () => {
|
||||
expect(
|
||||
prlimitArgumentVector(BOUNDED_PRODUCER_LIMITS, "/bin/producer"),
|
||||
).toEqual([
|
||||
"--as=1073741824:1073741824",
|
||||
"--fsize=67108864:67108864",
|
||||
"--cpu=30:30",
|
||||
"--core=0:0",
|
||||
"--",
|
||||
"/bin/producer",
|
||||
]);
|
||||
expect(
|
||||
prlimitArgumentVector(
|
||||
clampLinuxResourceLimits(BOUNDED_PRODUCER_LIMITS, report("10 20")),
|
||||
"/bin/producer",
|
||||
),
|
||||
).toEqual([
|
||||
"--as=10:10",
|
||||
"--fsize=67108864:67108864",
|
||||
"--cpu=30:30",
|
||||
"--core=0:0",
|
||||
"--",
|
||||
"/bin/producer",
|
||||
]);
|
||||
});
|
||||
@@ -15,6 +15,29 @@ const rows = {
|
||||
cpu_seconds: "Max cpu time",
|
||||
} as const;
|
||||
|
||||
/** Bound the resident input copy and retained text before allocation/collection. */
|
||||
export const BOUNDED_PRODUCER_LIMITS = {
|
||||
input_bytes: 32 * 1024 * 1024,
|
||||
output_bytes: 4 * 1024 * 1024,
|
||||
command_timeout_ms: 30_000,
|
||||
address_space_bytes: 1024 * 1024 * 1024,
|
||||
file_bytes: 64 * 1024 * 1024,
|
||||
cpu_seconds: 30,
|
||||
} as const;
|
||||
|
||||
/** Pin soft and hard ceilings together, then hand the producer argv to the limiter. */
|
||||
export const prlimitArgumentVector = (
|
||||
limits: LinuxResourceLimits,
|
||||
command: string,
|
||||
): string[] => [
|
||||
`--as=${limits.address_space_bytes}:${limits.address_space_bytes}`,
|
||||
`--fsize=${limits.file_bytes}:${limits.file_bytes}`,
|
||||
`--cpu=${limits.cpu_seconds}:${limits.cpu_seconds}`,
|
||||
"--core=0:0",
|
||||
"--",
|
||||
command,
|
||||
];
|
||||
|
||||
/** Never raise either inherited soft policy or hard ceiling. */
|
||||
export const clampLinuxResourceLimits = (
|
||||
configured: LinuxResourceLimits,
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
parseDecoderDiscovery,
|
||||
} from "./parseDecoderDiscovery.js";
|
||||
import {
|
||||
BOUNDED_PRODUCER_LIMITS as LIMITS,
|
||||
prlimitArgumentVector,
|
||||
readLinuxResourceLimits,
|
||||
type LinuxResourceLimits,
|
||||
} from "../process/LinuxResourceLimits.js";
|
||||
@@ -59,15 +61,6 @@ export const SIGROK_PROVIDER_IDENTITY = {
|
||||
name: "REA sigrok recording adapter",
|
||||
version: "sigrok-cli@0.7.2",
|
||||
} as const;
|
||||
// Bound the resident input copy and retained text before allocation/collection.
|
||||
const LIMITS = {
|
||||
input_bytes: 32 * 1024 * 1024,
|
||||
output_bytes: 4 * 1024 * 1024,
|
||||
command_timeout_ms: 30_000,
|
||||
address_space_bytes: 1024 * 1024 * 1024,
|
||||
file_bytes: 64 * 1024 * 1024,
|
||||
cpu_seconds: 30,
|
||||
} as const;
|
||||
type Launcher = NonNullable<Parameters<typeof runOwnedCommand>[2]>["launcher"];
|
||||
|
||||
/** Decode selected recordings only, retaining cleanup ownership across failures. */
|
||||
@@ -163,14 +156,7 @@ export class SigrokRecordingProvider implements SignalRecordingPort {
|
||||
options?.signal,
|
||||
);
|
||||
effectiveLimits = await this.#resources.readLimits(LIMITS);
|
||||
const limitArguments = [
|
||||
`--as=${effectiveLimits.address_space_bytes}:${effectiveLimits.address_space_bytes}`,
|
||||
`--fsize=${effectiveLimits.file_bytes}:${effectiveLimits.file_bytes}`,
|
||||
`--cpu=${effectiveLimits.cpu_seconds}:${effectiveLimits.cpu_seconds}`,
|
||||
"--core=0:0",
|
||||
"--",
|
||||
command,
|
||||
];
|
||||
const limitArguments = prlimitArgumentVector(effectiveLimits, command);
|
||||
phase = "workspace";
|
||||
root = await this.createRuntime();
|
||||
this.#resources.retain(root, undefined);
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join, sep } from "node:path";
|
||||
|
||||
import { createPackageWithOptions, listPackage } from "@electron/asar";
|
||||
import { expect } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { javascriptApplicationAnalysisResultSchema } from "../../../src/domain/javascript/javascriptApplicationAnalysis.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { connectLocalToolsMcp } from "../../fixtures/localToolsMcp.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for(["CLI", "stdio MCP"] as const)(
|
||||
"links a block source-map URL containing an asterisk through %s",
|
||||
async (surface, { cli }) => {
|
||||
const root = await createTestTempDirectory("rea-block-map-");
|
||||
const directory = join(root, "input");
|
||||
await mkdir(directory);
|
||||
const url = "app*part.js.map";
|
||||
const directive = "/*# sourceMappingURL=" + url;
|
||||
await writeFile(join(directory, "app.js"), directive + "*/");
|
||||
await writeFile(
|
||||
join(directory, "app_part.js.map"),
|
||||
JSON.stringify({ version: 3, sources: [], names: [], mappings: "" }),
|
||||
);
|
||||
const archive = join(root, "app.asar");
|
||||
await createPackageWithOptions(directory, archive, {});
|
||||
// ASAR logical names can contain '*' even on Windows. Rename this header
|
||||
// key without changing entry bytes, sizes or payload offsets.
|
||||
const bytes = await readFile(archive);
|
||||
const key = Buffer.from('"app_part.js.map"');
|
||||
const offset = bytes.indexOf(key);
|
||||
expect(offset).toBeGreaterThan(0);
|
||||
expect(bytes.indexOf(key, offset + key.length)).toBe(-1);
|
||||
Buffer.from('"' + url + '"').copy(bytes, offset);
|
||||
await writeFile(archive, bytes);
|
||||
expect(listPackage(archive, { isPack: false })).toContain(sep + url);
|
||||
let document: unknown;
|
||||
if (surface === "CLI") {
|
||||
const result = await cli.run({
|
||||
arguments: ["analyze-javascript-application", archive, "--json"],
|
||||
});
|
||||
expect(result.exitCode, result.stderr).toBe(0);
|
||||
document = result.json;
|
||||
} else {
|
||||
const { call } = await connectLocalToolsMcp();
|
||||
const result = await call("analyze_javascript_application", {
|
||||
input_path: archive,
|
||||
format: "asar",
|
||||
});
|
||||
expect(result.isError).not.toBe(true);
|
||||
document = result.structuredContent;
|
||||
}
|
||||
const {
|
||||
normalized_result: { graph },
|
||||
} = z
|
||||
.object({
|
||||
normalized_result: javascriptApplicationAnalysisResultSchema,
|
||||
})
|
||||
.parse(document);
|
||||
const digest = createHash("sha256").update(url).digest("hex");
|
||||
const map = graph.nodes.find(({ observations }) =>
|
||||
observations.some(({ properties }) => properties.declared_url === url),
|
||||
);
|
||||
expect(
|
||||
map,
|
||||
"the complete declared URL must identify the map",
|
||||
).toBeDefined();
|
||||
expect(map?.observations).toContainEqual(
|
||||
expect.objectContaining({
|
||||
properties: {
|
||||
declared_url: url,
|
||||
declared_url_sha256: digest,
|
||||
resolved_path: url,
|
||||
available: true,
|
||||
},
|
||||
evidence: expect.objectContaining({
|
||||
location: {
|
||||
available: true,
|
||||
value: {
|
||||
kind: "source-range",
|
||||
source: "app.js",
|
||||
start: { line: 1, column: 0 },
|
||||
end: { line: 1, column: directive.length },
|
||||
},
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
relation: "maps_to",
|
||||
target_node_id: map?.node_id,
|
||||
properties: { declared_url_sha256: digest, resolved_path: url },
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,35 @@
|
||||
import { expect } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../support/cli/cliFixture.js";
|
||||
|
||||
cliTest.for(["analyze", "inspect-artifact"])(
|
||||
"%s rejects an explicitly empty target before resolving it against cwd",
|
||||
async (command, { cli }) => {
|
||||
const cwd = await createTestTempDirectory("rea-empty-target-");
|
||||
const result = await cli.run({
|
||||
arguments: [command, "", "--json"],
|
||||
cwd,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({ code: "VALIDATION_ERROR" });
|
||||
expect(result.stdout).toContain("path");
|
||||
expect(result.stdout).not.toContain("directory_requires_file");
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"inspect-artifact still admits an explicitly selected current directory",
|
||||
async ({ cli }) => {
|
||||
const cwd = await createTestTempDirectory("rea-explicit-target-");
|
||||
const result = await cli.run({
|
||||
arguments: ["inspect-artifact", ".", "--json"],
|
||||
cwd,
|
||||
});
|
||||
expect(result.exitCode).toBe(1);
|
||||
expect(result.json).toMatchObject({
|
||||
code: "target_unavailable",
|
||||
details: { path: cwd, constraint: "directory_requires_file" },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -188,8 +188,12 @@ it("closes the service and retains an unexpected producer rejection", async () =
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects an oversized selector budget before the CLI invokes its service", async () => {
|
||||
const cli = Cli.create("capture-selector-test", { sync: false });
|
||||
/** Serve one inspection request whose service must never be reached. */
|
||||
const rejectBeforeService = async (
|
||||
name: string,
|
||||
args: string[],
|
||||
): Promise<string> => {
|
||||
const cli = Cli.create(name, { sync: false });
|
||||
let called = false;
|
||||
registerCaptureCommands(
|
||||
cli,
|
||||
@@ -207,66 +211,41 @@ it("rejects an oversized selector budget before the CLI invokes its service", as
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
[
|
||||
"inspect-packet-capture",
|
||||
"recording.pcap",
|
||||
"--fields",
|
||||
"x".repeat(8192),
|
||||
"--display_filter",
|
||||
"界".repeat(8192),
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => {},
|
||||
await cli.serve(args, {
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
);
|
||||
exit: () => {},
|
||||
});
|
||||
expect(called).toBe(false);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
it("rejects an oversized selector budget before the CLI invokes its service", async () => {
|
||||
const stdout = await rejectBeforeService("capture-selector-test", [
|
||||
"inspect-packet-capture",
|
||||
"recording.pcap",
|
||||
"--fields",
|
||||
"x".repeat(8192),
|
||||
"--display_filter",
|
||||
"界".repeat(8192),
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expect(stdout).toContain("32 KiB");
|
||||
});
|
||||
|
||||
it("rejects conflicting output options before the CLI invokes its service", async () => {
|
||||
const cli = Cli.create("capture-window-test", { sync: false });
|
||||
let called = false;
|
||||
registerCaptureCommands(
|
||||
cli,
|
||||
silentLogger,
|
||||
{},
|
||||
{
|
||||
inspectFields: async () => {
|
||||
throw new Error("Unexpected field discovery call");
|
||||
},
|
||||
inspect: async () => {
|
||||
called = true;
|
||||
throw new Error("Must not inspect");
|
||||
},
|
||||
close: async () => {},
|
||||
},
|
||||
);
|
||||
let stdout = "";
|
||||
await cli.serve(
|
||||
[
|
||||
"inspect-packet-capture",
|
||||
"recording.pcap",
|
||||
"--fields",
|
||||
"ip.addr",
|
||||
"--all_matches",
|
||||
"--match_limit",
|
||||
"20",
|
||||
"--format",
|
||||
"json",
|
||||
],
|
||||
{
|
||||
stdout: (text) => {
|
||||
stdout += text;
|
||||
},
|
||||
exit: () => {},
|
||||
},
|
||||
);
|
||||
expect(called).toBe(false);
|
||||
const stdout = await rejectBeforeService("capture-window-test", [
|
||||
"inspect-packet-capture",
|
||||
"recording.pcap",
|
||||
"--fields",
|
||||
"ip.addr",
|
||||
"--all_matches",
|
||||
"--match_limit",
|
||||
"20",
|
||||
"--format",
|
||||
"json",
|
||||
]);
|
||||
expect(stdout).toContain("mutually exclusive");
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AsarArtifactReader } from "../../../src/artifacts/AsarArtifactReader.js";
|
||||
import { readAsarListing } from "../../../src/artifacts/AsarListing.js";
|
||||
import {
|
||||
asarHeaderBytes,
|
||||
nestedAsarHeader,
|
||||
} from "../../fixtures/asarHeader.js";
|
||||
import { inventoryArtifact } from "../../fixtures/artifactInventory.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const archiveWith = async (bytes: Buffer) => {
|
||||
const root = await createTestTempDirectory("rea-asar-listing-");
|
||||
const path = join(root, "app.asar");
|
||||
await writeFile(path, bytes);
|
||||
return path;
|
||||
};
|
||||
|
||||
it("interrupts expanded listing admission between traversal batches", async () => {
|
||||
const controller = new AbortController();
|
||||
const header = {
|
||||
files: Object.fromEntries(
|
||||
Array.from({ length: 4000 }, (_, index) => [
|
||||
`f${index}`,
|
||||
{ size: 0, offset: "0" },
|
||||
]),
|
||||
),
|
||||
};
|
||||
const listing = readAsarListing(header, controller.signal);
|
||||
await setImmediate();
|
||||
controller.abort();
|
||||
await expect(listing).rejects.toMatchObject({ reason: "cancelled" });
|
||||
});
|
||||
|
||||
it("rejects path expansion before publishing any inventory, preserving the input", async () => {
|
||||
const bytes = nestedAsarHeader(64, "d".repeat(256), 4000);
|
||||
expect(bytes.length).toBeLessThan(150_000);
|
||||
const path = await archiveWith(bytes);
|
||||
await expect(inventoryArtifact(path)).rejects.toMatchObject({
|
||||
reason: "limit",
|
||||
message: "ASAR expanded listing exceeds its 64 MiB representation budget",
|
||||
});
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
});
|
||||
|
||||
it("reads deeply nested packed metadata without recursive listing or member lookup", async () => {
|
||||
const path = await archiveWith(nestedAsarHeader(3500, "d", 1));
|
||||
const reader = new AsarArtifactReader(path);
|
||||
try {
|
||||
let count = 0;
|
||||
for await (const entry of reader.entries()) {
|
||||
count++;
|
||||
if (entry.kind === "file") {
|
||||
expect(entry.path).toBe(`${"d/".repeat(3500)}f0`);
|
||||
expect(await buffer(await reader.open(entry))).toEqual(Buffer.alloc(0));
|
||||
}
|
||||
}
|
||||
expect(count).toBe(3501);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["..", ".", "", "a/../b", "a/b", "a\0b"])(
|
||||
"rejects raw ASAR component %j before host normalization can alias another entry",
|
||||
async (component) => {
|
||||
const json = JSON.stringify({
|
||||
files: { [component]: { size: 0, offset: "0" } },
|
||||
});
|
||||
await expect(
|
||||
inventoryArtifact(await archiveWith(asarHeaderBytes(json))),
|
||||
).rejects.toMatchObject({ reason: "path" });
|
||||
},
|
||||
);
|
||||
|
||||
it("orders raw names portably and retains prototype-named entries and links", async () => {
|
||||
const path = await archiveWith(
|
||||
asarHeaderBytes(
|
||||
'{"files":{"a0":{"size":0,"offset":"0"},"__proto__":{"size":0,"offset":"0"},"a":{"files":{"b":{"link":"a0"}}},"😀":{"size":0,"offset":"0"},"\uE000":{"size":0,"offset":"0"}}}',
|
||||
),
|
||||
);
|
||||
const reader = new AsarArtifactReader(path);
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries())
|
||||
entries.push([entry.path, entry.kind]);
|
||||
expect(entries).toEqual([
|
||||
["__proto__", "file"],
|
||||
["a", "directory"],
|
||||
["a/b", "symlink"],
|
||||
["a0", "file"],
|
||||
["\uE000", "file"],
|
||||
["😀", "file"],
|
||||
]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves POSIX literal backslashes and rejects Windows component separators", async () => {
|
||||
const path = await archiveWith(
|
||||
asarHeaderBytes(
|
||||
JSON.stringify({ files: { "a\\b": { size: 0, offset: "0" } } }),
|
||||
),
|
||||
);
|
||||
const reader = new AsarArtifactReader(path);
|
||||
try {
|
||||
const list = async () => {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
return entries;
|
||||
};
|
||||
if (process.platform === "win32") {
|
||||
await expect(list()).rejects.toMatchObject({ reason: "path" });
|
||||
} else {
|
||||
const entries = await list();
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.path).toBe("a\\b");
|
||||
expect(await buffer(await reader.open(entries[0]!))).toEqual(
|
||||
Buffer.alloc(0),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
@@ -2195,3 +2195,135 @@ describe("DeepSeek Harness caller node metadata", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("DeepSeek Harness uninstall of a shared operation", () => {
|
||||
it("keeps a group patch's own fields and comment when its only row was REA's", async () => {
|
||||
const { home, client } = await harnessClient();
|
||||
const entry = canonicalEntry();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
`- insert:
|
||||
- id: user-a
|
||||
name: plugin-a
|
||||
# caller group patch
|
||||
- id: tools
|
||||
config:
|
||||
sandbox: strict
|
||||
insert:
|
||||
- id: rea-mcp
|
||||
name: "${entry.name}"
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: ${entry.config.command}
|
||||
args: ${JSON.stringify(entry.config.args)}
|
||||
`,
|
||||
);
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
const text = await readFile(client.configPath, "utf8");
|
||||
expect(parseYaml(text)).toEqual([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
{ id: "tools", config: { sandbox: "strict" } },
|
||||
]);
|
||||
expect(text).toContain("# caller group patch");
|
||||
});
|
||||
|
||||
it("keeps an operation's own fields when setup removes a second REA row from it", async () => {
|
||||
// Setup refuses a REA row in a group patch, so the setup path meets the
|
||||
// keep rule only on an operation without a selector.
|
||||
const { home, client } = await harnessClient();
|
||||
const entry = canonicalEntry();
|
||||
const row = (launcher: string) => ` - id: rea-mcp
|
||||
name: "${entry.name}"
|
||||
config:
|
||||
serverName: rea
|
||||
transport: stdio
|
||||
command: ${launcher}
|
||||
args: [mcp]
|
||||
`;
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
`- insert:
|
||||
${row("first-launcher")}# caller operation
|
||||
- disabled: false
|
||||
insert:
|
||||
${row("second-launcher")}`,
|
||||
);
|
||||
|
||||
expect(
|
||||
(await configureClientConfiguration(client, {}, command)).status,
|
||||
).toBe("configured");
|
||||
const text = await readFile(client.configPath, "utf8");
|
||||
expect(text).toContain("# caller operation");
|
||||
expect(loadedInserts(text)).toHaveLength(1);
|
||||
expect((parseYaml(text) as unknown[]).at(-1)).toEqual({ disabled: false });
|
||||
expect(await dshStatus(home)).toMatchObject({ state: "aligned" });
|
||||
});
|
||||
|
||||
it("drops an emptied operation that targets REA's own row", async () => {
|
||||
const { home, client } = await harnessClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
stringifyYaml([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
{
|
||||
id: "rea-mcp",
|
||||
config: { serverName: "rea" },
|
||||
insert: [canonicalEntry()],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
const host = systemUninstallHost(home);
|
||||
expect((await host.removeClient(client)).status).toBe("removed");
|
||||
expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ selector: { id: "tools" } },
|
||||
{ selector: { name: "tools" } },
|
||||
{ selector: { id: "tools", name: "tools" } },
|
||||
])(
|
||||
"drops an emptied operation left with only its target selector $selector",
|
||||
async ({ selector }) => {
|
||||
const { home, client } = await harnessClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
stringifyYaml([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
{ ...selector, insert: [canonicalEntry()] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect(
|
||||
(await systemUninstallHost(home).removeClient(client)).status,
|
||||
).toBe("removed");
|
||||
expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("still drops an insert-only operation that REA's row emptied", async () => {
|
||||
const { home, client } = await harnessClient();
|
||||
await writeFile(
|
||||
client.configPath,
|
||||
stringifyYaml([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
{ insert: [canonicalEntry()] },
|
||||
]),
|
||||
);
|
||||
|
||||
expect((await systemUninstallHost(home).removeClient(client)).status).toBe(
|
||||
"removed",
|
||||
);
|
||||
expect(parseYaml(await readFile(client.configPath, "utf8"))).toEqual([
|
||||
{ insert: [{ id: "user-a", name: "plugin-a" }] },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -209,13 +209,7 @@ describe("installer TTY fallback", { timeout: 20_000 }, () => {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fixture.home,
|
||||
TMPDIR: fixture.temporary,
|
||||
PATH: `${fixture.bin}:/usr/bin:/bin`,
|
||||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
FAKE_REA_LOG: fixture.reaLog,
|
||||
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
|
||||
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
|
||||
...installerEnvironment(fixture),
|
||||
TERM: "xterm-256color",
|
||||
},
|
||||
name: "xterm-256color",
|
||||
@@ -581,6 +575,19 @@ const executable = async (path: string, source: string): Promise<void> => {
|
||||
await chmod(path, 0o755);
|
||||
};
|
||||
|
||||
/** Fixture-owned installer environment shared by the exec, pipe and PTY runs. */
|
||||
const installerEnvironment = (
|
||||
fixture: InstallerFixture,
|
||||
): Record<string, string> => ({
|
||||
HOME: fixture.home,
|
||||
TMPDIR: fixture.temporary,
|
||||
PATH: `${fixture.bin}:/usr/bin:/bin`,
|
||||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
FAKE_REA_LOG: fixture.reaLog,
|
||||
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
|
||||
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
|
||||
});
|
||||
|
||||
const runInstaller = async (
|
||||
fixture: InstallerFixture,
|
||||
args: readonly string[] = [],
|
||||
@@ -590,13 +597,7 @@ const runInstaller = async (
|
||||
execFileAsync("/bin/bash", [script, ...args], {
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: fixture.home,
|
||||
TMPDIR: fixture.temporary,
|
||||
PATH: `${fixture.bin}:/usr/bin:/bin`,
|
||||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
FAKE_REA_LOG: fixture.reaLog,
|
||||
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
|
||||
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
|
||||
...installerEnvironment(fixture),
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
@@ -618,13 +619,7 @@ const runPipedInstaller = async (
|
||||
new Promise((resolve, reject) => {
|
||||
const child = spawn("/bin/bash", ["-s", "--", ...args], {
|
||||
env: {
|
||||
HOME: fixture.home,
|
||||
TMPDIR: fixture.temporary,
|
||||
PATH: `${fixture.bin}:/usr/bin:/bin`,
|
||||
FAKE_NPM_LOG: fixture.npmLog,
|
||||
FAKE_REA_LOG: fixture.reaLog,
|
||||
FAKE_REA_SOURCE: join(fixture.bin, "rea-source"),
|
||||
FAKE_NPM_PREFIX: join(fixture.home, ".npm-global"),
|
||||
...installerEnvironment(fixture),
|
||||
...overrides,
|
||||
},
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
|
||||
@@ -54,6 +54,42 @@ const holdReadHandle = (path: string, reason: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
/** Linux setup host with its VS Code client configuration directory prepared. */
|
||||
const preparedVsCodeSetupHost = async (prefix: string) => {
|
||||
const home = await createTestTempDirectory(prefix);
|
||||
const host = systemSetupHost(
|
||||
{ platform: "linux", homeDirectory: home } as DoctorHost,
|
||||
{ HOME: home, XDG_CONFIG_HOME: join(home, "xdg") },
|
||||
);
|
||||
const client = (await host.supportedClients()).find(
|
||||
({ name }) => name === "vscode",
|
||||
);
|
||||
if (client === undefined) throw new Error("VS Code setup client missing");
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
return { home, host, client };
|
||||
};
|
||||
|
||||
/** Release the held read handle and show host cleanup reuses it as a third attempt. */
|
||||
const expectCloseReclaimsHandle = async (
|
||||
host: { close?: () => Promise<string | undefined> },
|
||||
closeAttempts: readonly object[],
|
||||
release: () => void,
|
||||
) => {
|
||||
release();
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(closeAttempts).toHaveLength(3);
|
||||
expect(closeAttempts[2]).toBe(closeAttempts[0]);
|
||||
};
|
||||
|
||||
/** Give up on the assertion path: release the handle and let cleanup close it. */
|
||||
const cleanupHeldHandle = async (
|
||||
host: { close?: () => Promise<string | undefined> },
|
||||
release: () => void,
|
||||
) => {
|
||||
release();
|
||||
await host.close?.();
|
||||
};
|
||||
|
||||
it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fails", async () => {
|
||||
const home = await createTestTempDirectory("rea-uninstall-utf8-close-");
|
||||
const path = join(home, "client.json");
|
||||
@@ -77,13 +113,9 @@ it("retains invalid UTF-8 diagnostics and the uninstall read owner when close fa
|
||||
});
|
||||
expect(await readFile(path)).toEqual(original);
|
||||
expect(closeAttempts).toHaveLength(2);
|
||||
release();
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(closeAttempts).toHaveLength(3);
|
||||
expect(closeAttempts[2]).toBe(closeAttempts[0]);
|
||||
await expectCloseReclaimsHandle(host, closeAttempts, release);
|
||||
} finally {
|
||||
release();
|
||||
await host.close?.();
|
||||
await cleanupHeldHandle(host, release);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -126,13 +158,9 @@ describe("ZCode fallback cleanup ownership", () => {
|
||||
await expect(readFile(client.configPath)).rejects.toMatchObject({
|
||||
code: "ENOENT",
|
||||
});
|
||||
release();
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(closeAttempts).toHaveLength(3);
|
||||
expect(closeAttempts[2]).toBe(closeAttempts[0]);
|
||||
await expectCloseReclaimsHandle(host, closeAttempts, release);
|
||||
} finally {
|
||||
release();
|
||||
await host.close?.();
|
||||
await cleanupHeldHandle(host, release);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -145,20 +173,9 @@ describe("setup client configuration cleanup ownership", () => {
|
||||
] as const)(
|
||||
"stops configuration and retains the %s read owner until host close",
|
||||
async (encoding, original) => {
|
||||
const home = await createTestTempDirectory("rea-setup-config-close-");
|
||||
const environment = {
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, "xdg"),
|
||||
};
|
||||
const host = systemSetupHost(
|
||||
{ platform: "linux", homeDirectory: home } as DoctorHost,
|
||||
environment,
|
||||
const { host, client } = await preparedVsCodeSetupHost(
|
||||
"rea-setup-config-close-",
|
||||
);
|
||||
const client = (await host.supportedClients()).find(
|
||||
({ name }) => name === "vscode",
|
||||
);
|
||||
if (client === undefined) throw new Error("VS Code setup client missing");
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFile(client.configPath, original);
|
||||
|
||||
const { closeAttempts, release } = holdReadHandle(
|
||||
@@ -210,32 +227,17 @@ describe("setup client configuration cleanup ownership", () => {
|
||||
expect(closeAttempts[0]).toBe(closeAttempts[1]);
|
||||
expect(await readFile(client.configPath)).toEqual(original);
|
||||
|
||||
release();
|
||||
expect(await host.close?.()).toBeUndefined();
|
||||
expect(closeAttempts).toHaveLength(3);
|
||||
expect(closeAttempts[2]).toBe(closeAttempts[0]);
|
||||
await expectCloseReclaimsHandle(host, closeAttempts, release);
|
||||
} finally {
|
||||
release();
|
||||
await host.close?.();
|
||||
await cleanupHeldHandle(host, release);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("waits for an admitted configuration read and rejects later admissions", async () => {
|
||||
const home = await createTestTempDirectory("rea-setup-config-inflight-");
|
||||
const environment = {
|
||||
HOME: home,
|
||||
XDG_CONFIG_HOME: join(home, "xdg"),
|
||||
};
|
||||
const host = systemSetupHost(
|
||||
{ platform: "linux", homeDirectory: home } as DoctorHost,
|
||||
environment,
|
||||
const { host, client } = await preparedVsCodeSetupHost(
|
||||
"rea-setup-config-inflight-",
|
||||
);
|
||||
const client = (await host.supportedClients()).find(
|
||||
({ name }) => name === "vscode",
|
||||
);
|
||||
if (client === undefined) throw new Error("VS Code setup client missing");
|
||||
await mkdir(dirname(client.configPath), { recursive: true });
|
||||
await writeFile(client.configPath, '{"servers": {}}\n');
|
||||
|
||||
let beginRead!: () => void;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { ArtifactProvider } from "../../../src/artifacts/ArtifactProvider.js";
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { nestedAsarHeader } from "../../fixtures/asarHeader.js";
|
||||
import { createTestBinarySession } from "../../fixtures/binarySession.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
it("reports expanded ASAR admission failure through MCP and permits a valid retry", async () => {
|
||||
const root = await createTestTempDirectory("rea-asar-listing-mcp-");
|
||||
const path = join(root, "app.asar");
|
||||
const bytes = nestedAsarHeader(64, "d".repeat(256), 4000);
|
||||
await writeFile(path, bytes);
|
||||
const session = createTestBinarySession(new ArtifactProvider(process.env));
|
||||
const server = createServer({ kind: "session", session });
|
||||
const client = new Client({ name: "asar-listing-test", version: "1" });
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
expect(
|
||||
(await client.callTool({ name: "open_binary", arguments: { path } }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
const result = await client.callTool({
|
||||
name: "inspect_artifact",
|
||||
arguments: {},
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(parseMcpToolError(result)).toMatchObject({
|
||||
error: {
|
||||
code: "truncated",
|
||||
category: "truncated",
|
||||
},
|
||||
});
|
||||
expect(result.structuredContent).toBeUndefined();
|
||||
expect(await readFile(path)).toEqual(bytes);
|
||||
const valid = join(root, "valid.asar");
|
||||
await writeFile(valid, nestedAsarHeader(1, "lib", 1));
|
||||
expect(
|
||||
(
|
||||
await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: valid },
|
||||
})
|
||||
).isError,
|
||||
).not.toBe(true);
|
||||
expect(
|
||||
(await client.callTool({ name: "inspect_artifact", arguments: {} }))
|
||||
.isError,
|
||||
).not.toBe(true);
|
||||
} finally {
|
||||
await Promise.allSettled([client.close(), server.close(), session.close()]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,184 @@
|
||||
import { Client, InMemoryTransport } from "@modelcontextprotocol/client";
|
||||
import type { CallToolResult } from "@modelcontextprotocol/server";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createServer } from "../../../src/server/createServer.js";
|
||||
import { evidenceBundleSchema } from "../../../src/domain/evidenceBundle.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { err, ok } from "../../../src/domain/result.js";
|
||||
import {
|
||||
createCacheProvider,
|
||||
createTestBinarySession,
|
||||
} from "../../fixtures/binarySession.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { parseMcpToolError } from "../../fixtures/mcpToolError.js";
|
||||
|
||||
const bundleResult = (reply: CallToolResult) =>
|
||||
z.object({ result: evidenceBundleSchema }).parse(reply.structuredContent)
|
||||
.result;
|
||||
|
||||
const successfulTool = async (
|
||||
client: Client,
|
||||
name: string,
|
||||
arguments_: Record<string, unknown> = {},
|
||||
): Promise<CallToolResult> => {
|
||||
const reply = await client.callTool({ name, arguments: arguments_ });
|
||||
expect(reply.isError, `${name}: ${JSON.stringify(reply.content)}`).not.toBe(
|
||||
true,
|
||||
);
|
||||
return reply;
|
||||
};
|
||||
|
||||
const recordUnknownHistory = async (client: Client): Promise<void> => {
|
||||
const fields = {
|
||||
severity: "medium",
|
||||
required_authority: "shipped-artifact",
|
||||
required_confidence: "observed",
|
||||
required_environment: null,
|
||||
recommended_probes: [],
|
||||
relationships: [],
|
||||
};
|
||||
const unknown = await successfulTool(client, "record_unknown", {
|
||||
...fields,
|
||||
question: "Which source revision produced this target?",
|
||||
domain: "provenance",
|
||||
});
|
||||
const recorded = z
|
||||
.object({ result: z.object({ unknown_id: z.string() }) })
|
||||
.parse(unknown.structuredContent).result;
|
||||
await successfulTool(client, "update_unknown", {
|
||||
...fields,
|
||||
unknown_id: recorded.unknown_id,
|
||||
expected_revision: 1,
|
||||
status: "investigating",
|
||||
supporting_evidence_ids: [],
|
||||
contradicting_evidence_ids: [],
|
||||
resolution: null,
|
||||
});
|
||||
};
|
||||
|
||||
it.each(["switch", "restart"] as const)(
|
||||
"preserves investigation records through failed target %s and cleanup retries",
|
||||
async (transition) => {
|
||||
const root = await createTestTempDirectory("rea-mcp-switch-evidence-");
|
||||
const first = join(root, "first.hop");
|
||||
const second = join(root, "second.hop");
|
||||
await writeFile(first, "first source-owned target");
|
||||
await writeFile(second, "second source-owned target");
|
||||
const application = join(root, "application");
|
||||
await mkdir(application);
|
||||
await writeFile(
|
||||
join(application, "index.js"),
|
||||
'export const label = "retained";',
|
||||
);
|
||||
const nextTarget = transition === "switch" ? second : first;
|
||||
const calls: string[] = [];
|
||||
const base = createCacheProvider(calls);
|
||||
let cleanupAllowed = true;
|
||||
let created = 0;
|
||||
const failure = new ProviderCleanupError(
|
||||
"fixture",
|
||||
[join(root, "runtime")],
|
||||
{
|
||||
reason: "fixture cleanup temporarily unavailable",
|
||||
},
|
||||
);
|
||||
const session = createTestBinarySession({
|
||||
...base,
|
||||
createClient: (target, profile, context) => {
|
||||
created += 1;
|
||||
return {
|
||||
...base.createClient(target, profile, context),
|
||||
operationHealthSnapshot: () => ({
|
||||
state: cleanupAllowed ? "idle" : "exited",
|
||||
stage: "analysis",
|
||||
retryAction: cleanupAllowed ? null : "restart_provider",
|
||||
exitCode: cleanupAllowed ? null : 1,
|
||||
requests: [],
|
||||
}),
|
||||
close: () =>
|
||||
Promise.resolve(cleanupAllowed ? ok(null) : err(failure)),
|
||||
};
|
||||
},
|
||||
});
|
||||
const server = createServer(
|
||||
{ kind: "session", session },
|
||||
{ environment: {} },
|
||||
);
|
||||
const client = new Client({
|
||||
name: "switch-evidence-recovery",
|
||||
version: "1",
|
||||
});
|
||||
const [clientTransport, serverTransport] =
|
||||
InMemoryTransport.createLinkedPair();
|
||||
const call = (name: string, arguments_: Record<string, unknown> = {}) =>
|
||||
successfulTool(client, name, arguments_);
|
||||
const query = { address: "0x1000", document: "fixture" };
|
||||
try {
|
||||
await server.connect(serverTransport);
|
||||
await client.connect(clientTransport);
|
||||
await call("open_binary", { path: first });
|
||||
// The cached primitive must be discarded when the provider is retired,
|
||||
// independently of the investigation's Evidence and Unknown history.
|
||||
await call("address_name", query);
|
||||
const analyzed = await call("analyze_javascript_application", {
|
||||
input_path: application,
|
||||
format: "directory",
|
||||
max_heap_mb: 128,
|
||||
});
|
||||
const applicationId = z
|
||||
.object({ evidence_id: z.string() })
|
||||
.parse(analyzed.structuredContent).evidence_id;
|
||||
await recordUnknownHistory(client);
|
||||
const before = bundleResult(await call("get_evidence_bundle"));
|
||||
expect(before.records).toHaveLength(4);
|
||||
expect(before.unknowns).toHaveLength(2);
|
||||
|
||||
cleanupAllowed = false;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
const replacement = await client.callTool({
|
||||
name: "open_binary",
|
||||
arguments: { path: nextTarget },
|
||||
});
|
||||
expect(parseMcpToolError(replacement)).toMatchObject({
|
||||
error: { code: "cleanup_incomplete" },
|
||||
});
|
||||
expect(session.activeTarget()).toBeUndefined();
|
||||
expect(created).toBe(1);
|
||||
expect(bundleResult(await call("get_evidence_bundle"))).toEqual(before);
|
||||
}
|
||||
const exportedPath = join(root, "retained.json");
|
||||
await call("export_evidence_bundle", { path: exportedPath });
|
||||
expect(
|
||||
evidenceBundleSchema.parse(
|
||||
JSON.parse(await readFile(exportedPath, "utf8")),
|
||||
),
|
||||
).toEqual(before);
|
||||
|
||||
cleanupAllowed = true;
|
||||
await call("open_binary", { path: nextTarget });
|
||||
expect(bundleResult(await call("get_evidence_bundle"))).toEqual(before);
|
||||
await call("address_name", query);
|
||||
expect(
|
||||
calls.filter((operation) => operation === "address_name"),
|
||||
).toHaveLength(2);
|
||||
expect(created).toBe(2);
|
||||
await call("inspect_analysis_view", {
|
||||
source: { kind: "retained-evidence", evidence_id: applicationId },
|
||||
view: { kind: "page", collection: "modules", offset: 0, limit: 10 },
|
||||
});
|
||||
await call("close_binary");
|
||||
const closed = bundleResult(await call("get_evidence_bundle"));
|
||||
expect(closed.records).toEqual([]);
|
||||
expect(closed.unknowns).toEqual([]);
|
||||
} finally {
|
||||
cleanupAllowed = true;
|
||||
await client.close();
|
||||
await server.close();
|
||||
await session.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
Vendored
+27
@@ -0,0 +1,27 @@
|
||||
/** Serialize a header-only ASAR, including Chromium pickle alignment. */
|
||||
export const asarHeaderBytes = (json: string): Buffer => {
|
||||
const text = Buffer.from(json);
|
||||
const headerSize = 8 + Math.ceil(text.length / 4) * 4;
|
||||
const bytes = Buffer.alloc(8 + headerSize);
|
||||
bytes.writeUInt32LE(4, 0);
|
||||
bytes.writeUInt32LE(headerSize, 4);
|
||||
bytes.writeUInt32LE(headerSize - 4, 8);
|
||||
bytes.writeUInt32LE(text.length, 12);
|
||||
text.copy(bytes, 16);
|
||||
return bytes;
|
||||
};
|
||||
|
||||
/** Nested metadata without a recursive fixture builder or real directories. */
|
||||
export const nestedAsarHeader = (
|
||||
depth: number,
|
||||
name: string,
|
||||
leaves: number,
|
||||
): Buffer => {
|
||||
let json = `{"files":{${Array.from(
|
||||
{ length: leaves },
|
||||
(_, index) => `"f${index}":{"size":0,"offset":"0"}`,
|
||||
).join(",")}}}`;
|
||||
for (let index = 0; index < depth; index++)
|
||||
json = `{"files":{${JSON.stringify(name)}:${json}}}`;
|
||||
return asarHeaderBytes(json);
|
||||
};
|
||||
Reference in new issue
Block a user