fix(native): validate plist target identity

This commit is contained in:
morluto committed 2026-10-27 10:48:42 +08:00
1 parent 0e17aab451
commit bae84d1b93
5 files changed
+191 -73

No files matched your search

+10 -27
View File
@@ -20,7 +20,7 @@ import {
} from "./apple/KeyedArchiveReader.js";
import { traceDylibResolution } from "./apple/DylibResolutionReader.js";
import { dirname } from "node:path";
import { hashStableRootArtifact } from "./inventory/hashStableRootArtifact.js";
import { withArtifactTargetIdentity } from "./withArtifactTargetIdentity.js";
import { inventoryArtifact } from "./inventory/ArtifactInventory.js";
import { extractArtifact } from "./extraction/ArtifactExtraction.js";
import { analyzeInterfaceBuilderBundle } from "./apple/InterfaceBuilderAnalysis.js";
@@ -243,7 +243,9 @@ class ArtifactClient implements AnalysisClient {
}
if (operation === "decode_interface_builder") {
const limits = interfaceBuilderLimitsSchema.parse(parameters);
const result = await this.withBundleTargetIdentity(
const result = await withArtifactTargetIdentity(
this.target,
this.#resourceScope,
() =>
analyzeInterfaceBuilderBundle({
bundlePath: this.target.sourcePath ?? this.target.path,
@@ -282,7 +284,9 @@ class ArtifactClient implements AnalysisClient {
? {}
: { signal: options.signal }),
})
: await this.withBundleTargetIdentity(
: await withArtifactTargetIdentity(
this.target,
this.#resourceScope,
() =>
inspectBundleKeyedArchive({
bundlePath: this.target.sourcePath ?? this.target.path,
@@ -407,35 +411,14 @@ class ArtifactClient implements AnalysisClient {
);
}
/** Resource reads belong to the executable identity established by this session. */
private async withBundleTargetIdentity<T>(
read: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> {
const verify = async () => {
const digest = await hashStableRootArtifact(
this.target.path,
this.#resourceScope,
signal,
);
if (digest.sha256 !== this.target.sha256)
throw new ArtifactReaderFailure(
"integrity",
`Active target digest changed: expected ${this.target.sha256}, observed ${digest.sha256}`,
);
};
await verify();
const result = await read();
await verify();
return result;
}
private async inspectAssetCatalog(
parameters: Readonly<Record<string, JsonValue>>,
options?: ExecutionOptions,
) {
const bundlePath = this.target.sourcePath ?? this.target.path;
const result = await this.withBundleTargetIdentity(
const result = await withArtifactTargetIdentity(
this.target,
this.#resourceScope,
() =>
analyzeAppleAssetCatalogs({
environment: this.environment,
@@ -0,0 +1,25 @@
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
import { ArtifactReaderFailure } from "./ArtifactReader.js";
import type { ArtifactResourceScope } from "./ArtifactResourceScope.js";
import { hashStableRootArtifact } from "./inventory/hashStableRootArtifact.js";
/** Bind live resource reads to the target identity established by the session. */
export const withArtifactTargetIdentity = async <T>(
target: BinaryTarget,
resources: ArtifactResourceScope,
read: () => Promise<T>,
signal?: AbortSignal,
): Promise<T> => {
const verify = async () => {
const digest = await hashStableRootArtifact(target.path, resources, signal);
if (digest.sha256 !== target.sha256)
throw new ArtifactReaderFailure(
"integrity",
`Active target digest changed: expected ${target.sha256}, observed ${digest.sha256}`,
);
};
await verify();
const result = await read();
await verify();
return result;
};
+83 -44
View File
@@ -1,3 +1,4 @@
import { withArtifactTargetIdentity } from "../artifacts/withArtifactTargetIdentity.js";
import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js";
import {
plistSourceFormat,
@@ -956,54 +957,36 @@ class NativeMacOSClient implements AnalysisClient {
);
try {
return await this.#plistResources.run(async () => {
if (
this.activeTarget === undefined ||
this.activeTarget.kind !== "executable" ||
this.activeTarget.bundleInfoPlist === undefined
)
return this.#readPlist(requested, signal, timeoutMs);
try {
const plist =
this.bundleResourceRoot === undefined
? await resolveNativePlistSelection(this.target, requested)
: resolveBundleResourcePlistSelection(
this.bundleResourceRoot,
requested,
);
if (!plist.ok) return plist;
const fileInput: NativePlistReadContext = {
selection: plist.value,
resources: this.#plistResources,
const observation = await withArtifactTargetIdentity(
this.activeTarget,
this.#plistResources,
async () => {
const result = await this.#readPlist(
requested,
signal,
timeoutMs,
);
if (!result.ok) throw result.error;
return result.value;
},
signal,
timeoutMs,
};
const selected = await requireRegularPlist(fileInput);
if (!selected.ok) return selected;
const classified = await this.#run(
"inspect_plist",
"file",
["-b", plist.value.path],
{ signal, timeoutMs },
);
if (!classified.ok) return classified;
const binary = await this.#inspectBinaryPlist(
fileInput,
classified.value,
);
if (binary !== undefined) return binary;
const format = await plistSourceFormat(
classified.value.stdout,
fileInput,
);
if (!format.ok) return format;
const source = await this.#inspectTextPlist(
fileInput,
classified.value,
format.value,
);
if (source !== undefined) return source;
return await this.#inspectConvertedPlist(
fileInput,
classified.value,
format.value,
);
return ok(observation);
} catch (cause: unknown) {
// Producer failures stay opaque; only scope admission failures reach the file mapper.
return err(nativeOutputFailure("inspect_plist", cause));
if (cause instanceof AnalysisError) return err(cause);
return err(
nativePlistFailure(cause, {
path: this.activeTarget.path,
origin: "selected",
}),
);
}
});
} catch (cause: unknown) {
@@ -1011,6 +994,62 @@ class NativeMacOSClient implements AnalysisClient {
}
}
async #readPlist(
requested: string | undefined,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
try {
const plist =
this.bundleResourceRoot === undefined
? await resolveNativePlistSelection(this.target, requested)
: resolveBundleResourcePlistSelection(
this.bundleResourceRoot,
requested,
);
if (!plist.ok) return plist;
const fileInput: NativePlistReadContext = {
selection: plist.value,
resources: this.#plistResources,
signal,
timeoutMs,
};
const selected = await requireRegularPlist(fileInput);
if (!selected.ok) return selected;
const classified = await this.#run(
"inspect_plist",
"file",
["-b", plist.value.path],
{ signal, timeoutMs },
);
if (!classified.ok) return classified;
const binary = await this.#inspectBinaryPlist(
fileInput,
classified.value,
);
if (binary !== undefined) return binary;
const format = await plistSourceFormat(
classified.value.stdout,
fileInput,
);
if (!format.ok) return format;
const source = await this.#inspectTextPlist(
fileInput,
classified.value,
format.value,
);
if (source !== undefined) return source;
return await this.#inspectConvertedPlist(
fileInput,
classified.value,
format.value,
);
} catch (cause: unknown) {
// Producer failures stay opaque; only scope admission failures reach the file mapper.
return err(nativeOutputFailure("inspect_plist", cause));
}
}
async #inspectBinaryPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
@@ -11,7 +11,9 @@ import { cliTest } from "../../support/cli/cliFixture.js";
cliTest.for([
"decode_interface_builder",
"inspect_keyed_archive",
...(process.platform === "darwin" ? ["inspect_asset_catalog"] : []),
...(process.platform === "darwin"
? ["inspect_asset_catalog", "inspect_plist"]
: []),
] as const)(
"binds live %s Evidence to the open executable through stdio MCP",
async (operation) => {
@@ -53,6 +55,11 @@ cliTest.for([
const changedNib = nib.replaceAll("UIViewController", "ChangedController");
expect(changedNib).not.toBe(nib);
await writeFile(nibPath, changedNib);
if (operation === "inspect_plist")
await writeFile(
join(contents, "Info.plist"),
"<plist><dict><key>CFBundleExecutable</key><string>Probe</string><key>Marker</key><string>ChangedController</string></dict></plist>",
);
const stale = await call(operation, parameters);
expect(stale.isError).toBe(true);
expect(JSON.stringify(stale)).toContain("integrity_mismatch");
@@ -68,7 +75,7 @@ cliTest.for([
expect(JSON.stringify(refreshed.normalized_result)).toContain(
"ChangedController",
);
if (operation !== "inspect_asset_catalog")
if (operation !== "inspect_asset_catalog" && operation !== "inspect_plist")
expect(JSON.stringify(refreshed.normalized_result)).toContain(
createHash("sha256").update(changedNib).digest("hex"),
);
@@ -6,6 +6,7 @@ import { ArtifactFileCleanupFailure } from "../../../../src/artifacts/ArtifactFi
import {
NativeFixtureRunner,
nativeMachoTarget,
nativeMachoTargetForFile,
} from "../../../fixtures/nativeCommands.js";
import { createTestTempDirectory } from "../../../fixtures/temporaryDirectory.js";
import { OwnedFileHandle } from "../../../../src/filesystem/OwnedFileHandle.js";
@@ -451,3 +452,66 @@ it.for([
});
},
);
it("preserves a failed app plist read and its cleanup when the executable changes", async () => {
const root = await createTestTempDirectory("rea-app-plist-primary-");
const path = join(root, "Info.plist");
const executable = join(root, "program");
await writeFile(path, "<plist><string>value</string></plist>");
await writeFile(executable, "original executable");
const target = {
...(await nativeMachoTargetForFile(executable)),
bundleInfoPlist: path,
};
const probe = await open(path, "r");
const inode = (await probe.stat()).ino;
const prototype = Object.getPrototypeOf(probe) as FileHandle;
await probe.close();
const primary = Object.assign(new Error("controlled plist read failure"), {
code: "EIO",
});
const cleanup = new Error("controlled plist close failure");
const original = prototype.read;
let release: (() => Promise<void>) | undefined;
let closeAttempts = 0;
const read = vi.spyOn(prototype, "read").mockImplementation(async function (
this: FileHandle,
...args: Parameters<FileHandle["read"]>
) {
if ((await this.stat()).ino !== inode)
return Reflect.apply(original, this, args);
await writeFile(executable, "changed executable");
release = this.close.bind(this);
this.close = async () => {
closeAttempts += 1;
if (closeAttempts === 1) throw cleanup;
await release!();
};
throw primary;
});
const client = new NativeMacOSProvider(
{},
new NativeFixtureRunner({ file: "ASCII text" }),
"darwin",
).createClient(target);
onTestFinished(async () => {
read.mockRestore();
await release?.();
await client.close();
});
const result = await client.execute("inspect_plist", {});
if (result.ok) throw new Error("Expected retained plist failure");
expect(result.error.cleanupResources).toEqual([path]);
expect(result.error.cause).toBeInstanceOf(ArtifactFileCleanupFailure);
const failure = result.error.cause as ArtifactFileCleanupFailure;
expect(failure.fileCleanupFailure.outcome).toEqual({
kind: "failed",
cause: primary,
});
expect(failure.fileCleanupFailure.cleanupCause).toBe(cleanup);
expect(closeAttempts).toBe(1);
expect((await client.close()).ok).toBe(true);
expect(closeAttempts).toBe(2);
expect(result.error.cleanupIncomplete).toBe(false);
expect(resolvedAnalysisError(result.error).cause).toBe(primary);
});