我们如何运行存储在变量中的命令?

我们如何运行存储在变量中的命令?
$ ls -l /tmp/test/my\ dir/
total 0

我想知道为什么以下方法运行上述命令失败或成功?

$ abc='ls -l "/tmp/test/my dir"'

$ $abc
ls: cannot access '"/tmp/test/my': No such file or directory
ls: cannot access 'dir"': No such file or directory

$ "$abc"
bash: ls -l "/tmp/test/my dir": No such file or directory

$ bash -c $abc
'my dir'

$ bash -c "$abc"
total 0

$ eval $abc
total 0

$ eval "$abc"
total 0

答案1

这已经在 unix.SE 上的许多问题中进行了讨论,我将尝试收集我可以在这里提出的所有问题。下边是

  • 各种尝试失败的原因和方式的描述,
  • 一种使用函数(对于固定命令)正确执行此操作的方法,或者
  • 使用 shell 数组 (Bash/ksh/zsh) 或$@伪数组 (POSIX sh),如果您只需要改变一些选项,这两者也允许构建命令行片段
  • 以及有关使用eval此操作的注释。

一些参考资料在最后。

出于此处的目的,如果仅将命令参数或命令名称存储在变量中,则没有多大关系。在启动命令之前,它们的处理方式类似,此时 shell 仅将第一个单词作为要运行的命令的名称。


为什么失败

您面临这些问题的原因是分词非常简单,不适合复杂的情况,而且从变量扩展的引号并不充当引号,而只是普通字符。

(请注意,有关引号的部分与所有其他编程语言类似:例如,char *s = "foo()"; printf("%s\n", s)不会调用foo()C 中的函数,而只是打印字符串foo()。这在宏处理器中有所不同,例如 m4、C 预处理器或 Make(在某种程度上) . shell 是一种编程语言,而不是宏处理器。)

在类 Unix 系统上,shell 负责处理命令行上的引号和变量扩展,将其从单个字符串转换为底层系统调用传递给启动命令的字符串列表。程序本身看不到 shell 处理的引号。例如,如果给出命令ls -l "foo bar",shell 会将其转换为三个字符串ls, -land foo bar(删除引号),并将它们传递给ls. (甚至命令名称也被传递,尽管并非所有程序都使用它。)

问题中提出的案例:

这里的赋值将单个字符串分配ls -l "/tmp/test/my dir"abc

$ abc='ls -l "/tmp/test/my dir"'

下面,$abc按空格分割,并ls获取三个参数-l,"/tmp/test/mydir"。这里的引号只是数据,所以第二个参数前面有一个,第三个参数后面有一个。该选项有效,但路径处理不正确,因为ls将引号视为文件名的一部分:

$ $abc
ls: cannot access '"/tmp/test/my': No such file or directory
ls: cannot access 'dir"': No such file or directory

此处,扩展被引用,因此它被保留为单个单词。 shell 尝试查找一个字面上名为 的程序ls -l "/tmp/test/my dir",其中包含空格和引号。

$ "$abc"
bash: ls -l "/tmp/test/my dir": No such file or directory

这里,$abc被分割,并且仅将第一个结果单词作为 的参数-c,因此 Bash 只ls在当前目录中运行。其他单词是 bash 的参数,用于填充$0$1等。

$ bash -c $abc
'my dir'

对于bash -c "$abc", and eval "$abc",有一个额外的 shell 处理步骤,这确实使引号起作用,但是还导致所有 shell 扩展被再次处理,因此存在意外运行例如用户提供的数据的命令替换的风险,除非您在引用时非常小心。


更好的方法来做到这一点

存储命令的两种更好的方法是 a) 使用函数,b) 使用数组变量(或位置参数)。

使用函数:

只需声明一个包含命令的函数,然后像命令一样运行该函数。函数内命令的扩展仅在命令运行时处理,而不是在定义时处理,并且您不需要引用各个命令。尽管这实际上仅在您需要存储一个固定命令(或多个固定命令)时才有帮助。

# define it
myls() {
    ls -l "/tmp/test/my dir"
}

# run it
myls

也可以定义多个函数并使用变量来存储最终要运行的函数的名称。

使用数组:

数组允许创建多单词变量,其中各个单词包含空格。在这里,各个单词存储为不同的数组元素,并且扩展"${array[@]}"将每个元素扩展为单独的 shell 单词:

# define the array
mycmd=(ls -l "/tmp/test/my dir")

# expand the array, run the command
"${mycmd[@]}"

该命令在括号内的书写方式与运行该命令时的书写方式完全相同。 shell 所做的处理在两种情况下都是相同的,只是在一种情况下它只保存结果字符串列表,而不是使用它来运行程序。

不过,稍后扩展数组的语法有点糟糕,并且它周围的引号很重要。

数组还允许您逐个构建命令行。例如:

mycmd=(ls)               # initial command
if [ "$want_detail" = 1 ]; then
    mycmd+=(-l)          # optional flag, append to array
fi
mycmd+=("$targetdir")    # the filename

"${mycmd[@]}"

或者保持命令行的部分内容不变并使用数组填充其中的一部分,例如选项或文件名:

options=(-x -v)
files=(file1 "file name with whitespace")
target=/somedir

somecommand "${options[@]}" "${files[@]}" "$target"

somecommand这里是通用占位符名称,不是任何真正的命令。)

