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.
This commit is contained in:
morluto committed 2026-10-25 03:30:38 +08:00
1 parent 7fe5e5ff5e
commit 8dfc6832a4
7 files changed
+171 -48

No files matched your search

+8 -1
View File
@@ -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`.
+36 -35
View File
@@ -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<typeof appleIpsSchema>;
+4 -2
View File
@@ -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
+1 -2
View File
@@ -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;
}
+75 -3
View File
@@ -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<string, number> = {}) =>
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<typeof appleIpsReport>[0]) =>
parse(appleIpsBytes(options));
+2 -5
View File
@@ -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`),
};
});
+45
View File
@@ -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 }) => {