mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
c72786f289
commit
5112fe07ae
14 files changed
+825
-91
No files matched your search
@@ -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) => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
@@ -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. */
|
||||
|
||||
@@ -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 })),
|
||||
|
||||
Reference in new issue
Block a user