diff --git a/docs/apple-ips.md b/docs/apple-ips.md index fa5fbd62d6..2877851af8 100644 --- a/docs/apple-ips.md +++ b/docs/apple-ips.md @@ -38,9 +38,16 @@ address uses that row's own image base and offset. The recorded `inline` flag does not supply a missing offset or authorize borrowing another row's address. When `faultingThread` is missing or does not resolve to a recorded thread object, row-zero roles are `unknown`; the recorded lookup remains available. +Observed JSON strings retain `""`; `null` means the field was missing or JSON +`null`. This includes image paths, frame symbols, process text, and the older +string `osVersion` form. Unlisted JSON keys and generic register names also +retain empty spellings. An empty image architecture remains recorded as `""` +while `uuid_unknown` stays true, since that spelling does not establish an +architecture. An empty UUID is malformed, so its exact spelling appears in +`coverage.type_mismatches` and its canonical UUID remains unknown. `process.os_version` keeps the producer's dictionary (`train`, `build`, `release_type`, `is_embedded`) or the older string form. Image UUIDs are uppercase 8-4-4-4-12; an all-zero UUID -or a missing `arch` is `uuid_unknown`. Image `source` letters keep their +or a missing or empty `arch` is `uuid_unknown`. Image `source` letters keep their documented meaning (`P` process, `S` shared cache, `C` shared cache library, `K` kernel, `U` kernel cache, `T` kernel text exec, `A` absolute). Keys the documentation does not list remain in `unlisted_keys` or `unlisted_body_keys`. diff --git a/src/domain/native/appleIps.ts b/src/domain/native/appleIps.ts index 01cd609e57..057cc37425 100644 --- a/src/domain/native/appleIps.ts +++ b/src/domain/native/appleIps.ts @@ -114,41 +114,42 @@ const uuidSchema = z .string() .regex(/^[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}$/u); +// Observed IPS strings, including JSON member names, retain empty source text. const metadataSchema = z.strictObject({ bug_type: z.literal("309"), - timestamp: z.string().min(1).nullable(), - os_version: z.string().min(1).nullable(), - bundle_id: z.string().min(1).nullable(), - incident_id: z.string().min(1).nullable(), - name: z.string().min(1).nullable(), - unlisted_keys: z.array(z.string().min(1)), + timestamp: z.string().nullable(), + os_version: z.string().nullable(), + bundle_id: z.string().nullable(), + incident_id: z.string().nullable(), + name: z.string().nullable(), + unlisted_keys: z.array(z.string()), }); const exceptionSchema = z .strictObject({ - type: z.string().min(1).nullable(), - signal: z.string().min(1).nullable(), - subtype: z.string().min(1).nullable(), - codes: z.string().min(1).nullable(), - message: z.string().min(1).nullable(), - unlisted_keys: z.array(z.string().min(1)), + type: z.string().nullable(), + signal: z.string().nullable(), + subtype: z.string().nullable(), + codes: z.string().nullable(), + message: z.string().nullable(), + unlisted_keys: z.array(z.string()), }) .nullable(); const terminationSchema = z .strictObject({ - namespace: z.string().min(1).nullable(), + namespace: z.string().nullable(), code: recordedIntegerSchema.nullable(), - indicator: z.string().min(1).nullable(), - reasons: z.array(z.string().min(1)).nullable(), - unlisted_keys: z.array(z.string().min(1)), + indicator: z.string().nullable(), + reasons: z.array(z.string()).nullable(), + unlisted_keys: z.array(z.string()), }) .nullable(); const registerSchema = z.strictObject({ - name: z.string().min(1), + name: z.string(), value: recordedIntegerSchema, - description: z.string().min(1).nullable(), + description: z.string().nullable(), }); const frameSchema = z.strictObject({ @@ -168,30 +169,30 @@ const frameSchema = z.strictObject({ "unknown", ]), inline: z.boolean().nullable(), - recorded_symbol: z.string().min(1).nullable(), + recorded_symbol: z.string().nullable(), }); const threadSchema = z.strictObject({ index: uint, id: recordedIntegerSchema.nullable(), triggered: z.boolean().nullable(), - name: z.string().min(1).nullable(), - queue: z.string().min(1).nullable(), + name: z.string().nullable(), + queue: z.string().nullable(), frames: z.array(frameSchema), - state_flavor: z.string().min(1).nullable(), + state_flavor: z.string().nullable(), registers: z.array(registerSchema), }); const imageSchema = z.strictObject({ index: uint, - source: z.string().min(1).nullable(), + source: z.string().nullable(), source_meaning: z.string().min(1).nullable(), - arch: z.string().min(1).nullable(), + arch: z.string().nullable(), base: recordedIntegerSchema.nullable(), size: recordedIntegerSchema.nullable(), uuid: uuidSchema.nullable(), uuid_unknown: z.boolean(), - path: z.string().min(1).nullable(), + path: z.string().nullable(), }); const classificationMatchSchema = z.strictObject({ @@ -243,14 +244,14 @@ const coverageSchema = z.strictObject({ const osVersionSchema = z .discriminatedUnion("form", [ - z.strictObject({ form: z.literal("text"), text: z.string().min(1) }), + z.strictObject({ form: z.literal("text"), text: z.string() }), z.strictObject({ form: z.literal("structured"), - train: z.string().min(1).nullable(), - build: z.string().min(1).nullable(), - release_type: z.string().min(1).nullable(), + train: z.string().nullable(), + build: z.string().nullable(), + release_type: z.string().nullable(), is_embedded: z.boolean().nullable(), - unlisted_keys: z.array(z.string().min(1)), + unlisted_keys: z.array(z.string()), }), ]) .nullable(); @@ -264,11 +265,11 @@ export const appleIpsSchema = z.strictObject({ }), metadata: metadataSchema, process: z.strictObject({ - name: z.string().min(1).nullable(), - path: z.string().min(1).nullable(), + name: z.string().nullable(), + path: z.string().nullable(), pid: recordedIntegerSchema.nullable(), - parent: z.string().min(1).nullable(), - cpu_type: z.string().min(1).nullable(), + parent: z.string().nullable(), + cpu_type: z.string().nullable(), translated: z.boolean().nullable(), os_version: osVersionSchema, }), @@ -288,7 +289,7 @@ export const appleIpsSchema = z.strictObject({ }), ]), coverage: coverageSchema, - unlisted_body_keys: z.array(z.string().min(1)), + unlisted_body_keys: z.array(z.string()), limitations: z.array(z.string().min(1)), }); export type AppleIps = z.infer; diff --git a/src/native/apple/AppleIpsClassification.ts b/src/native/apple/AppleIpsClassification.ts index c9957b0581..7953e1c611 100644 --- a/src/native/apple/AppleIpsClassification.ts +++ b/src/native/apple/AppleIpsClassification.ts @@ -40,7 +40,9 @@ const exceptionMatches = (exception: AppleIps["exception"]): Match[] => { const subtype = exception?.subtype ?? null; if (subtype === null) return []; const typeField = - type === null ? [] : [{ name: "exception.type", value: type }]; + type === null || type === "" + ? [] + : [{ name: "exception.type", value: type }]; const fields = [...typeField, { name: "exception.subtype", value: subtype }]; const matches: Match[] = type === "EXC_GUARD" @@ -66,7 +68,7 @@ const dyldMatches = (termination: AppleIps["termination"]): Match[] => { rule: "dyld_missing_library", fields: [ { name: "termination.namespace", value: "DYLD" }, - ...(indicator === null + ...(indicator === null || indicator === "" ? [] : [{ name: "termination.indicator", value: indicator }]), ...(missingLibrary === undefined diff --git a/src/native/apple/AppleIpsFieldReader.ts b/src/native/apple/AppleIpsFieldReader.ts index 87ed2e99d3..1300b767b8 100644 --- a/src/native/apple/AppleIpsFieldReader.ts +++ b/src/native/apple/AppleIpsFieldReader.ts @@ -87,8 +87,7 @@ export class FieldReader { text(value: LosslessJson | undefined, path: string): string | null { if (value === undefined || value.kind === "null") return null; - if (value.kind === "string") - return value.value.length === 0 ? null : value.value; + if (value.kind === "string") return value.value; this.mismatch(path, "string", value); return null; } diff --git a/src/native/apple/AppleIpsParser.test.ts b/src/native/apple/AppleIpsParser.test.ts index 487b5cd573..d715a98046 100644 --- a/src/native/apple/AppleIpsParser.test.ts +++ b/src/native/apple/AppleIpsParser.test.ts @@ -1,6 +1,9 @@ import { createHash } from "node:crypto"; import { expect, it } from "vitest"; -import { inspectAppleIpsInputSchema } from "../../domain/native/appleIps.js"; +import { + appleIpsSchema, + inspectAppleIpsInputSchema, +} from "../../domain/native/appleIps.js"; import { appleIpsBytes, appleIpsMetadataLine, @@ -240,11 +243,11 @@ it("matches documented watchdog, guard, PAC and dyld rules", () => { }); }); -it("treats an empty image path as omitted and accepts CRLF line endings", () => { +it("retains an empty image path and accepts CRLF line endings", () => { const bytes = appleIpsBytes(); const crlf = Buffer.from(appleIpsReport().replaceAll("\n", "\r\n"), "utf8"); const report = parseAppleIps(bytes, artifact(bytes), input); - expect(report.images[1]?.path).toBeNull(); + expect(report.images[1]?.path).toBe(""); const crlfReport = parseAppleIps(crlf, artifact(crlf), input); expect(crlfReport.metadata.bug_type).toBe("309"); expect(crlfReport.threads[0]?.frames[0]?.role).toBe("faulting_instruction"); @@ -277,6 +280,75 @@ const parse = (bytes: Buffer, options: Record = {}) => const withBody = (body: string): Buffer => Buffer.from(`${appleIpsMetadataLine()}\n${body}`, "utf8"); +it("keeps empty observed text and JSON keys distinct from omitted fields", () => { + const bytes = Buffer.from( + '{"bug_type":"309","":0}\n{"procName":"","osVersion":"","threads":[{"threadState":{"":0},"frames":[{"symbol":""},{}]}],"usedImages":[{"source":"","arch":"","uuid":"","path":""},{}],"":0}', + ); + const parsed = appleIpsSchema.parse(parse(bytes)); + expect(parsed.metadata.unlisted_keys).toEqual([""]); + expect(parsed.unlisted_body_keys).toEqual([""]); + expect(parsed.process.name).toBe(""); + expect(parsed.process.path).toBeNull(); + expect(parsed.process.os_version).toEqual({ form: "text", text: "" }); + expect(parsed.threads[0]?.registers[0]).toMatchObject({ + name: "", + value: { decimal: "0", hex: "0x0" }, + }); + expect( + parsed.threads[0]?.frames.map((frame) => frame.recorded_symbol), + ).toEqual(["", null]); + expect(parsed.images[0]).toMatchObject({ + source: "", + source_meaning: null, + arch: "", + uuid: null, + uuid_unknown: true, + path: "", + }); + expect(parsed.images[1]?.path).toBeNull(); + expect(parsed.classification.status).toBe("unclassified"); + expect(parsed.coverage.type_mismatches).toEqual([ + { + path: "body.usedImages[0].uuid", + expected: "32 hexadecimal UUID digits with optional canonical hyphens", + observed: "malformed UUID string", + recorded_value: "", + }, + ]); +}); + +it("keeps an empty recorded architecture without claiming a UUID join", () => { + const parsed = parse( + withBody( + '{"threads":[],"usedImages":[{"arch":"","uuid":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}]}', + ), + ); + expect(parsed.images[0]).toMatchObject({ + arch: "", + uuid: "AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA", + uuid_unknown: true, + }); + expect(parsed.coverage.status).toBe("complete"); +}); + +it("does not copy empty source text into classification match fields", () => { + const parsed = parse( + withBody( + '{"threads":[],"usedImages":[],"exception":{"type":"","subtype":"KERN_INVALID_ADDRESS (possible pointer authentication failure)"},"termination":{"namespace":"DYLD","indicator":"","reasons":["Library not loaded: @rpath/missing.dylib"]}}', + ), + ); + expect(parsed.exception?.type).toBe(""); + expect(parsed.termination?.indicator).toBe(""); + expect(parsed.classification.matches.map((match) => match.rule)).toEqual([ + "possible_pointer_authentication_failure", + "dyld_missing_library", + ]); + expect( + parsed.classification.matches.flatMap((match) => match.fields), + ).not.toContainEqual(expect.objectContaining({ value: "" })); + expect(appleIpsSchema.safeParse(parsed).success).toBe(true); +}); + const report = (options: Parameters[0]) => parse(appleIpsBytes(options)); diff --git a/src/native/apple/AppleIpsParser.ts b/src/native/apple/AppleIpsParser.ts index 359cb14170..aa377cf906 100644 --- a/src/native/apple/AppleIpsParser.ts +++ b/src/native/apple/AppleIpsParser.ts @@ -105,10 +105,7 @@ const osVersionOf = ( read: FieldReader, ): AppleIps["process"]["os_version"] => { if (value === undefined || value.kind === "null") return null; - if (value.kind === "string") - return value.value.length === 0 - ? null - : { form: "text", text: value.value }; + if (value.kind === "string") return { form: "text", text: value.value }; if (value.kind !== "object") { read.mismatch("body.osVersion", "string or object", value); return null; @@ -210,7 +207,7 @@ const imagesOf = ( base: read.integer(members.base, `${path}.base`), size: read.integer(members.size, `${path}.size`), uuid: uuid.uuid, - uuid_unknown: uuid.unknown || arch === null, + uuid_unknown: uuid.unknown || arch === null || arch === "", path: read.text(members.path, `${path}.path`), }; }); diff --git a/tests/acceptance/appleIpsCli.test.ts b/tests/acceptance/appleIpsCli.test.ts index a529c00761..be4af11cfc 100644 --- a/tests/acceptance/appleIpsCli.test.ts +++ b/tests/acceptance/appleIpsCli.test.ts @@ -84,6 +84,51 @@ cliTest( }, ); +cliTest( + "preserves empty IPS source text through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-empty-"); + const path = join(root, "empty.ips"); + await writeFile( + path, + '{"bug_type":"309","":0}\n{"procName":"","osVersion":"","threads":[{"threadState":{"":0},"frames":[{"symbol":""}]}],"usedImages":[{"arch":"","uuid":"","path":""}],"":0}', + ); + const cliReply = await cli.run({ + arguments: ["inspect-apple-ips", path, "--json"], + }); + expect(cliReply.exitCode, cliReply.stderr).toBe(0); + const call = await cli.sharedMcpCall?.(); + if (call === undefined) throw new Error("Expected a stdio MCP session"); + const mcpReply = await call("inspect_apple_ips", { path }); + expect(mcpReply.isError, JSON.stringify(mcpReply.content)).not.toBe(true); + for (const reply of [cliReply.json, mcpReply.structuredContent]) { + const report = + toolContract("inspect_apple_ips").outputSchema.parse( + reply, + ).normalized_result; + expect(report.metadata.unlisted_keys).toEqual([""]); + expect(report.unlisted_body_keys).toEqual([""]); + expect(report.process.name).toBe(""); + expect(report.process.os_version).toEqual({ form: "text", text: "" }); + expect(report.threads[0]?.registers[0]?.name).toBe(""); + expect(report.threads[0]?.frames[0]?.recorded_symbol).toBe(""); + expect(report.images[0]).toMatchObject({ + arch: "", + uuid: null, + uuid_unknown: true, + path: "", + }); + expect(report.coverage.type_mismatches).toContainEqual( + expect.objectContaining({ + path: "body.usedImages[0].uuid", + recorded_value: "", + }), + ); + expect(report.classification.status).toBe("unclassified"); + } + }, +); + cliTest( "resolves a relative IPS path against the working directory", async ({ cli }) => {