From 8dfc6832a4d497a6aff3c7151394a47d7968f7ba Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 03:30:38 +0800 Subject: [PATCH] fix(apple): preserve empty IPS text and source keys Keep observed empty JSON strings separate from missing fields in IPS process, image, frame, register and OS-version facts. Preserve empty unlisted keys, retain malformed UUID source text, and keep unknown architecture interpretation separate from raw spelling. Classification excludes empty supporting fields. Validate producer cases plus compiled CLI and stdio MCP delivery against the same output contract. This is saved-report inspection, without target execution. --- docs/apple-ips.md | 9 ++- src/domain/native/appleIps.ts | 71 ++++++++++---------- src/native/apple/AppleIpsClassification.ts | 6 +- src/native/apple/AppleIpsFieldReader.ts | 3 +- src/native/apple/AppleIpsParser.test.ts | 78 +++++++++++++++++++++- src/native/apple/AppleIpsParser.ts | 7 +- tests/acceptance/appleIpsCli.test.ts | 45 +++++++++++++ 7 files changed, 171 insertions(+), 48 deletions(-) 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 }) => {