fix(native): report plist permission denials as access failures

A regular plist that the host refuses to open made both plutil
conversions fail, which was reported as an undecodable property list.
Open the file before running plutil and report EACCES/EPERM as a
target access failure naming the path.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 23:31:14 -07:00
parent 0674facd46
commit 5738a0cf8c
2 changed files with 55 additions and 9 deletions

View File

@ -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<Result<null, AnalysisError>> => {
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 }),
);

View File

@ -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, "<plist><dict/></plist>");
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 }) => {