diff --git a/src/native/NativeMacOSProvider.ts b/src/native/NativeMacOSProvider.ts index 9dd3bdfd8..ec22fa1dd 100644 --- a/src/native/NativeMacOSProvider.ts +++ b/src/native/NativeMacOSProvider.ts @@ -6,7 +6,7 @@ export { NATIVE_MACOS_PROVIDER_IDENTITY } from "./NativeMacOSProviderMetadata.js import { inspectAppleDispatchMetadata } from "./AppleDispatchMetadata.js"; import { observeNativeUi } from "./NativeUiObservation.js"; import { dirname, isAbsolute, resolve } from "node:path"; -import { realpath, stat } from "node:fs/promises"; +import { open, realpath, stat } from "node:fs/promises"; import { parse as parseXmlPlist } from "plist"; import { z } from "zod"; @@ -31,6 +31,7 @@ import { AnalysisInputError, AnalysisOutputError, } from "../domain/analysisErrorCore.js"; +import { BinaryTargetError } from "../domain/configurationErrors.js"; import { ProviderAdapterError } from "../domain/providerAdapterError.js"; import { AnalysisError } from "../domain/analysisErrorBase.js"; import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js"; @@ -679,20 +680,33 @@ const plutilDiagnostic = (stderr: string, path: string): string => { return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text; }; +/** + * Admit a readable regular file before plutil runs, so a later plutil failure + * reflects the bytes rather than a missing path or a host permission denial. + */ 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")); + if (!(await stat(path)).isFile()) + return err(unreadablePlist(path, requested, "it is not a regular file")); + // Opening also observes ACL and macOS privacy denials that stat permits. + await (await open(path, "r")).close(); + return ok(null); } catch (cause: unknown) { - if ( - cause instanceof Error && - "code" in cause && - (cause.code === "ENOENT" || cause.code === "ENOTDIR") - ) + const code = + cause instanceof Error && "code" in cause ? cause.code : undefined; + if (code === "ENOENT" || code === "ENOTDIR") return err(unreadablePlist(path, requested, "no file exists there")); + if (code === "EACCES" || code === "EPERM") + return err( + new BinaryTargetError( + path, + `permission denied while reading plist (${code})`, + { cause }, + ), + ); return err( new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }), ); diff --git a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts index a1e194e84..fc5a4d0d2 100644 --- a/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts +++ b/tests/boundary/cli/nativePlistSelectionFailureCli.test.ts @@ -1,4 +1,4 @@ -import { mkdir, writeFile } from "node:fs/promises"; +import { chmod, mkdir, writeFile } from "node:fs/promises"; import { join } from "node:path"; import { describe, expect } from "vitest"; @@ -67,6 +67,38 @@ describe.skipIf(process.platform !== "darwin")( }, ); + // Root bypasses file modes, so the denial is only observable otherwise. + cliTest.skipIf(process.getuid?.() === 0)( + "reports a permission denial instead of a malformed plist", + async ({ cli, onTestFinished }) => { + const app = await fixtureApp(); + const locked = join(app, "Contents/Locked.plist"); + await writeFile(locked, ""); + await chmod(locked, 0o000); + onTestFinished(async () => { + await chmod(locked, 0o600); + }); + const result = await cli.run({ + arguments: [ + "inspect-plist", + app, + "--relative-path", + "Contents/Locked.plist", + "--json", + ], + environment, + }); + expect(result.exitCode).toBe(1); + expect(result.json).toMatchObject({ + code: "target_unavailable", + details: { + path: locked, + reason: "permission denied while reading plist (EACCES)", + }, + }); + }, + ); + cliTest( "reports a missing default Info.plist as unavailable for the target", async ({ cli }) => {