357 lines
11 KiB
TypeScript
357 lines
11 KiB
TypeScript
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
|
|
import { afterEach, describe, expect, it } from "vitest";
|
|
import { z } from "zod";
|
|
|
|
import {
|
|
createAnalysisExecution,
|
|
type AnalysisClient,
|
|
type AnalysisProvider,
|
|
type AnalysisProviderCandidate,
|
|
type CapabilityDescriptor,
|
|
type ProviderIdentity,
|
|
} from "../src/application/AnalysisProvider.js";
|
|
import { AnalysisProviderRegistry } from "../src/application/AnalysisProviderRegistry.js";
|
|
import { BinarySession } from "../src/application/BinarySession.js";
|
|
import { SessionProviderRouter } from "../src/application/SessionProviderRouter.js";
|
|
import { createAnalysisProfile } from "../src/domain/analysisProfile.js";
|
|
import {
|
|
AnalysisCancelledError,
|
|
ProviderAdapterError,
|
|
} from "../src/domain/errors.js";
|
|
import { err, ok } from "../src/domain/result.js";
|
|
|
|
let directory: string | undefined;
|
|
afterEach(async () => {
|
|
if (directory !== undefined)
|
|
await rm(directory, { recursive: true, force: true });
|
|
directory = undefined;
|
|
});
|
|
|
|
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(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",
|
|
});
|
|
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(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(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(bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "beta" } },
|
|
});
|
|
|
|
await session.close();
|
|
expect(beta.clients[0]?.closed).toBe(true);
|
|
});
|
|
|
|
it("cancels a provider switch, closes its old client, and restores the binding", 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(true);
|
|
expect(beta.clients).toHaveLength(0);
|
|
expect(bindingStatus(session)).toMatchObject({
|
|
analysis_provider_binding: { provider: { id: "alpha" } },
|
|
});
|
|
|
|
alpha.pending = undefined;
|
|
const restored = await session.execute("address_name", { address: "0x2" });
|
|
expect(restored.ok && restored.value.provider.id).toBe("alpha");
|
|
expect(alpha.clients).toHaveLength(2);
|
|
await session.close();
|
|
expect(alpha.clients[1]?.closed).toBe(true);
|
|
});
|
|
});
|
|
|
|
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" },
|
|
1,
|
|
{ provider: id },
|
|
),
|
|
compatibility: {},
|
|
}),
|
|
);
|
|
},
|
|
createClient: (_target, profile): AnalysisClient => {
|
|
const state: ClientState = { calls: [], closed: false };
|
|
fixture.clients.push(state);
|
|
return {
|
|
execute: (operation) => {
|
|
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();
|
|
},
|
|
};
|
|
},
|
|
};
|
|
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: "address_name" | "inspect_macho" = "inspect_macho",
|
|
): AnalysisProvider => {
|
|
const identity = {
|
|
id,
|
|
name: `${id} provider`,
|
|
version: "1",
|
|
};
|
|
return {
|
|
identity: () => identity,
|
|
capabilities: () => [capability(identity, operation)],
|
|
createClient: () => ({
|
|
execute: (operation) =>
|
|
Promise.resolve(
|
|
ok(createAnalysisExecution(`${operation}:auxiliary`, identity)),
|
|
),
|
|
close: () => Promise.resolve(),
|
|
}),
|
|
};
|
|
};
|
|
|
|
const selectableSession = (
|
|
providers: readonly AnalysisProviderCandidate[],
|
|
auxiliary?: AnalysisProvider,
|
|
): BinarySession =>
|
|
new BinarySession(
|
|
SessionProviderRouter.selectable(
|
|
new AnalysisProviderRegistry(providers),
|
|
auxiliary === undefined ? [] : [auxiliary],
|
|
),
|
|
);
|
|
|
|
const capability = (
|
|
provider: ProviderIdentity,
|
|
operation: "address_name" | "inspect_macho",
|
|
): CapabilityDescriptor => ({
|
|
provider,
|
|
operation,
|
|
inputContractVersion: 1,
|
|
outputContractVersion: 1,
|
|
available: true,
|
|
reason: null,
|
|
pagination: "none",
|
|
exhaustive: true,
|
|
effects: {
|
|
mutatesArtifact: false,
|
|
launchesProcess: operation === "address_name",
|
|
mayShowUi: false,
|
|
mayAccessNetwork: false,
|
|
mayWriteFilesystem: false,
|
|
changesPermissions: false,
|
|
requiresRoot: false,
|
|
},
|
|
limits: { maxResults: null, maxPayloadBytes: 1_000, timeoutMs: 1_000 },
|
|
limitations: [],
|
|
});
|
|
|
|
const databaseTarget = async (): Promise<string> => {
|
|
directory = await mkdtemp(join(tmpdir(), "rea-provider-binding-"));
|
|
const path = join(directory, "fixture.hop");
|
|
await writeFile(path, "fixture");
|
|
return path;
|
|
};
|
|
|
|
const bindingStatus = (session: BinarySession) =>
|
|
z
|
|
.object({
|
|
analysis_provider_binding: z.unknown(),
|
|
analysis_provider_candidates: z.array(z.unknown()),
|
|
})
|
|
.passthrough()
|
|
.parse(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);
|
|
},
|
|
};
|
|
};
|