fix(adb): reject unrepresentable activity arguments (#9918)

* fix(adb): reject unrepresentable activity arguments

* test(adb): lock complete activity component boundaries

The CLI and advertised schema still admitted a slash-free 512-character
component after admission required a nonempty package and class. Keep
that length sample rejected, admit a pattern-valid 512-character
component, and assert the typed field error.

* style(docs): format NTFS verification tables

oxfmt rejects the tables merged from main. The alignment is formatting
only, so static checks can run on this branch.

* test(adb): use a well-formed component for the length constraint

An over-long run of letters also fails the package/activity pattern, so the selector check reported both issues. The length fixture now has a valid component shape.

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
This commit is contained in:
morlutoandCursor Agent authored and GitHub committed 2026-11-11 17:26:41 +08:00
1 parent 13abfdadff
commit eff195d6aa
5 files changed
+130 -25

No files matched your search

@@ -50,6 +50,41 @@ it.each([
});
});
it.each([
[{ component: "example.app" }, ["component"]],
[{ component: "example.app/" }, ["component"]],
[
{ extras: [{ key: "count", type: "int", value: "2147483648" }] },
["extras", 0, "value"],
],
[
{ extras: [{ key: "count", type: "int", value: "-2147483649" }] },
["extras", 0, "value"],
],
[
{ extras: [{ key: "count", type: "long", value: "9223372036854775808" }] },
["extras", 0, "value"],
],
[
{ extras: [{ key: "count", type: "long", value: "-9223372036854775809" }] },
["extras", 0, "value"],
],
] as const)(
"rejects an unrepresentable activity argument before any effect: %j",
async (input, path) => {
const service = new AdbDeviceAnalysisService(refusedPort);
const result = await service.execute("start_adb_activity", {
serial: "fixture",
action: "example.TEST",
...input,
});
expect(result).toMatchObject({
ok: false,
error: { _tag: "AnalysisInputError", issues: [{ path }] },
});
},
);
it("rejects an unknown operation before any effect", async () => {
const service = new AdbDeviceAnalysisService(refusedPort);
const result = await service.execute("unknown_adb_operation" as never, {});
+16 -13
View File
@@ -2,11 +2,11 @@ import { expect, it } from "vitest";
import { adbInputSchemas } from "./adbDeviceAnalysis.js";
it("preserves ADB key, action and component admission when making advertised patterns portable", () => {
it("matches portable patterns for ADB keys, actions and complete components", () => {
const serial = "emulator-5554";
const variants = [
{
previous: /^[A-Za-z0-9._-]+$/u,
expectedPattern: /^[A-Za-z0-9._-]+$/u,
max: 256,
accepts: (key: string) =>
adbInputSchemas.read_adb_setting.safeParse({
@@ -16,7 +16,7 @@ it("preserves ADB key, action and component admission when making advertised pat
}).success,
},
{
previous: /^[A-Za-z0-9._-]+$/u,
expectedPattern: /^[A-Za-z0-9._-]+$/u,
max: 256,
accepts: (key: string) =>
adbInputSchemas.start_adb_activity.safeParse({
@@ -26,14 +26,14 @@ it("preserves ADB key, action and component admission when making advertised pat
}).success,
},
{
previous: /^[A-Za-z][A-Za-z0-9._-]*$/u,
expectedPattern: /^[A-Za-z][A-Za-z0-9._-]*$/u,
max: 256,
accepts: (action: string) =>
adbInputSchemas.start_adb_activity.safeParse({ serial, action })
.success,
},
{
previous: /^[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._$-]*)?$/u,
expectedPattern: /^[A-Za-z0-9._-]+\/[A-Za-z0-9._$-]+$/u,
max: 512,
accepts: (component: string) =>
adbInputSchemas.start_adb_activity.safeParse({
@@ -53,6 +53,7 @@ it("preserves ADB key, action and component admission when making advertised pat
"android.intent.action.VIEW",
"com.example/.MainActivity",
"com.example/.Outer$InnerActivity",
"com.example/com.example.Outer$InnerActivity",
"com.example.Outer$InnerActivity",
"com.example/",
"com.example/a/b",
@@ -75,11 +76,11 @@ it("preserves ADB key, action and component admission when making advertised pat
for (const sample of samples)
expect(
variant.accepts(sample),
JSON.stringify({ pattern: variant.previous.source, sample }),
JSON.stringify({ pattern: variant.expectedPattern.source, sample }),
).toBe(
sample.length > 0 &&
sample.length <= variant.max &&
variant.previous.test(sample),
variant.expectedPattern.test(sample),
);
});
@@ -97,6 +98,9 @@ it("admits empty string extras and validates typed extra text at domain admissio
const accepted = [
{ type: "boolean", value: "false" },
{ type: "int", value: "-2147483648" },
{ type: "int", value: "0002147483647" },
{ type: "long", value: "-0009223372036854775808" },
{ type: "long", value: "9223372036854775807" },
{ type: "long", value: "9007199254740993" },
{ type: "float", value: "-1.25" },
{ type: "float", value: "1e-3" },
@@ -106,14 +110,13 @@ it("admits empty string extras and validates typed extra text at domain admissio
{ type: "float", value: "0x1.8p1" },
{ type: "float", value: "NaN" },
] as const;
for (const { type, value } of accepted)
for (const { type, value } of accepted) {
const extras = [{ key: "value", type, value }];
expect(
adbInputSchemas.start_adb_activity.safeParse({
...input,
extras: [{ key: "value", type, value }],
}).success,
adbInputSchemas.start_adb_activity.parse({ ...input, extras }).extras,
`${type}: ${value}`,
).toBe(true);
).toEqual(extras);
}
const rejected = [
{ type: "boolean", value: "True" },
{ type: "int", value: "1.5" },
+22 -8
View File
@@ -54,11 +54,29 @@ const keyPattern = /^[A-Za-z0-9._\x2d]+$/u;
const activityExtraKeySchema = keyStringSchema
.regex(keyPattern, "Extra keys are identifiers")
.describe("Extra key");
const MAX_ADB_ACTIVITY_EXTRA_VALUE_LENGTH = 2048;
const activityExtraStringValueSchema = z
.string()
.max(2048)
.max(MAX_ADB_ACTIVITY_EXTRA_VALUE_LENGTH)
.regex(/^[^\x00]*$/u, "Extra values cannot contain NUL");
const activityExtraNonEmptyValueSchema = activityExtraStringValueSchema.min(1);
/** Check Java int/long range without rounding or changing the caller's text. */
const javaDecimalExtraValueSchema = (bits: 32 | 64) => {
const upperBound = 1n << BigInt(bits - 1);
const range = `signed ${String(bits)}-bit`;
return z
.string()
.max(MAX_ADB_ACTIVITY_EXTRA_VALUE_LENGTH, { abort: true })
.regex(/^-?\d+$/u, {
message: "Integer extra values must be signed decimal text",
abort: true,
})
.refine((value) => {
const decimal = BigInt(value);
return decimal >= -upperBound && decimal < upperBound;
}, `Integer extra value is outside the ${range} range`)
.describe(`Integer extra value as ${range} decimal text`);
};
/** 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
@@ -83,16 +101,12 @@ const activityExtraSchema = z.discriminatedUnion("type", [
z.strictObject({
key: activityExtraKeySchema,
type: z.literal("int").describe("Integer extra value type"),
value: activityExtraNonEmptyValueSchema
.regex(/^-?\d+$/u, "Integer extra values must be an integer")
.describe("Integer extra value as signed decimal text"),
value: javaDecimalExtraValueSchema(32),
}),
z.strictObject({
key: activityExtraKeySchema,
type: z.literal("long").describe("Long extra value type"),
value: activityExtraNonEmptyValueSchema
.regex(/^-?\d+$/u, "Long extra values must be an integer")
.describe("Long extra value as signed decimal text"),
value: javaDecimalExtraValueSchema(64),
}),
z.strictObject({
key: activityExtraKeySchema,
@@ -330,7 +344,7 @@ export const adbInputSchemas = {
.min(1)
.max(512)
.regex(
/^[A-Za-z0-9._\x2d]+(?:\/[A-Za-z0-9._$\x2d]*)?$/u,
/^[A-Za-z0-9._\x2d]+\/[A-Za-z0-9._$\x2d]+$/u,
"Component must be package/activity form",
)
.optional()
+7 -1
View File
@@ -397,7 +397,13 @@ it("retains safe structured constraints for ADB selectors", async () => {
expected: "regex",
},
{
argv: ["start-adb-activity", "S", "x", "--component", "a".repeat(513)],
argv: [
"start-adb-activity",
"S",
"x",
"--component",
`${"a".repeat(256)}/${"b".repeat(256)}`,
],
path: "component",
maximum: 512,
},
@@ -485,11 +485,14 @@ describe("ADB selector input schemas: package and activity", () => {
undefined,
"",
"bad component!",
"example.app",
"example.app/",
"com.example/.Main$Inner",
"a".repeat(512),
"a".repeat(513),
`${"a".repeat(255)}/${"b".repeat(256)}`,
`${"a".repeat(256)}/${"b".repeat(256)}`,
],
valid: [true, false, false, true, true, false],
valid: [true, false, false, false, false, true, false, true, false],
},
];
for (const selection of selections) {
@@ -512,7 +515,7 @@ describe("ADB selector input schemas: package and activity", () => {
const expected = selection.valid[index];
expect(
selection.canonical.safeParse(value).success,
`${command} owner`,
`${command} ${selection.key} owner ${String(index)}`,
).toBe(expected);
const base = selection.group === "args" ? { serial: "S" } : {};
const input =
@@ -564,6 +567,50 @@ describe("ADB selector input schemas: dispatch", () => {
code: "VALIDATION_ERROR",
});
}
const incompleteComponents = [
"example.app",
"example.app/",
"a".repeat(512),
];
for (const component of incompleteComponents) {
const result = await serve(cli, [
"start-adb-activity",
"S",
"x",
"--component",
component,
"--json",
]);
expect(result.exitCode, component).not.toBe(0);
expect(JSON.parse(result.stdout), component).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({
path: "component",
code: "invalid_format",
}),
],
});
}
const overlongComponent = `${"a".repeat(256)}/${"b".repeat(256)}`;
const overlong = await serve(cli, [
"start-adb-activity",
"S",
"x",
"--component",
overlongComponent,
"--json",
]);
expect(overlong.exitCode).not.toBe(0);
expect(JSON.parse(overlong.stdout)).toMatchObject({
code: "VALIDATION_ERROR",
fieldErrors: [
expect.objectContaining({
path: "component",
code: "too_big",
}),
],
});
expect(provider.inspectAvailability).not.toHaveBeenCalled();
expect(provider.execute).not.toHaveBeenCalled();
});