diff --git a/src/application/adb/AdbDeviceAnalysisService.test.ts b/src/application/adb/AdbDeviceAnalysisService.test.ts index 9e0a08269a..0e8e2079ae 100644 --- a/src/application/adb/AdbDeviceAnalysisService.test.ts +++ b/src/application/adb/AdbDeviceAnalysisService.test.ts @@ -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, {}); diff --git a/src/domain/adb/adbDeviceAnalysis.test.ts b/src/domain/adb/adbDeviceAnalysis.test.ts index 8a90451d43..4d88ffa008 100644 --- a/src/domain/adb/adbDeviceAnalysis.test.ts +++ b/src/domain/adb/adbDeviceAnalysis.test.ts @@ -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" }, diff --git a/src/domain/adb/adbDeviceAnalysis.ts b/src/domain/adb/adbDeviceAnalysis.ts index cff26cf708..03d4681906 100644 --- a/src/domain/adb/adbDeviceAnalysis.ts +++ b/src/domain/adb/adbDeviceAnalysis.ts @@ -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() diff --git a/tests/boundary/cli/adbCli.test.ts b/tests/boundary/cli/adbCli.test.ts index 401dd77f73..04387cd6f5 100644 --- a/tests/boundary/cli/adbCli.test.ts +++ b/tests/boundary/cli/adbCli.test.ts @@ -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, }, diff --git a/tests/boundary/cli/cliInputSchemaRequired.test.ts b/tests/boundary/cli/cliInputSchemaRequired.test.ts index 439f133a88..74f37fd62f 100644 --- a/tests/boundary/cli/cliInputSchemaRequired.test.ts +++ b/tests/boundary/cli/cliInputSchemaRequired.test.ts @@ -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(); });