Files
reverse-engineering-anything/src/native/NativeMacOSProvider.ts
T

1592 lines
51 KiB
TypeScript

import { translateNativeCommandFailure } from "./NativeCommandAnalysisError.js";
import {
plistSourceFormat,
isBinaryPlistDescription,
readNativePlistBytes,
requireRegularPlist,
nativePlistFailure,
resolveNativePlistSelection,
unreadablePlist,
type NativePlistSelection,
} from "./NativePlistSource.js";
import { bindArtifactFileCleanup } from "../artifacts/ArtifactFileOperation.js";
import { snapshotEnvironment } from "../process/snapshotEnvironment.js";
import {
APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
NATIVE_MACOS_PROVIDER_IDENTITY as IDENTITY,
nativeMacOSCapabilities,
} from "./NativeMacOSProviderMetadata.js";
import {
appleDispatchMetadataFailure,
inspectAppleDispatchMetadata,
} from "./AppleDispatchMetadata.js";
import {
observeNativeUi,
finishNativeUiObservation,
} from "./NativeUiObservation.js";
import { NativeUiCleanupError } from "./NativeUiCleanupError.js";
import { LldbCallTracer, type NativeCallTracer } from "./LldbCallTracer.js";
import { observeNativeCalls } from "./NativeCallObservation.js";
import { realpath } from "node:fs/promises";
import { z } from "zod";
import {
createAnalysisExecution,
type AnalysisClient,
type AnalysisOperation,
type AnalysisProvider,
type CapabilityDescriptor,
type ProviderIdentity,
} from "../application/AnalysisProvider.js";
import {
NATIVE_TOOL_CONTRACTS,
type NativeToolName,
} from "../contracts/native/nativeToolContracts.js";
import {
isMachOFormat,
type BinaryTarget,
} from "../domain/binaryTargetTypes.js";
import {
isAppleDispatchMetadataTarget,
nativeDispatchMetadataInputSchema,
} from "../domain/native/objcSwiftMetadata.js";
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js";
import type { EvidenceLocation } from "../domain/evidence.js";
import {
AnalysisCancelledError,
AnalysisCapabilityUnavailableError,
AnalysisInputError,
AnalysisOutputError,
AnalysisUnsupportedTargetError,
type AnalysisResourceConstraintError,
} from "../domain/analysisErrorCore.js";
import {
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
DEFAULT_NATIVE_UI_DEADLINE_MS,
MAX_NATIVE_TIMER_MS,
readNativeDeadline,
} from "../domain/native/nativeDeadlines.js";
import { ProviderAdapterError } from "../domain/providerAdapterError.js";
import { ProviderCleanupError } from "../domain/providerCleanupError.js";
import {
AnalysisError,
resolvedAnalysisError,
} from "../domain/analysisErrorBase.js";
import { jsonValueSchema, type JsonValue } from "../domain/jsonValue.js";
import {
projectPlistValue,
nonFiniteRealLimitation,
negativeZeroRealLimitation,
unknownDateLimitation,
unknownScalarLimitation,
} from "../domain/apple/plistValue.js";
import { createPlistNumberProjection } from "../domain/apple/plistNumbers.js";
import type { NativeUiObservationResult } from "../domain/native/nativeUiObservation.js";
import {
omittedPrototypeKeysLimitation,
parseXmlPropertyList,
} from "../domain/propertyListKeys.js";
import {
demangleSwiftSchema,
inspectPlistSchema,
inspectSignatureSchema,
type InspectSignature,
listArchitecturesSchema,
type NativeCommandInvocation,
} from "../domain/native/nativeInspection.js";
import { err, ok, type Result } from "../domain/result.js";
import {
NativeCommandFailure,
XcrunCommandRunner,
failureCapture,
type NativeCommandCleanupFailure,
type NativeCommandCapture,
type NativeCommandRunner,
} from "./CommandRunner.js";
import {
withSignatureTarget,
verifySignatureTarget,
type SignatureTargetBinding,
} from "./SignatureTargetBinding.js";
import { parseCodeSignature } from "./parsers/codesign.js";
import { parseDemangledSymbols } from "./parsers/demangle.js";
import { parseLipoCapture } from "./parsers/lipo.js";
import {
readPlistJson,
readPlistJsonSource,
parsePlistBinary,
parsePlistXml,
parsePlistXmlSource,
type ParsedPlist,
} from "./parsers/plist.js";
import { XmlPlistTextDecodeError } from "../domain/propertyListXmlText.js";
import {
architectureLocations,
inspectNativeMacho,
} from "./NativeMachoInspection.js";
import { analysisErrorWithCleanupFailure } from "../domain/analysisErrorCleanup.js";
import { ArtifactResourceScope } from "../artifacts/ArtifactResourceScope.js";
import { ArtifactReaderFailure } from "../artifacts/ArtifactReader.js";
import type { PrivateRuntimeRoot } from "../process/PrivateRuntimeRoot.js";
import {
createNativeUiHelperRuntime,
type NativeUiHelperRuntime,
} from "./NativeUiHelperRuntime.js";
import { NativeUiInterruptionControl } from "./NativeUiInterruptionControl.js";
import type { ResolvedAppBundleResourceRoot } from "../application/AppBundleExecutable.js";
import { resolveBundleResourcePlistSelection } from "./NativePlistSource.js";
import { UnsupportedBinaryPlistUidError } from "../artifacts/apple/PlistScalars.js";
type SignatureRuntimeRootFactory = () => Promise<
Pick<PrivateRuntimeRoot, "path" | "close">
>;
interface NativePlistReadContext {
readonly selection: NativePlistSelection;
readonly resources: ArtifactResourceScope;
readonly signal: AbortSignal | undefined;
readonly timeoutMs: number;
}
/** Read-only semantic provider composed from Xcode command-line utilities. */
export class NativeMacOSProvider implements AnalysisProvider {
readonly #capabilities: readonly CapabilityDescriptor[];
readonly #environment: NodeJS.ProcessEnv;
readonly #tracerFactory: () => NativeCallTracer;
readonly #privateRuntimeRootFactory: SignatureRuntimeRootFactory | undefined;
constructor(
environment: Readonly<NodeJS.ProcessEnv>,
private readonly runner: NativeCommandRunner = new XcrunCommandRunner(
environment,
),
platform: NodeJS.Platform = process.platform,
tracerFactory?: () => NativeCallTracer,
privateRuntimeRootFactory?: SignatureRuntimeRootFactory,
) {
this.#environment = snapshotEnvironment(environment, platform);
this.#tracerFactory =
tracerFactory ?? (() => new LldbCallTracer(this.#environment));
this.#privateRuntimeRootFactory = privateRuntimeRootFactory;
this.#capabilities = nativeMacOSCapabilities(platform);
}
identity(): ProviderIdentity {
return IDENTITY;
}
capabilities(): readonly CapabilityDescriptor[] {
return this.#capabilities;
}
createClient(target: BinaryTarget): AnalysisClient {
return new NativeMacOSClient(
target,
this.runner,
this.#tracerFactory(),
this.#environment,
this.#privateRuntimeRootFactory,
);
}
createBundleResourceClient(
root: ResolvedAppBundleResourceRoot,
): AnalysisClient {
const plistAvailable = this.#capabilities.some(
({ operation, available }) => operation === "inspect_plist" && available,
);
return new NativeMacOSClient(
undefined,
this.runner,
this.#tracerFactory(),
this.#environment,
this.#privateRuntimeRootFactory,
root,
plistAvailable,
);
}
}
class NativeMacOSClient implements AnalysisClient {
readonly #pendingNativeUiRuntimes = new Set<NativeUiHelperRuntime>();
readonly #pendingCommandOperations = new Set<Promise<unknown>>();
readonly #signatureResources = new ArtifactResourceScope();
readonly #plistResources = new ArtifactResourceScope();
readonly #callResources = new ArtifactResourceScope();
readonly #dispatchResources = new ArtifactResourceScope();
#nativeUiTail: Promise<void> = Promise.resolve();
#closed = false;
constructor(
private readonly activeTarget: BinaryTarget | undefined,
private readonly runner: NativeCommandRunner,
private readonly tracer: NativeCallTracer,
private readonly environment: Readonly<NodeJS.ProcessEnv>,
private readonly privateRuntimeRootFactory:
| SignatureRuntimeRootFactory
| undefined,
private readonly bundleResourceRoot?: ResolvedAppBundleResourceRoot,
private readonly bundlePlistAvailable = true,
) {}
private get target(): BinaryTarget {
if (this.activeTarget === undefined)
throw new Error("This provider client has no executable target");
return this.activeTarget;
}
async execute(
operation: AnalysisOperation,
parameters: Readonly<Record<string, JsonValue>>,
options?: { readonly signal?: AbortSignal },
) {
if (this.#closed)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Native provider client is closed",
),
);
if (options?.signal?.aborted === true)
return err(
new AnalysisCancelledError(operation, { cause: options.signal.reason }),
);
if (operation === "health")
return ok(createAnalysisExecution(null, IDENTITY));
if (this.activeTarget === undefined && operation !== "inspect_plist")
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"This client is bound to app resource access only.",
),
);
if (this.bundleResourceRoot !== undefined && !this.bundlePlistAvailable)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"inspect_plist",
"Property list inspection requires a macOS host.",
),
);
if (operation === "inspect_native_dispatch_metadata") {
const input = nativeDispatchMetadataInputSchema.safeParse(parameters);
if (!input.success)
return err(
analysisInputErrorFromIssues(
operation,
input.error.issues,
parameters,
{
cause: input.error,
},
),
);
if (!isAppleDispatchMetadataTarget(this.target))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Binary Apple metadata requires Mach-O; other providers can report symbol-only metadata",
),
);
try {
const result = await this.#dispatchResources.run(() =>
inspectAppleDispatchMetadata(
this.target,
input.data.max_records,
this.#dispatchResources,
{
signal: options?.signal,
objcClassImportAddresses: input.data.objc_class_import_addresses,
objcClassImportAddressesComplete:
input.data.objc_class_import_addresses_complete ?? true,
maxInputBytes: input.data.max_input_bytes,
},
),
);
return ok(
createAnalysisExecution(result, result.provider, {
analysisProfile: APPLE_DISPATCH_METADATA_ANALYSIS_PROFILE,
locations: [{ kind: "artifact-path", path: this.target.path }],
limitations: result.result.coverage.flatMap(({ reason }) =>
reason === null ? [] : [reason],
),
}),
);
} catch (cause) {
return err(appleDispatchMetadataFailure(cause, this.target.path));
}
}
if (!isNativeOperation(operation))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Operation is not implemented by native macOS tools.",
),
);
if (operation === "observe_native_calls") {
const result = await observeNativeCalls(
this.target,
parameters,
this.tracer,
this.#callResources,
options?.signal,
);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: result.value.limitations,
locations: [{ kind: "artifact-path", path: this.target.path }],
}),
)
: result;
}
if (
operation === "observe_native_ui" ||
operation === "capture_native_ui_scenario"
) {
return this.#observeNativeUi(operation, parameters, options?.signal);
}
const pending = Promise.resolve().then(() =>
this.#dispatch(operation, parameters, options?.signal),
);
this.#pendingCommandOperations.add(pending);
try {
const observation = await pending;
return observation.ok
? ok(
createAnalysisExecution(observation.value.result, IDENTITY, {
limitations: observation.value.limitations,
locations: observation.value.locations,
}),
)
: err(resolvedAnalysisError(observation.error));
} catch (cause: unknown) {
return err(nativeOutputFailure(operation, cause));
} finally {
this.#pendingCommandOperations.delete(pending);
}
}
async close(): Promise<Result<null, AnalysisError>> {
this.#closed = true;
await Promise.allSettled(this.#pendingCommandOperations);
const uiCleanupFailures = await this.#serializeNativeUi(() =>
this.#retryNativeUiCleanup(),
);
const signatureCleanup = await closeNativeFileScope(
this.#signatureResources,
"signature-snapshot",
);
const plistCleanup = await closeNativeFileScope(
this.#plistResources,
"plist-file",
);
const callCleanup = await closeNativeFileScope(
this.#callResources,
"native-call-target",
);
const dispatchCleanup = await closeNativeFileScope(
this.#dispatchResources,
"native-dispatch-metadata-target",
);
const commandCleanup = await this.#retryNativeCommandCleanup();
let result = await this.tracer.close();
const cleanupErrors = [
...(uiCleanupFailures.length === 0
? []
: [this.#nativeUiCleanupFailure(uiCleanupFailures, "close_binary")]),
...(commandCleanup === undefined ? [] : [commandCleanup]),
...(signatureCleanup === undefined ? [] : [signatureCleanup]),
...(plistCleanup === undefined ? [] : [plistCleanup]),
...(callCleanup === undefined ? [] : [callCleanup]),
...(dispatchCleanup === undefined ? [] : [dispatchCleanup]),
];
for (const cleanupError of cleanupErrors)
result = result.ok
? err(cleanupError)
: err(
analysisErrorWithCleanupFailure(
result.error,
cleanupError,
).bindCleanupDependencies([result.error, cleanupError]),
);
return result;
}
#observeNativeUi(
operation: "observe_native_ui" | "capture_native_ui_scenario",
parameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
) {
return this.#serializeNativeUi(async () => {
if (this.#closed)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"Native provider client is closed",
),
);
const earlierCleanupFailures = await this.#retryNativeUiCleanup();
if (earlierCleanupFailures.length > 0)
return err(
this.#nativeUiCleanupFailure(earlierCleanupFailures, operation),
);
const deadline = readNativeDeadline(
parameters.native_ui_deadline_ms,
DEFAULT_NATIVE_UI_DEADLINE_MS,
);
if (!deadline.ok)
return err(
new AnalysisInputError(operation, { userMessage: deadline.message }, [
{
path: ["native_ui_deadline_ms"],
reason: deadline.reason,
message: deadline.message,
minimum: 1,
maximum: MAX_NATIVE_TIMER_MS,
},
]),
);
const runtime = createNativeUiHelperRuntime(
this.environment,
deadline.timeoutMs,
);
const interruption = new NativeUiInterruptionControl(
signal,
runtime.signal,
deadline.timeoutMs,
);
try {
this.#pendingNativeUiRuntimes.add(runtime);
let result: Awaited<ReturnType<typeof observeNativeUi>>;
try {
result = await observeNativeUi(this.target, operation, parameters, {
environment: this.environment,
runtime,
interruption,
});
} catch (cause: unknown) {
result = err(
cause instanceof AnalysisError
? cause
: new ProviderAdapterError(IDENTITY.id, operation, { cause }),
);
}
try {
await runtime.close();
this.#pendingNativeUiRuntimes.delete(runtime);
} catch (cause: unknown) {
result = finishNativeUiObservation(result, operation, interruption);
const cleanup = this.#nativeUiCleanupFailure(
[{ runtime, cause }],
operation,
result.ok ? result.value : undefined,
);
return err(
result.ok
? cleanup
: analysisErrorWithCleanupFailure(
result.error,
cleanup,
operation,
).bindCleanupDependencies([result.error, cleanup]),
);
}
result = finishNativeUiObservation(result, operation, interruption);
return result.ok
? ok(
createAnalysisExecution(result.value, IDENTITY, {
limitations: result.value.limitations,
}),
)
: result;
} finally {
interruption.dispose();
}
});
}
#serializeNativeUi<Value>(operation: () => Promise<Value>): Promise<Value> {
const call = this.#nativeUiTail.then(operation, operation);
this.#nativeUiTail = call.then(
() => undefined,
() => undefined,
);
return call;
}
async #retryNativeUiCleanup(): Promise<
Array<{ readonly runtime: NativeUiHelperRuntime; readonly cause: unknown }>
> {
const failures: Array<{
readonly runtime: NativeUiHelperRuntime;
readonly cause: unknown;
}> = [];
for (const runtime of this.#pendingNativeUiRuntimes) {
try {
await runtime.close();
this.#pendingNativeUiRuntimes.delete(runtime);
} catch (cause: unknown) {
failures.push({ runtime, cause });
}
}
return failures;
}
async #retryNativeCommandCleanup(): Promise<AnalysisError | undefined> {
if (this.runner.retryCleanup === undefined) return undefined;
try {
const failures = await this.runner.retryCleanup();
return failures.length === 0
? undefined
: nativeCommandCleanupFailure(failures);
} catch (cause: unknown) {
return new ProviderCleanupError(
IDENTITY.id,
["native-command-process-cleanup"],
{
reason:
cause instanceof Error
? cause.message || cause.name
: String(cause) || "native command cleanup failed unexpectedly",
},
{ cause },
);
}
}
#nativeUiCleanupFailure(
failures: readonly {
readonly runtime: NativeUiHelperRuntime;
readonly cause: unknown;
}[],
operation: string,
partialObservation?: NativeUiObservationResult,
): ProviderCleanupError {
return new NativeUiCleanupError(failures, operation, partialObservation);
}
#dispatch(
operation: NativeToolName,
parameters: Readonly<Record<string, JsonValue>>,
signal?: AbortSignal,
): Promise<Result<NativeObservation, AnalysisError>> {
switch (operation) {
case "observe_native_ui":
case "capture_native_ui_scenario":
case "observe_native_calls":
return Promise.resolve(
err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
operation,
"UI observation uses its explicit authority boundary",
),
),
);
case "inspect_macho":
case "inspect_signature":
case "inspect_plist":
case "list_architectures":
case "demangle_swift":
return this.#dispatchCommand(operation, parameters, signal);
}
}
#dispatchCommand(
operation:
| "inspect_macho"
| "inspect_signature"
| "inspect_plist"
| "list_architectures"
| "demangle_swift",
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
): Promise<Result<NativeObservation, AnalysisError>> {
const timeout = readNativeDeadline(
parameters.native_command_timeout_ms,
DEFAULT_NATIVE_COMMAND_TIMEOUT_MS,
);
if (!timeout.ok)
return Promise.resolve(
err(
new AnalysisInputError(operation, { userMessage: timeout.message }, [
{
path: ["native_command_timeout_ms"],
reason: timeout.reason,
message: timeout.message,
minimum: 1,
maximum: MAX_NATIVE_TIMER_MS,
},
]),
),
);
const timeoutMs = timeout.timeoutMs;
switch (operation) {
case "inspect_macho":
return this.#inspectMacho(signal, timeoutMs);
case "inspect_signature":
return this.#inspectSignature(signal, timeoutMs);
case "inspect_plist":
return this.#inspectPlist(parameters, signal, timeoutMs);
case "list_architectures":
return this.#listArchitectures(signal, timeoutMs);
case "demangle_swift":
return this.#demangle(parameters, signal, timeoutMs);
}
}
async #listArchitectures(
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
// lipo reads only Mach-O; its refusal of a PE, ELF, or plist target is not
// a tool failure that a retry or `rea doctor` could repair.
if (!isMachOFormat(this.target.format))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"list_architectures",
"Active artifact is not Mach-O.",
),
);
const capture = await this.#run(
"list_architectures",
"lipo",
["-detailed_info", this.target.path],
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const parsed = parseLipoCapture(
capture.value,
"list_architectures",
this.target.path,
);
if (!parsed.ok) return parsed;
const { architectures } = parsed.value;
const result = listArchitecturesSchema.parse({
architectures: {
items: architectures,
total: architectures.length,
exhaustive: true,
limitations: [],
},
provenance,
limitations: [],
});
return ok({
result: jsonValueSchema.parse(result),
limitations: [],
locations: architectureLocations(result.architectures.items),
});
}
async #demangle(
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
const symbols = z.array(z.string()).safeParse(parameters.symbols);
if (!symbols.success)
return err(
new AnalysisOutputError(
"demangle_swift",
"symbols were not parsed strings",
),
);
const capture = await this.#run(
"demangle_swift",
"swift-demangle",
// `--` keeps symbols that begin with `-` from selecting demangler options.
["--compact", "--", ...symbols.data],
{ signal, timeoutMs },
);
if (!capture.ok) return capture;
const provenance = [invocation(capture.value, this.target.path)];
const result = demangleSwiftSchema.parse({
symbols: parseDemangledSymbols(symbols.data, capture.value.stdout),
provenance,
limitations: [],
});
return ok({
result: jsonValueSchema.parse(result),
limitations: [],
locations: [],
});
}
async #inspectSignature(
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
// This provider's signature inspection covers Mach-O code signatures
// only. On another container, codesign's "not signed" verdict cannot
// see a format-level signature such as a xar pkg signature, so
// reporting it would claim unsigned for a layer that was never
// evaluated.
if (this.target.kind !== "executable" || !isMachOFormat(this.target.format))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"inspect_signature",
"Active artifact is not Mach-O; its container-level signature layer is not evaluated by codesign.",
),
);
const inspected = await withSignatureTarget(
this.target,
this.#signatureResources,
(binding) => this.#inspectBoundSignature(binding, signal, timeoutMs),
signal,
this.privateRuntimeRootFactory,
);
return inspected.ok
? ok({
result: inspected.value,
limitations: inspected.value.limitations,
locations: [],
})
: inspected;
}
async #inspectBoundSignature(
binding: SignatureTargetBinding,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<InspectSignature, AnalysisError>> {
const architecture =
this.target.kind === "executable" ? this.target.architecture : undefined;
if (architecture === undefined)
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"inspect_signature",
"No executable architecture is selected.",
),
);
const selectedArchitecture = architecture === "x86" ? "i386" : architecture;
const executablePaths = [
binding.snapshotPath,
...(await canonicalPath(binding.snapshotPath)),
];
const display = await this.#captureSignature(
binding,
["-d", "-a", selectedArchitecture, "--verbose=4"],
signal,
timeoutMs,
);
if (!display.ok) return display;
const unsigned = isNonzeroUnsignedObservation(
display.value,
binding.snapshotPath,
executablePaths,
);
const displayFailure = codeSignCaptureFailure(
display.value,
binding.snapshotPath,
executablePaths,
);
if (displayFailure !== null) return err(displayFailure);
const requirements = await this.#captureSignature(
binding,
["-d", "-a", selectedArchitecture, "-r-"],
signal,
timeoutMs,
);
if (!requirements.ok) return requirements;
const requirementsFailure = codeSignCaptureFailure(
requirements.value,
binding.snapshotPath,
executablePaths,
);
if (requirementsFailure !== null) return err(requirementsFailure);
const entitlements = await this.#captureSignature(
binding,
["-d", "-a", selectedArchitecture, "--entitlements", ":-"],
signal,
timeoutMs,
);
if (!entitlements.ok) return entitlements;
const entitlementsFailure = codeSignCaptureFailure(
entitlements.value,
binding.snapshotPath,
executablePaths,
);
if (entitlementsFailure !== null) return err(entitlementsFailure);
// codesign echoes the resolved executable path in its diagnostics.
const parsed = parseCodeSignature(
display.value.stderr,
unsigned,
executablePaths,
);
// The requirement is printed to stdout; stderr echoes the path.
const requirementText =
/designated\s*=>\s*(.+)$/mu.exec(requirements.value.stdout)?.[1] ?? null;
// Entitlements XML is printed to stdout; stderr echoes the path.
const entitlementValue = parseEntitlements(entitlements.value.stdout);
const captures = [display.value, requirements.value, entitlements.value];
const limitations = [
...parsed.limitations,
...entitlementValue.limitations.map((note) => `Entitlements: ${note}`),
"Inspected a digest-verified Mach-O byte snapshot; filesystem-bound signing context and trust validation are not examined.",
...(entitlementValue.omittedPrototypeKeys === 0
? []
: [
`Entitlements: ${omittedPrototypeKeysLimitation(entitlementValue.omittedPrototypeKeys)}`,
]),
];
const architectures = codeSignArchitectures(parsed.format, this.target);
if (architectures.length > 1)
limitations.push(
`Partial signature coverage: signature identity, hashes, designated requirement, entitlements and runtime state describe the requested ${selectedArchitecture} architecture; other slices' metadata is not returned.`,
);
if (
!unsigned &&
isNonzeroUnsignedObservation(
requirements.value,
binding.snapshotPath,
executablePaths,
)
)
limitations.push(
"The designated requirement for the requested architecture is unavailable because codesign reported it unsigned.",
);
if (
!unsigned &&
isNonzeroUnsignedObservation(
entitlements.value,
binding.snapshotPath,
executablePaths,
)
)
limitations.push(
"The entitlements for the requested architecture are unavailable because codesign reported it unsigned.",
);
const observation = () =>
inspectSignatureSchema.parse({
...parsed,
designated_requirement: requirementText,
entitlements: entitlementValue.value,
provenance: captures.map((capture) =>
invocation(capture, binding.snapshotPath),
),
limitations,
});
if (architectures.length > 1) {
const slices = await this.#inspectSignatureSliceStates(
binding,
parsed.format,
executablePaths,
{ captures, limitations },
signal,
timeoutMs,
);
if (!slices.ok) {
limitations.push(
"Architecture-specific signature inspection did not complete; remaining slice signing states are unknown.",
);
return err(slices.error.retainPartialObservation(observation()));
}
}
const version = await verifySignatureTarget(this.target, binding, signal);
if (!version.ok)
return err(version.error.retainPartialObservation(observation()));
return ok(observation());
}
async #inspectSignatureSliceStates(
binding: SignatureTargetBinding,
format: string | null,
executablePaths: readonly string[],
observation: { captures: NativeCommandCapture[]; limitations: string[] },
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<void, AnalysisError>> {
const { captures, limitations } = observation;
const unsigned: string[] = [];
let unsignedNote: number | undefined;
for (const architecture of codeSignArchitectures(format, this.target)) {
const slice = await this.#captureSignature(
binding,
["-d", "-a", architecture, "--verbose=4"],
signal,
timeoutMs,
);
if (!slice.ok) return slice;
captures.push(slice.value);
if (slice.value.exitCode === 0) continue;
const failure = codeSignCaptureFailure(
slice.value,
binding.snapshotPath,
executablePaths,
);
if (failure !== null) return err(failure);
unsigned.push(architecture);
if (unsignedNote === undefined) {
unsignedNote = limitations.length;
limitations.push("");
}
limitations[unsignedNote] =
`Unsigned Mach-O slices: ${unsigned.join(", ")}.`;
}
return ok(undefined);
}
async #captureSignature(
binding: SignatureTargetBinding,
args: readonly string[],
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeCommandCapture, AnalysisError>> {
const before = await verifySignatureTarget(this.target, binding, signal);
if (!before.ok) return before;
let capture: Result<NativeCommandCapture, AnalysisError>;
try {
capture = await this.#run(
"inspect_signature",
"codesign",
[...args, binding.snapshotPath],
{
signal,
acceptNonZero: true,
timeoutMs,
},
);
} catch (cause: unknown) {
capture = err(
new ProviderAdapterError(IDENTITY.id, "inspect_signature", {
cause,
diagnostics: {
phase: "codesign-capture",
path: this.target.path,
reason: cause instanceof Error ? cause.message : String(cause),
},
}),
);
}
// Verify failed captures too: a disappearing target is not a codesign
// execution failure, and later captures must never use another version.
const after = await verifySignatureTarget(this.target, binding, signal);
return after.ok ? capture : after;
}
async #inspectPlist(
parameters: Readonly<Record<string, JsonValue>>,
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
const requested = parameters.path;
if (requested !== undefined && typeof requested !== "string")
return err(
new AnalysisOutputError("inspect_plist", "path was not a string"),
);
try {
return await this.#plistResources.run(async () => {
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));
}
});
} catch (cause: unknown) {
return err(nativePlistFailure(cause, undefined));
}
}
async #inspectBinaryPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
): Promise<Result<NativeObservation, AnalysisError> | undefined> {
if (!isBinaryPlistDescription(classified.stdout)) return undefined;
const source = await readNativePlistBytes(fileInput);
if (!source.ok) return source;
const parsed = parsePlistBinary(source.value);
if (!parsed.ok) {
if (parsed.error instanceof UnsupportedBinaryPlistUidError)
return err(
new AnalysisUnsupportedTargetError(
"inspect_plist",
fileInput.selection.path,
parsed.error.message,
{ cause: parsed.error },
),
);
return parsed.error instanceof AnalysisOutputError
? err(
unreadablePlist(
fileInput.selection,
"binary property list could not be decoded",
{ cause: parsed.error },
),
)
: err(parsed.error);
}
return ok(
nativePlistObservation({
parsed: parsed.value,
format: "binary",
path: fileInput.selection.path,
resourceRoot: this.bundleResourceRoot,
provenance: [
invocation(classified, fileInput.selection.path, "$PLIST"),
],
}),
);
}
async #inspectTextPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
format: z.infer<typeof inspectPlistSchema>["format"],
): Promise<Result<NativeObservation, AnalysisError> | undefined> {
const source = await readNativePlistBytes(fileInput);
if (!source.ok) return source;
let parsed: ReturnType<typeof parsePlistXmlSource>;
const provenance = [
invocation(classified, fileInput.selection.path, "$PLIST"),
];
if (format === "xml") {
parsed = parsePlistXmlSource(source.value);
if (parsed.ok) {
const validated = await this.#run(
"inspect_plist",
"plutil",
["-lint", "--", fileInput.selection.path],
{
signal: fileInput.signal,
acceptNonZero: true,
timeoutMs: fileInput.timeoutMs,
},
);
if (!validated.ok) return validated;
if (validated.value.exitCode !== 0)
return err(
nativePlistDecodeFailure(fileInput.selection, validated.value),
);
provenance.push(
invocation(validated.value, fileInput.selection.path, "$PLIST"),
);
}
if (
!parsed.ok &&
parsed.error instanceof XmlPlistTextDecodeError &&
parsed.error.reason === "unsupported-encoding"
)
return undefined;
} else {
const json = readPlistJsonSource(source.value);
if (
json === undefined ||
json.rootKind === "invalid" ||
(json.rootKind !== "container" && format !== "json") ||
json.needsNumberTypes ||
json.nonFiniteNumberObserved
)
return undefined;
parsed = json.project();
}
if (!parsed.ok)
return parsed.error instanceof AnalysisOutputError ||
parsed.error instanceof XmlPlistTextDecodeError
? err(
unreadablePlist(fileInput.selection, parsed.error.message, {
cause: parsed.error,
}),
)
: err(parsed.error);
return ok(
nativePlistObservation({
parsed: parsed.value,
format,
path: fileInput.selection.path,
resourceRoot: this.bundleResourceRoot,
provenance,
}),
);
}
async #inspectConvertedPlist(
fileInput: NativePlistReadContext,
classified: NativeCommandCapture,
format: z.infer<typeof inspectPlistSchema>["format"],
): Promise<Result<NativeObservation, AnalysisError>> {
const { path } = fileInput.selection;
const { signal, timeoutMs } = fileInput;
const json = await this.#run(
"inspect_plist",
"plutil",
["-convert", "json", "-o", "-", "--", path],
{ signal, acceptNonZero: true, timeoutMs },
);
if (!json.ok) return json;
// JSON cannot express data, dates, or non-finite reals; plutil rejects
// such plists, so decode its XML conversion instead. JSON also prints an
// integral <real> like an <integer>, so XML identifies unsafe number types.
const jsonDecoded = json.value.exitCode === 0;
const jsonAttempt = jsonDecoded
? readPlistJson(json.value.stdout)
: undefined;
let xml: NativeCommandCapture | undefined;
let parsed: Result<
ParsedPlist,
AnalysisOutputError | AnalysisResourceConstraintError
>;
if (jsonAttempt === undefined || jsonAttempt.needsNumberTypes) {
const converted = await this.#run(
"inspect_plist",
"plutil",
["-convert", "xml1", "-o", "-", "--", path],
{ signal, acceptNonZero: !jsonDecoded, timeoutMs },
);
if (!converted.ok) return converted;
xml = converted.value;
if (xml.exitCode !== 0) {
return err(nativePlistDecodeFailure(fileInput.selection, xml));
}
parsed =
jsonAttempt === undefined
? parsePlistXml(xml.stdout)
: jsonAttempt.project(xml.stdout);
} else {
parsed = jsonAttempt.project();
}
if (!parsed.ok) return parsed;
const provenance = [
classified,
json.value,
...(xml === undefined ? [] : [xml]),
].map((item) => invocation(item, path, "$PLIST"));
return ok(
nativePlistObservation({
parsed: parsed.value,
format,
path,
resourceRoot: this.bundleResourceRoot,
provenance,
}),
);
}
async #inspectMacho(
signal: AbortSignal | undefined,
timeoutMs: number,
): Promise<Result<NativeObservation, AnalysisError>> {
if (!isMachOFormat(this.target.format))
return err(
new AnalysisCapabilityUnavailableError(
IDENTITY.id,
"inspect_macho",
"Active artifact is not Mach-O.",
),
);
return inspectNativeMacho({
target: this.target,
...(signal === undefined ? {} : { signal }),
run: (tool, arguments_, commandSignal, acceptNonZeroIf) =>
this.#run("inspect_macho", tool, arguments_, {
signal: commandSignal,
timeoutMs,
...(acceptNonZeroIf === undefined ? {} : { acceptNonZeroIf }),
}),
invocation: (capture) => invocation(capture, this.target.path),
});
}
async #run(
operation: NativeToolName,
tool: string,
arguments_: readonly string[],
options: {
readonly signal?: AbortSignal | undefined;
readonly timeoutMs: number;
readonly acceptNonZero?: boolean;
readonly acceptNonZeroIf?:
| ((capture: NativeCommandCapture) => boolean)
| undefined;
},
): Promise<Result<NativeCommandCapture, AnalysisError>> {
// Commands borrow their enclosing operation's admission through finalization.
const captured = await this.runner.run(tool, arguments_, {
...(options.signal === undefined ? {} : { signal: options.signal }),
timeoutMs: options.timeoutMs,
acceptNonZero:
options.acceptNonZero ?? options.acceptNonZeroIf !== undefined,
});
if (captured.ok) {
const hasFailedExit =
captured.value.exitCode !== 0 || captured.value.signal !== null;
if (
hasFailedExit &&
options.acceptNonZeroIf !== undefined &&
!options.acceptNonZeroIf(captured.value)
)
return err(
translateNativeCommandFailure(
operation,
new NativeCommandFailure(
tool,
captured.value.signal !== null ||
(captured.value.exitCode !== null &&
captured.value.exitCode !== 0)
? "nonzero-exit"
: "io",
captured.value.exitCode,
undefined,
{
stdout: captured.value.stdout,
stderr: captured.value.stderr,
stdoutBytes: captured.value.stdoutBytes,
stderrBytes: captured.value.stderrBytes,
exitCode: captured.value.exitCode,
signal: captured.value.signal,
truncated: false,
},
),
),
);
return captured;
}
return err(
translateNativeCommandFailure(
operation,
captured.error,
options.timeoutMs,
),
);
}
}
const nativeCommandCleanupFailure = (
failures: readonly NativeCommandCleanupFailure[],
): ProviderCleanupError => {
const failure = new ProviderCleanupError(
IDENTITY.id,
[...new Set(failures.flatMap(({ resources }) => resources))],
{
reason: "Native command process cleanup remains incomplete",
failures: failures.map(({ reason, resources }) => ({
reason,
resources: [...resources],
})),
},
);
if (
failures.length === 0 ||
failures.some(({ marker }) => marker === undefined)
)
return failure;
const markers = failures.flatMap(({ marker }) =>
marker === undefined ? [] : [marker],
);
return markers.length === 0
? failure
: failure.bindCleanupDependencies(markers);
};
/** Each file workflow retains its own exact resource set through client close. */
const closeNativeFileScope = async (
scope: ArtifactResourceScope,
resource: string,
): Promise<AnalysisError | undefined> => {
try {
await scope.close();
return undefined;
} catch (cause: unknown) {
const observation = ArtifactReaderFailure.cleanupObservation(
cause,
resource,
);
const failure = new ProviderCleanupError(
IDENTITY.id,
observation.resources,
{ reason: observation.reason },
{ operation: "close_binary", cause },
);
bindArtifactFileCleanup(cause, failure);
return failure;
}
};
const nativeOutputFailure = (
operation: AnalysisOperation,
cause: unknown,
): AnalysisError =>
new AnalysisOutputError(operation, "Native output parsing failed", { cause });
interface NativeObservation {
readonly result: JsonValue;
readonly limitations: readonly string[];
readonly locations: readonly EvidenceLocation[];
}
const isNativeOperation = (
operation: AnalysisOperation,
): operation is NativeToolName =>
NATIVE_TOOL_CONTRACTS.some(({ name }) => name === operation);
const translateCodeSignExitFailure = (
capture: NativeCommandCapture,
): AnalysisError =>
translateNativeCommandFailure(
"inspect_signature",
new NativeCommandFailure(
"codesign",
"nonzero-exit",
capture.exitCode,
undefined,
// Accepted output stays complete until the provider decides it failed.
failureCapture(capture, false),
),
);
/**
* Accept only codesign's exact unsigned diagnostic for the bound snapshot,
* optionally preceded by its verified Executable path echo.
* The operand is framed byte-for-byte so caller-controlled path text cannot
* supply the phrase that determines signature state.
*/
const isNonzeroUnsignedObservation = (
capture: NativeCommandCapture,
snapshotPath: string,
executablePaths: readonly string[],
): boolean => {
if (
capture.exitCode === null ||
capture.exitCode === 0 ||
capture.signal !== null ||
capture.arguments.at(-1) !== snapshotPath ||
capture.stdout !== ""
)
return false;
const diagnostic = `${snapshotPath}: code object is not signed at all`;
return [
"",
...executablePaths.flatMap((path) => [
`Executable=${path}\n`,
`Executable=${path}\r\n`,
]),
].some((prefix) =>
[diagnostic, `${diagnostic}\n`, `${diagnostic}\r\n`].some(
(body) => capture.stderr === `${prefix}${body}`,
),
);
};
const canonicalPath = async (path: string): Promise<string[]> => {
try {
return [await realpath(path)];
} catch {
return [];
}
};
const codeSignCaptureFailure = (
capture: NativeCommandCapture,
snapshotPath: string,
executablePaths: readonly string[],
): AnalysisError | null =>
capture.exitCode === 0 ||
isNonzeroUnsignedObservation(capture, snapshotPath, executablePaths)
? null
: translateCodeSignExitFailure(capture);
const codeSignArchitectures = (
format: string | null,
target: BinaryTarget,
): string[] => {
const universal = /^Mach-O universal \(([^)\r\n]+)\)$/u.exec(
format ?? "",
)?.[1];
if (universal !== undefined) {
const architectures = universal
.split(/\s+/u)
.filter((architecture) => /^[A-Za-z][A-Za-z0-9_.]*$/u.test(architecture));
if (architectures.length > 0) return [...new Set(architectures)];
}
if (format?.startsWith("Mach-O thin ") === true) return [];
if (target.kind !== "executable" || !isMachOFormat(target.format)) return [];
return target.availableArchitectures.map((architecture) =>
architecture === "x86" ? "i386" : architecture,
);
};
const invocation = (
capture: NativeCommandCapture,
artifactPath: string,
alias = "$ARTIFACT",
): NativeCommandInvocation => ({
tool: capture.tool,
command: [
capture.executable,
...capture.arguments.map((argument) =>
argument === artifactPath ? alias : argument,
),
],
tool_version: capture.toolVersion,
version_reason: capture.versionReason,
executable_sha256: capture.executableSha256,
exit: { code: capture.exitCode, signal: capture.signal },
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
});
const nativePlistObservation = (input: {
readonly parsed: ParsedPlist;
readonly format: z.infer<typeof inspectPlistSchema>["format"];
readonly path: string;
readonly resourceRoot: ResolvedAppBundleResourceRoot | undefined;
readonly provenance: readonly NativeCommandInvocation[];
}): NativeObservation => {
const { parsed, format, path, resourceRoot, provenance } = input;
const result = inspectPlistSchema.parse({
format,
value: parsed.value,
bundle: parsed.bundle,
source_path: path,
...(resourceRoot === undefined
? {}
: {
resource_root: {
caller_path: resourceRoot.callerPath,
canonical_path: resourceRoot.canonicalPath,
info_plist_path: resourceRoot.infoPlistPath,
layout: resourceRoot.layout,
executable_sha256: resourceRoot.executableSha256,
},
}),
provenance,
limitations: parsed.limitations,
});
return {
result: jsonValueSchema.parse(result),
limitations: parsed.limitations,
locations: [{ kind: "artifact-path", path }],
};
};
const parseEntitlements = (
output: string,
): {
readonly value: JsonValue;
readonly omittedPrototypeKeys: number;
readonly limitations: readonly string[];
} => {
const start = output.indexOf("<?xml");
const end = output.lastIndexOf("</plist>");
if (start < 0 || end < start)
return { value: null, omittedPrototypeKeys: 0, limitations: [] };
const decoded = parseXmlPropertyList(
output.slice(start, end + "</plist>".length),
);
const projected = projectPlistValue(decoded.value);
const numbers = createPlistNumberProjection(decoded.numberLiterals);
const value = numbers.project(projected.value);
return {
value,
omittedPrototypeKeys: decoded.omittedPrototypeKeys,
limitations: [
...numbers.limitations(),
...(projected.nonFiniteRealCount === 0
? []
: [nonFiniteRealLimitation(projected.nonFiniteRealCount)]),
...(projected.negativeZeroRealCount === 0
? []
: [negativeZeroRealLimitation(projected.negativeZeroRealCount)]),
...(projected.unknownDateCount === 0
? []
: [unknownDateLimitation(projected.unknownDateCount)]),
...(projected.unknownScalarCount === 0
? []
: [unknownScalarLimitation(projected.unknownScalarCount)]),
],
};
};
/** Native admission failures preserve the selected/default source and physical command output. */
const nativePlistDecodeFailure = (
selection: NativePlistSelection,
command: NativeCommandCapture,
): AnalysisError => {
const capture = failureCapture(command, false);
return unreadablePlist(
selection,
`plutil could not decode it as a property list (${plutilDiagnostic(capture, selection.path)})`,
{
capturedOutput: {
stdout: capture.stdout,
stderr: capture.stderr,
truncated: capture.truncated,
stdout_bytes: capture.stdoutBytes,
stderr_bytes: capture.stderrBytes,
exit_code: capture.exitCode,
signal: capture.signal,
},
},
);
};
const plutilDiagnostic = (
capture: Pick<NativeCommandCapture, "stdout" | "stderr">,
path: string,
): string => {
const diagnostics = [capture.stderr, capture.stdout]
.map((stream) => {
const text = stream.trim();
return text.startsWith(`${path}: `) ? text.slice(path.length + 2) : text;
})
.filter((text) => text.length > 0);
return diagnostics.length === 0
? "plutil printed no diagnostic"
: diagnostics.join("\n");
};