From 97b5503f398ef0be35631e2dfb32853bb84c7184 Mon Sep 17 00:00:00 2001 From: 2SinceJuly Date: Mon, 9 Nov 2026 20:20:33 +0800 Subject: [PATCH] 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> --- docs/apple-ips.md | 67 ++- .../binaryDiagnostics/AppleIpsService.test.ts | 51 ++ .../binaryDiagnostics/AppleIpsService.ts | 14 +- src/cli/binaryDiagnosticsCommands.ts | 13 + .../native/binaryDiagnosticsToolContracts.ts | 9 +- src/domain/analysisErrorBase.ts | 2 + src/domain/native/appleIps.test.ts | 5 +- src/domain/native/appleIps.ts | 97 ++++ src/native/apple/AppleIpsFieldReader.ts | 36 ++ src/native/apple/AppleIpsMachOJoin.ts | 384 +++++++++++++ src/native/apple/AppleIpsParser.ts | 5 +- src/native/apple/AppleIpsProvider.ts | 136 +++-- .../acceptance/appleIpsJoinFailureCli.test.ts | 40 ++ .../boundary/filesystem/appleIpsJoin.test.ts | 532 ++++++++++++++++++ .../artifactReadFailurePrecedence.test.ts | 30 + 15 files changed, 1376 insertions(+), 45 deletions(-) create mode 100644 src/native/apple/AppleIpsMachOJoin.ts create mode 100644 tests/acceptance/appleIpsJoinFailureCli.test.ts create mode 100644 tests/boundary/filesystem/appleIpsJoin.test.ts diff --git a/docs/apple-ips.md b/docs/apple-ips.md index 375427fddd..16519c38a6 100644 --- a/docs/apple-ips.md +++ b/docs/apple-ips.md @@ -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. diff --git a/src/application/binaryDiagnostics/AppleIpsService.test.ts b/src/application/binaryDiagnostics/AppleIpsService.test.ts index 868c9f181b..894e045b52 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.test.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.test.ts @@ -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" }, + }); +}); diff --git a/src/application/binaryDiagnostics/AppleIpsService.ts b/src/application/binaryDiagnostics/AppleIpsService.ts index 25539b3353..160f3a2eba 100644 --- a/src/application/binaryDiagnostics/AppleIpsService.ts +++ b/src/application/binaryDiagnostics/AppleIpsService.ts @@ -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; diff --git a/src/cli/binaryDiagnosticsCommands.ts b/src/cli/binaryDiagnosticsCommands.ts index 3ca5ff6280..448af4c8bd 100644 --- a/src/cli/binaryDiagnosticsCommands.ts +++ b/src/cli/binaryDiagnosticsCommands.ts @@ -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 }, ), diff --git a/src/contracts/native/binaryDiagnosticsToolContracts.ts b/src/contracts/native/binaryDiagnosticsToolContracts.ts index a219e30bf1..9648e23d03 100644 --- a/src/contracts/native/binaryDiagnosticsToolContracts.ts +++ b/src/contracts/native/binaryDiagnosticsToolContracts.ts @@ -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", + }, + }, ], }, { diff --git a/src/domain/analysisErrorBase.ts b/src/domain/analysisErrorBase.ts index 9f2228e857..9e3cb3495d 100644 --- a/src/domain/analysisErrorBase.ts +++ b/src/domain/analysisErrorBase.ts @@ -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 diff --git a/src/domain/native/appleIps.test.ts b/src/domain/native/appleIps.test.ts index baf2cae70b..0f9664c801 100644 --- a/src/domain/native/appleIps.test.ts +++ b/src/domain/native/appleIps.test.ts @@ -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"); diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts index b9d3a9fff2..9d2cd64d96 100644 --- a/src/domain/native/appleIps.ts +++ b/src/domain/native/appleIps.ts @@ -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; @@ -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; @@ -309,6 +384,7 @@ export type AppleIpsThread = z.infer; export type AppleIpsImage = z.infer; export type AppleIpsFrame = z.infer; export type AppleIpsTypeMismatch = z.infer; +export type AppleIpsJoin = z.infer; export type RecordedInteger = z.infer; /** 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; +}; diff --git a/src/native/apple/AppleIpsFieldReader.ts b/src/native/apple/AppleIpsFieldReader.ts index 1300b767b8..6db1d2954b 100644 --- a/src/native/apple/AppleIpsFieldReader.ts +++ b/src/native/apple/AppleIpsFieldReader.ts @@ -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) }); } diff --git a/src/native/apple/AppleIpsMachOJoin.ts b/src/native/apple/AppleIpsMachOJoin.ts new file mode 100644 index 0000000000..1c9b9e026b --- /dev/null +++ b/src/native/apple/AppleIpsMachOJoin.ts @@ -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 => { + 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> = { + 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 => { + 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(); + const rejectedImage = new Map(); + 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; + readonly rejected: ReadonlyMap; + readonly slices: readonly SliceIdentity[]; + readonly read: FieldReader; +} + +const frameJoin = ( + frame: AppleIps["threads"][number]["frames"][number], + threadIndex: number, + context: FrameJoinContext, +): Extract["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["frames"][number] => ({ + thread_index: threadIndex, + frame_index: frameIndex, + status: "unjoined", + reason, + static_address: null, +}); diff --git a/src/native/apple/AppleIpsParser.ts b/src/native/apple/AppleIpsParser.ts index e1dc7157ea..851c62e033 100644 --- a/src/native/apple/AppleIpsParser.ts +++ b/src/native/apple/AppleIpsParser.ts @@ -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, }; }; diff --git a/src/native/apple/AppleIpsProvider.ts b/src/native/apple/AppleIpsProvider.ts index e8c7574f4d..24b0a1df1d 100644 --- a/src/native/apple/AppleIpsProvider.ts +++ b/src/native/apple/AppleIpsProvider.ts @@ -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, - ); -}; diff --git a/tests/acceptance/appleIpsJoinFailureCli.test.ts b/tests/acceptance/appleIpsJoinFailureCli.test.ts new file mode 100644 index 0000000000..1ca7f6d196 --- /dev/null +++ b/tests/acceptance/appleIpsJoinFailureCli.test.ts @@ -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, + }, + }, + }, + }); + }, +); diff --git a/tests/boundary/filesystem/appleIpsJoin.test.ts b/tests/boundary/filesystem/appleIpsJoin.test.ts new file mode 100644 index 0000000000..1533475a33 --- /dev/null +++ b/tests/boundary/filesystem/appleIpsJoin.test.ts @@ -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[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(); + } + }, +); diff --git a/tests/process-global/artifactReadFailurePrecedence.test.ts b/tests/process-global/artifactReadFailurePrecedence.test.ts index 83270a4a10..5e3e9ab734 100644 --- a/tests/process-global/artifactReadFailurePrecedence.test.ts +++ b/tests/process-global/artifactReadFailurePrecedence.test.ts @@ -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(); + } +});