数组的缺点是它们不是标准功能,因此普通的 POSIX shell(例如Debian/Ubuntu 中的dash默认设置/bin/sh)不支持它们(但请参见下文)。但是,Bash、ksh 和 zsh 可以,因此您的系统可能有一些支持数组的 shell。

使用"$@"

在不支持命名数组的 shell 中,仍然可以使用位置参数(伪数组"$@")来保存命令的参数。

以下应该是可移植脚本位,其功能与上一节中的代码位相同。该数组被替换为"$@"位置参数列表。设置"$@"是用 完成的set,周围的双引号"$@"很重要(这会导致列表的元素被单独引用)。

首先,简单地存储带有参数的命令"$@"并运行它:

set -- ls -l "/tmp/test/my dir"
"$@"

有条件地设置命令的部分命令行选项:

set -- ls
if [ "$want_detail" = 1 ]; then
    set -- "$@" -l
fi
set -- "$@" "$targetdir"

"$@"

仅用于"$@"选项和操作数:

set -- -x -v
set -- "$@" file1 "file name with whitespace"
set -- "$@" /somedir

somecommand "$@"

当然,"$@"通常充满了脚本本身的参数,因此您必须在重新使用之前将它们保存在某个地方"$@"

要有条件地传递单个参数,您还可以使用替代值扩展${var:+word}和一些仔细的引用。在这里,-f 仅当文件名非空时,我们才包含和 文件名:

file="foo bar"
somecommand ${file:+-f "$file"}

使用eval(这里要小心!)

eval接受一个字符串并将其作为命令运行,就像在 shell 命令行中输入它一样。这包括所有引用和扩展处理,这既有用又危险。

在简单的情况下,它允许做我们想做的事情:

cmd='ls -l "/tmp/test/my dir"'
eval "$cmd"

使用 时eval,引号会被处理,因此ls最终只会看到两个参数-l/tmp/test/my dir,就像我们想要的那样。eval也足够聪明,可以连接它获得的任何参数,因此eval $cmd在某些情况下也可以工作,但例如所有空白都会更改为单个空格。最好在此处引用变量,因为这将确保它不被修改为eval.

然而,将用户输入包含在命令字符串中是危险的eval。例如,这似乎有效:

read -r filename
cmd="ls -ld '$filename'"
eval "$cmd";

但是,如果用户提供包含单引号的输入,他们可以突破引号并运行任意命令!例如,使用输入'$(whatever)'.txt,您的脚本会愉快地运行命令替换。事实可能会是rm -rf(或更糟)。

问题在于 的值$filename嵌入在运行的命令行中eval。它之前被扩展过eval,例如命令ls -l ''$(whatever)'.txt'。为了安全起见,您需要预处理输入。

如果我们用另一种方式来做,将文件名保留在变量中,并让eval命令扩展它,它会再次更安全:

read -r filename
cmd='ls -ld "$filename"'
eval "$cmd";

请注意,外部引号现在是单引号,因此内部不会发生扩展。因此,eval查看该命令ls -l "$filename"并自行安全地扩展文件名。

但这与仅将命令存储在函数或数组中没有太大区别。对于函数或数组,不存在这样的问题,因为单词始终保持分开,并且对 的内容没有引号或其他处理filename

read -r filename
cmd=(ls -ld -- "$filename")
"${cmd[@]}"

几乎唯一使用的原因eval是变化部分涉及无法通过变量(管道、重定向等)引入的 shell 语法元素。但是,您需要引用/转义所有内容别的在需要保护免受额外解析步骤影响的命令行上(请参阅下面的链接)。无论如何,最好避免将用户的输入嵌入到eval命令中!


参考

答案2

运行(重要)命令的最安全方法是eval。然后,您可以像在命令行上一样编写命令,并且它的执行就像您刚刚输入的一样。但你必须引用所有内容。

简单案例:

abc='ls -l "/tmp/test/my dir"'
eval "$abc"

情况并非如此简单:

# command: awk '! a[$0]++ { print "foo: " $0; }' inputfile
abc='awk '\''! a[$0]++ { print "foo: " $0; }'\'' inputfile'
eval "$abc"

答案3

第二个引号打破了命令。

当我跑步时:

abc="ls -l '/home/wattana/Desktop'"
$abc

它给了我一个错误。

但当我跑步时

abc="ls -l /home/wattana/Desktop"
$abc

完全没有错误

当时(对我来说)无法解决此问题,但您可以通过目录名称中没有空格来避免该错误。

这个答案 说 eval 命令可以用来解决这个问题,但它对我不起作用:(

答案4

虽然@ilkkachu 提到bash 的分词,我认为明确指出 IFS shell 变量的重要性会很好。例如在 bash 中:

OLD_IFS="$IFS"
IFS=$'\x1a'
my_command=$'ls\x1a-l\x1a-a\x1a/tmp/test/my dir'
$my_command
IFS="$OLD_IFS"

将按预期运行存储在 my_command 中的命令。 \x1a 是 ASCII 中的 CTRL-Z 和 a好的分隔符选择。只要执行的命令不包含任何 CTRL+Z 字符(可以说比空格更有可能),这种方法就有效。我还提到了 bash,因为 ANSI-C 风格引用 $'...' 目前还不是 POSIX。

当您有硬编码命令或正在构造命令时,此技术即可发挥作用。只是不要忘记将 IFS 重置为其之前的值。

相关内容