diff --git a/packages/cli-platform-android/src/commands/buildAndroid/index.ts b/packages/cli-platform-android/src/commands/buildAndroid/index.ts index 1441978ef..4205ca761 100644 --- a/packages/cli-platform-android/src/commands/buildAndroid/index.ts +++ b/packages/cli-platform-android/src/commands/buildAndroid/index.ts @@ -2,6 +2,7 @@ import { CLIError, logger, printRunDoctorTip, + tokenize, } from '@react-native-community/cli-tools'; import {Config} from '@react-native-community/cli-types'; import execa from 'execa'; @@ -115,7 +116,7 @@ export const options = [ { name: '--extra-params ', description: 'Custom params passed to gradle build command', - parse: (val: string) => val.split(' '), + parse: tokenize, }, { name: '-i --interactive', diff --git a/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts b/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts index 303431e4e..89cf63ad2 100644 --- a/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts +++ b/packages/cli-platform-apple/src/commands/buildCommand/buildOptions.ts @@ -1,3 +1,4 @@ +import {tokenize} from '@react-native-community/cli-tools'; import {BuilderCommand} from '../../types'; import {getPlatformInfo} from '../runCommand/getPlatformInfo'; @@ -49,7 +50,7 @@ export const getBuildOptions = ({platformName}: BuilderCommand) => { { name: '--extra-params ', description: 'Custom params that will be passed to xcodebuild command.', - parse: (val: string) => val.split(' '), + parse: tokenize, }, { name: '--target ', diff --git a/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts b/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts index 5f6f2b547..89e0611e8 100644 --- a/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts +++ b/packages/cli-platform-apple/src/commands/buildCommand/buildProject.ts @@ -86,10 +86,13 @@ export function buildProject( } const loader = getLoader(); + // Wrap arguments containing whitespace in quotes so the logged command stays + // copy-pasteable (e.g. a `-destination` value like `iOS Simulator`). + const printableArgs = xcodebuildArgs + .map((arg) => (/\s/.test(arg) ? `"${arg}"` : arg)) + .join(' '); logger.info( - `Building ${pico.dim( - `(using "xcodebuild ${xcodebuildArgs.join(' ')}")`, - )}`, + `Building ${pico.dim(`(using "xcodebuild ${printableArgs}")`)}`, ); let xcodebuildOutputFormatter: ChildProcess | any; if (!args.verbose) { diff --git a/packages/cli-tools/src/__tests__/tokenize.test.ts b/packages/cli-tools/src/__tests__/tokenize.test.ts new file mode 100644 index 000000000..5d1c79b6d --- /dev/null +++ b/packages/cli-tools/src/__tests__/tokenize.test.ts @@ -0,0 +1,166 @@ +import tokenize from '../tokenize'; + +const originalPlatform = process.platform; + +function mockPlatform(platform: NodeJS.Platform) { + Object.defineProperty(process, 'platform', { + value: platform, + configurable: true, + }); +} + +describe('tokenize', () => { + it('splits space-separated xcodebuild arguments', () => { + expect(tokenize('-scheme MyApp -configuration Release')).toEqual([ + '-scheme', + 'MyApp', + '-configuration', + 'Release', + ]); + }); + + it('splits space-separated gradle properties', () => { + expect( + tokenize('-PnewArchEnabled=true -PreactNativeArchitectures=arm64-v8a'), + ).toEqual([ + '-PnewArchEnabled=true', + '-PreactNativeArchitectures=arm64-v8a', + ]); + }); + + it('keeps a double-quoted xcodebuild destination with spaces in one token', () => { + expect(tokenize('-destination "generic/platform=iOS Simulator"')).toEqual([ + '-destination', + 'generic/platform=iOS Simulator', + ]); + }); + + it('keeps a single-quoted destination specifier with spaces in one token', () => { + expect( + tokenize("-destination 'platform=iOS Simulator,name=iPhone 15'"), + ).toEqual(['-destination', 'platform=iOS Simulator,name=iPhone 15']); + }); + + it('strips the surrounding quotes from a quoted path containing a space', () => { + expect(tokenize('-project "My App.xcodeproj"')).toEqual([ + '-project', + 'My App.xcodeproj', + ]); + }); + + it('joins an unquoted build-setting name with its quoted, space-containing value', () => { + expect(tokenize('EXCLUDED_ARCHS="arm64 i386"')).toEqual([ + 'EXCLUDED_ARCHS=arm64 i386', + ]); + }); + + it('collapses extra whitespace (spaces and tabs) between arguments', () => { + expect(tokenize('-scheme MyApp \t -quiet')).toEqual([ + '-scheme', + 'MyApp', + '-quiet', + ]); + }); + + it('passes xcodebuild build-setting syntax such as $(inherited) through verbatim', () => { + expect( + tokenize( + 'OTHER_LDFLAGS=$(inherited) GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1', + ), + ).toEqual([ + 'OTHER_LDFLAGS=$(inherited)', + 'GCC_PREPROCESSOR_DEFINITIONS=DEBUG=1', + ]); + }); + + it('preserves backslashes inside single quotes so Windows gradle paths survive', () => { + expect( + tokenize("-Pandroid.injected.signing.store.file='C:\\keys\\app.jks'"), + ).toEqual(['-Pandroid.injected.signing.store.file=C:\\keys\\app.jks']); + }); + + it('throws on a lone unquoted apostrophe', () => { + expect(() => tokenize("-Pmessage=don't")).toThrowError( + "Unterminated ' quote in: -Pmessage=don't", + ); + }); + + it('parses a quoted Gradle property value containing an apostrophe', () => { + expect(tokenize(`-Pmessage="It's ready"`)).toEqual([ + "-Pmessage=It's ready", + ]); + }); + + it('returns an empty array for empty input', () => { + expect(tokenize('')).toEqual([]); + }); + + it('returns an empty array for whitespace-only input', () => { + expect(tokenize(' \t ')).toEqual([]); + }); + + it('keeps an explicit empty quoted argument', () => { + expect(tokenize('-quiet ""')).toEqual(['-quiet', '']); + }); + + it('throws on an unterminated double quote', () => { + expect(() => tokenize('-destination "platform=iOS')).toThrowError( + 'Unterminated " quote in: -destination "platform=iOS', + ); + }); + + it('throws on an unterminated single quote', () => { + expect(() => tokenize("-destination 'platform=iOS")).toThrowError( + "Unterminated ' quote in: -destination 'platform=iOS", + ); + }); + + describe('with POSIX backslash escaping (non-Windows)', () => { + beforeEach(() => mockPlatform('linux')); + afterEach(() => mockPlatform(originalPlatform)); + + it('treats an unquoted backslash as an escape that preserves the next character', () => { + expect(tokenize('-project My\\ App.xcodeproj')).toEqual([ + '-project', + 'My App.xcodeproj', + ]); + }); + + it('escapes an apostrophe with a backslash outside quotes', () => { + expect(tokenize("-Pmessage=don\\'t")).toEqual(["-Pmessage=don't"]); + }); + + it('unescapes quotes inside double-quoted JSON values', () => { + expect(tokenize(String.raw`-Pconfig="{\"name\":\"My App\"}"`)).toEqual([ + '-Pconfig={"name":"My App"}', + ]); + }); + + it('keeps backslashes literal inside double-quoted values', () => { + expect(tokenize(String.raw`-Pdir="C:\Program Files\App"`)).toEqual([ + String.raw`-Pdir=C:\Program Files\App`, + ]); + }); + }); + + describe('on Windows', () => { + beforeEach(() => mockPlatform('win32')); + afterEach(() => mockPlatform(originalPlatform)); + + it('keeps unquoted backslashes literal so native paths survive', () => { + expect( + tokenize( + String.raw`-Pandroid.injected.signing.store.file=C:\keys\app.jks`, + ), + ).toEqual([ + String.raw`-Pandroid.injected.signing.store.file=C:\keys\app.jks`, + ]); + }); + + it('keeps backslashes literal inside double-quoted values', () => { + expect(tokenize(String.raw`-Pdir="C:\Program Files\App"`)).toEqual([ + String.raw`-Pdir=C:\Program Files\App`, + ]); + }); + }); +}); diff --git a/packages/cli-tools/src/index.ts b/packages/cli-tools/src/index.ts index 19132c2aa..b89415bae 100644 --- a/packages/cli-tools/src/index.ts +++ b/packages/cli-tools/src/index.ts @@ -16,5 +16,6 @@ export * from './port'; export {default as cacheManager} from './cacheManager'; export {default as runSudo} from './runSudo'; export {default as unixifyPaths} from './unixifyPaths'; +export {default as tokenize} from './tokenize'; export * from './errors'; diff --git a/packages/cli-tools/src/tokenize.ts b/packages/cli-tools/src/tokenize.ts new file mode 100644 index 000000000..c7ae99a7f --- /dev/null +++ b/packages/cli-tools/src/tokenize.ts @@ -0,0 +1,109 @@ +import {CLIError} from './errors'; + +// Inside double quotes a backslash keeps its escaping power only before one of +// these characters. Before anything else the backslash is a literal character. +const DOUBLE_QUOTE_ESCAPABLE = new Set(['"', '\\', '$', '`', '\n']); + +/** + * Splits a command-line string into arguments the way a POSIX shell would when + * building the `argv` it hands to a program. Only quoting, backslash escaping + * and whitespace splitting are applied — there is no expansion phase, so + * variables (`$FOO`), command substitution (`$(...)`), globs (`*`) and operators + * (`;`, `|`) are passed through verbatim for xcodebuild/gradle to interpret. + * + * - Unquoted whitespace separates arguments; adjacent quoted and unquoted + * segments join into one argument (`a"b"c` -> `abc`). + * - Single quotes are fully literal (backslashes included). + * - Double quotes are literal except a backslash escaping `"`, `\`, `$`, `` ` `` + * or a newline; any other backslash is kept. + * - Outside quotes a backslash escapes the next character (`My\ App`); before a + * newline it is a line continuation. + * - `""`/`''` produce an empty argument; empty input produces `[]`. + * - An unterminated quote throws a {@link CLIError}. + * + * On Windows the backslash is a path separator, so escaping is disabled there + * and paths such as `C:\keys\app.jks` survive unquoted; quoting and splitting + * still apply. + */ +export default function tokenize(input: string): string[] { + const backslashIsEscape = process.platform !== 'win32'; + + const args: string[] = []; + let arg = ''; + // Distinguishes "no argument yet" from "an argument that is empty" (e.g. the + // `""` in `--flag ""`), which must be preserved. + let hasArg = false; + let quote: '"' | "'" | undefined; + + const append = (char: string) => { + arg += char; + hasArg = true; + }; + + for (let index = 0; index < input.length; index++) { + const char = input[index]; + const next = input[index + 1]; + + // Single quotes: literal until the closing quote. + if (quote === "'") { + if (char === "'") { + quote = undefined; + } else { + append(char); + } + + continue; + } + + // Double quotes: literal, plus a limited set of backslash escapes. + if (quote === '"') { + if (char === '"') { + quote = undefined; + } else if ( + char === '\\' && + backslashIsEscape && + DOUBLE_QUOTE_ESCAPABLE.has(next) + ) { + if (next !== '\n') { + append(next); + } // "\" is a line continuation + index++; + } else { + append(char); + } + + continue; + } + + // Outside any quotes. + if (char === '"' || char === "'") { + quote = char; + hasArg = true; // an opening quote starts an argument, even an empty one + } else if (char === '\\' && backslashIsEscape && next !== undefined) { + if (next !== '\n') { + append(next); + } // "\" is a line continuation + + index++; + } else if (/\s/.test(char)) { + if (hasArg) { + args.push(arg); + } + + arg = ''; + hasArg = false; + } else { + append(char); + } + } + + if (quote) { + throw new CLIError(`Unterminated ${quote} quote in: ${input}`); + } + + if (hasArg) { + args.push(arg); + } + + return args; +}