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>
This commit is contained in:
morluto
2026-10-25 09:45:47 +08:00
parent d23dc5d188
commit cb26e8ca63
10 changed files with 365 additions and 9 deletions
+6
View File
@@ -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.<name>` 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
+5 -1
View File
@@ -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",
+4 -2
View File
@@ -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)"),
}),
+7
View File
@@ -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,
+139 -4
View File
@@ -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":[]}`);
+14
View File
@@ -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);
}
+66 -1
View File
@@ -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 }) => {
+51 -1
View File
@@ -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 () => {
@@ -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({});
+29
View File
@@ -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",
);