diff --git a/docs/installation.md b/docs/installation.md index 08eace3070..57eba7949d 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -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. diff --git a/src/application/binary/DeferredAnalysisClient.test.ts b/src/application/binary/DeferredAnalysisClient.test.ts index 861df20a35..740273dc64 100644 --- a/src/application/binary/DeferredAnalysisClient.test.ts +++ b/src/application/binary/DeferredAnalysisClient.test.ts @@ -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 = () => { let resolve!: (value: T) => void; const promise = new Promise((finish) => { diff --git a/src/artifacts/ArtifactFileOperation.ts b/src/artifacts/ArtifactFileOperation.ts index a7d49754ee..72576ef997 100644 --- a/src/artifacts/ArtifactFileOperation.ts +++ b/src/artifacts/ArtifactFileOperation.ts @@ -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 ( path: string, - resources: ArtifactResourceScope, + resources: { + retain( + owner: Extract, + ): void; + }, signal: AbortSignal | undefined, operation: (file: FileHandle) => Promise, symlinks: "follow" | "reject" = "reject", diff --git a/src/filesystem/RegularFile.ts b/src/filesystem/RegularFile.ts index 8b83f936a5..e5850f532e 100644 --- a/src/filesystem/RegularFile.ts +++ b/src/filesystem/RegularFile.ts @@ -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"; } } diff --git a/src/filesystem/readStableFileBytes.ts b/src/filesystem/readStableFileBytes.ts new file mode 100644 index 0000000000..882a430619 --- /dev/null +++ b/src/filesystem/readStableFileBytes.ts @@ -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 => { + 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; +}; diff --git a/src/ghidra/GhidraAnalysisSeeds.ts b/src/ghidra/GhidraAnalysisSeeds.ts index 5dc6ba2d66..d1819e35ab 100644 --- a/src/ghidra/GhidraAnalysisSeeds.ts +++ b/src/ghidra/GhidraAnalysisSeeds.ts @@ -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, }); diff --git a/src/ghidra/GhidraProfileArtifacts.ts b/src/ghidra/GhidraFileArtifacts.ts similarity index 58% rename from src/ghidra/GhidraProfileArtifacts.ts rename to src/ghidra/GhidraFileArtifacts.ts index 3527992eda..ff30de6cc4 100644 --- a/src/ghidra/GhidraProfileArtifacts.ts +++ b/src/ghidra/GhidraFileArtifacts.ts @@ -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 = ( interpret: (bytes: Buffer) => Value, ): Promise => 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> = {}, ): 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); diff --git a/src/ghidra/GhidraInstallation.ts b/src/ghidra/GhidraInstallation.ts index 0b351da5b6..090dfee35f 100644 --- a/src/ghidra/GhidraInstallation.ts +++ b/src/ghidra/GhidraInstallation.ts @@ -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; 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 { diff --git a/src/ghidra/GhidraInstallationProperties.ts b/src/ghidra/GhidraInstallationProperties.ts new file mode 100644 index 0000000000..7562cd39a0 --- /dev/null +++ b/src/ghidra/GhidraInstallationProperties.ts @@ -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 => { + 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); + } +}; diff --git a/src/ghidra/extensions/GhidraExtensions.ts b/src/ghidra/extensions/GhidraExtensions.ts index 8d188ec723..890ead74c3 100644 --- a/src/ghidra/extensions/GhidraExtensions.ts +++ b/src/ghidra/extensions/GhidraExtensions.ts @@ -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. */ diff --git a/src/providers/AnalysisClientFailure.ts b/src/providers/AnalysisClientFailure.ts index 5cb04cc269..e002450913 100644 --- a/src/providers/AnalysisClientFailure.ts +++ b/src/providers/AnalysisClientFailure.ts @@ -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; diff --git a/tests/boundary/cli/ghidraInstallationProperties.test.ts b/tests/boundary/cli/ghidraInstallationProperties.test.ts new file mode 100644 index 0000000000..5f68cb0b17 --- /dev/null +++ b/tests/boundary/cli/ghidraInstallationProperties.test.ts @@ -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" }, + }, + }); + }, +); diff --git a/tests/boundary/filesystem/ghidraInstallationProperties.test.ts b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts new file mode 100644 index 0000000000..7cf0f4777a --- /dev/null +++ b/tests/boundary/filesystem/ghidraInstallationProperties.test.ts @@ -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(); + return { + ...actual, + open: async (...args: Parameters) => { + 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) => { + 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) | 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) => { + 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(); + } + }, +); diff --git a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts index 652d33b847..7d847495d2 100644 --- a/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts +++ b/tests/boundary/filesystem/ghidraProfileArtifactOwnership.test.ts @@ -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 })),