为什么我的Shell脚本会在空白或其他特殊字符上造成窒息?


284

或者,一个介绍性的指南,介绍了强大的文件名处理以及其他在Shell脚本中传递的字符串。

我编写了一个外壳脚本,该脚本在大多数情况下都能正常工作。但是它在某些输入(例如某些文件名)上阻塞了。

我遇到了以下问题:

  • 我有一个包含空格的文件名hello world,它被视为两个单独的文件helloworld
  • 我的输入行有两个连续的空格,它们在输入中缩小为一个。
  • 开头和结尾的空格从输入行中消失。
  • 有时,当输入包含一个字符时\[*?,它们会被一些实际上是文件名的文本替换。
  • 输入中有撇号'(或双引号"),从那以后事情变得很奇怪。
  • 输入中有一个反斜杠(或:我正在使用Cygwin,并且我的某些文件名具有Windows样式的\分隔符)。

怎么回事,如何解决?


16
shellcheck帮助您提高程序质量。
aurelien

3
除了答案中描述的保护技术外,尽管对于大多数读者来说可能很明显,但我认为值得一提的是,当打算使用命令行工具处理文件时,最好避免在文件中使用花哨的字符。如果可能的话,将名称放在首位。
bli


1
@bli不,这样只会使bug花费的时间更长。今天隐藏了错误。现在,您不知道以后与代码一起使用的所有文件名。
Volker Siegel '18

首先,如果您的参数包含空格,则需要用引号将它们引起来(在命令行上)。但是,您可以获取整个命令行并自己解析。两个空格不会变成一个空格。任何数量的空间都会告诉您的脚本是下一个变量,因此,如果您执行“ echo $ 1 $ 2”之类的操作,那么您的脚本将在两者之间插入一个空格。也可以使用“ find(-exec)”遍历带空格的文件,而不是for循环;您可以更轻松地处理这些空间。
帕特里克·泰勒

Answers:


352

始终在变量替换和命令替换周围使用双引号:"$foo""$(foo)"

如果使用无$foo引号,则脚本将阻塞$(foo)包含空格或的输入或参数(或命令输出,带有)\[*?

在那里,您可以停止阅读。好吧,这里还有更多:

  • readread使用内置命令逐行读取输入,请使用while IFS= read -r line; do …
    Plain read特殊对待反斜杠和空格。
  • xargs避免xargs。如果必须使用xargs,那就做xargs -0。相反find … | xargs宁愿find … -exec …。特殊
    xargs对待空格和字符\"'

这个答案适用于伯恩/ POSIX风格的贝壳(shashdashbashkshmkshyash...)。Zsh用户应该跳过它,并阅读何时需要双引号?代替。如果您想了解所有细节,请阅读标准或外壳手册。


请注意,以下说明包含一些近似值(在大多数情况下正确的陈述,但可能会受到周围环境或配置的影响)。

我为什么需要写"$foo"?没有引号会怎样?

$foo并不意味着“取变量的值foo”。这意味着要复杂得多:

  • 首先,取变量的值。
  • 字段拆分:将该值视为由空格分隔的字段列表,然后构建结果列表。例如,如果该变量包含foo * bar ​然后此步骤的结果为第3元素的列表foo*bar
  • 生成文件名:将每个字段都视为全局字段(即通配符模式),然后将其替换为与此模式匹配的文件名列表。如果该模式与任何文件都不匹配,则将其保留不变。在我们的示例中,这导致包含foo的列表,其次是当前目录中的文件列表,最后是bar。如果当前目录是空的,结果是foo*bar

请注意,结果是一个字符串列表。Shell语法中有两个上下文:列表上下文和字符串上下文。字段拆分和文件名生成仅在列表上下文中发生,但这是大多数时间。双引号分隔字符串上下文:整个双引号字符串是单个字符串,不能分割。(例外:"$@"扩展到位置参数列表,例如"$@",等同于"$1" "$2" "$3"是否存在三个位置参数。请参见$ *和$ @有什么区别?

使用$(foo)或使用进行命令替换时也会发生同样的情况`foo`。附带说明一下,不要使用`foo`:它的引用规则很奇怪且不可移植,并且所有现代shell都支持$(foo),除了具有直观的引用规则外,它是完全等效的。

算术替换的输出也经历相同的扩展,但是通常不必担心,因为它仅包含不可扩展的字符(假设IFS不包含数字或-)。

请参阅何时需要双引号?有关可以省略引号的情况的更多详细信息。

除非您想让所有这些麻烦事情发生,否则请记住始终在变量和命令替换周围使用双引号。请当心:省略引号不仅会导致错误,还会导致安全漏洞

如何处理文件名列表?

如果您myfiles="file1 file2"使用空格分隔文件,请使用包含空格的文件名。Unix文件名可以包含除/(始终是目录分隔符)和空字节(在大多数shell的shell脚本中不能使用)以外的任何字符。

同样的问题myfiles=*.txt; … process $myfiles。执行此操作时,该变量myfiles包含5个字符的字符串*.txt,并且是在编写$myfiles通配符时编写的。在您将脚本更改为之前,该示例将真正起作用myfiles="$someprefix*.txt"; … process $myfiles。如果someprefix设置为final report,则无法使用。

要处理任何类型的列表(例如文件名),请将其放入数组中。这需要mksh,ksh93,yash或bash(或zsh,它们没有所有这些引用问题);一个普通的POSIX外壳程序(如灰或破折号)没有数组变量。

myfiles=("$someprefix"*.txt)
process "${myfiles[@]}"

Ksh88的数组变量具有不同的赋值语法set -A myfiles "someprefix"*.txt(如果需要ksh88 / bash可移植性,请参见不同ksh环境下的赋值变量)。Bourne / POSIX样式的外壳具有一个单一的数组,"$@"即您使用set其设置的位置参数数组,并且该位置参数对函数而言是局部的:

set -- "$someprefix"*.txt
process -- "$@"

以开头的文件名-呢?

请注意,文件名可以以-(破折号/减号)开头,大多数命令将其解释为表示选项。如果您的文件名以可变部分开头,请确保--在其前面通过,如上面的代码片段所示。这表明命令已到达选项的末尾,因此此后的任何内容都是文件名,即使它以开头-

或者,您可以确保文件名以以外的其他字符开头-。绝对文件名以开头/,您可以./在相对名称的开头添加。以下代码段将变量的内容f转换为“安全”的方式,以引用保证不以开头的同一文件-

case "$f" in -*) "f=./$f";; esac

最后,请注意-,即使在之后,某些命令也将其解释为标准输入或标准输出--。如果您需要引用一个名为的实际文件-,或者正在调用此类程序,而又不想从stdin读取该文件或将其写入stdout,请确保-按上述方法进行重写。请参见“ du -sh *”和“ du -sh ./*”之间的区别是什么?有待进一步讨论。

如何将命令存储在变量中?

“命令”可能意味着三件事:命令名称(作为可执行文件的名称,带有或不带有完整路径,或者函数的名称,内置或别名),带有参数的命令名称或一段外壳代码。因此,存在将它们存储在变量中的不同方式。

如果您有命令名称,则只需存储它,然后照常使用带双引号的变量即可。

command_path="$1"

"$command_path" --option --message="hello world"

如果您有带有参数的命令,则问题与上述文件名列表相同:这是字符串列表,而不是字符串。您不能仅将参数填充到单个字符串中,并在其之间留有空格,因为如果这样做,您将无法分辨出作为参数一部分的空格与分隔参数的空格之间的区别。如果您的外壳中有数组,则可以使用它们。

cmd=(/path/to/executable --option --message="hello world" --)
cmd=("${cmd[@]}" "$file1" "$file2")
"${cmd[@]}"

如果您使用的是不带阵列的外壳怎么办?如果您不介意修改位置参数,则仍然可以使用它们。

set -- /path/to/executable --option --message="hello world" --
set -- "$@" "$file1" "$file2"
"$@"

如果您需要存储复杂的Shell命令(例如,带有重定向,管道等)怎么办?或者,如果您不想修改位置参数?然后,您可以构建包含命令的字符串,并使用eval内置函数。

code='/path/to/executable --option --message="hello world" -- /path/to/file1 | grep "interesting stuff"'
eval "$code"

请注意以下定义中的嵌套引号code:单引号'…'分隔字符串文字,因此变量的code值为string /path/to/executable --option --message="hello world" -- /path/to/file1。该eval内建告诉shell解析作为参数传递,如果它出现在脚本中的字符串,所以在这一点上引号和管道解析等。

使用eval是棘手的。仔细考虑何时解析的内容。特别是,您不能仅将文件名填充到代码中:您需要对其进行引用,就像在源代码文件中一样。没有直接的方法可以做到这一点。喜欢的东西code="$code $filename",如果文件名中包含任何shell特殊字符符(空格,$;|<>,等)。code="$code \"$filename\""仍然中断"$\`code="$code '$filename'"如果文件名包含,则偶数中断'。有两种解决方案。

  • 在文件名周围添加引号。最简单的方法是在其周围添加单引号,并用替换单引号'\''

    quoted_filename=$(printf %s. "$filename" | sed "s/'/'\\\\''/g")
    code="$code '${quoted_filename%.}'"
  • 将变量扩展保留在代码内,以便在评估代码时(而不是在构建代码片段时)查找它。这比较简单,但仅在执行代码时变量仍在相同值附近的情况下才有效,例如,如果代码是在循环中构建的,则不行。

    code="$code \"\$filename\""

最后,您真的需要一个包含代码的变量吗?给代码块命名的最自然的方法是定义一个函数。

怎么了read

如果不使用-r,则read允许使用续行-这是单个逻辑输入行:

hello \
world

read将输入行拆分为以中的字符分隔的字段$IFS(不带-r,反斜杠也将转义)。例如,如果输入是包含三个单词的行,则将其read first second third设置first为输入的第一个单词,second第二个单词和third第三个单词。如果还有更多的单词,则最后一个变量包含设置前面的单词后剩下的所有内容。前导和尾随空格被修剪。

设置IFS为空字符串可避免任何修整。请参阅为什么经常使用“ IFS =读取时”而不是“ IFS =”。在阅读时。详细的解释。

这有什么错xargs

输入格式xargs是用空格分隔的字符串,可以选择用单引号或双引号引起来。没有标准工具输出此格式。

xargs -L1xargs -l几乎是行列表的输入,但不完全是-如果行尾有空格,则下一行是续行。

您可以xargs -0在适用的地方使用(以及可用的地方:GNU(Linux,Cygwin),BusyBox,BSD,OSX,但不在POSIX中)。这很安全,因为空字节不能出现在大多数数据中,尤其是在文件名中。要生成一个以空分隔的文件名列表,请使用find … -print0(或可以find … -exec …按以下说明使用)。

我该如何处理所找到的文件find

find  -exec some_command a_parameter another_parameter {} +

some_command需要是外部命令,不能是shell函数或别名。如果需要调用Shell处理文件,请sh显式调用。

find  -exec sh -c '
  for x do
    … # process the file "$x"
  done
' find-sh {} +

我还有其他问题

浏览此站点上的标记,或者浏览。(单击“了解更多…”以查看一些一般性提示以及一些常见问题的手动选择列表。)如果您已搜索但找不到答案,请提出


6
@ John1024这只是GNU功能,因此我坚持使用“没有标准工具”。
吉尔斯

2
除了(甚至在sh仿真中)和中,您还需要用引号引起来$(( ... ))$[...]在某些shell中也是zsh如此)mksh
斯特凡Chazelas

3
注意xargs -0不是POSIX。除了FreeBSD以外xargs,您通常希望xargs -r0使用xargs -0
斯特凡Chazelas

2
@ John1024,不,ls --quoting-style=shell-always与不兼容xargs。尝试touch $'a\nb'; ls --quoting-style=shell-always | xargs
斯特凡Chazelas

3
另一个不错的功能(仅GNU)是xargs -d "\n"这样,您可以运行例如locate PATTERN1 |xargs -d "\n" grep PATTERN2搜索与PATTERN1匹配且内容与PATTERN2匹配的文件名。没有GNU,您可以这样做,例如locate PATTERN1 |perl -pne 's/\n/\0/' |xargs -0 grep PATTERN1
Adam Katz

26

尽管吉尔斯的回答非常好,但我主要提出了问题

始终在变量替换和命令替换周围使用双引号:“ $ foo”,“ $(foo)”

当您使用类似Bash的shell进行单词拆分时,当然可以,安全的建议总是使用引号。但是,并非总是执行分词

§分词

这些命令可以正确运行

foo=$bar
bar=$(a command)
logfile=$logdir/foo-$(date +%Y%m%d)
PATH=/usr/local/bin:$PATH ./myscript
case $foo in bar) echo bar ;; baz) echo baz ;; esac

我不鼓励用户采取这种行为,但如果有人牢牢理解何时会发生分词,那么他们应该能够自行决定何时使用引号。


19
正如我在回答中提到的那样,请参阅unix.stackexchange.com/questions/68694/…了解详细信息。请注意问题-“为什么我的shell脚本会窒息?”。最常见的问题(来自本网站和其他地方的多年经验)缺少双引号。与“始终使用双引号,但在没有必要的情况下”相比,“始终使用双引号”更容易记住。
吉尔斯2014年

14
规则对于初学者来说很难理解。举例来说,foo=$bar还算可以,但export foo=$bar还是env foo=$var没有(至少在某些贝壳)。初学者的建议:除非您知道自己在做什么,并且有充分的理由不这样做,否则请始终引用您的变量
斯特凡Chazelas

5
@StevenPenny真的更正确吗?在合理的情况下,引号会破坏脚本吗?在一半情况下必须使用引号,而在另一半情况下可以选择使用引号的情况下-应该考虑的建议是“始终使用引号,以防万一”,因为它是真实,简单且风险较小的。众所周知,向初学者教授这样的例外列表是无效的(缺乏上下文,他们不会记住它们)并且适得其反,因为它们会混淆需要/不需要的引号,破坏脚本并激励他们进一步学习。
彼得斯2014年

6
我的$ 0.02就是建议报价都不错。错误地引用不需要的东西是无害的,错误地引用不需要的东西是有害的。因此,对于大多数Shell脚本作者来说,他们永远都不会理解何时发生精确的单词拆分的复杂性,因此,引用所有内容比仅在必要时进行引用要安全得多。
godlygeek

5
@Peteris和godlygeek:“在合理的情况下,引号会破坏脚本吗?” 这取决于您对“合理”的定义。如果脚本设置了criteria="-type f",则find . $criteria可以,但find . "$criteria"不能。
G-Man

22

据我所知,只有两种情况需要对双引号进行扩展,而这两种情况都涉及两个特殊的shell参数"$@""$*"-被指定为在用双引号引起来时进行不同的扩展。在所有其他情况下(也许不包括特定于shell的数组实现),扩展的行为是可配置的,为此可以选择。

当然,这并不是说应该避免双引号-相反,这可能是界定外壳必须提供的扩展的最方便,最可靠的方法。但是,我认为,由于已经对替代方案进行了专业的阐述,因此这是讨论shell扩展值时会发生什么情况的绝佳场所。

外壳,在其心脏和灵魂(对于具有这种外壳的人)中,是一个命令解释器-它是一个解析器,就像一个大型的,交互式的sed。如果您的shell语句在空格或类似字符令人窒息,则很可能是因为您尚未完全理解shell的解释过程-尤其是如何以及为什么它将输入语句转换为可操作命令的原因。Shell的工作是:

  1. 接受输入

  2. 正确解释并将其拆分为标记化的输入

    • 输入的单词是shell语法项,例如$wordecho $words 3 4* 5

    • 单词总是在空格上分割-只是语法-但只有文字空白字符在其输入文件中提供给外壳程序

  3. 如有必要,将其扩展为多个字段

    • 字段单词扩展产生-它们构成了最终的可执行命令

    • 除了输入字段"$@"$IFS 字段拆分路径名扩展之外,输入必须始终求值为单个字段

  4. 然后执行结果命令

    • 在大多数情况下,这涉及以某种形式或其他形式传递其解释结果

人们经常说外壳是胶水,如果这是真的,那么它所坚持的就是当它们成为一个或另一个进程时的参数列表或字段exec。大多数shell不能NUL很好地处理字节-如果有的话-这是因为它们已经在上面拆分了。Shell exec 的工作量很大,它必须使用NUL定界的参数数组来做到这一点,然后将这些参数传递给系统内核exec。如果要将shell的定界符与其定界数据混合在一起,则shell可能会将其弄糟。像大多数程序一样,其内部数据结构也依赖该定界符。zsh,值得注意的是,这并不能解决问题。

就是这样,$IFS$IFS总是存在-同样是可设置的-shell参数,它定义shell如何将shell扩展从单词字段拆分-尤其是在这些字段应定界的值上。$IFSNUL- 以外的分隔符上分割shell扩展,或者换句话说,shell替换由扩展产生的字节,这些字节$IFSNUL内部数据数组中with 的值匹配。像这样看时,您可能会开始看到,每个由字段拆分的 shell扩展都是- $IFS分隔的数据数组。

理解这一点很重要$IFS限定那些扩展不是,你可以用做-已另有分隔"双引号。引用扩展时,可以在其值的开头和至少结尾处定界。在这些情况下$IFS,由于没有可分隔的字段,因此不适用。实际上,当设置为空值时,双引号展开与未引号展开具有相同的场分裂行为IFS=

除非引用,否则$IFS它本身就是$IFS定界的shell扩展。它的默认值为<space><tab><newline>-,当包含在中时,这三个都显示特殊的属性$IFS。而任何其它值用于$IFS被指定为计算结果为单个每膨胀发生$IFS 空白 -任何那些三-被指定到的Elid每膨胀单场序列和前/后的序列完全省略。通过示例可能最容易理解。

slashes=///// spaces='     '
IFS=/; printf '<%s>' $slashes$spaces
<><><><><><     >
IFS=' '; printf '<%s>' $slashes$spaces
</////>
IFS=; printf '<%s>' $slashes$spaces
</////     >
unset IFS; printf '<%s>' "$slashes$spaces"
</////     >

但这仅仅是$IFS-所要求的只是单词拆分或空格,那么特殊字符是什么?

默认情况下,当shell 出现在列表中时,它们也会将某些未加引号的标记(如此?*[处其他地方所述)扩展为多个字段。这称为路径名扩展,即通配符。这是一个非常有用的工具,而且它在以shell的解析顺序进行字段拆分后会受到$ IFS的影响- 路径名扩展生成的字段在文件名本身的头部/尾部定界其内容包含当前在中的任何字符$IFS。默认情况下,此行为设置为“开”-否则很容易进行配置。

set -f

这指示shell glob的。至少在以某种方式撤消该设置之前,至少不会发生路径名扩展-例如,如果当前的shell被另一个新的shell进程替换,或者....

set +f

...发给炮弹。双引号-就像它们也用于$IFS 字段拆分一样 -使得每次扩展都不需要此全局设置。所以:

echo "*" *

...如果当前启用了路径名扩展,则每个参数可能会产生非常不同的结果-因为第一个仅扩展至其文字值(单个星号字符,根本没有扩展,第二个仅扩展至相同值如果当前工作目录不包含可能匹配的文件名(并且几乎匹配所有文件名。但是,如果您这样做:

set -f; echo "*" *

...两个参数的结果相同- *在这种情况下不会扩展。


我实际上同意@StéphaneChazelas的观点,(大多数情况)它使事情变得比帮助更令人困惑……但我个人认为它很有帮助,因此我投票赞成。现在,我对IFS实际工作方式有了一个更好的主意(以及一些示例)。我没有得到的是为什么它会永远是设置一个好主意IFS为非默认的东西。
通配符

1
@Wildcard-这是字段定界符。如果您想将变量中的值扩展到多个字段,则将其拆分$IFScd /usr/bin; set -f; IFS=/; for path_component in $PWD; do echo $path_component; done打印\n,然后usr\nbin\n。第一个echo为空,因为/是空字段。path_components可以有换行符或空格,也可以是其他内容-没关系,因为组件是分开的,/而不是默认值。awk无论如何,人们总是在做它。您的贝壳也可以
mikeserv

3

我有一个大型视频项目,文件名中有空格,目录名中有空格。虽然find -type f -print0 | xargs -0可以用于多种目的并且可以在不同的外壳上使用,但是我发现使用自定义IFS(输入字段分隔符)可以使您在使用bash时更加灵活。下面的代码片段使用bash并将IFS设置为换行符;只要您的文件名中没有换行符:

(IFS=$'\n'; for i in $(find -type f -print) ; do
    echo ">>>$i<<<"
done)

请注意使用括号来隔离IFS的重新定义。我已经阅读了有关如何恢复IFS的其他文章,但这很容易。

此外,将IFS设置为换行符可让您预先设置shell变量并轻松打印出来。例如,我可以使用换行符作为分隔符来逐渐增加变量V:

V=""
V="./Ralphie's Camcorder/STREAM/00123.MTS,04:58,05:52,-vf yadif"
V="$V"$'\n'"./Ralphie's Camcorder/STREAM/00111.MTS,00:00,59:59,-vf yadif"
V="$V"$'\n'"next item goes here..."

并相应地:

(IFS=$'\n'; for v in $V ; do
    echo ">>>$v<<<"
done)

现在,我可以echo "$V"使用双引号“列出” V的设置以输出换行符。(有关此内容,请$'\n'单击此线程。)


3
但是那样,您仍然会遇到包含换行符或glob字符的文件名的问题。另请参阅:为什么遍历find的输出是错误的做法?。如果使用zsh,则可以使用IFS=$'\0'和使用-print0zsh扩展时不会进行glob,因此glob字符在那里不是问题)。
斯特凡·查泽拉斯18'Feb

1
这适用于包含空格的文件名,但不适用于潜在的敌对文件名或意外的“无意义”文件名。您可以通过添加轻松解决包含通配符的文件名的问题set -f。另一方面,您的方法从根本上失败了,因为文件名包含换行符。当处理除文件名以外的数据时,它还会失败并显示空白项。
Gilles

是的,我的警告是它不适用于文件名中的换行符。但是,我相信我们必须划清界限;-)
罗斯(Russ)

而且我不确定为什么这会引起反对。这是遍历带空格的文件名的完全合理的方法。使用-print0需要xargs,并且使用该链有些困难。抱歉,有人不同意我的回答,但这没有理由不赞成。
罗斯

0

考虑到上面提到的所有安全隐患,并假设您信任并且可以控制扩展变量,则可以使用使用多个空格的路径eval。不过要小心!

$ FILES='"a b" c'
$ eval ls $FILES
ls: a b: No such file or directory
ls: c: No such file or directory
$ FILES='a\ b c'
$ eval ls $FILES
ls: a b: No such file or directory
ls: c: No such file or directory
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.