mirror of
https://github.com/morluto/rea
synced 2026-10-26 06:16:17 -04:00
fix(ghidra): own Java prerequisite probes through final cleanup
Replace synchronous Java execution with bounded owned commands. Keep task-scoped readiness observations fresh across later tasks, defer target client prerequisites behind a closeable owner, and join doctor cancellation and cleanup. Preserve completed physical failures across late cancellation, exact cleanup dependencies and partial observations. Admit target/profile inputs before probing and return typed factory failures instead of unavailable client wrappers. Validation: 405 focused tests across 24 files, 2 explicit skips; 109 provider/doctor controls rerun after final admission refinement; check:fast and docs:check. Controlled macOS Java-shaped commands and public CLI/MCP workflows; no new JVM/Ghidra or Windows/Linux lifecycle verification. Measured warm public doctor baseline/candidate: elapsed 0.447/1.223s, CPU 0.624/1.237s, sampled family RSS 379/529MB, reply tokens 3133/3133. Ownership adds cost; no performance improvement claimed. True cold, whole-agent and MCP costs unmeasured. Refs #4561. Remaining installation file admission tracked in #4672.
This commit is contained in:
@@ -26,6 +26,7 @@ graph TD
|
||||
end
|
||||
|
||||
subgraph "Provider Layers"
|
||||
PROVIDER_OWNERS["src/providers/<br/>shared deferred client and inspection owners<br/>exact cleanup retry and attributed failures"]
|
||||
PWNTOOLS["src/native/pwntools/ + src/native/pwndbg/<br/>BYO offline ELF/core adapters<br/>owned snapshots/Python and optional core-only GDB<br/>unchanged upstream parsers, ABI structures and mapping API"]
|
||||
WASM["src/wasm/<br/>caller-supplied WABT; immutable artifact inspection<br/>owned process supervision and retryable workspace cleanup"]
|
||||
EVM["src/evm/<br/>unchanged EVMole/WASM in bounded owned worker<br/>explicit carrier parsing and inferred interface adapter"]
|
||||
@@ -138,6 +139,11 @@ graph TD
|
||||
GHIDRA --> GHIDRA_BRIDGE
|
||||
HOPPER --> PROCESS
|
||||
GHIDRA --> PROCESS
|
||||
GHIDRA --> PROVIDER_OWNERS
|
||||
APP --> PROVIDER_OWNERS
|
||||
PROVIDER_OWNERS --> PROCESS
|
||||
PROVIDER_OWNERS --> AUX
|
||||
PROVIDER_OWNERS --> DOMAIN
|
||||
GHIDRA --> WINDOWS
|
||||
PROCESS --> WINDOWS
|
||||
WINDOWS --> WINDOWS_NATIVE
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import type { OfficialToolName } from "../contracts/officialToolContracts.js";
|
||||
import type { EnhancedToolName } from "../contracts/enhancedInputs.js";
|
||||
@@ -193,8 +194,12 @@ export interface AnalysisProfileResolution {
|
||||
}
|
||||
|
||||
/** Cancellation context for side-effect-free provider profile resolution. */
|
||||
export interface AnalysisProfileResolutionOptions {
|
||||
export interface AnalysisProviderInspectionOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly scope?: ProviderInspectionScope;
|
||||
}
|
||||
|
||||
export interface AnalysisProfileResolutionOptions extends AnalysisProviderInspectionOptions {
|
||||
readonly fileCloseResources?: FileCloseResourceOwner;
|
||||
}
|
||||
|
||||
@@ -304,10 +309,10 @@ export interface AnalysisProviderCandidate extends AnalysisProvider {
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>>;
|
||||
/** Await prerequisite observations and finalization under the supplied owner signal. */
|
||||
inspectAvailability(
|
||||
signal?: AbortSignal,
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
): ProviderAvailability | Promise<ProviderAvailability>;
|
||||
inspectReadinessSnapshot?(
|
||||
signal?: AbortSignal,
|
||||
options?: AnalysisProviderInspectionOptions,
|
||||
):
|
||||
| AnalysisProviderReadinessSnapshot
|
||||
| Promise<AnalysisProviderReadinessSnapshot>;
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { createDoctorHostFixture } from "./Doctor.fixture.js";
|
||||
import { runDoctorOwned, systemDoctorHost } from "./Doctor.js";
|
||||
|
||||
it("joins a late provider inspection and retries only its unresolved cleanup owners", async () => {
|
||||
let enter!: (signal: AbortSignal) => void;
|
||||
const entered = new Promise<AbortSignal>((resolve) => {
|
||||
enter = resolve;
|
||||
});
|
||||
let handOff!: () => void;
|
||||
const handoff = new Promise<void>((resolve) => {
|
||||
handOff = resolve;
|
||||
});
|
||||
let finish!: () => void;
|
||||
const finishing = new Promise<void>((resolve) => {
|
||||
finish = resolve;
|
||||
});
|
||||
let denied = true;
|
||||
let closed = false;
|
||||
const closes = { successful: 0, refused: 0 };
|
||||
const host = systemDoctorHost({
|
||||
environment: {},
|
||||
providerInspections: async (owner, signal) => {
|
||||
enter(signal);
|
||||
await handoff;
|
||||
owner.retain(
|
||||
{
|
||||
close: async () => {
|
||||
closes.successful++;
|
||||
await finishing;
|
||||
},
|
||||
},
|
||||
"successful-prerequisite",
|
||||
);
|
||||
owner.retain(
|
||||
{
|
||||
close: async () => {
|
||||
closes.refused++;
|
||||
if (denied) throw new Error("controlled cleanup refusal");
|
||||
},
|
||||
},
|
||||
"refused-prerequisite",
|
||||
);
|
||||
return [];
|
||||
},
|
||||
});
|
||||
const inspection = host.providerInspections!();
|
||||
const signal = await entered;
|
||||
const closing = host.close!().then(
|
||||
() => {
|
||||
closed = true;
|
||||
return undefined;
|
||||
},
|
||||
(cause: unknown) => {
|
||||
closed = true;
|
||||
return cause;
|
||||
},
|
||||
);
|
||||
try {
|
||||
expect(signal.aborted).toBe(true);
|
||||
expect(closed).toBe(false);
|
||||
handOff();
|
||||
await inspection;
|
||||
expect(closed).toBe(false);
|
||||
finish();
|
||||
const failure = await closing;
|
||||
if (!(failure instanceof AnalysisError))
|
||||
throw new Error("Expected attributed cleanup failure");
|
||||
expect(failure.cleanupResources).toEqual(["refused-prerequisite"]);
|
||||
expect(failure.cleanupIncomplete).toBe(true);
|
||||
expect(closes).toEqual({ successful: 1, refused: 1 });
|
||||
denied = false;
|
||||
await host.close!();
|
||||
expect(failure.cleanupIncomplete).toBe(false);
|
||||
expect(closes).toEqual({ successful: 1, refused: 2 });
|
||||
await expect(host.providerInspections!()).rejects.toThrow(
|
||||
"scope is closed",
|
||||
);
|
||||
} finally {
|
||||
denied = false;
|
||||
handOff();
|
||||
finish();
|
||||
await inspection;
|
||||
await closing;
|
||||
await host.close!();
|
||||
}
|
||||
});
|
||||
|
||||
it("retains a completed healthy report when cancellation arrives during final cleanup", async () => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel final doctor cleanup" };
|
||||
await expect(
|
||||
runDoctorOwned(
|
||||
undefined,
|
||||
createDoctorHostFixture({
|
||||
signal: controller.signal,
|
||||
close: async () => {
|
||||
controller.abort(reason);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
diagnostics: { doctor_report: { healthy: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves an inspection-authored physical failure when final cleanup also observes cancellation", async () => {
|
||||
const controller = new AbortController();
|
||||
const physical = new ProviderAdapterError("ghidra", "doctor", {
|
||||
diagnostics: { reason: "controlled inspection fault" },
|
||||
});
|
||||
await expect(
|
||||
runDoctorOwned(
|
||||
undefined,
|
||||
createDoctorHostFixture({
|
||||
signal: controller.signal,
|
||||
providerInspections: async () => {
|
||||
throw physical;
|
||||
},
|
||||
close: async () => {
|
||||
controller.abort(physical);
|
||||
return undefined;
|
||||
},
|
||||
}),
|
||||
),
|
||||
).rejects.toBe(physical);
|
||||
expect(physical.cause).toBeUndefined();
|
||||
});
|
||||
@@ -1,3 +1,12 @@
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import { STARTUP_OWNER_CLOSED } from "../process/SharedStartupAttempt.js";
|
||||
import { constants } from "node:fs";
|
||||
import { access, readdir, stat } from "node:fs/promises";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
@@ -9,7 +18,7 @@ import { analysisErrorRemediationAction } from "../domain/analysisErrorPresentat
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { parseBinaryTarget } from "./BinaryTargetResolver.js";
|
||||
import { execFileOutput } from "../process/ExecFileOutput.js";
|
||||
import type { JsonValue } from "../domain/jsonValue.js";
|
||||
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
|
||||
import {
|
||||
javaScriptRuntime,
|
||||
type JavaScriptRuntime,
|
||||
@@ -93,6 +102,7 @@ export interface DoctorProviderInspection {
|
||||
}
|
||||
/** Read-only host capabilities required by diagnostics. */
|
||||
export interface DoctorHost {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly platform: NodeJS.Platform;
|
||||
readonly architecture: NodeJS.Architecture;
|
||||
readonly runtime: JavaScriptRuntime;
|
||||
@@ -203,15 +213,36 @@ export const runDoctorOwned = async (
|
||||
report = await inspectDoctor(target, host, requestedScope);
|
||||
} catch (cause: unknown) {
|
||||
const cleanup = await closeDoctorHost(host);
|
||||
if (cleanup !== undefined && cause instanceof AnalysisError) {
|
||||
const cleanupError =
|
||||
typeof cleanup === "string"
|
||||
? new ProviderCleanupError(
|
||||
"rea",
|
||||
[],
|
||||
{ reason: cleanup },
|
||||
{ operation: "doctor" },
|
||||
)
|
||||
: cleanup;
|
||||
throw analysisErrorWithCleanupFailure(
|
||||
cause,
|
||||
cleanupError,
|
||||
"doctor",
|
||||
).bindCleanupDependencies([cause, cleanupError]);
|
||||
}
|
||||
if (cleanup !== undefined)
|
||||
throw new AggregateError(
|
||||
[cause, new Error(cleanup)],
|
||||
[cause, typeof cleanup === "string" ? new Error(cleanup) : cleanup],
|
||||
"Doctor failed and its resource cleanup remains incomplete.",
|
||||
{ cause },
|
||||
);
|
||||
throw cause;
|
||||
}
|
||||
const cleanup = await closeDoctorHost(host);
|
||||
if (cleanup === undefined && report.healthy && host.signal?.aborted)
|
||||
throw new AnalysisCancelledError("doctor", {
|
||||
cause: host.signal.reason,
|
||||
diagnostics: { doctor_report: jsonValueSchema.parse(report) },
|
||||
});
|
||||
return cleanup === undefined
|
||||
? report
|
||||
: doctorReportWithCleanup(report, cleanup);
|
||||
@@ -222,6 +253,8 @@ const inspectDoctor = async (
|
||||
host: DoctorHost,
|
||||
requestedScope?: DoctorScope,
|
||||
): Promise<DoctorReport> => {
|
||||
if (host.signal?.aborted)
|
||||
throw new AnalysisCancelledError("doctor", { cause: host.signal.reason });
|
||||
const scope = normalizeDoctorScope(requestedScope, target);
|
||||
const {
|
||||
checks: diagnosticChecks,
|
||||
@@ -283,23 +316,30 @@ const registrationAdvisoryChecks = (
|
||||
|
||||
const closeDoctorHost = async (
|
||||
host: DoctorHost,
|
||||
): Promise<string | undefined> => {
|
||||
): Promise<string | AnalysisError | undefined> => {
|
||||
try {
|
||||
return await host.close?.();
|
||||
} catch (cause: unknown) {
|
||||
if (cause instanceof AnalysisError) return cause;
|
||||
return `Doctor host cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
}
|
||||
};
|
||||
|
||||
const doctorReportWithCleanup = (
|
||||
report: DoctorReport,
|
||||
detail: string,
|
||||
cleanup: string | AnalysisError,
|
||||
): DoctorReport => {
|
||||
const check: DoctorCheck = {
|
||||
name: "doctor-resource-cleanup",
|
||||
ok: false,
|
||||
classification: "config_drift",
|
||||
detail,
|
||||
detail:
|
||||
typeof cleanup === "string"
|
||||
? cleanup
|
||||
: `${cleanup.message} ${cleanup.cleanupResources.join(", ")}`,
|
||||
...(typeof cleanup === "string"
|
||||
? {}
|
||||
: { details: { cleanup_failure: projectAnalysisError(cleanup) } }),
|
||||
remediation: "Resolve the reported cleanup failure, then rerun rea doctor.",
|
||||
};
|
||||
return {
|
||||
@@ -340,7 +380,10 @@ const collectDoctorIdentity = async (
|
||||
cli_package_version: PRODUCT_IDENTITY.packageVersion,
|
||||
expected_skill_version: PRODUCT_IDENTITY.skillVersion,
|
||||
sdk: SDK_IDENTITY,
|
||||
catalog: CATALOG_IDENTITY,
|
||||
catalog: {
|
||||
counts: CATALOG_IDENTITY.counts,
|
||||
digests: CATALOG_IDENTITY.digests,
|
||||
},
|
||||
live_server: {
|
||||
state: "unknown",
|
||||
remediation:
|
||||
@@ -430,12 +473,14 @@ const installationState = (
|
||||
|
||||
/** Optional outer-adapter diagnostics composed without reversing dependencies. */
|
||||
export interface SystemDoctorHostOptions {
|
||||
readonly signal?: AbortSignal;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
readonly environment?: NodeJS.ProcessEnv;
|
||||
readonly execFileOutput?: typeof execFileOutput;
|
||||
readonly providerInspections?: (
|
||||
fileCloseResources: FileCloseResourceOwner,
|
||||
signal: AbortSignal,
|
||||
) => Promise<readonly DoctorProviderInspection[]>;
|
||||
readonly providerRegistrationEnvironment?: () => Readonly<
|
||||
Record<string, string>
|
||||
@@ -449,6 +494,11 @@ export const systemDoctorHost = (
|
||||
options: SystemDoctorHostOptions = {},
|
||||
): DoctorHost => {
|
||||
const resources = new ArtifactResourceScope();
|
||||
const controller = new AbortController();
|
||||
const inspectionSignal =
|
||||
options.signal === undefined
|
||||
? controller.signal
|
||||
: AbortSignal.any([options.signal, controller.signal]);
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const environment = options.environment ?? process.env;
|
||||
@@ -460,11 +510,12 @@ export const systemDoctorHost = (
|
||||
resources.retain({
|
||||
kind: "lease",
|
||||
resource,
|
||||
lease: { release: () => owner.close() },
|
||||
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
|
||||
}),
|
||||
run: (operation) => resources.run(operation),
|
||||
};
|
||||
return {
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
platform,
|
||||
architecture,
|
||||
runtime: javaScriptRuntime(process.versions),
|
||||
@@ -561,7 +612,12 @@ export const systemDoctorHost = (
|
||||
? {}
|
||||
: {
|
||||
providerInspections: () =>
|
||||
options.providerInspections!(fileCloseResources),
|
||||
resources.run(() =>
|
||||
options.providerInspections!(
|
||||
fileCloseResources,
|
||||
inspectionSignal,
|
||||
),
|
||||
),
|
||||
}),
|
||||
...(options.providerRegistrationEnvironment === undefined
|
||||
? {}
|
||||
@@ -612,11 +668,23 @@ export const systemDoctorHost = (
|
||||
resources,
|
||||
),
|
||||
close: async () => {
|
||||
controller.abort(STARTUP_OWNER_CLOSED);
|
||||
try {
|
||||
await resources.close();
|
||||
return undefined;
|
||||
} catch (cause: unknown) {
|
||||
return `Doctor resource cleanup failed: ${cause instanceof Error ? cause.message : String(cause)}`;
|
||||
const observation = ArtifactReaderFailure.cleanupObservation(
|
||||
cause,
|
||||
"doctor resources",
|
||||
);
|
||||
const failure = new ProviderCleanupError(
|
||||
"rea",
|
||||
observation.resources,
|
||||
{ reason: observation.reason },
|
||||
{ cause, operation: "doctor" },
|
||||
);
|
||||
bindArtifactFileCleanup(cause, failure);
|
||||
throw failure;
|
||||
}
|
||||
},
|
||||
clientRegistrations: () =>
|
||||
|
||||
@@ -10,10 +10,12 @@ import {
|
||||
import { AnalysisError } from "../../domain/analysisErrorBase.js";
|
||||
import { jsonObjectSchema, type JsonValue } from "../../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../../domain/result.js";
|
||||
import type { AnalysisProviderCandidate } from "../AnalysisProvider.js";
|
||||
import type {
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProviderCandidate,
|
||||
} from "../AnalysisProvider.js";
|
||||
import type { AnalysisProviderCandidateStatus } from "./AnalysisProviderRegistry.js";
|
||||
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
|
||||
import type { FileCloseResourceOwner } from "../AnalysisProvider.js";
|
||||
|
||||
/** Profile-resolution truth for one discovered provider candidate. */
|
||||
export interface AnalysisProviderCandidateEvaluation {
|
||||
@@ -28,21 +30,18 @@ export const evaluateAnalysisProviderCandidate = async (
|
||||
candidate: AnalysisProviderCandidate,
|
||||
status: AnalysisProviderCandidateStatus,
|
||||
target: BinaryTarget,
|
||||
signal: AbortSignal | undefined,
|
||||
fileCloseResources?: FileCloseResourceOwner,
|
||||
options: AnalysisProfileResolutionOptions = {},
|
||||
): Promise<
|
||||
Result<AnalysisProviderCandidateEvaluation, AnalysisCancelledError>
|
||||
> => {
|
||||
const { signal } = options;
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal?.reason }),
|
||||
);
|
||||
let resolved: Awaited<ReturnType<typeof candidate.resolveAnalysisProfile>>;
|
||||
try {
|
||||
resolved = await candidate.resolveAnalysisProfile(target, {
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(fileCloseResources === undefined ? {} : { fileCloseResources }),
|
||||
});
|
||||
resolved = await candidate.resolveAnalysisProfile(target, options);
|
||||
} catch (cause: unknown) {
|
||||
resolved = err(
|
||||
cause instanceof AnalysisError
|
||||
|
||||
@@ -779,7 +779,7 @@ it("joins cancelled readiness finalization and shutdown before admitting another
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async (signal) => {
|
||||
inspectAvailability: async ({ signal } = {}) => {
|
||||
calls++;
|
||||
if (calls === 1) {
|
||||
observed = signal;
|
||||
@@ -936,7 +936,7 @@ it("registers selection before async readiness can race shutdown", async () => {
|
||||
const registry = new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async (signal) => {
|
||||
inspectAvailability: async ({ signal } = {}) => {
|
||||
calls++;
|
||||
observed = signal;
|
||||
return base.inspectAvailability();
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProviderInspectionScope } from "../../providers/ProviderInspectionScope.js";
|
||||
import { AnalysisCleanupLease } from "../../artifacts/AnalysisCleanupLease.js";
|
||||
import { ArtifactResourceScope } from "../../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
|
||||
@@ -84,7 +85,8 @@ export interface AnalysisProviderSelection {
|
||||
interface TargetSelectionContext {
|
||||
readonly target: BinaryTarget;
|
||||
readonly baseline: readonly AnalysisProviderCandidateStatus[];
|
||||
readonly signal: AbortSignal | undefined;
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
}
|
||||
|
||||
type SelectionResult = Result<AnalysisProviderSelection, AnalysisError>;
|
||||
@@ -279,11 +281,18 @@ export class AnalysisProviderRegistry {
|
||||
capabilities: provider.capabilities(),
|
||||
}),
|
||||
);
|
||||
const scope = new ProviderInspectionScope();
|
||||
const discovery = new SharedStartupAttempt<
|
||||
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
>(
|
||||
(signal) =>
|
||||
Promise.resolve().then(() => this.#discoverCandidates(target, signal)),
|
||||
Promise.resolve().then(() =>
|
||||
this.#discoverCandidates(target, {
|
||||
signal,
|
||||
scope,
|
||||
operation: "binary_session",
|
||||
}),
|
||||
),
|
||||
options.signal,
|
||||
);
|
||||
this.#discoveries.add(discovery);
|
||||
@@ -299,24 +308,29 @@ export class AnalysisProviderRegistry {
|
||||
});
|
||||
return result.value;
|
||||
} finally {
|
||||
scope.close();
|
||||
this.#discoveries.delete(discovery);
|
||||
}
|
||||
}
|
||||
|
||||
async #discoverCandidates(
|
||||
target: BinaryTarget | undefined,
|
||||
signal: AbortSignal,
|
||||
operation: "binary_session" | "open_binary" = "binary_session",
|
||||
context: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
readonly operation: "binary_session" | "open_binary";
|
||||
},
|
||||
): Promise<
|
||||
Result<readonly AnalysisProviderCandidateStatus[], AnalysisError>
|
||||
> {
|
||||
const { signal, operation } = context;
|
||||
if (signal.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError(operation, { cause: signal.reason }),
|
||||
);
|
||||
const results = await Promise.allSettled(
|
||||
this.#providers.map((provider) =>
|
||||
candidateStatus(provider, target, signal, operation),
|
||||
candidateStatus(provider, target, context),
|
||||
),
|
||||
);
|
||||
const statuses: AnalysisProviderCandidateStatus[] = [];
|
||||
@@ -430,71 +444,78 @@ export class AnalysisProviderRegistry {
|
||||
requestedSelector: AnalysisProviderSelector | undefined,
|
||||
signal: AbortSignal,
|
||||
): Promise<SelectionResult> {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
const discovery = await this.#discoverCandidates(
|
||||
target,
|
||||
signal,
|
||||
"open_binary",
|
||||
);
|
||||
if (!discovery.ok) return discovery;
|
||||
const baseline = discovery.value;
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
baseline,
|
||||
),
|
||||
);
|
||||
const request = parsedRequest?.data;
|
||||
const selector = request ?? this.#defaultSelector;
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
const scope = new ProviderInspectionScope();
|
||||
try {
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
const discovery = await this.#discoverCandidates(target, {
|
||||
signal,
|
||||
scope,
|
||||
operation: "open_binary",
|
||||
});
|
||||
if (
|
||||
request === undefined &&
|
||||
this.#byId.has(selector) &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
if (!discovery.ok) return discovery;
|
||||
const baseline = discovery.value;
|
||||
const parsedRequest =
|
||||
requestedSelector === undefined
|
||||
? undefined
|
||||
: analysisProviderSelectorSchema.safeParse(requestedSelector);
|
||||
if (parsedRequest !== undefined && !parsedRequest.success)
|
||||
return err(
|
||||
selectionError(
|
||||
"invalid_options",
|
||||
typeof requestedSelector === "string"
|
||||
? requestedSelector
|
||||
: "<invalid>",
|
||||
baseline,
|
||||
),
|
||||
);
|
||||
const request = parsedRequest?.data;
|
||||
const selector = request ?? this.#defaultSelector;
|
||||
if (signalIsAborted(signal))
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", { cause: signal.reason }),
|
||||
);
|
||||
if (selector === "auto" && this.canInspectWithoutDeepProvider?.(target))
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (
|
||||
request === undefined &&
|
||||
this.#byId.has(selector) &&
|
||||
baseline.length > 0 &&
|
||||
baseline.every(
|
||||
({ targetSupport }) => targetSupport.status === "unsupported",
|
||||
)
|
||||
)
|
||||
)
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
return ok({
|
||||
requestedProviderId: selector,
|
||||
binding: null,
|
||||
candidates: baseline,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return await this.#selectConcrete(
|
||||
{
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
scope,
|
||||
},
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
);
|
||||
return await this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
scope,
|
||||
});
|
||||
if (selector !== "auto")
|
||||
return this.#selectConcrete(
|
||||
{
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
},
|
||||
selector,
|
||||
request === undefined ? "environment" : "request",
|
||||
);
|
||||
return this.#selectAutomatically({
|
||||
target,
|
||||
baseline,
|
||||
signal: signal,
|
||||
});
|
||||
} finally {
|
||||
scope.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** Explain why a deep operation is unavailable on an unbound target. */
|
||||
@@ -522,7 +543,7 @@ export class AnalysisProviderRegistry {
|
||||
providerId: string,
|
||||
source: AnalysisProviderBinding["selectionSource"],
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const { baseline, signal, target, scope } = context;
|
||||
const provider = this.#byId.get(providerId);
|
||||
if (provider === undefined)
|
||||
return err(selectionError("unknown_provider", providerId, baseline));
|
||||
@@ -541,8 +562,7 @@ export class AnalysisProviderRegistry {
|
||||
provider,
|
||||
initial,
|
||||
target,
|
||||
signal,
|
||||
this.#profileFileOwner,
|
||||
{ signal, fileCloseResources: this.#profileFileOwner, scope },
|
||||
);
|
||||
if (!evaluation.ok) return evaluation;
|
||||
const evaluated = evaluation.value;
|
||||
@@ -567,7 +587,7 @@ export class AnalysisProviderRegistry {
|
||||
async #selectAutomatically(
|
||||
context: TargetSelectionContext,
|
||||
): Promise<Result<AnalysisProviderSelection, AnalysisError>> {
|
||||
const { baseline, signal, target } = context;
|
||||
const { baseline, signal, target, scope } = context;
|
||||
const results = await Promise.all(
|
||||
this.#providers.map(
|
||||
async (
|
||||
@@ -584,13 +604,11 @@ export class AnalysisProviderRegistry {
|
||||
);
|
||||
return status.availability.status === "available" &&
|
||||
status.targetSupport.status === "supported"
|
||||
? evaluateAnalysisProviderCandidate(
|
||||
provider,
|
||||
status,
|
||||
target,
|
||||
? evaluateAnalysisProviderCandidate(provider, status, target, {
|
||||
signal,
|
||||
this.#profileFileOwner,
|
||||
)
|
||||
fileCloseResources: this.#profileFileOwner,
|
||||
scope,
|
||||
})
|
||||
: ok({
|
||||
candidate: provider,
|
||||
status,
|
||||
@@ -647,18 +665,22 @@ export class AnalysisProviderRegistry {
|
||||
const candidateStatus = async (
|
||||
provider: AnalysisProviderCandidate,
|
||||
target: BinaryTarget | undefined,
|
||||
signal: AbortSignal,
|
||||
operation: "binary_session" | "open_binary",
|
||||
context: {
|
||||
readonly signal: AbortSignal;
|
||||
readonly scope: ProviderInspectionScope;
|
||||
readonly operation: "binary_session" | "open_binary";
|
||||
},
|
||||
): Promise<AnalysisProviderCandidateStatus> => {
|
||||
const { signal, scope, operation } = context;
|
||||
if (signal.aborted)
|
||||
throw new AnalysisCancelledError(operation, { cause: signal.reason });
|
||||
const readiness =
|
||||
provider.inspectReadinessSnapshot === undefined
|
||||
? {
|
||||
availability: await provider.inspectAvailability(signal),
|
||||
availability: await provider.inspectAvailability({ signal, scope }),
|
||||
capabilities: provider.capabilities(),
|
||||
}
|
||||
: await provider.inspectReadinessSnapshot(signal);
|
||||
: await provider.inspectReadinessSnapshot({ signal, scope });
|
||||
return projectCandidateStatus(provider, target, readiness);
|
||||
};
|
||||
|
||||
|
||||
+15
-12
@@ -1,3 +1,4 @@
|
||||
import { withCommandCancellation } from "./commandCancellation.js";
|
||||
import { z } from "incur";
|
||||
|
||||
import type { DoctorScope } from "../application/Doctor.js";
|
||||
@@ -120,18 +121,20 @@ const registerDoctorCommand = (
|
||||
.describe("Require the installed REA skill identity to be aligned"),
|
||||
}),
|
||||
run: ({ options }) =>
|
||||
logCliCommand(logger, "doctor", async () => {
|
||||
const [{ runDoctorOwned }, { createSystemDoctorHost }] =
|
||||
await Promise.all([
|
||||
import("../application/Doctor.js"),
|
||||
import("../doctorRuntime.js"),
|
||||
]);
|
||||
return runDoctorOwned(
|
||||
options.target,
|
||||
createSystemDoctorHost(environment),
|
||||
doctorScope(options),
|
||||
);
|
||||
}),
|
||||
logCliCommand(logger, "doctor", () =>
|
||||
withCommandCancellation(async (signal) => {
|
||||
const [{ runDoctorOwned }, { createSystemDoctorHost }] =
|
||||
await Promise.all([
|
||||
import("../application/Doctor.js"),
|
||||
import("../doctorRuntime.js"),
|
||||
]);
|
||||
return runDoctorOwned(
|
||||
options.target,
|
||||
createSystemDoctorHost(environment, signal),
|
||||
doctorScope(options),
|
||||
);
|
||||
}),
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import { ok } from "./domain/result.js";
|
||||
/** Compose provider diagnostics at the outer CLI adapter boundary. */
|
||||
export const createSystemDoctorHost = (
|
||||
environment: Readonly<NodeJS.ProcessEnv> = process.env,
|
||||
signal?: AbortSignal,
|
||||
): DoctorHost => {
|
||||
const selectedJebEndpoint = environment.REA_JEB_MCP_URL;
|
||||
const selectedJebRegistrationEnvironment: Readonly<Record<string, string>> =
|
||||
@@ -26,6 +27,7 @@ export const createSystemDoctorHost = (
|
||||
: {};
|
||||
return systemDoctorHost({
|
||||
environment,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
providerRegistrationEnvironment: () => selectedJebRegistrationEnvironment,
|
||||
resolveProviderRequestBudget: (
|
||||
analysisProvider,
|
||||
@@ -42,8 +44,12 @@ export const createSystemDoctorHost = (
|
||||
// parser-owned error when IDA itself was explicitly selected.
|
||||
return analysisProvider === "ida" ? registration : ok(undefined);
|
||||
},
|
||||
providerInspections: async (fileCloseResources) => [
|
||||
await inspectSystemGhidraProvider(environment),
|
||||
providerInspections: async (fileCloseResources, inspectionSignal) => [
|
||||
await inspectSystemGhidraProvider(
|
||||
environment,
|
||||
fileCloseResources,
|
||||
inspectionSignal,
|
||||
),
|
||||
inspectIdaRegistration(environment.REA_IDA_MCP_CONFIG),
|
||||
await inspectJadxProvider(environment),
|
||||
await inspectKunaProvider(environment, process.platform, {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
||||
|
||||
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
@@ -15,6 +17,12 @@ import {
|
||||
} from "./GhidraInstallation.js";
|
||||
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
|
||||
let inspectionResources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await inspectionResources.close();
|
||||
inspectionResources = createInspectionResources();
|
||||
});
|
||||
|
||||
const INSTALL = "/opt/ghidra_12.1.4_PUBLIC";
|
||||
const installationHost = (
|
||||
overrides: Partial<GhidraInstallationHost> = {},
|
||||
@@ -35,16 +43,17 @@ const installationHost = (
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const inspection = (
|
||||
const inspection = async (
|
||||
options: {
|
||||
readonly installDir?: string;
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
} = { installDir: INSTALL },
|
||||
overrides: Partial<GhidraInstallationHost> = {},
|
||||
): GhidraInstallationInspection =>
|
||||
inspectGhidraInstallation(
|
||||
): Promise<GhidraInstallationInspection> =>
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
platform: options.platform ?? "linux",
|
||||
architecture: options.architecture ?? "x64",
|
||||
@@ -85,9 +94,9 @@ const doctorHost = (
|
||||
Promise.resolve([projectGhidraDoctorInspection(ghidra)]),
|
||||
});
|
||||
|
||||
describe("Ghidra doctor integration", () => {
|
||||
describe("Ghidra doctor integration", async () => {
|
||||
it("accepts a valid BYO Ghidra engine when Hopper is absent", async () => {
|
||||
const result = await runDoctor(undefined, doctorHost(inspection()));
|
||||
const result = await runDoctor(undefined, doctorHost(await inspection()));
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.hopperPath).toBeUndefined();
|
||||
@@ -111,12 +120,12 @@ describe("Ghidra doctor integration", () => {
|
||||
it.each([
|
||||
[
|
||||
"bad installation",
|
||||
inspection({ installDir: INSTALL }, { readText: () => undefined }),
|
||||
await inspection({ installDir: INSTALL }, { readText: () => undefined }),
|
||||
"ghidra-installation",
|
||||
],
|
||||
[
|
||||
"wrong version",
|
||||
inspection(
|
||||
await inspection(
|
||||
{ installDir: INSTALL },
|
||||
{
|
||||
readText: () => "application.version=11.4\napplication.java.min=21\n",
|
||||
@@ -126,12 +135,12 @@ describe("Ghidra doctor integration", () => {
|
||||
],
|
||||
[
|
||||
"missing analyzeHeadless",
|
||||
inspection({ installDir: INSTALL }, { executable: () => false }),
|
||||
await inspection({ installDir: INSTALL }, { executable: () => false }),
|
||||
"ghidra-headless",
|
||||
],
|
||||
[
|
||||
"wrong Java",
|
||||
inspection(
|
||||
await inspection(
|
||||
{ installDir: INSTALL },
|
||||
{
|
||||
probeJava: () =>
|
||||
@@ -150,7 +159,7 @@ describe("Ghidra doctor integration", () => {
|
||||
],
|
||||
[
|
||||
"missing Java minimum",
|
||||
inspection(
|
||||
await inspection(
|
||||
{ installDir: INSTALL },
|
||||
{ readText: () => "application.version=12.1.4\n" },
|
||||
),
|
||||
@@ -158,7 +167,7 @@ describe("Ghidra doctor integration", () => {
|
||||
],
|
||||
[
|
||||
"unsupported architecture",
|
||||
inspection({ installDir: INSTALL, architecture: "arm" }),
|
||||
await inspection({ installDir: INSTALL, architecture: "arm" }),
|
||||
"ghidra-architecture",
|
||||
],
|
||||
] as const)("distinguishes %s", async (_label, ghidra, checkName) => {
|
||||
@@ -172,7 +181,10 @@ describe("Ghidra doctor integration", () => {
|
||||
});
|
||||
|
||||
it("treats unconfigured Ghidra as optional when Hopper is healthy", async () => {
|
||||
const result = await runDoctor(undefined, doctorHost(inspection({}), true));
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
doctorHost(await inspection({}), true),
|
||||
);
|
||||
|
||||
expect(result.healthy).toBe(true);
|
||||
expect(result.checks.find(({ name }) => name === "ghidra")).toMatchObject({
|
||||
@@ -192,7 +204,7 @@ describe("Windows Ghidra doctor authority", () => {
|
||||
});
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
it("distinguishes a valid Windows installation from unavailable native authority", async () => {
|
||||
const ghidra = inspection({
|
||||
const ghidra = await inspection({
|
||||
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
|
||||
platform: "win32",
|
||||
});
|
||||
@@ -237,7 +249,7 @@ describe("Windows Ghidra doctor authority", () => {
|
||||
});
|
||||
|
||||
it("fails provider-scoped Windows readiness without blaming installation", async () => {
|
||||
const ghidra = inspection({
|
||||
const ghidra = await inspection({
|
||||
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
|
||||
platform: "win32",
|
||||
});
|
||||
@@ -301,7 +313,9 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
const result = await runDoctor(
|
||||
undefined,
|
||||
{
|
||||
...doctorHost(inspection({ installDir: INSTALL, platform: "win32" })),
|
||||
...doctorHost(
|
||||
await inspection({ installDir: INSTALL, platform: "win32" }),
|
||||
),
|
||||
platform: "win32",
|
||||
linuxDistribution: () => Promise.resolve(undefined),
|
||||
},
|
||||
@@ -332,7 +346,7 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps one failure observation even when the next query would differ", () => {
|
||||
it("keeps one failure observation even when the next query would differ", async () => {
|
||||
const load = vi.mocked(nativeLoader.systemWindowsNativeAuthority);
|
||||
load.mockReturnValueOnce({
|
||||
available: false,
|
||||
@@ -340,7 +354,7 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
retryable: true,
|
||||
reason: "cleanup failed (145)",
|
||||
});
|
||||
const ghidra = inspection({ installDir: INSTALL, platform: "win32" });
|
||||
const ghidra = await inspection({ installDir: INSTALL, platform: "win32" });
|
||||
const failed = projectGhidraDoctorInspection(ghidra);
|
||||
expect(load).toHaveBeenCalledTimes(1);
|
||||
expect(failed.checks).toContainEqual(
|
||||
@@ -362,9 +376,9 @@ describe("Windows native doctor failure diagnostics", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps missing Windows Java actionable as a dependency failure", () => {
|
||||
it("keeps missing Windows Java actionable as a dependency failure", async () => {
|
||||
const projected = projectGhidraDoctorInspection(
|
||||
inspection(
|
||||
await inspection(
|
||||
{
|
||||
installDir: "C:\\tools\\ghidra_12.1.4_PUBLIC",
|
||||
platform: "win32",
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
||||
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
|
||||
import type {
|
||||
DoctorProviderCheck,
|
||||
DoctorProviderInspection,
|
||||
@@ -63,13 +65,17 @@ export const projectGhidraDoctorInspection = (
|
||||
};
|
||||
|
||||
/** Inspect the process-configured BYO Ghidra installation for `rea doctor`. */
|
||||
export const inspectSystemGhidraProvider = (
|
||||
export const inspectSystemGhidraProvider = async (
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
): Promise<DoctorProviderInspection> =>
|
||||
Promise.resolve(
|
||||
projectGhidraDoctorInspection(
|
||||
inspectGhidraInstallation({
|
||||
resourceOwner: FileCloseResourceOwner,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DoctorProviderInspection> => {
|
||||
try {
|
||||
return projectGhidraDoctorInspection(
|
||||
await inspectGhidraInstallation({
|
||||
environment,
|
||||
resourceOwner,
|
||||
...(signal === undefined ? {} : { signal }),
|
||||
...(environment.GHIDRA_INSTALL_DIR === undefined
|
||||
? {}
|
||||
: { installDir: environment.GHIDRA_INSTALL_DIR }),
|
||||
@@ -77,8 +83,11 @@ export const inspectSystemGhidraProvider = (
|
||||
? {}
|
||||
: { javaHome: environment.JAVA_HOME }),
|
||||
}),
|
||||
),
|
||||
);
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
throw analysisClientFailureForOperation(cause, "ghidra", "doctor");
|
||||
}
|
||||
};
|
||||
|
||||
const selectedChecks = (
|
||||
inspection: GhidraInstallationInspection,
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
||||
|
||||
import { missingJavaProbe } from "./GhidraInstallation.fixture.js";
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import {
|
||||
parseGhidraJavaOutput,
|
||||
type GhidraJavaObservation,
|
||||
} from "./GhidraJavaProbe.js";
|
||||
import { chmod, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { elf, fat, thinMach } from "../domain/binaryTarget.fixture.js";
|
||||
import { projectGhidraDoctorInspection } from "./GhidraDoctor.js";
|
||||
@@ -26,12 +30,10 @@ import {
|
||||
} from "./GhidraReleasePolicy.js";
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
|
||||
// The real Java probe shells out via spawnSync; stub it so one test can run
|
||||
// the real probeJava against captured `-XshowSettings:properties` output.
|
||||
// Every other test overrides probeJava, so the stub never fires for them.
|
||||
vi.mock("node:child_process", async (importOriginal) => {
|
||||
const original = await importOriginal<typeof import("node:child_process")>();
|
||||
return { ...original, spawnSync: vi.fn() };
|
||||
let inspectionResources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await inspectionResources.close();
|
||||
inspectionResources = createInspectionResources();
|
||||
});
|
||||
|
||||
const INSTALL = "/opt/ghidra";
|
||||
@@ -151,9 +153,10 @@ it.skipIf(process.platform === "win32")(
|
||||
},
|
||||
);
|
||||
|
||||
it("does not fall back to PATH when an explicit Ghidra installation path is malformed", () => {
|
||||
const inspection = inspectGhidraInstallation(
|
||||
it("does not fall back to PATH when an explicit Ghidra installation path is malformed", async () => {
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: { PATH: "/valid-ghidra-bin" },
|
||||
installDir: "relative/ghidra",
|
||||
platform: "linux",
|
||||
@@ -170,11 +173,12 @@ it("does not fall back to PATH when an explicit Ghidra installation path is malf
|
||||
});
|
||||
});
|
||||
|
||||
it("probes the selected Java environment without reading conflicting ambient settings", () => {
|
||||
it("probes the selected Java environment without reading conflicting ambient settings", async () => {
|
||||
vi.stubEnv("REA_GHIDRA_CONTEXT", "ambient");
|
||||
vi.stubEnv("REA_GHIDRA_AMBIENT", "ambient-only");
|
||||
const inspection = inspectGhidraInstallation(
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: { REA_GHIDRA_CONTEXT: "selected", PATH: "/selected-bin" },
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -216,10 +220,11 @@ describe("Ghidra installation inspection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts the verified Linux x64 Ghidra release and JDK", () => {
|
||||
it("accepts the verified Linux x64 Ghidra release and JDK", async () => {
|
||||
expect(
|
||||
inspectGhidraInstallation(
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -242,9 +247,10 @@ describe("Ghidra installation inspection", () => {
|
||||
[SUPPORTED_GHIDRA_VERSION, "25.0.1", 25],
|
||||
])(
|
||||
"accepts Ghidra %s with JDK %s on the verified release line",
|
||||
(providerVersion, javaVersion, major) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
async (providerVersion, javaVersion, major) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -281,12 +287,18 @@ describe("Ghidra installation inspection", () => {
|
||||
["linux", "arm64", "linux_arm_64"],
|
||||
] as const)(
|
||||
"accepts %s/%s with the matching distribution or locally built native tool",
|
||||
(platform, architecture, nativeDirectory) => {
|
||||
async (platform, architecture, nativeDirectory) => {
|
||||
for (const location of ["os", "build/os"]) {
|
||||
const decompiler = `${INSTALL}/Ghidra/Features/Decompiler/${location}/${nativeDirectory}/decompile`;
|
||||
expect(
|
||||
inspectGhidraInstallation(
|
||||
{ environment: {}, installDir: INSTALL, platform, architecture },
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform,
|
||||
architecture,
|
||||
},
|
||||
host({
|
||||
executable: (path) => path === HEADLESS || path === decompiler,
|
||||
probeJava: () =>
|
||||
@@ -309,7 +321,7 @@ describe("Ghidra installation inspection", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra Windows installation commitment", () => {
|
||||
it("accepts the exact Windows x64 batch launcher and JDK commitment", () => {
|
||||
it("accepts the exact Windows x64 batch launcher and JDK commitment", async () => {
|
||||
const windowsHost: GhidraInstallationHost = {
|
||||
platform: "win32",
|
||||
architecture: "x64",
|
||||
@@ -331,8 +343,9 @@ describe("Ghidra Windows installation commitment", () => {
|
||||
};
|
||||
|
||||
expect(
|
||||
inspectGhidraInstallation(
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: WINDOWS_INSTALL,
|
||||
javaHome: "C:\\Java\\jdk-21",
|
||||
@@ -475,9 +488,9 @@ describe("Ghidra installation rejection diagnostics", () => {
|
||||
failed: "java",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
])("distinguishes $name", ({ options, override, failed, code }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{ ...options, environment: {} },
|
||||
])("distinguishes $name", async ({ options, override, failed, code }) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{ resourceOwner: inspectionResources.owner, ...options, environment: {} },
|
||||
host(override),
|
||||
);
|
||||
expect(result.status).toBe("unavailable");
|
||||
@@ -551,9 +564,10 @@ describe("Ghidra configuration directory identity", () => {
|
||||
["application.version=12.1.4\napplication.java.min=21\n", null],
|
||||
])(
|
||||
"preserves the inspected identity independently of the installation basename",
|
||||
(content, expected) => {
|
||||
const inspection = inspectGhidraInstallation(
|
||||
async (content, expected) => {
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -687,9 +701,10 @@ describe("Ghidra release line and JDK bounds", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra malformed property admission", () => {
|
||||
it("fails installation and doctor admission closed on malformed Unicode escapes", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("fails installation and doctor admission closed on malformed Unicode escapes", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -775,9 +790,10 @@ describe("Ghidra installation version admission", () => {
|
||||
failed: "version",
|
||||
code: "unsupported_version",
|
||||
},
|
||||
])("distinguishes $name", ({ readText, probeJava, failed, code }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
])("distinguishes $name", async ({ readText, probeJava, failed, code }) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -791,9 +807,10 @@ describe("Ghidra installation version admission", () => {
|
||||
).toMatchObject({ name: failed, code });
|
||||
});
|
||||
|
||||
it("names the accepted Ghidra line and JDK range in failure remediation", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("names the accepted Ghidra line and JDK range in failure remediation", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
javaHome: "/usr/lib/jvm/java-17-openjdk",
|
||||
@@ -821,10 +838,11 @@ describe("Ghidra installation version admission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a lower Java minimum declared by the installation", () => {
|
||||
it("accepts a lower Java minimum declared by the installation", async () => {
|
||||
expect(
|
||||
inspectGhidraInstallation(
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -855,9 +873,15 @@ describe("Ghidra configuration paths", () => {
|
||||
],
|
||||
])(
|
||||
"rejects relative paths that REA's configuration parser rejects (%o)",
|
||||
(paths, detail) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
{ environment: {}, ...paths, platform: "linux", architecture: "x64" },
|
||||
async (paths, detail) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
...paths,
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
},
|
||||
host(),
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
@@ -884,10 +908,11 @@ describe("Ghidra configuration paths", () => {
|
||||
["win32", "jdk"],
|
||||
] as const)(
|
||||
"does not run Java from a relative JAVA_HOME (%s %o)",
|
||||
(platform, javaHome) => {
|
||||
async (platform, javaHome) => {
|
||||
const probed: string[] = [];
|
||||
const result = inspectGhidraInstallation(
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: platform === "win32" ? WINDOWS_INSTALL : INSTALL,
|
||||
javaHome,
|
||||
@@ -909,9 +934,10 @@ describe("Ghidra configuration paths", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("judges absolute paths by the inspected platform", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("judges absolute paths by the inspected platform", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: "C:\\ghidra",
|
||||
platform: "win32",
|
||||
@@ -945,9 +971,10 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
status: "available",
|
||||
detail: "could not be established",
|
||||
},
|
||||
])("distinguishes $name", ({ bytes, status, detail }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
])("distinguishes $name", async ({ bytes, status, detail }) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -969,11 +996,12 @@ describe("Ghidra FAT native decompiler admission", () => {
|
||||
).toContain(detail);
|
||||
});
|
||||
|
||||
it("keeps a truncated host slice unknown despite a readable foreign slice", () => {
|
||||
it("keeps a truncated host slice unknown despite a readable foreign slice", async () => {
|
||||
const bytes = fat([0x01000007, 0x0100000c]);
|
||||
bytes.writeUInt32BE(bytes.length + 1, 8 + 20 + 12);
|
||||
const result = inspectGhidraInstallation(
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1033,9 +1061,9 @@ describe("Ghidra native decompiler admission", () => {
|
||||
? headerProbe(thinMach(0xcffaedfe, 0x0100000c))
|
||||
: undefined,
|
||||
},
|
||||
])("distinguishes $name", ({ options, executableHeader }) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
options,
|
||||
])("distinguishes $name", async ({ options, executableHeader }) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{ ...options, resourceOwner: inspectionResources.owner },
|
||||
host({
|
||||
probeJava: () =>
|
||||
javaProbeOk<GhidraJavaObservation>({
|
||||
@@ -1052,9 +1080,10 @@ describe("Ghidra native decompiler admission", () => {
|
||||
).toMatchObject({ name: "native_decompiler", code: "executable_missing" });
|
||||
});
|
||||
|
||||
it("explains an accepted decompiler by its header, not only its path", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("explains an accepted decompiler by its header, not only its path", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -1077,9 +1106,10 @@ describe("Ghidra native decompiler admission", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra native decompiler compatibility", () => {
|
||||
it("keeps an unreadable header unknown instead of failing the install", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("keeps an unreadable header unknown instead of failing the install", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -1097,10 +1127,11 @@ describe("Ghidra native decompiler compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a one-slice universal-format Darwin native decompiler", () => {
|
||||
it("accepts a one-slice universal-format Darwin native decompiler", async () => {
|
||||
const bytes = fat([0x0100000c]);
|
||||
const result = inspectGhidraInstallation(
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1127,9 +1158,10 @@ describe("Ghidra native decompiler compatibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("names the observed and the required executable in the failure", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("names the observed and the required executable in the failure", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1161,9 +1193,10 @@ describe("Ghidra native decompiler compatibility", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra JVM native directory admission", () => {
|
||||
it("rejects a JDK whose os.arch selects a different native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("rejects a JDK whose os.arch selects a different native decompiler directory", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1192,9 +1225,10 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts a JDK whose os.arch selects the validated native decompiler directory", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("accepts a JDK whose os.arch selects the validated native decompiler directory", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1212,10 +1246,11 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
expect(result.status).toBe("available");
|
||||
});
|
||||
|
||||
it("rejects a successful Java probe that omits os.arch", () => {
|
||||
it("rejects a successful Java probe that omits os.arch", async () => {
|
||||
const { osArch: _observedArchitecture, ...withoutArchitecture } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1238,9 +1273,10 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
|
||||
it.each(["riscv64", "sparcv9", ""])(
|
||||
"refuses an unrecognized JVM os.arch (%s) instead of assuming alignment",
|
||||
(osArch) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
async (osArch) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1262,9 +1298,10 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects a JDK whose os.name selects a different host OS", () => {
|
||||
const result = inspectGhidraInstallation(
|
||||
it("rejects a JDK whose os.name selects a different host OS", async () => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1293,10 +1330,11 @@ describe("Ghidra JVM native directory admission", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra JVM OS admission", () => {
|
||||
it("rejects a successful Java probe that omits os.name", () => {
|
||||
it("rejects a successful Java probe that omits os.name", async () => {
|
||||
const { osName: _observedOsName, ...withoutOsName } = JAVA;
|
||||
const result = inspectGhidraInstallation(
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -1318,9 +1356,10 @@ describe("Ghidra JVM OS admission", () => {
|
||||
|
||||
it.each(["SunOS", "FreeBSD", ""])(
|
||||
"refuses an unrecognized JVM os.name (%s) instead of assuming alignment",
|
||||
(osName) => {
|
||||
const result = inspectGhidraInstallation(
|
||||
async (osName) => {
|
||||
const result = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "linux",
|
||||
@@ -1344,49 +1383,52 @@ describe("Ghidra JVM OS admission", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra Java probe os.arch extraction", () => {
|
||||
it("extracts os.arch through the real Java probe into the installation diagnostics", () => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
status: 0,
|
||||
stdout: Buffer.alloc(0),
|
||||
signal: null,
|
||||
stderr: Buffer.from(
|
||||
[
|
||||
"Property settings:",
|
||||
" file.encoding = UTF-8",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n"),
|
||||
),
|
||||
} as never);
|
||||
try {
|
||||
// No host override: the real system host runs the real probeJava
|
||||
// against the stubbed spawnSync.
|
||||
const inspection = inspectGhidraInstallation({
|
||||
it("extracts os.arch through the real Java probe into the installation diagnostics", async () => {
|
||||
const stderr = [
|
||||
"Property settings:",
|
||||
" java.home = /usr/lib/jvm/jdk-21",
|
||||
" java.version = 21.0.11",
|
||||
" java.vm.name = OpenJDK 64-Bit Server VM",
|
||||
" os.arch = x86_64",
|
||||
" os.name = Linux",
|
||||
" sun.arch.data.model = 64",
|
||||
"",
|
||||
].join("\n");
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
javaHome: "/usr/lib/jvm/jdk-21",
|
||||
platform: "linux",
|
||||
architecture: "x64",
|
||||
});
|
||||
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
|
||||
java_os_arch: "x86_64",
|
||||
java_os_name: "Linux",
|
||||
});
|
||||
} finally {
|
||||
vi.mocked(spawnSync).mockReset();
|
||||
}
|
||||
},
|
||||
host({
|
||||
probeJava: (command) =>
|
||||
parseGhidraJavaOutput(command, {
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
stdout: { text: "", bytes: 0, observedBytes: 0 },
|
||||
stderr: {
|
||||
text: stderr,
|
||||
bytes: Buffer.byteLength(stderr),
|
||||
observedBytes: Buffer.byteLength(stderr),
|
||||
},
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(ghidraInstallationDiagnostics(inspection)).toMatchObject({
|
||||
java_os_arch: "x86_64",
|
||||
java_os_name: "Linux",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("explains the macOS keg-only JDK case without probing an unselected installation", () => {
|
||||
it("explains the macOS keg-only JDK case without probing an unselected installation", async () => {
|
||||
const probed: string[] = [];
|
||||
const inspection = inspectGhidraInstallation(
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
@@ -1414,43 +1456,49 @@ it("explains the macOS keg-only JDK case without probing an unselected installat
|
||||
it.each([
|
||||
["arm64", "/opt/homebrew"],
|
||||
["x64", "/usr/local"],
|
||||
] as const)("discovers keg-only Java for macOS %s", (architecture, prefix) => {
|
||||
const probed: string[] = [];
|
||||
const checked: string[] = [];
|
||||
const inspection = inspectGhidraInstallation(
|
||||
{
|
||||
environment: { PATH: ":relative-bin:/empty-bin" },
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture,
|
||||
},
|
||||
host({
|
||||
executable: (candidate) => {
|
||||
checked.push(candidate);
|
||||
return (
|
||||
candidate.includes("/opt/openjdk/") || host().executable(candidate)
|
||||
);
|
||||
] as const)(
|
||||
"discovers keg-only Java for macOS %s",
|
||||
async (architecture, prefix) => {
|
||||
const probed: string[] = [];
|
||||
const checked: string[] = [];
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: { PATH: ":relative-bin:/empty-bin" },
|
||||
installDir: INSTALL,
|
||||
platform: "darwin",
|
||||
architecture,
|
||||
},
|
||||
probeJava: (command) => {
|
||||
probed.push(command);
|
||||
return javaProbeOk({
|
||||
...JAVA,
|
||||
home: `${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home`,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "x86_64",
|
||||
osName: "Mac OS X",
|
||||
});
|
||||
host({
|
||||
executable: (candidate) => {
|
||||
checked.push(candidate);
|
||||
return (
|
||||
candidate.includes("/opt/openjdk/") || host().executable(candidate)
|
||||
);
|
||||
},
|
||||
probeJava: (command) => {
|
||||
probed.push(command);
|
||||
return javaProbeOk({
|
||||
...JAVA,
|
||||
home: `${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home`,
|
||||
osArch: architecture === "arm64" ? "aarch64" : "x86_64",
|
||||
osName: "Mac OS X",
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(probed).toEqual([
|
||||
`${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java`,
|
||||
]);
|
||||
expect(checked).not.toContain("java");
|
||||
expect(checked).not.toContain("relative-bin/java");
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject(
|
||||
{
|
||||
status: "passed",
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(probed).toEqual([
|
||||
`${prefix}/opt/openjdk/libexec/openjdk.jdk/Contents/Home/bin/java`,
|
||||
]);
|
||||
expect(checked).not.toContain("java");
|
||||
expect(checked).not.toContain("relative-bin/java");
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
status: "passed",
|
||||
});
|
||||
});
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
["darwin", undefined, "/selected-bin/java", "java"],
|
||||
@@ -1459,11 +1507,12 @@ it.each([
|
||||
["win32", undefined, undefined, "java.exe"],
|
||||
] as const)(
|
||||
"preserves selected Java on %s with JAVA_HOME %s",
|
||||
(platform, javaHome, pathJava, expected) => {
|
||||
async (platform, javaHome, pathJava, expected) => {
|
||||
const checked: string[] = [];
|
||||
const probed: string[] = [];
|
||||
inspectGhidraInstallation(
|
||||
await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: { PATH: "/selected-bin" },
|
||||
installDir: INSTALL,
|
||||
platform,
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
|
||||
import {
|
||||
probeGhidraJava,
|
||||
type GhidraJavaObservation,
|
||||
type GhidraJavaProbeFailure,
|
||||
type GhidraJavaProbeOptions,
|
||||
} from "./GhidraJavaProbe.js";
|
||||
import type { Result } from "../domain/result.js";
|
||||
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
@@ -162,6 +166,8 @@ const assessNativeDecompiler = (
|
||||
/** Caller-owned paths and host coordinates used for one installation probe. */
|
||||
export interface GhidraInstallationOptions {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly resourceOwner: FileCloseResourceOwner;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly installDir?: string;
|
||||
readonly javaHome?: string;
|
||||
readonly platform?: NodeJS.Platform;
|
||||
@@ -257,7 +263,7 @@ export interface GhidraExecutableHeaderProbe {
|
||||
readonly size: number;
|
||||
}
|
||||
|
||||
/** Narrow synchronous seam used by provider discovery without launching Ghidra. */
|
||||
/** Host observations and owned Java prerequisite work; never launches Ghidra. */
|
||||
export interface GhidraInstallationHost {
|
||||
readonly platform?: NodeJS.Platform;
|
||||
readonly architecture?: NodeJS.Architecture;
|
||||
@@ -273,7 +279,12 @@ export interface GhidraInstallationHost {
|
||||
probeJava(
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Result<GhidraJavaObservation, GhidraJavaProbeFailure>;
|
||||
options: GhidraJavaProbeOptions,
|
||||
):
|
||||
| Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
||||
| Promise<
|
||||
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
||||
>;
|
||||
/** Resolve a PATH launcher to a validated installation without executing it. */
|
||||
discoverInstallDirOnPath?(
|
||||
environment: Readonly<NodeJS.ProcessEnv>,
|
||||
@@ -301,13 +312,17 @@ interface GhidraInstallationCheckContext {
|
||||
}
|
||||
|
||||
/** Inspect an installation without importing a target or modifying Ghidra. */
|
||||
export const inspectGhidraInstallation = (
|
||||
export const inspectGhidraInstallation = async (
|
||||
options: GhidraInstallationOptions,
|
||||
host: GhidraInstallationHost = systemGhidraInstallationHost(),
|
||||
): GhidraInstallationInspection => {
|
||||
): Promise<GhidraInstallationInspection> => {
|
||||
if (options.signal?.aborted)
|
||||
throw new AnalysisCancelledError("ghidra:installation", {
|
||||
cause: options.signal.reason,
|
||||
});
|
||||
const platform = options.platform ?? process.platform;
|
||||
const architecture = options.architecture ?? process.arch;
|
||||
const coordinates = installationCoordinates(
|
||||
const coordinates = await installationCoordinates(
|
||||
options,
|
||||
platform,
|
||||
architecture,
|
||||
@@ -359,6 +374,13 @@ export const inspectGhidraInstallation = (
|
||||
javaVersion: javaObservation?.version ?? null,
|
||||
rejection: { code: failed.code, reason: failed.remediation },
|
||||
};
|
||||
if (options.signal?.aborted)
|
||||
throw new AnalysisCancelledError("ghidra:installation", {
|
||||
cause: options.signal.reason,
|
||||
diagnostics: ghidraInstallationDiagnostics(
|
||||
availableInstallation(coordinates, observation),
|
||||
),
|
||||
});
|
||||
return availableInstallation(coordinates, observation);
|
||||
};
|
||||
|
||||
@@ -390,12 +412,12 @@ const availableInstallation = (
|
||||
};
|
||||
};
|
||||
|
||||
const installationCoordinates = (
|
||||
const installationCoordinates = async (
|
||||
options: GhidraInstallationOptions,
|
||||
platform: NodeJS.Platform,
|
||||
architecture: NodeJS.Architecture,
|
||||
host: GhidraInstallationHost,
|
||||
): GhidraInstallationCoordinates => {
|
||||
): Promise<GhidraInstallationCoordinates> => {
|
||||
const path = platform === "win32" ? win32 : posix;
|
||||
// An explicit setting remains authoritative even when malformed or stale;
|
||||
// automatic discovery is only a fallback when the caller supplied none.
|
||||
@@ -481,15 +503,25 @@ const installationCoordinates = (
|
||||
// Relative JAVA_HOME is refused by configuration validation without execution.
|
||||
const javaProbe =
|
||||
options.javaHome === undefined || path.isAbsolute(options.javaHome)
|
||||
? host.probeJava(
|
||||
? await host.probeJava(
|
||||
javaCommand,
|
||||
ghidraJavaEnvironment(
|
||||
options.javaHome,
|
||||
options.environment,
|
||||
platform,
|
||||
),
|
||||
{
|
||||
resourceOwner: options.resourceOwner,
|
||||
...(options.signal === undefined ? {} : { signal: options.signal }),
|
||||
},
|
||||
)
|
||||
: undefined;
|
||||
if (
|
||||
javaProbe !== undefined &&
|
||||
!javaProbe.ok &&
|
||||
javaProbe.error instanceof AnalysisError
|
||||
)
|
||||
throw javaProbe.error;
|
||||
return {
|
||||
installDir,
|
||||
applicationPropertiesPath,
|
||||
@@ -502,7 +534,11 @@ const installationCoordinates = (
|
||||
javaCommand,
|
||||
java: javaProbe?.ok === true ? javaProbe.value : undefined,
|
||||
javaProbeFailure:
|
||||
javaProbe !== undefined && !javaProbe.ok ? javaProbe.error : undefined,
|
||||
javaProbe !== undefined &&
|
||||
!javaProbe.ok &&
|
||||
!(javaProbe.error instanceof AnalysisError)
|
||||
? javaProbe.error
|
||||
: undefined,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -617,6 +653,9 @@ export const ghidraInstallationDiagnostics = (
|
||||
inspection.javaProbeFailure.processErrorCode,
|
||||
}),
|
||||
detail: inspection.javaProbeFailure.detail,
|
||||
...(inspection.javaProbeFailure.cancellation === undefined
|
||||
? {}
|
||||
: { cancellation_observed: true }),
|
||||
...(inspection.javaProbeFailure.properties === undefined
|
||||
? {}
|
||||
: { properties: { ...inspection.javaProbeFailure.properties } }),
|
||||
@@ -902,7 +941,7 @@ const javaRemediation = (
|
||||
: `Update JAVA_HOME to the root of ${range} installation.`;
|
||||
};
|
||||
|
||||
const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
|
||||
readText(path) {
|
||||
try {
|
||||
// Ghidra loads this file with Properties.load(InputStream), which maps
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeGhidraJava } from "./GhidraJavaProbe.js";
|
||||
|
||||
vi.mock("node:child_process", async (original) => ({
|
||||
...(await original<typeof import("node:child_process")>()),
|
||||
spawnSync: vi.fn(),
|
||||
}));
|
||||
afterEach(() => vi.mocked(spawnSync).mockReset());
|
||||
|
||||
const probe = (values: Partial<ReturnType<typeof spawnSync>>) => {
|
||||
vi.mocked(spawnSync).mockReturnValue({
|
||||
pid: 1,
|
||||
status: 0,
|
||||
signal: null,
|
||||
stdout: Buffer.alloc(0),
|
||||
stderr: Buffer.alloc(0),
|
||||
output: [],
|
||||
...values,
|
||||
} as never);
|
||||
return probeGhidraJava("/selected/bin/java", {});
|
||||
import { describe, expect, it, afterEach } from "vitest";
|
||||
import { probeGhidraJava, parseGhidraJavaOutput } from "./GhidraJavaProbe.js";
|
||||
import { createInspectionResources } from "../../tests/fixtures/inspectionResources.js";
|
||||
let resources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await resources.close();
|
||||
resources = createInspectionResources();
|
||||
});
|
||||
const probe = (values: {
|
||||
readonly stdout?: Buffer;
|
||||
readonly stderr?: Buffer;
|
||||
readonly status?: number | null;
|
||||
readonly signal?: NodeJS.Signals | null;
|
||||
}) => {
|
||||
const stdout = values.stdout ?? Buffer.alloc(0),
|
||||
stderr = values.stderr ?? Buffer.alloc(0);
|
||||
return parseGhidraJavaOutput("/selected/bin/java", {
|
||||
stdout: {
|
||||
text: stdout.toString("utf8"),
|
||||
bytes: stdout.length,
|
||||
observedBytes: stdout.length,
|
||||
},
|
||||
stderr: {
|
||||
text: stderr.toString("utf8"),
|
||||
bytes: stderr.length,
|
||||
observedBytes: stderr.length,
|
||||
},
|
||||
exitCode: values.status === undefined ? 0 : values.status,
|
||||
signal: values.signal ?? null,
|
||||
});
|
||||
};
|
||||
|
||||
describe("Ghidra Java prerequisite process facts", () => {
|
||||
@@ -27,19 +36,32 @@ describe("Ghidra Java prerequisite process facts", () => {
|
||||
["EACCES", "runtime_unavailable"],
|
||||
["ETIMEDOUT", "runtime_unavailable"],
|
||||
["ENOBUFS", "runtime_unavailable"],
|
||||
])("keeps %s distinct from other runtime failures", (code, expected) => {
|
||||
const error = Object.assign(new Error(`controlled ${code}`), { code });
|
||||
expect(probe({ error, status: null })).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: expected,
|
||||
processErrorCode: code,
|
||||
detail: expect.stringContaining(code),
|
||||
output: { exit_code: null, truncated: code === "ENOBUFS" },
|
||||
},
|
||||
});
|
||||
});
|
||||
])(
|
||||
"keeps %s distinct from other runtime failures",
|
||||
async (code, expected) => {
|
||||
const error = Object.assign(new Error(`controlled ${code}`), { code });
|
||||
const result = await probeGhidraJava(
|
||||
"/selected/bin/java",
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
launcher: async () => {
|
||||
throw error;
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: {
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: expected,
|
||||
processErrorCode: code,
|
||||
detail: expect.stringContaining(code),
|
||||
},
|
||||
});
|
||||
expect(result.ok ? null : result.error).not.toHaveProperty("output");
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps raw byte counts and both streams on a nonzero exit", () => {
|
||||
const result = probe({
|
||||
|
||||
+296
-46
@@ -1,4 +1,20 @@
|
||||
import { spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
|
||||
import { jsonValueSchema } from "../domain/jsonValue.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../process/DarwinProcessRunTokenReader.js";
|
||||
import {
|
||||
OwnedCommandFailure,
|
||||
runOwnedCommand,
|
||||
} from "../process/OwnedCommand.js";
|
||||
import type { ProviderProcessSnapshot } from "../process/ProviderProcess.js";
|
||||
import { boundedUtf8Text } from "../process/boundedUtf8Text.js";
|
||||
import { constants, accessSync, statSync } from "node:fs";
|
||||
import { posix, win32 } from "node:path";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
@@ -44,6 +60,8 @@ export interface GhidraJavaProbeFailure {
|
||||
| "runtime_unavailable"
|
||||
| "version_unresolved";
|
||||
readonly detail: string;
|
||||
/** Non-enumerable caller reason observed during finalization; the probe failure stays primary. */
|
||||
readonly cancellation?: { readonly reason: unknown };
|
||||
readonly processErrorCode?: string;
|
||||
readonly output?: AnalysisCapturedOutput;
|
||||
readonly properties?: {
|
||||
@@ -58,23 +76,28 @@ export interface GhidraJavaProbeFailure {
|
||||
|
||||
const JAVA_DIAGNOSTIC_STREAM_BYTES = 8192;
|
||||
const capturedProbeOutput = (
|
||||
observed: SpawnSyncReturns<Buffer>,
|
||||
observed: ProviderProcessSnapshot,
|
||||
): AnalysisCapturedOutput => {
|
||||
const stdout = observed.stdout ?? Buffer.alloc(0);
|
||||
const stderr = observed.stderr ?? Buffer.alloc(0);
|
||||
const stdout = boundedUtf8Text(
|
||||
observed.stdout.text,
|
||||
JAVA_DIAGNOSTIC_STREAM_BYTES,
|
||||
);
|
||||
const stderr = boundedUtf8Text(
|
||||
observed.stderr.text,
|
||||
JAVA_DIAGNOSTIC_STREAM_BYTES,
|
||||
);
|
||||
return {
|
||||
stdout: stdout.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
|
||||
stderr: stderr.subarray(0, JAVA_DIAGNOSTIC_STREAM_BYTES).toString("utf8"),
|
||||
stdout: stdout.text,
|
||||
stderr: stderr.text,
|
||||
truncated:
|
||||
stdout.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
|
||||
stderr.length > JAVA_DIAGNOSTIC_STREAM_BYTES ||
|
||||
(observed.error !== undefined &&
|
||||
"code" in observed.error &&
|
||||
observed.error.code === "ENOBUFS"),
|
||||
stdout_bytes: stdout.length,
|
||||
stderr_bytes: stderr.length,
|
||||
exit_code: observed.status,
|
||||
signal: observed.signal,
|
||||
stdout.truncated ||
|
||||
stderr.truncated ||
|
||||
observed.diagnosticTruncated === true ||
|
||||
observed.outputIncomplete === true,
|
||||
stdout_bytes: observed.stdout.observedBytes,
|
||||
stderr_bytes: observed.stderr.observedBytes,
|
||||
exit_code: observed.exitCode ?? null,
|
||||
signal: observed.signal ?? null,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -86,44 +109,271 @@ const executablePath = (path: string): boolean => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
export const probeGhidraJava = (
|
||||
export interface GhidraJavaProbeOptions {
|
||||
readonly resourceOwner: FileCloseResourceOwner;
|
||||
readonly signal?: AbortSignal;
|
||||
readonly launcher?: NonNullable<
|
||||
Parameters<typeof runOwnedCommand>[2]
|
||||
>["launcher"];
|
||||
/** Tests can shorten the production five-second deadline. */
|
||||
readonly timeoutMs?: number;
|
||||
}
|
||||
|
||||
/** Admit one complete probe into its caller's durable cleanup scope. */
|
||||
export const probeGhidraJava = async (
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
|
||||
const observed = spawnSync(
|
||||
command,
|
||||
[
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
// JDK 17/18 use the legacy names; JDK 19+ use the names above.
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
"-XX:-UsePerfData",
|
||||
"-XshowSettings:properties",
|
||||
"-version",
|
||||
],
|
||||
{
|
||||
encoding: "buffer",
|
||||
env: environment,
|
||||
timeout: 5_000,
|
||||
maxBuffer: JAVA_PROBE_MAX_OUTPUT_BYTES,
|
||||
windowsHide: true,
|
||||
options: GhidraJavaProbeOptions,
|
||||
): Promise<
|
||||
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
||||
> => {
|
||||
try {
|
||||
return await options.resourceOwner.run(() =>
|
||||
observeGhidraJava(command, environment, options),
|
||||
);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
analysisClientFailureForOperation(cause, "ghidra", "ghidra:java"),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Own a bounded prerequisite process, its interpretation, and exact cleanup retries. */
|
||||
const observeGhidraJava = async (
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
options: GhidraJavaProbeOptions,
|
||||
): Promise<
|
||||
Result<GhidraJavaObservation, GhidraJavaProbeFailure | AnalysisError>
|
||||
> => {
|
||||
let interpreted:
|
||||
| Result<GhidraJavaObservation, GhidraJavaProbeFailure>
|
||||
| undefined;
|
||||
try {
|
||||
await runOwnedCommand(
|
||||
{
|
||||
command,
|
||||
arguments: [
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
"-XX:-UsePerfData",
|
||||
"-XshowSettings:properties",
|
||||
"-version",
|
||||
],
|
||||
runId: randomUUID(),
|
||||
expectedCommand: null,
|
||||
hostEnvironment: environment,
|
||||
sweepTokenOwnedProcesses: true,
|
||||
},
|
||||
{
|
||||
timeoutMs: options.timeoutMs ?? 5000,
|
||||
diagnosticBytes: JAVA_PROBE_MAX_OUTPUT_BYTES,
|
||||
acceptNonZeroExit: true,
|
||||
observeCompleted: (snapshot) => {
|
||||
interpreted = parseGhidraJavaOutput(command, snapshot);
|
||||
},
|
||||
},
|
||||
{
|
||||
signal: options.signal,
|
||||
...(options.launcher === undefined
|
||||
? {}
|
||||
: { launcher: options.launcher }),
|
||||
},
|
||||
);
|
||||
if (interpreted === undefined)
|
||||
throw new Error("Java command completed without an output observation");
|
||||
return interpreted;
|
||||
} catch (cause: unknown) {
|
||||
const commandFailure =
|
||||
cause instanceof OwnedCommandFailure ? cause : undefined;
|
||||
const preparation =
|
||||
commandFailure?.preparationFailure ??
|
||||
(cause instanceof DarwinProcessOwnershipInspectionError
|
||||
? cause
|
||||
: undefined);
|
||||
const output =
|
||||
commandFailure?.snapshot === undefined || commandFailure.snapshot === null
|
||||
? preparation?.capturedOutput
|
||||
: capturedProbeOutput(commandFailure.snapshot);
|
||||
if (
|
||||
interpreted !== undefined &&
|
||||
!interpreted.ok &&
|
||||
commandFailure?.cancellation !== undefined
|
||||
)
|
||||
Object.defineProperty(interpreted.error, "cancellation", {
|
||||
value: commandFailure.cancellation,
|
||||
});
|
||||
let primary: AnalysisError | undefined;
|
||||
if (interpreted !== undefined && !interpreted.ok) {
|
||||
// A completed, invalid report remains the primary fact across cleanup-time cancellation.
|
||||
if (
|
||||
commandFailure?.cleanupOwner === undefined &&
|
||||
preparation?.cleanupFailure === undefined
|
||||
)
|
||||
return interpreted;
|
||||
primary = probeFailureError(interpreted.error, cause);
|
||||
} else if (
|
||||
commandFailure?.reason === "cancelled" &&
|
||||
commandFailure.cancellation !== undefined
|
||||
) {
|
||||
primary = new AnalysisCancelledError("ghidra:java", {
|
||||
cause: commandFailure.cancellation.reason,
|
||||
...(output === undefined ? {} : { capturedOutput: output }),
|
||||
...(interpreted?.ok === true
|
||||
? {
|
||||
diagnostics: {
|
||||
java_observation: jsonValueSchema.parse(interpreted.value),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
} else if (
|
||||
interpreted?.ok === true &&
|
||||
commandFailure?.cleanupOwner !== undefined
|
||||
) {
|
||||
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
|
||||
cause,
|
||||
...(output === undefined ? {} : { capturedOutput: output }),
|
||||
diagnostics: {
|
||||
java_observation: jsonValueSchema.parse(interpreted.value),
|
||||
stage: "cleanup",
|
||||
...(commandFailure.cancellation === undefined
|
||||
? {}
|
||||
: { cancellation_observed: true }),
|
||||
},
|
||||
});
|
||||
} else if (preparation !== undefined) {
|
||||
primary = new ProviderAdapterError("ghidra", "ghidra:java", {
|
||||
cause,
|
||||
...(output === undefined ? {} : { capturedOutput: output }),
|
||||
diagnostics: {
|
||||
stage: "ownership_preparation",
|
||||
...(preparation.compilerOutcome === undefined
|
||||
? {}
|
||||
: {
|
||||
compiler_outcome: jsonValueSchema.parse(
|
||||
preparation.compilerOutcome,
|
||||
),
|
||||
}),
|
||||
},
|
||||
});
|
||||
} else if (cause instanceof AnalysisError) {
|
||||
primary = cause;
|
||||
}
|
||||
const cleanups: AnalysisError[] = [];
|
||||
if (preparation?.cleanupFailure !== undefined) {
|
||||
const cleanup = preparation.cleanupFailure;
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
if (!cleanup.cleanupIncomplete) return;
|
||||
if (preparation.cleanupOwner === undefined) throw cleanup;
|
||||
await preparation.cleanupOwner.close();
|
||||
if (cleanup.cleanupIncomplete) throw cleanup;
|
||||
}, cleanup.cleanupResources);
|
||||
options.resourceOwner.retain(
|
||||
{ close: () => lease.release() },
|
||||
cleanup.cleanupResources[0] ??
|
||||
"Java prerequisite ownership preparation",
|
||||
);
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
if (commandFailure?.cleanupOwner !== undefined) {
|
||||
const supervisor = commandFailure.cleanupOwner;
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"ghidra",
|
||||
commandFailure.resources,
|
||||
{
|
||||
reason:
|
||||
commandFailure.cleanupFailure ??
|
||||
"Java prerequisite cleanup remains incomplete",
|
||||
...(commandFailure.cleanupResult === undefined
|
||||
? {}
|
||||
: {
|
||||
process_cleanup: jsonValueSchema.parse(
|
||||
commandFailure.cleanupResult,
|
||||
),
|
||||
}),
|
||||
},
|
||||
{ cause, operation: "ghidra:java" },
|
||||
);
|
||||
const lease = new AnalysisCleanupLease(async () => {
|
||||
const stopped = await supervisor.stop();
|
||||
if (stopped.status === "incomplete") throw cleanup;
|
||||
}, commandFailure.resources);
|
||||
cleanup.bindCleanupResourceOwner(lease);
|
||||
lease.bindCleanupError(cleanup);
|
||||
options.resourceOwner.retain(
|
||||
{ close: () => lease.release() },
|
||||
commandFailure.resources[0] ?? "Java prerequisite process",
|
||||
);
|
||||
cleanups.push(cleanup);
|
||||
}
|
||||
if (primary === undefined) {
|
||||
const code =
|
||||
commandFailure?.reason === "timeout"
|
||||
? "ETIMEDOUT"
|
||||
: commandFailure?.reason === "output-limit"
|
||||
? "ENOBUFS"
|
||||
: systemCode(commandFailure?.processError ?? cause);
|
||||
const failed: GhidraJavaProbeFailure = {
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
|
||||
detail: `${command}: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
...(code === undefined ? {} : { processErrorCode: code }),
|
||||
...(output === undefined ? {} : { output }),
|
||||
};
|
||||
if (commandFailure?.cancellation !== undefined)
|
||||
Object.defineProperty(failed, "cancellation", {
|
||||
value: commandFailure.cancellation,
|
||||
});
|
||||
if (cleanups.length === 0) return err(failed);
|
||||
primary = probeFailureError(failed, cause);
|
||||
}
|
||||
for (const cleanup of cleanups)
|
||||
primary = analysisErrorWithCleanupFailure(
|
||||
primary,
|
||||
cleanup,
|
||||
"ghidra:java",
|
||||
).bindCleanupDependencies([primary, cleanup]);
|
||||
return err(primary);
|
||||
}
|
||||
};
|
||||
|
||||
const systemCode = (cause: unknown): string | undefined =>
|
||||
cause instanceof Error && "code" in cause && typeof cause.code === "string"
|
||||
? cause.code
|
||||
: undefined;
|
||||
const probeFailureError = (
|
||||
failure: GhidraJavaProbeFailure,
|
||||
cause: unknown,
|
||||
): AnalysisError => {
|
||||
const { output, ...facts } = failure;
|
||||
return new ProviderAdapterError("ghidra", "ghidra:java", {
|
||||
cause,
|
||||
...(output === undefined ? {} : { capturedOutput: output }),
|
||||
diagnostics: {
|
||||
java_probe_failure: jsonValueSchema.parse(facts),
|
||||
...(failure.cancellation === undefined
|
||||
? {}
|
||||
: { cancellation_observed: true }),
|
||||
},
|
||||
);
|
||||
if (observed.error !== undefined || observed.status !== 0) {
|
||||
const code =
|
||||
observed.error !== undefined && "code" in observed.error
|
||||
? observed.error.code
|
||||
: undefined;
|
||||
});
|
||||
};
|
||||
|
||||
/** Parse only complete command output; callers retain interruption separately. */
|
||||
export const parseGhidraJavaOutput = (
|
||||
command: string,
|
||||
observed: ProviderProcessSnapshot,
|
||||
): Result<GhidraJavaObservation, GhidraJavaProbeFailure> => {
|
||||
if (observed.exitCode !== 0 || observed.signal !== null)
|
||||
return err({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: code === "ENOENT" ? "runtime_missing" : "runtime_unavailable",
|
||||
...(typeof code === "string" ? { processErrorCode: code } : {}),
|
||||
detail: `${command}: ${observed.error?.message ?? `Java exited ${String(observed.status)}${observed.signal === null ? "" : ` (${observed.signal})`}`}`,
|
||||
code: "runtime_unavailable",
|
||||
detail: `${command}: Java exited ${String(observed.exitCode)}${observed.signal == null ? "" : ` (${observed.signal})`}`,
|
||||
output: capturedProbeOutput(observed),
|
||||
});
|
||||
}
|
||||
const output = `${observed.stdout ?? ""}\n${observed.stderr ?? ""}`;
|
||||
const output = `${observed.stdout.text}\n${observed.stderr.text}`;
|
||||
const version = javaProperty(output, "java.version") || null;
|
||||
const home = javaProperty(output, "java.home") || null;
|
||||
const vmName = javaProperty(output, "java.vm.name");
|
||||
|
||||
@@ -51,7 +51,9 @@ const target = (change: Partial<PowerPcElfMetadata> = {}): BinaryTarget => ({
|
||||
describe("bounded Ghidra PowerPC admission", () => {
|
||||
it("binds the generic language instead of the loader's e500 choice", async () => {
|
||||
const ghidra = new GhidraProvider(config, silentLogger, {}, host);
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
expect(ghidra.inspectTargetSupport(target()).status).toBe("supported");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target());
|
||||
if (!resolved.ok) throw resolved.error;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import { ok as javaProbeOk } from "../domain/result.js";
|
||||
import type { GhidraJavaObservation } from "./GhidraJavaProbe.js";
|
||||
import type { GhidraSeedReport } from "./GhidraAnalysisSeeds.js";
|
||||
@@ -89,6 +90,69 @@ const createElfClient = async (ghidra: GhidraProvider) => {
|
||||
return ghidra.createClient(target, resolved.value.profile);
|
||||
};
|
||||
|
||||
it.each(["readiness", "client"] as const)(
|
||||
"keeps completed Java prerequisite failure primary across late cancellation in %s",
|
||||
async (consumer) => {
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "late Java cancellation" };
|
||||
let armed = false;
|
||||
let nativeCreates = 0;
|
||||
const base = installationHost();
|
||||
const ghidra = provider(
|
||||
{
|
||||
...base,
|
||||
probeJava: (command, environment, options) => {
|
||||
if (!armed) return base.probeJava(command, environment, options);
|
||||
controller.abort(reason);
|
||||
return err({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: "version_unresolved",
|
||||
detail: "completed invalid Java report",
|
||||
});
|
||||
},
|
||||
},
|
||||
() => {
|
||||
nativeCreates++;
|
||||
throw new Error("A failed prerequisite cannot create a native client");
|
||||
},
|
||||
);
|
||||
const client =
|
||||
consumer === "client" ? await createElfClient(ghidra) : undefined;
|
||||
armed = true;
|
||||
try {
|
||||
if (client === undefined) {
|
||||
const availability = await ghidra.inspectAvailability({
|
||||
signal: controller.signal,
|
||||
});
|
||||
expect(availability).toMatchObject({
|
||||
status: "unavailable",
|
||||
diagnostics: { java_probe_failure: { code: "version_unresolved" } },
|
||||
});
|
||||
} else {
|
||||
const result = await client.execute(
|
||||
"analyze_function",
|
||||
{},
|
||||
{ signal: controller.signal },
|
||||
);
|
||||
if (result.ok)
|
||||
throw new Error("Expected physical prerequisite failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "ProviderAdapterError",
|
||||
operation: "analyze_function",
|
||||
diagnostics: { java_probe_failure: { code: "version_unresolved" } },
|
||||
});
|
||||
expect(projectAnalysisError(result.error).code).toBe(
|
||||
"execution_failure",
|
||||
);
|
||||
}
|
||||
expect(nativeCreates).toBe(0);
|
||||
} finally {
|
||||
await client?.close();
|
||||
await ghidra.closeOwnedResources();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
"program-info-v1",
|
||||
"program-info-v2",
|
||||
@@ -170,6 +234,8 @@ it("carries the inspected release identity into the production launcher", async
|
||||
},
|
||||
);
|
||||
const client = await createElfClient(ghidra);
|
||||
expect(launchers).toHaveLength(0);
|
||||
expect((await client.execute("health", {})).ok).toBe(true);
|
||||
expect(launchers[0]?.options).toMatchObject({
|
||||
configurationDirectoryName: "ghidra_12.1.4_PUBLIC",
|
||||
});
|
||||
@@ -205,7 +271,7 @@ describe("Ghidra provider", () => {
|
||||
const windows = provider(installationHost("win32"));
|
||||
expect(windows.inspectTargetSupport(target).status).toBe("unsupported");
|
||||
});
|
||||
it("discovers the exact installation once without launching Ghidra", () => {
|
||||
it("re-observes the exact installation for each fresh task without launching Ghidra", async () => {
|
||||
let probeCount = 0;
|
||||
const host: GhidraInstallationHost = {
|
||||
...installationHost(),
|
||||
@@ -244,7 +310,7 @@ describe("Ghidra provider", () => {
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
diagnostics: {
|
||||
install_dir: INSTALL,
|
||||
@@ -252,8 +318,10 @@ describe("Ghidra provider", () => {
|
||||
java_version: "21.0.11",
|
||||
},
|
||||
});
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({ status: "available" });
|
||||
expect(probeCount).toBe(1);
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
});
|
||||
expect(probeCount).toBe(2);
|
||||
});
|
||||
|
||||
it("separates target kind, format, and concrete architecture", () => {
|
||||
@@ -566,7 +634,7 @@ describe("Ghidra platform support", () => {
|
||||
});
|
||||
|
||||
describe("Ghidra Windows platform support", () => {
|
||||
it("keeps Windows P0 unavailable until native isolation authority exists", () => {
|
||||
it("keeps Windows P0 unavailable until native isolation authority exists", async () => {
|
||||
const native = vi
|
||||
.spyOn(nativeLoader, "systemWindowsNativeAuthority")
|
||||
.mockReturnValue({
|
||||
@@ -589,7 +657,7 @@ describe("Ghidra Windows platform support", () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "unavailable",
|
||||
code: "runtime_missing",
|
||||
reason: expect.stringContaining(
|
||||
@@ -694,7 +762,7 @@ describe("Windows Ghidra native failure diagnostics", () => {
|
||||
managed: false,
|
||||
},
|
||||
});
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "unavailable",
|
||||
code,
|
||||
reason: expect.stringContaining(
|
||||
@@ -2342,7 +2410,7 @@ describe("compatible Ghidra builds", () => {
|
||||
);
|
||||
const health = await client.execute("health", {});
|
||||
const procedures = await client.execute("list_procedures", {});
|
||||
expect(ghidra.inspectAvailability()).toMatchObject({
|
||||
expect(await ghidra.inspectAvailability()).toMatchObject({
|
||||
status: "available",
|
||||
diagnostics: { provider_version: "12.1.2", java_version: "27" },
|
||||
});
|
||||
@@ -2708,6 +2776,8 @@ describe("Ghidra disabled analyzer options", () => {
|
||||
analysis_options: { disabled_analyzers: ["Decompiler Switch Analysis"] },
|
||||
});
|
||||
const client = ghidra.createClient(target, resolved.value.profile);
|
||||
expect(launchers).toHaveLength(0);
|
||||
expect((await client.execute("health", {})).ok).toBe(true);
|
||||
expect(launchers).toHaveLength(1);
|
||||
expect(launchers[0]?.options).toMatchObject({
|
||||
disabledAnalyzers: ["Decompiler Switch Analysis"],
|
||||
@@ -3060,3 +3130,110 @@ describe("Ghidra OOME error projection", () => {
|
||||
expect(projected.code).not.toBe("resource_constraint");
|
||||
});
|
||||
});
|
||||
|
||||
it("reuses readiness only inside its active task and provider lifetime", async () => {
|
||||
let probes = 0;
|
||||
let version = "21.0.11";
|
||||
const base = installationHost();
|
||||
const ghidra = provider({
|
||||
...base,
|
||||
probeJava: async (command, environment, options) => {
|
||||
probes++;
|
||||
const observed = await base.probeJava(command, environment, options);
|
||||
return observed.ok
|
||||
? javaProbeOk({ ...observed.value, version })
|
||||
: observed;
|
||||
},
|
||||
});
|
||||
const scope = new ProviderInspectionScope();
|
||||
try {
|
||||
expect((await ghidra.inspectAvailability({ scope })).status).toBe(
|
||||
"available",
|
||||
);
|
||||
expect(
|
||||
(
|
||||
await ghidra.resolveAnalysisProfile(executableTarget("elf", "x86_64"), {
|
||||
scope,
|
||||
})
|
||||
).ok,
|
||||
).toBe(true);
|
||||
expect(probes).toBe(1);
|
||||
version = "21.0.12";
|
||||
expect(await ghidra.closeOwnedResources()).toMatchObject({ ok: true });
|
||||
expect(
|
||||
(await ghidra.inspectAvailability({ scope })).diagnostics.java_version,
|
||||
).toBe(version);
|
||||
expect(probes).toBe(2);
|
||||
scope.close();
|
||||
version = "21.0.13";
|
||||
expect(
|
||||
(await ghidra.inspectAvailability({ scope })).diagnostics.java_version,
|
||||
).toBe(version);
|
||||
expect(probes).toBe(3);
|
||||
} finally {
|
||||
scope.close();
|
||||
await ghidra.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
|
||||
it("refuses a changed installation before creating a target-bound native client", async () => {
|
||||
let release = "12.1.4",
|
||||
nativeCreates = 0;
|
||||
const ghidra = provider(
|
||||
{
|
||||
...installationHost(),
|
||||
readText: () =>
|
||||
`application.version=${release}\napplication.java.min=21\n`,
|
||||
},
|
||||
() => {
|
||||
nativeCreates++;
|
||||
throw new Error("Changed installation must not create a native client");
|
||||
},
|
||||
);
|
||||
const target = executableTarget("elf", "x86_64");
|
||||
const resolved = await ghidra.resolveAnalysisProfile(target);
|
||||
if (!resolved.ok || resolved.value.profile === null)
|
||||
throw new Error("Expected initial profile");
|
||||
const client = ghidra.createClient(target, resolved.value.profile);
|
||||
release = "12.1.5";
|
||||
try {
|
||||
const result = await client.execute("analyze_function", {});
|
||||
if (result.ok) throw new Error("Changed installation was admitted");
|
||||
expect(result.error).toMatchObject({
|
||||
operation: "analyze_function",
|
||||
diagnostics: {
|
||||
selected_provider_version: "12.1.4",
|
||||
current_installation: { provider_version: "12.1.5" },
|
||||
},
|
||||
});
|
||||
expect(client.reuseReadiness?.()).toBe("blocked");
|
||||
expect(nativeCreates).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await ghidra.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects an absent profile before probing Java and attributes it to the requested operation", async () => {
|
||||
let probes = 0;
|
||||
const base = installationHost();
|
||||
const ghidra = provider({
|
||||
...base,
|
||||
probeJava: (...args) => {
|
||||
probes++;
|
||||
return base.probeJava(...args);
|
||||
},
|
||||
});
|
||||
const client = ghidra.createClient(executableTarget("elf", "x86_64"));
|
||||
try {
|
||||
const result = await client.execute("analyze_function", {});
|
||||
expect(result).toMatchObject({
|
||||
ok: false,
|
||||
error: { _tag: "ProviderAdapterError", operation: "analyze_function" },
|
||||
});
|
||||
expect(probes).toBe(0);
|
||||
} finally {
|
||||
await client.close();
|
||||
await ghidra.closeOwnedResources();
|
||||
}
|
||||
});
|
||||
|
||||
+239
-39
@@ -1,3 +1,11 @@
|
||||
import { DeferredAnalysisClient } from "../providers/DeferredAnalysisClient.js";
|
||||
import type { ProviderInspectionScope } from "../providers/ProviderInspectionScope.js";
|
||||
import { analysisClientFailureForOperation } from "../providers/AnalysisClientFailure.js";
|
||||
import { AnalysisCleanupLease } from "../artifacts/AnalysisCleanupLease.js";
|
||||
import {
|
||||
SharedStartupAttempt,
|
||||
STARTUP_OWNER_CLOSED,
|
||||
} from "../process/SharedStartupAttempt.js";
|
||||
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
|
||||
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
|
||||
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
|
||||
@@ -5,6 +13,8 @@ import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
|
||||
import type {
|
||||
AnalysisClient,
|
||||
AnalysisClientContext,
|
||||
AnalysisProviderInspectionOptions,
|
||||
FileCloseResourceOwner,
|
||||
AnalysisProfileResolutionOptions,
|
||||
AnalysisProfileResolution,
|
||||
AnalysisProviderCandidate,
|
||||
@@ -29,7 +39,7 @@ import {
|
||||
type JsonValue,
|
||||
} from "../domain/jsonValue.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import type { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { AnalysisError } from "../domain/analysisErrorBase.js";
|
||||
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
|
||||
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
|
||||
import type { Logger } from "pino";
|
||||
@@ -61,6 +71,7 @@ import {
|
||||
} from "./GhidraProviderCapabilities.js";
|
||||
import {
|
||||
createGhidraProviderClient,
|
||||
admitGhidraClient,
|
||||
type GhidraProviderClientFactory,
|
||||
} from "./GhidraProviderClient.js";
|
||||
import {
|
||||
@@ -84,11 +95,28 @@ const SUPPORTED_ARCHITECTURES = new Set([
|
||||
/** Ghidra candidate backed by an isolated ephemeral headless import. */
|
||||
export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
readonly #inspectionResources = new ArtifactResourceScope();
|
||||
readonly #profileWork = new Set<
|
||||
Promise<Result<AnalysisProfileResolution, AnalysisError>>
|
||||
readonly #inspectionWork = new Set<Promise<unknown>>();
|
||||
#lifetime = new AbortController();
|
||||
readonly #observations = new WeakMap<
|
||||
ProviderInspectionScope,
|
||||
{
|
||||
readonly lifetime: AbortSignal;
|
||||
readonly installation: GhidraInstallationInspection;
|
||||
}
|
||||
>();
|
||||
#inspectionAttempt:
|
||||
| SharedStartupAttempt<Result<GhidraInstallationInspection, AnalysisError>>
|
||||
| undefined;
|
||||
readonly #inspectionOwner: FileCloseResourceOwner = {
|
||||
retain: (owner, resource) =>
|
||||
this.#inspectionResources.retain({
|
||||
kind: "lease",
|
||||
resource,
|
||||
lease: new AnalysisCleanupLease(() => owner.close(), [resource]),
|
||||
}),
|
||||
run: (operation) => this.#inspectionResources.run(operation),
|
||||
};
|
||||
#closing: Promise<Result<null, AnalysisError>> | undefined;
|
||||
#installation: GhidraInstallationInspection | undefined;
|
||||
private readonly environment: NodeJS.ProcessEnv;
|
||||
|
||||
constructor(
|
||||
@@ -115,18 +143,42 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
: CAPABILITIES;
|
||||
}
|
||||
|
||||
inspectAvailability(): ProviderAvailability {
|
||||
return this.inspectReadinessSnapshot().availability;
|
||||
async inspectAvailability(
|
||||
options: AnalysisProviderInspectionOptions = {},
|
||||
): Promise<ProviderAvailability> {
|
||||
return (await this.inspectReadinessSnapshot(options)).availability;
|
||||
}
|
||||
|
||||
inspectReadinessSnapshot(): AnalysisProviderReadinessSnapshot {
|
||||
const installation = this.#inspectInstallation();
|
||||
inspectReadinessSnapshot(
|
||||
options: AnalysisProviderInspectionOptions = {},
|
||||
): Promise<AnalysisProviderReadinessSnapshot> {
|
||||
return this.#runInspectionWork(
|
||||
(signal) => this.#readinessSnapshot(signal, options.scope),
|
||||
options.signal,
|
||||
);
|
||||
}
|
||||
|
||||
async #readinessSnapshot(
|
||||
signal: AbortSignal,
|
||||
scope?: ProviderInspectionScope,
|
||||
): Promise<AnalysisProviderReadinessSnapshot> {
|
||||
const installation = await this.#inspectInstallation(
|
||||
signal,
|
||||
"binary_session",
|
||||
scope,
|
||||
);
|
||||
const readiness =
|
||||
installation.platform === "win32"
|
||||
? inspectWindowsNativeReadiness(installation.platform)
|
||||
: undefined;
|
||||
const availability = this.#availability(installation, readiness);
|
||||
if (availability.status === "available" && signal.aborted)
|
||||
throw new AnalysisCancelledError("binary_session", {
|
||||
cause: signal.reason,
|
||||
diagnostics: availability.diagnostics,
|
||||
});
|
||||
return {
|
||||
availability: this.#availability(installation, readiness),
|
||||
availability,
|
||||
capabilities:
|
||||
readiness === undefined
|
||||
? CAPABILITIES
|
||||
@@ -234,16 +286,28 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
}
|
||||
|
||||
hasPendingOwnedResources(): boolean {
|
||||
return this.#profileWork.size > 0 || this.#inspectionResources.hasPending;
|
||||
return (
|
||||
this.#inspectionWork.size > 0 ||
|
||||
this.#inspectionAttempt !== undefined ||
|
||||
this.#inspectionResources.hasPending
|
||||
);
|
||||
}
|
||||
|
||||
closeOwnedResources() {
|
||||
this.#closing ??= Promise.resolve()
|
||||
.then(async () => {
|
||||
await Promise.allSettled(this.#profileWork);
|
||||
this.#lifetime.abort(STARTUP_OWNER_CLOSED);
|
||||
this.#inspectionAttempt?.close();
|
||||
await Promise.allSettled([
|
||||
...this.#inspectionWork,
|
||||
...(this.#inspectionAttempt === undefined
|
||||
? []
|
||||
: [this.#inspectionAttempt.promise]),
|
||||
]);
|
||||
return this.#closeInspectionResources();
|
||||
})
|
||||
.finally(() => {
|
||||
this.#lifetime = new AbortController();
|
||||
this.#closing = undefined;
|
||||
});
|
||||
return this.#closing;
|
||||
@@ -271,18 +335,33 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
): Promise<Result<AnalysisProfileResolution, AnalysisError>> {
|
||||
if (this.#closing !== undefined) {
|
||||
const closed = await this.#closing;
|
||||
if (!closed.ok) return closed;
|
||||
}
|
||||
const work = Promise.resolve().then(() =>
|
||||
this.#resolveProfile(target, options),
|
||||
);
|
||||
this.#profileWork.add(work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
this.#profileWork.delete(work);
|
||||
return await this.#runInspectionWork(async (signal) => {
|
||||
const result = await this.#resolveProfile(target, {
|
||||
...options,
|
||||
signal,
|
||||
});
|
||||
if (result.ok && signal.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("open_binary", {
|
||||
cause: signal.reason,
|
||||
diagnostics: {
|
||||
completed_profile_digest: result.value.profile?.digest ?? null,
|
||||
},
|
||||
}),
|
||||
);
|
||||
return result;
|
||||
}, options?.signal);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
analysisClientFailureForOperation(
|
||||
cause,
|
||||
"ghidra",
|
||||
cause instanceof AnalysisCancelledError
|
||||
? "open_binary"
|
||||
: "resolve_analysis_profile",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -290,7 +369,11 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
target: BinaryTarget,
|
||||
options?: AnalysisProfileResolutionOptions,
|
||||
) {
|
||||
const installation = this.#inspectInstallation();
|
||||
const installation = await this.#inspectInstallation(
|
||||
options?.signal,
|
||||
"resolve_analysis_profile",
|
||||
options?.scope,
|
||||
);
|
||||
const resolved = await resolveGhidraAnalysisProfile(
|
||||
target,
|
||||
GHIDRA_PROVIDER_IDENTITY,
|
||||
@@ -381,20 +464,139 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
profile?: AnalysisProfileCommitment,
|
||||
context?: AnalysisClientContext,
|
||||
): AnalysisClient {
|
||||
return createGhidraProviderClient({
|
||||
config: this.config,
|
||||
environment: this.environment,
|
||||
logger: this.logger,
|
||||
clientFactory: this.clientFactory,
|
||||
target,
|
||||
...(profile === undefined ? {} : { profile }),
|
||||
...(context === undefined ? {} : { context }),
|
||||
installation: this.#inspectInstallation(),
|
||||
});
|
||||
return new DeferredAnalysisClient(async (signal, resourceOwner) => {
|
||||
const admitted = admitGhidraClient(
|
||||
target,
|
||||
profile,
|
||||
this.installationHost?.platform ?? process.platform,
|
||||
);
|
||||
if (!admitted.ok) throw admitted.error;
|
||||
const installation = await this.#observeInstallation(
|
||||
signal,
|
||||
resourceOwner,
|
||||
);
|
||||
const created = createGhidraProviderClient({
|
||||
config: this.config,
|
||||
environment: this.environment,
|
||||
logger: this.logger,
|
||||
clientFactory: this.clientFactory,
|
||||
admission: admitted.value,
|
||||
...(context === undefined ? {} : { context }),
|
||||
installation,
|
||||
signal,
|
||||
});
|
||||
if (!created.ok) throw created.error;
|
||||
return created.value;
|
||||
}, "ghidra");
|
||||
}
|
||||
|
||||
#inspectInstallation(): GhidraInstallationInspection {
|
||||
async #runInspectionWork<T>(
|
||||
operation: (signal: AbortSignal) => Promise<T>,
|
||||
callerSignal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
if (this.#closing !== undefined) {
|
||||
const closed = await this.#closing;
|
||||
if (!closed.ok) throw closed.error;
|
||||
}
|
||||
const signal =
|
||||
callerSignal === undefined
|
||||
? this.#lifetime.signal
|
||||
: AbortSignal.any([callerSignal, this.#lifetime.signal]);
|
||||
if (signal.aborted)
|
||||
throw new AnalysisCancelledError("ghidra:inspection", {
|
||||
cause: signal.reason,
|
||||
});
|
||||
const work = Promise.resolve().then(() => operation(signal));
|
||||
this.#inspectionWork.add(work);
|
||||
try {
|
||||
return await work;
|
||||
} finally {
|
||||
this.#inspectionWork.delete(work);
|
||||
}
|
||||
}
|
||||
|
||||
async #inspectInstallation(
|
||||
callerSignal: AbortSignal | undefined,
|
||||
operation: string,
|
||||
scope?: ProviderInspectionScope,
|
||||
): Promise<GhidraInstallationInspection> {
|
||||
if (callerSignal?.aborted)
|
||||
throw new AnalysisCancelledError(operation, {
|
||||
cause: callerSignal.reason,
|
||||
});
|
||||
if (scope?.active) {
|
||||
const observed = this.#observations.get(scope);
|
||||
if (observed?.lifetime === this.#lifetime.signal)
|
||||
return observed.installation;
|
||||
}
|
||||
let attempt = this.#inspectionAttempt;
|
||||
if (attempt === undefined) {
|
||||
attempt = new SharedStartupAttempt(
|
||||
(signal) =>
|
||||
Promise.resolve().then(async () => {
|
||||
try {
|
||||
return ok(await this.#observeInstallation(signal));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
analysisClientFailureForOperation(
|
||||
cause,
|
||||
"ghidra",
|
||||
"ghidra:inspection",
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
this.#lifetime.signal,
|
||||
);
|
||||
const owned = attempt;
|
||||
this.#inspectionAttempt = owned;
|
||||
void owned.promise.then(
|
||||
() => {
|
||||
if (this.#inspectionAttempt === owned)
|
||||
this.#inspectionAttempt = undefined;
|
||||
},
|
||||
() => {
|
||||
if (this.#inspectionAttempt === owned)
|
||||
this.#inspectionAttempt = undefined;
|
||||
},
|
||||
);
|
||||
}
|
||||
const waited = await attempt.wait(callerSignal);
|
||||
if (waited.status === "cancelled")
|
||||
throw new AnalysisCancelledError(operation, {
|
||||
cause: callerSignal?.reason,
|
||||
});
|
||||
if (!waited.value.ok)
|
||||
throw analysisClientFailureForOperation(
|
||||
waited.value.error,
|
||||
"ghidra",
|
||||
operation,
|
||||
);
|
||||
if (
|
||||
waited.value.value.status === "available" &&
|
||||
(callerSignal?.aborted || attempt.signal.aborted)
|
||||
)
|
||||
throw new AnalysisCancelledError(operation, {
|
||||
cause: callerSignal?.aborted
|
||||
? callerSignal.reason
|
||||
: attempt.signal.reason,
|
||||
diagnostics: ghidraInstallationDiagnostics(waited.value.value),
|
||||
});
|
||||
if (scope?.active && !callerSignal?.aborted && !attempt.signal.aborted)
|
||||
this.#observations.set(scope, {
|
||||
lifetime: this.#lifetime.signal,
|
||||
installation: waited.value.value,
|
||||
});
|
||||
return waited.value.value;
|
||||
}
|
||||
|
||||
#observeInstallation(
|
||||
signal: AbortSignal,
|
||||
resourceOwner: FileCloseResourceOwner = this.#inspectionOwner,
|
||||
): Promise<GhidraInstallationInspection> {
|
||||
const options = {
|
||||
resourceOwner,
|
||||
signal,
|
||||
environment: this.environment,
|
||||
...(this.config.ghidraInstallDir === undefined
|
||||
? {}
|
||||
@@ -409,11 +611,9 @@ export class GhidraProvider implements AnalysisProviderCandidate {
|
||||
? {}
|
||||
: { architecture: this.installationHost.architecture }),
|
||||
};
|
||||
this.#installation ??=
|
||||
this.installationHost === undefined
|
||||
? inspectGhidraInstallation(options)
|
||||
: inspectGhidraInstallation(options, this.installationHost);
|
||||
return this.#installation;
|
||||
return this.installationHost === undefined
|
||||
? inspectGhidraInstallation(options)
|
||||
: inspectGhidraInstallation(options, this.installationHost);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -373,19 +373,15 @@ export const createGhidraProviderClient = (input: {
|
||||
readonly environment: Readonly<NodeJS.ProcessEnv>;
|
||||
readonly logger: Logger;
|
||||
readonly clientFactory: GhidraProviderClientFactory;
|
||||
readonly target: BinaryTarget;
|
||||
readonly profile?: AnalysisProfileCommitment;
|
||||
readonly admission: GhidraClientAdmission;
|
||||
readonly context?: AnalysisClientContext;
|
||||
readonly installation: GhidraInstallationInspection;
|
||||
}): AnalysisClient => {
|
||||
const { config, logger, clientFactory, target, context, installation } =
|
||||
input;
|
||||
const prerequisites = ghidraClientPrerequisites(
|
||||
target,
|
||||
input.profile,
|
||||
installation,
|
||||
);
|
||||
if (!prerequisites.ok) return unavailableClient(prerequisites.error);
|
||||
readonly signal: AbortSignal;
|
||||
}): Result<AnalysisClient, AnalysisError> => {
|
||||
const { config, logger, clientFactory, context, installation } = input;
|
||||
const { target, profile } = input.admission;
|
||||
const prerequisites = ghidraClientPrerequisites(profile, installation);
|
||||
if (!prerequisites.ok) return err(prerequisites.error);
|
||||
const committedProfile = prerequisites.value.profile;
|
||||
const psp = isGhidraPspTarget(target);
|
||||
const pspExtension = psp
|
||||
@@ -399,7 +395,7 @@ export const createGhidraProviderClient = (input: {
|
||||
committedProfile.parameters.language_id !== "Allegrex:LE:32:default" ||
|
||||
committedProfile.parameters.loader !== "PspElfLoader")
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -413,7 +409,7 @@ export const createGhidraProviderClient = (input: {
|
||||
(committedProfile.parameters.mips_soft_float_storage ===
|
||||
GHIDRA_MIPS_SOFT_FLOAT_STORAGE)
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -429,7 +425,7 @@ export const createGhidraProviderClient = (input: {
|
||||
(config.ghidraNativeAotJar !== undefined &&
|
||||
extensionProfile.data.length === 0)
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -445,7 +441,7 @@ export const createGhidraProviderClient = (input: {
|
||||
!seedProfile.success ||
|
||||
(config.ghidraSeedFile === undefined) !== (seedProfile.data === undefined)
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -460,7 +456,7 @@ export const createGhidraProviderClient = (input: {
|
||||
const pinnedLanguage = ghidraPinnedMipsLanguage(committedProfile.parameters);
|
||||
const targetLanguage = ghidraPinnedLanguage(target);
|
||||
if (pinnedLanguage !== undefined && targetLanguage === undefined)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -475,7 +471,7 @@ export const createGhidraProviderClient = (input: {
|
||||
pinnedLanguage !== undefined &&
|
||||
config.ghidraLanguageOverride !== undefined
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -489,7 +485,7 @@ export const createGhidraProviderClient = (input: {
|
||||
config.ghidraLanguageOverride ?? targetLanguage,
|
||||
)
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -509,7 +505,7 @@ export const createGhidraProviderClient = (input: {
|
||||
config.ghidraDisabledAnalyzers,
|
||||
)
|
||||
)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
@@ -525,7 +521,7 @@ export const createGhidraProviderClient = (input: {
|
||||
installation.platform,
|
||||
);
|
||||
if (invalidProfile !== null)
|
||||
return unavailableClient(
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "open_binary", {
|
||||
diagnostics: { reason: invalidProfile },
|
||||
}),
|
||||
@@ -556,9 +552,7 @@ export const createGhidraProviderClient = (input: {
|
||||
"Static analysis does not emulate reset vectors, memory-mapped I/O, or firmware boot sequences.",
|
||||
]
|
||||
: []),
|
||||
...(target.kind === "executable" && target.architecture === "mips"
|
||||
? (target.mips.limitations ?? [])
|
||||
: []),
|
||||
...(target.architecture === "mips" ? (target.mips.limitations ?? []) : []),
|
||||
...(committedProfile.parameters.mips_abi_basis ===
|
||||
"inferred-o32-assumption-v1"
|
||||
? [
|
||||
@@ -578,6 +572,13 @@ export const createGhidraProviderClient = (input: {
|
||||
"macOS sessions require a matching executable Ghidra native decompiler; REA checks for it but does not build native components or change Gatekeeper quarantine state.",
|
||||
]
|
||||
: [];
|
||||
if (input.signal.aborted)
|
||||
return err(
|
||||
new AnalysisCancelledError("create_analysis_client", {
|
||||
cause: input.signal.reason,
|
||||
diagnostics: ghidraInstallationDiagnostics(installation),
|
||||
}),
|
||||
);
|
||||
const client = clientFactory({
|
||||
startupTimeoutMs: config.ghidraStartupTimeoutMs,
|
||||
platform: installation.platform,
|
||||
@@ -774,7 +775,7 @@ export const createGhidraProviderClient = (input: {
|
||||
if (!parsed.ok) return parsed;
|
||||
return ok(projectExecution(operation, parsed.value, called.value));
|
||||
};
|
||||
return {
|
||||
return ok<AnalysisClient>({
|
||||
execute: async (operation, parameters, options) => {
|
||||
if (extensionFailure !== undefined) return err(extensionFailure);
|
||||
if (
|
||||
@@ -987,7 +988,7 @@ export const createGhidraProviderClient = (input: {
|
||||
nativeAotOwner?.close();
|
||||
return client.close();
|
||||
},
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
interface GhidraClientCoordinates {
|
||||
@@ -998,11 +999,17 @@ interface GhidraClientCoordinates {
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
}
|
||||
|
||||
const ghidraClientPrerequisites = (
|
||||
export interface GhidraClientAdmission {
|
||||
readonly target: Extract<BinaryTarget, { readonly kind: "executable" }>;
|
||||
readonly profile: AnalysisProfileCommitment;
|
||||
}
|
||||
|
||||
/** Admit caller inputs and native authority before Java work starts. */
|
||||
export const admitGhidraClient = (
|
||||
target: BinaryTarget,
|
||||
profile: AnalysisProfileCommitment | undefined,
|
||||
installation: GhidraInstallationInspection,
|
||||
): Result<GhidraClientCoordinates, AnalysisError> => {
|
||||
platform: NodeJS.Platform,
|
||||
): Result<GhidraClientAdmission, AnalysisError> => {
|
||||
if (target.kind !== "executable")
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
@@ -1011,6 +1018,26 @@ const ghidraClientPrerequisites = (
|
||||
`Ghidra cannot import ${target.kind} targets through this adapter.`,
|
||||
),
|
||||
);
|
||||
if (platform === "win32") {
|
||||
const readiness = inspectWindowsNativeReadiness(platform);
|
||||
if (!readiness.available)
|
||||
return err(
|
||||
new AnalysisCapabilityUnavailableError(
|
||||
"ghidra",
|
||||
"health",
|
||||
readiness.reason,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (profile === undefined || profile.provider.id !== "ghidra")
|
||||
return err(new ProviderAdapterError("ghidra", "health"));
|
||||
return ok({ target, profile });
|
||||
};
|
||||
|
||||
const ghidraClientPrerequisites = (
|
||||
profile: AnalysisProfileCommitment,
|
||||
installation: GhidraInstallationInspection,
|
||||
): Result<GhidraClientCoordinates, AnalysisError> => {
|
||||
if (installation.status === "unavailable")
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "health", {
|
||||
@@ -1029,8 +1056,17 @@ const ghidraClientPrerequisites = (
|
||||
readiness.reason,
|
||||
),
|
||||
);
|
||||
if (profile === undefined || profile.provider.id !== "ghidra")
|
||||
return err(new ProviderAdapterError("ghidra", "health"));
|
||||
if (profile.provider.version !== installation.providerVersion)
|
||||
return err(
|
||||
new ProviderAdapterError("ghidra", "health", {
|
||||
diagnostics: {
|
||||
reason:
|
||||
"The current installation version differs from the selected analysis profile; resolve the profile again.",
|
||||
selected_provider_version: profile.provider.version,
|
||||
current_installation: ghidraInstallationDiagnostics(installation),
|
||||
},
|
||||
}),
|
||||
);
|
||||
return ok({
|
||||
analyzeHeadlessPath: installation.analyzeHeadlessPath,
|
||||
configurationDirectoryName: installation.configurationDirectoryName,
|
||||
@@ -1040,11 +1076,6 @@ const ghidraClientPrerequisites = (
|
||||
});
|
||||
};
|
||||
|
||||
const unavailableClient = (failure: AnalysisError): AnalysisClient => ({
|
||||
execute: () => Promise.resolve(err(failure)),
|
||||
close: () => Promise.resolve(ok(null)),
|
||||
});
|
||||
|
||||
const projectSessionError = (
|
||||
operation: AnalysisOperation,
|
||||
failure: GhidraSessionError,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Lifetime of one task's prerequisite observations, distinct from resource ownership. */
|
||||
export class ProviderInspectionScope {
|
||||
#active = true;
|
||||
get active(): boolean {
|
||||
return this.#active;
|
||||
}
|
||||
close(): void {
|
||||
this.#active = false;
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,14 @@
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { expect } from "vitest";
|
||||
import { elf, thinMach } from "../../../src/domain/binaryTarget.fixture.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
import {
|
||||
observeOwnedProcessGroups,
|
||||
systemProcessOwnershipHost,
|
||||
} from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
|
||||
cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([
|
||||
["missing", "missing_dependency", "runtime_missing"],
|
||||
@@ -115,3 +120,113 @@ cliTest.skipIf(!["darwin", "linux"].includes(process.platform)).for([
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.skipIf(!["darwin", "linux"].includes(process.platform))(
|
||||
"doctor returns its own deadline result for a SIGTERM-resistant selected Java command",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-java-doctor-deadline-");
|
||||
const jdk = join(root, "jdk");
|
||||
const install = join(root, "ghidra");
|
||||
const started = join(root, "java-started.json");
|
||||
const release = join(root, "release-java");
|
||||
await mkdir(join(jdk, "bin"), { recursive: true });
|
||||
await mkdir(join(install, "Ghidra"), { recursive: true });
|
||||
await writeFile(
|
||||
join(install, "Ghidra/application.properties"),
|
||||
"application.version=12.1.4\napplication.java.min=21\n",
|
||||
);
|
||||
await writeFile(
|
||||
join(jdk, "bin/java"),
|
||||
`#!${process.execPath}
|
||||
const fs = require("node:fs");
|
||||
process.on("SIGTERM", () => {});
|
||||
fs.writeFileSync(${JSON.stringify(`${started}.tmp`)}, JSON.stringify({ pid: process.pid, runId: process.env.REA_PROCESS_RUN_ID }));
|
||||
fs.renameSync(${JSON.stringify(`${started}.tmp`)}, ${JSON.stringify(started)});
|
||||
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
|
||||
setTimeout(() => process.exit(92), 12000);
|
||||
`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
const pending = cli.run({
|
||||
arguments: ["doctor", "--provider", "ghidra", "--json"],
|
||||
environment: {
|
||||
JAVA_HOME: jdk,
|
||||
GHIDRA_INSTALL_DIR: install,
|
||||
REA_LOG_LEVEL: "silent",
|
||||
},
|
||||
timeoutMs: 15000,
|
||||
});
|
||||
// Capture the actual group while the selected process is alive; the PID
|
||||
// alone is never used as cleanup authority. Release is a private protocol.
|
||||
let identity: { pid: number; runId: string } | undefined;
|
||||
let processGroupId: number | undefined;
|
||||
try {
|
||||
for (let attempt = 0; attempt < 80; attempt++) {
|
||||
try {
|
||||
identity = JSON.parse(
|
||||
await readFile(started, "utf8"),
|
||||
) as typeof identity;
|
||||
} catch (cause: unknown) {
|
||||
if (
|
||||
!(
|
||||
cause instanceof Error &&
|
||||
"code" in cause &&
|
||||
cause.code === "ENOENT"
|
||||
)
|
||||
)
|
||||
throw cause;
|
||||
}
|
||||
if (identity !== undefined) break;
|
||||
await delay(50);
|
||||
}
|
||||
if (identity === undefined)
|
||||
throw new Error("Selected Java command did not start");
|
||||
expect(identity.runId).toEqual(expect.any(String));
|
||||
processGroupId = (await systemProcessOwnershipHost.listProcesses()).find(
|
||||
(entry) => entry.pid === identity?.pid,
|
||||
)?.processGroupId;
|
||||
expect(processGroupId).toBeDefined();
|
||||
const result = await pending;
|
||||
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
|
||||
expect(result.signal).toBeNull();
|
||||
expect(result.json).toMatchObject({
|
||||
providerInspections: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "ghidra",
|
||||
checks: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
name: "java",
|
||||
code: "runtime_unavailable",
|
||||
details: expect.objectContaining({
|
||||
process_error_code: "ETIMEDOUT",
|
||||
}),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
});
|
||||
if (processGroupId !== undefined) {
|
||||
const observed = await observeOwnedProcessGroups(identity.runId, [
|
||||
processGroupId,
|
||||
]);
|
||||
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
|
||||
}
|
||||
} finally {
|
||||
await writeFile(release, "release");
|
||||
await pending;
|
||||
if (identity !== undefined && processGroupId !== undefined) {
|
||||
for (let attempt = 0; attempt < 40; attempt++) {
|
||||
const observed = await observeOwnedProcessGroups(identity.runId, [
|
||||
processGroupId,
|
||||
]);
|
||||
if (observed.get(processGroupId)?.state === "empty") break;
|
||||
await delay(50);
|
||||
}
|
||||
const observed = await observeOwnedProcessGroups(identity.runId, [
|
||||
processGroupId,
|
||||
]);
|
||||
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,42 +1,67 @@
|
||||
import type { SpawnSyncReturns } from "node:child_process";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { probeGhidraJava } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
|
||||
|
||||
import { mkdir, writeFile, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { afterEach, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
inspectGhidraInstallation,
|
||||
systemGhidraInstallationHost,
|
||||
ghidraInstallationDiagnostics,
|
||||
} from "../../../src/ghidra/GhidraInstallation.js";
|
||||
import { SUPPORTED_GHIDRA_VERSION } from "../../../src/ghidra/GhidraReleasePolicy.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
const { mockSpawnSync } = vi.hoisted(() => ({ mockSpawnSync: vi.fn() }));
|
||||
let inspectionResources = createInspectionResources();
|
||||
afterEach(async () => {
|
||||
await inspectionResources.close();
|
||||
inspectionResources = createInspectionResources();
|
||||
});
|
||||
|
||||
vi.mock("node:child_process", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("node:child_process")>()),
|
||||
spawnSync: mockSpawnSync,
|
||||
}));
|
||||
|
||||
afterEach(() => mockSpawnSync.mockReset());
|
||||
|
||||
/** Match the production probe's raw-byte spawn contract at this fixture boundary. */
|
||||
const probeOutput = (
|
||||
/** Controlled Java-shaped command exercising the real process owner, not a JVM. */
|
||||
const writeJavaProbe = async (
|
||||
home: string,
|
||||
stderrText: string,
|
||||
error?: Error,
|
||||
): SpawnSyncReturns<Buffer> => {
|
||||
const stdout = Buffer.alloc(0);
|
||||
const stderr = Buffer.from(stderrText, "utf8");
|
||||
return {
|
||||
pid: 1,
|
||||
output: [null, stdout, stderr],
|
||||
stdout,
|
||||
stderr,
|
||||
status: error === undefined ? 0 : null,
|
||||
signal: null,
|
||||
...(error === undefined ? {} : { error }),
|
||||
};
|
||||
outputBytes?: number,
|
||||
) => {
|
||||
const executable = join(
|
||||
home,
|
||||
"bin",
|
||||
process.platform === "win32" ? "java.exe" : "java",
|
||||
);
|
||||
await mkdir(join(home, "bin"), { recursive: true });
|
||||
await writeFile(
|
||||
process.platform === "win32" ? `${executable}.cjs` : executable,
|
||||
`#!${process.execPath}\nconst fs = require('node:fs');\nfs.writeFileSync(${JSON.stringify(join(home, "probe-arguments.json"))}, JSON.stringify(process.argv.slice(2)));\nconst prefix = ${JSON.stringify(stderrText)};\nprocess.stderr.write(prefix${outputBytes === undefined ? "" : ` + 'X'.repeat(${outputBytes} - Buffer.byteLength(prefix))`});\n`,
|
||||
{ mode: 0o700 },
|
||||
);
|
||||
};
|
||||
|
||||
// Windows executes the controlled JS prerequisite with the real owned Node exe;
|
||||
// POSIX executes its selected shebang path. Neither fixture is a real JVM.
|
||||
const probeHost =
|
||||
process.platform !== "win32"
|
||||
? undefined
|
||||
: {
|
||||
...systemGhidraInstallationHost(),
|
||||
probeJava: (
|
||||
command: string,
|
||||
environment: NodeJS.ProcessEnv,
|
||||
options: Parameters<typeof probeGhidraJava>[2],
|
||||
) =>
|
||||
probeGhidraJava(command, environment, {
|
||||
...options,
|
||||
launcher: (spawn) =>
|
||||
spawnOwnedProviderProcess({
|
||||
...spawn,
|
||||
command: process.execPath,
|
||||
arguments: [`${command}.cjs`, ...spawn.arguments],
|
||||
}),
|
||||
}),
|
||||
};
|
||||
|
||||
it.each([
|
||||
{ major: 17, fullJdk: true, vmName: "Java HotSpot(TM) 64-Bit Server VM" },
|
||||
{ major: 21, fullJdk: true, vmName: "Eclipse OpenJ9 VM" },
|
||||
@@ -66,17 +91,20 @@ it.each([
|
||||
);
|
||||
}
|
||||
const version = `${String(major)}.0.11`;
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
|
||||
),
|
||||
await writeJavaProbe(
|
||||
configuredHome,
|
||||
` java.version = ${version}\n java.home = ${reportedHome}\n sun.arch.data.model = 64\n${vmName === undefined ? "" : ` java.vm.name = ${vmName}\n`}`,
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
});
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
},
|
||||
probeHost,
|
||||
);
|
||||
|
||||
expect(inspection.javaHome).toBe(reportedHome);
|
||||
expect(inspection.javaVersion).toBe(version);
|
||||
@@ -90,19 +118,17 @@ it.each([
|
||||
detail: expect.stringContaining(fullJdk ? "; JDK;" : "; runtime only;"),
|
||||
},
|
||||
);
|
||||
expect(mockSpawnSync).toHaveBeenCalledExactlyOnceWith(
|
||||
join(
|
||||
configuredHome,
|
||||
"bin",
|
||||
process.platform === "win32" ? "java.exe" : "java",
|
||||
expect(
|
||||
JSON.parse(
|
||||
await readFile(join(configuredHome, "probe-arguments.json"), "utf8"),
|
||||
),
|
||||
).toEqual(
|
||||
expect.arrayContaining([
|
||||
"-Dstdout.encoding=UTF-8",
|
||||
"-Dstderr.encoding=UTF-8",
|
||||
"-Dsun.stdout.encoding=UTF-8",
|
||||
"-Dsun.stderr.encoding=UTF-8",
|
||||
]),
|
||||
expect.objectContaining({ encoding: "buffer" }),
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -146,17 +172,20 @@ it.each([
|
||||
join(installDir, "Ghidra", "application.properties"),
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
);
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
|
||||
),
|
||||
await writeJavaProbe(
|
||||
configuredHome,
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n${property}`,
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
});
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
},
|
||||
probeHost,
|
||||
);
|
||||
|
||||
expect(inspection.status).toBe("unavailable");
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
@@ -191,17 +220,20 @@ it("keeps a known 32-bit Java report classified as unsupported", async () => {
|
||||
join(installDir, "Ghidra", "application.properties"),
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
);
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
|
||||
),
|
||||
await writeJavaProbe(
|
||||
configuredHome,
|
||||
` java.version = 21.0.11\n java.home = ${reportedHome}\n sun.arch.data.model = 32\n`,
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
});
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
},
|
||||
probeHost,
|
||||
);
|
||||
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
status: "failed",
|
||||
@@ -219,27 +251,23 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
|
||||
join(installDir, "Ghidra", "application.properties"),
|
||||
`application.version=${SUPPORTED_GHIDRA_VERSION}\napplication.java.min=17\n`,
|
||||
);
|
||||
// A chatty or hostile JVM that overruns the capture budget: spawnSync reports
|
||||
// ENOBUFS, which the probe already treats as a failed observation.
|
||||
mockSpawnSync.mockReturnValue(
|
||||
probeOutput(
|
||||
"java.version = 21.0.11\n",
|
||||
Object.assign(new Error("spawnSync /java ENOBUFS"), { code: "ENOBUFS" }),
|
||||
),
|
||||
// The real command owner observes output beyond its one-MiB aggregate budget.
|
||||
await writeJavaProbe(
|
||||
configuredHome,
|
||||
"java.version = 21.0.11\n",
|
||||
1024 * 1024 + 1,
|
||||
);
|
||||
|
||||
const inspection = inspectGhidraInstallation({
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
});
|
||||
const inspection = await inspectGhidraInstallation(
|
||||
{
|
||||
resourceOwner: inspectionResources.owner,
|
||||
environment: {},
|
||||
installDir,
|
||||
javaHome: configuredHome,
|
||||
},
|
||||
probeHost,
|
||||
);
|
||||
|
||||
const [, , options] = mockSpawnSync.mock.calls[0] as [
|
||||
string,
|
||||
readonly string[],
|
||||
{ readonly maxBuffer?: number },
|
||||
];
|
||||
expect(options.maxBuffer).toBe(1024 * 1024);
|
||||
expect(inspection.javaVersion).toBeNull();
|
||||
expect(inspection.javaHome).toBe(configuredHome);
|
||||
expect(inspection.checks.find(({ name }) => name === "java")).toMatchObject({
|
||||
@@ -252,7 +280,7 @@ it("bounds the Java version probe and reports an over-budget probe as a failed J
|
||||
code: "runtime_unavailable",
|
||||
process_error_code: "ENOBUFS",
|
||||
output: {
|
||||
stderr: "java.version = 21.0.11\n",
|
||||
stderr: expect.stringContaining("java.version = 21.0.11\n"),
|
||||
truncated: true,
|
||||
signal: null,
|
||||
},
|
||||
|
||||
@@ -334,7 +334,7 @@ it.each(["ready", "cancelled", "physical"] as const)(
|
||||
new AnalysisProviderRegistry([
|
||||
{
|
||||
...base,
|
||||
inspectAvailability: async (signal) => {
|
||||
inspectAvailability: async ({ signal } = {}) => {
|
||||
enter();
|
||||
await gate;
|
||||
if (mode === "physical")
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
import { beforeAll, expect, it } from "vitest";
|
||||
import { writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
|
||||
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
|
||||
import { DarwinProcessOwnershipInspectionError } from "../../../src/process/DarwinProcessRunTokenReader.js";
|
||||
import { probeGhidraJava } from "../../../src/ghidra/GhidraJavaProbe.js";
|
||||
import { spawnOwnedProviderProcess } from "../../../src/process/ProviderProcess.js";
|
||||
import { cleanupOwnedProcessGroup } from "../../../src/process/ProcessOwnership.js";
|
||||
import {
|
||||
observeOwnedProcessGroups,
|
||||
prepareProcessOwnershipInspection,
|
||||
} from "../../../src/process/ProcessOwnershipObservation.js";
|
||||
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
|
||||
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
|
||||
|
||||
// Real, idle Node processes stand in for Java. These controls exercise POSIX
|
||||
// process ownership and complete-output semantics, not a JVM or Ghidra.
|
||||
beforeAll(() => prepareProcessOwnershipInspection());
|
||||
const posix = it.skipIf(process.platform === "win32");
|
||||
type Launched = Awaited<ReturnType<typeof spawnOwnedProviderProcess>>;
|
||||
|
||||
const expectEmpty = async (launched: Launched) => {
|
||||
const { runId, processGroupId } = launched.ownership;
|
||||
const observed = await observeOwnedProcessGroups(runId, [processGroupId]);
|
||||
expect(observed.get(processGroupId)).toEqual({ state: "empty" });
|
||||
};
|
||||
|
||||
posix(
|
||||
"cancels an active Java prerequisite with the exact opaque reason and joins cleanup",
|
||||
async () => {
|
||||
const resources = createInspectionResources();
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel Java", cleanup: "opaque caller value" };
|
||||
let launched: Launched | undefined;
|
||||
try {
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 2500,
|
||||
launcher: async (input) => {
|
||||
launched = await spawnOwnedProviderProcess({
|
||||
...input,
|
||||
arguments: [
|
||||
"-e",
|
||||
'process.stdout.write("READY"); setInterval(() => {}, 1000)',
|
||||
],
|
||||
});
|
||||
launched.process.stdout?.on("data", () => controller.abort(reason));
|
||||
return launched;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok || launched === undefined)
|
||||
throw new Error("Expected cancellation");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
});
|
||||
expect((result.error as AnalysisError).cause).toBe(reason);
|
||||
await expectEmpty(launched);
|
||||
} finally {
|
||||
controller.abort(reason);
|
||||
await resources.close();
|
||||
if (launched !== undefined)
|
||||
expect(
|
||||
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
posix(
|
||||
"bounds a SIGTERM-resistant prerequisite while the event loop remains responsive",
|
||||
async () => {
|
||||
const resources = createInspectionResources();
|
||||
let launched: Launched | undefined;
|
||||
let timerFired = false;
|
||||
const timer = setTimeout(() => {
|
||||
timerFired = true;
|
||||
}, 50);
|
||||
try {
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
timeoutMs: 750,
|
||||
launcher: async (input) => {
|
||||
launched = await spawnOwnedProviderProcess({
|
||||
...input,
|
||||
arguments: [
|
||||
"-e",
|
||||
'process.on("SIGTERM", () => {}); process.stdout.write("READY"); setInterval(() => {}, 1000)',
|
||||
],
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok || launched === undefined)
|
||||
throw new Error("Expected deadline failure");
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
processErrorCode: "ETIMEDOUT",
|
||||
output: { stdout: "READY" },
|
||||
});
|
||||
expect(timerFired).toBe(true);
|
||||
await expectEmpty(launched);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
await resources.close();
|
||||
if (launched !== undefined)
|
||||
expect(
|
||||
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
posix.each([true, false])(
|
||||
"preserves completed Java output across final-cleanup cancellation (valid: %s)",
|
||||
async (valid) => {
|
||||
const resources = createInspectionResources();
|
||||
const controller = new AbortController();
|
||||
const reason: { cycle?: unknown } = {};
|
||||
reason.cycle = reason;
|
||||
let launched: Launched | undefined;
|
||||
try {
|
||||
const output = valid
|
||||
? "java.version = 21.0.1\njava.home = /controlled-java-home\n"
|
||||
: "invalid Java report";
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 2500,
|
||||
launcher: async (input) => {
|
||||
const acquired = await spawnOwnedProviderProcess({
|
||||
...input,
|
||||
arguments: [
|
||||
"-e",
|
||||
`process.stderr.write(${JSON.stringify(output)})`,
|
||||
],
|
||||
});
|
||||
launched = acquired;
|
||||
return {
|
||||
...acquired,
|
||||
cleanup: async () => {
|
||||
const cleaned = await cleanupOwnedProcessGroup(
|
||||
acquired.ownership,
|
||||
);
|
||||
controller.abort(reason);
|
||||
return cleaned;
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok || launched === undefined)
|
||||
throw new Error("Expected finalization failure");
|
||||
if (valid) {
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
diagnostics: { java_observation: { version: "21.0.1" } },
|
||||
});
|
||||
expect((result.error as AnalysisError).cause).toBe(reason);
|
||||
} else {
|
||||
expect(result.error).toMatchObject({
|
||||
_tag: "GhidraJavaProbeFailure",
|
||||
code: "version_unresolved",
|
||||
cancellation: { reason },
|
||||
});
|
||||
expect(Object.keys(result.error)).not.toContain("cancellation");
|
||||
expect(() => JSON.stringify(result.error)).not.toThrow();
|
||||
}
|
||||
await expectEmpty(launched);
|
||||
} finally {
|
||||
await resources.close();
|
||||
if (launched !== undefined)
|
||||
expect(
|
||||
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
posix(
|
||||
"does not interpret a root's zero exit while a detached descendant holds its output open",
|
||||
async () => {
|
||||
const resources = createInspectionResources();
|
||||
const root = await createTestTempDirectory("rea-java-descendant-");
|
||||
const release = join(root, "release");
|
||||
let launched: Launched | undefined;
|
||||
let descendantGroup: number | undefined;
|
||||
const child = `const fs = require("node:fs");
|
||||
process.stdout.write("DESCENDANT_READY\\n");
|
||||
setInterval(() => { if (fs.existsSync(${JSON.stringify(release)})) process.exit(0); }, 50);
|
||||
setTimeout(() => process.exit(92), 10000);`;
|
||||
try {
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
timeoutMs: 1000,
|
||||
launcher: async (input) => {
|
||||
launched = await spawnOwnedProviderProcess({
|
||||
...input,
|
||||
arguments: [
|
||||
"-e",
|
||||
`
|
||||
const child = require("node:child_process").spawn(process.execPath, ["-e", ${JSON.stringify(child)}], {
|
||||
detached: true, stdio: ["ignore", "inherit", "inherit"]
|
||||
});
|
||||
process.stdout.write("DESCENDANT_GROUP:" + child.pid + "\\n");
|
||||
child.unref();
|
||||
`,
|
||||
],
|
||||
});
|
||||
return launched;
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
result.ok ||
|
||||
launched === undefined ||
|
||||
result.error._tag !== "GhidraJavaProbeFailure"
|
||||
)
|
||||
throw new Error("Expected incomplete-output deadline failure");
|
||||
expect(result.error).toMatchObject({
|
||||
processErrorCode: "ETIMEDOUT",
|
||||
output: { exit_code: 0 },
|
||||
});
|
||||
expect(result.error.output?.stdout).toContain("DESCENDANT_READY");
|
||||
descendantGroup = Number(
|
||||
/DESCENDANT_GROUP:(\d+)/u.exec(result.error.output?.stdout ?? "")?.[1],
|
||||
);
|
||||
expect(Number.isSafeInteger(descendantGroup)).toBe(true);
|
||||
const observed = await observeOwnedProcessGroups(
|
||||
launched.ownership.runId,
|
||||
[descendantGroup],
|
||||
);
|
||||
expect(observed.get(descendantGroup)).toEqual({ state: "empty" });
|
||||
await expectEmpty(launched);
|
||||
} finally {
|
||||
await writeFile(release, "release");
|
||||
await resources.close();
|
||||
if (launched !== undefined)
|
||||
expect(
|
||||
(
|
||||
await (launched.cleanup?.() ??
|
||||
cleanupOwnedProcessGroup(launched.ownership))
|
||||
).cleaned,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("retains only the preparation owner's exact failed root through cancellation and retry", async () => {
|
||||
const resources = createInspectionResources();
|
||||
const controller = new AbortController();
|
||||
const reason = new ProviderCleanupError(
|
||||
"foreign",
|
||||
["caller-owned-resource"],
|
||||
{ reason: "opaque caller error" },
|
||||
);
|
||||
const cleanup = new ProviderCleanupError(
|
||||
"process-ownership",
|
||||
["exact-helper-root"],
|
||||
{ reason: "controlled cleanup refusal" },
|
||||
);
|
||||
let denied = true;
|
||||
let closes = 0;
|
||||
try {
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
signal: controller.signal,
|
||||
launcher: async () => {
|
||||
controller.abort(reason);
|
||||
throw new DarwinProcessOwnershipInspectionError(
|
||||
"cancelled preparation with failed root cleanup",
|
||||
{
|
||||
cancellation: { reason },
|
||||
cleanupFailure: cleanup,
|
||||
cleanupOwner: {
|
||||
close: async () => {
|
||||
closes++;
|
||||
if (denied) throw cleanup;
|
||||
cleanup.resolveCleanup();
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
if (result.ok || !(result.error instanceof AnalysisError))
|
||||
throw new Error("Expected retained preparation failure");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupResources).toEqual(["exact-helper-root"]);
|
||||
expect(result.error.cleanupPrimary).toMatchObject({
|
||||
_tag: "AnalysisCancelledError",
|
||||
cause: reason,
|
||||
});
|
||||
await expect(resources.close()).rejects.toThrow();
|
||||
denied = false;
|
||||
await resources.close();
|
||||
expect(closes).toBe(2);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
expect(reason.cleanupIncomplete).toBe(true);
|
||||
} finally {
|
||||
denied = false;
|
||||
await resources.close();
|
||||
}
|
||||
});
|
||||
|
||||
posix.each([true, false])(
|
||||
"retains exact cleanup authority and resolves prior errors after retry (valid: %s)",
|
||||
async (valid) => {
|
||||
const resources = createInspectionResources();
|
||||
const controller = new AbortController();
|
||||
const reason = { request: "cancel during refused cleanup" };
|
||||
let denied = true;
|
||||
let launched: Launched | undefined;
|
||||
let attempts = 0;
|
||||
try {
|
||||
const output = valid
|
||||
? "java.version = 21.0.1\njava.home = /controlled-java-home\n"
|
||||
: "invalid Java report";
|
||||
const result = await probeGhidraJava(
|
||||
process.execPath,
|
||||
{},
|
||||
{
|
||||
resourceOwner: resources.owner,
|
||||
signal: controller.signal,
|
||||
timeoutMs: 2500,
|
||||
launcher: async (input) => {
|
||||
const acquired = await spawnOwnedProviderProcess({
|
||||
...input,
|
||||
arguments: [
|
||||
"-e",
|
||||
`process.stderr.write(${JSON.stringify(output)})`,
|
||||
],
|
||||
});
|
||||
launched = acquired;
|
||||
return {
|
||||
...acquired,
|
||||
cleanup: async () => {
|
||||
attempts++;
|
||||
if (denied) {
|
||||
controller.abort(reason);
|
||||
return {
|
||||
cleaned: false,
|
||||
reason: "controlled verification refusal",
|
||||
};
|
||||
}
|
||||
return cleanupOwnedProcessGroup(acquired.ownership);
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
);
|
||||
if (
|
||||
result.ok ||
|
||||
!(result.error instanceof AnalysisError) ||
|
||||
launched === undefined
|
||||
)
|
||||
throw new Error("Expected retained cleanup error");
|
||||
expect(result.error.cleanupIncomplete).toBe(true);
|
||||
expect(result.error.cleanupPrimary?.diagnostics).toMatchObject(
|
||||
valid
|
||||
? { java_observation: { version: "21.0.1" } }
|
||||
: { java_probe_failure: { code: "version_unresolved" } },
|
||||
);
|
||||
denied = false;
|
||||
await resources.close();
|
||||
expect(attempts).toBe(2);
|
||||
expect(result.error.cleanupIncomplete).toBe(false);
|
||||
await expectEmpty(launched);
|
||||
} finally {
|
||||
denied = false;
|
||||
await resources.close();
|
||||
if (launched !== undefined)
|
||||
expect(
|
||||
(await cleanupOwnedProcessGroup(launched.ownership)).cleaned,
|
||||
).toBe(true);
|
||||
}
|
||||
},
|
||||
);
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
import { ArtifactResourceScope } from "../../src/artifacts/ArtifactResourceScope.js";
|
||||
import { AnalysisCleanupLease } from "../../src/artifacts/AnalysisCleanupLease.js";
|
||||
import type { FileCloseResourceOwner } from "../../src/application/AnalysisProvider.js";
|
||||
|
||||
/** Test-scoped real inspection leases; every retained owner is closed by the fixture. */
|
||||
export const createInspectionResources = () => {
|
||||
const scope = new ArtifactResourceScope();
|
||||
const owner: FileCloseResourceOwner = {
|
||||
retain: (resourceOwner, resource) =>
|
||||
scope.retain({
|
||||
kind: "lease",
|
||||
resource,
|
||||
lease: new AnalysisCleanupLease(
|
||||
() => resourceOwner.close(),
|
||||
[resource],
|
||||
),
|
||||
}),
|
||||
run: (operation) => scope.run(operation),
|
||||
};
|
||||
return { owner, close: () => scope.close() };
|
||||
};
|
||||
Reference in New Issue
Block a user