mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
fix(desktop): classify rejected desktop roots as target failures (#5577)
inspect-desktop-application reported a regular-file, missing or read-denied root as artifact_operation_failed/execution_failure, leaving the ENOTDIR, ENOENT or EACCES cause only in message text. DirectoryArtifactReader now marks root admission and root-open failures with DirectoryArtifactRootFailure. DesktopApplicationReader, which owns the caller's selection, maps a non-directory root to unsupported_target and host failures to the shared BinaryTargetError (target_unavailable, or access_denied with read-access recovery), keeping any partial observation. Child-entry failures and other DirectoryArtifactReader consumers are unchanged. Fixes #5513 Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b81431d7ff
commit
0cfef92ae1
4 files changed
+125
-9
No files matched your search
@@ -5,6 +5,7 @@ import {
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import { ArtifactOperationError } from "../domain/artifactOperationError.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { ArtifactReaderFailure } from "./ArtifactReader.js";
|
||||
import {
|
||||
artifactFileCancellationCause,
|
||||
@@ -36,12 +37,13 @@ export const translateArtifactFailure = (
|
||||
bindArtifactFileCleanup(cause, error);
|
||||
return error;
|
||||
}
|
||||
// Caller-selection and unsupported-target failures are already typed; keep
|
||||
// their correction details instead of reducing them to an I/O failure.
|
||||
// Caller-selection, target and unsupported-target failures are already typed;
|
||||
// keep their correction details instead of reducing them to an I/O failure.
|
||||
if (
|
||||
cause instanceof AnalysisInputError ||
|
||||
cause instanceof AnalysisCancelledError ||
|
||||
cause instanceof AnalysisUnsupportedTargetError
|
||||
cause instanceof AnalysisUnsupportedTargetError ||
|
||||
cause instanceof BinaryTargetError
|
||||
)
|
||||
return cause;
|
||||
return new ArtifactOperationError(operation, "io");
|
||||
|
||||
@@ -2,6 +2,7 @@ import { desktopPathConventions } from "../domain/nativeConvention.js";
|
||||
import { isAbsolute } from "node:path";
|
||||
import {
|
||||
DirectoryArtifactReader,
|
||||
DirectoryArtifactRootFailure,
|
||||
DirectoryArtifactTraversalLimit,
|
||||
type DirectoryArtifactEntry,
|
||||
} from "./DirectoryArtifactReader.js";
|
||||
@@ -33,7 +34,15 @@ import { digestCanonicalValue } from "../domain/canonicalDigest.js";
|
||||
import { err, ok } from "../domain/result.js";
|
||||
import { createDesktopApplicationEvidence } from "../domain/desktopApplicationEvidence.js";
|
||||
import { ARTIFACT_GRAPH_PROVIDER } from "../application/InvestigationProviders.js";
|
||||
import { AnalysisResourceConstraintError } from "../domain/analysisErrorCore.js";
|
||||
import {
|
||||
AnalysisResourceConstraintError,
|
||||
AnalysisUnsupportedTargetError,
|
||||
} from "../domain/analysisErrorCore.js";
|
||||
import type {
|
||||
AnalysisError,
|
||||
AnalysisPartialObservation,
|
||||
} from "../domain/analysisErrorBase.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
|
||||
const RETAINED_METADATA_BYTES = 32 * 1024 * 1024;
|
||||
|
||||
@@ -141,6 +150,8 @@ export class DesktopApplicationReader implements DesktopApplicationPort {
|
||||
throw this.#resources.cleanupFailure(outcome, [closed], partial);
|
||||
if (outcome.kind === "failed") {
|
||||
const cause = outcome.cause;
|
||||
if (cause instanceof DirectoryArtifactRootFailure)
|
||||
throw selectedRootFailure(input.path, cause, partial);
|
||||
if (!(cause instanceof ArtifactReaderFailure) || partial === undefined)
|
||||
throw cause;
|
||||
throw new ArtifactReaderFailure(
|
||||
@@ -167,6 +178,33 @@ export class DesktopApplicationReader implements DesktopApplicationPort {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The root is the caller's selection: a non-directory is an unsupported
|
||||
* target, and host failures keep the shared target-unavailable/access-denied
|
||||
* classification instead of becoming artifact I/O faults.
|
||||
*/
|
||||
const selectedRootFailure = (
|
||||
path: string,
|
||||
failure: DirectoryArtifactRootFailure,
|
||||
partialObservation: AnalysisPartialObservation | undefined,
|
||||
): AnalysisError =>
|
||||
failure.reason === "path"
|
||||
? new AnalysisUnsupportedTargetError(
|
||||
"inspect_desktop_application",
|
||||
path,
|
||||
failure.message,
|
||||
{
|
||||
cause: failure,
|
||||
partialObservation,
|
||||
remediationAction:
|
||||
"Select the application's local installation directory, such as a macOS .app bundle or the directory containing its executable.",
|
||||
},
|
||||
)
|
||||
: new BinaryTargetError(path, failure.message, {
|
||||
cause: failure.cause,
|
||||
partialObservation,
|
||||
});
|
||||
|
||||
const scanDirectory = async (
|
||||
reader: DirectoryArtifactReader,
|
||||
input: InspectDesktopApplicationInput,
|
||||
|
||||
@@ -60,6 +60,12 @@ export class DirectoryArtifactTraversalLimit extends ArtifactReaderFailure {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The selected root could not be admitted or opened. The consumer that owns
|
||||
* the caller's selection classifies it; `cause` retains the host failure.
|
||||
*/
|
||||
export class DirectoryArtifactRootFailure extends ArtifactReaderFailure {}
|
||||
|
||||
/** Non-symlink-following directory reader. */
|
||||
export class DirectoryArtifactReader implements ArtifactReader {
|
||||
readonly format = "directory" as const;
|
||||
@@ -133,7 +139,7 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
"Artifact root changed during traversal",
|
||||
);
|
||||
if (!metadata.isDirectory())
|
||||
throw new ArtifactReaderFailure(
|
||||
throw new DirectoryArtifactRootFailure(
|
||||
"path",
|
||||
"Artifact root must be a directory",
|
||||
);
|
||||
@@ -143,7 +149,12 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
});
|
||||
})().catch((cause: unknown) => {
|
||||
if (cause instanceof ArtifactReaderFailure) throw cause;
|
||||
throw directoryIoFailure("resolve artifact root", this.#rootPath, cause);
|
||||
throw directoryIoFailure(
|
||||
"resolve artifact root",
|
||||
this.#rootPath,
|
||||
cause,
|
||||
DirectoryArtifactRootFailure,
|
||||
);
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -158,7 +169,14 @@ export class DirectoryArtifactReader implements ArtifactReader {
|
||||
const root = admittedRoot.canonicalPath;
|
||||
const handle = new OwnedDirectoryHandle(
|
||||
await opendir(directory).catch((cause: unknown) => {
|
||||
throw directoryIoFailure("open directory", directory, cause);
|
||||
throw directoryIoFailure(
|
||||
"open directory",
|
||||
directory,
|
||||
cause,
|
||||
directory === root
|
||||
? DirectoryArtifactRootFailure
|
||||
: ArtifactReaderFailure,
|
||||
);
|
||||
}),
|
||||
);
|
||||
this.#directories.add(handle);
|
||||
@@ -484,6 +502,7 @@ const directoryIoFailure = (
|
||||
operation: string,
|
||||
path: string,
|
||||
cause: unknown,
|
||||
Failure: typeof ArtifactReaderFailure = ArtifactReaderFailure,
|
||||
): ArtifactReaderFailure => {
|
||||
if (cause instanceof ArtifactReaderFailure) return cause;
|
||||
const code =
|
||||
@@ -494,7 +513,7 @@ const directoryIoFailure = (
|
||||
? ` (${cause.code})`
|
||||
: "";
|
||||
const detail = failureMessage(cause);
|
||||
return new ArtifactReaderFailure(
|
||||
return new Failure(
|
||||
"io",
|
||||
`Could not ${operation} at ${path}${code}: ${detail}`,
|
||||
{ cause },
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { readEvidenceBundle } from "../../../src/application/EvidenceBundleFiles.js";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { expect } from "vitest";
|
||||
import { cliTest } from "../../support/cli/cliFixture.js";
|
||||
@@ -109,3 +109,60 @@ cliTest(
|
||||
expect(overwrite.exitCode).not.toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
cliTest(
|
||||
"refuses a non-directory or missing root as a target selection",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-cli-root-");
|
||||
const file = join(root, "regular-file");
|
||||
await writeFile(file, "fixture\n");
|
||||
const notDirectory = await cli.run({
|
||||
arguments: ["inspect-desktop-application", file, "--json"],
|
||||
});
|
||||
expect(notDirectory.exitCode).toBe(1);
|
||||
expect(notDirectory.json).toMatchObject({
|
||||
code: "unsupported_target",
|
||||
category: "unsupported_target",
|
||||
details: { operation: "inspect_desktop_application", path: file },
|
||||
});
|
||||
const missingPath = join(root, "missing-directory");
|
||||
const missing = await cli.run({
|
||||
arguments: ["inspect-desktop-application", missingPath, "--json"],
|
||||
});
|
||||
expect(missing.exitCode).toBe(1);
|
||||
expect(missing.json).toMatchObject({
|
||||
code: "target_unavailable",
|
||||
category: "unavailable",
|
||||
details: { path: missingPath, reason: expect.stringContaining("ENOENT") },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
cliTest.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"reports a read-denied root as host access denial with read-access recovery",
|
||||
async ({ cli }) => {
|
||||
const root = await createTestTempDirectory("rea-desktop-cli-denied-");
|
||||
const locked = join(root, "locked-directory");
|
||||
await mkdir(locked);
|
||||
await chmod(locked, 0o000);
|
||||
try {
|
||||
const denied = await cli.run({
|
||||
arguments: ["inspect-desktop-application", locked, "--json"],
|
||||
});
|
||||
expect(denied.exitCode).toBe(1);
|
||||
expect(denied.json).toMatchObject({
|
||||
code: "access_denied",
|
||||
category: "unavailable",
|
||||
remediation: { action: expect.stringContaining("read access") },
|
||||
details: {
|
||||
path: locked,
|
||||
system_code: "EACCES",
|
||||
boundary: "filesystem-read",
|
||||
partial_observation: expect.any(Object),
|
||||
},
|
||||
});
|
||||
} finally {
|
||||
await chmod(locked, 0o700);
|
||||
}
|
||||
},
|
||||
);
|
||||
Reference in new issue
Block a user