mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
Merge remote-tracking branch 'origin/main' into feat/dylib-resolution
# Conflicts: # docs/product-catalog.json # docs/verification/managed-conformance-manifest.json # skills/reverse-engineer-anything/SKILL.md # src/generatedMcpToolCatalog.ts
This commit is contained in:
commit
63800ff5a2
10 files changed
+723
-165
No files matched your search
@@ -0,0 +1,186 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { lstat, readdir, readFile, realpath, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/** Where one app bundle layout keeps its Info.plist and program file. */
|
||||
interface BundleLayout {
|
||||
readonly plist: readonly string[];
|
||||
readonly programs: readonly string[];
|
||||
readonly programsLabel: string;
|
||||
}
|
||||
|
||||
const BUNDLE_LAYOUTS: readonly BundleLayout[] = [
|
||||
// macOS bundles keep both under Contents.
|
||||
{
|
||||
plist: ["Contents", "Info.plist"],
|
||||
programs: ["Contents", "MacOS"],
|
||||
programsLabel: "Contents/MacOS",
|
||||
},
|
||||
// iOS-style bundles, as extracted from an IPA, keep both at the root.
|
||||
{ plist: ["Info.plist"], programs: [], programsLabel: "the bundle root" },
|
||||
];
|
||||
|
||||
/** A resolved bundle program file and the Info.plist that declared it. */
|
||||
export interface ResolvedAppBundle {
|
||||
readonly executable: string;
|
||||
readonly infoPlist?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve an app bundle directory to its declared program file. macOS and
|
||||
* flat iOS-style layouts are read directly. An iOS app installed on a Mac is
|
||||
* a wrapper whose `Wrapper` directory holds the iOS bundle; that bundle is
|
||||
* found by listing `Wrapper`, never by following the `WrappedBundle` link.
|
||||
*/
|
||||
export const resolveAppBundleExecutable = async (
|
||||
path: string,
|
||||
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
|
||||
const layout = await presentLayout(path);
|
||||
if (layout !== undefined) return resolveLayoutExecutable(path, layout);
|
||||
const wrapped = await wrappedBundle(path);
|
||||
if (wrapped !== undefined) {
|
||||
const wrappedLayout = await presentLayout(wrapped);
|
||||
if (wrappedLayout !== undefined)
|
||||
return resolveLayoutExecutable(wrapped, wrappedLayout);
|
||||
}
|
||||
return err(
|
||||
new BinaryTargetError(path, "app has no readable CFBundleExecutable", {
|
||||
cause: new Error(
|
||||
"No Contents/Info.plist, root Info.plist, or single Wrapper/*.app bundle was found",
|
||||
),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
const presentLayout = async (
|
||||
bundle: string,
|
||||
): Promise<BundleLayout | undefined> => {
|
||||
for (const layout of BUNDLE_LAYOUTS)
|
||||
if (await layoutPlistPresent(join(bundle, ...layout.plist))) return layout;
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Whether a layout's Info.plist exists. Readable symlinks count, as they did
|
||||
* when the plist was read directly; any failure other than absence, such as
|
||||
* a permission denial, is left for the caller to report.
|
||||
*/
|
||||
const layoutPlistPresent = async (path: string): Promise<boolean> => {
|
||||
try {
|
||||
return (await stat(path)).isFile();
|
||||
} catch (cause: unknown) {
|
||||
if (isAbsence(cause)) return false;
|
||||
throw cause;
|
||||
}
|
||||
};
|
||||
|
||||
const isAbsence = (cause: unknown): boolean => {
|
||||
const code: unknown =
|
||||
cause instanceof Error ? Reflect.get(cause, "code") : undefined;
|
||||
return code === "ENOENT" || code === "ENOTDIR";
|
||||
};
|
||||
|
||||
/** The single real `.app` directory inside an iOS-on-Mac `Wrapper`. */
|
||||
const wrappedBundle = async (bundle: string): Promise<string | undefined> => {
|
||||
const wrapper = join(bundle, "Wrapper");
|
||||
try {
|
||||
if (!(await lstat(wrapper)).isDirectory()) return undefined;
|
||||
} catch (cause: unknown) {
|
||||
if (isAbsence(cause)) return undefined;
|
||||
throw cause;
|
||||
}
|
||||
const apps = (await readdir(wrapper, { withFileTypes: true })).filter(
|
||||
(entry) => entry.isDirectory() && entry.name.toLowerCase().endsWith(".app"),
|
||||
);
|
||||
const [app] = apps;
|
||||
return apps.length === 1 && app !== undefined
|
||||
? join(wrapper, app.name)
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const resolveLayoutExecutable = async (
|
||||
bundle: string,
|
||||
layout: BundleLayout,
|
||||
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
|
||||
const plistPath = join(bundle, ...layout.plist);
|
||||
let name: string;
|
||||
try {
|
||||
const plist = await readFile(plistPath);
|
||||
name =
|
||||
plist.subarray(0, 6).toString("ascii") === "bplist"
|
||||
? await readBinaryPlistExecutable(plistPath)
|
||||
: parseXmlPlistExecutable(plist.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(bundle, "app has no readable CFBundleExecutable", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!isSafeExecutableName(name))
|
||||
return err(
|
||||
new BinaryTargetError(bundle, "app has an unsafe CFBundleExecutable"),
|
||||
);
|
||||
const programs = join(bundle, ...layout.programs);
|
||||
const executable = join(programs, name);
|
||||
try {
|
||||
const [canonicalPrograms, canonicalExecutable] = await Promise.all([
|
||||
realpath(programs),
|
||||
realpath(executable),
|
||||
]);
|
||||
if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable))
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
bundle,
|
||||
`app program file leaves ${layout.programsLabel}`,
|
||||
),
|
||||
);
|
||||
return ok({ executable: canonicalExecutable, infoPlist: plistPath });
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(bundle, "app program file is missing", { cause }),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/** Decode the top-level executable name with an XML parser, not a pattern. */
|
||||
const parseXmlPlistExecutable = (plist: string): string => {
|
||||
// An unrelated `__proto__` entry must not make the bundle unreadable.
|
||||
const { value } = parseXmlPropertyList(plist);
|
||||
const executable = executableEntrySchema.safeParse(value);
|
||||
if (!executable.success) throw new Error("CFBundleExecutable is missing");
|
||||
return executable.data.CFBundleExecutable;
|
||||
};
|
||||
|
||||
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
|
||||
|
||||
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
|
||||
// `-n` strips only the newline plutil appends; it requires macOS 12+.
|
||||
(
|
||||
await execFileAsync("/usr/bin/plutil", [
|
||||
"-extract",
|
||||
"CFBundleExecutable",
|
||||
"raw",
|
||||
"-n",
|
||||
"-o",
|
||||
"-",
|
||||
plistPath,
|
||||
])
|
||||
).stdout;
|
||||
|
||||
const isSafeExecutableName = (name: string): boolean =>
|
||||
name.length > 0 &&
|
||||
name !== "." &&
|
||||
name !== ".." &&
|
||||
!name.includes("\0") &&
|
||||
!/[/\\]/u.test(name);
|
||||
@@ -3,22 +3,19 @@ import { createHash } from "node:crypto";
|
||||
import {
|
||||
access,
|
||||
open,
|
||||
readFile,
|
||||
realpath,
|
||||
stat,
|
||||
type FileHandle,
|
||||
} from "node:fs/promises";
|
||||
import { execFile } from "node:child_process";
|
||||
import { extname, isAbsolute, join, resolve } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { extname, isAbsolute, resolve } from "node:path";
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { isPathWithinRoot } from "../domain/localPath.js";
|
||||
import { parseXmlPropertyList } from "../domain/propertyListKeys.js";
|
||||
import { BinaryTargetError } from "../domain/configurationErrors.js";
|
||||
import type { BinaryTarget } from "../domain/binaryTargetTypes.js";
|
||||
import { err, ok, type Result } from "../domain/result.js";
|
||||
import {
|
||||
resolveAppBundleExecutable,
|
||||
type ResolvedAppBundle,
|
||||
} from "./AppBundleExecutable.js";
|
||||
import {
|
||||
validateDosComLength,
|
||||
type ExecutableFormatHint,
|
||||
@@ -35,8 +32,6 @@ import {
|
||||
type ExecutableMetadata,
|
||||
} from "../domain/binaryTarget.js";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
/**
|
||||
* Resolve and classify a readable local target before a provider is selected.
|
||||
* FAT Mach-O inputs select only a host-compatible architecture so setup remains
|
||||
@@ -55,7 +50,9 @@ export const parseBinaryTarget = async (
|
||||
const canonical = await realpath(candidate);
|
||||
const resolved = await resolveAppBundle(canonical);
|
||||
if (!resolved.ok) return err(resolved.error);
|
||||
const path = resolved.value;
|
||||
const { executable: path, infoPlist } = resolved.value;
|
||||
const bundle =
|
||||
infoPlist === undefined ? {} : { bundleInfoPlist: infoPlist };
|
||||
const handle = await open(path, "r");
|
||||
try {
|
||||
if (!(await handle.stat()).isFile())
|
||||
@@ -107,6 +104,7 @@ export const parseBinaryTarget = async (
|
||||
return ok({
|
||||
path,
|
||||
sourcePath: process.platform === "win32" ? candidate : canonical,
|
||||
...bundle,
|
||||
sha256: await sha256Handle(handle),
|
||||
kind: "executable",
|
||||
...detected.value,
|
||||
@@ -216,89 +214,22 @@ const sha256Handle = async (handle: FileHandle): Promise<string> => {
|
||||
|
||||
const resolveAppBundle = async (
|
||||
path: string,
|
||||
): Promise<Result<string, BinaryTargetError>> => {
|
||||
): Promise<Result<ResolvedAppBundle, BinaryTargetError>> => {
|
||||
const metadata = await stat(path);
|
||||
if (!metadata.isDirectory()) return ok(path);
|
||||
if (!metadata.isDirectory()) return ok({ executable: path });
|
||||
if (extname(path).toLowerCase() !== ".app")
|
||||
return err(
|
||||
new BinaryTargetError(
|
||||
path,
|
||||
"this opening route requires a file or macOS app bundle",
|
||||
"this opening route requires a file or app bundle",
|
||||
{
|
||||
constraint: "directory_requires_file",
|
||||
},
|
||||
),
|
||||
);
|
||||
const plistPath = join(path, "Contents", "Info.plist");
|
||||
let name: string;
|
||||
try {
|
||||
const plist = await readFile(plistPath);
|
||||
name =
|
||||
plist.subarray(0, 6).toString("ascii") === "bplist"
|
||||
? await readBinaryPlistExecutable(plistPath)
|
||||
: parseXmlPlistExecutable(plist.toString("utf8"));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(path, "app has no readable CFBundleExecutable", {
|
||||
cause,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!isSafeExecutableName(name))
|
||||
return err(
|
||||
new BinaryTargetError(path, "app has an unsafe CFBundleExecutable"),
|
||||
);
|
||||
const programs = join(path, "Contents", "MacOS");
|
||||
const executable = join(programs, name);
|
||||
try {
|
||||
const [canonicalPrograms, canonicalExecutable] = await Promise.all([
|
||||
realpath(programs),
|
||||
realpath(executable),
|
||||
]);
|
||||
if (!isPathWithinRoot(canonicalPrograms, canonicalExecutable))
|
||||
return err(
|
||||
new BinaryTargetError(path, "app program file leaves Contents/MacOS"),
|
||||
);
|
||||
return ok(canonicalExecutable);
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new BinaryTargetError(path, "app program file is missing", { cause }),
|
||||
);
|
||||
}
|
||||
return resolveAppBundleExecutable(path);
|
||||
};
|
||||
|
||||
/** Decode the top-level executable name with an XML parser, not a pattern. */
|
||||
const parseXmlPlistExecutable = (plist: string): string => {
|
||||
// An unrelated `__proto__` entry must not make the bundle unreadable.
|
||||
const { value } = parseXmlPropertyList(plist);
|
||||
const executable = executableEntrySchema.safeParse(value);
|
||||
if (!executable.success) throw new Error("CFBundleExecutable is missing");
|
||||
return executable.data.CFBundleExecutable;
|
||||
};
|
||||
|
||||
const executableEntrySchema = z.looseObject({ CFBundleExecutable: z.string() });
|
||||
|
||||
const readBinaryPlistExecutable = async (plistPath: string): Promise<string> =>
|
||||
// `-n` strips only the newline plutil appends; it requires macOS 12+.
|
||||
(
|
||||
await execFileAsync("/usr/bin/plutil", [
|
||||
"-extract",
|
||||
"CFBundleExecutable",
|
||||
"raw",
|
||||
"-n",
|
||||
"-o",
|
||||
"-",
|
||||
plistPath,
|
||||
])
|
||||
).stdout;
|
||||
|
||||
const isSafeExecutableName = (name: string): boolean =>
|
||||
name.length > 0 &&
|
||||
name !== "." &&
|
||||
name !== ".." &&
|
||||
!name.includes("\0") &&
|
||||
!/[/\\]/u.test(name);
|
||||
|
||||
const readExecutableMetadata = async (
|
||||
handle: FileHandle,
|
||||
hostArchitecture: NodeJS.Architecture,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
||||
import { parseBinary } from "plist";
|
||||
|
||||
import { DirectoryArtifactReader } from "../artifacts/DirectoryArtifactReader.js";
|
||||
import { projectPlistValue } from "../domain/plistValue.js";
|
||||
import {
|
||||
omittedPrototypeKeysLimitation,
|
||||
parseXmlPropertyList,
|
||||
@@ -214,15 +215,17 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
|
||||
const object = byId.get(objectId);
|
||||
if (object === undefined) return null;
|
||||
const storedClass = object.class_name.replace(/\0+$/u, "");
|
||||
return storedClass === "NSClassSwapper"
|
||||
? (referencedString(object.values.NSClassName) ?? storedClass)
|
||||
if (storedClass === "NSClassSwapper")
|
||||
return referencedString(object.values.NSClassName) ?? storedClass;
|
||||
return storedClass === "UIClassSwapper"
|
||||
? (referencedString(object.values.UIClassName) ?? storedClass)
|
||||
: storedClass;
|
||||
};
|
||||
const objects = Object.fromEntries(
|
||||
archive.objects
|
||||
.filter((object) => {
|
||||
const name = runtimeClass(object.id) ?? "";
|
||||
return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector)$/u.test(
|
||||
return !/^(?:NSObject|NSIBObjectData|NSString|NSMutableString|NSNumber|NSArray|NSMutableArray|NSSet|NSMutableSet|NSDictionary|NSMutableDictionary|NSApplication|NSNib.*Connector|UIRuntime\w*Connection)$/u.test(
|
||||
name,
|
||||
);
|
||||
})
|
||||
@@ -268,26 +271,34 @@ const projectNibArchive = (archive: NibArchiveDocument) => {
|
||||
};
|
||||
for (const object of archive.objects) {
|
||||
const className = object.class_name.replace(/\0+$/u, "");
|
||||
const source = reference(object.values.NSSource);
|
||||
const destination = reference(object.values.NSDestination);
|
||||
if (className.includes("Connector") && source !== null) {
|
||||
const label = referencedString(object.values.NSLabel);
|
||||
const type = className.includes("Outlet")
|
||||
? "outlet"
|
||||
: className.includes("Control")
|
||||
? "action"
|
||||
: className;
|
||||
// NSSource is the outlet owner or the sending control; NSDestination is
|
||||
// the outlet value or the action target, and a nil target is the first
|
||||
// responder.
|
||||
(connections[String(source)] ??= []).push({
|
||||
type,
|
||||
"destination-id": destination === null ? null : String(destination),
|
||||
label,
|
||||
source_id: String(source),
|
||||
archive_object_id: String(object.id),
|
||||
});
|
||||
}
|
||||
// AppKit connectors use NS-prefixed keys; UIKit's runtime outlet and
|
||||
// event connections use UI-prefixed keys with the same roles.
|
||||
const uiKit = /^UIRuntime\w*Connection$/u.test(className);
|
||||
if (!uiKit && !className.includes("Connector")) continue;
|
||||
const values = object.values;
|
||||
const source = reference(uiKit ? values.UISource : values.NSSource);
|
||||
if (source === null) continue;
|
||||
const destination = reference(
|
||||
uiKit ? values.UIDestination : values.NSDestination,
|
||||
);
|
||||
const label = referencedString(uiKit ? values.UILabel : values.NSLabel);
|
||||
const type = className.includes("Outlet")
|
||||
? "outlet"
|
||||
: className.includes("Control") || className.includes("Event")
|
||||
? "action"
|
||||
: className;
|
||||
const eventMask = values.UIEventMask;
|
||||
// The source is the outlet owner or the sending control; the destination
|
||||
// is the outlet value or the action target, and a nil target is the
|
||||
// first responder.
|
||||
(connections[String(source)] ??= []).push({
|
||||
type,
|
||||
"destination-id": destination === null ? null : String(destination),
|
||||
label,
|
||||
source_id: String(source),
|
||||
archive_object_id: String(object.id),
|
||||
...(typeof eventMask === "number" ? { ui_event_mask: eventMask } : {}),
|
||||
});
|
||||
}
|
||||
let hierarchyOmitted = 0;
|
||||
const hierarchyFor = (
|
||||
@@ -390,6 +401,7 @@ const readEntry = async (
|
||||
return Buffer.concat(chunks, length);
|
||||
};
|
||||
|
||||
/** Project decoded plist data and dates while retaining omitted-key coverage. */
|
||||
const decodePlist = (
|
||||
bytes: Buffer,
|
||||
): { readonly value: JsonValue; readonly omittedPrototypeKeys: number } => {
|
||||
@@ -397,5 +409,5 @@ const decodePlist = (
|
||||
bytes.subarray(0, 8).toString("ascii") === "bplist00"
|
||||
? { value: parseBinary(bytes), omittedPrototypeKeys: 0 }
|
||||
: parseXmlPropertyList(bytes.toString("utf8"));
|
||||
return { value: jsonValueSchema.parse(value), omittedPrototypeKeys };
|
||||
return { value: projectPlistValue(value).value, omittedPrototypeKeys };
|
||||
};
|
||||
@@ -4,6 +4,8 @@ export type BinaryArchitecture = "x86" | "x86_64" | "arm" | "arm64";
|
||||
interface BinaryTargetIdentity {
|
||||
readonly path: string;
|
||||
readonly sourcePath?: string;
|
||||
/** Info.plist of the app bundle the target was resolved from. */
|
||||
readonly bundleInfoPlist?: string;
|
||||
readonly sha256: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -318,6 +318,91 @@ describe("keyed archive decoding and graph limits", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("keyed archive object references", () => {
|
||||
it("keeps referenced objects as stubs so shared and cyclic references stay finite", () => {
|
||||
// Each view references its window, superview, and next key view, so
|
||||
// inlining references would expand the table into an exponential tree.
|
||||
const viewCount = 40;
|
||||
const views = Array.from({ length: viewCount }, (_, index) => ({
|
||||
$class: { UID: 1 },
|
||||
NSWindow: { UID: 2 },
|
||||
NSSuperview: { UID: index === 0 ? 2 : 3 + index - 1 },
|
||||
NSNextKeyView: { UID: 3 + ((index + 1) % viewCount) },
|
||||
NSSubviews: index + 1 < viewCount ? [{ UID: 3 + index + 1 }] : [],
|
||||
NSTitle: { UID: 3 + viewCount },
|
||||
}));
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $classname: "NSView", $classes: ["NSView", "NSObject"] },
|
||||
{ $class: { UID: 3 + viewCount + 1 }, NSContentView: { UID: 3 } },
|
||||
...views,
|
||||
"Title",
|
||||
{ $classname: "NSWindow", $classes: ["NSWindow", "NSObject"] },
|
||||
],
|
||||
$top: { root: { UID: 2 } },
|
||||
});
|
||||
|
||||
expect(parsed.objects).toHaveLength(viewCount + 1);
|
||||
const view = parsed.objects.find(({ id }) => id === "3");
|
||||
expect(view?.name).toBe("NSView");
|
||||
expect(view?.attributes).toEqual({
|
||||
NSWindow: { archiveUID: 2, className: "NSWindow" },
|
||||
NSSuperview: { archiveUID: 2, className: "NSWindow" },
|
||||
NSNextKeyView: { archiveUID: 4, className: "NSView" },
|
||||
NSSubviews: [{ archiveUID: 4, className: "NSView" }],
|
||||
NSTitle: "Title",
|
||||
});
|
||||
expect(parsed.hierarchy).toMatchObject([
|
||||
{
|
||||
objectID: "2",
|
||||
children: [{ objectID: "3", children: [{ objectID: "4" }] }],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("resolves XML CF$UID references and the archived nil", () => {
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$objects: [
|
||||
"$null",
|
||||
{
|
||||
$class: { CF$UID: 3 },
|
||||
NSSource: { CF$UID: 2 },
|
||||
source: { CF$UID: 2 },
|
||||
destination: { CF$UID: 0 },
|
||||
label: { CF$UID: 4 },
|
||||
},
|
||||
{ $class: { CF$UID: 5 } },
|
||||
{
|
||||
$classname: "NSNibOutletConnector",
|
||||
$classes: ["NSNibOutletConnector", "NSObject"],
|
||||
},
|
||||
{ $class: { CF$UID: 6 }, "NS.string": "delegate" },
|
||||
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
|
||||
{
|
||||
$classname: "NSMutableString",
|
||||
$classes: ["NSMutableString", "NSString", "NSObject"],
|
||||
},
|
||||
],
|
||||
$top: {},
|
||||
});
|
||||
|
||||
expect(parsed.connections).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "outlet",
|
||||
source_id: "2",
|
||||
destination_id: null,
|
||||
label: "delegate",
|
||||
}),
|
||||
]);
|
||||
expect(parsed.objects.find(({ id }) => id === "2")).toMatchObject({
|
||||
class_name: "NSButton",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("AppKit keyed archive connectors", () => {
|
||||
it("reads outlet and control connectors from NS-prefixed keys", () => {
|
||||
const parsed = parseInterfaceBuilderRecords({
|
||||
|
||||
@@ -117,62 +117,130 @@ export const parseInterfaceBuilderRecords = (
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* A UID decodes as a one-key `UID` (binary) or `CF$UID` (XML) dictionary with
|
||||
* an integer value. Any other dictionary is ordinary archive data.
|
||||
*/
|
||||
const archiveUid = (value: unknown): number | undefined => {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value))
|
||||
return undefined;
|
||||
if (Object.keys(value).length !== 1) return undefined;
|
||||
const item = record(value);
|
||||
const marker = Object.hasOwn(item, "UID") ? item.UID : item["CF$UID"];
|
||||
return typeof marker === "number" && Number.isInteger(marker)
|
||||
? marker
|
||||
: undefined;
|
||||
};
|
||||
|
||||
const HIERARCHY_KEY =
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu;
|
||||
|
||||
/**
|
||||
* Read an NSKeyedArchiver object table without expanding it into a tree.
|
||||
* Archived objects reference each other freely, including cycles such as a
|
||||
* view and its superview, so inlining every reference grows exponentially.
|
||||
* A reference instead resolves to the referenced string, or to a stub that
|
||||
* names the referenced object; the hierarchy walk visits each object once.
|
||||
*/
|
||||
const keyedArchiveTable = (objectTable: readonly unknown[]) => {
|
||||
const classNameOf = (value: unknown): string | null => {
|
||||
const classReference = record(value).$class;
|
||||
const uid = archiveUid(classReference);
|
||||
return firstString(
|
||||
record(uid === undefined ? classReference : objectTable[uid]).$classname,
|
||||
);
|
||||
};
|
||||
/** The string an archived string object holds, if it is one. */
|
||||
const archivedString = (target: unknown): string | undefined => {
|
||||
if (typeof target === "string") return target;
|
||||
const text = record(target)["NS.string"];
|
||||
return typeof text === "string" &&
|
||||
/^NS(?:Mutable)?String$/u.test(classNameOf(target) ?? "")
|
||||
? text
|
||||
: undefined;
|
||||
};
|
||||
const text = (value: unknown): string | null => {
|
||||
const uid = archiveUid(value);
|
||||
return firstString(
|
||||
uid === undefined ? value : archivedString(objectTable[uid]),
|
||||
);
|
||||
};
|
||||
const authoredId = (item: Record<string, unknown>): string | null =>
|
||||
firstString(text(item.objectID), text(item["object-id"]));
|
||||
const reference = (uid: number): unknown => {
|
||||
// UID 0 is the archived nil.
|
||||
const target = uid === 0 ? null : objectTable[uid];
|
||||
if (target === undefined || target === null) return null;
|
||||
if (typeof target !== "object") return target;
|
||||
const string = archivedString(target);
|
||||
if (string !== undefined) return string;
|
||||
const objectId = authoredId(record(target));
|
||||
return {
|
||||
archiveUID: uid,
|
||||
...(objectId === null ? {} : { objectID: objectId }),
|
||||
className: classNameOf(target),
|
||||
};
|
||||
};
|
||||
/** Resolve one object's own fields, leaving references to other objects as stubs. */
|
||||
const resolve = (value: unknown): unknown => {
|
||||
const uid = archiveUid(value);
|
||||
if (uid !== undefined) return reference(uid);
|
||||
if (Array.isArray(value)) return value.map(resolve);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") output.className = classNameOf(value);
|
||||
else output[key] = resolve(child);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const hierarchy = (start: unknown): unknown[] => {
|
||||
const visited = new Set<number>();
|
||||
const node = (
|
||||
item: Record<string, unknown>,
|
||||
uid: number | undefined,
|
||||
depth: number,
|
||||
): unknown[] => {
|
||||
const objectId = firstString(
|
||||
authoredId(item),
|
||||
text(item.id),
|
||||
uid === undefined ? null : String(uid),
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) => HIERARCHY_KEY.test(key))
|
||||
.flatMap(([, child]) => visit(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const visit = (value: unknown, depth: number): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
const uid = archiveUid(value);
|
||||
if (uid !== undefined) {
|
||||
if (visited.has(uid)) return [];
|
||||
visited.add(uid);
|
||||
const target = objectTable[uid];
|
||||
if (Array.isArray(target)) return visit(target, depth + 1);
|
||||
return typeof target === "object" && target !== null
|
||||
? node(record(target), uid, depth)
|
||||
: [];
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => visit(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
return node(record(value), undefined, depth);
|
||||
};
|
||||
return visit(start, 0);
|
||||
};
|
||||
return { resolve, hierarchy };
|
||||
};
|
||||
|
||||
/** Project class and connection objects from an NSKeyedArchiver object table. */
|
||||
const parseKeyedArchive = (
|
||||
root: Record<string, unknown>,
|
||||
): ReturnType<typeof parseInterfaceBuilderRecords> | null => {
|
||||
if (root.$archiver !== "NSKeyedArchiver" || !Array.isArray(root.$objects))
|
||||
return null;
|
||||
const objectTable = root.$objects;
|
||||
const resolve = (value: unknown, depth = 0): unknown => {
|
||||
if (depth > 16) return null;
|
||||
const uid = record(value).UID;
|
||||
if (typeof uid === "number") {
|
||||
const target = objectTable[uid];
|
||||
const resolved = target === undefined ? null : resolve(target, depth + 1);
|
||||
return typeof resolved === "object" &&
|
||||
resolved !== null &&
|
||||
!Array.isArray(resolved)
|
||||
? { ...resolved, archiveUID: uid }
|
||||
: resolved;
|
||||
}
|
||||
if (Array.isArray(value))
|
||||
return value.map((item) => resolve(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const [key, child] of Object.entries(value)) {
|
||||
if (key === "$class") {
|
||||
const classObject = record(
|
||||
typeof record(child).UID === "number"
|
||||
? objectTable[record(child).UID as number]
|
||||
: child,
|
||||
);
|
||||
output.className = firstString(classObject.$classname);
|
||||
} else output[key] = resolve(child, depth + 1);
|
||||
}
|
||||
return output;
|
||||
};
|
||||
const toHierarchy = (value: unknown, depth = 0): unknown[] => {
|
||||
if (depth > 32) return [];
|
||||
if (Array.isArray(value))
|
||||
return value.flatMap((item) => toHierarchy(item, depth + 1));
|
||||
if (typeof value !== "object" || value === null) return [];
|
||||
const item = record(value);
|
||||
const objectId = firstString(
|
||||
item.objectID,
|
||||
item["object-id"],
|
||||
item.id,
|
||||
typeof item.archiveUID === "number" ? String(item.archiveUID) : null,
|
||||
);
|
||||
const children = Object.entries(item)
|
||||
.filter(([key]) =>
|
||||
/(?:subviews|contentview|childviewcontrollers|children|views)$/iu.test(
|
||||
key,
|
||||
),
|
||||
)
|
||||
.flatMap(([, child]) => toHierarchy(child, depth + 1));
|
||||
return objectId === null ? children : [{ objectID: objectId, children }];
|
||||
};
|
||||
const objectTable: readonly unknown[] = root.$objects;
|
||||
const { resolve, hierarchy: toHierarchy } = keyedArchiveTable(objectTable);
|
||||
const objects: InterfaceBuilderObject[] = [];
|
||||
const connections: InterfaceBuilderConnection[] = [];
|
||||
let objectCount = 0;
|
||||
@@ -252,8 +320,7 @@ const parseKeyedArchive = (
|
||||
}
|
||||
}
|
||||
const top = record(root.$top);
|
||||
const topObject = resolve(top.root ?? top.UITopLevelObjectsKey);
|
||||
const hierarchy = toHierarchy(topObject);
|
||||
const hierarchy = toHierarchy(top.root ?? top.UITopLevelObjectsKey);
|
||||
const classes = Object.fromEntries(
|
||||
objectTable.flatMap((raw) => {
|
||||
const item = record(raw);
|
||||
@@ -327,7 +394,11 @@ const classifyObject = (
|
||||
const value = className?.toLowerCase() ?? "";
|
||||
if (value.includes("constraint")) return "constraint";
|
||||
if (value.includes("layoutguide")) return "layout_guide";
|
||||
if (value.includes("placeholder") || value.includes("firstresponder"))
|
||||
if (
|
||||
value.includes("placeholder") ||
|
||||
value.includes("firstresponder") ||
|
||||
value === "uiproxyobject"
|
||||
)
|
||||
return "placeholder";
|
||||
if (
|
||||
value.includes("image") ||
|
||||
|
||||
@@ -736,11 +736,10 @@ const resolvePlistPath = async (
|
||||
return ok(resolve(requested));
|
||||
const sourceMetadata = await stat(source);
|
||||
const root = sourceMetadata.isDirectory() ? source : dirname(target.path);
|
||||
return ok(
|
||||
requested === undefined
|
||||
? resolve(root, "Contents/Info.plist")
|
||||
: resolve(root, requested),
|
||||
);
|
||||
if (requested !== undefined) return ok(resolve(root, requested));
|
||||
// A bundle target records the Info.plist its program file was declared
|
||||
// in: Contents/Info.plist, or the root plist of an iOS-style bundle.
|
||||
return ok(target.bundleInfoPlist ?? resolve(root, "Contents/Info.plist"));
|
||||
} catch (cause: unknown) {
|
||||
return err(
|
||||
new ProviderAdapterError(IDENTITY.id, "inspect_plist", { cause }),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, mkdir, realpath, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
@@ -293,6 +293,122 @@ describe("app executable filename fidelity", () => {
|
||||
);
|
||||
});
|
||||
|
||||
describe("iOS-style app bundle targets", () => {
|
||||
const plist = (name: string) =>
|
||||
`<plist><dict><key>CFBundleExecutable</key><string>${name}</string></dict></plist>`;
|
||||
|
||||
it("resolves a flat bundle's program file beside its Info.plist", async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Flat.app");
|
||||
await mkdir(app);
|
||||
await writeFile(join(app, "Info.plist"), plist("Flat"));
|
||||
await writeFile(join(app, "Flat"), thinMach(0xfeedfacf, 0x0100000c));
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
path: await realpath(join(app, "Flat")),
|
||||
bundleInfoPlist: join(await realpath(app), "Info.plist"),
|
||||
format: "mach-o",
|
||||
});
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"keeps the declaring Info.plist for a symlinked program file",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Linked.app");
|
||||
await mkdir(join(app, "bin"), { recursive: true });
|
||||
await writeFile(join(app, "Info.plist"), plist("Linked"));
|
||||
await writeFile(
|
||||
join(app, "bin", "real"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
await symlink("bin/real", join(app, "Linked"));
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
path: await realpath(join(app, "bin", "real")),
|
||||
bundleInfoPlist: join(await realpath(app), "Info.plist"),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"follows a readable Contents/Info.plist symlink",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Linked.app");
|
||||
const contents = join(app, "Contents");
|
||||
await mkdir(join(contents, "MacOS"), { recursive: true });
|
||||
await writeFile(join(contents, "Real.plist"), plist("Linked"));
|
||||
await symlink("Real.plist", join(contents, "Info.plist"));
|
||||
await writeFile(
|
||||
join(contents, "MacOS", "Linked"),
|
||||
thinMach(0xfeedfacf, 0x0100000c),
|
||||
);
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
path: await realpath(join(contents, "MacOS", "Linked")),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"reports a wrapper it cannot read as a permission denial",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Locked.app");
|
||||
const wrapper = join(app, "Wrapper");
|
||||
await mkdir(join(wrapper, "Locked.app"), { recursive: true });
|
||||
await chmod(wrapper, 0o000);
|
||||
try {
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected a permission denial");
|
||||
expect(result.error.message).toContain("permission denied");
|
||||
} finally {
|
||||
await chmod(wrapper, 0o755);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"resolves an iOS-on-Mac wrapper through its single Wrapper bundle",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Phone.app");
|
||||
const wrapped = join(app, "Wrapper", "Phone.app");
|
||||
await mkdir(wrapped, { recursive: true });
|
||||
await writeFile(join(wrapped, "Info.plist"), plist("Phone"));
|
||||
await writeFile(join(wrapped, "Phone"), thinMach(0xfeedfacf, 0x0100000c));
|
||||
await symlink("Wrapper/Phone.app", join(app, "WrappedBundle"));
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok && result.value).toMatchObject({
|
||||
path: await realpath(join(wrapped, "Phone")),
|
||||
format: "mach-o",
|
||||
});
|
||||
|
||||
await mkdir(join(app, "Wrapper", "Second.app"));
|
||||
expect((await parseBinaryTarget(app, directory, "arm64")).ok).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"rejects a flat bundle program symlink that leaves the bundle",
|
||||
async () => {
|
||||
const directory = await createTestTempDirectory("rea-target-");
|
||||
const app = join(directory, "Escaping.app");
|
||||
const outside = join(directory, "outside");
|
||||
await mkdir(app);
|
||||
await writeFile(outside, thinMach(0xfeedfacf, 0x0100000c));
|
||||
await writeFile(join(app, "Info.plist"), plist("Escaping"));
|
||||
await symlink(outside, join(app, "Escaping"));
|
||||
const result = await parseBinaryTarget(app, directory, "arm64");
|
||||
expect(result.ok).toBe(false);
|
||||
if (result.ok) throw new Error("Expected an escaping program file");
|
||||
expect(result.error.message).toContain("leaves the bundle root");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("DOS binary target I/O", () => {
|
||||
it("validates a complete DOS file beyond the initial metadata probe", async () => {
|
||||
const directory = await createTestTempDirectory("rea-dos-target-");
|
||||
|
||||
@@ -164,6 +164,50 @@ describe("compiled Interface Builder bundle reader", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("flat Interface Builder plist values", () => {
|
||||
it("decodes flat nib archives that hold data and date values", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
const resources = join(bundle, "Contents", "Resources", "English.lproj");
|
||||
await mkdir(resources, { recursive: true });
|
||||
await writeFile(
|
||||
join(resources, "Binary.nib"),
|
||||
buildBinary({
|
||||
$archiver: "NSKeyedArchiver",
|
||||
$version: 100000,
|
||||
$objects: [
|
||||
"$null",
|
||||
{ $class: { UID: 2 }, title: "Build", NSColor: { UID: 3 } },
|
||||
{ $classname: "NSButton", $classes: ["NSButton", "NSObject"] },
|
||||
{ $class: { UID: 4 }, NSWhite: Buffer.from("0.5\0", "ascii") },
|
||||
{ $classname: "NSColor", $classes: ["NSColor", "NSObject"] },
|
||||
],
|
||||
$top: { root: { UID: 1 } },
|
||||
}),
|
||||
);
|
||||
await writeFile(
|
||||
join(resources, "Xml.nib"),
|
||||
'<?xml version="1.0"?><plist version="1.0"><dict><key>$archiver</key><string>NSKeyedArchiver</string><key>$objects</key><array><string>$null</string><dict><key>NSWhite</key><data>MC41AA==</data><key>NSDate</key><date>2026-10-07T00:00:00Z</date></dict></array><key>$top</key><dict/></dict></plist>',
|
||||
);
|
||||
|
||||
const analysis = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "d".repeat(64),
|
||||
});
|
||||
|
||||
expect(
|
||||
analysis.documents.map(({ relative_path }) => relative_path).sort(),
|
||||
).toEqual([
|
||||
"Contents/Resources/English.lproj/Binary.nib",
|
||||
"Contents/Resources/English.lproj/Xml.nib",
|
||||
]);
|
||||
expect(analysis.graph.coverage).toContainEqual(
|
||||
expect.objectContaining({ facet: "archive_decode", status: "complete" }),
|
||||
);
|
||||
expect(analysis.graph.nodes.map(({ name }) => name)).toContain("Build");
|
||||
});
|
||||
});
|
||||
|
||||
describe("bounded Interface Builder archive decoding", () => {
|
||||
it("counts malformed archives against the document limit", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
@@ -296,3 +340,92 @@ describe("bounded Interface Builder archive decoding", () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("compiled UIKit NIB connections", () => {
|
||||
it("projects UIKit runtime outlet and event connections", async () => {
|
||||
const root = await createTestTempDirectory("rea-ib-test-");
|
||||
const bundle = join(root, "Example.app");
|
||||
await mkdir(bundle, { recursive: true });
|
||||
await writeFile(
|
||||
join(bundle, "Cell.nib"),
|
||||
encodeNibArchiveFixture({
|
||||
classes: [
|
||||
"UIProxyObject",
|
||||
"UIClassSwapper",
|
||||
"UIButton",
|
||||
"UIRuntimeEventConnection",
|
||||
"UIRuntimeOutletConnection",
|
||||
"NSString",
|
||||
],
|
||||
objects: [
|
||||
{ classIndex: 0, values: { UIProxiedObjectIdentifier: { ref: 5 } } },
|
||||
{ classIndex: 1, values: { UIClassName: { ref: 6 } } },
|
||||
{ classIndex: 2, values: {} },
|
||||
{
|
||||
classIndex: 3,
|
||||
values: {
|
||||
UISource: { ref: 2 },
|
||||
UIDestination: { ref: 1 },
|
||||
UILabel: { ref: 7 },
|
||||
UIEventMask: 64,
|
||||
},
|
||||
},
|
||||
{
|
||||
classIndex: 4,
|
||||
values: {
|
||||
UISource: { ref: 1 },
|
||||
UIDestination: { ref: 2 },
|
||||
UILabel: { ref: 8 },
|
||||
},
|
||||
},
|
||||
{ classIndex: 5, values: { "NS.bytes": "IBFilesOwner" } },
|
||||
{ classIndex: 5, values: { "NS.bytes": "BuildCell" } },
|
||||
{ classIndex: 5, values: { "NS.bytes": "buildTapped:" } },
|
||||
{ classIndex: 5, values: { "NS.bytes": "buildButton" } },
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const analysis = await analyzeInterfaceBuilderBundle({
|
||||
bundlePath: bundle,
|
||||
targetSha256: "e".repeat(64),
|
||||
});
|
||||
const byId = new Map(analysis.graph.nodes.map((node) => [node.id, node]));
|
||||
const named = (id: string | null) =>
|
||||
id === null ? null : (byId.get(id)?.name ?? null);
|
||||
const action = analysis.graph.nodes.find(({ kind }) => kind === "action");
|
||||
expect(action).toMatchObject({
|
||||
name: "buildTapped:",
|
||||
attributes: { ui_event_mask: 64 },
|
||||
});
|
||||
const actionEdges = analysis.graph.edges.filter(
|
||||
({ relation }) => relation === "target_action",
|
||||
);
|
||||
expect(
|
||||
actionEdges
|
||||
.filter(({ to }) => to === action?.id)
|
||||
.map(({ from }) => named(from)),
|
||||
).toEqual(["UIButton"]);
|
||||
expect(
|
||||
actionEdges
|
||||
.filter(({ from }) => from === action?.id)
|
||||
.map(({ to }) => named(to)),
|
||||
).toEqual(expect.arrayContaining(["BuildCell"]));
|
||||
const outlet = analysis.graph.nodes.find(({ kind }) => kind === "outlet");
|
||||
expect(outlet?.name).toBe("buildButton");
|
||||
expect(
|
||||
analysis.graph.edges
|
||||
.filter(
|
||||
({ relation, from }) =>
|
||||
relation === "outlet_to" && from === outlet?.id,
|
||||
)
|
||||
.map(({ to }) => named(to)),
|
||||
).toEqual(["UIButton"]);
|
||||
expect(
|
||||
analysis.graph.nodes.find(({ name }) => name === "IBFilesOwner")?.kind,
|
||||
).toBe("placeholder");
|
||||
expect(analysis.graph.nodes.map(({ name }) => name)).not.toContain(
|
||||
"UIRuntimeEventConnection",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -278,6 +278,29 @@ describe("native macOS provider inspection", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("native plist defaults for iOS-style bundles", () => {
|
||||
it("defaults to the Info.plist the bundle program was resolved from", async () => {
|
||||
directory = await createTestTempDirectory("rea-native-flat-");
|
||||
const app = join(directory, "Flat.app");
|
||||
const executable = join(app, "Flat");
|
||||
await mkdir(app, { recursive: true });
|
||||
await writeFile(executable, "fixture");
|
||||
await writeFile(join(app, "Info.plist"), "fixture");
|
||||
const client = new NativeMacOSProvider(
|
||||
new FixtureRunner(),
|
||||
"darwin",
|
||||
).createClient({
|
||||
...machoTarget(executable, app),
|
||||
bundleInfoPlist: join(app, "Info.plist"),
|
||||
});
|
||||
|
||||
const plist = await client.execute("inspect_plist", {});
|
||||
expect(plist.ok && plist.value.result).toMatchObject({
|
||||
source_path: join(app, "Info.plist"),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/** Emit entitlements whose dictionary also holds a legal `__proto__` key. */
|
||||
class PrototypeEntitlementsRunner extends FixtureRunner {
|
||||
override async run(tool: string, arguments_: readonly string[]) {
|
||||
|
||||
Reference in new issue
Block a user