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:
Kaoru0822andClaude Opus 5.5 authored and GitHub committed 2026-10-20 22:54:40 -07:00
1 parent b81431d7ff
commit 0cfef92ae1
4 files changed
+125 -9

No files matched your search

+5 -3
View File
@@ -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");
+39 -1
View File
@@ -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,
+23 -4
View File
@@ -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);
}
},
);