diff --git a/.changeset/android-launch-arg-early-validation.md b/.changeset/android-launch-arg-early-validation.md new file mode 100644 index 0000000000..acf103226b --- /dev/null +++ b/.changeset/android-launch-arg-early-validation.md @@ -0,0 +1,6 @@ +--- +'@expo/eas-build-job': minor +'@expo/build-tools': patch +--- + +Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. diff --git a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts index 15d01dd6c8..3661d164b2 100644 --- a/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/startAgentDeviceSession.test.ts @@ -687,6 +687,18 @@ describe(createStartAgentDeviceSessionBuildFunction, () => { expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); }); + it.each([ + ['NUL argument', { launch_args: ['a\u0000b'] }], + ['command length', { launch_args: ['--es', 'quotes', "'".repeat(20_000)] }], + ['NUL URL', { open_url: 'example:\u0000screen' }], + ])('rejects Android launch %s before startup work', async (_name, inputs) => { + const step = runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', ...inputs }); + await expect(step).rejects.toMatchObject({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }); + expect(startAndroidEmulatorAsync).not.toHaveBeenCalled(); + expect(downloadBuildAsync).not.toHaveBeenCalled(); + expect(runAgentDeviceRemoteSessionAsync).not.toHaveBeenCalled(); + }); + it('keeps Android launch inputs on its existing direct path', async () => { const launchArgs = ['a'.repeat(9000)]; await runStep(BuildRuntimePlatform.LINUX, { build_id: 'build-id', launch_args: launchArgs }); diff --git a/packages/build-tools/src/steps/functions/launchApplication.ts b/packages/build-tools/src/steps/functions/launchApplication.ts index 6146ea3a11..cebaecd0f0 100644 --- a/packages/build-tools/src/steps/functions/launchApplication.ts +++ b/packages/build-tools/src/steps/functions/launchApplication.ts @@ -1,4 +1,4 @@ -import { UserError } from '@expo/eas-build-job'; +import { UserError, quoteAndroidShellCommand } from '@expo/eas-build-job'; import { bunyan } from '@expo/logger'; import { BuildFunction, @@ -11,8 +11,6 @@ import spawn from '@expo/turtle-spawn'; const IOS_URL_SCHEME_APPROVAL_DOMAIN = 'com.apple.launchservices.schemeapproval'; const IOS_URL_SCHEME_APPROVAL_KEY_PREFIX = 'com.apple.CoreSimulator.CoreSimulatorBridge-->'; -// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). -const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; export function createLaunchApplicationFunction(): BuildFunction { return new BuildFunction({ @@ -135,33 +133,11 @@ export async function launchApplicationAsync({ } } -// `adb shell` joins its arguments with spaces and the device shell parses the result again, so -// each argument is quoted to reach the command as one literal word. async function spawnAndroidShellCommandAsync( command: string[], { env, logger }: { env: BuildStepEnv; logger: bunyan } ): Promise { - if (command.some(argument => argument.includes('\0'))) { - throw new UserError( - 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - 'Android "launch_args" and "open_url" cannot contain NUL characters.' - ); - } - const quotedCommand = command.map(quoteAndroidShellArgument); - if (Buffer.byteLength(quotedCommand.join(' ')) > ANDROID_SHELL_COMMAND_MAX_BYTES) { - throw new UserError( - 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', - `The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".` - ); - } - await spawn('adb', ['shell', ...quotedCommand], { env, logger }); -} - -function quoteAndroidShellArgument(argument: string): string { - if (/^[\w@%+=:,./-]+$/.test(argument)) { - return argument; - } - return `'${argument.replaceAll("'", `'\\''`)}'`; + await spawn('adb', ['shell', ...quoteAndroidShellCommand(command)], { env, logger }); } async function preapproveIosUrlSchemeAsync({ diff --git a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts index a2cc2535de..472efce41a 100644 --- a/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts +++ b/packages/build-tools/src/steps/functions/startAgentDeviceSession.ts @@ -1,4 +1,4 @@ -import { UserError } from '@expo/eas-build-job'; +import { UserError, validateAndroidLaunchOptions } from '@expo/eas-build-job'; import { BuildFunction, BuildRuntimePlatform, @@ -164,6 +164,8 @@ export function createStartAgentDeviceSessionBuildFunction(ctx: CustomBuildConte } if (isIos) { validateServeSimLaunchOptions({ launchArgs, openUrl }); + } else { + validateAndroidLaunchOptions({ launchArgs, openUrl }); } const deviceIdentifier = inputs.device_identifier.value as string | undefined; const capture = parseNetworkCaptureInputs( diff --git a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts index 4e6524d40f..eb033a41e3 100644 --- a/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts +++ b/packages/eas-build-job/src/__tests__/deviceRunSession.test.ts @@ -1,4 +1,9 @@ -import { DeviceRunSessionEvent, parseDeviceRunSessionEvents } from '../deviceRunSession'; +import { + DeviceRunSessionEvent, + parseDeviceRunSessionEvents, + quoteAndroidShellCommand, + validateAndroidLaunchOptions, +} from '../deviceRunSession'; describe('parseDeviceRunSessionEvents', () => { it('parses, deduplicates, and sorts events from the dedicated event log', () => { @@ -33,6 +38,56 @@ describe('parseDeviceRunSessionEvents', () => { }); }); +describe(quoteAndroidShellCommand, () => { + it('quotes only arguments the device shell would split or expand', () => { + expect( + quoteAndroidShellCommand(['am', 'start', '--es', 'text', 'hello world', "it's", '', '-n']) + ).toEqual(['am', 'start', '--es', 'text', "'hello world'", "'it'\\''s'", "''", '-n']); + }); + + it('rejects NUL characters', () => { + expect(() => quoteAndroidShellCommand(['am', 'start', 'a\0b'])).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); +}); + +describe(validateAndroidLaunchOptions, () => { + it('rejects only commands that are too long without the launch component', () => { + const fixedLength = 'am start --es key -n'.length; + const launchArgs = (valueLength: number) => ['--es', 'key', 'a'.repeat(valueLength)]; + + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength) }) + ).not.toThrow(); + expect(() => + validateAndroidLaunchOptions({ launchArgs: launchArgs(65_000 - fixedLength + 1) }) + ).toThrow(expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' })); + }); + + it('counts bytes, not characters', () => { + // "é" is two bytes in UTF-8 and is quoted, which adds two more. + const fixedLength = "am start --es key '' -n".length; + const launchArgs = (extraLength: number) => [ + '--es', + 'key', + 'a'.repeat(extraLength) + 'é'.repeat(32_488), + ]; + + expect(fixedLength + 1 + 2 * 32_488).toBe(65_000); + expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(1) })).not.toThrow(); + expect(() => validateAndroidLaunchOptions({ launchArgs: launchArgs(2) })).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); + + it('checks the URL to open', () => { + expect(() => validateAndroidLaunchOptions({ openUrl: 'exp://example.test/a\0b' })).toThrow( + expect.objectContaining({ errorCode: 'EAS_LAUNCH_APPLICATION_INVALID_INPUT' }) + ); + }); +}); + function createEvent(overrides: Partial = {}): string { return JSON.stringify(createEventValue(overrides)); } diff --git a/packages/eas-build-job/src/deviceRunSession.ts b/packages/eas-build-job/src/deviceRunSession.ts index fa5e9c224f..8f86014fff 100644 --- a/packages/eas-build-job/src/deviceRunSession.ts +++ b/packages/eas-build-job/src/deviceRunSession.ts @@ -1,5 +1,7 @@ import { z } from 'zod'; +import { UserError } from './errors'; + /** * Canonical record format for the activity events emitted during a remote device run (simulator) * session. Producers (e.g. the worker and agent-device) write these as NDJSON to the @@ -46,3 +48,54 @@ export function parseDeviceRunSessionEvents(eventLog: string): DeviceRunSessionE (left, right) => new Date(left.ts).getTime() - new Date(right.ts).getTime() ); } + +// adb closes the connection for longer shell commands (about 65.5 KB with adb 37 on API 35). +export const ANDROID_SHELL_COMMAND_MAX_BYTES = 65_000; + +/** + * Check Android launch inputs before a session starts. The launch component is only known once the + * build is installed, so this rejects only commands that are too long without it; the launch itself + * checks the exact command. + */ +export function validateAndroidLaunchOptions({ + launchArgs = [], + openUrl, +}: { + launchArgs?: string[]; + openUrl?: string; +}): void { + quoteAndroidShellCommand(['am', 'start', ...launchArgs, '-n']); + if (openUrl) { + quoteAndroidShellCommand(['am', 'start', '-a', 'android.intent.action.VIEW', '-d', openUrl]); + } +} + +/** + * `adb shell` joins its arguments with spaces and the device shell parses the result again, so + * each argument is quoted to reach the command as one literal word. + */ +export function quoteAndroidShellCommand(command: string[]): string[] { + if (command.some(argument => argument.includes('\0'))) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + 'Android "launch_args" and "open_url" cannot contain NUL characters.' + ); + } + const quotedCommand = command.map(quoteAndroidShellArgument); + if ( + new TextEncoder().encode(quotedCommand.join(' ')).byteLength > ANDROID_SHELL_COMMAND_MAX_BYTES + ) { + throw new UserError( + 'EAS_LAUNCH_APPLICATION_INVALID_INPUT', + `The Android launch command is longer than ${ANDROID_SHELL_COMMAND_MAX_BYTES} bytes after shell quoting. Shorten "launch_args" or "open_url".` + ); + } + return quotedCommand; +} + +function quoteAndroidShellArgument(argument: string): string { + if (/^[\w@%+=:,./-]+$/.test(argument)) { + return argument; + } + return `'${argument.replaceAll("'", `'\\''`)}'`; +}