Guyutongxue avatar

Guyutongxue

干他娘的命令行参数

命令行参数这个东西看上去很简单,但它实际上恶心得很。我随便举一个例子:请向一个程序原样传递如下命令行参数:

首先明确一点,就是命令行参数的传递方法主要是两种:

  1. 通过系统调用(操作系统 API),例如 CreateProcessW exec 等;
  2. 通过壳层程序(Shell),例如 cmd.exe bash 等。

然后,世界上主流的操作系统分为两类,*nix 和 Windows。前者我就用 POSIX 标准代替。那么整个问题就划分为这样四个象限:

POSIXWindows
系统调用execCreateProcessW
壳层POSIX Shellcmd.exe

具体而言:

本文的后续算法描述使用 JavaScript/TypeScript。

POSIX Shell

参考:IEEE Std 1003.1-2017 Shell & Utilities 2.2

从命令行到参数

首先,POSIX Shell 的命令行是由空格分隔的若干参数。若参数带有空格,则需要用引号括起。

这些字符在 POSIX Shell 中具有特殊含义:| & ; < > ( ) $ ` \ " '   Tab 和换行符。所以若参数包含它们,则必须要括起。

其中,主要有两种括起方式:单引号和双引号。单引号是由天然缺陷的:单引号内部的参数不能再出现单引号,所以这里不提及它;双引号括起的命令行参数是完备的。

双引号内的所有字符都会原样作为参数,以下字符除外:

从参数到命令行

根据上述规则,推算出反向算法为:

function argvToShell(argv: string[]) {
    let cmd = "";
    for (const arg of argv) {
        cmd += "\"";
        for (const c of arg) {
            if ("$`\"\\"].includes(c)) {
                cmd += "\\";
                cmd += c;
            } else {
                cmd += c;
            }
        }
        cmd += "\" ";
    }
    return cmd;
}

这个规则非常简洁;也可以直接用正则表达式:

function argvToShell(argv: string[]) {
  return argv
    .map((arg) => `"${arg.replace(/(\$|`|"|\\)/g, "\\$1")}"`)
    .join(" ");
}

CreateProcessW

POSIX 简洁的设计让人感到欣慰,但 Windows 这边就痛苦起来了。最核心的问题就在于:Windows 的 CreateProcessW(或者 ANSI 版本的 CreateProcessA)是传递整条命令行的,而不是命令行参数!

BOOL CreateProcessW(
  LPCWSTR               lpApplicationName,
  LPWSTR                lpCommandLine,       /* 这里,指向一整行命令行 */
  LPSECURITY_ATTRIBUTES lpProcessAttributes,
  LPSECURITY_ATTRIBUTES lpThreadAttributes,
  BOOL                  bInheritHandles,
  DWORD                 dwCreationFlags,
  LPVOID                lpEnvironment,
  LPCWSTR               lpCurrentDirectory,
  LPSTARTUPINFOW        lpStartupInfo,
  LPPROCESS_INFORMATION lpProcessInformation
);

不同于 exec,Windows 只能通过一整行命令行启动进程。而如何将命令行解释为若干参数,是被启动的进程自主解释的!不过,微软定义了一种及其别扭的解释方式,Visual C++ 和 .NET Framework 都遵循这个规则提供 argc/argv/args 的值。

下文内容均按照这种解释方式。

从命令行到参数

参考:

微软定义的规则时这样说的:

此外,这个规则还包含一些例外,这里暂时先忽略。

  • 位于命令行最开头的连续个空白字符被视为一个额外的空参数。
  • 在引号内状态下,允许使用两个双引号作为单个双引号的转义。
    • msvcr80.dll (Microsoft Visual C++ 2008 Redistributable)及更早版本的运行时库中,在引号外状态下也会应用两个双引号的转义。

——感谢知友 @王扶之 提供的补充

从参数到命令行

根据上述规则,推算出反向算法为:

function argvToCommandLine(argv: string[]) {
  let cmd = "";
  for (const arg of argv) {
    cmd += '"';
    for (let i = 0; true; i++) {
      // 记录已经连续了多少个反斜杠
      let slashNum = 0;
      while (i !== arg.length && arg[i] === "\\") {
        i++;
        slashNum++;
      }
      if (i === arg.length) {
        cmd += "\\".repeat(slashNum * 2);
        break;
      } else if (arg[i] === '"') {
        cmd += "\\".repeat(slashNum * 2 + 1);
        cmd += '"';
      } else {
        cmd += "\\".repeat(slashNum);
        cmd += arg[i];
      }
    }
    cmd += '" ';
  }
  return cmd;
}

libuv 中有另一种更简单的实现(感谢知友 @王扶之 提供的资料):

function argvToCommandLine(argv: string[]) {
  return argv
    .map((arg) => {
      let rev = '"'; // 逆向构造
      let quoteHit = true; // 是否处于保留引号的区间
      for (let i = arg.length - 1; i >= 0; i--) {
        rev += arg[i];
        if (quoteHit && arg[i] === "\\") {
          // 若需要保留引号,则添加额外的反斜杠
          rev += "\\";
        } else if (arg[i] === '"') {
          quoteHit = true;
          rev += "\\";
        } else {
          quoteHit = false;
        }
      }
      rev += '"';
      // 反转为正向字符串
      return Array.from(rev).reverse().join("");
    })
    .join(" ");
}

不得不说,这个规则实在太古怪,比如 \\"\\d 的正确括起写法分别是 "\\\\\"""\\d";反斜杠的数量有天壤之别。

cmd.exe

还有更恶心的。Windows 的默认壳层程序 cmd.exe 用了更糟糕的解析规则,而且这个规则还没有官方的文档。

好在神通广大的网友们通过大量试验逆向出了这个规则。本节参考:Stack Overflow

命令行到参数

cmd.exe 的任务是解析用户的输入,理解诸如控制语句、IO 重定向等信息。对我们而言,最重要的是运行外部程序时的两部分内容:目标程序和参数命令行。

总的来说,明确这些要点:

可以看到,cmd.exe 是混乱邪恶的。为此我强烈呼吁:永远不要用 cmd.exe 传递参数。

参数到命令行

但是编程的时候总是会有意无意地碰到 cmd.exe 这块硬骨头。最简单的手段也是最极端的手段:在每个字符前都添加 ^——幸好 ^ 只是取消特殊含义,^ 作用于普通的字符上没有效果也不会报错。

function argvToCmdDotExe(argv: string[]) {
  if (argv.join('').includes("\n")) {
    throw new Error(`别想了,这事儿不能成`);
  }
  return argvToCommandLine(argv).replace(/(.)/g, "^$1");
}

附录:程序名

说完了参数解析,程序名的解析就相对简单了。

POSIXWindows
系统调用exec 的首个参数原样传递见下文
壳层规则和参数相同首个词法标记,并删除双引号

CreateProcessW 虽然提供了用作程序名的首个参数,但一般习惯设置为空(NULLnullptr)。要启动的程序名一般通过如下解析规则获取:

之所以不推荐通过 CreateProcessW 的首个参数传递程序名,是因为如果这样做的话,被启动的进程的命令行就会缺失“程序名”部分——换而言之,被启动的程序的 argv[0] 不再是程序名了!这与 POSIX 标准,以及用户习惯都不吻合。