mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 16:57:19 +00:00
Merge commit '70c9a7c20' into feat/environment-call-values
This commit is contained in:
commit
2ee34513b3
123 files changed
+4537
-838
No files matched your search
@@ -491,6 +491,11 @@ Dispatching a provider metadata write invalidates the active session's snapshot
|
||||
even if its reply fails: the provider may already have applied the write.
|
||||
Close and reopen the target before importing or saving another snapshot.
|
||||
|
||||
Saved snapshots include only cache entries whose subject identifies the selected
|
||||
target. Unbound observations and observations of child artifacts remain available
|
||||
in the open session and its Evidence bundle; they are excluded from the target's
|
||||
saved replay entries without changing their subjects.
|
||||
|
||||
Snapshots retain eligible target-scoped question histories in full, including
|
||||
mutation Evidence recorded while another target was active. Observations and
|
||||
related questions must still belong to the saved target. If those dependencies
|
||||
|
||||
@@ -1074,8 +1074,17 @@ counted as known archive omissions. Recognized symlinked `.nib` entries are
|
||||
not followed; they retain their paths and reader limitations as counted archive
|
||||
omissions. Either gap makes `archive_decode` partial and the graph truncated
|
||||
while preserving decoded documents. Its `examined` count remains actual decode
|
||||
attempts, and asset-catalog UI joins retain their existing incomplete-graph
|
||||
qualification.
|
||||
attempts. `archive_object_decode` separately reports whether archive selection
|
||||
and decoding examined the object sources; hierarchy-only and connection-only
|
||||
unknowns do not make that facet partial. Asset-catalog resource-key joins use
|
||||
this source coverage together with document and object projection coverage.
|
||||
Incomplete hierarchy or synthetic hierarchy-node limits alone do not qualify
|
||||
otherwise complete resource-key joins; omitted archives or resource objects do.
|
||||
Archive omission reasons are counted independently of the bounded path samples.
|
||||
A UID outside the exact supported range reports `unsupported_uid_exact_range`,
|
||||
not budget exhaustion. Mixed source failures report `multiple_archive_decode_causes`;
|
||||
limitations retain omission counts by cause even when individual paths exceed
|
||||
the reporting allowance.
|
||||
|
||||
UI dispatch joins use the authored receiver's class independently of its
|
||||
presentation kind (controller, view, control or another object). An unresolved
|
||||
|
||||
@@ -625,7 +625,8 @@ refused for them, as the manifest is not.
|
||||
|
||||
Each `GET` tool whose template ends in a parameter is an item tool, and its
|
||||
type's `collection_template` is the template without that parameter; any other
|
||||
`GET` tool is a list tool of its own template. Empty segments are ignored, so a
|
||||
`GET` tool is a list tool of its own template. Interior empty segments remain
|
||||
meaningful: `/api/books` and `/api//books` name distinct collections. A single
|
||||
trailing slash names the same collection. An item tool proves its type's
|
||||
`id_field` when every initial response carries the requested path value in the
|
||||
same field of one entity object (`id_basis: "path-parameter"`). The object is
|
||||
@@ -794,13 +795,7 @@ past those deleted before the capture. Two `made-object` rules agree in their
|
||||
clocks and own members, and a count only the later call shows starts at the
|
||||
first object's value. Members either call sent are accepted, only those every call sent are
|
||||
required, and an echoed member a call left out takes the value that call stored
|
||||
(`body.defaults`). A member a create sent empty (`""`, `null`, `false`, `[]` or
|
||||
`{}`), as a form sends a field left as it was, is not required: a call that
|
||||
leaves it out is applied as sending that value (`body.blank`), unless a call
|
||||
that left it out showed a default, which it then keeps. A call may send it as any
|
||||
recorded call sent it empty, even after a default is learned, and one leaving it
|
||||
out is applied as the earliest did, as when one create sent `false` and another
|
||||
`null`. A clock rule widens to `trimmed` only when no held entity or
|
||||
(`body.defaults`). A clock rule widens to `trimmed` only when no held entity or
|
||||
earlier call showed that clock at a fixed width. A later create that disagrees
|
||||
leaves the plan as it was. A create whose observed reference differs from the
|
||||
entity the plan rebuilds, whether or not it sent the reference member, is
|
||||
|
||||
@@ -121,6 +121,11 @@ preceding browser mappings when a nested section starts with a gap or has no
|
||||
child leaves. Raw map text, original source identities and the dedicated decoder
|
||||
profile are preserved.
|
||||
|
||||
Browser `original_module_edges` carry a `declaration_index` joining each edge to
|
||||
its `original_sources` declaration, including repeated source URLs across indexed
|
||||
sections. Imports are deduplicated within each declaration. Older observations
|
||||
may omit the index; their edge declaration identity remains unknown.
|
||||
|
||||
Browser collection accepts up to 64 MiB of map responses per inspection and
|
||||
preflights indexed sections, sources, names, generated rows and mapping segments
|
||||
against a 262144-record decode budget before upstream flattening. Indexed maps
|
||||
|
||||
@@ -273,7 +273,7 @@ function normalizeLint(report) {
|
||||
.filter((item) => item.severity === "error")
|
||||
.map((item) => {
|
||||
if (
|
||||
(item.filename !== undefined && !repoPath(item.filename)) ||
|
||||
(item.filename !== undefined && !validRepoPath(item.filename)) ||
|
||||
(item.code !== undefined && typeof item.code !== "string") ||
|
||||
typeof item.message !== "string"
|
||||
)
|
||||
@@ -304,7 +304,7 @@ function normalizeDuplicates(report) {
|
||||
if (exitCode !== 1) return [];
|
||||
return report.duplicates.map((item) => {
|
||||
const paths = [item.firstFile?.name, item.secondFile?.name].sort();
|
||||
if (paths.some((path) => !repoPath(path)))
|
||||
if (paths.some((path) => !validRepoPath(path)))
|
||||
throw new Error("jscpd duplicate paths are malformed");
|
||||
return { kind: "jscpd", paths };
|
||||
});
|
||||
@@ -338,18 +338,6 @@ async function readLimitedBytes(response, limit) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function repoPath(value) {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.length > 0 &&
|
||||
!value.startsWith("/") &&
|
||||
!value.includes("\\") &&
|
||||
value
|
||||
.split("/")
|
||||
.every((part) => part !== "" && part !== "." && part !== "..")
|
||||
);
|
||||
}
|
||||
|
||||
function positiveId(value) {
|
||||
const id = Number(value);
|
||||
if (!Number.isSafeInteger(id) || id < 1)
|
||||
@@ -398,19 +386,11 @@ function sameTaskHashes(diagnostics, left, right) {
|
||||
});
|
||||
}
|
||||
function validContext(value) {
|
||||
return (
|
||||
value?.version === 1 &&
|
||||
SHA.test(value.sourceSha ?? "") &&
|
||||
value.sourceClean === true &&
|
||||
typeof value.nodeVersion === "string" &&
|
||||
typeof value.npmVersion === "string" &&
|
||||
typeof value.turboVersion === "string" &&
|
||||
typeof value.platform === "string" &&
|
||||
typeof value.architecture === "string" &&
|
||||
value.taskHashes &&
|
||||
typeof value.taskHashes === "object" &&
|
||||
!Array.isArray(value.taskHashes)
|
||||
);
|
||||
try {
|
||||
return parseStaticTaskContext(value).sourceClean;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
function sameExecutionContext(left, right) {
|
||||
return [
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
|
||||
import type {
|
||||
AnalysisExecution,
|
||||
AnalysisOperationPort,
|
||||
@@ -5,7 +6,6 @@ import type {
|
||||
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -32,11 +32,7 @@ export const resolveAnalysisDocument = async (
|
||||
executionOptions(signal),
|
||||
);
|
||||
if (!current.ok) {
|
||||
if (
|
||||
!(current.error instanceof AnalysisCapabilityUnavailableError) ||
|
||||
current.error.cleanupIncomplete
|
||||
)
|
||||
return current;
|
||||
if (!permitsCapabilityFallback(current.error)) return current;
|
||||
if (signal?.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("current_document", {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCapabilityUnavailableError } from "../domain/analysisErrorCore.js";
|
||||
|
||||
/** Unsupported operations permit alternatives only after owned cleanup completes. */
|
||||
export const permitsCapabilityFallback = (
|
||||
error: AnalysisError,
|
||||
): error is AnalysisCapabilityUnavailableError =>
|
||||
error instanceof AnalysisCapabilityUnavailableError &&
|
||||
!error.cleanupIncomplete;
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
} from "../config/parseConfig.js";
|
||||
import { homeDirectoryFromEnvironment } from "../config/homeDirectory.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import { join } from "node:path";
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentation.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
@@ -708,7 +708,10 @@ export const systemDoctorHost = (
|
||||
);
|
||||
return appdir === undefined
|
||||
? undefined
|
||||
: join(appdir, "Hopper Disassembler.app/Contents/MacOS/hopper");
|
||||
: appendCallerPath(
|
||||
appdir,
|
||||
"Hopper Disassembler.app/Contents/MacOS/hopper",
|
||||
);
|
||||
});
|
||||
},
|
||||
manualHopperPaths: () =>
|
||||
|
||||
@@ -259,3 +259,43 @@ describe("trace path completion and cancellation", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, "", "0x1000"] as const)(
|
||||
"validates the observed call-graph root and retains its limitations (%s)",
|
||||
async (address) => {
|
||||
const calls: string[] = [];
|
||||
const tools = new EnhancedTools({
|
||||
execute: async (operation) => {
|
||||
calls.push(operation);
|
||||
return ok(
|
||||
createAnalysisExecution(
|
||||
operation === "procedure_address" ? address : [],
|
||||
provider,
|
||||
{
|
||||
limitations:
|
||||
operation === "procedure_address"
|
||||
? ["Root identity caveat"]
|
||||
: [],
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
const result = await tools.execute("get_call_graph", { address: "main" });
|
||||
if (address === "0x1000") {
|
||||
expect(result).toMatchObject({
|
||||
ok: true,
|
||||
value: {
|
||||
limitations: expect.arrayContaining(["Root identity caveat"]),
|
||||
},
|
||||
});
|
||||
expect(calls).toEqual(["procedure_address", "procedure_callees"]);
|
||||
} else {
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "AnalysisOutputError", operation: "procedure_address" },
|
||||
});
|
||||
expect(calls).toEqual(["procedure_address"]);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
|
||||
import type { NativeCallGraph } from "../domain/native/nativeCallGraph.js";
|
||||
import { nativeProcedureInputSchema } from "../domain/native/nativeProcedureInput.js";
|
||||
import { CALL_RELATIONSHIP_TRAVERSALS } from "./CallRelationshipTraversal.js";
|
||||
@@ -16,7 +17,6 @@ import {
|
||||
} from "../contracts/enhancedInputs.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisOutputError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
@@ -64,7 +64,10 @@ import {
|
||||
import { resolveAnalysisDocument } from "./AnalysisDocument.js";
|
||||
import { traceCallPath } from "./CallPathTracing.js";
|
||||
import { resolveXrefsName } from "./XrefsAddressResolution.js";
|
||||
import { resolveProcedureAddress } from "./ProcedureAddressResolution.js";
|
||||
import {
|
||||
parseProcedureAddress,
|
||||
resolveProcedureAddress,
|
||||
} from "./ProcedureAddressResolution.js";
|
||||
import { traceLiteralFeature } from "./EnhancedLiteralTracing.js";
|
||||
import { projectNativeApiInspection } from "./native/NativeApiInspection.js";
|
||||
|
||||
@@ -103,12 +106,6 @@ const isAnalysisCancellation = (
|
||||
error: AnalysisError,
|
||||
): error is AnalysisCancelledError => error._tag === "AnalysisCancelledError";
|
||||
|
||||
const permitsCapabilityFallback = (
|
||||
error: AnalysisError,
|
||||
): error is AnalysisCapabilityUnavailableError =>
|
||||
error instanceof AnalysisCapabilityUnavailableError &&
|
||||
!error.cleanupIncomplete;
|
||||
|
||||
/**
|
||||
* Composes direct provider operations into reverse-engineering tools.
|
||||
* Direct callers use `execute`; adapters that own validation use
|
||||
@@ -578,14 +575,9 @@ export class EnhancedTools {
|
||||
if (!resolved.ok) return resolved;
|
||||
for (const limitation of resolved.value.limitations)
|
||||
limitations.add(limitation);
|
||||
const startAddress = resolved.value.result;
|
||||
if (typeof startAddress !== "string" || startAddress.length === 0)
|
||||
return err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_address",
|
||||
"provider returned no canonical procedure address",
|
||||
),
|
||||
);
|
||||
const parsedAddress = parseProcedureAddress(resolved.value.result);
|
||||
if (!parsedAddress.ok) return parsedAddress;
|
||||
const startAddress = parsedAddress.value;
|
||||
const discovered = new Set([startAddress]);
|
||||
const queue: Array<{ address: string; depth: number }> = [
|
||||
{ address: startAddress, depth: 0 },
|
||||
|
||||
@@ -5,7 +5,10 @@ import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutput,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
import { inspectLinuxHopperPythonRuntime } from "./LinuxHopperPython.js";
|
||||
|
||||
const response = (value: unknown) => {
|
||||
@@ -128,6 +131,50 @@ describe("distribution Python runtime inspection", () => {
|
||||
});
|
||||
|
||||
describe("distribution Python process cleanup", () => {
|
||||
it("retains output failure when cancellation arrives during command cleanup", async () => {
|
||||
const directory = await createTestTempDirectory("rea-python-failure-stop-");
|
||||
const stopping = join(directory, "stopping");
|
||||
const release = join(directory, "release");
|
||||
const controller = new AbortController();
|
||||
const program = `
|
||||
const fs = require('node:fs');
|
||||
process.on('SIGTERM', () => {
|
||||
fs.writeFileSync(${JSON.stringify(stopping)}, 'stopping');
|
||||
const timer = setInterval(() => {
|
||||
if (fs.existsSync(${JSON.stringify(release)})) {
|
||||
clearInterval(timer);
|
||||
process.exit(0);
|
||||
}
|
||||
}, 10);
|
||||
});
|
||||
process.stdout.write(Buffer.alloc(128 * 1024, 120));
|
||||
setInterval(() => {}, 1000);
|
||||
`;
|
||||
const execution = inspectLinuxHopperPythonRuntime(
|
||||
process.env,
|
||||
controller.signal,
|
||||
(_command, _arguments, options) =>
|
||||
execFileOutput(process.execPath, ["-e", program], options),
|
||||
);
|
||||
try {
|
||||
await vi.waitFor(() => access(stopping), {
|
||||
timeout: 5_000,
|
||||
interval: 10,
|
||||
});
|
||||
controller.abort({ request: "cancel during failed command cleanup" });
|
||||
} finally {
|
||||
await writeFile(release, "release");
|
||||
await execution;
|
||||
}
|
||||
expect(await execution).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
reason: "query_failed",
|
||||
cause: { code: "ERR_CHILD_PROCESS_STDIO_MAXBUFFER" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform !== "linux" || !existsSync("/usr/bin/python3"))(
|
||||
"waits for a delayed Python exit before completing cancellation",
|
||||
async () => {
|
||||
@@ -194,6 +241,22 @@ while True:
|
||||
});
|
||||
|
||||
describe("distribution Python isolation and cancellation", () => {
|
||||
it("uses the producer's cancellation reason without requiring an aborted caller", async () => {
|
||||
const reason = { producer: "interrupted command" };
|
||||
const result = await inspectLinuxHopperPythonRuntime(
|
||||
{},
|
||||
undefined,
|
||||
async () => {
|
||||
throw new ExecFileOutputCancelledError(reason);
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { reason: "cancelled", cause: reason },
|
||||
});
|
||||
if (!result.ok) expect(result.error.cause).toBe(reason);
|
||||
});
|
||||
|
||||
it("preserves cancellation and starts no interpreter for an already cancelled query", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("caller cancellation");
|
||||
|
||||
@@ -3,7 +3,10 @@ import { posix } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import {
|
||||
ExecFileOutputCancelledError,
|
||||
execFileOutput,
|
||||
} from "../process/ExecFileOutput.js";
|
||||
|
||||
const libraryPath = z
|
||||
.string()
|
||||
@@ -76,15 +79,15 @@ export const inspectLinuxHopperPythonRuntime = async (
|
||||
signal?: AbortSignal,
|
||||
execute: typeof execFileOutput = execFileOutput,
|
||||
): Promise<Result<LinuxHopperPythonRuntime, LinuxHopperPythonError>> => {
|
||||
const cancelled = () =>
|
||||
const cancelled = (reason: unknown) =>
|
||||
err(
|
||||
new LinuxHopperPythonError(
|
||||
"cancelled",
|
||||
"Python runtime inspection was cancelled.",
|
||||
signal?.reason,
|
||||
reason,
|
||||
),
|
||||
);
|
||||
if (isAborted(signal)) return cancelled();
|
||||
if (isAborted(signal)) return cancelled(signal?.reason);
|
||||
let output: string;
|
||||
try {
|
||||
const result = await execute(
|
||||
@@ -100,7 +103,8 @@ export const inspectLinuxHopperPythonRuntime = async (
|
||||
);
|
||||
output = result.stdout;
|
||||
} catch (cause: unknown) {
|
||||
if (isAborted(signal)) return cancelled();
|
||||
if (cause instanceof ExecFileOutputCancelledError)
|
||||
return cancelled(cause.cause);
|
||||
return err(
|
||||
new LinuxHopperPythonError(
|
||||
"query_failed",
|
||||
@@ -109,7 +113,7 @@ export const inspectLinuxHopperPythonRuntime = async (
|
||||
),
|
||||
);
|
||||
}
|
||||
if (isAborted(signal)) return cancelled();
|
||||
if (isAborted(signal)) return cancelled(signal?.reason);
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(output);
|
||||
|
||||
@@ -18,12 +18,18 @@ export const resolveProcedureAddress = async (
|
||||
): Promise<Result<string, AnalysisError>> => {
|
||||
const result = await call("procedure_address", { procedure }, signal);
|
||||
if (!result.ok) return result;
|
||||
return typeof result.value === "string" && result.value.length > 0
|
||||
? ok(result.value)
|
||||
return parseProcedureAddress(result.value);
|
||||
};
|
||||
|
||||
/** Validate the provider-owned canonical identity without changing its source value. */
|
||||
export const parseProcedureAddress = (
|
||||
value: JsonValue,
|
||||
): Result<string, AnalysisError> =>
|
||||
typeof value === "string" && value.length > 0
|
||||
? ok(value)
|
||||
: err(
|
||||
new AnalysisOutputError(
|
||||
"procedure_address",
|
||||
"provider returned no canonical procedure address",
|
||||
),
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { permitsCapabilityFallback } from "./CapabilityFallback.js";
|
||||
import { addressedValueSchema } from "../domain/native/nativeInventory.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import {
|
||||
AnalysisCapabilityUnavailableError,
|
||||
AnalysisCancelledError,
|
||||
AnalysisInputError,
|
||||
AnalysisOutputError,
|
||||
@@ -48,9 +48,8 @@ export const resolveXrefsName = async (
|
||||
if (!inventory.ok) {
|
||||
if (
|
||||
operation === "xrefs" &&
|
||||
inventory.error instanceof AnalysisCapabilityUnavailableError &&
|
||||
inventory.error.operation === "list_names" &&
|
||||
!inventory.error.cleanupIncomplete
|
||||
permitsCapabilityFallback(inventory.error) &&
|
||||
inventory.error.operation === "list_names"
|
||||
)
|
||||
return resolveProcedureAddress(
|
||||
async (operation, parameters, signal) => {
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { scenarioCaptureEvidence } from "../../../tests/fixtures/browserScenarioCapture.js";
|
||||
import {
|
||||
createImmutableEvidenceBundle,
|
||||
parseEvidenceBundle,
|
||||
serializeEvidenceBundle,
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { EvidenceLedger } from "../investigation/EvidenceLedger.js";
|
||||
import {
|
||||
inspectAnalysisView,
|
||||
summarizeRetainedAnalysis,
|
||||
} from "./AnalysisViewService.js";
|
||||
|
||||
describe("analysis view execution environment provenance", () => {
|
||||
it("preserves controlled replay environment through projection, retention, and portable export", async () => {
|
||||
const parent = scenarioCaptureEvidence();
|
||||
expect(parent.environment).not.toBeNull();
|
||||
const ledger = new EvidenceLedger();
|
||||
const summary = summarizeRetainedAnalysis(parent, {
|
||||
recordEvidence: (evidence) => ledger.record(evidence),
|
||||
evidenceById: (id) => ledger.forAnalysis(id),
|
||||
});
|
||||
if (!summary.ok) throw summary.error;
|
||||
const manifest = await inspectAnalysisView(
|
||||
{
|
||||
source: { kind: "retained-evidence", evidence_id: parent.evidence_id },
|
||||
view: { kind: "environment-manifest" },
|
||||
},
|
||||
(id) => ledger.forAnalysis(id),
|
||||
);
|
||||
if (!manifest.ok) throw manifest.error;
|
||||
for (const view of [summary.value, manifest.value]) {
|
||||
expect(view.environment).toEqual(parent.environment);
|
||||
expect(view.authority).toBe("controlled-replay");
|
||||
expect(view.evidence_links).toEqual([parent.evidence_id]);
|
||||
expect(ledger.record(view).ok).toBe(true);
|
||||
}
|
||||
const bundle = parseEvidenceBundle(
|
||||
JSON.parse(
|
||||
serializeEvidenceBundle(
|
||||
createImmutableEvidenceBundle([
|
||||
parent,
|
||||
summary.value,
|
||||
manifest.value,
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(bundle.environments).toEqual([parent.environment]);
|
||||
for (const record of bundle.records)
|
||||
expect(record.environment).toEqual(parent.environment);
|
||||
});
|
||||
|
||||
it("keeps an unknown source environment unknown", async () => {
|
||||
const { analysisViewCaptureEvidence } =
|
||||
await import("../../../tests/fixtures/analysisView.js");
|
||||
const parent = analysisViewCaptureEvidence();
|
||||
expect(parent.environment).toBeNull();
|
||||
const projected = await inspectAnalysisView({
|
||||
source: { kind: "inline", evidence: parent },
|
||||
view: { kind: "summary" },
|
||||
});
|
||||
if (!projected.ok) throw projected.error;
|
||||
expect(projected.value.environment).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -400,6 +400,7 @@ const createAnalysisViewEvidence = (
|
||||
? "inferred"
|
||||
: "derived",
|
||||
authority: parent.authority,
|
||||
environment: parent.environment,
|
||||
limitations: result.limitations,
|
||||
...((nativeFunctionView !== undefined || nativeObservationView) &&
|
||||
parent.relationship_coverage !== null
|
||||
|
||||
@@ -1132,6 +1132,34 @@ it("joins cancelled readiness finalization and shutdown before admitting another
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["alpha", "beta"])(
|
||||
"retains unknown owned-resource cleanup when %s has no completion signal",
|
||||
async (unknownOwner) => {
|
||||
const unknown = new ProviderAdapterError(unknownOwner, "close_binary", {
|
||||
userMessage: "cleanup completion unknown",
|
||||
});
|
||||
const tracked = new ProviderCleanupError("rea", ["owned-reader"], {});
|
||||
let released = false;
|
||||
const registry = new AnalysisProviderRegistry(
|
||||
["alpha", "beta"].map((id) => ({
|
||||
...candidate(id).provider,
|
||||
closeOwnedResources: async () => {
|
||||
if (!released) return err(id === unknownOwner ? unknown : tracked);
|
||||
if (id !== unknownOwner) tracked.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
})),
|
||||
);
|
||||
const closed = await registry.closeOwnedResources();
|
||||
if (closed.ok) throw new Error("Expected incomplete registry close");
|
||||
expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
released = true;
|
||||
expect(await registry.closeOwnedResources()).toEqual(ok(null));
|
||||
expect(tracked.cleanupIncomplete).toBe(false);
|
||||
expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it("joins every candidate and retains physical failure with exact mixed cleanup owners", async () => {
|
||||
const alpha = candidate("alpha").provider;
|
||||
const beta = candidate("beta").provider;
|
||||
|
||||
@@ -252,16 +252,15 @@ export class AnalysisProviderRegistry {
|
||||
const distinct = [...new Set(failures)];
|
||||
if (distinct.length === 0) return ok(null);
|
||||
if (distinct.length === 1) return err(distinct[0]!);
|
||||
return err(
|
||||
new ProviderCleanupError(
|
||||
"rea",
|
||||
[...new Set(distinct.flatMap((failure) => failure.cleanupResources))],
|
||||
{ reason: distinct.map((failure) => failure.message).join("; ") },
|
||||
{ cause: new AggregateError(distinct) },
|
||||
).bindCleanupDependencies(
|
||||
distinct.filter((failure) => failure.cleanupIncomplete),
|
||||
),
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea",
|
||||
[...new Set(distinct.flatMap((failure) => failure.cleanupResources))],
|
||||
{ reason: distinct.map((failure) => failure.message).join("; ") },
|
||||
{ cause: new AggregateError(distinct) },
|
||||
);
|
||||
if (distinct.every((failure) => failure.cleanupIncomplete))
|
||||
failure.bindCleanupDependencies(distinct);
|
||||
return err(failure);
|
||||
}
|
||||
|
||||
/** List the unique deep-operation declarations without probing candidates. */
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
ANALYSIS_SNAPSHOT_TARGET,
|
||||
} from "../../domain/analysisSnapshot.fixture.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { createEvidenceBundle } from "../../domain/evidenceBundle.js";
|
||||
import {
|
||||
createEvidenceBundle,
|
||||
evidenceBundleForTarget,
|
||||
} from "../../domain/evidenceBundle.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import {
|
||||
@@ -291,6 +294,90 @@ it.each(["provider", "workflow"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["provider", "workflow"] as const)(
|
||||
"exports target-scoped %s rows without changing retained observations",
|
||||
(kind) => {
|
||||
const cache = new AnalysisSnapshotCache();
|
||||
const ledger = new EvidenceLedger();
|
||||
const target = ANALYSIS_SNAPSHOT_TARGET;
|
||||
const profile = ANALYSIS_SNAPSHOT_PROFILE;
|
||||
const operation = kind === "provider" ? "procedure_info" : "trace_feature";
|
||||
const provider =
|
||||
kind === "provider" ? ANALYSIS_SNAPSHOT_PROVIDER : REA_WORKFLOW_PROVIDER;
|
||||
const analysisProfile =
|
||||
kind === "provider"
|
||||
? profile
|
||||
: workflowAnalysisProfile(profile, operation);
|
||||
const child = { ...target, path: "/tmp/child", sha256: "b".repeat(64) };
|
||||
for (const [index, subject] of [target, null, child].entries()) {
|
||||
const parameters = { address: `0x${index}000` };
|
||||
const result = { observation: index };
|
||||
const execution = createAnalysisExecution(result, provider, {
|
||||
subject,
|
||||
analysisProfile,
|
||||
});
|
||||
const input = {
|
||||
target,
|
||||
profile,
|
||||
operation,
|
||||
parameters,
|
||||
execution: { ...execution, subject, analysisProfile },
|
||||
};
|
||||
if (kind === "provider")
|
||||
cache.record({ ...input, operation: "procedure_info" });
|
||||
else cache.recordWorkflow(input);
|
||||
expect(
|
||||
ledger.record(
|
||||
createEvidence(subject ?? undefined, provider, {
|
||||
operation,
|
||||
parameters,
|
||||
result,
|
||||
analysisProfile,
|
||||
confidence: kind === "provider" ? "observed" : "derived",
|
||||
}),
|
||||
).ok,
|
||||
).toBe(true);
|
||||
}
|
||||
const before =
|
||||
kind === "provider" ? cache.entries() : cache.workflowEntries();
|
||||
expect(before).toHaveLength(3);
|
||||
const evidence = ledger.export();
|
||||
const exported = cache.export(
|
||||
target,
|
||||
profile,
|
||||
evidenceBundleForTarget(evidence, target.sha256),
|
||||
);
|
||||
if (!exported.ok) throw exported.error;
|
||||
const snapshot = parseAnalysisSnapshot(
|
||||
JSON.parse(serializeAnalysisSnapshot(exported.value)),
|
||||
);
|
||||
const saved =
|
||||
kind === "provider" ? snapshot.entries : snapshot.workflow_entries;
|
||||
expect(saved).toHaveLength(1);
|
||||
expect(saved[0]?.execution.subject?.sha256).toBe(target.sha256);
|
||||
expect(snapshot.evidence_bundle.records).toHaveLength(1);
|
||||
expect(
|
||||
kind === "provider" ? cache.entries() : cache.workflowEntries(),
|
||||
).toEqual(before);
|
||||
expect(ledger.export()).toEqual(evidence);
|
||||
const restored = new AnalysisSnapshotCache();
|
||||
expect(restored.stage(snapshot)).toBe(1);
|
||||
expect(
|
||||
kind === "provider" ? restored.entries() : restored.workflowEntries(),
|
||||
).toEqual(saved);
|
||||
if (kind === "provider") {
|
||||
expect(
|
||||
cache.lookup(target, profile, "procedure_info", { address: "0x1000" })
|
||||
?.subject,
|
||||
).toBeNull();
|
||||
expect(
|
||||
cache.lookup(target, profile, "procedure_info", { address: "0x2000" })
|
||||
?.subject?.sha256,
|
||||
).toBe(child.sha256);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("counts newly retained direct and workflow bindings when staging", () => {
|
||||
const source = new AnalysisSnapshotCache();
|
||||
const target = ANALYSIS_SNAPSHOT_TARGET;
|
||||
@@ -629,6 +716,26 @@ describe("analysis snapshot cache policy", () => {
|
||||
effects: { changesPermissions: true },
|
||||
cacheable: false,
|
||||
},
|
||||
{
|
||||
policy: "snapshot",
|
||||
operation: "decode_interface_builder",
|
||||
cacheable: false,
|
||||
},
|
||||
{
|
||||
policy: "snapshot",
|
||||
operation: "inspect_asset_catalog",
|
||||
cacheable: false,
|
||||
},
|
||||
{
|
||||
policy: "snapshot",
|
||||
operation: "inspect_keyed_archive",
|
||||
cacheable: false,
|
||||
},
|
||||
{
|
||||
policy: "snapshot",
|
||||
operation: "trace_dylib_resolution",
|
||||
cacheable: false,
|
||||
},
|
||||
{ policy: "snapshot", operation: "list_documents", cacheable: false },
|
||||
{ policy: "snapshot", operation: "list_strings", cacheable: false },
|
||||
{
|
||||
|
||||
@@ -51,6 +51,15 @@ const RELATIONSHIP_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"procedure_callees",
|
||||
]);
|
||||
|
||||
// These reports depend on selected bundle resources or loader context beyond
|
||||
// the immutable executable identity carried by a binary analysis snapshot.
|
||||
const RESOURCE_SCOPED_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"decode_interface_builder",
|
||||
"inspect_asset_catalog",
|
||||
"inspect_keyed_archive",
|
||||
"trace_dylib_resolution",
|
||||
]);
|
||||
|
||||
const STATEFUL_OPERATIONS: ReadonlySet<AnalysisOperation> = new Set([
|
||||
"health",
|
||||
"current_address",
|
||||
@@ -85,6 +94,7 @@ export const isSnapshotCacheable = (
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
): descriptor is CapabilityDescriptor =>
|
||||
!STATEFUL_OPERATIONS.has(operation) &&
|
||||
!RESOURCE_SCOPED_OPERATIONS.has(operation) &&
|
||||
(!DOCUMENT_SCOPED_OPERATIONS.has(operation) ||
|
||||
typeof parameters.document === "string") &&
|
||||
(!CURSOR_DEFAULT_OPERATIONS.has(operation) ||
|
||||
@@ -291,13 +301,19 @@ export class AnalysisSnapshotCache {
|
||||
try {
|
||||
const snapshotTargetIdentity = snapshotTarget(target);
|
||||
const binding = snapshotBinding(profile);
|
||||
const entries = [...this.#entries.values()].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.query_id, right.query_id),
|
||||
);
|
||||
const workflows = [...this.#workflowEntries.values()].sort(
|
||||
(left, right) =>
|
||||
// In-memory replay can preserve unbound and child-artifact observations.
|
||||
// A persisted target snapshot can authenticate only rows whose Evidence
|
||||
// has that target subject; the record owner applies the same scope to its bundle.
|
||||
const entries = [...this.#entries.values()]
|
||||
.filter((entry) => entry.execution.subject?.sha256 === target.sha256)
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.query_id, right.query_id),
|
||||
);
|
||||
);
|
||||
const workflows = [...this.#workflowEntries.values()]
|
||||
.filter((entry) => entry.execution.subject?.sha256 === target.sha256)
|
||||
.sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.query_id, right.query_id),
|
||||
);
|
||||
// Parsing owns the returned JSON and metadata; pre-cloning the same
|
||||
// payloads here only adds another full materialization.
|
||||
return ok(
|
||||
|
||||
@@ -93,6 +93,18 @@ interface CleanupFailureTracker {
|
||||
readonly onFailure: (failure: AnalysisError) => void;
|
||||
}
|
||||
|
||||
/** Compose live cleanup facts without treating an unowned rejection as completion. */
|
||||
const sessionCleanupFailure = (
|
||||
primary: AnalysisError,
|
||||
cleanup: AnalysisError,
|
||||
operation: string,
|
||||
): AnalysisError => {
|
||||
const failure = analysisErrorWithCleanupFailure(primary, cleanup, operation);
|
||||
return cleanup.cleanupIncomplete
|
||||
? failure.bindCleanupDependencies([primary, cleanup])
|
||||
: failure;
|
||||
};
|
||||
|
||||
const trackCleanupFailures = (
|
||||
signal: AbortSignal | undefined,
|
||||
operation: string,
|
||||
@@ -120,20 +132,16 @@ const trackCleanupFailures = (
|
||||
});
|
||||
cleanupFailure = failure;
|
||||
} else {
|
||||
cleanupFailure = analysisErrorWithCleanupFailure(
|
||||
cleanupFailure,
|
||||
failure,
|
||||
operation,
|
||||
);
|
||||
// Both operands are cleanup observations here. An earlier rejection
|
||||
// without a live lifecycle must not disappear when a later owner resolves.
|
||||
cleanupFailure = cleanupFailure.cleanupIncomplete
|
||||
? sessionCleanupFailure(cleanupFailure, failure, operation)
|
||||
: analysisErrorWithCleanupFailure(cleanupFailure, failure, operation);
|
||||
}
|
||||
error =
|
||||
cancellation === undefined
|
||||
? cleanupFailure
|
||||
: analysisErrorWithCleanupFailure(
|
||||
cancellation,
|
||||
cleanupFailure,
|
||||
operation,
|
||||
);
|
||||
: sessionCleanupFailure(cancellation, cleanupFailure, operation);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -270,7 +278,7 @@ export class BinarySession
|
||||
return err(
|
||||
closed.ok
|
||||
? started.error
|
||||
: analysisErrorWithCleanupFailure(
|
||||
: sessionCleanupFailure(
|
||||
started.error,
|
||||
closed.error,
|
||||
"open_bundle_resources",
|
||||
@@ -286,7 +294,7 @@ export class BinarySession
|
||||
return err(
|
||||
closed.ok
|
||||
? cancelled
|
||||
: analysisErrorWithCleanupFailure(
|
||||
: sessionCleanupFailure(
|
||||
cancelled,
|
||||
closed.error,
|
||||
"open_bundle_resources",
|
||||
@@ -333,7 +341,7 @@ export class BinarySession
|
||||
cleanupFailures.error ??
|
||||
(candidateClosed.ok
|
||||
? cancelled
|
||||
: analysisErrorWithCleanupFailure(
|
||||
: sessionCleanupFailure(
|
||||
cancelled,
|
||||
candidateClosed.error,
|
||||
"open_bundle_resources",
|
||||
@@ -905,7 +913,8 @@ export class BinarySession
|
||||
next,
|
||||
"close_binary",
|
||||
);
|
||||
resourceFailure.bindCleanupDependencies([previous, next]);
|
||||
if (previous.cleanupIncomplete && next.cleanupIncomplete)
|
||||
resourceFailure.bindCleanupDependencies([previous, next]);
|
||||
}
|
||||
if (!closed.ok && resourceFailure !== undefined) {
|
||||
const combined = analysisErrorWithCleanupFailure(
|
||||
@@ -913,7 +922,8 @@ export class BinarySession
|
||||
resourceFailure,
|
||||
"close_binary",
|
||||
);
|
||||
combined.bindCleanupDependencies([closed.error, resourceFailure]);
|
||||
if (closed.error.cleanupIncomplete && resourceFailure.cleanupIncomplete)
|
||||
combined.bindCleanupDependencies([closed.error, resourceFailure]);
|
||||
return err(combined);
|
||||
}
|
||||
if (!closed.ok) return closed;
|
||||
@@ -939,14 +949,20 @@ export class BinarySession
|
||||
);
|
||||
if (!closed.ok) {
|
||||
retained.push(session);
|
||||
const previous = failure;
|
||||
failure =
|
||||
failure === undefined
|
||||
previous === undefined
|
||||
? closed.error
|
||||
: analysisErrorWithCleanupFailure(
|
||||
failure,
|
||||
previous,
|
||||
closed.error,
|
||||
"close_binary",
|
||||
);
|
||||
if (
|
||||
previous?.cleanupIncomplete === true &&
|
||||
closed.error.cleanupIncomplete
|
||||
)
|
||||
failure.bindCleanupDependencies([previous, closed.error]);
|
||||
}
|
||||
}
|
||||
this.#pendingBundleResourceCleanup = retained;
|
||||
@@ -1006,11 +1022,7 @@ export class BinarySession
|
||||
onCleanupFailure === undefined ? {} : { onCleanupFailure },
|
||||
);
|
||||
if (!closed.ok)
|
||||
return analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
closed.error,
|
||||
"open_binary",
|
||||
);
|
||||
return sessionCleanupFailure(primary, closed.error, "open_binary");
|
||||
return this.#restoreAfterFailedOpen(previous, primary);
|
||||
}
|
||||
|
||||
@@ -1021,7 +1033,7 @@ export class BinarySession
|
||||
const restored = await this.#restore(previous);
|
||||
return restored.ok
|
||||
? primary
|
||||
: analysisErrorWithCleanupFailure(primary, restored.error, "open_binary");
|
||||
: sessionCleanupFailure(primary, restored.error, "open_binary");
|
||||
}
|
||||
|
||||
/** Describe the active binary session. */
|
||||
@@ -1212,12 +1224,7 @@ export class BinarySession
|
||||
unboundOperationError: (operation, route) =>
|
||||
this.#providerRouter.unboundOperationError(operation, route),
|
||||
lookupSnapshot: (target, profile, operation, parameters) =>
|
||||
operation === "decode_interface_builder" ||
|
||||
operation === "inspect_asset_catalog" ||
|
||||
operation === "inspect_keyed_archive" ||
|
||||
operation === "trace_dylib_resolution"
|
||||
? undefined
|
||||
: this.lookupSnapshot(target, profile, operation, parameters),
|
||||
this.lookupSnapshot(target, profile, operation, parameters),
|
||||
});
|
||||
if (!prepared.ok) return prepared;
|
||||
const { active, capability, profile, cacheable, cached } = prepared.value;
|
||||
@@ -1263,15 +1270,7 @@ export class BinarySession
|
||||
);
|
||||
if (!sourcesRecorded.ok) return sourcesRecorded;
|
||||
}
|
||||
if (
|
||||
execution.ok &&
|
||||
cacheable &&
|
||||
profile !== undefined &&
|
||||
name !== "decode_interface_builder" &&
|
||||
name !== "inspect_asset_catalog" &&
|
||||
name !== "inspect_keyed_archive" &&
|
||||
name !== "trace_dylib_resolution"
|
||||
) {
|
||||
if (execution.ok && cacheable && profile !== undefined) {
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
name,
|
||||
arguments_,
|
||||
@@ -1462,13 +1461,7 @@ export class BinarySession
|
||||
const closed = await this.#retireClient(run);
|
||||
return closed.ok
|
||||
? ok(null)
|
||||
: err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
started.error,
|
||||
closed.error,
|
||||
"open_binary",
|
||||
),
|
||||
);
|
||||
: err(sessionCleanupFailure(started.error, closed.error, "open_binary"));
|
||||
}
|
||||
|
||||
async #waitForTransition(
|
||||
|
||||
@@ -16,12 +16,12 @@ import { SessionProviderRouter } from "./SessionProviderRouter.js";
|
||||
import { bundleResourceRootSchema } from "../../domain/apple/bundleResourceRoot.js";
|
||||
import { AnalysisSnapshotMismatchError } from "../../domain/evidenceErrors.js";
|
||||
import type { AnalysisSnapshot } from "../../domain/analysisSnapshot.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import { ok } from "../../domain/result.js";
|
||||
import { createTestProviderRouter } from "../../../tests/fixtures/binarySession.js";
|
||||
|
||||
const identity: ProviderIdentity = {
|
||||
@@ -251,90 +251,101 @@ it("cancels a resource switch during prior-client retirement and closes its cand
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
});
|
||||
|
||||
it("keeps cancellation primary when resource retirement fails and retries the owner", async () => {
|
||||
const rootPath = await mkdtemp(
|
||||
join(tmpdir(), "rea-resource-cancel-close-failure-"),
|
||||
);
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
let closeStarted!: () => void;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
closeStarted = resolve;
|
||||
});
|
||||
let releaseClose!: () => void;
|
||||
const closeGate = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
let created = 0;
|
||||
let oldCloseAttempts = 0;
|
||||
let candidateCloseAttempts = 0;
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
new InvestigationRecords(),
|
||||
{
|
||||
createClient: () => {
|
||||
created += 1;
|
||||
const candidate = created > 1;
|
||||
return {
|
||||
client: {
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: createAnalysisExecution(null, identity),
|
||||
}),
|
||||
close: async () => {
|
||||
if (candidate) {
|
||||
candidateCloseAttempts += 1;
|
||||
it.each([false, true])(
|
||||
"keeps cancellation primary through resource retirement retry (live cleanup: %s)",
|
||||
async (liveCleanup) => {
|
||||
const rootPath = await mkdtemp(
|
||||
join(tmpdir(), "rea-resource-cancel-close-failure-"),
|
||||
);
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
let closeStarted!: () => void;
|
||||
const closing = new Promise<void>((resolve) => {
|
||||
closeStarted = resolve;
|
||||
});
|
||||
let releaseClose!: () => void;
|
||||
const closeGate = new Promise<void>((resolve) => {
|
||||
releaseClose = resolve;
|
||||
});
|
||||
let created = 0;
|
||||
let oldCloseAttempts = 0;
|
||||
const cleanupFailure = liveCleanup
|
||||
? new ProviderCleanupError(identity.id, ["previous-reader"], {
|
||||
reason: "previous reader remains open",
|
||||
})
|
||||
: new ProviderAdapterError(identity.id, "close_binary");
|
||||
let candidateCloseAttempts = 0;
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
new InvestigationRecords(),
|
||||
{
|
||||
createClient: () => {
|
||||
created += 1;
|
||||
const candidate = created > 1;
|
||||
return {
|
||||
client: {
|
||||
execute: async () => ({
|
||||
ok: true,
|
||||
value: createAnalysisExecution(null, identity),
|
||||
}),
|
||||
close: async () => {
|
||||
if (candidate) {
|
||||
candidateCloseAttempts += 1;
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
oldCloseAttempts += 1;
|
||||
if (oldCloseAttempts === 1) {
|
||||
closeStarted();
|
||||
await closeGate;
|
||||
}
|
||||
if (oldCloseAttempts < 3) return err(cleanupFailure);
|
||||
if (liveCleanup) cleanupFailure.resolveCleanup();
|
||||
return { ok: true, value: null };
|
||||
}
|
||||
oldCloseAttempts += 1;
|
||||
if (oldCloseAttempts === 1) {
|
||||
closeStarted();
|
||||
await closeGate;
|
||||
}
|
||||
return oldCloseAttempts < 3
|
||||
? err(new ProviderAdapterError(identity.id, "close_binary"))
|
||||
: { ok: true, value: null };
|
||||
},
|
||||
},
|
||||
},
|
||||
capabilities: [],
|
||||
};
|
||||
capabilities: [],
|
||||
};
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
);
|
||||
|
||||
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const reason = { phase: "resource-close-failed" };
|
||||
const opening = session.openBundleResources(rootPath, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await Promise.race([
|
||||
closing,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("resource close did not start")),
|
||||
1_000,
|
||||
expect((await session.openBundleResources(rootPath)).ok).toBe(true);
|
||||
const controller = new AbortController();
|
||||
const reason = { phase: "resource-close-failed" };
|
||||
const opening = session.openBundleResources(rootPath, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
await Promise.race([
|
||||
closing,
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error("resource close did not start")),
|
||||
1_000,
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
controller.abort(reason);
|
||||
releaseClose();
|
||||
]);
|
||||
controller.abort(reason);
|
||||
releaseClose();
|
||||
|
||||
const result = await opening;
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected failed resource switch");
|
||||
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(candidateCloseAttempts).toBe(1);
|
||||
expect(oldCloseAttempts).toBe(2);
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: false,
|
||||
cleanup_pending: true,
|
||||
});
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(oldCloseAttempts).toBe(3);
|
||||
});
|
||||
const result = await opening;
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected failed resource switch");
|
||||
expect(result.error.cleanupPrimary).toBeInstanceOf(AnalysisCancelledError);
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(candidateCloseAttempts).toBe(1);
|
||||
expect(oldCloseAttempts).toBe(2);
|
||||
expect(await session.status()).toMatchObject({
|
||||
open: false,
|
||||
cleanup_pending: true,
|
||||
});
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(oldCloseAttempts).toBe(3);
|
||||
expect(result.error.cleanupIncomplete).toBe(!liveCleanup);
|
||||
if (liveCleanup) expect(result.error.cleanupResources).toEqual([]);
|
||||
expect(result.error.cleanupPrimary?.cause).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps an earlier resource close failure primary when a later cleanup is cancelled", async () => {
|
||||
const rootPath = await mkdtemp(
|
||||
@@ -797,3 +808,292 @@ it("cancels a binary switch during resource retirement and retains null-subject
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(resourceCloseAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("tracks independent pending resource owners after a failed session replacement", async () => {
|
||||
const rootPath = await mkdtemp(
|
||||
join(tmpdir(), "rea-resource-pending-owners-"),
|
||||
);
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
const failures = [
|
||||
new ProviderCleanupError(identity.id, ["previous-reader"], {}),
|
||||
new ProviderCleanupError(identity.id, ["candidate-reader"], {}),
|
||||
];
|
||||
const released = [false, false];
|
||||
let created = 0;
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
new InvestigationRecords(),
|
||||
{
|
||||
createClient: () => {
|
||||
const index = created++;
|
||||
const failure = failures[index];
|
||||
if (failure === undefined)
|
||||
throw new Error("Unexpected resource client");
|
||||
return {
|
||||
capabilities: [],
|
||||
client: {
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close: async () => {
|
||||
if (!released[index]) return err(failure);
|
||||
failure.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await session.openBundleResources(rootPath)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const replacement = await session.openBundleResources(rootPath);
|
||||
if (replacement.ok) throw new Error("Expected failed resource replacement");
|
||||
expect(new Set(replacement.error.cleanupResources)).toEqual(
|
||||
new Set(["previous-reader", "candidate-reader"]),
|
||||
);
|
||||
const first = await session.close();
|
||||
if (first.ok)
|
||||
throw new Error("Expected both resource owners to remain pending");
|
||||
expect(new Set(first.error.cleanupResources)).toEqual(
|
||||
new Set(["previous-reader", "candidate-reader"]),
|
||||
);
|
||||
|
||||
released[0] = true;
|
||||
expect(await session.close()).toMatchObject({ ok: false });
|
||||
expect(first.error.cleanupIncomplete).toBe(true);
|
||||
expect(first.error.cleanupResources).toEqual(["candidate-reader"]);
|
||||
expect(replacement.error.cleanupIncomplete).toBe(true);
|
||||
expect(replacement.error.cleanupResources).toEqual(["candidate-reader"]);
|
||||
|
||||
released[1] = true;
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(first.error.cleanupIncomplete).toBe(false);
|
||||
expect(first.error.cleanupResources).toEqual([]);
|
||||
expect(replacement.error.cleanupIncomplete).toBe(false);
|
||||
expect(replacement.error.cleanupResources).toEqual([]);
|
||||
expect(await session.status()).toMatchObject({ cleanup_pending: false });
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"retains failed-open primary through cleanup retry (live cleanup: %s)",
|
||||
async (liveCleanup) => {
|
||||
const primary = new ProviderAdapterError(binaryIdentity.id, "health", {
|
||||
userMessage: "candidate health failed",
|
||||
});
|
||||
const cleanup = liveCleanup
|
||||
? new ProviderCleanupError(
|
||||
binaryIdentity.id,
|
||||
["candidate-reader"],
|
||||
{},
|
||||
{
|
||||
cleanup: {
|
||||
reason: "candidate reader remains open",
|
||||
resources: ["candidate-reader"],
|
||||
},
|
||||
},
|
||||
)
|
||||
: new ProviderAdapterError(binaryIdentity.id, "close_binary");
|
||||
let attempts = 0;
|
||||
const session = new BinarySession(
|
||||
createTestProviderRouter(() => ({
|
||||
execute: async () => err(primary),
|
||||
close: async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) return err(cleanup);
|
||||
if (liveCleanup) cleanup.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
})),
|
||||
new InvestigationRecords(),
|
||||
);
|
||||
const opened = await session.open(process.execPath);
|
||||
if (opened.ok) throw new Error("Expected failed candidate health");
|
||||
expect(opened.error.cleanupPrimary).toBe(primary);
|
||||
expect(opened.error.cleanupIncomplete).toBe(true);
|
||||
if (liveCleanup)
|
||||
expect(opened.error.cleanup?.reason).toBe(
|
||||
"candidate reader remains open",
|
||||
);
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(attempts).toBe(2);
|
||||
expect(opened.error.cleanupIncomplete).toBe(!liveCleanup);
|
||||
if (liveCleanup) expect(opened.error.cleanupResources).toEqual([]);
|
||||
expect(opened.error.cleanupPrimary).toBe(primary);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([0, 1])(
|
||||
"retains unknown cleanup completion alongside a retryable owner (unknown owner: %s)",
|
||||
async (unknownOwner) => {
|
||||
const rootPath = await mkdtemp(
|
||||
join(tmpdir(), "rea-resource-mixed-cleanup-"),
|
||||
);
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
const ownedFailure = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["owned-reader"],
|
||||
{},
|
||||
);
|
||||
const unknownFailure = new ProviderAdapterError(
|
||||
identity.id,
|
||||
"close_binary",
|
||||
{
|
||||
userMessage: "cleanup completion unknown",
|
||||
},
|
||||
);
|
||||
let created = 0;
|
||||
let released = false;
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
new InvestigationRecords(),
|
||||
{
|
||||
createClient: () => {
|
||||
const owner = created++;
|
||||
return {
|
||||
capabilities: [],
|
||||
client: {
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close: async () => {
|
||||
if (!released)
|
||||
return err(
|
||||
owner === unknownOwner ? unknownFailure : ownedFailure,
|
||||
);
|
||||
if (owner !== unknownOwner) ownedFailure.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(await session.openBundleResources(rootPath)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
const replacement = await session.openBundleResources(rootPath);
|
||||
if (replacement.ok) throw new Error("Expected failed resource replacement");
|
||||
expect(replacement.error.cleanupIncomplete).toBe(true);
|
||||
released = true;
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(ownedFailure.cleanupIncomplete).toBe(false);
|
||||
expect(replacement.error.cleanupIncomplete).toBe(true);
|
||||
expect(await session.status()).toMatchObject({ cleanup_pending: false });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["binary", "client"],
|
||||
["binary", "files"],
|
||||
["bundle", "client"],
|
||||
["bundle", "files"],
|
||||
] as const)(
|
||||
"keeps unknown %s close completion when the %s owner has no cleanup signal",
|
||||
async (targetKind, unknownOwner) => {
|
||||
const tracked = new ProviderCleanupError(identity.id, ["owned-reader"], {});
|
||||
const unknown = new ProviderAdapterError(identity.id, "close_binary", {
|
||||
userMessage: "cleanup completion unknown",
|
||||
});
|
||||
let released = false;
|
||||
let failCleanup = false;
|
||||
const closeOwner = async (owner: "client" | "files") => {
|
||||
if (!failCleanup) return ok(null);
|
||||
if (!released) return err(owner === unknownOwner ? unknown : tracked);
|
||||
if (owner !== unknownOwner) tracked.resolveCleanup();
|
||||
return ok(null);
|
||||
};
|
||||
const client: AnalysisClient = {
|
||||
execute: async () => ok(createAnalysisExecution(null, binaryIdentity)),
|
||||
close: () => closeOwner("client"),
|
||||
};
|
||||
// Exercise the public session close composition with independently owned
|
||||
// file cleanup; the file owner's failure shape is the regression boundary.
|
||||
class SessionWithFileCleanup extends BinarySession {
|
||||
override closeFileResources() {
|
||||
return closeOwner("files");
|
||||
}
|
||||
}
|
||||
const session = new SessionWithFileCleanup(
|
||||
createTestProviderRouter(() => client),
|
||||
new InvestigationRecords(),
|
||||
{ createClient: () => ({ client, capabilities: [] }) },
|
||||
);
|
||||
if (targetKind === "binary") {
|
||||
expect(await session.open(process.execPath)).toMatchObject({ ok: true });
|
||||
} else {
|
||||
const rootPath = await mkdtemp(join(tmpdir(), "rea-resource-close-"));
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
expect(await session.openBundleResources(rootPath)).toMatchObject({
|
||||
ok: true,
|
||||
});
|
||||
}
|
||||
failCleanup = true;
|
||||
const closed = await session.close();
|
||||
if (closed.ok) throw new Error("Expected incomplete close");
|
||||
expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
released = true;
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(tracked.cleanupIncomplete).toBe(false);
|
||||
expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
expect(await session.status()).toMatchObject({ cleanup_pending: false });
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["health", "cancellation"] as const)(
|
||||
"updates early resource-open %s cleanup after its candidate retries",
|
||||
async (failureStage) => {
|
||||
const rootPath = await mkdtemp(
|
||||
join(tmpdir(), "rea-resource-early-cleanup-"),
|
||||
);
|
||||
temporaryDirectories.push(rootPath);
|
||||
await writeFile(join(rootPath, "Info.plist"), "<plist><dict/></plist>");
|
||||
const primary = new ProviderAdapterError(identity.id, "health");
|
||||
const cleanup = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["candidate-reader"],
|
||||
{},
|
||||
);
|
||||
const controller = new AbortController();
|
||||
const reason = { stage: "candidate-health" };
|
||||
let closeAttempts = 0;
|
||||
const session = new BinarySession(
|
||||
SessionProviderRouter.selectable(new AnalysisProviderRegistry([]), []),
|
||||
new InvestigationRecords(),
|
||||
{
|
||||
createClient: () => ({
|
||||
capabilities: [],
|
||||
client: {
|
||||
execute: async () => {
|
||||
if (failureStage === "health") return err(primary);
|
||||
controller.abort(reason);
|
||||
return ok(createAnalysisExecution(null, identity));
|
||||
},
|
||||
close: async () => {
|
||||
if (++closeAttempts === 1) return err(cleanup);
|
||||
cleanup.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
);
|
||||
const opened = await session.openBundleResources(rootPath, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (opened.ok) throw new Error("Expected early resource open failure");
|
||||
expect(opened.error.cleanupIncomplete).toBe(true);
|
||||
if (failureStage === "health")
|
||||
expect(opened.error.cleanupPrimary).toBe(primary);
|
||||
else {
|
||||
expect(opened.error.cleanupPrimary).toBeInstanceOf(
|
||||
AnalysisCancelledError,
|
||||
);
|
||||
expect(opened.error.cleanupPrimary?.cause).toBe(reason);
|
||||
}
|
||||
expect(await session.close()).toMatchObject({ ok: true });
|
||||
expect(closeAttempts).toBe(2);
|
||||
expect(opened.error.cleanupIncomplete).toBe(false);
|
||||
expect(opened.error.cleanupResources).toEqual([]);
|
||||
},
|
||||
);
|
||||
@@ -73,6 +73,81 @@ it("preserves an explicitly unbound provider observation", async () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps unbound observations out of persisted target snapshots", async () => {
|
||||
const { session, deep } = openSession();
|
||||
const directory = await createTestTempDirectory("rea-unbound-snapshot-");
|
||||
const path = join(directory, "snapshot.json");
|
||||
const parameters = { document: "0", address: "0x1000" };
|
||||
try {
|
||||
expect((await session.open(process.execPath)).ok).toBe(true);
|
||||
const bound = await session.execute("address_name", parameters);
|
||||
expect(bound.ok).toBe(true);
|
||||
const child = deep.created[0];
|
||||
if (child === undefined || !bound.ok)
|
||||
throw new Error("Expected bound read");
|
||||
child.execute = () =>
|
||||
Promise.resolve(
|
||||
ok(
|
||||
createAnalysisExecution("unbound observation", deepIdentity, {
|
||||
subject: null,
|
||||
}),
|
||||
),
|
||||
);
|
||||
const execution = await session.execute("address_name", {
|
||||
...parameters,
|
||||
address: "0x2000",
|
||||
});
|
||||
expect(execution.ok).toBe(true);
|
||||
if (!execution.ok) return;
|
||||
expect(execution.value.subject).toBeNull();
|
||||
expect(session.exportEvidenceBundle().records).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
subject: null,
|
||||
normalized_result: "unbound observation",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const snapshot = session.exportAnalysisSnapshot();
|
||||
expect(snapshot.ok, snapshot.ok ? undefined : snapshot.error.message).toBe(
|
||||
true,
|
||||
);
|
||||
if (!snapshot.ok) return;
|
||||
expect(snapshot.value.entries).toHaveLength(1);
|
||||
expect(snapshot.value.entries[0]?.parameters).toEqual(parameters);
|
||||
const unboundReplay = await session.execute("address_name", {
|
||||
...parameters,
|
||||
address: "0x2000",
|
||||
});
|
||||
expect(unboundReplay.ok).toBe(true);
|
||||
if (unboundReplay.ok) {
|
||||
expect(unboundReplay.value.subject).toBeNull();
|
||||
expect(unboundReplay.value.evidenceId).toBe(execution.value.evidenceId);
|
||||
}
|
||||
expect(
|
||||
(await session.writeAnalysisSnapshot(snapshot.value, path, false)).ok,
|
||||
).toBe(true);
|
||||
expect((await session.close()).ok).toBe(true);
|
||||
expect(
|
||||
(
|
||||
await session.open(process.execPath, {
|
||||
loadSnapshot: () => session.readAnalysisSnapshot(path),
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
const replay = await session.execute("address_name", parameters);
|
||||
expect(replay.ok).toBe(true);
|
||||
if (replay.ok) {
|
||||
expect(replay.value.evidenceId).toBe(bound.value.evidenceId);
|
||||
expect(replay.value.limitations).toContain(
|
||||
"Loaded from a local REA analysis snapshot; this call did not re-run the provider.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
});
|
||||
|
||||
describe("BinarySession open liveness", () => {
|
||||
it("retires a locally blocked owner even when its native health is idle", async () => {
|
||||
const { session, deep } = openSession();
|
||||
|
||||
@@ -6,8 +6,9 @@ import {
|
||||
type AnalysisClient,
|
||||
type ProviderIdentity,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { AnalysisInputError } from "../../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
|
||||
const identity: ProviderIdentity = {
|
||||
id: "bundle-resource-close-fixture",
|
||||
@@ -104,3 +105,67 @@ it("reports the first mixed child failure before awaiting the other owner and re
|
||||
expect(artifactCloseAttempts).toBe(2);
|
||||
expect(nativeCloseAttempts).toBe(2);
|
||||
});
|
||||
|
||||
it("updates retained cleanup facts as the two child owners release independently", async () => {
|
||||
const artifactFailure = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["artifact-reader"],
|
||||
{},
|
||||
);
|
||||
const nativeFailure = new ProviderCleanupError(
|
||||
identity.id,
|
||||
["native-reader"],
|
||||
{},
|
||||
);
|
||||
let releaseArtifact = false;
|
||||
let releaseNative = false;
|
||||
const client = (
|
||||
failure: ProviderCleanupError,
|
||||
released: () => boolean,
|
||||
): AnalysisClient => ({
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close: async () => {
|
||||
if (!released()) return err(failure);
|
||||
failure.resolveCleanup();
|
||||
return ok(null);
|
||||
},
|
||||
});
|
||||
const artifact = client(artifactFailure, () => releaseArtifact);
|
||||
const native = client(nativeFailure, () => releaseNative);
|
||||
const combined = combineBundleResourceClients(artifact, native);
|
||||
const first = await combined.close();
|
||||
if (first.ok) throw new Error("Expected both child owners to remain open");
|
||||
expect(new Set(first.error.cleanupResources)).toEqual(
|
||||
new Set(["artifact-reader", "native-reader"]),
|
||||
);
|
||||
|
||||
releaseArtifact = true;
|
||||
expect(await artifact.close()).toMatchObject({ ok: true });
|
||||
expect(first.error.cleanupIncomplete).toBe(true);
|
||||
expect(first.error.cleanupResources).toEqual(["native-reader"]);
|
||||
|
||||
releaseNative = true;
|
||||
expect(await combined.close()).toMatchObject({ ok: true });
|
||||
expect(first.error.cleanupIncomplete).toBe(false);
|
||||
expect(first.error.cleanupResources).toEqual([]);
|
||||
expect(new Set(first.error.cleanup?.resources)).toEqual(
|
||||
new Set(["artifact-reader", "native-reader"]),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps cleanup unknown when a child close failure has no cleanup lifecycle", async () => {
|
||||
const owned = new ProviderCleanupError(identity.id, ["artifact-reader"], {});
|
||||
const client = (
|
||||
failure: AnalysisInputError | ProviderCleanupError,
|
||||
): AnalysisClient => ({
|
||||
execute: async () => ok(createAnalysisExecution(null, identity)),
|
||||
close: async () => err(failure),
|
||||
});
|
||||
const closed = await combineBundleResourceClients(
|
||||
client(owned),
|
||||
client(new AnalysisInputError("close_binary")),
|
||||
).close();
|
||||
if (closed.ok) throw new Error("Expected child close failures");
|
||||
owned.resolveCleanup();
|
||||
expect(closed.error.cleanupIncomplete).toBe(true);
|
||||
});
|
||||
@@ -70,14 +70,16 @@ export const combineBundleResourceClients = (
|
||||
closeAnalysisClient(artifactClient, "bundle-resources", options),
|
||||
closeAnalysisClient(nativeClient, "bundle-resources", options),
|
||||
]);
|
||||
if (!artifact.ok && !native.ok)
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
artifact.error,
|
||||
native.error,
|
||||
"close_binary",
|
||||
),
|
||||
if (!artifact.ok && !native.ok) {
|
||||
const failure = analysisErrorWithCleanupFailure(
|
||||
artifact.error,
|
||||
native.error,
|
||||
"close_binary",
|
||||
);
|
||||
if (artifact.error.cleanupIncomplete && native.error.cleanupIncomplete)
|
||||
failure.bindCleanupDependencies([artifact.error, native.error]);
|
||||
return err(failure);
|
||||
}
|
||||
if (!artifact.ok) return artifact;
|
||||
if (!native.ok) return native;
|
||||
return ok(null);
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type AnalysisOperation,
|
||||
type AnalysisOperationPort,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import { EnhancedTools } from "../EnhancedTools.js";
|
||||
import { DecompileExport } from "./exportTree.js";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
decompileJournalLineSchema,
|
||||
} from "../../contracts/decompileDirectory.js";
|
||||
import { createAnalysisProfile } from "../../domain/analysisProfile.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import {
|
||||
AnalysisCancelledError,
|
||||
AnalysisCapabilityUnavailableError,
|
||||
@@ -1704,3 +1706,163 @@ describe("decompile_all repeated output failures", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("decompile_all provider cleanup ownership", () => {
|
||||
const directory = useOutputDirectory();
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it.each([
|
||||
["procedure_info", false],
|
||||
["procedure_info", true],
|
||||
["procedure_pseudo_code", false],
|
||||
["procedure_pseudo_code", true],
|
||||
] as const)(
|
||||
"retains %s cleanup and committed work with caller aborted=%s",
|
||||
async (operation, abort) => {
|
||||
const { port } = harness();
|
||||
const controller = new AbortController();
|
||||
const cause = new Error("provider cleanup stopped");
|
||||
const callerReason = new Error("caller stopped");
|
||||
const partial = createEvidence(subject, provider, {
|
||||
operation,
|
||||
parameters: { procedure: "0x2000" },
|
||||
result:
|
||||
operation === "procedure_info"
|
||||
? infoFor("0x2000")
|
||||
: "int partial(void) {}",
|
||||
analysisProfile: profile,
|
||||
confidence: "observed",
|
||||
});
|
||||
const failure = new AnalysisCancelledError(operation, {
|
||||
cause,
|
||||
partialObservation: partial,
|
||||
capturedOutput: {
|
||||
stdout: "retained provider output",
|
||||
stderr: "",
|
||||
truncated: false,
|
||||
},
|
||||
cleanup: {
|
||||
reason: "descriptor close denied",
|
||||
resources: ["provider descriptor"],
|
||||
},
|
||||
});
|
||||
const lease = new AnalysisCleanupLease(
|
||||
async () => undefined,
|
||||
["provider descriptor"],
|
||||
);
|
||||
lease.bindCleanupError(failure);
|
||||
const observed: AnalysisOperation[] = [];
|
||||
const failing: AnalysisOperationPort = {
|
||||
execute: (selected, parameters, options) => {
|
||||
observed.push(selected);
|
||||
if (selected === operation && parameters.procedure === "0x2000") {
|
||||
if (abort) controller.abort(callerReason);
|
||||
return Promise.resolve(err(failure));
|
||||
}
|
||||
return port.execute(selected, parameters, options);
|
||||
},
|
||||
};
|
||||
const result = await run(
|
||||
new EnhancedTools(failing),
|
||||
directory(),
|
||||
{},
|
||||
controller.signal,
|
||||
);
|
||||
expect(result).toEqual(err(failure));
|
||||
expect(failure.cause).toBe(cause);
|
||||
if (abort) {
|
||||
expect(failure.secondaryFailures).toHaveLength(1);
|
||||
expect(failure.secondaryFailures[0]).toBeInstanceOf(
|
||||
AnalysisCancelledError,
|
||||
);
|
||||
expect(failure.secondaryFailures[0]?.cause).toBe(callerReason);
|
||||
} else expect(failure.secondaryFailures).toHaveLength(0);
|
||||
expect(failure.partialObservation).toBe(partial);
|
||||
expect(failure.capturedOutput?.stdout).toBe("retained provider output");
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
expect(observed.at(-1)).toBe(operation);
|
||||
const index = decompileDirectoryIndexSchema.parse(
|
||||
JSON.parse(
|
||||
await readFile(
|
||||
join(directory(), DECOMPILE_DIRECTORY_INDEX_NAME),
|
||||
"utf8",
|
||||
),
|
||||
),
|
||||
);
|
||||
expect(index.functions).toHaveLength(1);
|
||||
expect(index.functions[0]).toMatchObject({
|
||||
address: "0x1000",
|
||||
status: "written",
|
||||
});
|
||||
await expect(
|
||||
readFile(join(directory(), "functions", "0x1000.c"), "utf8"),
|
||||
).resolves.toContain(sentinel);
|
||||
await lease.release();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("decompile_all mixed cleanup ownership", () => {
|
||||
const directory = useOutputDirectory();
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("waits for both provider and journal cleanup owners after mixed finalization failures", async () => {
|
||||
const { port } = harness();
|
||||
const providerFailure = new AnalysisOutputError(
|
||||
"procedure_info",
|
||||
"provider cleanup failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "provider close denied",
|
||||
resources: ["provider descriptor"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const closeFailure = new AnalysisOutputError(
|
||||
"decompile_all",
|
||||
"journal cleanup failed",
|
||||
{
|
||||
cleanup: {
|
||||
reason: "journal close denied",
|
||||
resources: ["journal descriptor"],
|
||||
},
|
||||
},
|
||||
);
|
||||
const providerLease = new AnalysisCleanupLease(
|
||||
async () => undefined,
|
||||
["provider descriptor"],
|
||||
);
|
||||
const journalLease = new AnalysisCleanupLease(
|
||||
async () => undefined,
|
||||
["journal descriptor"],
|
||||
);
|
||||
providerLease.bindCleanupError(providerFailure);
|
||||
journalLease.bindCleanupError(closeFailure);
|
||||
const close = DecompileExport.prototype.close;
|
||||
vi.spyOn(DecompileExport.prototype, "close").mockImplementation(
|
||||
async function (this: DecompileExport) {
|
||||
await close.call(this);
|
||||
return err(closeFailure);
|
||||
},
|
||||
);
|
||||
const failing: AnalysisOperationPort = {
|
||||
execute: (operation, parameters, options) =>
|
||||
operation === "procedure_info"
|
||||
? Promise.resolve(err(providerFailure))
|
||||
: port.execute(operation, parameters, options),
|
||||
};
|
||||
const result = await run(new EnhancedTools(failing), directory());
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error.cleanupPrimary).toBe(providerFailure);
|
||||
expect(result.error.cleanupResources).toEqual(
|
||||
expect.arrayContaining(["provider descriptor", "journal descriptor"]),
|
||||
);
|
||||
await journalLease.release();
|
||||
expect(closeFailure.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
await providerLease.release();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -173,6 +173,7 @@ export const decompileAll = async (
|
||||
const callerStoppedPublish =
|
||||
!published.ok &&
|
||||
published.error instanceof AnalysisCancelledError &&
|
||||
!published.error.cleanupIncomplete &&
|
||||
isAborted(signal);
|
||||
const failures = [
|
||||
...(!published.ok && !callerStoppedPublish ? [published.error] : []),
|
||||
@@ -181,6 +182,14 @@ export const decompileAll = async (
|
||||
];
|
||||
const primary = failures[0];
|
||||
if (primary !== undefined) {
|
||||
if (
|
||||
isAborted(signal) &&
|
||||
!(
|
||||
primary instanceof AnalysisCancelledError &&
|
||||
primary.cause === signal?.reason
|
||||
)
|
||||
)
|
||||
primary.retainSecondaryFailure(cancelled(signal));
|
||||
for (const failure of failures.slice(1))
|
||||
primary.retainSecondaryFailure(failure);
|
||||
return !closed.ok &&
|
||||
@@ -191,7 +200,7 @@ export const decompileAll = async (
|
||||
primary,
|
||||
closed.error,
|
||||
"decompile_all",
|
||||
).bindCleanupDependencies([closed.error]),
|
||||
).bindCleanupDependencies([primary, closed.error]),
|
||||
)
|
||||
: err(primary);
|
||||
}
|
||||
@@ -465,7 +474,7 @@ const publishOne = async (input: {
|
||||
input.signal,
|
||||
input.bodySupport,
|
||||
);
|
||||
if (body.kind === "cancelled")
|
||||
if (body.kind === "error")
|
||||
return { result: err(body.error), bodySupport: input.bodySupport };
|
||||
if (body.kind === "unavailable") input.state.bodyUnavailable = true;
|
||||
// Caveats of the observations this attempt used. The journal record keeps
|
||||
@@ -489,6 +498,9 @@ const publishOne = async (input: {
|
||||
{ procedure: input.procedure.address },
|
||||
executionOptions(input.signal),
|
||||
);
|
||||
// A provider still owns cleanup even if the caller aborted meanwhile.
|
||||
if (!decompiled.ok && decompiled.error.cleanupIncomplete)
|
||||
return { result: decompiled, bodySupport: body.support };
|
||||
if (isAborted(input.signal))
|
||||
return { result: err(cancelled(input.signal)), bodySupport: body.support };
|
||||
if (!decompiled.ok) {
|
||||
@@ -613,7 +625,7 @@ const readBody = async (
|
||||
readonly fact: FunctionBody;
|
||||
readonly support: "unavailable";
|
||||
}
|
||||
| { readonly kind: "cancelled"; readonly error: AnalysisCancelledError }
|
||||
| { readonly kind: "error"; readonly error: AnalysisError }
|
||||
> => {
|
||||
if (support === "unavailable")
|
||||
return {
|
||||
@@ -627,8 +639,11 @@ const readBody = async (
|
||||
executionOptions(signal),
|
||||
);
|
||||
if (!observed.ok) {
|
||||
if (observed.error instanceof AnalysisCancelledError)
|
||||
return { kind: "cancelled", error: observed.error };
|
||||
if (
|
||||
observed.error.cleanupIncomplete ||
|
||||
observed.error instanceof AnalysisCancelledError
|
||||
)
|
||||
return { kind: "error", error: observed.error };
|
||||
if (observed.error instanceof AnalysisCapabilityUnavailableError)
|
||||
return {
|
||||
kind: "unavailable",
|
||||
|
||||
@@ -130,6 +130,26 @@ describe("matchProbeRequest", () => {
|
||||
expect(item?.args).toEqual({ id: "%31" });
|
||||
});
|
||||
|
||||
it("keeps trailing slashes significant when matching HTTP route templates", () => {
|
||||
const manifest = {
|
||||
...source.manifest,
|
||||
tools: source.manifest.tools.map((tool) => ({
|
||||
...tool,
|
||||
path_template: `${tool.path_template}/`,
|
||||
})),
|
||||
};
|
||||
const request = {
|
||||
method: "GET",
|
||||
path: "/api/books/7/",
|
||||
query: null,
|
||||
body: undefined,
|
||||
};
|
||||
expect(matchProbeRequest(manifest, request)?.args).toEqual({ id: "7" });
|
||||
expect(
|
||||
matchProbeRequest(manifest, { ...request, path: "/api/books/7" }),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps every occurrence of a repeated query value", () => {
|
||||
const item = matchProbeRequest(environmentServeSource().manifest, {
|
||||
method: "GET",
|
||||
@@ -176,6 +196,31 @@ describe("collections under different parents", () => {
|
||||
expect(map.env(110, "/api/orgs/2/users")).toBe(20);
|
||||
});
|
||||
|
||||
it("uses state collection identity for root and trailing-slash routes", () => {
|
||||
const trailing = {
|
||||
...nestedUsers,
|
||||
path_template: `${nestedUsers.path_template}/`,
|
||||
};
|
||||
const segments = ["api", "orgs", "%32", "users", "101", ""];
|
||||
expect(collectionScope(trailing, segments)).toBe("/api/orgs/2/users");
|
||||
expect(parameterScopes(trailing, segments)).toEqual(
|
||||
parameterScopes(nestedUsers, segments),
|
||||
);
|
||||
expect(
|
||||
collectionScope({ ...nestedUsers, path_template: "/", params: [] }),
|
||||
).toBe("/");
|
||||
expect(
|
||||
collectionScope({
|
||||
...nestedUsers,
|
||||
path_template: "/api//books/",
|
||||
params: [],
|
||||
}),
|
||||
).toBe("/api//books");
|
||||
expect(
|
||||
collectionScope(trailing, ["api", "orgs", "a%2Fb", "users", "101", ""]),
|
||||
).toBe("/api/orgs/a%2Fb/users");
|
||||
});
|
||||
|
||||
it("shares a child map when the same parent is spelled with one encoding", () => {
|
||||
const encoded = ["api", "orgs", "%32", "users", "101"];
|
||||
const plain = ["api", "orgs", "2", "users", "101"];
|
||||
@@ -465,6 +510,24 @@ const compareRows = (
|
||||
return report.value.probes[0]!;
|
||||
};
|
||||
|
||||
it("learns ids from trailing-slash lists in the collection their item calls name", () => {
|
||||
const item = (id: number, name: string) => ({ id, name });
|
||||
const probe = compareRows(
|
||||
[
|
||||
["GET", "/api/books", [item(1, "Book")]],
|
||||
["GET", "/api/books/1", item(1, "Book")],
|
||||
["GET", "/api/authors/", [item(2, "Ann")]],
|
||||
["GET", "/api/authors/2/", item(2, "Ann")],
|
||||
],
|
||||
[
|
||||
["GET", "/api/books", [item(101, "Book")]],
|
||||
["GET", "/api/authors/", [item(101, "Ann")]],
|
||||
["GET", "/api/authors/101/", item(101, "Ann")],
|
||||
],
|
||||
);
|
||||
expect(probe.results.map((step) => step.equal)).toEqual([true, true, true]);
|
||||
});
|
||||
|
||||
describe("ids the comparison used to misread", () => {
|
||||
it.each([
|
||||
["", "", true],
|
||||
|
||||
@@ -59,9 +59,7 @@ const collectionAt = (
|
||||
segments?: readonly string[],
|
||||
): string => {
|
||||
const params = new Set(tool.params.map((param) => param.index));
|
||||
return `/${tool.path_template
|
||||
.slice(1)
|
||||
.split("/")
|
||||
return `/${segmentsOf(tool.path_template)
|
||||
.slice(0, end)
|
||||
.map((segment, index) => {
|
||||
if (!params.has(index)) return segment;
|
||||
@@ -78,7 +76,7 @@ export const collectionScope = (
|
||||
tool: EnvironmentManifestTool,
|
||||
segments?: readonly string[],
|
||||
): string => {
|
||||
const length = tool.path_template.slice(1).split("/").length;
|
||||
const length = segmentsOf(tool.path_template).length;
|
||||
return collectionAt(
|
||||
tool,
|
||||
tool.params.some((param) => param.index === length - 1)
|
||||
|
||||
@@ -3,6 +3,8 @@ import { join } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { appendCallerPath } from "../filesystem/callerPath.js";
|
||||
|
||||
/** Standard executable names and locations used to discover Homebrew. */
|
||||
const HOMEBREW_COMMANDS = [
|
||||
"brew",
|
||||
@@ -73,5 +75,7 @@ export const installedCaskAppdir = async (
|
||||
config.data.env?.appdir ??
|
||||
config.data.default?.appdir;
|
||||
if (appdir === undefined) return undefined;
|
||||
return appdir.startsWith("~/") ? join(home, appdir.slice(2)) : appdir;
|
||||
return appdir.startsWith("~/")
|
||||
? appendCallerPath(home, appdir.slice(2))
|
||||
: appdir;
|
||||
};
|
||||
@@ -1,21 +1,25 @@
|
||||
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||
import type { ProviderIdentity } from "../AnalysisProvider.js";
|
||||
import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js";
|
||||
import type { Evidence, EvidenceObservation } from "../../domain/evidence.js";
|
||||
import { createEvidence } from "../../domain/evidence.js";
|
||||
import type { ElectronActiveObservationResult } from "../../domain/javascript/electronActiveObservation.js";
|
||||
import type {
|
||||
ElectronActiveObservationInput,
|
||||
ElectronActiveObservationResult,
|
||||
} from "../../domain/javascript/electronActiveObservation.js";
|
||||
import { jsonObjectSchema } from "../../domain/jsonValue.js";
|
||||
import type { CanonicalElectronActiveObservationInput } from "./ElectronActiveObservationPort.js";
|
||||
|
||||
/** Commit the selected Electron experiment and its observed result. */
|
||||
export const createElectronActiveEvidence = (
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
input: ElectronActiveObservationInput,
|
||||
result: ElectronActiveObservationResult,
|
||||
provider: ProviderIdentity,
|
||||
resolvedInput?: CanonicalElectronActiveObservationInput,
|
||||
): Evidence =>
|
||||
createEvidence(undefined, provider, {
|
||||
predicateType: "rea.electron-active-scenario",
|
||||
operation: "capture_electron_scenario",
|
||||
parameters: parameters(input),
|
||||
parameters: parameters(input, resolvedInput),
|
||||
result,
|
||||
confidence: "observed",
|
||||
authority: "controlled-replay",
|
||||
@@ -32,11 +36,21 @@ export const createElectronActiveEvidence = (
|
||||
});
|
||||
|
||||
const parameters = (
|
||||
input: CanonicalElectronActiveObservationInput,
|
||||
input: ElectronActiveObservationInput,
|
||||
resolvedInput?: CanonicalElectronActiveObservationInput,
|
||||
): EvidenceObservation["parameters"] => {
|
||||
const scenario = jsonObjectSchema.parse(input);
|
||||
return {
|
||||
...scenario,
|
||||
scenario_sha256: digestCanonicalValue(scenario, "Electron scenario"),
|
||||
...(resolvedInput === undefined
|
||||
? {}
|
||||
: {
|
||||
resolved_paths: {
|
||||
executable_path: resolvedInput.executable_path,
|
||||
application_path: resolvedInput.application_path,
|
||||
application_root: resolvedInput.application_root,
|
||||
},
|
||||
}),
|
||||
};
|
||||
};
|
||||
@@ -1,11 +1,13 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import { AnalysisCapabilityUnavailableError } from "../../domain/analysisErrorCore.js";
|
||||
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
|
||||
import { electronActiveObservationInputSchema } from "../../domain/javascript/electronActiveObservation.js";
|
||||
import { err } from "../../domain/result.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
import { digestCanonicalValue } from "../../domain/canonicalDigest.js";
|
||||
import { createElectronActiveObservationFixtureResult } from "../../domain/javascript/electronActiveObservation.fixture.js";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import type { ElectronActiveObservationPort } from "./ElectronActiveObservationPort.js";
|
||||
import { captureElectronScenario } from "./ElectronActiveObservationService.js";
|
||||
@@ -188,3 +190,46 @@ it("admits readable selections and hands canonical paths to the provider", async
|
||||
application_root: readable,
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"records selected scenario paths and omission while executing canonical paths",
|
||||
async () => {
|
||||
const { root, readable } = await fixture();
|
||||
const alias = join(root, "selected-application");
|
||||
await symlink(readable, alias);
|
||||
const input = electronActiveObservationInputSchema.parse({
|
||||
executable_path: process.execPath,
|
||||
application_path: join(alias, "main.js"),
|
||||
});
|
||||
const canonicalApplication = await realpath(input.application_path);
|
||||
const capture = vi.fn<ElectronActiveObservationPort["capture"]>(() =>
|
||||
Promise.resolve(
|
||||
ok(createElectronActiveObservationFixtureResult(canonicalApplication)),
|
||||
),
|
||||
);
|
||||
const result = await captureElectronScenario(
|
||||
{ identity: () => ({ id: "test", name: "test", version: "1" }), capture },
|
||||
input,
|
||||
);
|
||||
expect(capture.mock.calls[0]?.[0]).toMatchObject({
|
||||
executable_path: await realpath(process.execPath),
|
||||
application_path: canonicalApplication,
|
||||
application_root: await realpath(readable),
|
||||
});
|
||||
expect(result.ok).toBe(true);
|
||||
if (!result.ok) throw result.error;
|
||||
expect(result.value.parameters).toEqual({
|
||||
...input,
|
||||
scenario_sha256: digestCanonicalValue(input, "Electron scenario"),
|
||||
resolved_paths: {
|
||||
executable_path: await realpath(process.execPath),
|
||||
application_path: canonicalApplication,
|
||||
application_root: await realpath(readable),
|
||||
},
|
||||
});
|
||||
expect(result.value.parameters).not.toHaveProperty("application_root");
|
||||
expect(result.value.normalized_result).toMatchObject({
|
||||
application: { application_path: canonicalApplication },
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -189,9 +189,10 @@ export const captureElectronScenario = async (
|
||||
? {
|
||||
ok: true,
|
||||
value: createElectronActiveEvidence(
|
||||
canonical.value,
|
||||
input,
|
||||
captured.value,
|
||||
provider.identity(),
|
||||
canonical.value,
|
||||
),
|
||||
}
|
||||
: captured;
|
||||
|
||||
@@ -305,9 +305,10 @@ export const reconstructJavaScriptArtifact = async (
|
||||
.map(({ logical_path }) => logical_path),
|
||||
);
|
||||
const nextAppContext = createJavaScriptNextAppContext(
|
||||
files.files,
|
||||
files,
|
||||
analysis.jsonDocuments,
|
||||
directoryPaths,
|
||||
snapshot.coverage,
|
||||
);
|
||||
const completed = {
|
||||
route_inventory: await completeJavaScriptAnalysisSteps(
|
||||
|
||||
@@ -4,6 +4,18 @@ import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptA
|
||||
import { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
|
||||
import { createJavaScriptNextAppContext } from "./JavaScriptNextAppContext.js";
|
||||
|
||||
const contextFor = (
|
||||
files: readonly JavaScriptArtifactFile[],
|
||||
documents: JavaScriptJsonDocuments,
|
||||
directories: ReadonlySet<string>,
|
||||
) =>
|
||||
createJavaScriptNextAppContext(
|
||||
{ files, unavailable_relevant_files: [] },
|
||||
documents,
|
||||
directories,
|
||||
{ scope: "container-entries", status: "complete" },
|
||||
);
|
||||
|
||||
const file = (path: string, text = ""): JavaScriptArtifactFile => ({
|
||||
path,
|
||||
container_sha256: "a".repeat(64),
|
||||
@@ -23,7 +35,7 @@ it("keeps package applicability and its root cache scoped to the source containe
|
||||
};
|
||||
const page = file("app/page.js");
|
||||
const foreignPage = { ...page, container_sha256: foreign.container_sha256 };
|
||||
const context = createJavaScriptNextAppContext(
|
||||
const context = contextFor(
|
||||
[manifest, foreign, page, foreignPage],
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["app"]),
|
||||
@@ -49,7 +61,7 @@ it("uses the nearest declared Next package and root directory precedence", () =>
|
||||
file("node_modules/ordinary/package.json", '{"name":"ordinary"}'),
|
||||
file("node_modules/ordinary/app/page.js"),
|
||||
];
|
||||
const context = createJavaScriptNextAppContext(
|
||||
const context = contextFor(
|
||||
files,
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set([
|
||||
@@ -76,7 +88,7 @@ it("uses the nearest declared Next package and root directory precedence", () =>
|
||||
it("keeps default compiled output under its declared package and unrelated chunk-like paths unknown", () => {
|
||||
const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}');
|
||||
const chunk = file(".next/static/chunks/app/build/page-abc.js");
|
||||
const context = createJavaScriptNextAppContext(
|
||||
const context = contextFor(
|
||||
[manifest, chunk],
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(),
|
||||
@@ -86,11 +98,7 @@ it("keeps default compiled output under its declared package and unrelated chunk
|
||||
directory: ".next/static/chunks/app",
|
||||
});
|
||||
expect(
|
||||
createJavaScriptNextAppContext(
|
||||
[chunk],
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(),
|
||||
)(chunk),
|
||||
contextFor([chunk], new JavaScriptJsonDocuments(), new Set())(chunk),
|
||||
).toMatchObject({ kind: "unknown" });
|
||||
});
|
||||
|
||||
@@ -101,17 +109,97 @@ it("supports src/app only when app is not inventoried and retains missing framew
|
||||
);
|
||||
const page = file("src/app/control/page.js");
|
||||
expect(
|
||||
createJavaScriptNextAppContext(
|
||||
contextFor(
|
||||
[manifest, page],
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["src/app"]),
|
||||
)(page),
|
||||
).toMatchObject({ kind: "app-directory", directory: "src/app" });
|
||||
expect(
|
||||
createJavaScriptNextAppContext(
|
||||
contextFor(
|
||||
[page],
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["src/app"]),
|
||||
)(page),
|
||||
).toMatchObject({ kind: "unknown", package: null });
|
||||
});
|
||||
|
||||
it("does not infer absent manifests or higher-priority directories from incomplete traversal", () => {
|
||||
const manifest = file("package.json", '{"dependencies":{"next":"16.4.2"}}');
|
||||
const page = file("src/app/control/page.js");
|
||||
const files = { files: [manifest, page], unavailable_relevant_files: [] };
|
||||
const context = createJavaScriptNextAppContext(
|
||||
files,
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["src/app"]),
|
||||
{ scope: "container-entries", status: "unknown" },
|
||||
);
|
||||
expect(context(page)).toMatchObject({
|
||||
kind: "unknown",
|
||||
package: null,
|
||||
reason: expect.stringContaining("src/app/control/package.json"),
|
||||
});
|
||||
const rootPage = file("page.js");
|
||||
expect(
|
||||
createJavaScriptNextAppContext(
|
||||
files,
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["src/app"]),
|
||||
{ scope: "container-entries", status: "unknown" },
|
||||
)(rootPage),
|
||||
).toMatchObject({
|
||||
kind: "unknown",
|
||||
package: { path: "package.json" },
|
||||
reason: expect.stringContaining("higher-priority app directory"),
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the nearest unavailable manifest's evidence instead of borrowing an ancestor's Next declaration", () => {
|
||||
const parent = file("package.json", '{"dependencies":{"next":"16.4.2"}}');
|
||||
const page = file("app/widget/app/page.js");
|
||||
const unavailable = {
|
||||
path: "app/widget/package.json",
|
||||
container_sha256: parent.container_sha256,
|
||||
sha256: null,
|
||||
bytes: 42,
|
||||
inventory_artifact_id: null,
|
||||
inventory_occurrence_id: "unreadable-nested-package",
|
||||
kind: "package-json" as const,
|
||||
limitations: ["The unpacked package companion is missing."],
|
||||
};
|
||||
const context = createJavaScriptNextAppContext(
|
||||
{ files: [parent, page], unavailable_relevant_files: [unavailable] },
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["app", "app/widget/app"]),
|
||||
{ scope: "container-entries", status: "complete" },
|
||||
);
|
||||
expect(context(page)).toMatchObject({
|
||||
kind: "unknown",
|
||||
package: null,
|
||||
reason: expect.stringContaining(unavailable.path),
|
||||
});
|
||||
const result = context(page);
|
||||
if (result.kind !== "unknown")
|
||||
throw new Error("Expected unknown package ownership");
|
||||
expect(result.reason).toContain(unavailable.inventory_occurrence_id);
|
||||
expect(result.reason).toContain(unavailable.limitations[0]);
|
||||
expect(context(file("app/page.js"))).toMatchObject({
|
||||
kind: "app-directory",
|
||||
package: { path: parent.path },
|
||||
});
|
||||
const foreign = createJavaScriptNextAppContext(
|
||||
{
|
||||
files: [parent, page],
|
||||
unavailable_relevant_files: [
|
||||
{ ...unavailable, container_sha256: "c".repeat(64) },
|
||||
],
|
||||
},
|
||||
new JavaScriptJsonDocuments(),
|
||||
new Set(["app", "app/widget/app"]),
|
||||
{ scope: "container-entries", status: "complete" },
|
||||
);
|
||||
expect(foreign(page)).toMatchObject({
|
||||
kind: "app-directory",
|
||||
package: { path: parent.path },
|
||||
});
|
||||
});
|
||||
@@ -1,20 +1,36 @@
|
||||
import { posix } from "node:path";
|
||||
|
||||
import type { JavaScriptArtifactFile } from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { ArtifactInventoryCoverage } from "../../domain/artifactGraph.js";
|
||||
import type {
|
||||
JavaScriptArtifactFileSet,
|
||||
JavaScriptArtifactFile,
|
||||
} from "../../domain/javascript/javascriptArtifactFiles.js";
|
||||
import type { NextAppSourceContext } from "../../domain/javascript/javascriptRoutePaths.js";
|
||||
import { compositeKey } from "../../domain/unicodeCodePointOrder.js";
|
||||
import type { JavaScriptJsonDocuments } from "./JavaScriptJsonDocuments.js";
|
||||
|
||||
/** Package declarations and inventoried directories own source-router applicability. */
|
||||
export const createJavaScriptNextAppContext = (
|
||||
files: readonly JavaScriptArtifactFile[],
|
||||
fileSet: Pick<
|
||||
JavaScriptArtifactFileSet,
|
||||
"files" | "unavailable_relevant_files"
|
||||
>,
|
||||
documents: JavaScriptJsonDocuments,
|
||||
directories: ReadonlySet<string>,
|
||||
coverage: ArtifactInventoryCoverage,
|
||||
): ((
|
||||
file: Pick<JavaScriptArtifactFile, "path" | "container_sha256">,
|
||||
) => NextAppSourceContext) => {
|
||||
const packages = new Map(
|
||||
files
|
||||
fileSet.files
|
||||
.filter(({ kind }) => kind === "package-json")
|
||||
.map((file) => [
|
||||
compositeKey([file.container_sha256, posix.dirname(file.path)]),
|
||||
file,
|
||||
]),
|
||||
);
|
||||
const unavailablePackages = new Map(
|
||||
fileSet.unavailable_relevant_files
|
||||
.filter(({ kind }) => kind === "package-json")
|
||||
.map((file) => [
|
||||
compositeKey([file.container_sha256, posix.dirname(file.path)]),
|
||||
@@ -28,9 +44,15 @@ export const createJavaScriptNextAppContext = (
|
||||
;
|
||||
directory = posix.dirname(directory)
|
||||
) {
|
||||
const manifest = packages.get(
|
||||
compositeKey([file.container_sha256, directory]),
|
||||
);
|
||||
const key = compositeKey([file.container_sha256, directory]);
|
||||
const unavailable = unavailablePackages.get(key);
|
||||
if (unavailable !== undefined)
|
||||
return {
|
||||
kind: "unknown",
|
||||
package: null,
|
||||
reason: `The nearest owning package manifest ${unavailable.path}${unavailable.inventory_occurrence_id === undefined ? "" : ` (inventory occurrence ${unavailable.inventory_occurrence_id})`} is unavailable: ${unavailable.limitations?.join("; ") || "Inventoried package bytes are unavailable."}`,
|
||||
};
|
||||
const manifest = packages.get(key);
|
||||
if (manifest !== undefined) {
|
||||
const identity = {
|
||||
path: manifest.path,
|
||||
@@ -53,6 +75,14 @@ export const createJavaScriptNextAppContext = (
|
||||
directory: compiled,
|
||||
package: identity,
|
||||
};
|
||||
// Selecting src/app requires proving that the higher-priority app is absent.
|
||||
if (coverage.status !== "complete" && !directories.has(`${prefix}app`))
|
||||
return {
|
||||
kind: "unknown",
|
||||
package: identity,
|
||||
reason:
|
||||
"Incomplete artifact traversal does not establish whether the owning package has a higher-priority app directory.",
|
||||
};
|
||||
let root = roots.get(manifest);
|
||||
if (root === undefined) {
|
||||
root =
|
||||
@@ -65,6 +95,12 @@ export const createJavaScriptNextAppContext = (
|
||||
? { kind: "app-directory", directory: root, package: identity }
|
||||
: { kind: "outside-app-directory", package: identity };
|
||||
}
|
||||
if (coverage.status !== "complete")
|
||||
return {
|
||||
kind: "unknown",
|
||||
package: null,
|
||||
reason: `Incomplete artifact traversal does not establish whether ${directory === "." ? "" : `${directory}/`}package.json owns this source.`,
|
||||
};
|
||||
if (directory === "." || directory === "/") break;
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
// Fake-backed composition coverage; real provider verification lives in
|
||||
// `npm run verify:hopper` and `npm run verify:ghidra`.
|
||||
import { describe, expect, it } from "vitest";
|
||||
@@ -874,3 +875,42 @@ describe("native UI trace boundaries", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("retains both initial cleanup owners until each lease releases", async () => {
|
||||
const failure = (operation: string, resource: string) =>
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
provider.id,
|
||||
operation,
|
||||
"unavailable",
|
||||
{ cleanup: { reason: "release failed", resources: [resource] } },
|
||||
);
|
||||
const ui = failure("decode_interface_builder", "ui-owner");
|
||||
const metadata = failure(
|
||||
"inspect_native_dispatch_metadata",
|
||||
"metadata-owner",
|
||||
);
|
||||
const uiLease = new AnalysisCleanupLease(async () => {}, ["ui-owner"]);
|
||||
const metadataLease = new AnalysisCleanupLease(async () => {}, [
|
||||
"metadata-owner",
|
||||
]);
|
||||
uiLease.bindCleanupError(ui);
|
||||
metadataLease.bindCleanupError(metadata);
|
||||
const result = await new EnhancedTools(
|
||||
testAnalysis({
|
||||
decode_interface_builder: () => err(ui),
|
||||
inspect_native_dispatch_metadata: () => err(metadata),
|
||||
}),
|
||||
).execute("trace_native_ui_action", { action: "0x1000" });
|
||||
if (result.ok) throw new Error("Expected cleanup error");
|
||||
expect(result.error.cleanupPrimary).toBe(metadata);
|
||||
expect(result.error.cleanupResources).toEqual(
|
||||
expect.arrayContaining(["ui-owner", "metadata-owner"]),
|
||||
);
|
||||
await uiLease.release();
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual(["metadata-owner"]);
|
||||
await metadataLease.release();
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(result.error.cleanupResources).toEqual([]);
|
||||
expect(result.error.cleanupPrimary).toBe(metadata);
|
||||
});
|
||||
@@ -1,6 +1,10 @@
|
||||
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { readNativeCallRoutes } from "./NativeCallRoutes.js";
|
||||
import { createAnalysisExecution } from "../AnalysisProvider.js";
|
||||
import {
|
||||
createAnalysisExecution,
|
||||
type AnalysisOperation,
|
||||
} from "../AnalysisProvider.js";
|
||||
import { ghidraReferenceEdge } from "../../domain/ghidraValues.fixture.js";
|
||||
import { jsonValueSchema } from "../../domain/jsonValue.js";
|
||||
import { err, ok } from "../../domain/result.js";
|
||||
@@ -575,3 +579,63 @@ describe("missing unresolved-call observations", () => {
|
||||
expect(calls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"preserves capability cleanup ownership (incomplete=%s)",
|
||||
async (incomplete) => {
|
||||
const failure = new AnalysisCapabilityUnavailableError(
|
||||
provider.id,
|
||||
"procedure_references",
|
||||
"Typed references unavailable",
|
||||
incomplete
|
||||
? { cleanup: { reason: "release failed", resources: ["route-owner"] } }
|
||||
: undefined,
|
||||
);
|
||||
const owner = {
|
||||
cleanupResources: incomplete ? ["route-owner"] : ([] as string[]),
|
||||
};
|
||||
failure.bindCleanupResourceOwner(owner);
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
owner.cleanupResources = [];
|
||||
}, owner);
|
||||
lease.bindCleanupError(failure);
|
||||
const calls: string[] = [];
|
||||
const analysis = {
|
||||
execute: async (operation: AnalysisOperation) => {
|
||||
calls.push(operation);
|
||||
return operation === "procedure_references"
|
||||
? err(failure)
|
||||
: ok(createAnalysisExecution(["0x2000"], provider));
|
||||
},
|
||||
};
|
||||
const result = await readNativeCallRoutes(analysis, "0x1000");
|
||||
expect(calls).toEqual(
|
||||
incomplete
|
||||
? ["procedure_references"]
|
||||
: ["procedure_references", "procedure_callees"],
|
||||
);
|
||||
if (incomplete) {
|
||||
expect(result.outcome).toEqual(err(failure));
|
||||
if (result.outcome.ok) throw new Error("Expected retained cleanup error");
|
||||
expect(result.outcome.error).toBe(failure);
|
||||
expect(result.outcome.error.cleanupResources).toEqual(["route-owner"]);
|
||||
await lease.release();
|
||||
expect(result.outcome.error.cleanupResources).toEqual([]);
|
||||
const recovered = await readNativeCallRoutes(analysis, "0x1000");
|
||||
expect(recovered.outcome.ok).toBe(true);
|
||||
expect(calls).toEqual([
|
||||
"procedure_references",
|
||||
"procedure_references",
|
||||
"procedure_callees",
|
||||
]);
|
||||
} else {
|
||||
expect(result.outcome.ok).toBe(true);
|
||||
expect(result.routes).toContainEqual(
|
||||
expect.objectContaining({
|
||||
address: "0x2000",
|
||||
relation: "provider_call",
|
||||
}),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -1,3 +1,4 @@
|
||||
import { permitsCapabilityFallback } from "../CapabilityFallback.js";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
AnalysisOperationPort,
|
||||
@@ -171,7 +172,7 @@ export const readNativeCallRoutes = async (
|
||||
(parsed.data.reference_kinds_available || references.length > 0)
|
||||
)
|
||||
return finish(ok(undefined));
|
||||
} else if (execution.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
} else if (!permitsCapabilityFallback(execution.error))
|
||||
return finish(err(execution.error));
|
||||
const callees = await analysis.execute(
|
||||
NATIVE_CALL_ROUTE_SOURCES.callees,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { permitsCapabilityFallback } from "../CapabilityFallback.js";
|
||||
import { nativeInventoryRowsSchemas } from "../../domain/native/nativeInventory.js";
|
||||
import {
|
||||
nativeDispatchMetadataResultSchema,
|
||||
@@ -59,7 +60,7 @@ export const inspectNativeDispatch = async (
|
||||
nativeDispatchMetadataResultSchema.parse(binaryMetadata.value.result),
|
||||
),
|
||||
);
|
||||
if (binaryMetadata.error._tag !== "AnalysisCapabilityUnavailableError")
|
||||
if (!permitsCapabilityFallback(binaryMetadata.error))
|
||||
return err(binaryMetadata.error);
|
||||
const execution = await analysis.execute(
|
||||
NATIVE_DISPATCH_METADATA_SOURCES.symbols,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { analysisErrorWithCleanupFailure } from "../../domain/analysisErrorCleanup.js";
|
||||
import { permitsCapabilityFallback } from "../CapabilityFallback.js";
|
||||
import { z } from "zod";
|
||||
|
||||
import type { AnalysisOperationPort } from "../AnalysisProvider.js";
|
||||
@@ -48,7 +50,31 @@ export const traceNativeUiAction = async (
|
||||
),
|
||||
inspectNativeDispatch(analysis, 20_000, { signal }),
|
||||
]);
|
||||
if (!uiExecution.ok && uiExecution.error._tag === "AnalysisCancelledError")
|
||||
if (!uiExecution.ok && !metadataExecution.ok) {
|
||||
const primary =
|
||||
uiExecution.error._tag === "AnalysisCancelledError"
|
||||
? uiExecution.error
|
||||
: metadataExecution.error;
|
||||
const secondary =
|
||||
primary === uiExecution.error
|
||||
? metadataExecution.error
|
||||
: uiExecution.error;
|
||||
if (secondary.cleanupIncomplete)
|
||||
return err(
|
||||
analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
secondary,
|
||||
"trace_native_ui_action",
|
||||
).bindCleanupDependencies([primary, secondary]),
|
||||
);
|
||||
if (primary.cleanupIncomplete) primary.retainSecondaryFailure(secondary);
|
||||
return err(primary);
|
||||
}
|
||||
if (
|
||||
!uiExecution.ok &&
|
||||
(uiExecution.error._tag === "AnalysisCancelledError" ||
|
||||
uiExecution.error.cleanupIncomplete)
|
||||
)
|
||||
return err(uiExecution.error);
|
||||
if (!metadataExecution.ok) return err(metadataExecution.error);
|
||||
const metadata = nativeDispatchMetadataResultSchema.parse(
|
||||
@@ -134,12 +160,7 @@ export const traceNativeUiAction = async (
|
||||
{ procedure: input.action },
|
||||
signal === undefined ? {} : { signal },
|
||||
);
|
||||
if (!native.ok && native.error._tag === "AnalysisCancelledError")
|
||||
return err(native.error);
|
||||
if (
|
||||
!native.ok &&
|
||||
native.error._tag !== "AnalysisCapabilityUnavailableError"
|
||||
)
|
||||
if (!native.ok && !permitsCapabilityFallback(native.error))
|
||||
return err(native.error);
|
||||
if (native.ok) {
|
||||
const address = z.string().safeParse(native.value.result);
|
||||
@@ -438,7 +459,8 @@ const expandNativeCalls = async (input: {
|
||||
sourceLimitations.push(...execution.limitations);
|
||||
if (
|
||||
!execution.outcome.ok &&
|
||||
execution.outcome.error._tag === "AnalysisCancelledError"
|
||||
(execution.outcome.error._tag === "AnalysisCancelledError" ||
|
||||
execution.outcome.error.cleanupIncomplete)
|
||||
)
|
||||
return { error: execution.outcome.error };
|
||||
const unknowns = execution.unknowns;
|
||||
|
||||
@@ -13,14 +13,17 @@ import {
|
||||
locateNodeSea,
|
||||
type NodeSeaLocation,
|
||||
} from "./nodeSea/NodeSeaLocation.js";
|
||||
import { containsPyInstallerArchive } from "./pyinstaller/CArchiveLayout.js";
|
||||
import {
|
||||
findPyInstallerCookie,
|
||||
type PyInstallerCookie,
|
||||
} from "./pyinstaller/CArchiveLayout.js";
|
||||
|
||||
/** Completed observations only; null means examined absence, omission means unexamined. */
|
||||
export interface ArtifactEmbeddedFormatProbeFacts {
|
||||
readonly sourceState: RegularFileState;
|
||||
readonly godotPck?: GodotPckLocation | null;
|
||||
readonly nodeSea?: NodeSeaLocation | null;
|
||||
readonly pyInstaller?: boolean;
|
||||
readonly pyInstaller?: PyInstallerCookie | null;
|
||||
}
|
||||
|
||||
/** Share format observations only across admissions of the same unchanged regular file. */
|
||||
@@ -72,17 +75,19 @@ export class ArtifactEmbeddedFormatProbes {
|
||||
return location;
|
||||
}
|
||||
|
||||
async pyInstaller(): Promise<boolean> {
|
||||
async pyInstaller(): Promise<PyInstallerCookie | undefined> {
|
||||
abortIfNeeded(this.signal);
|
||||
if (this.#facts.pyInstaller !== undefined) return this.#facts.pyInstaller;
|
||||
const found = await containsPyInstallerArchive(
|
||||
if (this.#facts.pyInstaller !== undefined)
|
||||
return this.#facts.pyInstaller ?? undefined;
|
||||
const found = await findPyInstallerCookie(
|
||||
this.source.handle,
|
||||
this.source.initial.size,
|
||||
this.signal,
|
||||
);
|
||||
abortIfNeeded(this.signal);
|
||||
this.#facts = { ...this.#facts, pyInstaller: found };
|
||||
return found;
|
||||
const cookie = found?.ok === true ? found.value : undefined;
|
||||
this.#facts = { ...this.#facts, pyInstaller: cookie ?? null };
|
||||
return cookie;
|
||||
}
|
||||
|
||||
completedFacts(): ArtifactEmbeddedFormatProbeFacts {
|
||||
|
||||
@@ -17,8 +17,6 @@ import { isAbsolute, join, relative, sep } from "node:path";
|
||||
import { Readable, Transform } from "node:stream";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
|
||||
import { getRawHeader, uncache } from "@electron/asar";
|
||||
|
||||
import {
|
||||
ArtifactReaderFailure,
|
||||
copyArtifactEntry,
|
||||
@@ -27,7 +25,7 @@ import {
|
||||
type ArtifactReader,
|
||||
} from "./ArtifactReader.js";
|
||||
import { closeAsarHandle, readValidatedAsarEntry } from "./AsarEntryStream.js";
|
||||
import { admitAsarHeader } from "./AsarHeader.js";
|
||||
import { admitAsarHeader, readAsarHeader } from "./AsarHeader.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import type { FileHandleChunkStream } from "../filesystem/readFileHandleChunks.js";
|
||||
import { readArtifactFileChunks } from "./readArtifactFileChunks.js";
|
||||
@@ -39,7 +37,7 @@ import {
|
||||
import { readAsarListing, type AsarListedEntry } from "./AsarListing.js";
|
||||
|
||||
/**
|
||||
* Official Electron ASAR adapter with range-streamed member reads.
|
||||
* Electron ASAR reader with strict header decoding and range-streamed member reads.
|
||||
*
|
||||
* An entry marked `unpacked` is metadata, not an integrity exemption: Electron
|
||||
* stores its bytes beside the archive in `<archive>.unpacked`, and callers must
|
||||
@@ -73,10 +71,7 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
this.#archiveSize = admitted.archiveBytes;
|
||||
this.#headerSize = admitted.headerBytes;
|
||||
assertAsarReadActive(signal);
|
||||
const ordered = await readAsarListing(
|
||||
getRawHeader(snapshotPath).header,
|
||||
signal,
|
||||
);
|
||||
const ordered = await readAsarListing(admitted.header, signal);
|
||||
const archiveMetadata = await lstat(snapshotPath);
|
||||
if (!archiveMetadata.isFile() || archiveMetadata.isSymbolicLink())
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -128,17 +123,23 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
let admission:
|
||||
| {
|
||||
readonly ok: true;
|
||||
readonly value: Awaited<ReturnType<typeof admitAsarHeader>>;
|
||||
readonly value: Awaited<ReturnType<typeof admitAsarHeader>> & {
|
||||
readonly header: unknown;
|
||||
};
|
||||
}
|
||||
| { readonly ok: false; readonly cause: unknown };
|
||||
try {
|
||||
const extents = await admitAsarHeader(
|
||||
headerHandle,
|
||||
signal,
|
||||
this.options.headerAdmission,
|
||||
);
|
||||
admission = {
|
||||
ok: true,
|
||||
value: await admitAsarHeader(
|
||||
headerHandle,
|
||||
signal,
|
||||
this.options.headerAdmission,
|
||||
),
|
||||
value: {
|
||||
...extents,
|
||||
header: await readAsarHeader(headerHandle, extents, signal),
|
||||
},
|
||||
};
|
||||
} catch (cause: unknown) {
|
||||
admission = { ok: false, cause };
|
||||
@@ -388,7 +389,6 @@ export class AsarArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
if (root === undefined || this.#snapshotRoot === undefined) {
|
||||
if (this.#snapshot !== undefined) uncache(this.#snapshot.path);
|
||||
this.#snapshot = undefined;
|
||||
this.#resetArchiveState();
|
||||
this.#entries.clear();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import type { Stats } from "node:fs";
|
||||
import { expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
admitAsarHeader,
|
||||
readAsarHeader,
|
||||
createAsarHeaderSignatureProbe,
|
||||
} from "./AsarHeader.js";
|
||||
import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
|
||||
@@ -78,3 +80,40 @@ it("rejects a signature whose declared header exceeds the observed archive", ()
|
||||
reason: "archive-too-short",
|
||||
});
|
||||
});
|
||||
|
||||
it("decodes admitted JSON across short reads without consuming padding", async () => {
|
||||
const archive = asarHeaderBytes('{"files":{"�":{}}}', {
|
||||
padding: Buffer.from([0xff]),
|
||||
});
|
||||
const handle = {
|
||||
stat: async () => ({ isFile: () => true, size: archive.length }) as Stats,
|
||||
read: async (
|
||||
target: Buffer,
|
||||
offset: number,
|
||||
length: number,
|
||||
position: number,
|
||||
) => {
|
||||
const bytesRead = Math.min(2, length, archive.length - position);
|
||||
archive.copy(target, offset, position, position + bytesRead);
|
||||
return { bytesRead, buffer: target };
|
||||
},
|
||||
} as unknown as Parameters<typeof admitAsarHeader>[0];
|
||||
const extents = await admitAsarHeader(handle);
|
||||
await expect(readAsarHeader(handle, extents)).resolves.toEqual({
|
||||
files: { "�": {} },
|
||||
});
|
||||
await expect(
|
||||
readAsarHeader(
|
||||
{
|
||||
read: (async (target: Buffer) => ({
|
||||
bytesRead: 0,
|
||||
buffer: target,
|
||||
})) as FileHandle["read"],
|
||||
},
|
||||
extents,
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: "Truncated ASAR JSON header",
|
||||
});
|
||||
});
|
||||
@@ -422,3 +422,39 @@ export const admitAsarHeader = async (
|
||||
export const createAsarHeaderSignatureProbe = (
|
||||
admission: AsarHeaderAdmission = DEFAULT_ASAR_HEADER_ADMISSION,
|
||||
): AsarHeaderSignatureProbe => new AsarHeaderSignatureProbeImpl(admission);
|
||||
|
||||
/** Decode only admitted JSON bytes from the same owned descriptor as its extents. */
|
||||
export const readAsarHeader = async (
|
||||
handle: Pick<FileHandle, "read">,
|
||||
extents: AsarHeaderExtents,
|
||||
signal?: AbortSignal,
|
||||
): Promise<unknown> => {
|
||||
const bytes = Buffer.alloc(extents.jsonBytes);
|
||||
let offset = 0;
|
||||
while (offset < bytes.length) {
|
||||
assertArtifactReadActive(signal);
|
||||
const read = await handle.read(
|
||||
bytes,
|
||||
offset,
|
||||
bytes.length - offset,
|
||||
16 + offset,
|
||||
);
|
||||
assertArtifactReadActive(signal);
|
||||
if (read.bytesRead === 0)
|
||||
throw new ArtifactReaderFailure("format", "Truncated ASAR JSON header");
|
||||
offset += read.bytesRead;
|
||||
}
|
||||
let json: string;
|
||||
try {
|
||||
json = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
|
||||
bytes,
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
"ASAR JSON header is not valid UTF-8",
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
return JSON.parse(json) as unknown;
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import { mkdtemp, writeFile, rm } from "node:fs/promises";
|
||||
import { buffer } from "node:stream/consumers";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { AsarArtifactReader } from "./AsarArtifactReader.js";
|
||||
import { asarHeaderBytes } from "../../tests/fixtures/asarHeader.js";
|
||||
|
||||
it("rejects malformed UTF-8 instead of inventing a replacement-character member identity", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-"));
|
||||
const path = join(root, "app.asar");
|
||||
const reader = new AsarArtifactReader(path);
|
||||
const bytes = asarHeaderBytes('{"files":{"x":{"size":0,"offset":"0"}}}');
|
||||
bytes[bytes.indexOf(Buffer.from('"x"')) + 1] = 0xff;
|
||||
await writeFile(path, bytes);
|
||||
try {
|
||||
await expect(collectEntries(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
message: expect.stringContaining("UTF-8"),
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["�", "é", "日本語", "\uFEFFname"])(
|
||||
"preserves valid UTF-8 member identity %j and reads its bytes through padded framing",
|
||||
async (name) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-"));
|
||||
const path = join(root, "app.asar");
|
||||
const reader = new AsarArtifactReader(path);
|
||||
const content = Buffer.from("member bytes");
|
||||
await writeFile(
|
||||
path,
|
||||
Buffer.concat([
|
||||
asarHeaderBytes(
|
||||
` \t\r\n${JSON.stringify({ files: { [name]: { size: content.length, offset: "0" } } })} `,
|
||||
{ padding: Buffer.from([0xff, 0xfe]) },
|
||||
),
|
||||
content,
|
||||
]),
|
||||
);
|
||||
try {
|
||||
const entries = await collectEntries(reader);
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.path).toBe(name);
|
||||
const output = await reader.open(entries[0]!);
|
||||
expect((await buffer(output)).equals(content)).toBe(true);
|
||||
} finally {
|
||||
await reader.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(['{"files":', '\uFEFF{"files":{}}'])(
|
||||
"rejects invalid JSON framing %j as format failure",
|
||||
async (json) => {
|
||||
const root = await mkdtemp(join(tmpdir(), "rea-asar-identity-"));
|
||||
const path = join(root, "app.asar");
|
||||
const reader = new AsarArtifactReader(path);
|
||||
await writeFile(path, asarHeaderBytes(json));
|
||||
try {
|
||||
await expect(collectEntries(reader)).rejects.toMatchObject({
|
||||
reason: "format",
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
async function collectEntries(reader: AsarArtifactReader) {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
return entries;
|
||||
}
|
||||
@@ -326,6 +326,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
signal?: AbortSignal,
|
||||
): Promise<OwnedFileHandle<FileHandle>> {
|
||||
abortIfNeeded(signal);
|
||||
await this.#validateRoot();
|
||||
if (entry.kind !== "file")
|
||||
throw new ArtifactReaderFailure(
|
||||
"format",
|
||||
@@ -380,6 +381,21 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
}
|
||||
}
|
||||
|
||||
async #validateRoot(): Promise<void> {
|
||||
const root = await this.#resolveRoot();
|
||||
await withDirectoryIoContext(
|
||||
"inspect artifact root",
|
||||
root.canonicalPath,
|
||||
() =>
|
||||
assertContainedDirectory(
|
||||
root.canonicalPath,
|
||||
root.canonicalPath,
|
||||
root.identity,
|
||||
"integrity",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** Borrow an admitted entry for bounded random reads; retain failed cleanup. */
|
||||
async inspectFile<Value>(
|
||||
entry: ArtifactEntry,
|
||||
@@ -404,6 +420,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
const [opened, named] = await Promise.all([
|
||||
file.stat({ bigint: true }),
|
||||
lstat(entry.adapterKey, { bigint: true }),
|
||||
this.#validateRoot(),
|
||||
]).catch((validationCause: unknown) => {
|
||||
if (outcome.kind !== "failed") throw validationCause;
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -460,7 +477,12 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
async open(entry: ArtifactEntry, signal?: AbortSignal): Promise<Readable> {
|
||||
const owner = await this.#openFile(entry, signal);
|
||||
const source = Readable.from(
|
||||
mapStableFileRead(owner.handle, entry, signal),
|
||||
mapStableFileRead(
|
||||
owner.handle,
|
||||
entry,
|
||||
() => this.#validateRoot(),
|
||||
signal,
|
||||
),
|
||||
{ objectMode: false },
|
||||
);
|
||||
source.once("close", () => {
|
||||
@@ -635,6 +657,7 @@ const failureMessage = (cause: unknown): string =>
|
||||
const mapStableFileRead = async function* (
|
||||
handle: FileHandle,
|
||||
entry: ArtifactEntry,
|
||||
validateRoot: () => Promise<void>,
|
||||
signal?: AbortSignal,
|
||||
): AsyncGenerator<Buffer> {
|
||||
let bytes = 0n;
|
||||
@@ -658,6 +681,7 @@ const mapStableFileRead = async function* (
|
||||
const [opened, current] = await Promise.all([
|
||||
handle.stat({ bigint: true }),
|
||||
lstat(entry.adapterKey, { bigint: true }),
|
||||
validateRoot(),
|
||||
]);
|
||||
abortIfNeeded(signal);
|
||||
if (
|
||||
@@ -713,6 +737,7 @@ const assertContainedDirectory = async (
|
||||
root: string,
|
||||
directory: string,
|
||||
rootIdentity: { readonly dev: bigint; readonly ino: bigint } | undefined,
|
||||
failureReason: "path" | "integrity" = "path",
|
||||
): Promise<void> => {
|
||||
const canonical = await realpath(directory);
|
||||
const fromRoot = relative(root, canonical);
|
||||
@@ -729,7 +754,7 @@ const assertContainedDirectory = async (
|
||||
isAbsolute(fromRoot)
|
||||
)
|
||||
throw new ArtifactReaderFailure(
|
||||
"path",
|
||||
failureReason,
|
||||
"Directory traversal escaped or changed its artifact root",
|
||||
);
|
||||
};
|
||||
@@ -17,6 +17,7 @@ import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory
|
||||
import { openRegularFile } from "../filesystem/RegularFile.js";
|
||||
|
||||
import type { ArtifactEntry } from "./ArtifactReader.js";
|
||||
import { ArtifactEmbeddedFormatProbes } from "./ArtifactEmbeddedFormatProbes.js";
|
||||
import { GodotPckArtifactReader } from "./GodotPckArtifactReader.js";
|
||||
|
||||
const writePck = async (bytes: Buffer): Promise<string> => {
|
||||
@@ -262,32 +263,83 @@ describe("GodotPckArtifactReader member streams", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("reads members from the admitted descriptor after the path is replaced", async () => {
|
||||
const root = await createTestTempDirectory("rea-godot-borrowed-source-");
|
||||
const selected = join(root, "selected");
|
||||
await mkdir(selected);
|
||||
const path = join(selected, "game");
|
||||
await writeFile(path, buildGodotPck(3, members));
|
||||
it("rejects stale located facts after the admitted carrier changes", async () => {
|
||||
const path = await writePck(buildGodotPck(3, members));
|
||||
const descriptor = await openRegularFile(path, { symlinks: "reject" });
|
||||
const { handle } = descriptor;
|
||||
try {
|
||||
// Replace the selected directory without changing the admitted inode's
|
||||
// metadata. Moving the file itself would change its validated ctime.
|
||||
await rename(selected, join(root, "original"));
|
||||
await mkdir(selected);
|
||||
await writeFile(path, buildGodotPck(3, [{ path: "other.gd", data: "" }]));
|
||||
const reader = new GodotPckArtifactReader(path, descriptor);
|
||||
const entries = await list(reader);
|
||||
expect(entries.map(({ path: member }) => member)).toEqual([
|
||||
"scenes/main.gd",
|
||||
"project.binary",
|
||||
"data/é.json",
|
||||
]);
|
||||
await reader.close();
|
||||
const location = await new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
descriptor,
|
||||
).godotPck();
|
||||
if (location === undefined) throw new Error("expected a pack location");
|
||||
const reader = new GodotPckArtifactReader(path, {
|
||||
kind: "godot-pck",
|
||||
source: descriptor,
|
||||
location,
|
||||
});
|
||||
try {
|
||||
await writeFile(path, Buffer.from("replaced carrier"));
|
||||
await expect(list(reader)).rejects.toMatchObject({
|
||||
reason: "integrity",
|
||||
});
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
} finally {
|
||||
await handle.close();
|
||||
await descriptor.handle.close();
|
||||
}
|
||||
});
|
||||
|
||||
it.each([false, true])(
|
||||
"reads located pack facts from the admitted descriptor after path replacement (embedded: %s)",
|
||||
async (embedded) => {
|
||||
const root = await createTestTempDirectory("rea-godot-borrowed-source-");
|
||||
const selected = join(root, "selected");
|
||||
await mkdir(selected);
|
||||
const path = join(selected, "game");
|
||||
const bytes = buildGodotPck(3, members);
|
||||
await writeFile(path, embedded ? embedGodotPck(bytes) : bytes);
|
||||
const descriptor = await openRegularFile(path, { symlinks: "reject" });
|
||||
const { handle } = descriptor;
|
||||
try {
|
||||
const location = await new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
descriptor,
|
||||
).godotPck();
|
||||
if (location === undefined) throw new Error("expected a pack location");
|
||||
// Replace the selected directory without changing the admitted inode's
|
||||
// metadata. Moving the file itself would change its validated ctime.
|
||||
await rename(selected, join(root, "original"));
|
||||
await mkdir(selected);
|
||||
await writeFile(
|
||||
path,
|
||||
buildGodotPck(3, [{ path: "other.gd", data: "" }]),
|
||||
);
|
||||
const reader = new GodotPckArtifactReader(path, {
|
||||
kind: "godot-pck",
|
||||
source: descriptor,
|
||||
location,
|
||||
});
|
||||
const cancelled = new AbortController();
|
||||
cancelled.abort();
|
||||
await expect(
|
||||
reader.entries(cancelled.signal)[Symbol.asyncIterator]().next(),
|
||||
).rejects.toMatchObject({
|
||||
reason: "cancelled",
|
||||
});
|
||||
const entries = await list(reader);
|
||||
expect(reader.formatMetadata()?.embedded).toBe(embedded);
|
||||
expect(entries.map(({ path: member }) => member)).toEqual([
|
||||
"scenes/main.gd",
|
||||
"project.binary",
|
||||
"data/é.json",
|
||||
]);
|
||||
await reader.close();
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("Godot PCK logical-byte admission", () => {
|
||||
|
||||
@@ -13,7 +13,12 @@ import {
|
||||
} from "./ArtifactReader.js";
|
||||
import { ArtifactResourceScope } from "./ArtifactResourceScope.js";
|
||||
import { assertArtifactReadActive } from "./ArtifactFileOperation.js";
|
||||
import { locateGodotPck, readPckBytes } from "./GodotPckLocation.js";
|
||||
import {
|
||||
locateGodotPck,
|
||||
readPckBytes,
|
||||
type GodotPckAdmission,
|
||||
type GodotPckLocation,
|
||||
} from "./GodotPckLocation.js";
|
||||
import type { GodotPckMetadata } from "../domain/artifactFormatMetadata.js";
|
||||
import { jsonValueByteLength } from "../domain/jsonValueByteLength.js";
|
||||
import { readFileHandleChunks } from "../filesystem/readFileHandleChunks.js";
|
||||
@@ -37,12 +42,23 @@ export class GodotPckArtifactReader implements ArtifactReader {
|
||||
readonly #entries = new Map<string, ArtifactEntry>();
|
||||
readonly #streams = new Set<Readable>();
|
||||
readonly #source: ArtifactFileSource;
|
||||
readonly #location: GodotPckLocation | undefined;
|
||||
#prepared: Promise<void> | undefined;
|
||||
#header: GodotPckMetadata | undefined;
|
||||
#closing = false;
|
||||
|
||||
constructor(path: string, admitted?: StableRegularFileDescriptor) {
|
||||
this.#source = new ArtifactFileSource(path, "Godot PCK", admitted);
|
||||
constructor(
|
||||
path: string,
|
||||
admitted?: StableRegularFileDescriptor | GodotPckAdmission,
|
||||
) {
|
||||
const admission =
|
||||
admitted !== undefined && "kind" in admitted ? admitted : undefined;
|
||||
this.#source = new ArtifactFileSource(
|
||||
path,
|
||||
"Godot PCK",
|
||||
admitted !== undefined && "kind" in admitted ? admitted.source : admitted,
|
||||
);
|
||||
this.#location = admission?.location;
|
||||
}
|
||||
|
||||
formatMetadata(): GodotPckMetadata | undefined {
|
||||
@@ -174,7 +190,12 @@ export class GodotPckArtifactReader implements ArtifactReader {
|
||||
|
||||
async #prepare(signal?: AbortSignal): Promise<void> {
|
||||
const { handle, initial } = await this.#source.read(signal);
|
||||
const header = await readGodotPckHeader(handle, initial.size, signal);
|
||||
const header = await readGodotPckHeader(
|
||||
handle,
|
||||
initial.size,
|
||||
signal,
|
||||
this.#location,
|
||||
);
|
||||
this.#header = header;
|
||||
if (header.directory_status !== "available") return;
|
||||
const cursor = new PckCursor(
|
||||
@@ -298,8 +319,10 @@ const readGodotPckHeader = async (
|
||||
handle: FileHandle,
|
||||
size: number,
|
||||
signal?: AbortSignal,
|
||||
admittedLocation?: GodotPckLocation,
|
||||
): Promise<GodotPckMetadata> => {
|
||||
const location = await locateGodotPck(handle, size, signal);
|
||||
const location =
|
||||
admittedLocation ?? (await locateGodotPck(handle, size, signal));
|
||||
if (location === undefined)
|
||||
throw formatFailure("no standalone header or executable trailer");
|
||||
const { start, end, embedded } = location;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { StableRegularFileDescriptor } from "../filesystem/RegularFile.js";
|
||||
import type { FileHandle } from "node:fs/promises";
|
||||
import {
|
||||
hasGodotPckHeader,
|
||||
@@ -13,6 +14,13 @@ export interface GodotPckLocation {
|
||||
readonly embedded: boolean;
|
||||
}
|
||||
|
||||
/** Located pack facts borrowed with the descriptor that produced them. */
|
||||
export interface GodotPckAdmission {
|
||||
readonly kind: "godot-pck";
|
||||
readonly source: StableRegularFileDescriptor;
|
||||
readonly location: GodotPckLocation;
|
||||
}
|
||||
|
||||
type DeclaredGodotPckTrailer = Extract<
|
||||
GodotPckTrailerFacts,
|
||||
{ readonly state: "declared" }
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js";
|
||||
import type { Readable } from "node:stream";
|
||||
import { finished } from "node:stream/promises";
|
||||
import { setImmediate } from "node:timers/promises";
|
||||
@@ -110,6 +111,7 @@ interface LoadedArchive {
|
||||
export class PyInstallerArtifactReader implements ArtifactReader {
|
||||
readonly format = "pyinstaller" as const;
|
||||
readonly #source: ArtifactFileSource;
|
||||
#admission: PyInstallerAdmission | undefined;
|
||||
#closed = false;
|
||||
readonly #scope = new ArtifactResourceScope();
|
||||
readonly #cleanup = new ArtifactResourceScope();
|
||||
@@ -124,8 +126,16 @@ export class PyInstallerArtifactReader implements ArtifactReader {
|
||||
readonly #members = new Map<string, PyInstallerMember>();
|
||||
readonly #openedPyzSizes = new Map<string, number>();
|
||||
|
||||
constructor(path: string, source?: StableRegularFileDescriptor) {
|
||||
this.#source = new ArtifactFileSource(path, "PyInstaller", source);
|
||||
constructor(
|
||||
path: string,
|
||||
source?: StableRegularFileDescriptor | PyInstallerAdmission,
|
||||
) {
|
||||
if (source !== undefined && "kind" in source) {
|
||||
this.#admission = source;
|
||||
this.#source = new ArtifactFileSource(path, "PyInstaller", source.source);
|
||||
} else {
|
||||
this.#source = new ArtifactFileSource(path, "PyInstaller", source);
|
||||
}
|
||||
this.#cleanup.retain({
|
||||
kind: "lease",
|
||||
resource: "PyInstaller private PYZ images",
|
||||
@@ -287,6 +297,7 @@ export class PyInstallerArtifactReader implements ArtifactReader {
|
||||
await this.#cleanup.close();
|
||||
this.#closed = true;
|
||||
this.#loaded = undefined;
|
||||
this.#admission = undefined;
|
||||
this.#members.clear();
|
||||
this.#openedPyzSizes.clear();
|
||||
}
|
||||
@@ -312,8 +323,11 @@ export class PyInstallerArtifactReader implements ArtifactReader {
|
||||
await this.#openHandle(signal);
|
||||
const { handle, initial } = await this.#source.readSelectedPath();
|
||||
const size = initial.size;
|
||||
const pathDialect = await carrierPathDialect(handle, size, signal);
|
||||
const cookie = await this.#cookie(handle, size, signal);
|
||||
const pathDialect =
|
||||
this.#admission?.carrierPathDialect ??
|
||||
(await carrierPathDialect(handle, size, signal));
|
||||
const cookie =
|
||||
this.#admission?.cookie ?? (await this.#cookie(handle, size, signal));
|
||||
this.#budget = artifactExpansionBudgetBytes(cookie.packageLength);
|
||||
const toc = await this.#toc(handle, cookie, signal);
|
||||
for (const entry of toc) {
|
||||
@@ -339,6 +353,7 @@ export class PyInstallerArtifactReader implements ArtifactReader {
|
||||
if (member.read?.kind === "carchive")
|
||||
this.#charge(member.read.header?.length ?? 0);
|
||||
await this.#source.readSelectedPath();
|
||||
this.#admission = undefined;
|
||||
this.#metadata = cookieMetadata(cookie, toc, encrypted);
|
||||
let opaqueImages = 0;
|
||||
for (const image of images.values())
|
||||
|
||||
@@ -225,7 +225,7 @@ it.skipIf(process.platform === "win32")(
|
||||
},
|
||||
});
|
||||
expect(result.limitations).toContain(
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded object inventory is incomplete.",
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -338,7 +338,7 @@ describe("Apple asset catalog and Interface Builder concurrency", () => {
|
||||
return bundle;
|
||||
};
|
||||
const emptyGraph = {
|
||||
graph: { nodes: [], truncated: false },
|
||||
graph: { nodes: [], truncated: false, coverage: [] },
|
||||
} as unknown as Awaited<ReturnType<typeof analyzeInterfaceBuilderBundle>>;
|
||||
|
||||
it("keeps the Interface Builder analysis running while catalogs are inspected", async () => {
|
||||
@@ -690,3 +690,70 @@ it("preserves every own JSON metadata field through catalog inspection and page
|
||||
expect(Object.hasOwn(metadata, "__proto__")).toBe(true);
|
||||
expect(Object.getPrototypeOf(metadata)).toBe(Object.prototype);
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"retains an exited assetutil failure when cancellation arrives while inherited pipes drain",
|
||||
async () => {
|
||||
const bundle = await createTestTempDirectory("rea-asset-exit-pipes-");
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const exited = join(bundle, "root-exited");
|
||||
const release = join(bundle, "release");
|
||||
const descendant = [
|
||||
'const fs = require("node:fs");',
|
||||
"const parent = Number(process.argv[1]);",
|
||||
"const timer = setInterval(() => {",
|
||||
" try { process.kill(parent, 0); } catch {",
|
||||
` fs.writeFileSync(${JSON.stringify(exited)}, "");`,
|
||||
" }",
|
||||
` if (fs.existsSync(${JSON.stringify(release)})) { clearInterval(timer); process.exit(0); }`,
|
||||
"}, 10);",
|
||||
"setTimeout(() => process.exit(0), 5000).unref();",
|
||||
].join("\n");
|
||||
const program = [
|
||||
'const { spawn } = require("node:child_process");',
|
||||
`const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}, String(process.pid)], { stdio: "inherit" });`,
|
||||
"child.unref();",
|
||||
'process.stderr.write("specific catalog failure\\n");',
|
||||
"process.exit(7);",
|
||||
].join("\n");
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
const analysis = analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
signal: controller.signal,
|
||||
runAssetUtil: async (_path, signal) =>
|
||||
(
|
||||
await processOutput.execFileOutput(
|
||||
process.execPath,
|
||||
["-e", program],
|
||||
{
|
||||
signal,
|
||||
stopSignal: "SIGTERM",
|
||||
},
|
||||
)
|
||||
).stdout,
|
||||
})
|
||||
.catch((cause: unknown) => cause)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
await vi.waitFor(() => expect(access(exited)).resolves.toBeUndefined());
|
||||
expect(settled).toBe(false);
|
||||
controller.abort("late cancellation");
|
||||
expect(settled).toBe(false);
|
||||
await writeFile(release, "");
|
||||
expect(await analysis).toMatchObject({
|
||||
_tag: "AnalysisOutputError",
|
||||
diagnostics: { catalog_path: "Assets.car" },
|
||||
capturedOutput: { stderr: "specific catalog failure\n", exit_code: 7 },
|
||||
});
|
||||
} finally {
|
||||
await writeFile(release, "");
|
||||
controller.abort();
|
||||
await analysis;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -43,10 +43,6 @@ const ASSETUTIL = "/usr/bin/assetutil";
|
||||
const MAX_CATALOG_BYTES = 64 * 1024 * 1024;
|
||||
const MAX_TOTAL_METADATA_BYTES = 64 * 1024 * 1024;
|
||||
|
||||
/** The command owner wraps the exact caller reason; do not replace it with the wrapper. */
|
||||
const cancellationCause = (cause: unknown, signal?: AbortSignal): unknown =>
|
||||
cause instanceof ExecFileOutputCancelledError ? cause.cause : signal?.reason;
|
||||
|
||||
/** Inspect compiled asset catalog metadata without extracting rendition bytes. */
|
||||
export const analyzeAppleAssetCatalogs = async (input: {
|
||||
readonly bundlePath: string;
|
||||
@@ -132,6 +128,13 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
const resourceReferences = collectInterfaceBuilderResourceKeys(
|
||||
interfaceBuilder.graph.nodes,
|
||||
);
|
||||
const resourceObjectsIncomplete = interfaceBuilder.graph.coverage.some(
|
||||
({ facet, status }) =>
|
||||
(facet === "archive_object_decode" ||
|
||||
facet === "documents" ||
|
||||
facet.startsWith("objects:")) &&
|
||||
status !== "complete",
|
||||
);
|
||||
const result = projectAppleAssetCatalogPage({
|
||||
targetSha256: input.targetSha256,
|
||||
...(input.resourceRoot === undefined
|
||||
@@ -143,7 +146,7 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
resourceKeyCoverage:
|
||||
catalogCoverage.status !== "complete"
|
||||
? catalogCoverage
|
||||
: interfaceBuilder.graph.truncated
|
||||
: resourceObjectsIncomplete
|
||||
? { status: "partial", reason: "interface_builder_graph_incomplete" }
|
||||
: { status: "complete", reason: null },
|
||||
offset: page.offset,
|
||||
@@ -168,9 +171,9 @@ export const analyzeAppleAssetCatalogs = async (input: {
|
||||
`Asset catalog entries that are not regular files were not inspected, so resource keys reported as unmatched may resolve there: ${uninspected.join(", ")}`,
|
||||
]),
|
||||
"Exact resource-key joins use only recognized image or asset key fields on decoded Interface Builder resource objects; other archive resource references are not inferred.",
|
||||
...(interfaceBuilder.graph.truncated
|
||||
...(resourceObjectsIncomplete
|
||||
? [
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded UI graph was truncated.",
|
||||
"Interface Builder resource-key joins may be incomplete because the decoded object inventory is incomplete.",
|
||||
]
|
||||
: []),
|
||||
],
|
||||
@@ -317,7 +320,7 @@ const inspectAssetCatalogEntry = async (input: {
|
||||
try {
|
||||
output = await input.runAssetUtil(entry.adapterKey, signal);
|
||||
} catch (cause) {
|
||||
throw assetUtilFailure(cause, entry, signal);
|
||||
throw assetUtilFailure(cause, entry);
|
||||
}
|
||||
const after = await lstat(entry.adapterKey, { bigint: true });
|
||||
if (
|
||||
@@ -415,17 +418,13 @@ const runAssetUtil = async (
|
||||
).stdout;
|
||||
|
||||
/** Preserve the command owner's failure and captured output before artifact adaptation. */
|
||||
const assetUtilFailure = (
|
||||
cause: unknown,
|
||||
entry: ArtifactEntry,
|
||||
signal?: AbortSignal,
|
||||
): unknown => {
|
||||
const assetUtilFailure = (cause: unknown, entry: ArtifactEntry): unknown => {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
if (signal?.aborted || cause instanceof ExecFileOutputCancelledError)
|
||||
if (cause instanceof ExecFileOutputCancelledError)
|
||||
return new ArtifactReaderFailure(
|
||||
"cancelled",
|
||||
"Asset catalog inspection cancelled",
|
||||
{ cause: cancellationCause(cause, signal) },
|
||||
{ cause: cause.cause },
|
||||
);
|
||||
const output = execFileOutputFailure(cause);
|
||||
const code =
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
import { symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { build } from "plist";
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { analyzeAppleAssetCatalogs } from "./AppleAssetCatalogAnalysis.js";
|
||||
import { analyzeInterfaceBuilderBundle } from "./InterfaceBuilderAnalysis.js";
|
||||
import { encodeNibArchiveFixture } from "./NibArchive.fixture.js";
|
||||
import { binaryPlistObjectTable } from "../../../tests/fixtures/binaryPlistObjectTable.js";
|
||||
|
||||
const unsupportedUidArchive = (): Buffer => {
|
||||
const bytes = Buffer.alloc(50);
|
||||
bytes[8] = 0x87;
|
||||
bytes.writeBigUInt64BE(9007199254740993n, 9);
|
||||
return binaryPlistObjectTable(bytes, [8], 17);
|
||||
};
|
||||
|
||||
for (const symlinks of [0, 1, 65])
|
||||
it.skipIf(process.platform === "win32" && symlinks > 0)(
|
||||
`retains exact UID and mixed omission diagnoses with ${symlinks} symlinks`,
|
||||
async () => {
|
||||
const bundle = await createTestTempDirectory("rea-resource-uid-range-");
|
||||
await writeFile(join(bundle, "Unsupported.nib"), unsupportedUidArchive());
|
||||
await writeFile(
|
||||
join(bundle, "Valid.nib"),
|
||||
build({
|
||||
"com.apple.ibtool.document.objects": {
|
||||
image: { customClass: "NSImage", NSImageName: "Toolbar" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (let index = 0; index < symlinks; index += 1)
|
||||
await symlink("Valid.nib", join(bundle, `Alias-${index}.nib`));
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const report = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
});
|
||||
for (const facet of ["archive_decode", "archive_object_decode"])
|
||||
expect(report.graph.coverage).toContainEqual({
|
||||
facet,
|
||||
status: "partial",
|
||||
reason:
|
||||
symlinks === 0
|
||||
? "unsupported_uid_exact_range"
|
||||
: "multiple_archive_decode_causes",
|
||||
examined: 2,
|
||||
omitted: 1 + symlinks,
|
||||
});
|
||||
expect(report.limitations).toContain(
|
||||
"1 Interface Builder archive(s) omitted because unsupported uid exact range.",
|
||||
);
|
||||
if (symlinks > 0)
|
||||
expect(report.limitations).toContain(
|
||||
`${symlinks} Interface Builder archive(s) omitted because symlink not followed.`,
|
||||
);
|
||||
if (symlinks < 64)
|
||||
expect(report.limitations).toContain(
|
||||
"Unsupported.nib: omitted because unsupported uid exact range.",
|
||||
);
|
||||
else
|
||||
expect(report.limitations).toContain(
|
||||
"2 additional omitted archive path(s) and their reasons were not retained; reporting is bounded to 64 paths.",
|
||||
);
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
analyzeInterfaceBuilder: async () => report,
|
||||
runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]),
|
||||
});
|
||||
expect(result.resource_key_coverage.status).toBe(
|
||||
symlinks === 0 ? "partial" : "unknown",
|
||||
);
|
||||
expect(result.resource_key_matches).toMatchObject([
|
||||
{ key: "Toolbar", status: "matched" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"hierarchy",
|
||||
"connections",
|
||||
"registry",
|
||||
"embedded-objects",
|
||||
"invalid-archive",
|
||||
"object-limit",
|
||||
] as const)(
|
||||
"qualifies resource-key coverage by object inventory rather than %s graph state",
|
||||
async (mode) => {
|
||||
const bundle = await createTestTempDirectory("rea-resource-coverage-");
|
||||
const archive = encodeNibArchiveFixture({
|
||||
classes: [
|
||||
"NSIBObjectData",
|
||||
"NSImage",
|
||||
"NSString",
|
||||
"UIStoryboardShowSegueTemplate",
|
||||
"UINib",
|
||||
"NSArray",
|
||||
],
|
||||
objects: [
|
||||
{
|
||||
classIndex: 0,
|
||||
values: {
|
||||
NSRoot: { ref: 1 },
|
||||
...(mode === "registry"
|
||||
? { NSObjectsKeys: { ref: 3 }, NSObjectsValues: { ref: 4 } }
|
||||
: {}),
|
||||
},
|
||||
},
|
||||
{ classIndex: 1, values: { NSImageName: { ref: 2 } } },
|
||||
{ classIndex: 2, values: { "NS.bytes": "Toolbar" } },
|
||||
...(mode === "connections" || mode === "invalid-archive"
|
||||
? [{ classIndex: 3, values: {} }]
|
||||
: []),
|
||||
...(mode === "registry"
|
||||
? [
|
||||
{ classIndex: 5, values: { "NS.objects": { ref: 1 } } },
|
||||
{ classIndex: 5, values: { "NS.objects": { ref: 2 } } },
|
||||
]
|
||||
: []),
|
||||
...(mode === "embedded-objects"
|
||||
? [
|
||||
{
|
||||
classIndex: 4,
|
||||
values: {
|
||||
archiveData: {
|
||||
data: Buffer.from("unexamined nested archive"),
|
||||
},
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
});
|
||||
await writeFile(join(bundle, "Panel.nib"), archive);
|
||||
if (mode === "invalid-archive")
|
||||
await writeFile(join(bundle, "Invalid.nib"), "invalid nib");
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
...(mode === "object-limit" ? { limits: { max_objects: 1 } } : {}),
|
||||
});
|
||||
expect(interfaceBuilder.graph.truncated).toBe(true);
|
||||
expect(interfaceBuilder.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_object_decode",
|
||||
status:
|
||||
mode === "embedded-objects" || mode === "invalid-archive"
|
||||
? "partial"
|
||||
: "complete",
|
||||
}),
|
||||
);
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
analyzeInterfaceBuilder: async () => interfaceBuilder,
|
||||
runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]),
|
||||
});
|
||||
expect(result.resource_key_coverage.status).toBe(
|
||||
mode === "hierarchy" || mode === "connections" || mode === "registry"
|
||||
? "complete"
|
||||
: "partial",
|
||||
);
|
||||
const incomplete = result.resource_key_coverage.status === "partial";
|
||||
expect(
|
||||
result.limitations.some((limitation) =>
|
||||
limitation.includes("resource-key joins may be incomplete"),
|
||||
),
|
||||
).toBe(incomplete);
|
||||
if (mode === "invalid-archive")
|
||||
expect(interfaceBuilder.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({
|
||||
facet: "archive_object_decode",
|
||||
reason: "one_or_more_archives_invalid",
|
||||
}),
|
||||
);
|
||||
if (mode === "registry" || mode === "connections")
|
||||
expect(interfaceBuilder.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({ facet: "archive_decode", status: "partial" }),
|
||||
);
|
||||
if (mode !== "object-limit")
|
||||
expect(result.resource_key_matches).toMatchObject([
|
||||
{ key: "Toolbar", status: "matched" },
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps resource coverage complete when only synthetic hierarchy nodes exceed graph capacity", async () => {
|
||||
const bundle = await createTestTempDirectory(
|
||||
"rea-resource-hierarchy-capacity-",
|
||||
);
|
||||
await writeFile(
|
||||
join(bundle, "Panel.nib"),
|
||||
build({
|
||||
"com.apple.ibtool.document.objects": {
|
||||
image: { customClass: "NSImage", NSImageName: "Toolbar" },
|
||||
},
|
||||
"com.apple.ibtool.document.connections": {},
|
||||
"com.apple.ibtool.document.hierarchy": [{ objectID: "unlisted-view" }],
|
||||
"com.apple.ibtool.document.classes": {},
|
||||
}),
|
||||
);
|
||||
await writeFile(join(bundle, "Assets.car"), "inert catalog");
|
||||
const interfaceBuilder = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
limits: { max_objects: 2 },
|
||||
});
|
||||
expect(interfaceBuilder.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({ facet: "graph_nodes", status: "partial" }),
|
||||
);
|
||||
expect(interfaceBuilder.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({ facet: "objects:Panel.nib", status: "complete" }),
|
||||
);
|
||||
const result = await analyzeAppleAssetCatalogs({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "a".repeat(64),
|
||||
environment: {},
|
||||
analyzeInterfaceBuilder: async () => interfaceBuilder,
|
||||
runAssetUtil: async () => JSON.stringify([{ Name: "Toolbar" }]),
|
||||
});
|
||||
expect(result.resource_key_coverage.status).toBe("complete");
|
||||
expect(result.resource_key_matches).toMatchObject([
|
||||
{ key: "Toolbar", status: "matched" },
|
||||
]);
|
||||
});
|
||||
@@ -101,6 +101,11 @@ const decodeInterfaceBuilderBundle = async (
|
||||
const documents: InterfaceBuilderDocumentInput[] = [];
|
||||
const invalid: string[] = [];
|
||||
const omittedArchives: OmittedArchive[] = [];
|
||||
const omissionCounts = new Map<string, number>();
|
||||
const recordOmission = (reason: string, count = 1): void => {
|
||||
if (count > 0)
|
||||
omissionCounts.set(reason, (omissionCounts.get(reason) ?? 0) + count);
|
||||
};
|
||||
let unreportedOmittedArchives = 0;
|
||||
const incompleteHierarchies = new Map<string, number>();
|
||||
const unexaminedSources = new Map<string, readonly NibUnexaminedSource[]>();
|
||||
@@ -120,7 +125,7 @@ const decodeInterfaceBuilderBundle = async (
|
||||
let outcome: { kind: "completed" } | { kind: "failed"; cause: unknown } = {
|
||||
kind: "completed",
|
||||
};
|
||||
const recordOmittedArchive = (
|
||||
const retainOmittedArchive = (
|
||||
path: string,
|
||||
reason: string,
|
||||
limitations: readonly string[] = [],
|
||||
@@ -129,6 +134,11 @@ const decodeInterfaceBuilderBundle = async (
|
||||
omittedArchives.push({ path, reason, limitations });
|
||||
else unreportedOmittedArchives += 1;
|
||||
};
|
||||
const recordOmittedArchive = (path: string, reason: string): void => {
|
||||
omitted += 1;
|
||||
recordOmission(reason);
|
||||
retainOmittedArchive(path, reason);
|
||||
};
|
||||
try {
|
||||
const selection = await selectInterfaceBuilderCandidates(
|
||||
reader,
|
||||
@@ -137,10 +147,12 @@ const decodeInterfaceBuilderBundle = async (
|
||||
traversal,
|
||||
);
|
||||
omitted = selection.omitted;
|
||||
recordOmission("max_documents", selection.documentLimitOmissions);
|
||||
recordOmission("symlink_not_followed", selection.symlinkOmissions);
|
||||
for (const entry of selection.omittedSamples)
|
||||
recordOmittedArchive(entry.path, "max_documents");
|
||||
retainOmittedArchive(entry.path, "max_documents");
|
||||
for (const entry of selection.symlinkSamples)
|
||||
recordOmittedArchive(
|
||||
retainOmittedArchive(
|
||||
entry.path,
|
||||
"symlink_not_followed",
|
||||
entry.limitations,
|
||||
@@ -156,7 +168,6 @@ const decodeInterfaceBuilderBundle = async (
|
||||
entry.declaredSize !== null &&
|
||||
entry.declaredSize > remainingInputBytes
|
||||
) {
|
||||
omitted += 1;
|
||||
recordOmittedArchive(entry.path, "aggregate_decode_budget_exhausted");
|
||||
continue;
|
||||
}
|
||||
@@ -262,12 +273,10 @@ const decodeInterfaceBuilderBundle = async (
|
||||
if (cause instanceof InterfaceBuilderDecodeBudgetExceeded) {
|
||||
// A document rejected while decoding, e.g. beyond the shared depth
|
||||
// limit, retains no representation; later documents keep its share.
|
||||
omitted += 1;
|
||||
recordOmittedArchive(entry.path, cause.reason);
|
||||
continue;
|
||||
}
|
||||
if (cause instanceof UnsupportedBinaryPlistUidError) {
|
||||
omitted += 1;
|
||||
recordOmittedArchive(entry.path, "unsupported_uid_exact_range");
|
||||
continue;
|
||||
}
|
||||
@@ -308,6 +317,7 @@ const decodeInterfaceBuilderBundle = async (
|
||||
invalid,
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
omissionCounts,
|
||||
attempted,
|
||||
omitted,
|
||||
traversal: traversal.snapshot(),
|
||||
@@ -325,6 +335,8 @@ const selectInterfaceBuilderCandidates = async (
|
||||
readonly omittedSamples: readonly ArtifactEntry[];
|
||||
readonly symlinkSamples: readonly ArtifactEntry[];
|
||||
readonly omitted: number;
|
||||
readonly documentLimitOmissions: number;
|
||||
readonly symlinkOmissions: number;
|
||||
readonly unreportedOmitted: number;
|
||||
}> => {
|
||||
const capacity = maximumDocuments + MAX_REPORTED_OMITTED_ARCHIVES;
|
||||
@@ -366,6 +378,8 @@ const selectInterfaceBuilderCandidates = async (
|
||||
omittedSamples,
|
||||
symlinkSamples,
|
||||
omitted: Math.max(0, total - maximumDocuments) + symlinks,
|
||||
documentLimitOmissions: Math.max(0, total - maximumDocuments),
|
||||
symlinkOmissions: symlinks,
|
||||
unreportedOmitted:
|
||||
Math.max(0, total - maximumDocuments - omittedSamples.length) +
|
||||
symlinks -
|
||||
@@ -409,6 +423,7 @@ const finalizeHierarchyCoverage = (
|
||||
invalid: readonly string[];
|
||||
omittedArchives: readonly OmittedArchive[];
|
||||
unreportedOmittedArchives: number;
|
||||
omissionCounts: ReadonlyMap<string, number>;
|
||||
attempted: number;
|
||||
omitted: number;
|
||||
traversal: ArtifactReaderTraversal;
|
||||
@@ -427,11 +442,35 @@ const finalizeHierarchyCoverage = (
|
||||
omittedArchives,
|
||||
unreportedOmittedArchives,
|
||||
} = counts;
|
||||
const archiveDecodePartial =
|
||||
invalid.length > 0 ||
|
||||
unexaminedSources.size > 0 ||
|
||||
counts.omitted > 0 ||
|
||||
counts.traversal.status === "unknown";
|
||||
const sourceReasons = new Set(
|
||||
[...counts.omissionCounts.keys()].map((reason) =>
|
||||
reason === "max_documents"
|
||||
? "document_limit_reached"
|
||||
: reason === "symlink_not_followed"
|
||||
? "archive_symlink_not_followed"
|
||||
: reason,
|
||||
),
|
||||
);
|
||||
if (invalid.length > 0) sourceReasons.add("one_or_more_archives_invalid");
|
||||
if (counts.traversal.status === "unknown")
|
||||
sourceReasons.add("source_traversal_unknown");
|
||||
const sourceDecodeReason =
|
||||
sourceReasons.size > 1
|
||||
? "multiple_archive_decode_causes"
|
||||
: (sourceReasons.values().next().value ?? null);
|
||||
const archiveDecodeReason =
|
||||
sourceDecodeReason ??
|
||||
(unexaminedSources.size > 0
|
||||
? "serialized_archive_content_unexamined"
|
||||
: null);
|
||||
const archiveObjectDecodeReason =
|
||||
sourceDecodeReason ??
|
||||
([...unexaminedSources.values()].some((sources) =>
|
||||
sources.some((source) => source.facets.includes("objects")),
|
||||
)
|
||||
? "serialized_archive_content_unexamined"
|
||||
: null);
|
||||
const archiveDecodePartial = archiveDecodeReason !== null;
|
||||
return {
|
||||
...result,
|
||||
documents: result.documents.map((document) => ({
|
||||
@@ -479,28 +518,21 @@ const finalizeHierarchyCoverage = (
|
||||
}),
|
||||
{
|
||||
facet: "archive_decode",
|
||||
status: archiveDecodePartial
|
||||
? ("partial" as const)
|
||||
: ("complete" as const),
|
||||
reason: omittedArchives.some(
|
||||
({ reason }) => reason === "aggregate_decode_budget_exhausted",
|
||||
)
|
||||
? "aggregate_decode_budget_exhausted"
|
||||
: omittedArchives.some(({ reason }) => reason === "max_documents")
|
||||
? "document_limit_reached"
|
||||
: omittedArchives.some(
|
||||
({ reason }) => reason === "symlink_not_followed",
|
||||
)
|
||||
? "archive_symlink_not_followed"
|
||||
: omittedArchives.length > 0
|
||||
? "archive_decode_budget_exhausted"
|
||||
: invalid.length > 0
|
||||
? "one_or_more_archives_invalid"
|
||||
: unexaminedSources.size > 0
|
||||
? "serialized_archive_content_unexamined"
|
||||
: counts.traversal.status === "unknown"
|
||||
? "source_traversal_unknown"
|
||||
: null,
|
||||
status:
|
||||
archiveDecodeReason === null
|
||||
? ("complete" as const)
|
||||
: ("partial" as const),
|
||||
reason: archiveDecodeReason,
|
||||
examined: counts.attempted,
|
||||
omitted: counts.omitted,
|
||||
},
|
||||
{
|
||||
facet: "archive_object_decode",
|
||||
status:
|
||||
archiveObjectDecodeReason === null
|
||||
? ("complete" as const)
|
||||
: ("partial" as const),
|
||||
reason: archiveObjectDecodeReason,
|
||||
examined: counts.attempted,
|
||||
omitted: counts.omitted,
|
||||
},
|
||||
@@ -548,6 +580,12 @@ const finalizeHierarchyCoverage = (
|
||||
...[...negativeZeroReals].map(
|
||||
([path, count]) => `${path}: ${negativeZeroRealLimitation(count)}`,
|
||||
),
|
||||
...[...counts.omissionCounts]
|
||||
.sort(([left], [right]) => left.localeCompare(right))
|
||||
.map(
|
||||
([reason, count]) =>
|
||||
`${count} Interface Builder archive(s) omitted because ${reason.replaceAll("_", " ")}.`,
|
||||
),
|
||||
...omittedArchives.flatMap(({ path, reason, limitations }) => [
|
||||
`${path}: omitted because ${reason.replaceAll("_", " ")}.`,
|
||||
...limitations.map((limitation) => `${path}: ${limitation}`),
|
||||
|
||||
@@ -240,18 +240,15 @@ export const inspectStandaloneKeyedArchive = (input: {
|
||||
const selected = keyedArchiveInputSchema.parse(input.parameters);
|
||||
const selectedBasename = basename(input.selectedPath);
|
||||
const physicalBasename = basename(input.path);
|
||||
if (
|
||||
selected.path !== "." &&
|
||||
selected.path !== selectedBasename &&
|
||||
selected.path !== physicalBasename
|
||||
)
|
||||
const basenames = [...new Set([selectedBasename, physicalBasename])];
|
||||
if (selected.path !== "." && !basenames.includes(selected.path))
|
||||
throw new AnalysisInputError("inspect_keyed_archive", undefined, [
|
||||
{
|
||||
path: ["path"],
|
||||
reason: "invalid_value",
|
||||
message:
|
||||
"For an active plist, path must select that archive (omit path or use its basename).",
|
||||
expected: [selectedBasename, physicalBasename],
|
||||
expected: basenames.length === 1 ? selectedBasename : basenames,
|
||||
},
|
||||
]);
|
||||
await assertStandaloneArchiveSelection(
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GodotPckAdmission } from "./GodotPckLocation.js";
|
||||
import type { PyInstallerAdmission } from "./pyinstaller/PyInstallerAdmission.js";
|
||||
import type { NodeSeaAdmission } from "./nodeSea/NodeSeaAdmission.js";
|
||||
import type { AsarHeaderAdmission } from "../config/asarHeaderAdmission.js";
|
||||
import { AsarArtifactReader } from "./AsarArtifactReader.js";
|
||||
@@ -33,6 +35,8 @@ interface ArtifactReaderConstruction {
|
||||
readonly admission?:
|
||||
| StableRegularFileDescriptor
|
||||
| NodeSeaAdmission
|
||||
| PyInstallerAdmission
|
||||
| GodotPckAdmission
|
||||
| undefined;
|
||||
readonly asarHeaderAdmission?: AsarHeaderAdmission | undefined;
|
||||
/** Caller-selected root spelling; only ASAR companion lookup consumes this. */
|
||||
@@ -75,11 +79,32 @@ export function createArtifactReader(
|
||||
: { selectedPath: options.selectedPath }),
|
||||
});
|
||||
case "pyinstaller":
|
||||
return new PyInstallerArtifactReader(path, rootSource);
|
||||
return new PyInstallerArtifactReader(
|
||||
path,
|
||||
admission !== undefined &&
|
||||
"kind" in admission &&
|
||||
admission.kind === "pyinstaller"
|
||||
? admission
|
||||
: rootSource,
|
||||
);
|
||||
case "node-sea":
|
||||
return new NodeSeaArtifactReader(path, admission);
|
||||
return new NodeSeaArtifactReader(
|
||||
path,
|
||||
admission !== undefined &&
|
||||
"kind" in admission &&
|
||||
admission.kind === "node-sea"
|
||||
? admission
|
||||
: rootSource,
|
||||
);
|
||||
case "godot-pck":
|
||||
return new GodotPckArtifactReader(path, rootSource);
|
||||
return new GodotPckArtifactReader(
|
||||
path,
|
||||
admission !== undefined &&
|
||||
"kind" in admission &&
|
||||
admission.kind === "godot-pck"
|
||||
? admission
|
||||
: rootSource,
|
||||
);
|
||||
case "mach-o-universal":
|
||||
return new MachOSliceArtifactReader(
|
||||
path,
|
||||
|
||||
@@ -375,7 +375,7 @@ it.each([false, true] as const)(
|
||||
|
||||
vi.mock("../pyinstaller/CArchiveLayout.js", async (importOriginal) => ({
|
||||
...(await importOriginal<object>()),
|
||||
containsPyInstallerArchive: () =>
|
||||
findPyInstallerCookie: () =>
|
||||
Promise.reject(new ArtifactReaderFailure("format", "detection failed")),
|
||||
}));
|
||||
|
||||
|
||||
@@ -603,7 +603,7 @@ const requireExtractableFormat = async (
|
||||
(await probes.godotPck()) !== undefined ||
|
||||
((format === "elf" || format === "pe" || format === "mach-o") &&
|
||||
((await probes.nodeSea()) !== undefined ||
|
||||
(await probes.pyInstaller())));
|
||||
(await probes.pyInstaller()) !== undefined));
|
||||
if (pack && input.expectedRootSha256 !== undefined) {
|
||||
const digest = await hashStableRootArtifactHandle(
|
||||
path,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GodotPckAdmission } from "../GodotPckLocation.js";
|
||||
import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js";
|
||||
import { assertArtifactSelectedSource } from "../ArtifactSelectedSource.js";
|
||||
import { artifactNameForClassification } from "../../domain/artifactNameSelection.js";
|
||||
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
|
||||
@@ -51,6 +53,8 @@ type RootInventoryClassification = RootClassification & {
|
||||
readonly readerAdmission:
|
||||
| StableRegularFileDescriptor
|
||||
| NodeSeaAdmission
|
||||
| PyInstallerAdmission
|
||||
| GodotPckAdmission
|
||||
| undefined;
|
||||
};
|
||||
|
||||
@@ -202,7 +206,12 @@ const classifyRootFormat = async (
|
||||
signal?: AbortSignal,
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
selectedPath?: string,
|
||||
): Promise<ArtifactOccurrence["artifact_format"] | NodeSeaAdmission> => {
|
||||
): Promise<
|
||||
| ArtifactOccurrence["artifact_format"]
|
||||
| NodeSeaAdmission
|
||||
| PyInstallerAdmission
|
||||
| GodotPckAdmission
|
||||
> => {
|
||||
const { handle } = source;
|
||||
const probe = createArtifactClassificationProbe(asarHeaderAdmission);
|
||||
const chunk = Buffer.alloc(64 * 1024);
|
||||
@@ -255,7 +264,8 @@ const classifyRootFormat = async (
|
||||
(await hasDiskImageSignature(archiveBytes, read))
|
||||
)
|
||||
return "dmg";
|
||||
if (await embeddedProbes.godotPck(evidence.prefix)) return "godot-pck";
|
||||
const pck = await embeddedProbes.godotPck(evidence.prefix);
|
||||
if (pck !== undefined) return { kind: "godot-pck", source, location: pck };
|
||||
const fatEvidence = await readRootFatSliceHeaders(handle, evidence, signal);
|
||||
const mzWindowsHeaderSignature = await readRootMzWindowsHeaderSignature(
|
||||
handle,
|
||||
@@ -292,7 +302,15 @@ const classifyRootFormat = async (
|
||||
}
|
||||
const packaged = await embeddedProbes.pyInstaller();
|
||||
abortIfNeeded(signal);
|
||||
if (packaged) return "pyinstaller";
|
||||
// A valid cookie also proves enough bytes for a thin Mach-O identity;
|
||||
// PE and universal Mach-O classification already require stronger proof.
|
||||
if (packaged !== undefined)
|
||||
return {
|
||||
kind: "pyinstaller",
|
||||
source,
|
||||
cookie: packaged,
|
||||
carrierPathDialect: format === "pe" ? "windows" : "posix",
|
||||
};
|
||||
}
|
||||
return format;
|
||||
};
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { GodotPckAdmission } from "../GodotPckLocation.js";
|
||||
import type { PyInstallerAdmission } from "../pyinstaller/PyInstallerAdmission.js";
|
||||
import type { NodeSeaAdmission } from "../nodeSea/NodeSeaAdmission.js";
|
||||
import type { AsarHeaderAdmission } from "../../config/asarHeaderAdmission.js";
|
||||
import type {
|
||||
@@ -19,7 +21,11 @@ export const createReader = (
|
||||
path: string,
|
||||
format: ArtifactOccurrence["artifact_format"],
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
readerAdmission?: StableRegularFileDescriptor | NodeSeaAdmission,
|
||||
readerAdmission?:
|
||||
| StableRegularFileDescriptor
|
||||
| NodeSeaAdmission
|
||||
| PyInstallerAdmission
|
||||
| GodotPckAdmission,
|
||||
asarHeaderAdmission?: AsarHeaderAdmission,
|
||||
selectedPath?: string,
|
||||
): ArtifactReader | undefined => {
|
||||
|
||||
@@ -83,16 +83,6 @@ export const isCArchiveDeclaration = (entry: CArchiveEntry): boolean =>
|
||||
export const isCArchivePythonModule = (entry: CArchiveEntry): boolean =>
|
||||
entry.role === "module" || entry.role === "package";
|
||||
|
||||
/** True when a native executable has a structurally valid CArchive cookie. */
|
||||
export const containsPyInstallerArchive = async (
|
||||
handle: FileHandle,
|
||||
fileSize: number,
|
||||
signal?: AbortSignal,
|
||||
): Promise<boolean> => {
|
||||
const found = await findPyInstallerCookie(handle, fileSize, signal);
|
||||
return found?.ok === true;
|
||||
};
|
||||
|
||||
/** Locate a structurally valid cookie. Invalid magic copies are skipped. */
|
||||
export const findPyInstallerCookie = async (
|
||||
handle: FileHandle,
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
import { appendFile, lstat, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { createTestTempDirectory } from "../../../tests/fixtures/temporaryDirectory.js";
|
||||
import { withOwnedRegularFile } from "../../filesystem/OwnedRegularFile.js";
|
||||
import { ArtifactEmbeddedFormatProbes } from "../ArtifactEmbeddedFormatProbes.js";
|
||||
import { ArtifactResourceScope } from "../ArtifactResourceScope.js";
|
||||
import { inventoryArtifact } from "../inventory/ArtifactInventory.js";
|
||||
import { classifyAndHashRootForInventory } from "../inventory/classify.js";
|
||||
import { PyInstallerArtifactReader } from "../PyInstallerArtifactReader.js";
|
||||
import { cArchiveExecutable } from "./CArchive.fixture.js";
|
||||
import * as layout from "./CArchiveLayout.js";
|
||||
|
||||
const fixture = () =>
|
||||
cArchiveExecutable({
|
||||
layout: "2.1",
|
||||
pythonVersion: 314,
|
||||
pythonLibrary: "libpython3.14.so",
|
||||
members: [
|
||||
{
|
||||
name: "payload",
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("fixture"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
carrier: "elf",
|
||||
offset: undefined,
|
||||
dialect: "posix",
|
||||
path: String.raw`pkg\data.bin`,
|
||||
},
|
||||
{ carrier: "pe", offset: 64, dialect: "windows", path: "pkg/data.bin" },
|
||||
{ carrier: "pe", offset: 8190, dialect: "windows", path: "pkg/data.bin" },
|
||||
] as const)(
|
||||
"retains $carrier carrier dialect at offset $offset through member projection",
|
||||
async ({ carrier, offset, dialect, path: memberPath }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-pyinstaller-carrier-admission-",
|
||||
);
|
||||
const path = join(root, "app");
|
||||
const sourceName = String.raw`pkg\data.bin`;
|
||||
await writeFile(
|
||||
path,
|
||||
cArchiveExecutable({
|
||||
carrier,
|
||||
...(offset === undefined ? {} : { peHeaderOffset: offset }),
|
||||
layout: "2.1",
|
||||
pythonVersion: 314,
|
||||
members: [
|
||||
{
|
||||
name: sourceName,
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("fixture"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
try {
|
||||
const classified = await classifyAndHashRootForInventory(
|
||||
path,
|
||||
false,
|
||||
await lstat(path),
|
||||
{ resourceScope },
|
||||
);
|
||||
const admission = classified.readerAdmission;
|
||||
if (
|
||||
admission === undefined ||
|
||||
!("kind" in admission) ||
|
||||
admission.kind !== "pyinstaller"
|
||||
)
|
||||
throw new Error("Fixture did not produce a PyInstaller admission");
|
||||
expect(admission.carrierPathDialect).toBe(dialect);
|
||||
const reader = new PyInstallerArtifactReader(path, admission);
|
||||
try {
|
||||
if (classified.digest === null)
|
||||
throw new Error("Missing carrier digest");
|
||||
await reader.prepareContainer(classified.digest.sha256);
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
expect(entries).toEqual([
|
||||
expect.objectContaining({
|
||||
path: memberPath,
|
||||
formatMetadata: expect.objectContaining({
|
||||
source_name: sourceName,
|
||||
}),
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
} finally {
|
||||
await resourceScope.close();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("preserves backslashes when a standalone archive has no recognized carrier", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-pyinstaller-unknown-carrier-",
|
||||
);
|
||||
const path = join(root, "app");
|
||||
const sourceName = String.raw`pkg\data.bin`;
|
||||
const bytes = cArchiveExecutable({
|
||||
layout: "2.1",
|
||||
pythonVersion: 314,
|
||||
members: [
|
||||
{
|
||||
name: sourceName,
|
||||
typeCode: "x",
|
||||
payload: Buffer.from("fixture"),
|
||||
compress: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
bytes.fill(0, 0, 64);
|
||||
await writeFile(path, bytes);
|
||||
const reader = new PyInstallerArtifactReader(path);
|
||||
try {
|
||||
const entries = [];
|
||||
for await (const entry of reader.entries()) entries.push(entry);
|
||||
expect(entries.map((entry) => entry.path)).toEqual([sourceName]);
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("inventories members using the cookie discovered by root classification", async () => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-pyinstaller-cookie-inventory-",
|
||||
);
|
||||
const path = join(root, "app");
|
||||
await writeFile(path, fixture());
|
||||
const locate = vi.spyOn(layout, "findPyInstallerCookie");
|
||||
const resourceScope = new ArtifactResourceScope();
|
||||
try {
|
||||
const result = await inventoryArtifact(path, {
|
||||
environment: {},
|
||||
resourceScope,
|
||||
});
|
||||
expect(result.manifest.root_format).toBe("pyinstaller");
|
||||
expect(
|
||||
result.occurrences.some(
|
||||
(entry) =>
|
||||
entry.logical_path === "payload" && entry.hash_status === "verified",
|
||||
),
|
||||
).toBe(true);
|
||||
expect(locate).toHaveBeenCalledTimes(1);
|
||||
} finally {
|
||||
await resourceScope.close();
|
||||
locate.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects changed source before consuming an admitted cookie and leaves the borrowed descriptor open", async () => {
|
||||
const root = await createTestTempDirectory("rea-pyinstaller-cookie-change-");
|
||||
const path = join(root, "app");
|
||||
await writeFile(path, fixture());
|
||||
await withOwnedRegularFile(path, {}, async (source) => {
|
||||
const cookie = await new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
).pyInstaller();
|
||||
if (cookie === undefined) throw new Error("Fixture cookie was not found");
|
||||
const reader = new PyInstallerArtifactReader(path, {
|
||||
kind: "pyinstaller",
|
||||
source,
|
||||
cookie,
|
||||
carrierPathDialect: "posix",
|
||||
});
|
||||
await appendFile(path, "changed");
|
||||
try {
|
||||
await expect(
|
||||
(async () => {
|
||||
for await (const entry of reader.entries())
|
||||
throw new Error(`Changed source exposed member ${entry.path}`);
|
||||
})(),
|
||||
).rejects.toMatchObject({ reason: "integrity" });
|
||||
} finally {
|
||||
await reader.close();
|
||||
}
|
||||
await expect(source.handle.stat()).resolves.toMatchObject({
|
||||
size: fixture().length + 7,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("retains completed cookie facts across unchanged admissions and honors cached cancellation", async () => {
|
||||
const root = await createTestTempDirectory("rea-pyinstaller-cookie-cache-");
|
||||
const path = join(root, "app");
|
||||
await writeFile(path, fixture());
|
||||
const locate = vi.spyOn(layout, "findPyInstallerCookie");
|
||||
try {
|
||||
await withOwnedRegularFile(path, {}, async (source) => {
|
||||
const controller = new AbortController();
|
||||
const probes = new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
controller.signal,
|
||||
);
|
||||
const cookie = await probes.pyInstaller();
|
||||
expect(cookie).toMatchObject({ layout: "2.1", pythonVersion: 314 });
|
||||
const resumed = new ArtifactEmbeddedFormatProbes(
|
||||
path,
|
||||
source,
|
||||
controller.signal,
|
||||
probes.completedFacts(),
|
||||
);
|
||||
await expect(resumed.pyInstaller()).resolves.toBe(cookie);
|
||||
controller.abort(new Error("cancel cached cookie"));
|
||||
await expect(resumed.pyInstaller()).rejects.toMatchObject({
|
||||
reason: "cancelled",
|
||||
});
|
||||
expect(locate).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
} finally {
|
||||
locate.mockRestore();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,12 @@
|
||||
import type { StableRegularFileDescriptor } from "../../filesystem/RegularFile.js";
|
||||
import type { PyInstallerCookie } from "./CArchiveLayout.js";
|
||||
import type { CarrierPathDialect } from "./CArchiveMembers.js";
|
||||
|
||||
/** Validated cookie facts borrowed with the descriptor that produced them. */
|
||||
export interface PyInstallerAdmission {
|
||||
readonly kind: "pyinstaller";
|
||||
readonly source: StableRegularFileDescriptor;
|
||||
readonly cookie: PyInstallerCookie;
|
||||
/** Carrier interpretation retained by the classifier that admitted this source. */
|
||||
readonly carrierPathDialect?: CarrierPathDialect;
|
||||
}
|
||||
@@ -868,7 +868,6 @@ const originalModuleEdges = (
|
||||
) => void,
|
||||
): OriginalModuleEdges => {
|
||||
const edges: ParsedSourceMapItem["original_module_edges"] = [];
|
||||
const seen = new Set<string>();
|
||||
const incomplete: string[] = [];
|
||||
for (const source of sources) {
|
||||
check();
|
||||
@@ -892,6 +891,7 @@ const originalModuleEdges = (
|
||||
// A recovered program still yields the imports it did parse, but nodes
|
||||
// after an unrecoverable point are missing, so the edges are a subset.
|
||||
if (parsed.errors.length > 0) incomplete.push(sourceName);
|
||||
const seen = new Set<string>();
|
||||
let unboundRequires: ReadonlySet<string> | undefined;
|
||||
traverseJavaScriptAst(parsed, {
|
||||
enter: (node) => {
|
||||
@@ -920,10 +920,11 @@ const originalModuleEdges = (
|
||||
)
|
||||
return;
|
||||
}
|
||||
const key = `${sourceName}\0${kind}\0${specifier}`;
|
||||
const key = `${kind}\0${specifier}`;
|
||||
if (seen.has(key)) return;
|
||||
seen.add(key);
|
||||
const edge = {
|
||||
declaration_index: source.declaration_index,
|
||||
from_source: sourceName,
|
||||
kind,
|
||||
specifier,
|
||||
|
||||
@@ -58,6 +58,7 @@ const artifact = <
|
||||
name: Name,
|
||||
description: string,
|
||||
inputSchema: Schema,
|
||||
retainedSummary?: ToolContract["retainedSummary"],
|
||||
) => {
|
||||
const outputSchema = requireOutputSchema(artifactOutputSchemas, name);
|
||||
return {
|
||||
@@ -66,6 +67,7 @@ const artifact = <
|
||||
description,
|
||||
kind: "artifact-provider",
|
||||
requiresActiveTarget: true,
|
||||
...(retainedSummary === undefined ? {} : { retainedSummary }),
|
||||
inputSchema,
|
||||
outputSchema,
|
||||
examples: [
|
||||
@@ -83,6 +85,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
"inspect_artifact",
|
||||
"Inspect an active archive or application package, including Node.js SEA carriers, in one call. By default returns the complete content-addressed artifact graph once, as the typed nested inventory Evidence, with root and integrity observations, integrity contradictions, hypotheses, unexplored branches, limitations, and next probes inline. For PCK and large containers prefer detail summary: it retains the complete inspection and returns its derived summary view; read exact paths, batch occurrence/content IDs and selected fields with inspect_analysis_view using parent_evidence_id. This changes delivery, not inventory coverage. Read-only DMG mounting is used automatically on supported macOS hosts. Integrity mismatches fail by default; record-and-continue keeps mismatches explicitly untrusted. It does not extract files; use extract_artifact to materialize regular contents of supported formats. DMG extraction is unavailable; use a caller-managed read-only mount on macOS to access its files.",
|
||||
artifactInspectionInputSchema,
|
||||
{ evidenceParameters: "preserve-detail" },
|
||||
),
|
||||
artifact(
|
||||
"extract_artifact",
|
||||
@@ -103,6 +106,7 @@ export const ARTIFACT_TOOL_CONTRACTS = [
|
||||
"Omitted or complete returns the complete decoded graph Evidence. summary retains that complete Evidence in this session and returns its inspect_analysis_view summary; use normalized_result.parent_evidence_id for interface-builder pages.",
|
||||
),
|
||||
}),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
artifact(
|
||||
"inspect_keyed_archive",
|
||||
|
||||
@@ -60,6 +60,7 @@ const nativeBoundaryOutputSchema = requireOutputSchema(
|
||||
export const MANAGED_TOOL_CONTRACTS = [
|
||||
{
|
||||
name: "inspect_managed_artifact",
|
||||
retainedSummary: { evidenceParameters: "omit-detail" },
|
||||
...toolContractMetadata("inspect_managed_artifact"),
|
||||
description:
|
||||
'Inspect an explicit PE/CLI path, or the selected managed target, without loading or executing target code. Omitted or complete detail returns complete metadata inventories with original coverage. Select detail: "summary" on the first call for complete identity, PE/CLI, classification with its evidence, module/MVID, assembly, frameworks, returned-collection counts, coverage/issues and limitations. It retains complete Evidence and returns a distinct linked inspect_analysis_view summary. Page original references/resources/attributes with view kind managed and normalized_result.parent_evidence_id. Counts cover producer-returned rows; partial, unavailable or malformed metadata remains explicit and tokens remain build-local.',
|
||||
@@ -80,6 +81,7 @@ export const MANAGED_TOOL_CONTRACTS = [
|
||||
},
|
||||
{
|
||||
name: "inspect_managed_members",
|
||||
retainedSummary: { evidenceParameters: "omit-detail" },
|
||||
...toolContractMetadata("inspect_managed_members"),
|
||||
description:
|
||||
'Inspect PE/CLI metadata members, signatures, raw CIL hashes, decoded-instruction-tuple hashes, separately reported exception regions, call edges, field-access anchors, and the #US string literals that ldstr loads, without loading or executing target code. Select type_names to decode only the members of those exact declaring types and the literals their bodies load, which keeps large assemblies such as a base class library within the representation budget. Metadata tokens are reported as build-local coordinates bound to the artifact SHA-256 and MVID; the tuple hash does not resolve them or fully commit control flow. Omitted or complete detail returns every collection, including the assembly-wide member_refs and exported_types, which type_names does not narrow. Select detail: "summary" for identity, metadata status, selection with unmatched names, coverage/issues, limitations and per-collection counts; it retains complete Evidence and returns a linked inspect_analysis_view summary. Page original rows with view kind managed-members, normalized_result.parent_evidence_id and collection types/exported_types/fields/methods/member_refs/call_edges/field_accesses/user_strings.',
|
||||
|
||||
@@ -48,6 +48,7 @@ const native = <
|
||||
name: Name,
|
||||
description: string,
|
||||
inputSchema: Schema,
|
||||
retainedSummary?: ToolContract["retainedSummary"],
|
||||
) => {
|
||||
const outputSchema = requireOutputSchema(nativeOutputSchemas, name);
|
||||
const strictInputSchema = inputSchema.strict();
|
||||
@@ -57,6 +58,7 @@ const native = <
|
||||
description,
|
||||
kind: "native-provider",
|
||||
requiresActiveTarget: true,
|
||||
...(retainedSummary === undefined ? {} : { retainedSummary }),
|
||||
inputSchema: strictInputSchema,
|
||||
outputSchema,
|
||||
examples: [
|
||||
@@ -108,6 +110,7 @@ export const NATIVE_TOOL_CONTRACTS = [
|
||||
),
|
||||
native_command_timeout_ms: nativeCommandTimeoutField,
|
||||
}),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
native(
|
||||
"inspect_signature",
|
||||
|
||||
@@ -30,6 +30,7 @@ const official = <
|
||||
name: Name,
|
||||
description: string,
|
||||
inputSchema: Schema,
|
||||
retainedSummary?: ToolContract["retainedSummary"],
|
||||
) => {
|
||||
const strictInputSchema = inputSchema.strict();
|
||||
return {
|
||||
@@ -38,6 +39,7 @@ const official = <
|
||||
description,
|
||||
kind: "official-proxy",
|
||||
requiresActiveTarget: true,
|
||||
...(retainedSummary === undefined ? {} : { retainedSummary }),
|
||||
inputSchema: strictInputSchema,
|
||||
outputSchema: requireOutputSchema(officialOutputSchemas, name),
|
||||
examples: examplesFor(name, strictInputSchema),
|
||||
@@ -55,6 +57,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
"inspect_native_load_image",
|
||||
"Inspect the provider's measured source digests, memory mappings, relocations, entry points and entry register context. Independent snapshot verification supports DOS MZ, explicitly selected COM, raw binaries and GameCube DOL; other formats retain their observations with unsupported verification. Overlay and uninitialized coverage remain explicit. Select detail summary for verification state and counts, retaining complete Evidence for inspect_analysis_view kind native-load-image: collection source_files, mappings, relocations, entry_points, entry_context, checks or sections, with indices, fields and offset/limit paging. Relocation bytes and check expected/observed payloads require explicit fields; defaults keep metadata. Null collections are unrecorded or inapplicable. Does not execute or modify the target.",
|
||||
z.strictObject({ detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"inspect_native_data_type",
|
||||
@@ -124,11 +127,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
address: optionalAddress,
|
||||
detail: analysisDetailSchema,
|
||||
}),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"list_procedures",
|
||||
"List every analyzed procedure as address/value pairs after provider analysis. Ghidra metadata distinguishes thunks and external functions; use returned addresses in later function operations. Select detail summary for counts and retained native-inventory pages; complete remains the default.",
|
||||
z.object({ document, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"list_segments",
|
||||
@@ -143,6 +148,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
address: optionalAddress,
|
||||
detail: analysisDetailSchema,
|
||||
}),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"next_address",
|
||||
@@ -158,6 +164,7 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
"procedure_address",
|
||||
"Resolve an unambiguous procedure symbol name or provider-normalized address to its canonical entry address. External address spaces remain explicit. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"procedure_assembly",
|
||||
@@ -168,11 +175,13 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
"procedure_callees",
|
||||
"Return the provider's resolved direct callees for one procedure identified by symbol or address. Unresolved indirect calls may be absent; use analyze_function and typed references to preserve available edge uncertainty. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"procedure_callers",
|
||||
"Return the provider's resolved direct callers for one procedure identified by symbol or address. Results reflect completed static analysis and may omit unresolved indirect references. Ghidra terminal-call assumptions that may cut the analyzed body are reported in Evidence limitations. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ procedure, document, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"procedure_info",
|
||||
@@ -205,16 +214,19 @@ export const OFFICIAL_TOOL_CONTRACTS = [
|
||||
"resolve_containing_procedure",
|
||||
"Resolve an arbitrary address, including an interior instruction or exact external entry, to its provider-analyzed containing procedure. A negative result distinguishes outside segments from not in a procedure and is never guessed from nearby symbols. Select detail summary for selected facts and a context digest; native-observation and native-context views retain complete source access.",
|
||||
z.object({ address, document, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"search_procedures",
|
||||
`Search every analyzed procedure name using literal matching by default or regex when requested. Results are deterministic and complete. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
|
||||
z.object({ ...nativeSearchInput, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"search_strings",
|
||||
`Search every provider-reported string object or candidate using literal matching by default or regex when requested. Results are deterministic and complete over that provider inventory, not every text sequence in artifact bytes. Kuna scans mapped sequences of at least five characters, including unterminated fragments, without computing references. Ghidra may omit packed or undefined literals, including Rust strings; an empty result does not prove text is absent. If an address is known, read_bytes and xrefs inspect its bytes and references. Hopper retains typed byte decoding, native display, and explicit decoding unknowns. Adjacent long-literal fragments match independently. ${hopperRegexDeadline} Select detail summary for counts and retained native-inventory pages; complete remains the default.`,
|
||||
z.object({ ...nativeSearchInput, detail: analysisDetailSchema }),
|
||||
{ evidenceParameters: "omit-detail" },
|
||||
),
|
||||
official(
|
||||
"set_address_name",
|
||||
|
||||
@@ -57,6 +57,10 @@ export interface ToolContract<
|
||||
readonly kind: ToolKind;
|
||||
/** This handler dispatches through the active binary-analysis session. */
|
||||
readonly requiresActiveTarget?: boolean;
|
||||
/** Adapter delivery of a retained summary; detail never reaches analysis. */
|
||||
readonly retainedSummary?: {
|
||||
readonly evidenceParameters: "omit-detail" | "preserve-detail";
|
||||
};
|
||||
readonly inputSchema: InputSchema;
|
||||
readonly outputSchema: OutputSchema;
|
||||
readonly effects: ToolEffects;
|
||||
|
||||
@@ -3,6 +3,8 @@ import { createHash } from "node:crypto";
|
||||
import canonicalize from "canonicalize";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js";
|
||||
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
compositeKey,
|
||||
@@ -82,33 +84,10 @@ const subjectBaseSchema = z.object({
|
||||
path: localPathStringSchema,
|
||||
sha256: digestSchema,
|
||||
});
|
||||
const subjectFormatSchema = z.enum([
|
||||
...formatSchema.options,
|
||||
"hopper",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
]);
|
||||
// Snapshot entries retain the same raw-only architecture boundary as targets.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]),
|
||||
architecture: architectureSchema
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: architectureSchema.nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("gamecube-dol"),
|
||||
architecture: z.literal("powerpc"),
|
||||
}),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]),
|
||||
]);
|
||||
const targetIdentitySchema = z.object({
|
||||
sha256: digestSchema,
|
||||
|
||||
@@ -497,14 +497,6 @@ export const explainUpdate = (
|
||||
if (kept !== undefined) rules.push(kept);
|
||||
continue;
|
||||
}
|
||||
// An empty list or map one answer spells `null` and another `[]` or `{}` is unchanged.
|
||||
if (
|
||||
emptySpellings(
|
||||
ownMember(evidence.before, field),
|
||||
ownMember(evidence.after, field),
|
||||
)
|
||||
)
|
||||
continue;
|
||||
const rule = changeRule(evidence, field, slugs);
|
||||
if (rule === undefined) unexplained.push(field);
|
||||
else rules.push(rule);
|
||||
|
||||
+6
-56
@@ -1,6 +1,8 @@
|
||||
import { basename } from "node:path";
|
||||
import { z } from "zod";
|
||||
|
||||
import { evidenceSubjectDeclarationShapes } from "./evidenceSubject.js";
|
||||
|
||||
import { digestCanonicalValue } from "./canonicalDigest.js";
|
||||
import { canonicalJsonDigestSteps } from "./canonicalJsonDigestSteps.js";
|
||||
import { nulFreeStringSchema } from "./localPath.js";
|
||||
@@ -9,12 +11,7 @@ import {
|
||||
analysisProfileSchema,
|
||||
type AnalysisProfileCommitment,
|
||||
} from "./analysisProfile.js";
|
||||
import {
|
||||
BINARY_ARCHITECTURES,
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
type BinaryArchitecture,
|
||||
type BinaryTarget,
|
||||
} from "./binaryTargetTypes.js";
|
||||
import type { BinaryArchitecture, BinaryTarget } from "./binaryTargetTypes.js";
|
||||
import {
|
||||
jsonObjectSchema,
|
||||
jsonValueSchema,
|
||||
@@ -41,62 +38,15 @@ export const providerSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
version: z.string().nullable(),
|
||||
});
|
||||
const subjectFormatSchema = z.enum([
|
||||
"hopper",
|
||||
"analysis-database",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"dos-mz",
|
||||
"dos-com",
|
||||
"raw-binary",
|
||||
"gamecube-dol",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"hap",
|
||||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"godot-pck",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
]);
|
||||
const subjectBaseSchema = z.strictObject({
|
||||
name: z.string().min(1),
|
||||
digest: z.strictObject({ sha256: digestSchema }),
|
||||
local_path: nulFreeStringSchema,
|
||||
});
|
||||
// The format owns the architecture claim, including nested Evidence subjects.
|
||||
const subjectSchema = z.discriminatedUnion("format", [
|
||||
subjectBaseSchema.extend({
|
||||
format: subjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]),
|
||||
architecture: z
|
||||
.enum(BINARY_ARCHITECTURES)
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
}),
|
||||
subjectBaseSchema.extend({
|
||||
format: z.literal("gamecube-dol"),
|
||||
architecture: z.literal("powerpc"),
|
||||
}),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[0]),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[1]),
|
||||
subjectBaseSchema.extend(evidenceSubjectDeclarationShapes[2]),
|
||||
]);
|
||||
/** Source location attached to an evidence observation. */
|
||||
/** Source-location schema shared by evidence-bearing persistence formats. */
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { analysisSnapshotSchema } from "./analysisSnapshot.js";
|
||||
import { BINARY_ARCHITECTURES } from "./binaryTargetTypes.js";
|
||||
import { evidenceSchema } from "./evidence.js";
|
||||
import { evidenceSubjectFormatSchema } from "./evidenceSubject.js";
|
||||
|
||||
const evidenceSubject = evidenceSchema.shape.subject;
|
||||
const snapshotSubject =
|
||||
analysisSnapshotSchema.shape.entries.element.shape.execution.shape.subject;
|
||||
const digest = "a".repeat(64);
|
||||
|
||||
describe("persisted observation subject declarations", () => {
|
||||
it("keeps Evidence and cache admission aligned for every format and architecture", () => {
|
||||
for (const format of evidenceSubjectFormatSchema.options) {
|
||||
for (const architecture of [
|
||||
...BINARY_ARCHITECTURES,
|
||||
null,
|
||||
"unsupported",
|
||||
]) {
|
||||
const declaration = { format, architecture };
|
||||
const evidence = evidenceSubject.safeParse({
|
||||
...declaration,
|
||||
name: "fixture",
|
||||
local_path: "/tmp/fixture",
|
||||
digest: { sha256: digest },
|
||||
});
|
||||
const snapshot = snapshotSubject.safeParse({
|
||||
...declaration,
|
||||
path: "/tmp/fixture",
|
||||
sha256: digest,
|
||||
});
|
||||
const allowed =
|
||||
architecture !== "unsupported" &&
|
||||
(format === "gamecube-dol"
|
||||
? architecture === "powerpc"
|
||||
: format === "raw-binary" ||
|
||||
(architecture !== "6502" && architecture !== "m68k"));
|
||||
expect(evidence.success, JSON.stringify(declaration)).toBe(allowed);
|
||||
expect(snapshot.success, JSON.stringify(declaration)).toBe(allowed);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves each persistence envelope's handling of additional fields", () => {
|
||||
expect(
|
||||
evidenceSubject.safeParse({
|
||||
format: "pe",
|
||||
architecture: null,
|
||||
name: "fixture",
|
||||
local_path: "/tmp/fixture",
|
||||
digest: { sha256: digest },
|
||||
extra: true,
|
||||
}).success,
|
||||
).toBe(false);
|
||||
expect(
|
||||
snapshotSubject.parse({
|
||||
format: "pe",
|
||||
architecture: null,
|
||||
path: "/tmp/fixture",
|
||||
sha256: digest,
|
||||
extra: true,
|
||||
}),
|
||||
).not.toHaveProperty("extra");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import { z } from "zod";
|
||||
|
||||
import {
|
||||
BINARY_ARCHITECTURES,
|
||||
RAW_ONLY_ARCHITECTURES,
|
||||
} from "./binaryTargetTypes.js";
|
||||
|
||||
export const evidenceSubjectFormatSchema = z.enum([
|
||||
"hopper",
|
||||
"analysis-database",
|
||||
"mach-o",
|
||||
"elf",
|
||||
"pe",
|
||||
"dos-mz",
|
||||
"dos-com",
|
||||
"raw-binary",
|
||||
"gamecube-dol",
|
||||
"zip",
|
||||
"ipa",
|
||||
"apk",
|
||||
"msix",
|
||||
"appx",
|
||||
"hap",
|
||||
"hsp",
|
||||
"app-pack",
|
||||
"asar",
|
||||
"godot-pck",
|
||||
"dmg",
|
||||
"pkg",
|
||||
"plist",
|
||||
"javascript",
|
||||
"source-map",
|
||||
"directory",
|
||||
"file",
|
||||
"unknown",
|
||||
"mach-o-universal",
|
||||
"javascript-bundle",
|
||||
"entitlements",
|
||||
"pyinstaller",
|
||||
"node-sea",
|
||||
]);
|
||||
|
||||
/** Format-owned architecture declarations shared by Evidence and persisted executions. */
|
||||
export const evidenceSubjectDeclarationShapes = [
|
||||
{
|
||||
format: evidenceSubjectFormatSchema.exclude(["raw-binary", "gamecube-dol"]),
|
||||
architecture: z
|
||||
.enum(BINARY_ARCHITECTURES)
|
||||
.exclude([...RAW_ONLY_ARCHITECTURES])
|
||||
.nullable(),
|
||||
},
|
||||
{
|
||||
format: z.literal("raw-binary"),
|
||||
architecture: z.enum(BINARY_ARCHITECTURES).nullable(),
|
||||
},
|
||||
{
|
||||
format: z.literal("gamecube-dol"),
|
||||
architecture: z.literal("powerpc"),
|
||||
},
|
||||
] as const;
|
||||
@@ -1,3 +1,4 @@
|
||||
import { nativeExportCorrespondenceKey } from "./javascriptNativeExportFacts.js";
|
||||
import { canonicalJson } from "../comparisonSemantics.js";
|
||||
import {
|
||||
compareUnicodeCodePoints,
|
||||
@@ -271,21 +272,7 @@ const semanticValue = (
|
||||
exactString(properties.api_key) ??
|
||||
nonEmptyString(properties.api_name)
|
||||
);
|
||||
if (node.kind === "native-export")
|
||||
return (
|
||||
joined(
|
||||
properties.specifier,
|
||||
compositeKey([
|
||||
stringList(properties.requested_members),
|
||||
typeof properties.namespace_access === "boolean"
|
||||
? properties.namespace_access
|
||||
: null,
|
||||
typeof properties.dynamic_member_access === "boolean"
|
||||
? properties.dynamic_member_access
|
||||
: null,
|
||||
]),
|
||||
) ?? firstLabel(node)
|
||||
);
|
||||
if (node.kind === "native-export") return nativeExportCorrespondenceKey(node);
|
||||
if (
|
||||
[
|
||||
"electron-main",
|
||||
@@ -334,13 +321,6 @@ const nonEmptyString = (value: unknown): string | null =>
|
||||
const exactString = (value: unknown): string | null =>
|
||||
typeof value === "string" ? value : null;
|
||||
|
||||
const stringList = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value
|
||||
.filter((item): item is string => typeof item === "string")
|
||||
.sort(compareUnicodeCodePoints)
|
||||
: [];
|
||||
|
||||
const sortedNodes = (nodes: Iterable<ApplicationNode>): ApplicationNode[] =>
|
||||
[...nodes].sort((left, right) =>
|
||||
compareUnicodeCodePoints(left.node_id, right.node_id),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { createJavaScriptApplicationNode } from "./javascriptApplicationGraph.js";
|
||||
import { artifactEvidence } from "./javascriptApplicationGraph.fixture.js";
|
||||
import {
|
||||
nativeExportCorrespondenceKey,
|
||||
nativeExportObservationFacts,
|
||||
} from "./javascriptNativeExportFacts.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
|
||||
const node = (
|
||||
observations: readonly {
|
||||
label: string;
|
||||
properties: Record<string, JsonValue>;
|
||||
}[],
|
||||
) =>
|
||||
createJavaScriptApplicationNode({
|
||||
kind: "native-export",
|
||||
identity: {
|
||||
strategy: "artifact-local-key",
|
||||
stability: "artifact-version",
|
||||
artifact_sha256: "a".repeat(64),
|
||||
namespace: "native-export-fixture",
|
||||
key: "addon.node",
|
||||
},
|
||||
observations: observations.map((observation) => ({
|
||||
...observation,
|
||||
evidence: artifactEvidence("a".repeat(64), "main.js"),
|
||||
})),
|
||||
});
|
||||
|
||||
it("preserves legacy labels and members while distinguishing omitted and empty requests", () => {
|
||||
const legacy = node([
|
||||
{
|
||||
label: "open",
|
||||
properties: { specifier: "./addon.node", members: ["open"] },
|
||||
},
|
||||
]);
|
||||
const empty = node([
|
||||
{
|
||||
label: "open",
|
||||
properties: { specifier: "./addon.node", requested_members: [] },
|
||||
},
|
||||
]);
|
||||
expect(nativeExportObservationFacts(legacy.observations[0]!)).toMatchObject({
|
||||
requestedPresent: false,
|
||||
members: ["open"],
|
||||
label: "open",
|
||||
});
|
||||
expect(nativeExportObservationFacts(empty.observations[0]!)).toMatchObject({
|
||||
requestedPresent: true,
|
||||
requested: [],
|
||||
label: null,
|
||||
});
|
||||
expect(nativeExportCorrespondenceKey(legacy)).not.toEqual(
|
||||
nativeExportCorrespondenceKey(empty),
|
||||
);
|
||||
});
|
||||
|
||||
it("considers later conflicting observations and retains malformed values without inventing identity", () => {
|
||||
const first = {
|
||||
label: "summary",
|
||||
properties: { specifier: "./addon.node", requested_members: ["open"] },
|
||||
};
|
||||
const second = {
|
||||
label: "summary",
|
||||
properties: { specifier: "./addon.node", requested_members: ["close"] },
|
||||
};
|
||||
expect(nativeExportCorrespondenceKey(node([first]))).not.toBeNull();
|
||||
expect(nativeExportCorrespondenceKey(node([first, second]))).toBeNull();
|
||||
const malformed = node([
|
||||
{
|
||||
label: "legacy",
|
||||
properties: {
|
||||
specifier: "./addon.node",
|
||||
requested_members: ["open", 42],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const before = JSON.stringify(malformed);
|
||||
expect(
|
||||
nativeExportObservationFacts(malformed.observations[0]!),
|
||||
).toMatchObject({ valid: false, requested: ["open"], label: null });
|
||||
expect(nativeExportCorrespondenceKey(malformed)).toBeNull();
|
||||
expect(JSON.stringify(malformed)).toBe(before);
|
||||
});
|
||||
|
||||
it("preserves label-only fallback without treating display labels as structured members", () => {
|
||||
for (const requested_members of [["open"], ["close"], []])
|
||||
expect(
|
||||
nativeExportCorrespondenceKey(
|
||||
node([{ label: "same summary", properties: { requested_members } }]),
|
||||
),
|
||||
).toBeNull();
|
||||
expect(
|
||||
nativeExportCorrespondenceKey(node([{ label: "open", properties: {} }])),
|
||||
).not.toBeNull();
|
||||
const first = node([
|
||||
{
|
||||
label: "summary one",
|
||||
properties: { specifier: "./addon.node", requested_members: ["open"] },
|
||||
},
|
||||
]);
|
||||
const second = node([
|
||||
{
|
||||
label: "summary two",
|
||||
properties: { specifier: "./addon.node", requested_members: ["open"] },
|
||||
},
|
||||
]);
|
||||
expect(nativeExportCorrespondenceKey(first)).toEqual(
|
||||
nativeExportCorrespondenceKey(second),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
import {
|
||||
compositeKey,
|
||||
compareUnicodeCodePoints,
|
||||
} from "../unicodeCodePointOrder.js";
|
||||
import type { ApplicationNode } from "./javascriptApplicationGraphSchemas.js";
|
||||
|
||||
type Observation = ApplicationNode["observations"][number];
|
||||
|
||||
/** Read member facts without changing historical observations or their identifiers. */
|
||||
export const nativeExportObservationFacts = (observation: Observation) => {
|
||||
const requested = observation.properties.requested_members;
|
||||
const members = observation.properties.members;
|
||||
const strings = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
const requestedPresent = Object.hasOwn(
|
||||
observation.properties,
|
||||
"requested_members",
|
||||
);
|
||||
const membersPresent = Object.hasOwn(observation.properties, "members");
|
||||
const validList = (value: unknown): boolean =>
|
||||
Array.isArray(value) && value.every((item) => typeof item === "string");
|
||||
return {
|
||||
requestedPresent,
|
||||
membersPresent,
|
||||
valid:
|
||||
(!requestedPresent || validList(requested)) &&
|
||||
(!membersPresent || validList(members)),
|
||||
requested: strings(requested),
|
||||
members: strings(members),
|
||||
// Labels were the historical fallback, but current labels summarize names
|
||||
// or namespace access and must not become requested native symbols.
|
||||
label: Array.isArray(requested) ? null : observation.label,
|
||||
};
|
||||
};
|
||||
|
||||
/** Correspondence requires every observation to describe the same binding facts. */
|
||||
export const nativeExportCorrespondenceKey = (
|
||||
node: ApplicationNode,
|
||||
): string | null => {
|
||||
const keys = node.observations.map((observation) => {
|
||||
const facts = nativeExportObservationFacts(observation);
|
||||
if (!facts.valid) return null;
|
||||
const specifier = observation.properties.specifier;
|
||||
if (typeof specifier !== "string" || specifier === "")
|
||||
return observation.label === null ||
|
||||
facts.requestedPresent ||
|
||||
facts.membersPresent
|
||||
? null
|
||||
: compositeKey(["label", observation.label]);
|
||||
const flag = (name: string) => {
|
||||
const value = observation.properties[name];
|
||||
return typeof value === "boolean" ? value : null;
|
||||
};
|
||||
return compositeKey([
|
||||
specifier,
|
||||
facts.requestedPresent
|
||||
? ["requested", [...facts.requested].sort(compareUnicodeCodePoints)]
|
||||
: ["requested-unknown"],
|
||||
facts.membersPresent
|
||||
? ["members", [...facts.members].sort(compareUnicodeCodePoints)]
|
||||
: ["members-unknown"],
|
||||
facts.label,
|
||||
flag("namespace_access"),
|
||||
flag("dynamic_member_access"),
|
||||
]);
|
||||
});
|
||||
if (keys.some((key) => key === null)) return null;
|
||||
const unique = new Set(keys);
|
||||
return unique.size === 1 ? keys[0]! : null;
|
||||
};
|
||||
@@ -7,6 +7,8 @@ import type {
|
||||
import type { JavaScriptApplicationGraph } from "./javascriptApplicationGraph.js";
|
||||
import type { ApplicationFeatureTraceResult } from "./javascriptFeatureTraceSchemas.js";
|
||||
|
||||
import { nativeExportObservationFacts } from "./javascriptNativeExportFacts.js";
|
||||
|
||||
type NativeHandoff = ApplicationFeatureTraceResult["native_handoffs"][number];
|
||||
|
||||
/** Build provider-neutral addon targets and link exact-subject native Evidence. */
|
||||
@@ -102,25 +104,18 @@ const nativeExportNodes = (
|
||||
|
||||
const requestedExports = (nodes: readonly ApplicationNode[]): string[] => {
|
||||
const values = nodes.flatMap((node) =>
|
||||
node.observations.flatMap((observation) => [
|
||||
// Display labels can summarize several names or namespace access.
|
||||
// Only legacy observations without structured member facts need them.
|
||||
...(observation.label === null ||
|
||||
Array.isArray(observation.properties.requested_members)
|
||||
? []
|
||||
: [observation.label]),
|
||||
...stringArray(observation.properties.requested_members),
|
||||
...stringArray(observation.properties.members),
|
||||
]),
|
||||
node.observations.flatMap((observation) => {
|
||||
const facts = nativeExportObservationFacts(observation);
|
||||
return [
|
||||
...(facts.label === null ? [] : [facts.label]),
|
||||
...facts.requested,
|
||||
...facts.members,
|
||||
];
|
||||
}),
|
||||
);
|
||||
return [...new Set(values)].sort(compareUnicodeCodePoints);
|
||||
};
|
||||
|
||||
const stringArray = (value: unknown): string[] =>
|
||||
Array.isArray(value)
|
||||
? value.filter((item): item is string => typeof item === "string")
|
||||
: [];
|
||||
|
||||
const uniqueProviders = (
|
||||
evidence: readonly Evidence[],
|
||||
): NativeHandoff["providers"] =>
|
||||
|
||||
@@ -39,15 +39,81 @@ const build = (inputs: JavaScriptRouteInventoryInput[], bytes?: number) =>
|
||||
completeSemanticSteps(buildJavaScriptRouteInventorySteps(inputs, bytes)),
|
||||
);
|
||||
|
||||
it("does not promote page-shaped paths without source-router applicability", () => {
|
||||
const unknown = source("app/page.js", "");
|
||||
delete (unknown as { next_app_context?: unknown }).next_app_context;
|
||||
const result = build([unknown]);
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.unknowns).toMatchObject([
|
||||
{ kind: "interpretation", reason: expect.stringContaining("not measured") },
|
||||
]);
|
||||
});
|
||||
it.each([
|
||||
["hello world", "hello%20world"],
|
||||
["team?admin", "team%3Fadmin"],
|
||||
["team#admin", "team%23admin"],
|
||||
] as const)(
|
||||
"preserves Next filename pattern %s and its source identity",
|
||||
(segment, encoded) => {
|
||||
const url = `https://example.test/_next/static/chunks/app/${encoded}/page-abc.js`;
|
||||
const result = build([
|
||||
source(`app/${segment}/page.js`, ""),
|
||||
source("captured.js", "", url),
|
||||
source(
|
||||
"runtime.js",
|
||||
`globalThis.TURBOPACK=[];const page=${JSON.stringify(url)};`,
|
||||
),
|
||||
]);
|
||||
expect(result.routes).toMatchObject([
|
||||
{ path: `/${segment}`, source_count: 3, occurrence_count: 3 },
|
||||
]);
|
||||
expect(result.routes).toHaveLength(1);
|
||||
expect(result.occurrences.map(({ source_id }) => source_id).sort()).toEqual(
|
||||
result.sources.map(({ id }) => id).sort(),
|
||||
);
|
||||
expect(result.sources).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: `app/${segment}/page.js`, url: null }),
|
||||
expect.objectContaining({ path: null, url }),
|
||||
]),
|
||||
);
|
||||
expect(result.unknowns).toEqual([]);
|
||||
expect(result.coverage).toEqual({
|
||||
scope: "provided-code-sources",
|
||||
source_parsing: "complete",
|
||||
candidate_enumeration: "complete",
|
||||
application_routes: "unknown",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["%00", "%09", "%0A", "%7F"])(
|
||||
"retains an interpretation gap for a Next filename containing %s",
|
||||
(encoded) => {
|
||||
const url = `https://example.test/_next/static/chunks/app/hello${encoded}world/page-abc.js`;
|
||||
const result = build([source("captured.js", "", url)]);
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.unknowns).toMatchObject([
|
||||
{
|
||||
source_id: result.sources[0]!.id,
|
||||
kind: "interpretation",
|
||||
reason: expect.stringContaining("control character"),
|
||||
},
|
||||
]);
|
||||
expect(result.sources[0]!.url).toBe(url);
|
||||
expect(result.coverage.candidate_enumeration).toBe("partial");
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["app/page.js", "app/hello\nworld/page.js"])(
|
||||
"does not promote page-shaped path %j without source-router applicability",
|
||||
(path) => {
|
||||
const unknown = source(path, "");
|
||||
delete (unknown as { next_app_context?: unknown }).next_app_context;
|
||||
const result = build([unknown]);
|
||||
expect(result.routes).toEqual([]);
|
||||
expect(result.unknowns).toMatchObject([
|
||||
{
|
||||
source_id: result.sources[0]!.id,
|
||||
kind: "interpretation",
|
||||
reason: expect.stringContaining("not measured"),
|
||||
},
|
||||
]);
|
||||
expect(result.sources[0]!.path).toBe(path);
|
||||
expect(result.coverage.candidate_enumeration).toBe("partial");
|
||||
},
|
||||
);
|
||||
|
||||
it("binds source identity to the package evidence establishing router applicability", () => {
|
||||
const first = source("app/page.js", "");
|
||||
|
||||
@@ -105,7 +105,7 @@ export const classifyNextAppRoutePath = (
|
||||
}
|
||||
if (relative === undefined) {
|
||||
if (
|
||||
/(?:^|\/)app\/(?:.*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test(
|
||||
/(?:^|\/)app\/(?:[\s\S]*\/)?page(?:\.[cm]?[jt]sx?|-[^/?#]+\.js|)(?:[?#].*)?$/u.test(
|
||||
value,
|
||||
)
|
||||
)
|
||||
@@ -137,7 +137,7 @@ export const classifyNextAppRoutePath = (
|
||||
}
|
||||
}
|
||||
const match =
|
||||
/^(?:(.*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec(
|
||||
/^(?:([\s\S]*?)\/)?page(?:-([^/?#]+)\.js|\.(?:[cm]?jsx?|tsx?)|)([?#].*)?$/u.exec(
|
||||
relative,
|
||||
);
|
||||
if (
|
||||
@@ -148,6 +148,11 @@ export const classifyNextAppRoutePath = (
|
||||
(match[3] !== undefined && (urlPath || context.kind === "source-path"))
|
||||
)
|
||||
return;
|
||||
if (/[\u0000-\u001f\u007f]/u.test(relative))
|
||||
return {
|
||||
kind: "unknown",
|
||||
reason: "A Next page filename contains a control character.",
|
||||
};
|
||||
const sourceSegments = (match[1] ?? "").split("/");
|
||||
// A private folder excludes the whole page, including intercepted ancestors.
|
||||
// Keep filesystem segment spelling: only Next's explicit %5F escape is
|
||||
@@ -178,8 +183,9 @@ export const classifyNextAppRoutePath = (
|
||||
: segment,
|
||||
);
|
||||
}
|
||||
const path = routePath(`/${segments.filter(Boolean).join("/")}`);
|
||||
return path === undefined ? undefined : { kind: "known", path };
|
||||
// These are filename-derived patterns, not navigation URLs. Preserve spaces
|
||||
// and literal delimiters rather than applying URL syntax validation.
|
||||
return { kind: "known", path: `/${segments.filter(Boolean).join("/")}` };
|
||||
};
|
||||
|
||||
export const nextScriptBasePath = (source: string): string | undefined => {
|
||||
|
||||
@@ -44,16 +44,30 @@ export const parseActiveElectronCapture = (
|
||||
z
|
||||
.object({
|
||||
application_path: absolutePathSchema,
|
||||
application_root: absolutePathSchema,
|
||||
application_root: absolutePathSchema.optional(),
|
||||
resolved_paths: z
|
||||
.strictObject({
|
||||
executable_path: absolutePathSchema,
|
||||
application_path: absolutePathSchema,
|
||||
application_root: absolutePathSchema,
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.passthrough(),
|
||||
evidence.parameters,
|
||||
[...path, "parameters"],
|
||||
);
|
||||
// Older captures committed canonical paths directly in parameters. New
|
||||
// captures keep request spelling separate from the admitted execution paths.
|
||||
const executionPaths = parameters.resolved_paths ?? parameters;
|
||||
if (
|
||||
result.application.application_path !== parameters.application_path ||
|
||||
executionPaths.application_root === undefined ||
|
||||
result.application.application_path !== executionPaths.application_path ||
|
||||
(parameters.resolved_paths !== undefined &&
|
||||
result.application.executable_path !==
|
||||
parameters.resolved_paths.executable_path) ||
|
||||
!isPathWithinRoot(
|
||||
parameters.application_root,
|
||||
executionPaths.application_root,
|
||||
result.application.application_path,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -2,19 +2,14 @@ import { z } from "zod";
|
||||
import { ok, type Result } from "../result.js";
|
||||
import { AnalysisOutputError } from "../analysisErrorCore.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
unwrapNativeProperty,
|
||||
invalidNativeOutput,
|
||||
} from "./nativeProviderOutput.js";
|
||||
import { invalidNativeOutput } from "./nativeProviderOutput.js";
|
||||
|
||||
/** Parse direct or wrapped provider document names. */
|
||||
/** Parse the provider document-name array. */
|
||||
export const parseDocuments = (
|
||||
value: JsonValue,
|
||||
operation = "list_documents",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(z.string())
|
||||
.safeParse(unwrapNativeProperty(value, "documents"));
|
||||
const parsed = z.array(z.string()).safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalidNativeOutput(operation, "document list", parsed.error);
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import {
|
||||
unwrapNativeProperty,
|
||||
invalidNativeOutput,
|
||||
} from "./nativeProviderOutput.js";
|
||||
import { invalidNativeOutput } from "./nativeProviderOutput.js";
|
||||
import { z } from "zod";
|
||||
import { procedureIdentitySchema } from "./procedureInfo.js";
|
||||
import { AnalysisOutputError } from "../analysisErrorCore.js";
|
||||
@@ -79,7 +76,7 @@ export const isNativeObservationOperation = (
|
||||
): operation is NativeObservationOperation =>
|
||||
NATIVE_OBSERVATION_OPERATIONS.some((supported) => supported === operation);
|
||||
|
||||
/** Parse callee/caller strings from direct or wrapped provider results. */
|
||||
/** Parse the provider callee/caller address array. */
|
||||
export const parseRelatedAddresses = (
|
||||
value: JsonValue,
|
||||
relation: "callees" | "callers",
|
||||
@@ -87,9 +84,7 @@ export const parseRelatedAddresses = (
|
||||
? "procedure_callees"
|
||||
: "procedure_callers",
|
||||
): Result<readonly string[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(z.string())
|
||||
.safeParse(unwrapNativeProperty(value, relation));
|
||||
const parsed = z.array(z.string()).safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalidNativeOutput(operation, `${relation} list`, parsed.error);
|
||||
|
||||
@@ -31,8 +31,8 @@ describe("native provider boundary values", () => {
|
||||
parseSegments([{ name: "__TEXT", start: "0x1", end: "0x2" }]).ok,
|
||||
).toBe(false);
|
||||
});
|
||||
it("parses direct and wrapped protocol shapes", () => {
|
||||
expect(parseRelatedAddresses({ callers: ["0x3"] }, "callers")).toEqual({
|
||||
it("parses provider arrays", () => {
|
||||
expect(parseRelatedAddresses(["0x3"], "callers")).toEqual({
|
||||
ok: true,
|
||||
value: ["0x3"],
|
||||
});
|
||||
@@ -68,6 +68,12 @@ describe("native provider boundary values", () => {
|
||||
|
||||
it.each([
|
||||
["relations", () => parseRelatedAddresses([1], "callees")],
|
||||
[
|
||||
"wrapped relations",
|
||||
() => parseRelatedAddresses({ callees: ["0x3"] }, "callees"),
|
||||
],
|
||||
["wrapped documents", () => parseDocuments({ documents: ["fixture"] })],
|
||||
["wrapped segments", () => parseSegments({ segments: [] })],
|
||||
["segments", () => parseSegments([{ name: 1 }])],
|
||||
["documents", () => parseDocuments([1])],
|
||||
])(
|
||||
|
||||
@@ -1,22 +1,6 @@
|
||||
import { z } from "zod";
|
||||
import { err, type Result } from "../result.js";
|
||||
import { AnalysisOutputError } from "../analysisErrorCore.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
|
||||
export const unwrapNativeProperty = (
|
||||
value: JsonValue,
|
||||
property: string,
|
||||
): JsonValue => {
|
||||
if (
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
property in value
|
||||
) {
|
||||
return value[property] ?? null;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
export const invalidNativeOutput = <T>(
|
||||
operation: string,
|
||||
|
||||
@@ -2,10 +2,7 @@ import { z } from "zod";
|
||||
import { ok, type Result } from "../result.js";
|
||||
import { AnalysisOutputError } from "../analysisErrorCore.js";
|
||||
import type { JsonValue } from "../jsonValue.js";
|
||||
import {
|
||||
unwrapNativeProperty,
|
||||
invalidNativeOutput,
|
||||
} from "./nativeProviderOutput.js";
|
||||
import { invalidNativeOutput } from "./nativeProviderOutput.js";
|
||||
import { addressDistance } from "./addressCoordinate.js";
|
||||
|
||||
export interface SegmentSummary {
|
||||
@@ -29,14 +26,12 @@ const segmentSchema = z.object({
|
||||
image_base: z.string().min(1).exactOptional(),
|
||||
address_space: z.string().min(1).exactOptional(),
|
||||
});
|
||||
/** Preserve reported address context in direct or wrapped provider segments. */
|
||||
/** Preserve reported address context in the provider segment array. */
|
||||
export const parseSegments = (
|
||||
value: JsonValue,
|
||||
operation = "list_segments",
|
||||
): Result<readonly SegmentSummary[], AnalysisOutputError> => {
|
||||
const parsed = z
|
||||
.array(segmentSchema)
|
||||
.safeParse(unwrapNativeProperty(value, "segments"));
|
||||
const parsed = z.array(segmentSchema).safeParse(value);
|
||||
return parsed.success
|
||||
? ok(parsed.data)
|
||||
: invalidNativeOutput(operation, "segment list", parsed.error);
|
||||
|
||||
@@ -254,6 +254,8 @@ const originalSourceSchema = z.object({
|
||||
artifact: webTextArtifactSchema.nullable(),
|
||||
});
|
||||
const originalModuleEdgeSchema = z.object({
|
||||
// Historical observations omit this link to original_sources declarations.
|
||||
declaration_index: z.int().min(0).optional(),
|
||||
from_source: z.string(),
|
||||
kind: z.enum(["static_import", "dynamic_import", "require"]),
|
||||
specifier: z.string(),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { cleanupOwnedProcessGroup } from "../process/ProcessOwnership.js";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
@@ -17,6 +18,7 @@ import {
|
||||
import {
|
||||
spawnOwnedProviderProcess,
|
||||
type ProviderProcessHandle,
|
||||
type ProviderProcessSupervisor,
|
||||
type OwnedProviderProcessSpawnOptions,
|
||||
} from "../process/ProviderProcess.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
@@ -335,3 +337,265 @@ test.each([
|
||||
expect(launcher).toHaveBeenCalledOnce();
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
{ exitCode: 0, cleanupFails: false },
|
||||
{ exitCode: 7, cleanupFails: false },
|
||||
{ exitCode: 7, cleanupFails: true },
|
||||
])(
|
||||
"preserves completed exit $exitCode through cancellation during real owned cleanup (fails: $cleanupFails)",
|
||||
async ({ exitCode, cleanupFails }) => {
|
||||
const root = await createTestTempDirectory("rea-firmware-finalization-");
|
||||
const caller = new AbortController();
|
||||
const reason = { caller: "cancel during final cleanup" };
|
||||
let cleanupCalls = 0;
|
||||
const retained: ProviderProcessSupervisor[] = [];
|
||||
const failure: unknown = await runFirmwareCommand({
|
||||
engine: "binwalk",
|
||||
operation: "inspect_firmware_regions",
|
||||
command: process.execPath,
|
||||
limiter: process.execPath,
|
||||
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
||||
args: [],
|
||||
cwd: root,
|
||||
environment: process.env,
|
||||
signal: caller.signal,
|
||||
deadline: {
|
||||
phase: "analysis",
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 30_000,
|
||||
expiresAtMs: performance.now() + 30_000,
|
||||
},
|
||||
launcher: async (options) => {
|
||||
const owned = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
`process.stdout.write("scan evidence\\n"); process.stderr.write("producer diagnostics\\n"); process.exitCode = ${exitCode}`,
|
||||
],
|
||||
});
|
||||
return {
|
||||
...owned,
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
expect(owned.process.exitCode).toBe(exitCode);
|
||||
caller.abort(reason);
|
||||
const cleaned = await cleanupOwnedProcessGroup(owned.ownership);
|
||||
if (cleanupFails && cleanupCalls === 1)
|
||||
return {
|
||||
cleaned: false as const,
|
||||
reason: "controlled cleanup refusal after real release",
|
||||
};
|
||||
return cleaned;
|
||||
},
|
||||
};
|
||||
},
|
||||
retainCleanup: (owner) => {
|
||||
retained.push(owner);
|
||||
},
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(cleanupCalls).toBe(1);
|
||||
expect(failure).toBeInstanceOf(AnalysisError);
|
||||
if (!(failure instanceof AnalysisError))
|
||||
throw new Error("Expected typed failure");
|
||||
if (exitCode === 0) {
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
});
|
||||
} else if (cleanupFails) {
|
||||
expect(retained).toHaveLength(1);
|
||||
expect(await retained[0]!.stop()).toMatchObject({
|
||||
status: "verified-cleanup",
|
||||
});
|
||||
expect(cleanupCalls).toBe(2);
|
||||
retained[0]!.dispose();
|
||||
expect(failure).toMatchObject({
|
||||
diagnostics: {
|
||||
previous_error: {
|
||||
code: "execution_failure",
|
||||
details: { diagnostics: { exit_code: 7 } },
|
||||
},
|
||||
},
|
||||
capturedOutput: {
|
||||
stdout: "scan evidence\n",
|
||||
stderr: "producer diagnostics\n",
|
||||
exit_code: 7,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
expect(failure).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
diagnostics: {
|
||||
exit_code: 7,
|
||||
stdout: "scan evidence\n",
|
||||
stderr: "producer diagnostics\n",
|
||||
},
|
||||
});
|
||||
expect(projectAnalysisError(failure).code).toBe("execution_failure");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// The injected launcher runs real owned Node processes on every POSIX host;
|
||||
// Linux firmware binaries and prlimit are deliberately outside this lifecycle seam.
|
||||
test.skipIf(process.platform === "win32").each([
|
||||
{
|
||||
overflow: false,
|
||||
exitCode: 7,
|
||||
acceptFailure: false,
|
||||
cancelBeforeExit: false,
|
||||
expected: "ProviderAdapterError",
|
||||
},
|
||||
{
|
||||
overflow: false,
|
||||
exitCode: 0,
|
||||
acceptFailure: false,
|
||||
cancelBeforeExit: false,
|
||||
expected: "AnalysisCancelledError",
|
||||
},
|
||||
{
|
||||
overflow: false,
|
||||
exitCode: 1,
|
||||
acceptFailure: true,
|
||||
cancelBeforeExit: false,
|
||||
expected: "AnalysisCancelledError",
|
||||
},
|
||||
{
|
||||
overflow: false,
|
||||
exitCode: 7,
|
||||
acceptFailure: false,
|
||||
cancelBeforeExit: true,
|
||||
expected: "AnalysisCancelledError",
|
||||
},
|
||||
{
|
||||
overflow: true,
|
||||
exitCode: 7,
|
||||
acceptFailure: false,
|
||||
cancelBeforeExit: false,
|
||||
expected: "ProviderAdapterError",
|
||||
},
|
||||
])(
|
||||
"retains root exit $exitCode while a handshaken descendant holds output (accepted: $acceptFailure, earlier cancellation: $cancelBeforeExit, overflow: $overflow)",
|
||||
async ({ exitCode, acceptFailure, cancelBeforeExit, expected, overflow }) => {
|
||||
const root = await createTestTempDirectory(
|
||||
"rea-firmware-inherited-output-",
|
||||
);
|
||||
const caller = new AbortController();
|
||||
const reason = { caller: "cancel with inherited output still open" };
|
||||
let cleanupCalls = 0;
|
||||
let closed = false;
|
||||
let exited = false;
|
||||
const failure: unknown = await runFirmwareCommand({
|
||||
engine: "binwalk",
|
||||
operation: "inspect_firmware_regions",
|
||||
command: process.execPath,
|
||||
limiter: process.execPath,
|
||||
fileSizeCeiling: Number.MAX_SAFE_INTEGER,
|
||||
args: [],
|
||||
cwd: root,
|
||||
environment: process.env,
|
||||
signal: caller.signal,
|
||||
deadline: {
|
||||
phase: "analysis",
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 30_000,
|
||||
expiresAtMs: performance.now() + 30_000,
|
||||
},
|
||||
acceptReportedExtractionFailure: acceptFailure,
|
||||
launcher: async (options) => {
|
||||
const exitRelease = join(root, "exit-release");
|
||||
const tailRelease = join(root, "tail-release");
|
||||
const descendant = `
|
||||
const { existsSync } = require("node:fs");
|
||||
setInterval(() => {}, 1000);
|
||||
const tail = setInterval(() => {
|
||||
if (!existsSync(${JSON.stringify(tailRelease)})) return;
|
||||
clearInterval(tail);
|
||||
process.stderr.write(Buffer.alloc(${overflow ? FIRMWARE_LIMITS.diagnosticBytes : 0}, 120), () => {
|
||||
process.stderr.write("descendant-tail\\n");
|
||||
});
|
||||
}, 10);
|
||||
process.send("ready");
|
||||
`;
|
||||
const owned = await spawnOwnedProviderProcess({
|
||||
...options,
|
||||
command: process.execPath,
|
||||
arguments: [
|
||||
"-e",
|
||||
`
|
||||
const { spawn } = require("node:child_process");
|
||||
const child = spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}], {
|
||||
stdio: ["ignore", "inherit", "inherit", "ipc"],
|
||||
});
|
||||
child.once("message", () => {
|
||||
process.stderr.write("descendant-ready\\n");
|
||||
child.disconnect();
|
||||
child.unref();
|
||||
if (${cancelBeforeExit}) {
|
||||
const release = setInterval(() => {
|
||||
if (!require("node:fs").existsSync(${JSON.stringify(exitRelease)})) return;
|
||||
clearInterval(release);
|
||||
process.exitCode = ${exitCode};
|
||||
}, 10);
|
||||
} else process.exitCode = ${exitCode};
|
||||
});
|
||||
`,
|
||||
],
|
||||
});
|
||||
owned.process.once("close", () => {
|
||||
closed = true;
|
||||
});
|
||||
let diagnostics = "";
|
||||
owned.process.stderr?.on("data", (chunk: Buffer) => {
|
||||
diagnostics += chunk.toString();
|
||||
if (cancelBeforeExit && !caller.signal.aborted) {
|
||||
expect(exited).toBe(false);
|
||||
caller.abort(reason);
|
||||
void writeFile(exitRelease, "release");
|
||||
} else if (
|
||||
!cancelBeforeExit &&
|
||||
diagnostics.includes("descendant-tail")
|
||||
) {
|
||||
expect(exited).toBe(true);
|
||||
expect(closed).toBe(false);
|
||||
caller.abort(reason);
|
||||
}
|
||||
});
|
||||
owned.process.once("exit", () => {
|
||||
exited = true;
|
||||
// Release descendant diagnostics only after the supervisor has also
|
||||
// observed the root's exit, leaving inherited pipes open until cleanup.
|
||||
queueMicrotask(() => {
|
||||
void writeFile(tailRelease, "release");
|
||||
});
|
||||
});
|
||||
return {
|
||||
...owned,
|
||||
cleanup: async () => {
|
||||
cleanupCalls += 1;
|
||||
return cleanupOwnedProcessGroup(owned.ownership);
|
||||
},
|
||||
};
|
||||
},
|
||||
retainCleanup: () => {
|
||||
throw new Error("Real descendant cleanup must complete");
|
||||
},
|
||||
}).catch((cause: unknown) => cause);
|
||||
expect(exited).toBe(true);
|
||||
expect(cleanupCalls).toBe(1);
|
||||
expect(failure).toMatchObject({ _tag: expected });
|
||||
if (expected === "ProviderAdapterError")
|
||||
expect(failure).toMatchObject({
|
||||
diagnostics: {
|
||||
exit_code: 7,
|
||||
stderr: overflow
|
||||
? expect.stringMatching(/^descendant-ready\nxxx/)
|
||||
: "descendant-ready\ndescendant-tail\n",
|
||||
truncated: overflow,
|
||||
},
|
||||
});
|
||||
else expect(failure).toMatchObject({ cause: reason });
|
||||
},
|
||||
);
|
||||
@@ -236,6 +236,39 @@ export const runFirmwareCommand = async (context: {
|
||||
throw firmwareCancellation(operation, signal.reason, context.deadline);
|
||||
throw cause;
|
||||
});
|
||||
let failure: AnalysisError | undefined;
|
||||
const isFailedExit = (code: number | null | undefined) =>
|
||||
code !== undefined &&
|
||||
code !== 0 &&
|
||||
!(context.acceptReportedExtractionFailure === true && code === 1);
|
||||
const completedFailure = (snapshot: ProviderProcessSnapshot) => {
|
||||
if (isFailedExit(snapshot.exitCode)) {
|
||||
const signalLimit = firmwareSignalLimitError(
|
||||
operation,
|
||||
snapshot.signal,
|
||||
fileSizeBytes,
|
||||
fileSizeAdjustable,
|
||||
capturedFirmwareOutput(snapshot),
|
||||
);
|
||||
if (signalLimit !== undefined) return signalLimit;
|
||||
return new ProviderAdapterError(engine, operation, {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures",
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
stdout_bytes: snapshot.stdout.bytes,
|
||||
stderr_bytes: snapshot.stderr.bytes,
|
||||
truncated: snapshot.diagnosticTruncated === true,
|
||||
},
|
||||
remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`,
|
||||
});
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
let completedFailureObserved = false;
|
||||
let diagnosticTruncated = false;
|
||||
const supervisor = new ProviderProcessSupervisor(
|
||||
{
|
||||
@@ -249,6 +282,17 @@ export const runFirmwareCommand = async (context: {
|
||||
onDiagnostic: (event) => {
|
||||
if (event.type === "output" && event.truncated === true)
|
||||
diagnosticTruncated = true;
|
||||
// Root exit and inherited-pipe closure are different observations.
|
||||
// Retain a completed failure before drain or cleanup can be interrupted,
|
||||
// while cancellation already present at exit keeps its precedence.
|
||||
if (
|
||||
event.type === "exit" &&
|
||||
failure === undefined &&
|
||||
!diagnosticTruncated &&
|
||||
firmwarePhaseInterruption(operation, signal, context.deadline) ===
|
||||
undefined
|
||||
)
|
||||
completedFailureObserved = isFailedExit(event.code);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -272,7 +316,6 @@ export const runFirmwareCommand = async (context: {
|
||||
);
|
||||
}
|
||||
};
|
||||
let failure: AnalysisError | undefined;
|
||||
try {
|
||||
while (!(await supervisor.waitForOutputClose(100))) {
|
||||
checkDiagnostics();
|
||||
@@ -289,13 +332,15 @@ export const runFirmwareCommand = async (context: {
|
||||
checkDiagnostics();
|
||||
checkInterruption();
|
||||
} catch (cause: unknown) {
|
||||
failure = firmwareFailure(cause, context);
|
||||
failure ??= firmwareFailure(cause, context);
|
||||
}
|
||||
const stopped = await supervisor.stop();
|
||||
// Cleanup is an awaited part of this phase. Preserve a prior observed failure,
|
||||
// otherwise retain an interruption that arrived while stopping owned resources.
|
||||
failure ??= firmwarePhaseInterruption(operation, signal, context.deadline);
|
||||
const snapshot = supervisor.snapshot();
|
||||
// Capture diagnostics only after owned cleanup has drained the inherited pipes.
|
||||
if (completedFailureObserved) failure = completedFailure(snapshot);
|
||||
if (stopped.status === "incomplete") {
|
||||
context.retainCleanup(supervisor);
|
||||
const previous =
|
||||
@@ -320,35 +365,6 @@ export const runFirmwareCommand = async (context: {
|
||||
);
|
||||
}
|
||||
if (failure !== undefined) throw failure;
|
||||
if (
|
||||
snapshot.exitCode !== 0 &&
|
||||
!(
|
||||
context.acceptReportedExtractionFailure === true &&
|
||||
snapshot.exitCode === 1
|
||||
)
|
||||
) {
|
||||
const signalLimit = firmwareSignalLimitError(
|
||||
operation,
|
||||
snapshot.signal,
|
||||
fileSizeBytes,
|
||||
fileSizeAdjustable,
|
||||
capturedFirmwareOutput(snapshot),
|
||||
);
|
||||
if (signalLimit !== undefined) throw signalLimit;
|
||||
throw new ProviderAdapterError(engine, operation, {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"Tool failed; inspect exit status and stderr for format, dependency, sandbox or resource-limit failures",
|
||||
exit_code: snapshot.exitCode ?? null,
|
||||
signal: snapshot.signal ?? null,
|
||||
stdout: snapshot.stdout.text,
|
||||
stderr: snapshot.stderr.text,
|
||||
stdout_bytes: snapshot.stdout.bytes,
|
||||
stderr_bytes: snapshot.stderr.bytes,
|
||||
},
|
||||
remediationAction: `Inspect exit status and stderr for format, dependency, sandbox, or resource-limit failures. ${firmwareProcessLimitRecovery(fileSizeBytes, fileSizeAdjustable)} A format or dependency failure is not repaired by changing analysis_timeout_ms.`,
|
||||
});
|
||||
}
|
||||
return {
|
||||
stdout: snapshot.stdout,
|
||||
stderr: snapshot.stderr,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { rm, writeFile } from "node:fs/promises";
|
||||
import { appendFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createIdaTarget } from "../../tests/fixtures/idaMcp.js";
|
||||
@@ -6,6 +6,7 @@ import { readIdaConfiguration } from "./IdaConfiguration.js";
|
||||
import { decodeIdaToolResult } from "./IdaMcpConnection.js";
|
||||
import type { RetainedResourceScope } from "../application/AnalysisProvider.js";
|
||||
import { OwnedFileHandle } from "../filesystem/OwnedFileHandle.js";
|
||||
import * as regularFile from "../filesystem/RegularFile.js";
|
||||
|
||||
const resourceScope: RetainedResourceScope = {
|
||||
retain: () => undefined,
|
||||
@@ -77,6 +78,29 @@ describe("IDA registration and MCP result boundaries", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects registration growth beyond the byte limit after descriptor admission", async () => {
|
||||
const { root } = await createIdaTarget();
|
||||
roots.push(root);
|
||||
const path = join(root, "mcp.json");
|
||||
await writeFile(path, JSON.stringify({ command: "python" }));
|
||||
const openRegularFile = regularFile.openRegularFile;
|
||||
const admission = vi
|
||||
.spyOn(regularFile, "openRegularFile")
|
||||
.mockImplementation(async (...args) => {
|
||||
const source = await openRegularFile(...args);
|
||||
await appendFile(path, " ".repeat(65_536));
|
||||
return source;
|
||||
});
|
||||
try {
|
||||
expect(await read(path)).toMatchObject({
|
||||
ok: false,
|
||||
error: { message: expect.stringContaining("64 KiB") },
|
||||
});
|
||||
} finally {
|
||||
admission.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
describe("IDA registration descriptor ownership", () => {
|
||||
it("retains the exact descriptor after close denial and resolves its error only after retry", async () => {
|
||||
const { root } = await createIdaTarget();
|
||||
|
||||
+13
-16
@@ -13,9 +13,10 @@ import {
|
||||
RegularFileCleanupFailure,
|
||||
} from "../filesystem/OwnedRegularFile.js";
|
||||
import {
|
||||
assertRegularFileActive,
|
||||
RegularFileOperationCancelled,
|
||||
NonRegularFileReadError,
|
||||
} from "../filesystem/RegularFile.js";
|
||||
import { readBoundedFileBytes } from "../process/BoundedFileBytes.js";
|
||||
|
||||
/** Omitted registration `timeoutMs` for each upstream IDA MCP request. */
|
||||
export const DEFAULT_IDA_REQUEST_TIMEOUT_MS = 300_000;
|
||||
@@ -143,21 +144,17 @@ export const readIdaConfiguration = (
|
||||
async ({ handle, initial: metadata }) => {
|
||||
if (metadata.size > MAXIMUM_REGISTRATION_BYTES)
|
||||
throw registrationTooLarge();
|
||||
const bytes = Buffer.alloc(MAXIMUM_REGISTRATION_BYTES + 1);
|
||||
let length = 0;
|
||||
while (length < bytes.length) {
|
||||
assertRegularFileActive(options.signal);
|
||||
const { bytesRead } = await handle.read(
|
||||
bytes,
|
||||
length,
|
||||
bytes.length - length,
|
||||
null,
|
||||
);
|
||||
if (bytesRead === 0) break;
|
||||
length += bytesRead;
|
||||
}
|
||||
if (length > MAXIMUM_REGISTRATION_BYTES) throw registrationTooLarge();
|
||||
return bytes.toString("utf8", 0, length);
|
||||
const bytes = await readBoundedFileBytes(
|
||||
handle,
|
||||
MAXIMUM_REGISTRATION_BYTES,
|
||||
{
|
||||
signal: options.signal,
|
||||
cancellationFailure: (reason) =>
|
||||
new RegularFileOperationCancelled(reason),
|
||||
},
|
||||
);
|
||||
if (bytes === undefined) throw registrationTooLarge();
|
||||
return bytes.toString("utf8");
|
||||
},
|
||||
);
|
||||
const input: unknown = JSON.parse(text);
|
||||
|
||||
@@ -53,6 +53,21 @@ it.each([undefined, "SIGTERM"] as const)(
|
||||
},
|
||||
);
|
||||
|
||||
it.each([undefined, "SIGTERM"] as const)(
|
||||
"closes unused command input with stop signal %s",
|
||||
async (stopSignal) => {
|
||||
const result = await execFileOutput(
|
||||
process.execPath,
|
||||
[
|
||||
"-e",
|
||||
'process.stdin.resume(); process.stdin.on("end", () => process.stdout.write("input closed"));',
|
||||
],
|
||||
{ timeout: 2_000, ...(stopSignal === undefined ? {} : { stopSignal }) },
|
||||
);
|
||||
expect(result.stdout).toBe("input closed");
|
||||
},
|
||||
);
|
||||
|
||||
describe("execFileOutput", () => {
|
||||
it("ignores non-Error and incomplete failure metadata", () => {
|
||||
expect(execFileOutputFailure("failure")).toBeUndefined();
|
||||
@@ -686,3 +701,68 @@ it.each([
|
||||
expect(failure.cause).toBe(reason);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each([
|
||||
{ code: 7, action: "abort" },
|
||||
{ code: 0, action: "abort" },
|
||||
{ code: 7, action: "overflow" },
|
||||
] as const)(
|
||||
"owns root exit $code before inherited-pipe $action",
|
||||
async ({ code, action }) => {
|
||||
const directory = await createTestTempDirectory("rea-exec-exit-pipes-");
|
||||
const exited = join(directory, "root-exited");
|
||||
const release = join(directory, "release");
|
||||
const descendant = [
|
||||
'const fs = require("node:fs");',
|
||||
"const parent = Number(process.argv[1]);",
|
||||
"const timer = setInterval(() => {",
|
||||
" try { process.kill(parent, 0); } catch {",
|
||||
` fs.writeFileSync(${JSON.stringify(exited)}, "");`,
|
||||
" }",
|
||||
` if (!fs.existsSync(${JSON.stringify(release)})) return;`,
|
||||
` process.stdout.write(${JSON.stringify(action === "overflow" ? "x".repeat(64) : "late")});`,
|
||||
" clearInterval(timer); process.exit(0);",
|
||||
"}, 10);",
|
||||
"setTimeout(() => process.exit(0), 5000).unref();",
|
||||
].join("\n");
|
||||
const program = [
|
||||
'const { spawn } = require("node:child_process");',
|
||||
`spawn(process.execPath, ["-e", ${JSON.stringify(descendant)}, String(process.pid)], { stdio: "inherit" }).unref();`,
|
||||
'process.stderr.write("root diagnostic");',
|
||||
`process.exit(${code});`,
|
||||
].join("\n");
|
||||
const controller = new AbortController();
|
||||
let settled = false;
|
||||
const execution = execFileOutput(process.execPath, ["-e", program], {
|
||||
stopSignal: "SIGTERM",
|
||||
signal: controller.signal,
|
||||
maxBuffer: 32,
|
||||
})
|
||||
.catch((cause: unknown) => cause)
|
||||
.finally(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
expect(await waitForFile(exited)).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
if (action === "abort") controller.abort("late abort");
|
||||
expect(settled).toBe(false);
|
||||
await writeFile(release, "");
|
||||
const failure = await execution;
|
||||
expect(failure).toMatchObject(
|
||||
code === 0
|
||||
? { _tag: "ExecFileOutputCancelledError", cause: "late abort" }
|
||||
: { code: 7 },
|
||||
);
|
||||
expect(execFileOutputFailure(failure)).toMatchObject({
|
||||
stderr: "root diagnostic",
|
||||
stdout: action === "overflow" ? "x".repeat(32) : "late",
|
||||
outputTruncated: action === "overflow",
|
||||
});
|
||||
} finally {
|
||||
await writeFile(release, "");
|
||||
controller.abort();
|
||||
await execution;
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -142,7 +142,7 @@ export const execFileFailureCapturedOutput = (
|
||||
truncated: failure.outputTruncated,
|
||||
});
|
||||
|
||||
/** Run a shell-free command while capturing its complete output bytes. */
|
||||
/** Run a shell-free command with no input while capturing its complete output bytes. */
|
||||
export const execFileBytes = (
|
||||
command: string,
|
||||
arguments_: readonly string[],
|
||||
@@ -175,7 +175,7 @@ export const execFileBytes = (
|
||||
resolve({ stdout, stderr });
|
||||
};
|
||||
if (stopSignal === undefined) {
|
||||
execFile(
|
||||
const child = execFile(
|
||||
command,
|
||||
[...arguments_],
|
||||
{
|
||||
@@ -194,6 +194,8 @@ export const execFileBytes = (
|
||||
stderr,
|
||||
),
|
||||
);
|
||||
// This promise API exposes no input writer; EOF belongs to the owner.
|
||||
child.stdin?.end();
|
||||
return;
|
||||
}
|
||||
startStoppableCommand(
|
||||
@@ -220,6 +222,7 @@ const startStoppableCommand = (
|
||||
}
|
||||
let stopped: "abort" | "timeout" | "output-limit" | undefined;
|
||||
let processError: Error | null = null;
|
||||
let rootFailed = false;
|
||||
let outputError: Error | undefined;
|
||||
let deadline: NodeJS.Timeout | undefined;
|
||||
let grace: NodeJS.Timeout | undefined;
|
||||
@@ -228,12 +231,12 @@ const startStoppableCommand = (
|
||||
// pipe lifetime of descendants still using caller-owned temporary files.
|
||||
const child = spawn(command, [...arguments_], {
|
||||
...execOptions,
|
||||
stdio: "pipe",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
killSignal: stopSignal,
|
||||
});
|
||||
// The first stop request names the failure; a later one is not resent.
|
||||
const stop = (reason: "abort" | "timeout" | "output-limit"): void => {
|
||||
if (stopped !== undefined) return;
|
||||
if (stopped !== undefined || rootFailed || processError !== null) return;
|
||||
stopped = reason;
|
||||
child.kill(stopSignal);
|
||||
grace = setTimeout(() => child.kill("SIGKILL"), STOP_GRACE_MS);
|
||||
@@ -268,6 +271,11 @@ const startStoppableCommand = (
|
||||
child.on("error", (error: Error) => {
|
||||
processError ??= error;
|
||||
});
|
||||
child.on("exit", (code, exitSignal) => {
|
||||
// A failed root has completed its outcome even while descendants retain
|
||||
// its pipes. Finalization cancellation cannot replace that failure.
|
||||
rootFailed = code !== 0 || exitSignal !== null;
|
||||
});
|
||||
child.on("close", (code, exitSignal) => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
clearTimeout(deadline);
|
||||
@@ -290,7 +298,9 @@ const startStoppableCommand = (
|
||||
? new ExecFileOutputCancelledError(signal.reason)
|
||||
: stopped === "timeout" && timeout !== undefined
|
||||
? timeoutError(command, timeout, processError)
|
||||
: (outputError ?? processError);
|
||||
: rootFailed && stopped === undefined
|
||||
? processError
|
||||
: (outputError ?? processError);
|
||||
if (failure !== null && outputError !== undefined)
|
||||
Reflect.set(failure, "outputTruncated", true);
|
||||
complete(failure, out, diagnostic);
|
||||
|
||||
@@ -3,22 +3,7 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import { ARTIFACT_TOOL_CONTRACTS } from "../contracts/artifactToolContracts.js";
|
||||
import { createArtifactExtractionDestination } from "../application/artifacts/ArtifactExtractionDestination.js";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Operations whose MCP delivery may be a retained summary view. */
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
|
||||
"inspect_artifact",
|
||||
"decode_interface_builder",
|
||||
]);
|
||||
const executionWithoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
/** inspect_artifact keeps an explicit detail in its Evidence parameters. */
|
||||
const decodeWithoutDelivery = withoutDeliveryDetail(
|
||||
new Set(["decode_interface_builder"]),
|
||||
);
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Register deterministic artifact inventory and safe extraction operations. */
|
||||
export const registerArtifactTools = (
|
||||
@@ -27,11 +12,9 @@ export const registerArtifactTools = (
|
||||
): void => {
|
||||
registerEvidenceTools(server, ARTIFACT_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: (operation, parameters) =>
|
||||
operation === "extract_artifact"
|
||||
? { ...parameters, output_root: createArtifactExtractionDestination() }
|
||||
: executionWithoutDelivery(operation, parameters),
|
||||
parametersForEvidence: decodeWithoutDelivery,
|
||||
: parameters,
|
||||
});
|
||||
};
|
||||
@@ -35,7 +35,7 @@ export const registerBinaryDiagnosticsTools = (
|
||||
withAdmittedAnalysis,
|
||||
);
|
||||
const contract = toolContract("inspect_binary_layout");
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
const delivery = retainedSummaryDelivery(contract.name, {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
@@ -68,17 +68,13 @@ export const registerBinaryDiagnosticsTools = (
|
||||
logger,
|
||||
contract.name,
|
||||
async () => {
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
const ready = delivery.preflight(parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await service.inspect(request, {
|
||||
signal: context.mcpReq.signal,
|
||||
});
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(
|
||||
contract.name,
|
||||
parameters,
|
||||
inspected.value,
|
||||
)
|
||||
? delivery.projectEvidence(parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -22,7 +22,7 @@ export const registerByteEntropyTool = (
|
||||
withAdmittedAnalysis: WithAdmittedAnalysis | undefined,
|
||||
): void => {
|
||||
const contract = toolContract("inspect_byte_entropy");
|
||||
const delivery = retainedSummaryDelivery(new Set([contract.name]), {
|
||||
const delivery = retainedSummaryDelivery(contract.name, {
|
||||
recordEvidence,
|
||||
evidenceById,
|
||||
});
|
||||
@@ -41,17 +41,13 @@ export const registerByteEntropyTool = (
|
||||
contract.name,
|
||||
async () => {
|
||||
const parameters = { detail };
|
||||
const ready = delivery.preflight(contract.name, parameters);
|
||||
const ready = delivery.preflight(parameters);
|
||||
if (!ready.ok) return ready;
|
||||
const inspected = await entropyService.inspect(request, {
|
||||
signal: context.mcpReq.signal,
|
||||
});
|
||||
return inspected.ok
|
||||
? delivery.projectEvidence(
|
||||
contract.name,
|
||||
parameters,
|
||||
inspected.value,
|
||||
)
|
||||
? delivery.projectEvidence(parameters, inspected.value)
|
||||
: inspected;
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { EvidenceWriter } from "../application/investigation/InvestigationRecordPort.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
import type {
|
||||
AnalysisOperation,
|
||||
AnalysisOperationPort,
|
||||
} from "../application/AnalysisProvider.js";
|
||||
import type { ToolContract } from "../contracts/toolContractTypes.js";
|
||||
import { ok } from "../domain/result.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { Evidence } from "../domain/evidence.js";
|
||||
import { createAnalysisExecutionEvidence } from "../domain/analysisObservation.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ok, type Result } from "../domain/result.js";
|
||||
import type { Logger } from "pino";
|
||||
import { mcpProgressReporter } from "./mcpProgress.js";
|
||||
import { logToolExecution } from "./toolLogging.js";
|
||||
@@ -27,6 +30,7 @@ interface EvidenceToolRegistration {
|
||||
readonly logger: Logger;
|
||||
readonly activeTarget: (() => BinaryTarget | undefined) | undefined;
|
||||
readonly recordEvidence: EvidenceWriter["recordEvidence"] | undefined;
|
||||
readonly evidenceById?: EvidenceToolFamilyOptions["evidenceById"];
|
||||
readonly withAdmittedAnalysis: WithAdmittedAnalysis;
|
||||
readonly analysisForAdmission?: (
|
||||
analysis: AnalysisOperationPort,
|
||||
@@ -35,21 +39,6 @@ interface EvidenceToolRegistration {
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Readonly<Record<string, JsonValue>>;
|
||||
/** Exclude adapter-only selections, such as delivery detail, from Evidence identity. */
|
||||
readonly parametersForEvidence?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Readonly<Record<string, JsonValue>>;
|
||||
/** Refuse an unsatisfiable delivery selection before any analysis runs. */
|
||||
readonly preflight?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
) => Result<unknown, AnalysisError>;
|
||||
readonly projectEvidence?: (
|
||||
operation: Exclude<AnalysisOperation, "health">,
|
||||
parameters: Readonly<Record<string, JsonValue>>,
|
||||
evidence: Evidence,
|
||||
) => Result<Evidence, AnalysisError>;
|
||||
}
|
||||
|
||||
/** Register provider-backed contracts that return atomic Evidence observations. */
|
||||
@@ -59,14 +48,17 @@ export const registerEvidenceTools = (
|
||||
options: EvidenceToolRegistration,
|
||||
): void => {
|
||||
for (const contract of contracts) {
|
||||
const delivery =
|
||||
contract.retainedSummary === undefined
|
||||
? undefined
|
||||
: retainedSummaryDelivery(contract.name, options);
|
||||
server.registerTool(
|
||||
contract.name,
|
||||
toolRegistrationOptions(contract),
|
||||
async (input, context) => {
|
||||
const parameters = jsonObjectSchema.parse(input);
|
||||
const ready = options.preflight?.(contract.name, parameters);
|
||||
if (ready !== undefined && !ready.ok)
|
||||
return server.delivery.toCallToolResult(ready, contract);
|
||||
const ready = delivery?.preflight(parameters) ?? ok(null);
|
||||
if (!ready.ok) return server.delivery.toCallToolResult(ready, contract);
|
||||
const admitted = await options.withAdmittedAnalysis(
|
||||
contract.name,
|
||||
context.mcpReq.signal,
|
||||
@@ -76,9 +68,15 @@ export const registerEvidenceTools = (
|
||||
admittedAnalysis;
|
||||
const progress = mcpProgressReporter(context);
|
||||
await reportOperationStart(progress, contract.name);
|
||||
const providerParameters =
|
||||
contract.retainedSummary === undefined
|
||||
? parameters
|
||||
: withoutDeliveryDetail(parameters);
|
||||
const executionParameters =
|
||||
options.parametersForExecution?.(contract.name, parameters) ??
|
||||
parameters;
|
||||
options.parametersForExecution?.(
|
||||
contract.name,
|
||||
providerParameters,
|
||||
) ?? providerParameters;
|
||||
const execution = await logToolExecution(
|
||||
options.logger,
|
||||
contract.name,
|
||||
@@ -94,8 +92,9 @@ export const registerEvidenceTools = (
|
||||
const sourceEvidence = execution.value.sourceEvidence ?? [];
|
||||
const evidence = createAnalysisExecutionEvidence(
|
||||
contract.name,
|
||||
options.parametersForEvidence?.(contract.name, parameters) ??
|
||||
parameters,
|
||||
contract.retainedSummary?.evidenceParameters === "omit-detail"
|
||||
? providerParameters
|
||||
: parameters,
|
||||
{
|
||||
...execution.value,
|
||||
subject:
|
||||
@@ -112,8 +111,7 @@ export const registerEvidenceTools = (
|
||||
if (!sourceRecorded.ok)
|
||||
return server.delivery.toCallToolResult(sourceRecorded, contract);
|
||||
const projected =
|
||||
options.projectEvidence?.(contract.name, parameters, evidence) ??
|
||||
ok(evidence);
|
||||
delivery?.projectEvidence(parameters, evidence) ?? ok(evidence);
|
||||
if (!projected.ok)
|
||||
return server.delivery.toCallToolResult(projected, contract);
|
||||
const recorded = options.recordEvidence?.(projected.value);
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
import type { AnalysisOperationPort } from "../application/AnalysisProvider.js";
|
||||
import { MANAGED_TOOL_CONTRACTS } from "../contracts/managed/managedToolContracts.js";
|
||||
import {
|
||||
MANAGED_TOOL_CONTRACTS,
|
||||
isManagedToolName,
|
||||
} from "../contracts/managed/managedToolContracts.js";
|
||||
import { AnalysisInputError } from "../domain/analysisErrorCore.js";
|
||||
import { err } from "../domain/result.js";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
import { runManagedProviderExecution } from "../composition/directAnalysis.js";
|
||||
import { isManagedToolName } from "../contracts/managed/managedToolContracts.js";
|
||||
|
||||
/** Operations whose MCP delivery may be a retained summary view. */
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set([
|
||||
"inspect_managed_artifact",
|
||||
"inspect_managed_members",
|
||||
]);
|
||||
|
||||
/** Register execution-free managed PE/CLI inspection. */
|
||||
export const registerManagedTools = (
|
||||
@@ -24,7 +16,6 @@ export const registerManagedTools = (
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
let selectedManagedPath: string | undefined;
|
||||
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
const analysisForAdmission = (
|
||||
analysis: AnalysisOperationPort,
|
||||
): AnalysisOperationPort => ({
|
||||
@@ -64,9 +55,6 @@ export const registerManagedTools = (
|
||||
});
|
||||
registerEvidenceTools(server, MANAGED_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: withoutDelivery,
|
||||
parametersForEvidence: withoutDelivery,
|
||||
analysisForAdmission,
|
||||
});
|
||||
};
|
||||
@@ -2,24 +2,12 @@ import type { EvidenceMcpServer } from "./EvidenceMcpServer.js";
|
||||
|
||||
import { NATIVE_TOOL_CONTRACTS } from "../contracts/native/nativeToolContracts.js";
|
||||
import { registerEvidenceTools } from "./registerEvidenceTools.js";
|
||||
import {
|
||||
retainedSummaryDelivery,
|
||||
withoutDeliveryDetail,
|
||||
type EvidenceToolFamilyOptions,
|
||||
} from "./retainedSummaryDelivery.js";
|
||||
|
||||
const SUMMARY_DELIVERY: ReadonlySet<string> = new Set(["inspect_macho"]);
|
||||
import type { EvidenceToolFamilyOptions } from "./retainedSummaryDelivery.js";
|
||||
|
||||
/** Register macOS native contracts; Mach-O inspection can deliver a retained summary. */
|
||||
export const registerNativeTools = (
|
||||
server: EvidenceMcpServer,
|
||||
options: EvidenceToolFamilyOptions,
|
||||
): void => {
|
||||
const withoutDelivery = withoutDeliveryDetail(SUMMARY_DELIVERY);
|
||||
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, {
|
||||
...options,
|
||||
...retainedSummaryDelivery(SUMMARY_DELIVERY, options),
|
||||
parametersForExecution: withoutDelivery,
|
||||
parametersForEvidence: withoutDelivery,
|
||||
});
|
||||
registerEvidenceTools(server, NATIVE_TOOL_CONTRACTS, options);
|
||||
};
|
||||
Loaded 100 of 123 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user