From cb26e8ca638ab5b64f4c475d727274d91ba1595f Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:45:47 +0800 Subject: [PATCH] fix(contracts): share ADB limits and preserve Rosetta register facts Use the domain-owned activity extra count in CLI parsing and advertisement. Preserve nested Rosetta registers at the selecting parser and qualified register projection, sharing the existing aggregate budget. Port the Rosetta work from Adolanium commits 1889ac153 and c9e93bd88 in #6976, including morluto follow-up 07fa932ee. Add malformed-container and mixed-thread budget controls. CLI/MCP coverage uses a recorded ReportCrash excerpt in an authored report, without generating a native crash or contacting an Android device. Refs: #6291, #6976, #6977 Co-authored-by: Adolanium <94890352+Adolanium@users.noreply.github.com> --- docs/apple-ips.md | 6 + src/cli/adbCommands.ts | 6 +- src/domain/adb/adbDeviceAnalysis.ts | 6 +- src/native/apple/AppleIpsJsonShape.ts | 7 + src/native/apple/AppleIpsParser.test.ts | 143 +++++++++++++++++- src/native/apple/AppleIpsParser.ts | 14 ++ tests/acceptance/appleIpsCli.test.ts | 67 +++++++- tests/boundary/cli/adbCli.test.ts | 52 ++++++- .../cli/cliInputSchemaRequired.test.ts | 44 ++++++ tests/fixtures/binaryDiagnostics/appleIps.ts | 29 ++++ 10 files changed, 365 insertions(+), 9 deletions(-) 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", + );