Carry asynchronous readiness through provider selection, session status, CLI and MCP. Join observations during cancellation and shutdown, keep status readable during finalization, and retain mixed provider errors and exact cleanup dependencies through the shared inspection failure owner. Validation: 255 focused tests across 18 files; 21 registry tests rerun with primary restoration assertion; check:fast; docs:check and 5 README conformance tests. Registered MCP and SDK schema checks use controlled providers; no real Ghidra/JVM verification. This is a prerequisite for #4561. The synchronous Ghidra Java producer and its doctor/client ownership migration remain unfinished. BREAKING CHANGE: binary_session candidate availability can be unknown during owner finalization. Consumers must handle this state; the internal session status API is now asynchronous. Refs: #4561
420 lines
14 KiB
TypeScript
420 lines
14 KiB
TypeScript
import { writeFile } from "node:fs/promises";
|
|
import { join } from "node:path";
|
|
|
|
import { describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
|
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisClient,
|
|
type AnalysisOperation,
|
|
type AnalysisProvider,
|
|
type AnalysisProviderCandidate,
|
|
type CapabilityDescriptor,
|
|
type ProviderIdentity,
|
|
} from "../../../src/application/AnalysisProvider.js";
|
|
import { AnalysisProviderRegistry } from "../../../src/application/binary/AnalysisProviderRegistry.js";
|
|
import type { BinarySession } from "../../../src/application/binary/BinarySession.js";
|
|
import { composeBinarySession } from "../../../src/application/binary/BinarySessionComposition.js";
|
|
import { SessionProviderRouter } from "../../../src/application/binary/SessionProviderRouter.js";
|
|
import { createAnalysisProfile } from "../../../src/domain/analysisProfile.js";
|
|
import { AnalysisCancelledError } from "../../../src/domain/analysisErrorCore.js";
|
|
import { ProviderAdapterError } from "../../../src/domain/providerAdapterError.js";
|
|
import { err, ok } from "../../../src/domain/result.js";
|
|
|
|
type DeclaredOperation = Exclude<AnalysisOperation, "health">;
|
|
|
|
describe("target-bound provider routing", () => {
|
|
it("rejects provider identity and operation-family collisions at composition", () => {
|
|
const alpha = deepProvider("alpha");
|
|
const registry = new AnalysisProviderRegistry([alpha.provider]);
|
|
expect(() =>
|
|
SessionProviderRouter.selectable(registry, [auxiliaryProvider("alpha")]),
|
|
).toThrow(/Duplicate configured provider ID: alpha/u);
|
|
expect(() =>
|
|
SessionProviderRouter.selectable(registry, [
|
|
auxiliaryProvider("auxiliary", "address_name"),
|
|
]),
|
|
).toThrow(
|
|
/Deep and auxiliary providers both declare operation address_name/u,
|
|
);
|
|
});
|
|
|
|
it("binds one overlapping provider, preserves provenance, and never falls back", async () => {
|
|
const target = await databaseTarget();
|
|
const alpha = deepProvider("alpha");
|
|
const beta = deepProvider("beta");
|
|
const auxiliary = auxiliaryProvider();
|
|
const session = selectableSession(
|
|
[beta.provider, alpha.provider],
|
|
auxiliary,
|
|
);
|
|
|
|
const ambiguous = await session.open(target);
|
|
expect(ambiguous.ok).toBe(false);
|
|
if (!ambiguous.ok)
|
|
expect(ambiguous.error).toMatchObject({ reason: "ambiguous" });
|
|
expect(alpha.clients).toHaveLength(0);
|
|
expect(beta.clients).toHaveLength(0);
|
|
expect(session.activeTarget()).toBeUndefined();
|
|
|
|
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
|
expect(await bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: {
|
|
provider: { id: "alpha", version: "1" },
|
|
selection_source: "request",
|
|
},
|
|
analysis_provider_candidates: [
|
|
{ provider: { id: "alpha" }, selected: true },
|
|
{ provider: { id: "beta" }, selected: false },
|
|
],
|
|
});
|
|
expect(alpha.clients).toHaveLength(0);
|
|
|
|
const observed = await session.execute("address_name", { address: "0x1" });
|
|
expect(observed.ok).toBe(true);
|
|
if (observed.ok)
|
|
expect(observed.value).toMatchObject({
|
|
provider: { id: "alpha", version: "1" },
|
|
analysisProfile: { provider: { id: "alpha", version: "1" } },
|
|
result: "alpha:address_name",
|
|
subject: {
|
|
sha256: expect.any(String),
|
|
path: target,
|
|
format: "analysis-database",
|
|
},
|
|
});
|
|
expect(alpha.clients).toHaveLength(1);
|
|
expect(beta.clients).toHaveLength(0);
|
|
|
|
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
|
expect(alpha.clients).toHaveLength(1);
|
|
expect(alpha.clients[0]?.closed).toBe(false);
|
|
|
|
const auxiliaryResult = await session.execute("inspect_macho", {});
|
|
expect(auxiliaryResult.ok && auxiliaryResult.value.provider.id).toBe(
|
|
"auxiliary",
|
|
);
|
|
|
|
alpha.available = false;
|
|
expect((await session.open(target)).ok).toBe(true);
|
|
expect(await bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "alpha" } },
|
|
});
|
|
|
|
const unknown = await session.open(target, { providerId: "missing" });
|
|
expect(unknown.ok).toBe(false);
|
|
if (!unknown.ok)
|
|
expect(unknown.error).toMatchObject({ reason: "unknown_provider" });
|
|
expect(await bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "alpha" } },
|
|
});
|
|
|
|
expect((await session.open(target, { providerId: "beta" })).ok).toBe(true);
|
|
expect(alpha.clients[0]?.closed).toBe(true);
|
|
beta.fail = true;
|
|
const failed = await session.execute("address_name", { address: "0x2" });
|
|
expect(failed.ok).toBe(false);
|
|
if (!failed.ok) expect(failed.error._tag).toBe("ProviderAdapterError");
|
|
expect(alpha.clients[0]?.calls).toEqual(["address_name"]);
|
|
expect(await bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "beta" } },
|
|
});
|
|
|
|
await session.close();
|
|
expect(beta.clients[0]?.closed).toBe(true);
|
|
});
|
|
|
|
it("preserves bundle-root identity for artifact inventory operations", async () => {
|
|
const target = await databaseTarget();
|
|
const analysis = deepProvider("alpha");
|
|
const artifact = auxiliaryProvider("artifact", "inventory_artifact");
|
|
const session = selectableSession([analysis.provider], artifact);
|
|
|
|
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
|
const result = await session.execute("inventory_artifact", {});
|
|
if (!result.ok) throw result.error;
|
|
|
|
expect(result).toMatchObject({
|
|
ok: true,
|
|
value: {
|
|
subject: {
|
|
path: target,
|
|
sha256: "f".repeat(64),
|
|
format: "directory",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("preserves the active client when a provider switch is cancelled while draining", async () => {
|
|
const target = await databaseTarget();
|
|
const alpha = deepProvider("alpha");
|
|
const beta = deepProvider("beta");
|
|
const pending = deferred<ReturnType<typeof successfulExecution>>();
|
|
alpha.pending = pending.promise;
|
|
const session = selectableSession([alpha.provider, beta.provider]);
|
|
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
|
const running = session.execute("address_name", { address: "0x1" });
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
|
|
const controller = new AbortController();
|
|
const switching = session.open(target, {
|
|
providerId: "beta",
|
|
signal: controller.signal,
|
|
});
|
|
while (beta.profileResolutions === 0)
|
|
await new Promise<void>((resolve) => setImmediate(resolve));
|
|
controller.abort();
|
|
pending.resolve(successfulExecution("alpha"));
|
|
|
|
expect(await running).toMatchObject({ ok: true });
|
|
const cancelled = await switching;
|
|
expect(cancelled).toEqual({
|
|
ok: false,
|
|
error: new AnalysisCancelledError("open_binary"),
|
|
});
|
|
expect(alpha.clients[0]?.closed).toBe(false);
|
|
expect(beta.clients).toHaveLength(0);
|
|
expect(await bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "alpha" } },
|
|
});
|
|
|
|
alpha.pending = undefined;
|
|
const retained = await session.execute("address_name", { address: "0x2" });
|
|
expect(retained.ok && retained.value.provider.id).toBe("alpha");
|
|
expect(alpha.clients).toHaveLength(1);
|
|
await session.close();
|
|
expect(alpha.clients[0]?.closed).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("selected provider capability provenance", () => {
|
|
it("labels selected-provider capabilities with the resolved identity", async () => {
|
|
const target = await databaseTarget();
|
|
const alpha = deepProvider("alpha");
|
|
const session = selectableSession([alpha.provider], auxiliaryProvider());
|
|
|
|
expect((await session.open(target, { providerId: "alpha" })).ok).toBe(true);
|
|
const resolved = { id: "alpha", name: "alpha provider", version: "1" };
|
|
const status = await bindingStatus(session);
|
|
expect(status).toMatchObject({
|
|
analysis_provider_binding: { provider: resolved },
|
|
analysis_provider_candidates: [
|
|
{
|
|
provider: resolved,
|
|
selected: true,
|
|
capabilities: [{ provider: resolved }],
|
|
},
|
|
],
|
|
});
|
|
expect(
|
|
z
|
|
.object({ capabilities: z.array(z.object({ provider: z.unknown() })) })
|
|
.parse(status)
|
|
.capabilities.map(({ provider }) => provider),
|
|
).toEqual([
|
|
{ id: "alpha", name: "alpha provider", version: "1" },
|
|
{ id: "auxiliary", name: "auxiliary provider", version: "1" },
|
|
]);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
interface ClientState {
|
|
readonly calls: string[];
|
|
closed: boolean;
|
|
}
|
|
|
|
interface DeepProviderFixture {
|
|
provider: AnalysisProviderCandidate;
|
|
readonly clients: ClientState[];
|
|
available: boolean;
|
|
fail: boolean;
|
|
profileResolutions: number;
|
|
pending: Promise<ReturnType<typeof successfulExecution>> | undefined;
|
|
}
|
|
|
|
const deepProvider = (id: string): DeepProviderFixture => {
|
|
const identity: ProviderIdentity = {
|
|
id,
|
|
name: `${id} provider`,
|
|
version: null,
|
|
};
|
|
const fixture: Omit<DeepProviderFixture, "provider"> = {
|
|
clients: [],
|
|
available: true,
|
|
fail: false,
|
|
profileResolutions: 0,
|
|
pending: undefined,
|
|
};
|
|
const provider: AnalysisProviderCandidate = {
|
|
identity: () => identity,
|
|
capabilities: () => [capability(identity, "address_name")],
|
|
inspectAvailability: () =>
|
|
fixture.available
|
|
? {
|
|
status: "available",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics: { executable_path: `/opt/${id}/bin/analyze` },
|
|
}
|
|
: {
|
|
status: "unavailable",
|
|
code: "runtime_missing",
|
|
reason: `${id} runtime is unavailable`,
|
|
diagnostics: { executable_path: `/opt/${id}/bin/analyze` },
|
|
},
|
|
inspectTargetSupport: () => ({
|
|
status: "supported",
|
|
code: null,
|
|
reason: null,
|
|
diagnostics: {},
|
|
}),
|
|
resolveAnalysisProfile: () => {
|
|
fixture.profileResolutions += 1;
|
|
return Promise.resolve(
|
|
ok({
|
|
profile: createAnalysisProfile(
|
|
{ id, name: identity.name, version: "1" },
|
|
{ provider: id },
|
|
),
|
|
}),
|
|
);
|
|
},
|
|
createClient: (_target, profile): AnalysisClient => {
|
|
const state: ClientState = { calls: [], closed: false };
|
|
fixture.clients.push(state);
|
|
return {
|
|
execute: (operation) => {
|
|
if (operation === "health")
|
|
return Promise.resolve(successfulExecution(id, profile?.provider));
|
|
state.calls.push(operation);
|
|
if (fixture.fail)
|
|
return Promise.resolve(
|
|
err(new ProviderAdapterError(id, operation)),
|
|
);
|
|
if (fixture.pending !== undefined) return fixture.pending;
|
|
return Promise.resolve(successfulExecution(id, profile?.provider));
|
|
},
|
|
close: () => {
|
|
state.closed = true;
|
|
return Promise.resolve(ok(null));
|
|
},
|
|
};
|
|
},
|
|
};
|
|
return Object.assign(fixture, { provider });
|
|
};
|
|
|
|
const successfulExecution = (
|
|
id: string,
|
|
provider: ProviderIdentity = {
|
|
id,
|
|
name: `${id} provider`,
|
|
version: "1",
|
|
},
|
|
) => ok(createAnalysisExecution(`${id}:address_name`, provider));
|
|
|
|
const auxiliaryProvider = (
|
|
id = "auxiliary",
|
|
operation: DeclaredOperation = "inspect_macho",
|
|
): AnalysisProvider => {
|
|
const identity = {
|
|
id,
|
|
name: `${id} provider`,
|
|
version: "1",
|
|
};
|
|
return {
|
|
identity: () => identity,
|
|
capabilities: () => [capability(identity, operation)],
|
|
createClient: (target) => ({
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
ok(
|
|
createAnalysisExecution(
|
|
`${operation}:auxiliary`,
|
|
identity,
|
|
operation === "inventory_artifact" ||
|
|
operation === "inspect_artifact"
|
|
? {
|
|
subject: {
|
|
path: target.sourcePath ?? target.path,
|
|
sha256: "f".repeat(64),
|
|
format: "directory",
|
|
},
|
|
}
|
|
: {},
|
|
),
|
|
),
|
|
),
|
|
close: () => Promise.resolve(ok(null)),
|
|
}),
|
|
};
|
|
};
|
|
|
|
const selectableSession = (
|
|
providers: readonly AnalysisProviderCandidate[],
|
|
auxiliary?: AnalysisProvider,
|
|
): BinarySession =>
|
|
composeBinarySession(
|
|
new AnalysisProviderRegistry(providers),
|
|
auxiliary === undefined ? [] : [auxiliary],
|
|
);
|
|
|
|
const capability = (
|
|
provider: ProviderIdentity,
|
|
operation: DeclaredOperation,
|
|
): CapabilityDescriptor => ({
|
|
provider,
|
|
operation,
|
|
available: true,
|
|
reason: null,
|
|
effects: {
|
|
mutatesArtifact: false,
|
|
launchesProcess: operation === "address_name",
|
|
mayShowUi: false,
|
|
mayAccessNetwork: false,
|
|
mayWriteFilesystem: false,
|
|
changesPermissions: false,
|
|
requiresRoot: false,
|
|
},
|
|
limitations: [],
|
|
});
|
|
|
|
const databaseTarget = async (): Promise<string> => {
|
|
const directory = await createTestTempDirectory("rea-provider-binding-");
|
|
const path = join(directory, "fixture.hop");
|
|
await writeFile(path, "fixture");
|
|
return path;
|
|
};
|
|
|
|
const bindingStatus = async (session: BinarySession) =>
|
|
z
|
|
.object({
|
|
analysis_provider_binding: z.unknown(),
|
|
analysis_provider_candidates: z.array(z.unknown()),
|
|
})
|
|
.passthrough()
|
|
.parse(await session.status());
|
|
|
|
interface Deferred<Value> {
|
|
readonly promise: Promise<Value>;
|
|
resolve(value: Value): void;
|
|
}
|
|
|
|
const deferred = <Value>(): Deferred<Value> => {
|
|
let resolvePromise: ((value: Value) => void) | undefined;
|
|
const promise = new Promise<Value>((resolve) => {
|
|
resolvePromise = resolve;
|
|
});
|
|
return {
|
|
promise,
|
|
resolve(value) {
|
|
resolvePromise?.(value);
|
|
},
|
|
};
|
|
};
|