mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
7fe5e5ff5e
commit
8dfc6832a4
7 files changed
+171
-48
No files matched your search
+8
-1
@@ -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`.
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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`),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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 }) => {
|
||||
|
||||
Reference in new issue
Block a user