mirror of
https://github.com/morluto/rea.git
synced 2026-11-07 05:56:19 -10:00
fix(native): validate plist target identity
This commit is contained in:
1 parent
0e17aab451
commit
bae84d1b93
5 files changed
+191
-73
No files matched your search
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user