diff --git a/src/domain/analysisErrorPresentation.ts b/src/domain/analysisErrorPresentation.ts index cc3dc7e40..db00c41cc 100644 --- a/src/domain/analysisErrorPresentation.ts +++ b/src/domain/analysisErrorPresentation.ts @@ -74,7 +74,10 @@ export const analysisErrorRemediationAction = ( error.constraint === "directory_requires_file" ) return "For a JavaScript/Electron application directory, call analyze_javascript_application with input_path or run `rea analyze `. For binary analysis, select its executable file."; - if (error instanceof AnalysisAccessDeniedError) + if ( + error instanceof AnalysisAccessDeniedError || + (error instanceof BinaryTargetError && error.systemCode !== undefined) + ) return "Check the current process's read access to the selected path. Retry with a readable local file."; if (error instanceof AnalysisArtifactChangedError) return "Wait until the selected file is stable. For an active binary session, reopen the target with open_binary before retrying so REA acquires its current identity; for a CLI command or target-free tool, rerun the operation."; diff --git a/src/domain/analysisErrorProjection.test.ts b/src/domain/analysisErrorProjection.test.ts index 8177b08ab..96525d518 100644 --- a/src/domain/analysisErrorProjection.test.ts +++ b/src/domain/analysisErrorProjection.test.ts @@ -439,6 +439,42 @@ describe("analysis error projection: caller contract", () => { expect(JSON.stringify(projected)).not.toContain("secret-token"); }); + it.each(["EACCES", "EPERM"] as const)( + "reports a target the host refused to read (%s) as access_denied", + (systemCode) => { + const denied = Object.assign(new Error("denied"), { code: systemCode }); + const reason = `permission denied while reading target: ${systemCode}`; + expect( + projectAnalysisError( + new BinaryTargetError("/local/targets/app", reason, { + cause: denied, + }), + ), + ).toMatchObject({ + code: "access_denied", + category: "unavailable", + retryable: false, + remediation: { + action: + "Check the current process's read access to the selected path. Retry with a readable local file.", + }, + details: { + path: "/local/targets/app", + reason, + system_code: systemCode, + boundary: "filesystem-read", + }, + }); + expect( + projectAnalysisError( + new BinaryTargetError("/local/targets/app", "missing", { + cause: Object.assign(new Error("missing"), { code: "ENOENT" }), + }), + ), + ).toMatchObject({ code: "target_unavailable" }); + }, + ); + it("uses explicit capability recovery while retaining the constraint", () => { const projected = projectAnalysisError( new AnalysisCapabilityUnavailableError( diff --git a/src/domain/analysisErrorProjection.ts b/src/domain/analysisErrorProjection.ts index 66e5c77e9..e0419c84a 100644 --- a/src/domain/analysisErrorProjection.ts +++ b/src/domain/analysisErrorProjection.ts @@ -101,6 +101,10 @@ const underlyingErrorCode = ( : browserErrorCode(error.reason); if (error instanceof ArtifactOperationError) return artifactOperationCode(error); + if (error instanceof BinaryTargetError) + return error.systemCode === undefined + ? "target_unavailable" + : "access_denied"; if (error instanceof EvidenceFileError) return evidenceFileCode(error.reason); if (error instanceof UnknownRegistryError) return unknownRegistryCode(error.reason); @@ -162,6 +166,7 @@ const processCaptureCode = ( type SpecializedErrorTag = | "ArtifactOperationError" + | "BinaryTargetError" | "BrowserObservationError" | "EvidenceFileError" | "ProcessCaptureError" @@ -186,7 +191,6 @@ const STATIC_ERROR_CODES = { HopperStartError: "provider_unavailable", ConfigurationError: "configuration_invalid", NoBinaryOpenError: "target_unavailable", - BinaryTargetError: "target_unavailable", EvidenceIntegrityError: "evidence_integrity_mismatch", ProviderAdapterError: "execution_failure", HopperRemoteError: "execution_failure", @@ -202,6 +206,7 @@ const staticErrorCode = ( ): AnalysisErrorProjection["code"] => { switch (tag) { case "ArtifactOperationError": + case "BinaryTargetError": case "BrowserObservationError": case "EvidenceFileError": case "ProcessCaptureError": @@ -489,6 +494,9 @@ const lifecycleErrorDetails = ( ...(error.constraint === undefined ? {} : { constraint: error.constraint }), + ...(error.systemCode === undefined + ? {} + : { system_code: error.systemCode, boundary: "filesystem-read" }), }; return undefined; }; diff --git a/src/domain/configurationErrors.ts b/src/domain/configurationErrors.ts index c7c07f75e..d1c89d03c 100644 --- a/src/domain/configurationErrors.ts +++ b/src/domain/configurationErrors.ts @@ -27,6 +27,8 @@ export interface BinaryTargetErrorOptions extends AnalysisErrorOptions { export class BinaryTargetError extends AnalysisError { readonly _tag = "BinaryTargetError"; readonly constraint: BinaryTargetErrorOptions["constraint"]; + /** The OS read-permission code of the cause, when the host denied access. */ + readonly systemCode: "EACCES" | "EPERM" | undefined; constructor( readonly path: string, readonly reason: string, @@ -34,5 +36,12 @@ export class BinaryTargetError extends AnalysisError { ) { super(`Cannot open artifact: ${reason}`, options); this.constraint = options?.constraint; + this.systemCode = readPermissionCode(options?.cause); } } + +const readPermissionCode = (cause: unknown): "EACCES" | "EPERM" | undefined => { + const code: unknown = + cause instanceof Error ? Reflect.get(cause, "code") : undefined; + return code === "EACCES" || code === "EPERM" ? code : undefined; +}; diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index fc5a4d0d2..df3733d51 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -90,10 +90,11 @@ describe.skipIf(process.platform !== "darwin")( }); expect(result.exitCode).toBe(1); expect(result.json).toMatchObject({ - code: "target_unavailable", + code: "access_denied", details: { path: locked, reason: "permission denied while reading plist (EACCES)", + system_code: "EACCES", }, }); }, diff --git a/tests/boundary/filesystem/binaryTarget.test.ts b/tests/boundary/filesystem/binaryTarget.test.ts index e9da6d234..54a70e00a 100644 --- a/tests/boundary/filesystem/binaryTarget.test.ts +++ b/tests/boundary/filesystem/binaryTarget.test.ts @@ -22,6 +22,7 @@ import { pe, thinMach, } from "../../../src/domain/binaryTarget.fixture.js"; +import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js"; import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; describe("binary target I/O: app plist permissions and decoding", () => { @@ -48,6 +49,7 @@ describe("binary target I/O: app plist permissions and decoding", () => { path: join(app, "Contents", "Info.plist"), reason: expect.stringContaining(message), cause: denied, + systemCode: code, }, }); }); @@ -232,6 +234,28 @@ describe("binary target I/O: filesystem and app bundle target resolution", () => expect((await parseBinaryTarget("missing", directory)).ok).toBe(false); }); + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( + "reports an unreadable target as a host read denial", + async () => { + const directory = await createTestTempDirectory("rea-target-denied-"); + const path = join(directory, "unreadable"); + await writeFile(path, thinMach(0xcffaedfe, 0x0100000c)); + await chmod(path, 0); + try { + const result = await parseBinaryTarget(path, directory, "arm64"); + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toMatchObject({ path, systemCode: "EACCES" }); + expect(projectAnalysisError(result.error)).toMatchObject({ + code: "access_denied", + details: { path, system_code: "EACCES" }, + }); + } finally { + await chmod(path, 0o600); + } + }, + ); + it("rejects non-regular targets before reading them", async () => { const directory = await createTestTempDirectory("rea-target-"); expect((await parseBinaryTarget(directory)).ok).toBe(false);