mirror of
https://github.com/morluto/rea.git
synced 2026-11-10 00:36:35 +00:00
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:
15 files changed
+1376
-45
No files matched your search
+63
-4
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user