From 0674facd468c3321584ece908031feee7220b9c6 Mon Sep 17 00:00:00 2001 From: Iwakurakaoru0822 Date: Tue, 6 Oct 2026 23:16:13 -0700 Subject: [PATCH] fix(native): report why a selected plist cannot be inspected `inspect_plist` turned a missing selected plist, a directory, or a file that is not a property list into a generic execution failure that advised retrying and running `rea doctor`. A bare Mach-O without Contents/Info.plist failed the same way. Check the resolved path before running plutil and keep plutil's decode diagnostic. A caller-selected path now fails as invalid input naming the path and the reason; a missing default Info.plist is unavailable for the target. The CLI no longer sends Contents/Info.plist as an explicit selection, so its default matches the MCP default. Co-Authored-By: Claude Opus 5.5 --- src/cli/utilityCommands.ts | 10 +- src/native/NativeMacOSProvider.ts | 64 ++++++++++++- .../nativePlistSelectionFailureCli.test.ts | 93 +++++++++++++++++++ 3 files changed, 163 insertions(+), 4 deletions(-) create mode 100644 tests/boundary/cli/nativePlistSelectionFailureCli.test.ts diff --git a/src/cli/utilityCommands.ts b/src/cli/utilityCommands.ts index de5d51aad..c54f20589 100644 --- a/src/cli/utilityCommands.ts +++ b/src/cli/utilityCommands.ts @@ -135,8 +135,10 @@ const registerNativeCommands = (cli: CliInstance, logger: Logger): void => { options: z.object({ relativePath: z .string() - .default("Contents/Info.plist") - .describe("Plist path relative to the app root"), + .optional() + .describe( + "Plist path relative to the app root (default: Contents/Info.plist)", + ), }), alias: { relativePath: "relative-path" }, run: ({ args, options }) => @@ -144,7 +146,9 @@ const registerNativeCommands = (cli: CliInstance, logger: Logger): void => { runProviderAnalysis( args.path, "inspect_plist", - { path: options.relativePath }, + options.relativePath === undefined + ? {} + : { path: options.relativePath }, logger, ), ), diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 57a9fa0fb..9dd3bdfd8 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -28,6 +28,7 @@ import type { EvidenceLocation } from "../domain/evidence.js"; import { AnalysisCancelledError, AnalysisCapabilityUnavailableError, + AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; @@ -419,6 +420,8 @@ class NativeMacOSClient implements AnalysisClient { ); const plist = await resolvePlistPath(this.target, requested); if (!plist.ok) return plist; + const selected = await requireRegularPlist(plist.value, requested); + if (!selected.ok) return selected; const classified = await this.#run( "inspect_plist", "file", @@ -445,9 +448,17 @@ class NativeMacOSClient implements AnalysisClient { "inspect_plist", "plutil", ["-convert", "xml1", "-o", "-", "--", plist.value], - { signal }, + { signal, acceptNonZero: !jsonDecoded }, ); if (xml !== undefined && !xml.ok) return xml; + if (xml !== undefined && xml.value.exitCode !== 0) + return err( + unreadablePlist( + plist.value, + requested, + `plutil could not decode it as a property list (${plutilDiagnostic(xml.value.stderr, plist.value)})`, + ), + ); const parsed = xml === undefined ? parsePlistJson(json.value.stdout) @@ -637,6 +648,57 @@ const parseEntitlements = (output: string): JsonValue | null => { ); }; +/** + * Report a plist that cannot be decoded as the caller's selection, or as the + * target's missing default, rather than as a failed tool run. + */ +const unreadablePlist = ( + path: string, + requested: string | undefined, + reason: string, +): AnalysisError => { + const sentence = reason.endsWith(".") ? reason : `${reason}.`; + return requested === undefined + ? new AnalysisCapabilityUnavailableError( + IDENTITY.id, + "inspect_plist", + `The target has no readable Contents/Info.plist at ${path}: ${sentence} Pass path to inspect another plist.`, + ) + : new AnalysisInputError("inspect_plist", undefined, [ + { + path: ["path"], + reason: "invalid_value", + message: `Cannot inspect ${path}: ${sentence}`, + }, + ]); +}; + +/** plutil prefixes its diagnostic with the path the message already names. */ +const plutilDiagnostic = (stderr: string, path: string): string => { + const text = stderr.trim(); + return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text; +}; + +const requireRegularPlist = async ( + path: string, + requested: string | undefined, +): Promise> => { + try { + if ((await stat(path)).isFile()) return ok(null); + return err(unreadablePlist(path, requested, "it is not a regular file")); + } catch (cause: unknown) { + if ( + cause instanceof Error && + "code" in cause && + (cause.code === "ENOENT" || cause.code === "ENOTDIR") + ) + return err(unreadablePlist(path, requested, "no file exists there")); + return err( + new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), + ); + } +}; + const resolvePlistPath = async ( target: BinaryTarget, requested: string | undefined, diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts new file mode 100644 index 000000000..a1e194e84 --- /dev/null +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -0,0 +1,93 @@ +import { mkdir, writeFile } from "node:fs/promises"; +import { join } from "node:path"; + +import { describe, expect } from "vitest"; + +import { thinMach } from "../../../src/domain/binaryTarget.fixture.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; +import { cliTest } from "../../support/cli/cliFixture.js"; + +const environment = { + REA_LOG_LEVEL: "silent", + REA_ANALYSIS_PROVIDER: "auto", +} as const; + +/** An app bundle whose Contents holds a directory and a non-plist file. */ +const fixtureApp = async () => { + const app = join(await createTestTempDirectory("rea-plist-fail-"), "A.app"); + const contents = join(app, "Contents"); + await mkdir(join(contents, "MacOS"), { recursive: true }); + await mkdir(join(contents, "Folder.plist")); + await writeFile(join(contents, "MacOS/App"), thinMach(0xfeedfacf, 0x100000c)); + await writeFile( + join(contents, "Info.plist"), + "CFBundleExecutableApp", + ); + await writeFile(join(contents, "Text.plist"), "not a property list\n"); + return app; +}; + +describe.skipIf(process.platform !== "darwin")( + "native plist CLI selection failures", + () => { + cliTest.for([ + ["Contents/Missing.plist", "no file exists there."], + ["Contents/Folder.plist", "it is not a regular file."], + ["Contents/Text.plist", "plutil could not decode it as a property list"], + ] as const)( + "reports why the selected %s cannot be inspected", + async ([selected, reason], { cli }) => { + const app = await fixtureApp(); + const result = await cli.run({ + arguments: [ + "inspect-plist", + app, + "--relative-path", + selected, + "--json", + ], + environment, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "invalid_request", + details: { + operation: "inspect_plist", + issues: [ + { + path: ["path"], + reason: "invalid_value", + message: expect.stringContaining( + `Cannot inspect ${join(app, selected)}: ${reason}`, + ), + }, + ], + }, + }); + }, + ); + + cliTest( + "reports a missing default Info.plist as unavailable for the target", + async ({ cli }) => { + const directory = await createTestTempDirectory("rea-plist-bare-"); + const program = join(directory, "tool"); + await writeFile(program, thinMach(0xfeedfacf, 0x100000c)); + const result = await cli.run({ + arguments: ["inspect-plist", program, "--json"], + environment, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "capability_unavailable", + details: { + operation: "inspect_plist", + reason: expect.stringContaining( + `no readable Contents/Info.plist at ${join(directory, "Contents/Info.plist")}: no file exists there.`, + ), + }, + }); + }, + ); + }, +);