From 0cfef92ae115a8994892aa821bd2176255f9affa Mon Sep 17 00:00:00 2001 From: Kaoru0822 Date: Tue, 20 Oct 2026 22:54:40 -0700 Subject: [PATCH] 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 --- src/artifacts/ArtifactOperationFailure.ts | 8 ++- src/artifacts/DesktopApplicationReader.ts | 40 ++++++++++++- src/artifacts/DirectoryArtifactReader.ts | 27 +++++++-- .../cli/desktopApplicationCli.test.ts | 59 ++++++++++++++++++- 4 files changed, 125 insertions(+), 9 deletions(-) diff --git a/src/artifacts/ArtifactOperationFailure.ts b/src/artifacts/ArtifactOperationFailure.ts index f6630ba9a0..13a7adda0e 100644 --- a/src/artifacts/ArtifactOperationFailure.ts +++ b/src/artifacts/ArtifactOperationFailure.ts @@ -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"); diff --git a/src/artifacts/DesktopApplicationReader.ts b/src/artifacts/DesktopApplicationReader.ts index c9f7ee879e..02d8fcbfa5 100644 --- a/src/artifacts/DesktopApplicationReader.ts +++ b/src/artifacts/DesktopApplicationReader.ts @@ -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, diff --git a/src/artifacts/DirectoryArtifactReader.ts b/src/artifacts/DirectoryArtifactReader.ts index 6a95213956..764915923a 100644 --- a/src/artifacts/DirectoryArtifactReader.ts +++ b/src/artifacts/DirectoryArtifactReader.ts @@ -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 }, diff --git a/tests/boundary/cli/desktopApplicationCli.test.ts b/tests/boundary/cli/desktopApplicationCli.test.ts index c0f4a6e3e8..52026748e6 100644 --- a/tests/boundary/cli/desktopApplicationCli.test.ts +++ b/tests/boundary/cli/desktopApplicationCli.test.ts @@ -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); + } + }, +);