Merge pull request #1197 from morluto/fix/target-admission-access-denied
fix(domain): report target read-permission denials as access_denied
This commit is contained in:
commit
f36c10f9e8
|
|
@ -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 <directory>`. 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.";
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
import { chmod, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { expect, it } from "vitest";
|
||||
|
||||
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
|
||||
import { projectAnalysisError } from "../domain/analysisErrorProjection.js";
|
||||
import { resolveHopperMachOImage } from "./HopperMachOImage.js";
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"reports a FAT64 source that became unreadable as a host read denial",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-hopper-fat64-");
|
||||
const path = join(directory, "universal");
|
||||
await writeFile(path, Buffer.alloc(64));
|
||||
await chmod(path, 0);
|
||||
try {
|
||||
const result = await resolveHopperMachOImage({
|
||||
path,
|
||||
sha256: "0".repeat(64),
|
||||
kind: "executable",
|
||||
format: "mach-o",
|
||||
architecture: "arm64",
|
||||
availableArchitectures: ["arm64"],
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) return;
|
||||
expect(result.error).toMatchObject({ systemCode: "EACCES" });
|
||||
expect(projectAnalysisError(result.error)).toMatchObject({
|
||||
code: "access_denied",
|
||||
details: { path, system_code: "EACCES" },
|
||||
});
|
||||
} finally {
|
||||
await chmod(path, 0o600);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -35,15 +35,23 @@ export const resolveHopperMachOImage = async (
|
|||
): Promise<
|
||||
Result<HopperMachOImage, BinaryTargetError | AnalysisCancelledError>
|
||||
> => {
|
||||
const invalid = (reason: string) =>
|
||||
err(new BinaryTargetError(target.path, reason));
|
||||
const invalid = (reason: string, cause?: unknown) =>
|
||||
err(
|
||||
new BinaryTargetError(
|
||||
target.path,
|
||||
reason,
|
||||
cause === undefined ? undefined : { cause },
|
||||
),
|
||||
);
|
||||
if (signal?.aborted) return err(new AnalysisCancelledError("open_binary"));
|
||||
let file: FileHandle;
|
||||
try {
|
||||
file = await open(target.path, "r");
|
||||
} catch (cause: unknown) {
|
||||
// Keep the OS error so a read-permission denial reports access_denied.
|
||||
return invalid(
|
||||
`Cannot read FAT64 source for Hopper image preparation: ${cause instanceof Error ? cause.message : String(cause)}`,
|
||||
cause,
|
||||
);
|
||||
}
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue