diff --git a/docs/apple-ips.md b/docs/apple-ips.md index 2877851af8..73cc8d204d 100644 --- a/docs/apple-ips.md +++ b/docs/apple-ips.md @@ -38,6 +38,12 @@ 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. +A translated process records Rosetta's registers in a nested +`threadState.rosetta` object; each one is reported as `rosetta.` and +counts toward the register budget. A [public ReportCrash `.ips` log](https://github.com/Mortennn/Dozer/issues/189) +shows `rosetta.tmp2`, `tmp1`, and `tmp0` beside ordinary x86 registers. The +test fixture quotes that register excerpt inside an authored report body; it +does not generate a new Rosetta crash during the test. 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 diff --git a/src/cli/adbCommands.ts b/src/cli/adbCommands.ts index b2759b7b5f..6b8bfff58e 100644 --- a/src/cli/adbCommands.ts +++ b/src/cli/adbCommands.ts @@ -5,7 +5,10 @@ import type { AdbDeviceAnalysisPort } from "../application/adb/AdbDeviceAnalysis import { createAdbDeviceAnalysisProvider } from "../composition/adb.js"; import { CLI_COMMANDS } from "../cliCommandNames.js"; import { logCliCommand } from "../cliLogging.js"; -import type { AdbOperation } from "../domain/adb/adbDeviceAnalysis.js"; +import { + MAX_ADB_ACTIVITY_EXTRAS, + type AdbOperation, +} from "../domain/adb/adbDeviceAnalysis.js"; import type { Logger } from "pino"; import type { CliInstance } from "./types.js"; import { withCommandCancellation } from "./commandCancellation.js"; @@ -343,6 +346,7 @@ export const registerAdbCommands = ( .describe("Explicit component in package/activity form (-n)"), extra: z .array(z.string().min(1)) + .max(MAX_ADB_ACTIVITY_EXTRAS) .default([]) .describe( "Typed extra as type:key:value (string, boolean, int, long, float), repeatable", diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index 5a90bd099a..4110a9ec9e 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -59,10 +59,12 @@ const activityExtraStringValueSchema = z .max(2048) .regex(/^[^\x00]*$/u, "Extra values cannot contain NUL"); const activityExtraNonEmptyValueSchema = activityExtraStringValueSchema.min(1); +/** Maximum typed extras admitted for one Android activity request. */ +export const MAX_ADB_ACTIVITY_EXTRAS = 16; // Android's `am start --ef` parses this text with Float.valueOf. Keep the // caller's spelling intact while admitting Java FloatValue forms. const javaFloatValuePattern = - /^[\x01-\x20]*[+\-]?(?:NaN|Infinity|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+\-]?\d+)?[fFdD]?|0[xX](?:[\da-fA-F]+(?:\.[\da-fA-F]*)?|\.[\da-fA-F]+)[pP][+\-]?\d+[fFdD]?))[\x01-\x20]*$/u; + /^[\x01-\x20]*[+-]?(?:NaN|Infinity|(?:(?:\d+(?:\.\d*)?|\.\d+)(?:[eE][+-]?\d+)?[fFdD]?|0[xX](?:[\da-fA-F]+(?:\.[\da-fA-F]*)?|\.[\da-fA-F]+)[pP][+-]?\d+[fFdD]?))[\x01-\x20]*$/u; const activityExtraSchema = z.discriminatedUnion("type", [ z.strictObject({ key: activityExtraKeySchema, @@ -335,7 +337,7 @@ export const adbInputSchemas = { .describe("Explicit component to start (-n)"), extras: z .array(activityExtraSchema) - .max(16) + .max(MAX_ADB_ACTIVITY_EXTRAS) .default([]) .describe("Typed intent extras (--es/--ez/--ei/--el/--ef)"), }), diff --git a/src/native/apple/AppleIpsJsonShape.ts b/src/native/apple/AppleIpsJsonShape.ts index 5ae8b57d52..014834f123 100644 --- a/src/native/apple/AppleIpsJsonShape.ts +++ b/src/native/apple/AppleIpsJsonShape.ts @@ -96,6 +96,13 @@ const threadShape = ( members: { flavor: SCALAR, x: { kind: "array", items: register, beforeItem: take.registers }, + // Rosetta-translated processes nest their translator registers. + rosetta: { + kind: "object", + members: {}, + rest: register, + beforeRest: take.registers, + }, }, rest: register, beforeRest: take.registers, diff --git a/src/native/apple/AppleIpsParser.test.ts b/src/native/apple/AppleIpsParser.test.ts index d715a98046..ef1ae9996b 100644 --- a/src/native/apple/AppleIpsParser.test.ts +++ b/src/native/apple/AppleIpsParser.test.ts @@ -572,6 +572,78 @@ it("reports wrong-type collections as partial instead of empty and complete", () expect(empty.coverage.status).toBe("complete"); }); +it("keeps the nested Rosetta registers of a translated process", () => { + const parsed = parse( + withBody(`{ + "translated": true, + "threads": [ + { + "threadState": { + "flavor": "x86_THREAD_STATE", + "rip": { "value": 4294983680 }, + "rosetta": { "tmp0": { "value": 7 }, "tmp1": 8 } + } + } + ], + "usedImages": [] + }`), + ); + expect(parsed.threads[0]?.registers).toEqual([ + { + name: "rip", + value: { decimal: "4294983680", hex: "0x100004000" }, + description: null, + }, + { + name: "rosetta.tmp0", + value: { decimal: "7", hex: "0x7" }, + description: null, + }, + { + name: "rosetta.tmp1", + value: { decimal: "8", hex: "0x8" }, + description: null, + }, + ]); + expect(parsed.coverage).toMatchObject({ + status: "complete", + registers: 3, + type_mismatches: [], + }); +}); + +it.each([ + { rosetta: "7", observed: "integer" }, + { rosetta: '"invalid"', observed: "string" }, + { rosetta: "[]", observed: "array" }, +])( + "reports a $observed Rosetta container without losing ordinary registers", + ({ rosetta, observed }) => { + const parsed = parse( + withBody(`{ + "threads": [{ "threadState": { "rip": 1, "rosetta": ${rosetta}, "rsp": 2 } }], + "usedImages": [] + }`), + { max_registers: 2 }, + ); + expect(parsed.threads[0]?.registers.map(({ name }) => name)).toEqual([ + "rip", + "rsp", + ]); + expect(parsed.coverage).toMatchObject({ + status: "partial", + registers: 2, + type_mismatches: [ + { + path: "body.threads[0].threadState.rosetta", + expected: "object", + observed, + }, + ], + }); + }, +); + it("records wrong-type rows, scalars and registers with their source path", () => { const parsed = parse( withBody(`{ @@ -583,7 +655,7 @@ it("records wrong-type rows, scalars and registers with their source path", () = 7, { "frames": [{ "imageIndex": 99999999999, "imageOffset": "8" }, []], - "threadState": { "flavor": 1, "x": [1, "two", { "value": null }, {}], "rosetta": { "tmp0": 1 } } + "threadState": { "flavor": 1, "x": [1, "two", { "value": null }, {}], "rosetta": { "tmp0": "one" } } } ], "usedImages": [null, { "base": 1.0 }] @@ -639,9 +711,9 @@ it("records wrong-type rows, scalars and registers with their source path", () = observed: "absent", }, { - path: "body.threads[1].threadState.rosetta.value", - expected: "integer", - observed: "absent", + path: "body.threads[1].threadState.rosetta.tmp0", + expected: "integer or object with integer value", + observed: "string", }, { path: "body.threads[1].threadState.flavor", @@ -700,6 +772,69 @@ it("budgets register slots before expanding threadState rows", () => { ); }); +it("budgets Rosetta registers with the ordinary registers they follow", () => { + const rosetta = (third: string): Buffer => + withBody( + `{"threads":[{"threadState":{"x":[0],"rosetta":{"tmp0":0,"tmp1":0,"tmp2":${third}}}}],"usedImages":[]}`, + ); + expect(parse(rosetta("0"), { max_registers: 4 }).coverage).toMatchObject({ + status: "complete", + registers: 4, + }); + // The register past the allowance is invalid JSON; the budget must refuse first. + expect(() => parse(rosetta("?"), { max_registers: 3 })).toThrow( + expect.objectContaining({ boundary: "registers", maximum: 3 }), + ); +}); + +it("keeps valid registers around a malformed Rosetta member at the exact slot limit", () => { + const parsed = parse( + withBody(`{ + "translated": true, + "threads": [{ + "threadState": { + "flavor": "x86_THREAD_STATE", + "rip": { "value": 1 }, + "rosetta": { "tmp0": { "value": 2 }, "tmp1": "invalid" }, + "rsp": { "value": 3 } + } + }], + "usedImages": [] + }`), + { max_registers: 4 }, + ); + expect(parsed.threads[0]?.registers.map(({ name }) => name)).toEqual([ + "rip", + "rosetta.tmp0", + "rsp", + ]); + expect(parsed.coverage).toMatchObject({ + status: "partial", + registers: 3, + type_mismatches: [ + { + path: "body.threads[0].threadState.rosetta.tmp1", + expected: "integer or object with integer value", + observed: "string", + }, + ], + }); +}); + +it("shares the register allowance across Rosetta groups and later ordinary registers", () => { + const mixed = (last: string): Buffer => + withBody( + `{"threads":[{"threadState":{"rosetta":{"tmp0":0}}},{"threadState":{"rosetta":{"tmp1":0},"rip":${last}}}],"usedImages":[]}`, + ); + expect(parse(mixed("0"), { max_registers: 3 }).coverage).toMatchObject({ + status: "complete", + registers: 3, + }); + expect(() => parse(mixed("?"), { max_registers: 2 })).toThrow( + expect.objectContaining({ boundary: "registers", maximum: 2 }), + ); +}); + it("refuses an over-budget collection before parsing the rows past the allowance", () => { // Rows after the allowance are invalid JSON; the budget must refuse first. const threads = withBody(`{"threads":[{},{},{},?],"usedImages":[]}`); diff --git a/src/native/apple/AppleIpsParser.ts b/src/native/apple/AppleIpsParser.ts index aa377cf906..9bf8e656ec 100644 --- a/src/native/apple/AppleIpsParser.ts +++ b/src/native/apple/AppleIpsParser.ts @@ -172,6 +172,20 @@ const threadStateOf = ( }); for (const [key, entry] of Object.entries(members)) { if (key === "x" || key === "flavor") continue; + if (key === "rosetta") { + // A translated process records Rosetta's registers as a nested state. + const rosetta = read.object(entry, `${path}.rosetta`); + for (const [name, nested] of Object.entries(rosetta?.members ?? {})) { + const register = registerValue( + `rosetta.${name}`, + nested, + `${path}.rosetta.${name}`, + read, + ); + if (register !== null) registers.push(register); + } + continue; + } const register = registerValue(key, entry, `${path}.${key}`, read); if (register !== null) registers.push(register); } diff --git a/tests/acceptance/appleIpsCli.test.ts b/tests/acceptance/appleIpsCli.test.ts index be4af11cfc..1fc118e568 100644 --- a/tests/acceptance/appleIpsCli.test.ts +++ b/tests/acceptance/appleIpsCli.test.ts @@ -3,7 +3,10 @@ import { createHash } from "node:crypto"; import { join } from "node:path"; import { expect } from "vitest"; import { toolContract } from "../../src/contracts/toolContracts.js"; -import { appleIpsBytes } from "../fixtures/binaryDiagnostics/appleIps.js"; +import { + appleIpsBytes, + appleIpsRosettaBytes, +} from "../fixtures/binaryDiagnostics/appleIps.js"; import { createTestTempDirectory } from "../fixtures/temporaryDirectory.js"; import { cliTest } from "../support/cli/cliFixture.js"; @@ -129,6 +132,68 @@ cliTest( }, ); +cliTest( + "keeps producer-shaped Rosetta registers through CLI and stdio MCP", + async ({ cli }) => { + const root = await createTestTempDirectory("rea-apple-ips-rosetta-"); + const path = join(root, "translated.ips"); + await writeFile(path, appleIpsRosettaBytes()); + 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.threads[0]?.state_flavor).toBe("x86_THREAD_STATE"); + expect(report.threads[0]?.registers.map(({ name }) => name)).toEqual([ + "rbp", + "r12", + "rosetta.tmp2", + "rosetta.tmp1", + "rosetta.tmp0", + "rbx", + ]); + expect(report.threads[0]?.registers[2]?.value.decimal).toBe( + "140706223703876", + ); + expect(report.coverage).toMatchObject({ + status: "complete", + registers: 6, + type_mismatches: [], + }); + } + + const malformedPath = join(root, "translated-malformed.ips"); + await writeFile(malformedPath, appleIpsRosettaBytes("invalid")); + const malformedReply = await cli.run({ + arguments: ["inspect-apple-ips", malformedPath, "--json"], + }); + expect(malformedReply.exitCode, malformedReply.stderr).toBe(0); + const malformed = toolContract("inspect_apple_ips").outputSchema.parse( + malformedReply.json, + ).normalized_result; + expect(malformed.coverage).toMatchObject({ + status: "partial", + registers: 5, + type_mismatches: [ + { + path: "body.threads[0].threadState.rosetta.tmp1.value", + expected: "integer", + observed: "string", + }, + ], + }); + expect(malformed.threads[0]?.registers.at(-1)?.name).toBe("rbx"); + }, +); + cliTest( "resolves a relative IPS path against the working directory", async ({ cli }) => { diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index ba41a93c49..6290ea4068 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -1,10 +1,11 @@ import { chmod, lstat, readFile, unlink } from "node:fs/promises"; import { createHash } from "node:crypto"; -import { basename, dirname } from "node:path"; +import { basename, dirname, join } from "node:path"; import { expect, it, onTestFinished } from "vitest"; import { z } from "zod"; import { toolContract } from "../../../src/contracts/toolContracts.js"; +import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js"; import { execFileOutput, execFileOutputFailure, @@ -188,6 +189,55 @@ it.each(["string:key", ":", "string"])( }, ); +it.each([16, 17])( + "enforces the activity extra count before host admission: %d entries", + async (count) => { + const root = await createTestTempDirectory("rea-adb-extra-limit-"); + const missingAdb = join(root, "missing-adb"); + const extras = Array.from({ length: count }, (_, index) => [ + "--extra", + `string:key${index}:value${index}`, + ]).flat(); + const result = await execFileOutput( + process.execPath, + [ + "scripts/rea.mjs", + "start-adb-activity", + "emulator-5554", + "android.intent.action.VIEW", + ...extras, + "--json", + ], + { + env: { ...process.env, REA_ADB_PATH: missingAdb }, + timeout: 20_000, + stopSignal: "SIGTERM", + maxBuffer: 1024 * 1024, + }, + ).then( + (output) => ({ exitCode: 0, stdout: output.stdout }), + (cause: unknown) => { + const failure = execFileOutputFailure(cause); + if (failure === undefined || typeof failure.code !== "number") + throw cause; + return { exitCode: failure.code, stdout: failure.stdout }; + }, + ); + if (count === 16) { + expectConfigurationRefusal( + result, + `The selected adb binary was not found: ${missingAdb}. Set REA_ADB_PATH`, + ); + } else { + expect(JSON.parse(result.stdout)).toMatchObject({ + code: "VALIDATION_ERROR", + fieldErrors: [{ code: "too_big", path: "extra" }], + }); + } + expect(result.exitCode, result.stdout).toBe(1); + }, +); + it.skipIf(process.platform === "win32")( "starts an explicitly selected nested Android activity through the CLI", async () => { diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index a64fc24851..0764147e61 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest"; import { createCli } from "../../../src/cli.js"; import { createCliInventory } from "../../../scripts/lib/catalog-cli.mjs"; +import { adbInputSchemas } from "../../../src/domain/adb/adbDeviceAnalysis.js"; import { apktoolInputSchemas, MAX_SELECTED_LOCALES, @@ -315,6 +316,49 @@ describe("web module CLI advertised input schema", () => { }); }); +describe("ADB activity extra input schema", () => { + it("matches the owning workflow's extra count through advertisement and CLI parsing", async () => { + const cli = createCli({}); + const advertised = await advertisedSchema(cli, "start-adb-activity"); + const parser = parserSchema(cli, "start-adb-activity", "options"); + if (parser === undefined) + throw new Error("ADB activity options parser was not registered"); + const validate = new Ajv2020({ strict: false }).compile( + advertised.options ?? {}, + ); + expect(advertised.options?.properties.extra).toMatchObject({ + type: "array", + maxItems: 16, + default: [], + }); + const request = { + serial: "emulator-5554", + action: "android.intent.action.VIEW", + }; + expect(validate({})).toBe(true); + const omitted = adbInputSchemas.start_adb_activity.parse(request); + expect(omitted.extras).toEqual([]); + expect(parser.parse({}).extra).toEqual(omitted.extras); + for (const count of [0, 16, 17]) { + const extras = Array.from({ length: count }, (_, index) => ({ + type: "string", + key: `key${index}`, + value: `value${index}`, + })); + const input = { + extra: extras.map(({ type, key, value }) => `${type}:${key}:${value}`), + }; + const expected = adbInputSchemas.start_adb_activity.safeParse({ + ...request, + extras, + }).success; + expect(expected).toBe(count <= 16); + expect(parser.safeParse(input).success).toBe(expected); + expect(validate(input)).toBe(expected); + } + }); +}); + describe("Apktool locale selection input schema", () => { it("preserves the owner's array bounds through CLI advertisement and parsing", async () => { const cli = createCli({}); diff --git a/tests/fixtures/binaryDiagnostics/appleIps.ts b/tests/fixtures/binaryDiagnostics/appleIps.ts index 29b1c6e007..4bb557fee7 100644 --- a/tests/fixtures/binaryDiagnostics/appleIps.ts +++ b/tests/fixtures/binaryDiagnostics/appleIps.ts @@ -166,3 +166,32 @@ export const appleIpsReport = ( export const appleIpsBytes = (options: AppleIpsFixtureOptions = {}): Buffer => Buffer.from(appleIpsReport(options), "utf8"); + +/** + * Register excerpt from a public ReportCrash .ips log: + * https://github.com/Mortennn/Dozer/issues/189 + * The metadata and surrounding body are authored test scaffolding. The + * malformed variant changes only tmp1 to exercise source-path reporting. + */ +export const appleIpsRosettaBytes = (tmp1: number | string = 532): Buffer => + Buffer.from( + `${appleIpsMetadataLine()}\n{ + "translated": true, + "cpuType": "X86-64", + "threads": [{ + "threadState": { + "flavor": "x86_THREAD_STATE", + "rbp": { "value": 13029550560 }, + "r12": { "value": 0 }, + "rosetta": { + "tmp2": { "value": 140706223703876 }, + "tmp1": { "value": ${JSON.stringify(tmp1)} }, + "tmp0": { "value": 140703707485410 } + }, + "rbx": { "value": 140703456658352 } + } + }], + "usedImages": [] + }`, + "utf8", + );