fix(ghidra): admit installation metadata through owned bounded reads

Reject special files without blocking, cap the properties payload at 1 MiB before allocation, and verify descriptor extent and the selected name. Preserve Latin-1 data, selected symlinks, physical filesystem facts, completed observations and exact failed-close ownership.

Reuse the stable bounded reader for profile artifacts and consolidate Ghidra file failure projection. Distinguish missing-at-open from disappearance or read failure, and attribute typed prerequisite failures at the consuming operation.

Validation: 384 focused tests across 14 files, 42 seed/extension controls, 32 final metadata/profile/CLI controls, check:fast and docs:check. Controlled macOS file/process fixtures; no real Ghidra/JVM or Windows/Linux lifecycle verification.

Bounded FIFO reproduction: baseline direct and public doctor required external cleanup around 2.2s; candidate returned independently around 0.4/0.57s, all fixture groups verified empty. Warm normal doctor baseline/candidate: elapsed 1.211/1.230s, CPU 1.239/1.247s, sampled family RSS 524/524MB, reply tokens 3116/3116. No performance improvement claimed; true cold and whole-agent costs unmeasured.

Refs #4672
This commit is contained in:
morluto
2026-10-17 12:02:26 +08:00
parent c72786f289
commit 5112fe07ae
14 changed files with 825 additions and 91 deletions
+7
View File
@@ -894,6 +894,13 @@ the launcher fails before preparing its home/cache/config/temp directories or
starting Java. The client may already have allocated private runtime/endpoint
resources and a target snapshot; existing startup-failure cleanup still applies.
Restore the selected release's properties instead of renaming its installation folder.
Inspection follows selected properties symlinks, admits only a regular file,
and caps the metadata payload at 1 MiB under REA's resource budget. A final
one-byte probe checks for growth. This is not a Java
Properties format limit. Reads preserve Ghidra's Latin-1 byte mapping and check
the descriptor and selected name for changes. Permission failures and resource
refusals remain explicit; failed descriptor cleanup must succeed before that
owner admits another inspection.
This coordinate selects only REA's session-private configuration directory, not
the installation or an existing user configuration directory.
@@ -4,7 +4,12 @@ import {
createAnalysisExecution,
type AnalysisClient,
} from "../AnalysisProvider.js";
import { AnalysisCancelledError } from "../../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisResourceConstraintError,
} from "../../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../../domain/providerCleanupError.js";
import { projectAnalysisError } from "../../domain/analysisErrorProjection.js";
@@ -12,6 +17,51 @@ import { resolvedAnalysisError } from "../../domain/analysisErrorBase.js";
import { err, ok } from "../../domain/result.js";
const identity = { id: "controlled", name: "Controlled client", version: "1" };
it.each([
new AnalysisAccessDeniedError(
"ghidra:installation",
"/selected/properties",
"EACCES",
),
new AnalysisArtifactChangedError(
"ghidra:installation",
"/selected/properties",
"selected name changed",
),
new AnalysisResourceConstraintError(
"ghidra:installation",
"file-size",
"metadata budget",
{ maximum_bytes: 1024 },
{ retryable: false },
),
])(
"attributes prerequisite $_tag to the requested operation without changing its facts",
async (physical) => {
const client = new DeferredAnalysisClient(async () => {
throw physical;
}, identity.id);
try {
const result = await client.execute("analyze_function", {});
if (result.ok) throw new Error("Expected prerequisite failure");
expect(result.error).toMatchObject({
_tag: physical._tag,
operation: "analyze_function",
});
expect(result.error.cause).toBe(physical);
if (physical instanceof AnalysisResourceConstraintError)
expect(result.error).toMatchObject({
resource: "file-size",
reportedLimits: { maximum_bytes: 1024 },
retryable: false,
});
else expect(result.error).toMatchObject({ path: "/selected/properties" });
} finally {
await client.close();
}
},
);
const gate = <T = void>() => {
let resolve!: (value: T) => void;
const promise = new Promise<T>((finish) => {
+6 -1
View File
@@ -10,6 +10,7 @@ import {
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import {
ArtifactResourceRetryFailure,
type ArtifactResourceOwner,
type ArtifactResourceScope,
} from "./ArtifactResourceScope.js";
import {
@@ -179,7 +180,11 @@ export const assertArtifactReadActive = (
/** Admit a regular file without blocking on pipes, retaining failed admission closes. */
export const withArtifactRegularFile = async <Value>(
path: string,
resources: ArtifactResourceScope,
resources: {
retain(
owner: Extract<ArtifactResourceOwner, { readonly kind: "lease" }>,
): void;
},
signal: AbortSignal | undefined,
operation: (file: FileHandle) => Promise<Value>,
symlinks: "follow" | "reject" = "reject",
+5 -2
View File
@@ -54,8 +54,11 @@ export class RegularFileAdmissionFailure extends Error {
/** A regular input no longer matches the extent or identity admitted for reading. */
export class RegularFileChangedError extends Error {
readonly code = "ESTALE";
constructor(readonly path: string) {
super(`Selected input changed while being read: ${path}`);
constructor(
readonly path: string,
options?: ErrorOptions,
) {
super(`Selected input changed while being read: ${path}`, options);
this.name = "RegularFileChangedError";
}
}
+75
View File
@@ -0,0 +1,75 @@
import { lstat, stat, type FileHandle } from "node:fs/promises";
import {
assertRegularFileActive,
RegularFileChangedError,
sameRegularFileState,
} from "./RegularFile.js";
/** Resource refusal carries the observed extent before allocating file bytes. */
export class RegularFileCapacityError extends Error {
readonly _tag = "RegularFileCapacityError";
constructor(
readonly path: string,
readonly maximumBytes: number,
readonly observedBytes: bigint,
) {
super(`Selected file must be at most ${maximumBytes} bytes: ${path}`);
this.name = this._tag;
}
}
/** Read an admitted descriptor completely, checking its extent and selected name. */
export const readStableFileBytes = async (
file: FileHandle,
path: string,
maximumBytes: number,
options: {
readonly signal?: AbortSignal;
readonly symlinks: "follow" | "reject";
},
): Promise<Buffer> => {
if (!Number.isSafeInteger(maximumBytes) || maximumBytes < 0)
throw new RangeError(
"Maximum file bytes must be a nonnegative safe integer",
);
assertRegularFileActive(options.signal);
const before = await file.stat({ bigint: true });
if (before.size > BigInt(maximumBytes))
throw new RegularFileCapacityError(path, maximumBytes, before.size);
assertRegularFileActive(options.signal);
const bytes = Buffer.alloc(Number(before.size));
let offset = 0;
while (offset < bytes.length) {
assertRegularFileActive(options.signal);
const read = await file.read(bytes, offset, bytes.length - offset, offset);
if (read.bytesRead === 0) throw new RegularFileChangedError(path);
offset += read.bytesRead;
}
assertRegularFileActive(options.signal);
const extra = Buffer.alloc(1);
const named = async () => {
try {
return await (options.symlinks === "follow" ? stat : lstat)(path, {
bigint: true,
});
} catch (cause: unknown) {
if (
cause instanceof Error &&
"code" in cause &&
(cause.code === "ENOENT" ||
cause.code === "ENOTDIR" ||
cause.code === "ELOOP")
)
throw new RegularFileChangedError(path, { cause });
throw cause;
}
};
if (
(await file.read(extra, 0, 1, offset)).bytesRead !== 0 ||
!sameRegularFileState(before, await file.stat({ bigint: true })) ||
!sameRegularFileState(before, await named())
)
throw new RegularFileChangedError(path);
assertRegularFileActive(options.signal);
return bytes;
};
+5 -5
View File
@@ -11,8 +11,8 @@ import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.j
import {
readGhidraProfileArtifact,
writeGhidraProfileArtifact,
ghidraProfileArtifactError,
} from "./GhidraProfileArtifacts.js";
ghidraFileFailure,
} from "./GhidraFileArtifacts.js";
import { err, ok, type Result } from "../domain/result.js";
/** Seed file format admitted by this adapter. */
@@ -180,7 +180,7 @@ export const resolveGhidraAnalysisSeeds = async (
entries = await readSeeds(path, resources, signal);
} catch (cause: unknown) {
return err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
ghidraFileFailure(cause, "resolve_analysis_profile", {
setting: "REA_GHIDRA_SEED_FILE",
path: configured,
}),
@@ -199,7 +199,7 @@ export const resolveGhidraAnalysisSeeds = async (
})
.catch((cause: unknown) =>
err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
ghidraFileFailure(cause, "resolve_analysis_profile", {
setting: "REA_GHIDRA_SEED_FILE",
}),
),
@@ -232,7 +232,7 @@ export const snapshotGhidraAnalysisSeeds = async (
return path;
})
.catch((cause: unknown) => {
throw ghidraProfileArtifactError(cause, "open_binary", {
throw ghidraFileFailure(cause, "open_binary", {
setting: "REA_GHIDRA_SEED_FILE",
path: commitment.path,
});
@@ -1,17 +1,27 @@
import { lstat, open } from "node:fs/promises";
import { open } from "node:fs/promises";
import type { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import {
assertArtifactReadActive,
artifactFileCancellationCause,
artifactFilePrimaryFailure,
mapArtifactFileFailure,
bindArtifactFileCleanup,
withArtifactFileHandle,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import { sameRegularFileState } from "../filesystem/RegularFile.js";
import {
readStableFileBytes,
RegularFileCapacityError,
} from "../filesystem/readStableFileBytes.js";
import { AnalysisError } from "../domain/analysisErrorBase.js";
import { AnalysisCancelledError } from "../domain/analysisErrorCore.js";
import {
AnalysisCancelledError,
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { RegularFileChangedError } from "../filesystem/RegularFile.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
@@ -26,34 +36,10 @@ export const readGhidraProfileArtifact = <Value>(
interpret: (bytes: Buffer) => Value,
): Promise<Value> =>
withArtifactRegularFile(path, resources, signal, async (file) => {
const before = await file.stat({ bigint: true });
if (before.size > BigInt(maximumBytes))
throw new Error(
`Ghidra profile artifact must be at most ${String(maximumBytes)} bytes: ${path}`,
);
const bytes = Buffer.alloc(Number(before.size));
let offset = 0;
while (offset < bytes.length) {
assertArtifactReadActive(signal);
const read = await file.read(
bytes,
offset,
bytes.length - offset,
offset,
);
if (read.bytesRead === 0)
throw new Error(`Incomplete Ghidra profile artifact read: ${path}`);
offset += read.bytesRead;
}
assertArtifactReadActive(signal);
const extra = Buffer.alloc(1);
if (
(await file.read(extra, 0, 1, offset)).bytesRead !== 0 ||
!sameRegularFileState(before, await file.stat({ bigint: true })) ||
!sameRegularFileState(before, await lstat(path, { bigint: true }))
)
throw new Error(`Ghidra profile artifact changed during read: ${path}`);
assertArtifactReadActive(signal);
const bytes = await readStableFileBytes(file, path, maximumBytes, {
symlinks: "reject",
...(signal === undefined ? {} : { signal }),
});
return interpret(bytes);
});
@@ -72,11 +58,47 @@ export const writeGhidraProfileArtifact = async (
};
/** Preserve physical failure, exact cancellation, and all retained file leases. */
export const ghidraProfileArtifactError = (
export const ghidraFileFailure = (
cause: unknown,
operation: string,
diagnostics: Readonly<Record<string, JsonValue>> = {},
): AnalysisError => {
cause = mapArtifactFileFailure(cause, (primary) => {
if (primary instanceof RegularFileCapacityError)
return new AnalysisResourceConstraintError(
operation,
"file-size",
primary.message,
{
maximum_bytes: primary.maximumBytes,
observed_bytes: primary.observedBytes.toString(),
},
{ cause: primary, diagnostics, retryable: false },
);
if (primary instanceof RegularFileChangedError)
return new AnalysisArtifactChangedError(
operation,
primary.path,
primary.message,
{ cause: primary, diagnostics },
);
if (
primary instanceof Error &&
"code" in primary &&
(primary.code === "EACCES" || primary.code === "EPERM")
) {
const path =
"path" in primary && typeof primary.path === "string"
? primary.path
: diagnostics.path;
if (typeof path === "string")
return new AnalysisAccessDeniedError(operation, path, primary.code, {
cause: primary,
diagnostics,
});
}
return primary;
});
if (cause instanceof AnalysisError) return cause;
const primary = artifactFilePrimaryFailure(cause);
const reason = primary instanceof Error ? primary.message : String(primary);
@@ -112,7 +134,20 @@ export const ghidraProfileArtifactError = (
: new ProviderAdapterError("ghidra", operation, {
cause,
cleanup,
diagnostics: { ...diagnostics, reason },
diagnostics: {
...diagnostics,
...(primary instanceof Error &&
"code" in primary &&
typeof primary.code === "string"
? { system_code: primary.code }
: {}),
...(primary instanceof Error &&
"syscall" in primary &&
typeof primary.syscall === "string"
? { syscall: primary.syscall }
: {}),
reason,
},
userMessage: reason,
});
bindArtifactFileCleanup(cause, error);
+13 -14
View File
@@ -9,13 +9,16 @@ import {
} from "./GhidraJavaProbe.js";
import type { Result } from "../domain/result.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
readGhidraInstallationProperties,
type GhidraInstallationFileOptions,
} from "./GhidraInstallationProperties.js";
import { constants } from "node:fs";
import {
accessSync,
closeSync,
fstatSync,
openSync,
readFileSync,
readSync,
realpathSync,
statSync,
@@ -267,7 +270,10 @@ export interface GhidraExecutableHeaderProbe {
export interface GhidraInstallationHost {
readonly platform?: NodeJS.Platform;
readonly architecture?: NodeJS.Architecture;
readText(path: string): string | undefined;
readText(
path: string,
options: GhidraInstallationFileOptions,
): string | undefined | Promise<string | undefined>;
executable(path: string): boolean;
/**
* Read a bounded header prefix of one executable so admission can reject a
@@ -467,7 +473,10 @@ const installationCoordinates = async (
const properties =
applicationPropertiesPath === null
? undefined
: host.readText(applicationPropertiesPath);
: await host.readText(applicationPropertiesPath, {
resourceOwner: options.resourceOwner,
...(options.signal === undefined ? {} : { signal: options.signal }),
});
const javaCommand = (() => {
if (options.javaHome !== undefined)
return path.join(
@@ -942,17 +951,7 @@ const javaRemediation = (
};
export const systemGhidraInstallationHost = (): GhidraInstallationHost => ({
readText(path) {
try {
// Ghidra loads this file with Properties.load(InputStream), which maps
// bytes as ISO-8859-1 rather than decoding UTF-8.
return readFileSync(path).toString("latin1");
} catch (cause: unknown) {
// best-effort cleanup: optional install probing; unreadable means unknown.
void cause;
return undefined;
}
},
readText: readGhidraInstallationProperties,
executableHeader(path) {
let handle: number | undefined;
try {
@@ -0,0 +1,96 @@
import { createHash } from "node:crypto";
import type { FileCloseResourceOwner } from "../application/AnalysisProvider.js";
import {
ArtifactFileCancelled,
ArtifactFileCleanupFailure,
artifactFilePrimaryFailure,
withArtifactRegularFile,
} from "../artifacts/ArtifactFileOperation.js";
import { jsonValueSchema } from "../domain/jsonValue.js";
import { NonRegularFileReadError } from "../filesystem/RegularFile.js";
import { readStableFileBytes } from "../filesystem/readStableFileBytes.js";
import { ghidraApplicationProperties } from "./GhidraInstallationPolicy.js";
import { ghidraFileFailure } from "./GhidraFileArtifacts.js";
/** Metadata admission budget, not a Java Properties format limit. */
export const MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES = 1024 * 1024;
export interface GhidraInstallationFileOptions {
readonly resourceOwner: FileCloseResourceOwner;
readonly signal?: AbortSignal;
}
/** Preserve Java's Latin-1 byte mapping and follow the caller's selected symlinks. */
export const readGhidraInstallationProperties = async (
path: string,
options: GhidraInstallationFileOptions,
): Promise<string | undefined> => {
try {
return await options.resourceOwner.run(() =>
withArtifactRegularFile(
path,
{
retain: (resource) =>
options.resourceOwner.retain(
{ close: () => resource.lease.release() },
resource.resource,
),
},
options.signal,
async (file) =>
(
await readStableFileBytes(
file,
path,
MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES,
{
symlinks: "follow",
...(options.signal === undefined
? {}
: { signal: options.signal }),
},
)
).toString("latin1"),
"follow",
),
);
} catch (cause: unknown) {
const physical = artifactFilePrimaryFailure(cause);
if (
!(cause instanceof ArtifactFileCleanupFailure) &&
physical instanceof Error &&
"code" in physical &&
"syscall" in physical &&
physical.syscall === "open" &&
(physical.code === "ENOENT" || physical.code === "ENOTDIR")
)
return undefined;
const completed =
cause instanceof ArtifactFileCancelled
? cause.fileCancellation.outcome.value
: cause instanceof ArtifactFileCleanupFailure &&
cause.fileCleanupFailure.outcome.kind === "completed"
? cause.fileCleanupFailure.outcome.value
: undefined;
const diagnostics = {
path,
stage: "installation_properties",
...(physical instanceof NonRegularFileReadError
? { system_code: physical.code }
: {}),
...(typeof completed !== "string"
? {}
: {
completed_properties: {
bytes: Buffer.byteLength(completed, "latin1"),
sha256: createHash("sha256")
.update(completed, "latin1")
.digest("hex"),
properties: jsonValueSchema.parse(
ghidraApplicationProperties(completed),
),
},
}),
};
throw ghidraFileFailure(cause, "ghidra:installation", diagnostics);
}
};
+5 -5
View File
@@ -14,8 +14,8 @@ import { assertArtifactReadActive } from "../../artifacts/ArtifactFileOperation.
import {
readGhidraProfileArtifact,
writeGhidraProfileArtifact,
ghidraProfileArtifactError,
} from "../GhidraProfileArtifacts.js";
ghidraFileFailure,
} from "../GhidraFileArtifacts.js";
import type { AnalysisError } from "../../domain/analysisErrorBase.js";
import { jsonObjectSchema } from "../../domain/jsonValue.js";
import { err, ok, type Result } from "../../domain/result.js";
@@ -125,7 +125,7 @@ export const resolveGhidraExtensions = async (
});
} catch (cause: unknown) {
return err(
ghidraProfileArtifactError(cause, "resolve_analysis_profile", {
ghidraFileFailure(cause, "resolve_analysis_profile", {
extension: adapter.id,
path: configured,
}),
@@ -139,7 +139,7 @@ export const resolveGhidraExtensions = async (
return ok(extensions);
})
.catch((cause: unknown) =>
err(ghidraProfileArtifactError(cause, "resolve_analysis_profile")),
err(ghidraFileFailure(cause, "resolve_analysis_profile")),
);
/** Require explicit local configuration before admitting executable code from a profile. */
@@ -239,7 +239,7 @@ export const snapshotGhidraExtensions = async (
return result;
})
.catch((cause: unknown) => {
throw ghidraProfileArtifactError(cause, "open_binary");
throw ghidraFileFailure(cause, "open_binary");
});
/** Require every configured producer result to match its artifact and registered ABI. */
+66 -29
View File
@@ -2,6 +2,9 @@ import { AnalysisError } from "../domain/analysisErrorBase.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisAccessDeniedError,
AnalysisArtifactChangedError,
AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
@@ -26,7 +29,10 @@ export const analysisClientFailureForOperation = (
if (
!(cause instanceof AnalysisCancelledError) &&
!(cause instanceof ProviderAdapterError) &&
!(cause instanceof AnalysisCapabilityUnavailableError)
!(cause instanceof AnalysisCapabilityUnavailableError) &&
!(cause instanceof AnalysisAccessDeniedError) &&
!(cause instanceof AnalysisArtifactChangedError) &&
!(cause instanceof AnalysisResourceConstraintError)
)
return cause;
const context = {
@@ -58,34 +64,65 @@ export const analysisClientFailureForOperation = (
}
: {}),
};
const result =
cause instanceof AnalysisCancelledError
? new AnalysisCancelledError(operation, {
...context,
cause: cause.cause,
})
: cause instanceof AnalysisCapabilityUnavailableError
? new AnalysisCapabilityUnavailableError(
cause.providerId,
operation,
cause.reason,
{
...context,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
retryable: cause.retryable,
},
)
: new ProviderAdapterError(providerId, operation, {
...context,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
let result: AnalysisError;
if (cause instanceof AnalysisCancelledError)
result = new AnalysisCancelledError(operation, {
...context,
cause: cause.cause,
});
else if (cause instanceof AnalysisCapabilityUnavailableError)
result = new AnalysisCapabilityUnavailableError(
cause.providerId,
operation,
cause.reason,
{
...context,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
retryable: cause.retryable,
},
);
else if (cause instanceof AnalysisAccessDeniedError)
result = new AnalysisAccessDeniedError(
operation,
cause.path,
cause.systemCode,
context,
);
else if (cause instanceof AnalysisArtifactChangedError)
result = new AnalysisArtifactChangedError(
operation,
cause.path,
cause.reason,
context,
);
else if (cause instanceof AnalysisResourceConstraintError)
result = new AnalysisResourceConstraintError(
operation,
cause.resource,
cause.reason,
cause.reportedLimits,
{
...context,
...(cause.retryable === undefined
? {}
: { retryable: cause.retryable }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
},
);
else
result = new ProviderAdapterError(providerId, operation, {
...context,
...(cause.userMessage === undefined
? {}
: { userMessage: cause.userMessage }),
...(cause.remediationAction === undefined
? {}
: { remediationAction: cause.remediationAction }),
});
return cause.cleanupIncomplete
? result.bindCleanupDependencies([cause])
: result;
@@ -0,0 +1,41 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { mkdir, truncate, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES } from "../../../src/ghidra/GhidraInstallationProperties.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.skipIf(process.platform === "win32").for(["pipe", "capacity"] as const)(
"doctor reports selected properties %s without launching Java",
async (kind, { cli }) => {
const root = await createTestTempDirectory("rea-doctor-properties-");
const install = join(root, "ghidra");
const path = join(install, "Ghidra/application.properties");
await mkdir(join(install, "Ghidra"), { recursive: true });
if (kind === "pipe") await promisify(execFile)("mkfifo", [path]);
else {
await writeFile(path, "application.version=12.1.4\n");
await truncate(path, MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES + 1);
}
const result = await cli.run({
arguments: ["doctor", "--provider", "ghidra", "--json"],
environment: {
GHIDRA_INSTALL_DIR: install,
JAVA_HOME: join(root, "missing-jdk"),
REA_LOG_LEVEL: "silent",
},
timeoutMs: 5000,
});
expect(result.exitCode, `${result.stdout}\n${result.stderr}`).toBe(1);
expect(result.signal).toBeNull();
expect(result.json).toMatchObject({
code: kind === "pipe" ? "execution_failure" : "resource_constraint",
details: {
operation: "doctor",
diagnostics: { path, stage: "installation_properties" },
},
});
},
);
@@ -0,0 +1,346 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import {
rename,
chmod,
appendFile,
symlink,
truncate,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { AnalysisError } from "../../../src/domain/analysisErrorBase.js";
import { ProviderCleanupError } from "../../../src/domain/providerCleanupError.js";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
readGhidraInstallationProperties,
MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES,
} from "../../../src/ghidra/GhidraInstallationProperties.js";
import { createInspectionResources } from "../../fixtures/inspectionResources.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const controls = vi.hoisted(() => ({
file: undefined as ((handle: FileHandle, path: string) => void) | undefined,
}));
vi.mock("node:fs/promises", async (importOriginal) => {
const actual = await importOriginal<typeof import("node:fs/promises")>();
return {
...actual,
open: async (...args: Parameters<typeof actual.open>) => {
const handle = await actual.open(...args);
controls.file?.(handle, String(args[0]));
return handle;
},
};
});
afterEach(() => {
controls.file = undefined;
vi.restoreAllMocks();
});
const properties = "application.version=12.1.4\napplication.java.min=21\n";
it("preserves a descriptor read failure instead of reporting absent metadata", async () => {
const root = await createTestTempDirectory(
"rea-install-properties-read-missing-",
);
const path = join(root, "application.properties");
await writeFile(path, properties);
const physical = Object.assign(
new Error("controlled descriptor read failure"),
{ code: "ENOENT", syscall: "read" },
);
controls.file = (handle, opened) => {
if (opened === path) vi.spyOn(handle, "read").mockRejectedValue(physical);
};
const resources = createInspectionResources();
try {
await expect(
readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
}),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
cause: physical,
diagnostics: {
path,
stage: "installation_properties",
system_code: "ENOENT",
syscall: "read",
},
});
} finally {
await resources.close();
}
});
it.skipIf(process.platform === "win32")(
"preserves Latin-1 bytes and a caller-selected properties symlink",
async () => {
const root = await createTestTempDirectory("rea-install-properties-");
const path = join(root, "actual.properties");
const selected = join(root, "selected.properties");
const bytes = Buffer.concat([
Buffer.from(properties),
Buffer.from("note=caf\xe9\n", "latin1"),
]);
await writeFile(path, bytes);
await symlink(path, selected);
const resources = createInspectionResources();
try {
expect(
await readGhidraInstallationProperties(selected, {
resourceOwner: resources.owner,
}),
).toBe(bytes.toString("latin1"));
expect(
await readGhidraInstallationProperties(join(root, "absent"), {
resourceOwner: resources.owner,
}),
).toBeUndefined();
} finally {
await resources.close();
}
},
);
it.skipIf(process.platform === "win32")(
"refuses a pipe without waiting for a writer",
async () => {
const root = await createTestTempDirectory("rea-install-properties-pipe-");
const path = join(root, "application.properties");
await promisify(execFile)("mkfifo", [path]);
const resources = createInspectionResources();
try {
await expect(
readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
}),
).rejects.toMatchObject({
_tag: "ProviderAdapterError",
diagnostics: {
path,
stage: "installation_properties",
system_code: "ENOTFILE",
},
});
} finally {
await resources.close();
}
},
);
it("refuses an oversized descriptor before reading file bytes", async () => {
const root = await createTestTempDirectory(
"rea-install-properties-capacity-",
);
const path = join(root, "application.properties");
await writeFile(path, properties);
await truncate(path, MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES + 1);
let reads = 0;
controls.file = (handle, opened) => {
if (opened === path) {
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementation(
(...args: Parameters<typeof handle.read>) => {
reads++;
return read(...args);
},
);
}
};
const resources = createInspectionResources();
try {
await expect(
readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
}),
).rejects.toMatchObject({
_tag: "AnalysisResourceConstraintError",
resource: "file-size",
retryable: false,
reportedLimits: {
maximum_bytes: MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES,
observed_bytes: String(MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES + 1),
},
diagnostics: { path, stage: "installation_properties" },
});
expect(reads).toBe(0);
} finally {
await resources.close();
}
});
it("does not open or adopt resources named by a pre-aborted opaque caller error", async () => {
const resources = createInspectionResources();
const reason = new ProviderCleanupError("foreign", ["caller-resource"], {
reason: "caller metadata",
});
const controller = new AbortController();
controller.abort(reason);
let opens = 0;
controls.file = () => {
opens++;
};
try {
await expect(
readGhidraInstallationProperties("/selected/application.properties", {
resourceOwner: resources.owner,
signal: controller.signal,
}),
).rejects.toMatchObject({ _tag: "AnalysisCancelledError", cause: reason });
expect(opens).toBe(0);
expect(reason.cleanupIncomplete).toBe(true);
} finally {
await resources.close();
}
});
it.each(["read", "capacity", "completed"] as const)(
"retains the exact descriptor and primary outcome across refused cleanup (%s)",
async (outcome) => {
const root = await createTestTempDirectory("rea-install-properties-retry-");
const path = join(root, "application.properties");
await writeFile(path, properties);
if (outcome === "capacity")
await truncate(path, MAX_GHIDRA_INSTALLATION_PROPERTIES_BYTES + 1);
const resources = createInspectionResources();
const controller = new AbortController();
const reason = { request: "cancel final metadata close" };
const physical = Object.assign(new Error("controlled metadata EIO"), {
code: "EIO",
});
let denied = true,
closes = 0;
let closeActual: (() => Promise<void>) | undefined;
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
closeActual = handle.close.bind(handle);
if (outcome === "read")
vi.spyOn(handle, "read").mockRejectedValue(physical);
vi.spyOn(handle, "close").mockImplementation(async () => {
closes++;
controller.abort(reason);
if (denied) throw new Error("controlled metadata close refusal");
await closeActual?.();
});
};
try {
const failure = await readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
signal: controller.signal,
}).catch((cause: unknown) => cause);
if (!(failure instanceof AnalysisError))
throw new Error("Expected attributed failure");
expect(failure.cleanupIncomplete).toBe(true);
expect(failure.cleanupResources).toEqual([path]);
await expect(resources.close()).rejects.toThrow();
expect(closes).toBe(2);
await rename(path, join(root, "retired.properties"));
await writeFile(path, "application.version=replacement\n");
let replacementOpens = 0;
controls.file = () => {
replacementOpens++;
};
denied = false;
await resources.close();
expect(closes).toBe(3);
expect(replacementOpens).toBe(0);
expect(failure.cleanupIncomplete).toBe(false);
if (outcome === "completed") {
expect(failure).toMatchObject({
_tag: "AnalysisCancelledError",
cause: reason,
diagnostics: {
completed_properties: {
bytes: Buffer.byteLength(properties),
properties: { providerVersion: "12.1.4" },
},
},
});
} else if (outcome === "capacity") {
expect(projectAnalysisError(failure).code).toBe("resource_constraint");
} else {
expect(failure._tag).toBe("ProviderAdapterError");
expect(failure.diagnostics).toMatchObject({ system_code: "EIO" });
expect(JSON.stringify(failure.diagnostics)).toContain(
"controlled metadata EIO",
);
}
} finally {
denied = false;
await resources.close();
await closeActual?.();
}
},
);
it.each(["replace", "grow", "shrink", "disappear"] as const)(
"rejects metadata that changes during a descriptor read (%s)",
async (change) => {
const root = await createTestTempDirectory(
"rea-install-properties-replace-",
);
const path = join(root, "application.properties");
const replacement = join(root, "replacement");
await writeFile(path, properties);
await writeFile(replacement, properties);
controls.file = (handle, opened) => {
if (opened !== path) return;
controls.file = undefined;
const read = handle.read.bind(handle);
vi.spyOn(handle, "read").mockImplementationOnce(
async (...args: Parameters<typeof handle.read>) => {
const result = await read(...args);
if (change === "replace") await rename(replacement, path);
else if (change === "disappear")
await rename(path, join(root, "retired"));
else if (change === "grow") await appendFile(path, "changed=true\n");
else await truncate(path, 1);
return result;
},
);
};
const resources = createInspectionResources();
try {
await expect(
readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
}),
).rejects.toMatchObject({ _tag: "AnalysisArtifactChangedError", path });
} finally {
await resources.close();
}
},
);
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
"reports a real metadata permission failure instead of missing installation",
async () => {
const root = await createTestTempDirectory(
"rea-install-properties-permission-",
);
const path = join(root, "application.properties");
await writeFile(path, properties);
const resources = createInspectionResources();
try {
await chmod(path, 0o000);
await expect(
readGhidraInstallationProperties(path, {
resourceOwner: resources.owner,
}),
).rejects.toMatchObject({
_tag: "AnalysisAccessDeniedError",
path,
systemCode: "EACCES",
diagnostics: { path, stage: "installation_properties" },
});
} finally {
await chmod(path, 0o600);
await resources.close();
}
},
);
@@ -8,7 +8,13 @@ import { ghidraProfileInstallationHost } from "../../../src/ghidra/GhidraInstall
import { silentLogger } from "../../../src/logger.js";
import { createDeferred } from "../../fixtures/binarySession.js";
import { STARTUP_OWNER_CLOSED } from "../../../src/process/SharedStartupAttempt.js";
import { readFile, rename, writeFile, type FileHandle } from "node:fs/promises";
import {
readFile,
rename,
truncate,
writeFile,
type FileHandle,
} from "node:fs/promises";
import { join } from "node:path";
import { afterEach, expect, it, vi } from "vitest";
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
@@ -16,6 +22,7 @@ import { parseConfig } from "../../../src/config/parseConfig.js";
import {
resolveGhidraAnalysisSeeds,
snapshotGhidraAnalysisSeeds,
MAX_GHIDRA_SEED_BYTES,
} from "../../../src/ghidra/GhidraAnalysisSeeds.js";
import {
resolveGhidraExtensions,
@@ -65,6 +72,39 @@ const configured = (
return result.value;
};
it("reports a seed extent over its resource budget without reading the payload", async () => {
const root = await createTestTempDirectory("rea-profile-capacity-");
const path = join(root, "seeds.tsv");
await writeFile(path, "0x1000\tcode\n");
await truncate(path, MAX_GHIDRA_SEED_BYTES + 1);
let reads = 0;
controls.file = (handle, opened) => {
if (opened !== path) return;
vi.spyOn(handle, "read").mockImplementation(async () => {
reads++;
throw new Error("Oversized input must not be read");
});
};
const resources = new ArtifactResourceScope();
try {
expect(await resolveGhidraAnalysisSeeds(path, resources)).toMatchObject({
ok: false,
error: {
_tag: "AnalysisResourceConstraintError",
resource: "file-size",
retryable: false,
reportedLimits: {
maximum_bytes: MAX_GHIDRA_SEED_BYTES,
observed_bytes: String(MAX_GHIDRA_SEED_BYTES + 1),
},
},
});
expect(reads).toBe(0);
} finally {
await resources.close();
}
});
it.each(
(["seed", "extension"] as const).flatMap((kind) =>
[false, true].map((lateAbort) => ({ kind, lateAbort })),