fix(bin): properly escape arguments (#788)

This commit is contained in:
Simon Chan 2025-08-25 14:13:03 +08:00 committed by GitHub
parent f3f5be0b1a
commit 2f57a86a80
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 106 additions and 79 deletions

View file

@ -13,7 +13,8 @@ export function escapeArg(s: string) {
break; break;
} }
result += s.substring(base, found); result += s.substring(base, found);
// a'b becomes a'\'b (the backslash is not a escape character) // a'b becomes 'a'\'b', which is 'a' + \' + 'b'
// (quoted string 'a', escaped single quote, and quoted string 'b')
result += String.raw`'\''`; result += String.raw`'\''`;
base = found + 1; base = found + 1;
} }
@ -22,23 +23,32 @@ export function escapeArg(s: string) {
return result; return result;
} }
export function splitCommand(command: string): string[] { /**
* Split the command.
*
* Quotes and escaped characters are supported, and will be returned as-is.
* @param input The input command
* @returns An array of string containing the arguments
*/
export function splitCommand(input: string): string[] {
const result: string[] = []; const result: string[] = [];
let quote: string | undefined; let quote: string | undefined;
let isEscaped = false; let isEscaped = false;
let start = 0; let start = 0;
for (let i = 0, len = command.length; i < len; i += 1) { for (let i = 0, len = input.length; i < len; i += 1) {
if (isEscaped) { if (isEscaped) {
isEscaped = false; isEscaped = false;
continue; continue;
} }
const char = command.charAt(i); const char = input.charAt(i);
switch (char) { switch (char) {
case " ": case " ":
if (!quote && i !== start) { if (!quote) {
result.push(command.substring(start, i)); if (i !== start) {
result.push(input.substring(start, i));
}
start = i + 1; start = i + 1;
} }
break; break;
@ -56,8 +66,8 @@ export function splitCommand(command: string): string[] {
} }
} }
if (start < command.length) { if (start < input.length) {
result.push(command.substring(start)); result.push(input.substring(start));
} }
return result; return result;

View file

@ -5,7 +5,7 @@ import { SplitStringStream, TextDecoderStream } from "@yume-chan/stream-extra";
import { Cmd } from "./cmd/index.js"; import { Cmd } from "./cmd/index.js";
import type { IntentBuilder } from "./intent.js"; import type { IntentBuilder } from "./intent.js";
import type { SingleUser } from "./utils.js"; import type { SingleUser } from "./utils.js";
import { buildArguments } from "./utils.js"; import { buildCommand } from "./utils.js";
export interface ActivityManagerStartActivityOptions { export interface ActivityManagerStartActivityOptions {
displayId?: number; displayId?: number;
@ -43,21 +43,25 @@ export class ActivityManager extends AdbServiceBase {
): Promise<void> { ): Promise<void> {
// `am start` and `am start-activity` are the same, // `am start` and `am start-activity` are the same,
// but `am start-activity` was added in Android 8. // but `am start-activity` was added in Android 8.
let args = buildArguments( const command = buildCommand(
[ActivityManager.ServiceName, "start", "-W"], [ActivityManager.ServiceName, "start", "-W"],
options, options,
START_ACTIVITY_OPTIONS_MAP, START_ACTIVITY_OPTIONS_MAP,
); );
args = args.concat(options.intent.build().map(escapeArg)); for (const arg of options.intent.build()) {
command.push(arg);
}
// `cmd activity` doesn't support `start` command on Android 7. // `cmd activity` doesn't support `start` command on Android 7.
let process: AdbNoneProtocolProcess; let process: AdbNoneProtocolProcess;
if (this.#apiLevel <= 25) { if (this.#apiLevel <= 25) {
args[0] = ActivityManager.CommandName; command[0] = ActivityManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(args); process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else { } else {
process = await this.#cmd.spawn(args); process = await this.#cmd.spawn(command);
} }
const lines = process.output const lines = process.output

View file

@ -3,6 +3,7 @@ import {
AdbFeature, AdbFeature,
AdbNoneProtocolProcessImpl, AdbNoneProtocolProcessImpl,
adbNoneProtocolSpawner, adbNoneProtocolSpawner,
escapeArg,
} from "@yume-chan/adb"; } from "@yume-chan/adb";
import { Cmd } from "./service.js"; import { Cmd } from "./service.js";
@ -37,7 +38,7 @@ export function createNoneProtocol(
spawn: adbNoneProtocolSpawner(async (command, signal) => { spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command); checkCommand(command);
const newCommand = command.slice(); const newCommand = command.map(escapeArg);
newCommand.unshift("cmd"); newCommand.unshift("cmd");
return adb.subprocess.noneProtocol.spawn(newCommand, signal); return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}), }),
@ -50,7 +51,7 @@ export function createNoneProtocol(
spawn: adbNoneProtocolSpawner(async (command, signal) => { spawn: adbNoneProtocolSpawner(async (command, signal) => {
checkCommand(command); checkCommand(command);
const newCommand = command.slice(); const newCommand = command.map(escapeArg);
newCommand[0] = resolveFallback(fallback, command[0]!); newCommand[0] = resolveFallback(fallback, command[0]!);
return adb.subprocess.noneProtocol.spawn(newCommand, signal); return adb.subprocess.noneProtocol.spawn(newCommand, signal);
}), }),

View file

@ -3,6 +3,7 @@ import {
AdbFeature, AdbFeature,
AdbShellProtocolProcessImpl, AdbShellProtocolProcessImpl,
adbShellProtocolSpawner, adbShellProtocolSpawner,
escapeArg,
} from "@yume-chan/adb"; } from "@yume-chan/adb";
import { Cmd } from "./service.js"; import { Cmd } from "./service.js";
@ -42,7 +43,7 @@ export function createShellProtocol(
spawn: adbShellProtocolSpawner(async (command, signal) => { spawn: adbShellProtocolSpawner(async (command, signal) => {
checkCommand(command); checkCommand(command);
const newCommand = command.slice(); const newCommand = command.map(escapeArg);
newCommand.unshift("cmd"); newCommand.unshift("cmd");
return shellProtocolService.spawn(newCommand, signal); return shellProtocolService.spawn(newCommand, signal);
}), }),
@ -55,7 +56,7 @@ export function createShellProtocol(
spawn: adbShellProtocolSpawner(async (command, signal) => { spawn: adbShellProtocolSpawner(async (command, signal) => {
checkCommand(command); checkCommand(command);
const newCommand = command.slice(); const newCommand = command.map(escapeArg);
newCommand[0] = resolveFallback(fallback, command[0]!); newCommand[0] = resolveFallback(fallback, command[0]!);
return shellProtocolService.spawn(newCommand, signal); return shellProtocolService.spawn(newCommand, signal);
}), }),

View file

@ -1,5 +1,3 @@
import { splitCommand } from "@yume-chan/adb";
import type { Cmd } from "./service.js"; import type { Cmd } from "./service.js";
export function resolveFallback( export function resolveFallback(
@ -31,13 +29,7 @@ export function serializeAbbService(
): string { ): string {
checkCommand(command); checkCommand(command);
// `abb` mode doesn't use `sh -c` to execute to command,
// so it doesn't accept escaped arguments.
// `splitCommand` can be used to remove the escaping,
// each item in `command` must be a single argument.
const newCommand = command.map((arg) => splitCommand(arg)[0]!);
// `abb` mode uses `\0` as the separator, allowing space in arguments. // `abb` mode uses `\0` as the separator, allowing space in arguments.
// The last `\0` is required for older versions of `adb`. // The last `\0` is required for older versions of `adb`.
return `${prefix}:${newCommand.join("\0")}\0`; return `${prefix}:${command.join("\0")}\0`;
} }

View file

@ -16,7 +16,7 @@ import {
import { Cmd } from "./cmd/index.js"; import { Cmd } from "./cmd/index.js";
import type { IntentBuilder } from "./intent.js"; import type { IntentBuilder } from "./intent.js";
import type { Optional, SingleUserOrAll } from "./utils.js"; import type { Optional, SingleUserOrAll } from "./utils.js";
import { buildArguments } from "./utils.js"; import { buildCommand } from "./utils.js";
export enum PackageManagerInstallLocation { export enum PackageManagerInstallLocation {
Auto, Auto,
@ -65,7 +65,7 @@ export const PackageManagerInstallOptions = {
restrictPermissions: option<boolean>("--restrict-permissions", 29), restrictPermissions: option<boolean>("--restrict-permissions", 29),
doNotKill: option<boolean>("--dont-kill"), doNotKill: option<boolean>("--dont-kill"),
originatingUri: option<string>("--originating-uri"), originatingUri: option<string>("--originating-uri"),
refererUri: option<string>("--referrer"), referrerUri: option<string>("--referrer"),
inheritFrom: option<string>("-p", 24), inheritFrom: option<string>("-p", 24),
packageName: option<string>("--pkg", 28), packageName: option<string>("--pkg", 28),
abi: option<string>("--abi", 21), abi: option<string>("--abi", 21),
@ -187,7 +187,7 @@ const PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP: Partial<
user: "--user", user: "--user",
}; };
function buildInstallArguments( function buildInstallCommand(
command: string, command: string,
options: PackageManagerInstallOptions | undefined, options: PackageManagerInstallOptions | undefined,
apiLevel: number | undefined, apiLevel: number | undefined,
@ -252,7 +252,7 @@ function buildInstallArguments(
args.push(option.name, value.toString()); args.push(option.name, value.toString());
break; break;
case "string": case "string":
args.push(option.name, escapeArg(value)); args.push(option.name, value);
break; break;
default: default:
throw new Error( throw new Error(
@ -287,22 +287,26 @@ export class PackageManager extends AdbServiceBase {
apks: readonly string[], apks: readonly string[],
options?: PackageManagerInstallOptions, options?: PackageManagerInstallOptions,
): Promise<void> { ): Promise<void> {
const args = buildInstallArguments("install", options, this.#apiLevel); const command = buildInstallCommand("install", options, this.#apiLevel);
args[0] = PackageManager.CommandName;
command[0] = PackageManager.CommandName;
// WIP: old version of pm doesn't support multiple apks // WIP: old version of pm doesn't support multiple apks
args.push(...apks.map(escapeArg)); for (const apk of apks) {
command.push(apk);
}
// Starting from Android 7, `pm` becomes a wrapper to `cmd package`. // Starting from Android 7, `pm` becomes a wrapper to `cmd package`.
// The benefit of `cmd package` is it starts faster than the old `pm`, // The benefit of `cmd package` is it starts faster than the old `pm`,
// because it connects to the already running `system` process, // because it connects to the already running `system` process,
// instead of initializing all system components from scratch. // instead of initializing all system components from scratch.
// //
// But launching `cmd package` directly causes it to not be able to // But `cmd` executable can't read files in `/data/local/tmp`
// read files in `/data/local/tmp` (and many other places) due to SELinux policies, // (and many other places) due to SELinux policies,
// so installing files must still use `pm`. // so installing from files must still use `pm`.
// (the starting executable file decides which SELinux policies to apply) // (the starting executable file decides which SELinux policies to apply)
const output = await this.adb.subprocess.noneProtocol const output = await this.adb.subprocess.noneProtocol
.spawn(args) .spawn(command.map(escapeArg))
.wait() .wait()
.toString() .toString()
.then((output) => output.trim()); .then((output) => output.trim());
@ -352,9 +356,9 @@ export class PackageManager extends AdbServiceBase {
return; return;
} }
const args = buildInstallArguments("install", options, this.#apiLevel); const command = buildInstallCommand("install", options, this.#apiLevel);
args.push("-S", size.toString()); command.push("-S", size.toString());
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
const output = process.output const output = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
@ -429,17 +433,17 @@ export class PackageManager extends AdbServiceBase {
async *listPackages( async *listPackages(
options?: Optional<PackageManagerListPackagesOptions>, options?: Optional<PackageManagerListPackagesOptions>,
): AsyncGenerator<PackageManagerListPackagesResult, void, void> { ): AsyncGenerator<PackageManagerListPackagesResult, void, void> {
const args = buildArguments( const command = buildCommand(
["package", "list", "packages"], ["package", "list", "packages"],
options, options,
PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP, PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP,
); );
// `PACKAGE_MANAGER_LIST_PACKAGES_OPTIONS_MAP` doesn't have `filter`
if (options?.filter) { if (options?.filter) {
args.push(escapeArg(options.filter)); command.push(options.filter);
} }
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
const output = process.output const output = process.output
.pipeThrough(new TextDecoderStream()) .pipeThrough(new TextDecoderStream())
@ -473,19 +477,23 @@ export class PackageManager extends AdbServiceBase {
): Promise<string[]> { ): Promise<string[]> {
// `pm path` and `pm -p` are the same, // `pm path` and `pm -p` are the same,
// but `pm path` allows an optional `--user` option. // but `pm path` allows an optional `--user` option.
const args = [PackageManager.ServiceName, "path"]; const command = [PackageManager.ServiceName, "path"];
if (options?.user !== undefined) { if (options?.user !== undefined) {
args.push("--user", options.user.toString()); command.push("--user", options.user.toString());
} }
args.push(escapeArg(packageName));
command.push(packageName);
// `cmd package` doesn't support `path` command on Android 7 and 8. // `cmd package` doesn't support `path` command on Android 7 and 8.
let process: AdbNoneProtocolProcess; let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 27) { if (this.#apiLevel !== undefined && this.#apiLevel <= 27) {
args[0] = PackageManager.CommandName; command[0] = PackageManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(args); process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else { } else {
process = await this.#cmd.spawn(args); process = await this.#cmd.spawn(command);
} }
const lines = process.output const lines = process.output
@ -510,18 +518,22 @@ export class PackageManager extends AdbServiceBase {
packageName: string, packageName: string,
options?: Optional<PackageManagerUninstallOptions>, options?: Optional<PackageManagerUninstallOptions>,
): Promise<void> { ): Promise<void> {
let args = buildArguments( const command = buildCommand(
[PackageManager.ServiceName, "uninstall"], [PackageManager.ServiceName, "uninstall"],
options, options,
PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP, PACKAGE_MANAGER_UNINSTALL_OPTIONS_MAP,
); );
args.push(escapeArg(packageName));
command.push(packageName);
if (options?.splitNames) { if (options?.splitNames) {
args = args.concat(options.splitNames.map(escapeArg)); for (const splitName of options.splitNames) {
command.push(splitName);
}
} }
const output = await this.#cmd const output = await this.#cmd
.spawn(args) .spawn(command)
.wait() .wait()
.toString() .toString()
.then((output) => output.trim()); .then((output) => output.trim());
@ -533,16 +545,18 @@ export class PackageManager extends AdbServiceBase {
async resolveActivity( async resolveActivity(
options: PackageManagerResolveActivityOptions, options: PackageManagerResolveActivityOptions,
): Promise<string | undefined> { ): Promise<string | undefined> {
let args = buildArguments( const command = buildCommand(
[PackageManager.ServiceName, "resolve-activity", "--components"], [PackageManager.ServiceName, "resolve-activity", "--components"],
options, options,
PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP, PACKAGE_MANAGER_RESOLVE_ACTIVITY_OPTIONS_MAP,
); );
args = args.concat(options.intent.build().map(escapeArg)); for (const arg of options.intent.build()) {
command.push(arg);
}
const output = await this.#cmd const output = await this.#cmd
.spawn(args) .spawn(command)
.wait() .wait()
.toString() .toString()
.then((output) => output.trim()); .then((output) => output.trim());
@ -567,14 +581,14 @@ export class PackageManager extends AdbServiceBase {
async sessionCreate( async sessionCreate(
options?: PackageManagerInstallOptions, options?: PackageManagerInstallOptions,
): Promise<number> { ): Promise<number> {
const args = buildInstallArguments( const command = buildInstallCommand(
"install-create", "install-create",
options, options,
this.#apiLevel, this.#apiLevel,
); );
const output = await this.#cmd const output = await this.#cmd
.spawn(args) .spawn(command)
.wait() .wait()
.toString() .toString()
.then((output) => output.trim()); .then((output) => output.trim());
@ -605,16 +619,18 @@ export class PackageManager extends AdbServiceBase {
splitName: string, splitName: string,
path: string, path: string,
): Promise<void> { ): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.CommandName, PackageManager.CommandName,
"install-write", "install-write",
sessionId.toString(), sessionId.toString(),
escapeArg(splitName), splitName,
escapeArg(path), path,
]; ];
// Similar to `install`, must use `adb.subprocess` so it can read `path` // Similar to `install`, must use `adb.subprocess` so it can read `path`
const process = await this.adb.subprocess.noneProtocol.spawn(args); const process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
await this.checkResult(process.output); await this.checkResult(process.output);
} }
@ -624,17 +640,17 @@ export class PackageManager extends AdbServiceBase {
size: number, size: number,
stream: ReadableStream<MaybeConsumable<Uint8Array>>, stream: ReadableStream<MaybeConsumable<Uint8Array>>,
): Promise<void> { ): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-write", "install-write",
"-S", "-S",
size.toString(), size.toString(),
sessionId.toString(), sessionId.toString(),
escapeArg(splitName), splitName,
"-", "-",
]; ];
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
await Promise.all([ await Promise.all([
stream.pipeTo(process.stdin), stream.pipeTo(process.stdin),
this.checkResult(process.output), this.checkResult(process.output),
@ -647,7 +663,7 @@ export class PackageManager extends AdbServiceBase {
* @returns A `Promise` that resolves when the session is committed * @returns A `Promise` that resolves when the session is committed
*/ */
async sessionCommit(sessionId: number): Promise<void> { async sessionCommit(sessionId: number): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-commit", "install-commit",
sessionId.toString(), sessionId.toString(),
@ -658,22 +674,24 @@ export class PackageManager extends AdbServiceBase {
// causing this function to fail with an empty message. // causing this function to fail with an empty message.
let process: AdbNoneProtocolProcess; let process: AdbNoneProtocolProcess;
if (this.#apiLevel !== undefined && this.#apiLevel <= 25) { if (this.#apiLevel !== undefined && this.#apiLevel <= 25) {
args[0] = PackageManager.CommandName; command[0] = PackageManager.CommandName;
process = await this.adb.subprocess.noneProtocol.spawn(args); process = await this.adb.subprocess.noneProtocol.spawn(
command.map(escapeArg),
);
} else { } else {
process = await this.#cmd.spawn(args); process = await this.#cmd.spawn(command);
} }
await this.checkResult(process.output); await this.checkResult(process.output);
} }
async sessionAbandon(sessionId: number): Promise<void> { async sessionAbandon(sessionId: number): Promise<void> {
const args: string[] = [ const command: string[] = [
PackageManager.ServiceName, PackageManager.ServiceName,
"install-abandon", "install-abandon",
sessionId.toString(), sessionId.toString(),
]; ];
const process = await this.#cmd.spawn(args); const process = await this.#cmd.spawn(command);
await this.checkResult(process.output); await this.checkResult(process.output);
} }
} }

View file

@ -42,14 +42,17 @@ export class Settings extends AdbServiceBase {
options: SettingsOptions | undefined, options: SettingsOptions | undefined,
...args: string[] ...args: string[]
): Promise<string> { ): Promise<string> {
let command = [Settings.ServiceName]; const command = [Settings.ServiceName];
if (options?.user !== undefined) { if (options?.user !== undefined) {
command.push("--user", options.user.toString()); command.push("--user", options.user.toString());
} }
command.push(verb, namespace); command.push(verb, namespace);
command = command.concat(args);
for (const arg of args) {
command.push(arg);
}
return this.#cmd.spawn(command).wait().toString(); return this.#cmd.spawn(command).wait().toString();
} }

View file

@ -1,6 +1,4 @@
import { escapeArg } from "@yume-chan/adb"; export function buildCommand<T>(
export function buildArguments<T>(
commands: readonly string[], commands: readonly string[],
options: Partial<T> | undefined, options: Partial<T> | undefined,
map: Partial<Record<keyof T, string>>, map: Partial<Record<keyof T, string>>,
@ -30,7 +28,7 @@ export function buildArguments<T>(
args.push(option, value.toString()); args.push(option, value.toString());
break; break;
case "string": case "string":
args.push(option, escapeArg(value)); args.push(option, value);
break; break;
default: default:
throw new Error( throw new Error(