feat(apple): join IPS images to admitted Mach-O slices (#9570)

* feat(apple): join Apple IPS frames to a selected Mach-O or dSYM slice

inspect_apple_ips reported every image UUID as unknown and stated that joined
frames were unobserved. Accept an optional join_path, read its LC_UUID and
__TEXT vmaddr with the existing bounded stable-artifact reader, and match by
canonical UUID plus architecture.

A frame joins only when exactly one slice carries the image UUID and an
agreeing architecture, so arm64 and arm64e never cross, all-zero UUIDs never
join, and a conflicted or unreadable artifact stays typed instead of guessed.
Joined frames report the slice __TEXT vmaddr plus the recorded imageOffset as
a static address, which is the value atos resolves for the same report.
Symbolication is still not performed.

* refactor(apple): remove superseded join integer type import

* fix(apple): share FAT admission and preserve completed join facts

* fix(apple): bind standalone joins to admitted source identity

---------

Co-authored-by: 2SinceJuly <202121119+2SinceJuly@users.noreply.github.com>
Co-authored-by: morluto <76467478+morluto@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-11-09 20:20:33 +08:00
1 parent 645735fc0f
commit 97b5503f39
15 files changed
+1376 -45

No files matched your search

+63 -4
View File
@@ -85,9 +85,45 @@ canonical hexadecimal form), `EXC_GUARD` subtypes,
pointer-authentication suffixes, and dyld missing-library strings. A report
that matches none of those rules is `unclassified`.
Opened Mach-O and dSYM UUID joins remain unobserved. Symbol names printed
inside the report are recorded claims and are not verified. Linux ELF cores
use the separate [recorded crash](guides/native/recorded-crashes.md) workflow.
## Joining frames to the crashed Mach-O or dSYM
Pass `join_path` (`--join-path` in the CLI) to select one local Mach-O
executable or dSYM. UUID bytes in the report and the artifact's `LC_UUID` load
commands are compared in hexadecimal, so an uppercase `dwarfdump --uuid` value
and a lowercase report `uuid` match.
```bash
rea inspect-apple-ips /absolute/path/to/report.ips \
--join-path /absolute/path/to/App.app/Contents/MacOS/App --json
```
Shared-cache image sources `S` and `C` remain unjoined: UUID agreement does not
establish that a standalone image has the cached layout.
For a FAT container every slice is examined. An image joins only when exactly
one slice has that UUID **and** an architecture that agrees with the report's
`arch`; `arm64` and `arm64e` stay distinct, and an all-zero UUID never joins. A
UUID match with a different architecture, more than one compatible slice, or a
slice without a readable `__TEXT` segment is reported as a rejected image with
its reason and stays unjoined rather than being guessed.
`join.status` is `selected` with the artifact path, SHA-256, byte length, every
slice's offset, arch, CPU type and subtype, UUID, and `__TEXT` vmaddr, plus
`image_matches`, per-frame results, and join-specific limitations. A joined
frame reports `static_address`, the slice `__TEXT` vmaddr plus the frame's
recorded `imageOffset`, in decimal and hexadecimal. An unjoined frame reports
a reason instead; an unknown image UUID, an absent `imageIndex` row, and a
frame without `imageOffset` all stay unjoined rather than treated as aligned.
An artifact that is not a Mach-O image or FAT container reports
`join.status: "unreadable"` with a typed reason and leaves every report fact
intact. Symbol names printed inside the report stay recorded claims. Function
or symbol lookup at a joined static address, and matching dSYM symbol tables,
are not performed by this step.
Symbol names printed inside the report are recorded claims and are not
verified. Linux ELF cores use the separate
[recorded crash](guides/native/recorded-crashes.md) workflow.
## Limits and failures
@@ -120,4 +156,27 @@ success or truncated integer facts.
rea inspect-apple-ips /absolute/path/to/report.ips --max-integer-text-bytes 16777216 --json
```
This workflow does not execute a target, launch atos, or search for dSYMs.
`max_join_bytes` (`--max-join-bytes`) independently bounds the join artifact
read, defaulting to 64 MiB. This workflow does not execute a target, launch
atos, or search for dSYMs.
## Join admission and failures
The join uses the shared Mach-O container, slice, command-table and segment
readers. Slice extents and `sizeofcmds` bound every identity observation; duplicate
UUIDs or malformed commands cannot select the first plausible value. Unreadable
join results retain the successfully read artifact's path, SHA-256 and byte count.
The integer-text allowance spans the report and join, including repeated integer
facts and derived static addresses. Reservation precedes derived address
expansion. If a selected join read, interpretation or allowance fails, the error
retains the completed IPS report. Join read limits and recovery use
`max_join_bytes`, even when the report and join paths are identical. Cancellation
retains the caller's reason independently of those completed facts.
When a failure follows a completed join-file read, `partial_observation` retains
both the decoded report and a `join_artifact` receipt with the independently
observed path, SHA-256 and byte count. Frame rejection reasons reference
`join.limitations`; source-sized architecture diagnostics are retained once per
image rather than copied into every frame. Universal-image admission includes
the shared reader's supported hidden ARM64 row and rejects overlapping slices.
@@ -52,6 +52,7 @@ const report: AppleIps = {
type_mismatches: [],
},
unlisted_body_keys: [],
join: null,
limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."],
};
@@ -321,3 +322,53 @@ it.each([
if (!result.ok) expect(result.error._tag).toBe("AnalysisOutputError");
},
);
it.each([
[undefined, 16, false],
["/artifacts/other.bin", 16, false],
["/artifacts/selected.bin", 15, false],
["/artifacts/selected.bin", 16, true],
] as const)(
"binds returned join identity to selected path %s and allowance %s",
async (selected, maximum, accepted) => {
const joined: AppleIps = {
...report,
join: {
status: "unreadable",
path: "/artifacts/selected.bin",
sha256: "b".repeat(64),
bytes: 16,
reason: "Not a Mach-O image",
},
};
const result = await new AppleIpsService({
identity,
inspect: async () => ok(joined),
}).inspect({
path: report.artifact.path,
...(selected === undefined ? {} : { join_path: selected }),
max_join_bytes: maximum,
});
expect(result.ok).toBe(accepted);
if (result.ok)
expect(result.value.locations).toEqual([
{ kind: "artifact-path", path: report.artifact.path },
{ kind: "artifact-path", path: "/artifacts/selected.bin" },
]);
else expect(result.error._tag).toBe("AnalysisOutputError");
},
);
it("rejects a provider's omitted join after explicit selection", async () => {
const result = await new AppleIpsService({
identity,
inspect: async () => ok(report),
}).inspect({
path: report.artifact.path,
join_path: "/artifacts/selected.bin",
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisOutputError" },
});
});
@@ -63,7 +63,12 @@ export class AppleIpsService {
result: value,
confidence: "observed",
limitations: value.limitations,
locations: [{ kind: "artifact-path", path: value.artifact.path }],
locations: [
{ kind: "artifact-path", path: value.artifact.path },
...(value.join === null || value.join.path === value.artifact.path
? []
: [{ kind: "artifact-path" as const, path: value.join.path }]),
],
},
),
options?.signal,
@@ -76,6 +81,11 @@ const validReport = (
input: InspectAppleIpsInputLike,
): boolean => {
if (
(input.join_path === undefined
? report.join !== null
: report.join === null ||
report.join.path !== input.join_path ||
report.join.bytes > input.max_join_bytes) ||
report.artifact.bytes > input.max_file_bytes ||
report.coverage.threads > input.max_threads ||
report.coverage.frames > input.max_frames ||
@@ -112,6 +122,8 @@ const validReport = (
};
type InspectAppleIpsInputLike = {
readonly join_path?: string | undefined;
readonly max_join_bytes: number;
readonly max_file_bytes: number;
readonly max_threads: number;
readonly max_frames: number;
+13
View File
@@ -109,6 +109,15 @@ export const registerBinaryDiagnosticsCommands = (
maxIntegerTextBytes: cliDecimalField(
inspectAppleIpsInputSchema.shape.max_integer_text_bytes,
),
joinPath: z
.string()
.optional()
.describe(
"Optional local Mach-O executable or dSYM whose slice LC_UUID matches report images",
),
maxJoinBytes: cliDecimalField(
inspectAppleIpsInputSchema.shape.max_join_bytes,
),
}),
run: ({ args, options }) =>
withCommandCancellation(async (signal) => {
@@ -129,6 +138,10 @@ export const registerBinaryDiagnosticsCommands = (
max_images: options.maxImages,
max_registers: options.maxRegisters,
max_integer_text_bytes: options.maxIntegerTextBytes,
...(options.joinPath === undefined
? {}
: { join_path: absoluteCallerPath(options.joinPath) }),
max_join_bytes: options.maxJoinBytes,
},
{ signal },
),
@@ -149,7 +149,7 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
...toolContractMetadata("inspect_apple_ips"),
kind: "native-provider",
description:
"Inspect one explicit local Apple .ips crash report without executing a target, launching atos, or opening a disassembler. Parses the first-line metadata object and, when bug_type is the string 309, the crash body: process identity, exception and termination facts, threads, usedImages, lossless integer registers, and deterministic classification from documented SIGKILL, EXC_GUARD, pointer-authentication and dyld strings. Other bug_type values, including 288 stackshots, return no crash result. A missing or truncated body fails without partial success. Opened Mach-O and dSYM UUID joins remain unobserved. Portable on supported Node hosts; no Xcode or pwntools required.",
"Inspect one explicit local Apple .ips crash report without executing a target, launching atos, or opening a disassembler. Parses the first-line metadata object and, when bug_type is the string 309, the crash body: process identity, exception and termination facts, threads, usedImages, lossless integer registers, and deterministic classification from documented SIGKILL, EXC_GUARD, pointer-authentication and dyld strings. Other bug_type values, including 288 stackshots, return no crash result. A missing or truncated body fails without partial success. With join_path, every frame whose image UUID and architecture match a slice LC_UUID reports the slice static __TEXT vmaddr plus its image offset as a static address; symbolication and dSYM symbol lookup remain unobserved. Portable on supported Node hosts; no Xcode or pwntools required.",
inputSchema: inspectAppleIpsInputSchema,
outputSchema: evidenceResultOf(appleIpsSchema),
examples: [
@@ -157,6 +157,13 @@ export const BINARY_DIAGNOSTICS_TOOL_CONTRACTS = [
title: "Inspect an Apple .ips crash report",
input: { path: "/artifacts/app.ips" },
},
{
title: "Join report frames to the crashed Mach-O",
input: {
path: "/artifacts/app.ips",
join_path: "/artifacts/App.app/Contents/MacOS/App",
},
},
],
},
{
+2
View File
@@ -1,3 +1,4 @@
import type { AppleIpsPartialObservation } from "./native/appleIps.js";
import type { PlaywrightTrace } from "./playwrightTrace/playwrightTrace.js";
import type { GraphqlContract } from "./apiContract/graphqlContract.js";
import type { SavedAnalysisSnapshotPartialObservation } from "./savedAnalysisSnapshot.js";
@@ -517,6 +518,7 @@ export type AnalysisPartialObservation =
| Evidence
| BinaryLayout
| RecordedCrash
| AppleIpsPartialObservation
| RecordedCrashPartialObservation
| AndroidPartialObservation
| FirmwarePartialObservation
+4 -1
View File
@@ -70,7 +70,10 @@ it("rejects extra caller arguments and admits an empty crash inventory", () => {
type_mismatches: [],
},
unlisted_body_keys: [],
limitations: ["Opened Mach-O and dSYM UUID joins remain unobserved."],
join: null,
limitations: [
"Frames join a caller-selected Mach-O or dSYM only by matching slice LC_UUID and architecture; function and symbol resolution at a joined static address stays unobserved.",
],
});
expect(report.metadata.bug_type).toBe("309");
expect(report.classification.status).toBe("unclassified");
+97
View File
@@ -1,3 +1,4 @@
import type { FileSnapshotPartialObservation } from "../fileSnapshotObservation.js";
import { z } from "zod";
import { digestSchema } from "../digests.js";
@@ -20,6 +21,8 @@ export const MAX_INSPECT_APPLE_IPS_THREADS = 4096;
export const MAX_INSPECT_APPLE_IPS_FRAMES = 65536;
/** Hard ceiling on usedImages rows. */
export const MAX_INSPECT_APPLE_IPS_IMAGES = 4096;
/** Hard ceiling on the optional join Mach-O/dSYM read budget. */
export const MAX_INSPECT_APPLE_IPS_JOIN_BYTES = 256 * 1024 * 1024;
/** Hard ceiling on threadState register slots across every thread. */
export const MAX_INSPECT_APPLE_IPS_REGISTERS = 262144;
/** Conservative retained decimal/hex text capacity, measured as UTF-16 bytes. */
@@ -107,6 +110,20 @@ export const inspectAppleIpsInputSchema = z.strictObject({
.describe(
"Conservative UTF-16 byte allowance for all retained decimal/hex integer text, including derived addresses (default 8 MiB, maximum 64 MiB); checked before conversion and allocation",
),
join_path: absoluteLocalPathStringSchema
.optional()
.describe(
"Optional Mach-O executable or dSYM whose slice LC_UUID matches report images; unsigned images never join",
),
max_join_bytes: z
.number()
.int()
.min(2)
.max(MAX_INSPECT_APPLE_IPS_JOIN_BYTES)
.default(67108864)
.describe(
"Maximum stable artifact bytes read from join_path (default 64 MiB)",
),
});
export type InspectAppleIpsInput = z.infer<typeof inspectAppleIpsInputSchema>;
@@ -253,6 +270,63 @@ const coverageSchema = z.strictObject({
type_mismatches: z.array(typeMismatchSchema),
});
const imageJoinMatchSchema = z.strictObject({
image_index: uint,
uuid: uuidSchema,
recorded_arch: z.string().nullable(),
slice_index: uint,
slice_offset: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
slice_arch: z.string().min(1),
text_vmaddr: recordedIntegerSchema,
runtime_base: recordedIntegerSchema.nullable(),
});
const frameJoinSchema = z.strictObject({
thread_index: uint,
frame_index: uint,
status: z.enum(["joined", "unjoined"]),
reason: z
.string()
.min(1)
.nullable()
.describe("Why the frame's image did not join the selected artifact."),
static_address: recordedIntegerSchema
.nullable()
.describe("Slice __TEXT vmaddr plus the frame's recorded imageOffset."),
});
/** Slice identity resolution against one caller-selected Mach-O or dSYM. */
const joinSchema = z.discriminatedUnion("status", [
z.strictObject({
status: z.literal("selected"),
path: z.string().min(1),
sha256: digestSchema,
bytes: uint,
slices: z.array(
z.strictObject({
index: uint,
offset: z.string().regex(/^(?:0|[1-9][0-9]*)$/u),
arch: z.string().min(1),
cpu_type: z.string().min(1),
cpu_subtype: z.string().min(1),
uuid: uuidSchema.nullable(),
text_vmaddr: recordedIntegerSchema.nullable(),
unexamined: z.array(z.string().min(1)),
}),
),
image_matches: z.array(imageJoinMatchSchema),
frames: z.array(frameJoinSchema),
limitations: z.array(z.string().min(1)),
}),
z.strictObject({
status: z.literal("unreadable"),
path: z.string().min(1),
sha256: digestSchema,
bytes: uint,
reason: z.string().min(1),
}),
]);
const osVersionSchema = z
.discriminatedUnion("form", [
z.strictObject({ form: z.literal("text"), text: z.string() }),
@@ -302,6 +376,7 @@ export const appleIpsSchema = z.strictObject({
]),
coverage: coverageSchema,
unlisted_body_keys: z.array(z.string()),
join: joinSchema.nullable(),
limitations: z.array(z.string().min(1)),
});
export type AppleIps = z.infer<typeof appleIpsSchema>;
@@ -309,6 +384,7 @@ export type AppleIpsThread = z.infer<typeof threadSchema>;
export type AppleIpsImage = z.infer<typeof imageSchema>;
export type AppleIpsFrame = z.infer<typeof frameSchema>;
export type AppleIpsTypeMismatch = z.infer<typeof typeMismatchSchema>;
export type AppleIpsJoin = z.infer<typeof joinSchema>;
export type RecordedInteger = z.infer<typeof recordedIntegerSchema>;
/** Count each appearance, including repeated objects serialized at different paths. */
@@ -342,5 +418,26 @@ export const appleIpsReportIntegerTextBytes = (report: AppleIps): number => {
}
for (const mismatch of report.coverage.type_mismatches)
bytes += 2 * (mismatch.recorded_integer?.length ?? 0);
if (report.join?.status === "selected") {
for (const slice of report.join.slices)
bytes +=
2 *
(slice.offset.length +
slice.cpu_type.length +
slice.cpu_subtype.length) +
appleIpsIntegerTextBytes(slice.text_vmaddr);
for (const match of report.join.image_matches)
bytes +=
2 * match.slice_offset.length +
appleIpsIntegerTextBytes(match.text_vmaddr) +
appleIpsIntegerTextBytes(match.runtime_base);
for (const frame of report.join.frames)
bytes += appleIpsIntegerTextBytes(frame.static_address);
}
return bytes;
};
/** Decoded report and independently completed join identity retained on failure. */
export type AppleIpsPartialObservation = AppleIps & {
readonly join_artifact?: FileSnapshotPartialObservation;
};
+36
View File
@@ -81,6 +81,42 @@ export class FieldReader {
return this.#retain(value, value.toString(), reserved);
}
/** Charge each serialized appearance of a borrowed integer, without reinterpreting it. */
retainIntegerReference(
value: RecordedInteger | null,
): RecordedInteger | null {
const bytes = appleIpsIntegerTextBytes(value);
if (bytes > this.maximumIntegerTextBytes - this.#integerTextBytes)
throw new AppleIpsResourceFailure(
"integer_text",
this.maximumIntegerTextBytes,
"Repeated join integer exceeds the retained text allowance",
);
this.#integerTextBytes += bytes;
return value;
}
recordedUnsigned64(value: bigint): RecordedInteger {
const reserved = this.#reserve(20, true);
const result = this.#retain(value, value.toString(), reserved);
this.#integers.set(result, value);
return result;
}
decimalText64(value: bigint): string {
const reserved = this.#reserve(20, false);
const result = value.toString();
this.#integerTextBytes -= reserved - result.length * 2;
return result;
}
hexText32(value: number): string {
const reserved = this.#reserve(10, false);
const result = `0x${value.toString(16)}`;
this.#integerTextBytes -= reserved - result.length * 2;
return result;
}
mismatch(path: string, expected: string, value: LosslessJson | undefined) {
this.mismatches.push({ path, expected, observed: observedType(value) });
}
+384
View File
@@ -0,0 +1,384 @@
import { readMachoContainer } from "../../artifacts/apple/MachoLoadCommandReader.js";
import { readUniversalMachoSlice } from "../../domain/apple/machoImageSelection.js";
import {
readMachoLoadCommandTableHeader,
MachoLoadCommandCursor,
} from "../../domain/apple/machoLoadCommands.js";
import {
readMachoSegmentHeader,
admitMachoSegmentCommand,
} from "../../domain/apple/machoSegmentHeader.js";
import { FieldReader } from "./AppleIpsFieldReader.js";
import { machoArchitectureName } from "../../domain/apple/machoCpuIdentity.js";
import type { AppleIps, AppleIpsJoin } from "../../domain/native/appleIps.js";
/**
* Slice identity join between a recorded Apple .ips report and one
* caller-selected Mach-O or dSYM. Only UUID and CPU identity are read from the
* join artifact; the report stays the owner of its own recorded rows.
*/
const LC_SEGMENT = 0x1;
const LC_SEGMENT_64 = 0x19;
const LC_UUID = 0x1b;
const MAX_SLICES = 64;
/** Canonical uppercase UUID text, or the source spelling when not 16 bytes. */
const canonicalUuidBytes = (bytes: Uint8Array): string | null => {
if (bytes.byteLength !== 16) return null;
const hex = Buffer.from(bytes).toString("hex").toUpperCase();
if (hex === "00000000000000000000000000000000") return null;
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
};
interface SliceIdentity {
readonly index: number;
readonly offset: bigint;
readonly arch: string;
readonly cpuType: number;
readonly cpuSubtype: number;
readonly uuid: string | null;
readonly textVmaddr: bigint | null;
readonly unexamined: readonly string[];
}
type SliceParse =
| { readonly kind: "slices"; readonly slices: readonly SliceIdentity[] }
| { readonly kind: "unreadable"; readonly reason: string };
const readSliceIdentities = async (bytes: Buffer): Promise<SliceParse> => {
const container = await readMachoContainer(
async (offset, length) => bytes.subarray(offset, offset + length),
bytes.length,
);
if (container.kind === "issue")
return { kind: "unreadable", reason: container.reason };
if (container.kind === "not-mach-o")
return {
kind: "unreadable",
reason: "Selected join is not a Mach-O image",
};
if (container.kind === "thin") {
const slice = sliceAt(bytes, 0n, 0);
return slice === null
? {
kind: "unreadable",
reason: "Mach-O slice or command table is unreadable",
}
: { kind: "slices", slices: [slice] };
}
const { header, hidden } = container;
const issue = header.rangeIssues.find((value) => value !== null);
if (issue !== undefined) return { kind: "unreadable", reason: issue.reason };
if (hidden !== null && hidden.status !== "declared")
return { kind: "unreadable", reason: hidden.reason };
if (hidden?.status === "declared" && hidden.rangeIssue !== null)
return { kind: "unreadable", reason: hidden.rangeIssue };
const declarations = [
...header.slices,
...(hidden?.status === "declared" ? [hidden.declaration] : []),
];
if (declarations.length > MAX_SLICES)
return { kind: "unreadable", reason: "Join slice allowance exceeded" };
const slices: SliceIdentity[] = [];
for (const declaration of declarations) {
const selected = readUniversalMachoSlice(bytes, header, declaration);
if (!selected.ok)
return { kind: "unreadable", reason: selected.error.message };
const slice = sliceAt(
selected.value,
BigInt(declaration.offset),
declaration.index,
);
if (slice === null)
return {
kind: "unreadable",
reason: `FAT slice ${String(declaration.index)} or its command table is unreadable`,
};
slices.push(slice);
}
return { kind: "slices", slices };
};
const sliceAt = (
bytes: Buffer,
offset: bigint,
index: number,
): SliceIdentity | null => {
const parsed = readMachoLoadCommandTableHeader(bytes, bytes.length);
if (!parsed.ok || parsed.value === null) return null;
const header = parsed.value;
const cursor = new MachoLoadCommandCursor(header);
let uuid: string | null = null;
let uuidSeen = false;
let textVmaddr: bigint | null = null;
for (;;) {
const next = cursor.nextOffset;
if (next === null) break;
const command = bytes.subarray(header.headerSize + next);
const frame = cursor.read(command);
if (!frame.ok) return null;
const payload = command.subarray(0, frame.value.size);
if (frame.value.command === LC_UUID) {
if (uuidSeen || payload.length !== 24) return null;
uuidSeen = true;
uuid = canonicalUuidBytes(payload.subarray(8, 24));
}
if (
frame.value.command === LC_SEGMENT ||
frame.value.command === LC_SEGMENT_64
) {
const wide = frame.value.command === LC_SEGMENT_64;
if (wide !== header.wide) return null;
const segment = readMachoSegmentHeader(
payload,
wide,
header.littleEndian,
);
if (
!segment.ok ||
!admitMachoSegmentCommand(
segment.value,
payload.length,
wide ? 64 : 32,
bytes.length,
).ok
)
return null;
const name = segment.value.nameBytes
.toString("utf8")
.replace(/\0.*$/su, "");
if (name === "__TEXT") {
if (textVmaddr !== null) return null;
textVmaddr = segment.value.address;
}
}
}
if (!cursor.finish().ok) return null;
const { cpuType, cpuSubtype } = header.identity;
return {
index,
offset,
cpuType,
cpuSubtype,
arch: machoArchitectureName(cpuType, cpuSubtype),
uuid,
textVmaddr,
unexamined: [],
};
};
const comparableArch = (value: string): string =>
value.trim().toLowerCase().replaceAll("-", "");
const ARCH_ALIASES: Readonly<Record<string, string>> = {
arm64: "arm64",
arm64e: "arm64e",
x8664: "x86_64",
x86_64: "x86_64",
i386: "i386",
armv7: "armv7",
armv7s: "armv7s",
armv8: "arm64",
};
const architecturesAgree = (recorded: string, slice: string): boolean => {
const left =
ARCH_ALIASES[comparableArch(recorded)] ?? comparableArch(recorded);
const right = ARCH_ALIASES[comparableArch(slice)] ?? comparableArch(slice);
return left === right;
};
/** Resolve every report image and frame against the selected artifact. */
export const resolveAppleIpsJoin = async (
report: AppleIps,
artifact: {
readonly path: string;
readonly sha256: string;
readonly bytes: Buffer;
},
input: { readonly path: string; readonly max_join_bytes: number },
read: FieldReader,
): Promise<AppleIpsJoin> => {
const parsed = await readSliceIdentities(artifact.bytes);
if (parsed.kind === "unreadable")
return {
status: "unreadable",
path: input.path,
sha256: artifact.sha256,
bytes: artifact.bytes.length,
reason: parsed.reason,
};
const matches: AppleIpsJoinDigest[] = [];
const limitations: string[] = [];
const matchedImage = new Map<number, AppleIpsJoinDigest>();
const rejectedImage = new Map<number, string>();
for (const image of report.images) {
if (image.source === "S" || image.source === "C") {
const reason = `image ${String(image.index)} is shared-cache-backed; standalone-image join applicability is unknown`;
limitations.push(reason);
rejectedImage.set(image.index, reason);
continue;
}
const recordedArch = image.arch ?? "";
if (image.uuid === null || recordedArch === "") continue;
const candidates = parsed.slices.filter(
({ uuid }) => uuid !== null && uuid === image.uuid,
);
if (candidates.length === 0) continue;
const compatible = candidates.filter(({ arch }) =>
architecturesAgree(recordedArch, arch),
);
if (compatible.length === 0) {
const reason = `image ${String(image.index)} UUID ${image.uuid} matches a slice, but its recorded architecture ${JSON.stringify(recordedArch)} does not match ${candidates.map(({ arch }) => arch).join(", ")}; the join is rejected.`;
limitations.push(reason);
rejectedImage.set(image.index, reason);
continue;
}
if (compatible.length > 1) {
const reason = `image ${String(image.index)} UUID ${image.uuid} matches ${String(compatible.length)} slices; the join stays ambiguous.`;
limitations.push(reason);
rejectedImage.set(image.index, reason);
continue;
}
const [slice] = compatible;
if (slice === undefined) continue;
if (slice.textVmaddr === null) {
const reason = `slice ${String(slice.index)} has no readable __TEXT segment, so image ${String(image.index)} joins without a static base.`;
limitations.push(reason);
rejectedImage.set(image.index, reason);
continue;
}
const digest: AppleIpsJoinDigest = {
image_index: image.index,
uuid: image.uuid,
recorded_arch: recordedArch,
slice_index: slice.index,
slice_offset: read.decimalText64(slice.offset),
slice_arch: slice.arch,
text_vmaddr: read.recordedUnsigned64(slice.textVmaddr),
runtime_base: read.retainIntegerReference(image.base),
};
matches.push(digest);
matchedImage.set(image.index, digest);
}
const frames = report.threads.flatMap((thread) =>
thread.frames.map((frame) =>
frameJoin(frame, thread.index, {
images: report.images,
matched: matchedImage,
rejected: rejectedImage,
slices: parsed.slices,
read,
}),
),
);
return {
status: "selected",
path: input.path,
sha256: artifact.sha256,
bytes: artifact.bytes.length,
slices: parsed.slices.map((slice) => ({
index: slice.index,
offset: read.decimalText64(slice.offset),
arch: slice.arch,
cpu_type: read.hexText32(slice.cpuType),
cpu_subtype: read.hexText32(slice.cpuSubtype),
uuid: slice.uuid,
text_vmaddr:
slice.textVmaddr === null
? null
: read.recordedUnsigned64(slice.textVmaddr),
unexamined: [...slice.unexamined],
})),
image_matches: matches,
frames,
limitations,
};
};
type AppleIpsJoinDigest = Extract<
AppleIpsJoin,
{ readonly status: "selected" }
>["image_matches"][number];
interface FrameJoinContext {
readonly images: AppleIps["images"];
readonly matched: ReadonlyMap<number, AppleIpsJoinDigest>;
readonly rejected: ReadonlyMap<number, string>;
readonly slices: readonly SliceIdentity[];
readonly read: FieldReader;
}
const frameJoin = (
frame: AppleIps["threads"][number]["frames"][number],
threadIndex: number,
context: FrameJoinContext,
): Extract<AppleIpsJoin, { status: "selected" }>["frames"][number] => {
const { images, matched, rejected, slices } = context;
const imageIndex = frame.image_index;
if (imageIndex === null)
return unjoined(
threadIndex,
frame.frame_index,
"frame records no imageIndex",
);
const image = images[imageIndex];
if (image === undefined)
return unjoined(
threadIndex,
frame.frame_index,
`frame imageIndex ${String(imageIndex)} has no usedImages row`,
);
if (image.uuid === null)
return unjoined(
threadIndex,
frame.frame_index,
`image ${String(imageIndex)} UUID is unknown, so it never joins`,
);
const digest = matched.get(imageIndex);
if (digest === undefined) {
const rejectReason = rejected.get(imageIndex);
if (rejectReason !== undefined)
return unjoined(
threadIndex,
frame.frame_index,
`image ${String(imageIndex)} did not join; see join.limitations`,
);
const uuidMatched = slices.some(({ uuid }) => uuid === image.uuid);
return unjoined(
threadIndex,
frame.frame_index,
uuidMatched
? `image ${String(imageIndex)} UUID ${image.uuid} matches a slice whose architecture does not match ${JSON.stringify(image.arch ?? null)}`
: `no slice LC_UUID matches image ${String(imageIndex)} UUID ${image.uuid}`,
);
}
const offset = frame.image_offset;
if (offset === null)
return unjoined(
threadIndex,
frame.frame_index,
"frame records no imageOffset",
);
const staticAddress = context.read.add(digest.text_vmaddr, offset);
return {
thread_index: threadIndex,
frame_index: frame.frame_index,
status: "joined",
reason: null,
static_address: staticAddress,
};
};
const unjoined = (
threadIndex: number,
frameIndex: number,
reason: string,
): Extract<AppleIpsJoin, { status: "selected" }>["frames"][number] => ({
thread_index: threadIndex,
frame_index: frameIndex,
status: "unjoined",
reason,
static_address: null,
});
+3 -2
View File
@@ -25,7 +25,7 @@ const recordedUuidPattern =
/^(?:[0-9a-fA-F]{32}|[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$/u;
const IPS_LIMITATIONS = [
"Rows reproduce the selected .ips JSON; they do not establish that the process still exists or that a live kernel agrees with the recorded state.",
"Opened Mach-O and dSYM UUID joins remain unobserved.",
"Frames join a caller-selected Mach-O or dSYM only by matching slice LC_UUID and architecture; function and symbol resolution at a joined static address stays unobserved.",
"Symbol names printed inside the report are recorded claims and are not verified.",
];
@@ -392,9 +392,9 @@ export const parseAppleIps = (
bytes: Buffer,
artifact: Artifact,
input: InspectAppleIpsInput,
read: FieldReader = new FieldReader(input.max_integer_text_bytes),
): AppleIps => {
const text = crashText(bytes);
const read = new FieldReader(input.max_integer_text_bytes);
const metadata = metadataOf(text.metadata, read);
if (text.body.length === 0)
throw new AppleIpsFormatFailure(
@@ -451,6 +451,7 @@ export const parseAppleIps = (
classification: classify(exception, termination),
coverage: coverageOf(threads, images, read.mismatches),
unlisted_body_keys: [...bodyJson.unlisted],
join: null,
limitations: IPS_LIMITATIONS,
};
};
+100 -36
View File
@@ -1,3 +1,7 @@
import { completedStableArtifactObservation } from "../../artifacts/StableArtifactObservation.js";
import { FieldReader } from "./AppleIpsFieldReader.js";
import type { AppleIps } from "../../domain/native/appleIps.js";
import type { StableArtifactSnapshot } from "../../artifacts/readStableArtifact.js";
import {
projectStableArtifactFailure,
stableArtifactInputRecovery,
@@ -5,10 +9,7 @@ import {
} from "../../artifacts/StableArtifactFailure.js";
import { isSelectedRegularFileError } from "../../filesystem/RegularFile.js";
import { ArtifactReaderFailure } from "../../artifacts/ArtifactReader.js";
import {
type StableArtifactSnapshot,
readStableArtifact,
} from "../../artifacts/readStableArtifact.js";
import { readStableArtifact } from "../../artifacts/readStableArtifact.js";
import {
artifactFilePrimaryFacts,
type ArtifactFilePrimaryFacts,
@@ -29,6 +30,7 @@ import {
} from "../../domain/analysisErrorCore.js";
import {
MAX_INSPECT_APPLE_IPS_FILE_BYTES,
MAX_INSPECT_APPLE_IPS_JOIN_BYTES,
MAX_INSPECT_APPLE_IPS_FRAMES,
MAX_INSPECT_APPLE_IPS_IMAGES,
MAX_INSPECT_APPLE_IPS_REGISTERS,
@@ -47,6 +49,7 @@ import {
type AppleIpsSelectedBoundary,
} from "./AppleIps.errors.js";
import { parseAppleIps } from "./AppleIpsParser.js";
import { resolveAppleIpsJoin } from "./AppleIpsMachOJoin.js";
const OPERATION = "inspect_apple_ips";
@@ -68,16 +71,21 @@ export class AppleIpsProvider implements AppleIpsPort {
}
async inspect(input: InspectAppleIpsInput, options?: ExecutionOptions) {
let snapshot: StableArtifactSnapshot | undefined;
let completedReport: AppleIps | undefined;
let activePath = input.path;
let activeField: "path" | "join_path" = "path";
let completedSnapshot: StableArtifactSnapshot | undefined;
const read = new FieldReader(input.max_integer_text_bytes);
try {
return await this.#resources.run(async () => {
snapshot = await readStableArtifact(
const snapshot = await readStableArtifact(
input.path,
input.max_file_bytes,
options?.signal,
undefined,
this.#resources,
);
completedSnapshot = snapshot;
assertArtifactReadActive(options?.signal);
const report = parseAppleIps(
snapshot.bytes,
@@ -87,20 +95,90 @@ export class AppleIpsProvider implements AppleIpsPort {
bytes: snapshot.bytes.length,
},
input,
read,
);
completedReport = report;
assertArtifactReadActive(options?.signal);
return ok(report);
if (input.join_path === undefined) return ok(report);
activePath = input.join_path;
activeField = "join_path";
completedSnapshot = undefined;
const joinArtifact = await readStableArtifact(
input.join_path,
input.max_join_bytes,
options?.signal,
undefined,
this.#resources,
);
completedSnapshot = joinArtifact;
assertArtifactReadActive(options?.signal);
return ok({
...report,
join: await resolveAppleIpsJoin(
report,
{ ...joinArtifact, path: input.join_path },
{
path: input.join_path,
max_join_bytes: input.max_join_bytes,
},
read,
),
});
});
} catch (cause) {
return err(appleIpsFailure(cause, input, snapshot));
const primary = appleIpsFailurePrimary(
artifactFilePrimaryFacts(cause),
{
...input,
path: activePath,
max_file_bytes:
activeField === "path"
? input.max_file_bytes
: input.max_join_bytes,
},
activeField === "path" ? "path" : "join_path",
);
if (completedReport !== undefined) {
const joinArtifact = completedStableArtifactObservation(
cause,
activePath,
completedSnapshot,
);
primary.retainPartialObservation(
input.join_path !== undefined &&
activePath === input.join_path &&
joinArtifact !== undefined
? { ...completedReport, join_artifact: joinArtifact }
: completedReport,
);
}
const failure = projectStableArtifactFailure(
cause,
primary,
activePath,
APPLE_IPS_PROVIDER.id,
OPERATION,
completedSnapshot,
);
return err(failure);
}
}
}
const fileSizeRemediation = (input: InspectAppleIpsInput): string =>
input.max_file_bytes >= MAX_INSPECT_APPLE_IPS_FILE_BYTES
? `The report exceeds the ${String(MAX_INSPECT_APPLE_IPS_FILE_BYTES)}-byte input ceiling this operation accepts. This release has no runtime override. Select a smaller artifact.`
: `Raise max_file_bytes above ${String(input.max_file_bytes)} bytes or select a smaller artifact; the limit is the caller-selected input read budget.`;
const fileSizeRemediation = (
input: InspectAppleIpsInput,
field: "path" | "join_path",
): string => {
const argument = field === "path" ? "max_file_bytes" : "max_join_bytes";
const maximum =
field === "path"
? MAX_INSPECT_APPLE_IPS_FILE_BYTES
: MAX_INSPECT_APPLE_IPS_JOIN_BYTES;
const effective = input.max_file_bytes;
return effective >= maximum
? `The selected ${field} artifact exceeds this operation's ${String(maximum)}-byte ceiling; select a smaller artifact.`
: `Raise ${argument} above ${String(effective)} bytes or select a smaller artifact; this is the caller-selected read budget.`;
};
const SELECTED_BUDGETS = {
threads: {
@@ -178,6 +256,7 @@ const resourceRecovery = (
const appleIpsFailurePrimary = (
facts: ArtifactFilePrimaryFacts,
input: InspectAppleIpsInput,
field: "path" | "join_path" = "path",
): AnalysisError => {
const { cause, origin } = facts;
if (origin === "owned-or-classified" && cause instanceof AnalysisError)
@@ -219,7 +298,7 @@ const appleIpsFailurePrimary = (
)
: new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
path: [field],
reason: "invalid_format",
message: `Selected artifact ${input.path}: ${cause.message}`,
},
@@ -233,18 +312,22 @@ const appleIpsFailurePrimary = (
OPERATION,
"file-size",
cause.message,
{ max_file_bytes: input.max_file_bytes },
{
[field === "path" ? "max_file_bytes" : "max_join_bytes"]:
input.max_file_bytes,
selected_input: field,
},
{
cause,
retryable: false,
remediationAction: fileSizeRemediation(input),
remediationAction: fileSizeRemediation(input, field),
},
);
if (cause.reason === "path" || cause.reason === "format")
return new AnalysisInputError(
OPERATION,
{ cause, ...stableArtifactInputRecovery(cause) },
[{ path: ["path"], reason: "invalid_format", message: cause.message }],
[{ path: [field], reason: "invalid_format", message: cause.message }],
);
}
if (cause instanceof Error && "code" in cause) {
@@ -255,7 +338,7 @@ const appleIpsFailurePrimary = (
if (isSelectedRegularFileError(cause))
return new AnalysisInputError(OPERATION, { cause }, [
{
path: ["path"],
path: [field],
reason: "invalid_value",
message: `Selected report could not be read (${String(cause.code)}): ${input.path}.`,
},
@@ -269,22 +352,3 @@ const appleIpsFailurePrimary = (
},
});
};
const appleIpsFailure = (
cause: unknown,
input: InspectAppleIpsInput,
snapshot?: StableArtifactSnapshot,
): AnalysisError => {
const primary = appleIpsFailurePrimary(
artifactFilePrimaryFacts(cause),
input,
);
return projectStableArtifactFailure(
cause,
primary,
input.path,
APPLE_IPS_PROVIDER.id,
OPERATION,
snapshot,
);
};
@@ -0,0 +1,40 @@
import { createHash } from "node:crypto";
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect } from "vitest";
import { appleIpsBytes } from "../fixtures/binaryDiagnostics/appleIps.js";
import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js";
import { cliTest } from "../support/cli/cliFixture.js";
cliTest(
"retains a completed IPS report when the selected join cannot be read",
async ({ cli }) => {
const root = await createTestTempDirectory("rea-ips-join-cli-");
const path = join(root, "report.ips");
const bytes = appleIpsBytes();
await writeFile(path, bytes);
const result = await cli.run({
arguments: [
"inspect-apple-ips",
path,
"--join-path",
join(root, "missing.bin"),
"--json",
],
});
expect(result.exitCode).toBe(1);
expect(result.json).toMatchObject({
code: "invalid_request",
details: {
issues: [expect.objectContaining({ path: ["join_path"] })],
partial_observation: {
artifact: {
path,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
},
},
},
});
},
);
@@ -0,0 +1,532 @@
import { createHash } from "node:crypto";
import { projectAnalysisError } from "../../../src/domain/analysisErrorProjection.js";
import {
appleIpsReportIntegerTextBytes,
appleIpsSchema,
} from "../../../src/domain/native/appleIps.js";
import { join } from "node:path";
import { readFile, writeFile } from "node:fs/promises";
import { describe, expect, it } from "vitest";
import { createAppleIpsService } from "../../../src/composition/binaryDiagnostics.js";
import { appleIpsBytes } from "../../fixtures/binaryDiagnostics/appleIps.js";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const FIXTURE_UUID = "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA";
const OTHER_UUID = "CCCCCCCC-CCCC-CCCC-CCCC-CCCCCCCCCCCC";
const ARM64 = { type: 0x0100000c, subtype: 0 } as const;
const X86_64 = { type: 0x01000007, subtype: 3 } as const;
interface SliceSpec {
readonly cpu: { readonly type: number; readonly subtype: number };
readonly uuid: string | null;
readonly textVmaddr: bigint;
readonly withText?: boolean;
}
const commandBytes = (commands: readonly Buffer[]): Buffer => {
const total = commands.reduce((sum, command) => sum + command.length, 0);
const body = Buffer.alloc(total);
let offset = 0;
for (const command of commands) {
command.copy(body, offset);
offset += command.length;
}
return body;
};
const uuidCommand = (uuid: string): Buffer => {
const bytes = Buffer.alloc(24);
bytes.writeUInt32LE(0x1b, 0);
bytes.writeUInt32LE(24, 4);
Buffer.from(uuid.replaceAll("-", ""), "hex").copy(bytes, 8);
return bytes;
};
const segmentCommand = (vmaddr: bigint): Buffer => {
const bytes = Buffer.alloc(72);
bytes.writeUInt32LE(0x19, 0);
bytes.writeUInt32LE(72, 4);
bytes.write("__TEXT\0", 8, "latin1");
bytes.writeBigUInt64LE(vmaddr, 24);
bytes.writeBigUInt64LE(0x4000n, 32);
return bytes;
};
/** One little-endian 64-bit slice with an LC_UUID and an optional __TEXT. */
const machoSlice = (spec: SliceSpec): Buffer => {
const commands = [
...(spec.uuid === null ? [] : [uuidCommand(spec.uuid)]),
...(spec.withText === false ? [] : [segmentCommand(spec.textVmaddr)]),
];
const body = commandBytes(commands);
const header = Buffer.alloc(32);
header.writeUInt32LE(0xfeedfacf, 0);
header.writeUInt32LE(spec.cpu.type, 4);
header.writeUInt32LE(spec.cpu.subtype, 8);
header.writeUInt32LE(2, 12);
header.writeUInt32LE(commands.length, 16);
header.writeUInt32LE(body.length, 20);
return Buffer.concat([header, body]);
};
/** A FAT container wrapping the given slices at 0x1000-byte alignment. */
const fatImage = (slices: readonly SliceSpec[]): Buffer => {
const alignment = 0x1000;
const table = Buffer.alloc(8 + slices.length * 20);
table.writeUInt32BE(0xcafebabe, 0);
table.writeUInt32BE(slices.length, 4);
const bodies = slices.map(machoSlice);
const offsets: number[] = [];
let cursor = Math.ceil(table.length / alignment) * alignment;
for (const [index, body_] of bodies.entries()) {
offsets.push(cursor);
table.writeUInt32BE(slices[index]?.cpu.type ?? 0, 8 + index * 20);
table.writeUInt32BE(slices[index]?.cpu.subtype ?? 0, 12 + index * 20);
table.writeUInt32BE(cursor, 16 + index * 20);
table.writeUInt32BE(body_.length, 20 + index * 20);
table.writeUInt32BE(12, 24 + index * 20);
cursor += Math.ceil(body_.length / alignment) * alignment;
}
const out = Buffer.alloc(cursor);
table.copy(out, 0);
for (const [index, body_] of bodies.entries())
body_.copy(out, offsets[index] ?? 0);
return out;
};
const inspectWithJoin = async (
root: string,
artifact: Buffer,
ipsOptions: Parameters<typeof appleIpsBytes>[0] = {},
) => {
const ipsPath = join(root, "report.ips");
const joinPath = join(root, "fixture.bin");
await writeFile(ipsPath, appleIpsBytes(ipsOptions));
await writeFile(joinPath, artifact);
const result = await createAppleIpsService().inspect({
path: ipsPath,
join_path: joinPath,
});
if (!result.ok) throw result.error;
expect(result.value.locations).toEqual([
{ kind: "artifact-path", path: ipsPath },
{ kind: "artifact-path", path: joinPath },
]);
const output = result.value.normalized_result as unknown as {
readonly join: {
readonly status: string;
readonly slices: readonly {
readonly index: number;
readonly arch: string;
}[];
readonly reason?: string;
readonly image_matches: readonly {
readonly image_index: number;
readonly slice_index: number;
readonly uuid: string;
readonly text_vmaddr: { readonly decimal: string };
}[];
readonly frames: readonly {
readonly thread_index: number;
readonly frame_index: number;
readonly status: string;
readonly reason: string | null;
readonly static_address: { readonly decimal: string } | null;
}[];
readonly limitations: readonly string[];
};
};
if (output.join.status === "unreadable")
throw new Error(`join unreadable: ${String(output.join.reason)}`);
return output;
};
describe("Apple IPS Mach-O and dSYM join", () => {
it("joins matching UUID and architecture and derives static addresses", async () => {
const root = await createTestTempDirectory("rea-apple-ips-join-");
const report = await inspectWithJoin(
root,
fatImage([
{ cpu: X86_64, uuid: OTHER_UUID, textVmaddr: 0n },
{ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n },
]),
);
expect(report.join.status).toBe("selected");
expect(report.join.image_matches).toEqual([
{
image_index: 0,
uuid: FIXTURE_UUID,
recorded_arch: "arm64",
slice_index: 1,
slice_offset: expect.any(String),
slice_arch: "arm64",
text_vmaddr: { decimal: "4294967296", hex: "0x100000000" },
runtime_base: { decimal: "4294967296", hex: "0x100000000" },
},
]);
const faulting = report.join.frames[0];
expect(faulting).toEqual({
thread_index: 0,
frame_index: 0,
status: "joined",
reason: null,
static_address: { decimal: "4294968120", hex: "0x100000338" },
});
expect(report.join.frames).toHaveLength(5);
expect(report.join.frames.every(({ status }) => status === "joined")).toBe(
true,
);
});
it("rejects a UUID match whose architecture differs", async () => {
const root = await createTestTempDirectory("rea-apple-ips-join-arch-");
const report = await inspectWithJoin(
root,
machoSlice({ cpu: X86_64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
);
expect(report.join.status).toBe("selected");
expect(report.join.image_matches).toEqual([]);
expect(report.join.frames[0]).toMatchObject({
status: "unjoined",
reason: expect.stringContaining("join.limitations"),
});
expect(report.join.limitations.join(" ")).toContain(
"does not match x86_64",
);
expect(report.join.limitations.join("\n")).toContain(
"does not match x86_64",
);
});
it("refuses to join a rebuilt binary with a different LC_UUID", async () => {
const root = await createTestTempDirectory("rea-apple-ips-join-uuid-");
const report = await inspectWithJoin(
root,
machoSlice({ cpu: ARM64, uuid: OTHER_UUID, textVmaddr: 0x100000000n }),
);
expect(report.join.image_matches).toEqual([]);
expect(report.join.frames[0]).toMatchObject({
status: "unjoined",
reason: expect.stringContaining("no slice LC_UUID matches"),
});
});
it("never joins an image whose recorded UUID is all zeroes", async () => {
const root = await createTestTempDirectory("rea-apple-ips-join-zero-");
const report = await inspectWithJoin(
root,
machoSlice({ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
{ uuid: "00000000-0000-0000-0000-000000000000" },
);
expect(report.join.image_matches).toEqual([]);
expect(
report.join.frames.every(({ reason }) =>
reason?.includes("UUID is unknown"),
),
).toBe(true);
});
it("reports an unreadable join artifact without dropping report facts", async () => {
const root = await createTestTempDirectory("rea-apple-ips-join-bad-");
const ipsPath = join(root, "report.ips");
const joinPath = join(root, "fixture.bin");
await writeFile(ipsPath, appleIpsBytes());
await writeFile(joinPath, Buffer.alloc(64, 0x41));
const result = await createAppleIpsService().inspect({
path: ipsPath,
join_path: joinPath,
});
if (!result.ok) throw result.error;
const report = result.value.normalized_result as { readonly join: unknown };
expect(report.join).toMatchObject({
status: "unreadable",
reason: expect.any(String),
});
});
});
it.each([
"slice-extent",
"command-extent",
"cpu-disagreement",
"duplicate-uuid",
] as const)(
"does not borrow identity outside the admitted %s",
async (mode) => {
const root = await createTestTempDirectory("rea-ips-invalid-join-");
let bytes = fatImage([
{ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n },
]);
if (mode === "slice-extent") bytes.writeUInt32BE(28, 20);
if (mode === "command-extent") bytes.writeUInt32LE(0, 0x1000 + 20);
if (mode === "cpu-disagreement") bytes.writeUInt32BE(X86_64.type, 8);
if (mode === "duplicate-uuid") {
const slice = machoSlice({
cpu: ARM64,
uuid: FIXTURE_UUID,
textVmaddr: 0x100000000n,
});
bytes = Buffer.concat([slice, uuidCommand(FIXTURE_UUID)]);
bytes.writeUInt32LE(3, 16);
bytes.writeUInt32LE(bytes.length - 32, 20);
}
const path = join(root, "report.ips");
const joinPath = join(root, "join.bin");
await writeFile(path, appleIpsBytes());
await writeFile(joinPath, bytes);
const result = await createAppleIpsService().inspect({
path,
join_path: joinPath,
});
if (!result.ok) throw result.error;
const report = appleIpsSchema.parse(result.value.normalized_result);
expect(report.join).toMatchObject({
status: "unreadable",
bytes: bytes.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
});
expect(report.images.length).toBeGreaterThan(0);
},
);
it("retains completed report facts on a failed join read and charges join integer expansion", async () => {
const root = await createTestTempDirectory("rea-ips-join-failure-");
const path = join(root, "report.ips");
const joinPath = join(root, "join.bin");
await writeFile(path, appleIpsBytes());
const service = createAppleIpsService();
try {
const plain = await service.inspect({ path });
if (!plain.ok) throw plain.error;
const report = appleIpsSchema.parse(plain.value.normalized_result);
const missing = await service.inspect({ path, join_path: joinPath });
expect(missing).toMatchObject({ ok: false });
if (missing.ok) throw new Error("Missing join was admitted");
expect(missing.error.partialObservation).toEqual(report);
await writeFile(
joinPath,
machoSlice({ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
);
const allowance = appleIpsReportIntegerTextBytes(report) + 128;
const baseline = await service.inspect({
path,
max_integer_text_bytes: allowance,
});
expect(baseline.ok).toBe(true);
const limited = await service.inspect({
path,
join_path: joinPath,
max_integer_text_bytes: allowance,
});
expect(limited).toMatchObject({ ok: false });
if (limited.ok) throw new Error("Join expansion exceeded its allowance");
expect(limited.error.partialObservation).toEqual({
...report,
join_artifact: {
kind: "file-snapshot-hash",
path: joinPath,
sha256: createHash("sha256")
.update(await readFile(joinPath))
.digest("hex"),
bytes: machoSlice({
cpu: ARM64,
uuid: FIXTURE_UUID,
textVmaddr: 0x100000000n,
}).length,
},
});
const complete = await service.inspect({ path, join_path: joinPath });
if (!complete.ok) throw complete.error;
const joined = appleIpsSchema.parse(complete.value.normalized_result);
expect(appleIpsReportIntegerTextBytes(joined)).toBeGreaterThan(allowance);
} finally {
await service.close();
}
});
it("keeps a frame without imageOffset unjoined", async () => {
const root = await createTestTempDirectory("rea-ips-unknown-offset-");
const path = join(root, "report.ips");
const joinPath = join(root, "join.bin");
await writeFile(
path,
appleIpsBytes()
.toString()
.replace(/"imageOffset"\s*:\s*\d+/u, '"imageOffset":null'),
);
await writeFile(
joinPath,
machoSlice({ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
);
const result = await createAppleIpsService().inspect({
path,
join_path: joinPath,
});
if (!result.ok) throw result.error;
const joinResult = appleIpsSchema.parse(result.value.normalized_result).join;
if (joinResult?.status !== "selected")
throw new Error("Expected selected join");
expect(joinResult.frames[0]).toMatchObject({
status: "unjoined",
static_address: null,
});
});
it("uses the selected join budget even when report and join paths are identical", async () => {
const root = await createTestTempDirectory("rea-ips-same-path-");
const path = join(root, "report.ips");
await writeFile(path, appleIpsBytes());
const service = createAppleIpsService();
try {
const result = await service.inspect({
path,
join_path: path,
max_join_bytes: 2,
});
if (result.ok) throw new Error("The join exceeded its own read allowance");
const failure = projectAnalysisError(result.error);
expect(failure).toMatchObject({
code: "resource_constraint",
details: {
partial_observation: { artifact: { path } },
reported_limits: { max_join_bytes: 2, selected_input: "join_path" },
},
});
expect(JSON.stringify(failure)).toContain("max_join_bytes");
expect(JSON.stringify(failure)).not.toContain("Raise max_file_bytes");
} finally {
await service.close();
}
});
it("includes an admitted hidden ARM64 compatibility slice", async () => {
const root = await createTestTempDirectory("rea-ips-fat-admission-");
const specs = [
{ cpu: X86_64, uuid: OTHER_UUID, textVmaddr: 0x100000000n },
{ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n },
];
const hidden = fatImage(specs);
hidden.writeUInt32BE(1, 4);
const hiddenReport = await inspectWithJoin(root, hidden);
expect(hiddenReport.join).toMatchObject({
status: "selected",
slices: [
expect.objectContaining({ index: 0, arch: "x86_64" }),
expect.objectContaining({ index: 1, arch: "arm64" }),
],
});
expect(
hiddenReport.join?.status === "selected" &&
hiddenReport.join.frames[0]?.status,
).toBe("joined");
});
it("rejects overlapping FAT slices through shared container admission", async () => {
const root = await createTestTempDirectory("rea-ips-overlapping-fat-");
await writeFile(join(root, "report.ips"), appleIpsBytes());
const spec = { cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n };
const overlap = fatImage([spec, spec]);
overlap.writeUInt32BE(overlap.readUInt32BE(16), 36);
const joinPath = join(root, "overlap.bin");
await writeFile(joinPath, overlap);
const service = createAppleIpsService();
try {
const rejected = await service.inspect({
path: join(root, "report.ips"),
join_path: joinPath,
});
if (!rejected.ok) throw rejected.error;
expect(
appleIpsSchema.parse(rejected.value.normalized_result).join,
).toMatchObject({
status: "unreadable",
reason: expect.stringContaining("overlap"),
});
} finally {
await service.close();
}
});
it("retains architecture diagnostics once per image instead of expanding them per frame", async () => {
const root = await createTestTempDirectory("rea-ips-architecture-size-");
const ips = JSON.parse(
appleIpsBytes().toString().split("\n").slice(1).join("\n"),
);
ips.usedImages[0].arch = "unrecognized-".repeat(1024);
ips.threads[0].frames = Array.from({ length: 32 }, () => ({
imageIndex: 0,
imageOffset: 824,
}));
const path = join(root, "large.ips");
await writeFile(
path,
appleIpsBytes().toString().split("\n")[0] + "\n" + JSON.stringify(ips),
);
const joinPath = join(root, "join.bin");
await writeFile(
joinPath,
machoSlice({ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
);
const service = createAppleIpsService();
try {
const result = await service.inspect({ path, join_path: joinPath });
if (!result.ok) throw result.error;
const report = appleIpsSchema.parse(result.value.normalized_result);
expect(report.join?.status).toBe("selected");
if (report.join?.status !== "selected") throw new Error("Missing join");
expect(report.join.limitations.join(" ")).toContain(ips.usedImages[0].arch);
expect(report.join.frames[0]?.reason).toContain("join.limitations");
expect(
report.join.frames.every(
({ reason }) => !reason?.includes(ips.usedImages[0].arch),
),
).toBe(true);
expect(Buffer.byteLength(JSON.stringify(report.join.frames))).toBeLessThan(
8192,
);
} finally {
await service.close();
}
});
it.each(["S", "C"])(
"leaves shared-cache image source %s unjoined despite matching UUID and CPU",
async (source) => {
const root = await createTestTempDirectory("rea-ips-cache-source-");
const path = join(root, "report.ips");
await writeFile(
path,
appleIpsBytes()
.toString()
.replace('"source": "P"', `"source": "${source}"`),
);
const joinPath = join(root, "join.bin");
await writeFile(
joinPath,
machoSlice({ cpu: ARM64, uuid: FIXTURE_UUID, textVmaddr: 0x100000000n }),
);
const service = createAppleIpsService();
try {
const result = await service.inspect({ path, join_path: joinPath });
if (!result.ok) throw result.error;
const parsed = appleIpsSchema.parse(result.value.normalized_result);
expect(parsed.join).toMatchObject({
status: "selected",
image_matches: [],
frames: expect.arrayContaining([
expect.objectContaining({
thread_index: 0,
frame_index: 0,
status: "unjoined",
}),
]),
});
if (parsed.join?.status === "selected")
expect(parsed.join.limitations.join(" ")).toContain(
"shared-cache-backed",
);
} finally {
await service.close();
}
},
);
@@ -1,3 +1,6 @@
import { AppleIpsProvider } from "../../src/native/apple/AppleIpsProvider.js";
import { inspectAppleIpsInputSchema } from "../../src/domain/native/appleIps.js";
import { appleIpsBytes } from "../fixtures/binaryDiagnostics/appleIps.js";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { access, mkdir, writeFile, type FileHandle } from "node:fs/promises";
@@ -205,3 +208,30 @@ it.each(["idle", "active", "read-failure"])(
}
},
);
it("preserves the completed IPS and exact caller reason when the join read is cancelled", async () => {
const root = await createTestTempDirectory("rea-ips-join-cancel-");
const path = join(root, "report.ips");
const joinPath = join(root, "join.bin");
await writeFile(path, appleIpsBytes());
await writeFile(joinPath, Buffer.alloc(64));
fault.path = joinPath;
fault.controller = new AbortController();
fault.reason = { selected: "join cancellation" };
fault.abortAtFinalIdentity = true;
const provider = new AppleIpsProvider();
try {
const result = await provider.inspect(
inspectAppleIpsInputSchema.parse({ path, join_path: joinPath }),
{ signal: fault.controller.signal },
);
if (result.ok) throw new Error("Expected join cancellation");
expect(result.error).toMatchObject({
_tag: "AnalysisCancelledError",
partialObservation: { artifact: { path } },
});
expect(result.error.cause).toBe(fault.reason);
} finally {
await provider.close();
}
});