为什么遍历find的输出是不好的做法?


170

这个问题的灵感来自

为什么使用shell循环处理文本被认为是不好的做法?

我看到这些构造

for file in `find . -type f -name ...`; do smth with ${file}; done

for dir in $(find . -type d -name ...); do smth with ${dir}; done

即使有些人花时间在这些帖子上发表评论,解释为什么应避免此类事情的原因,也几乎每天都在这里使用...
看到此类帖子的数量(以及有时忽略这些评论的事实)我想我不妨问一个问题:

为什么循环遍历find输出是不正确的做法,并且为每个返回的文件名/路径运行一个或多个命令的正确方法是find什么?


12
我认为这有点像“永不解析ls输出!” -当然,您可以一次性完成任何一项操作,但是与生产质量相比,它们更多的是快速的技巧。或者,更笼统地说,绝对不要教条。
Bruce Ediger


这应该变成一个规范的答案
Zaid

6
因为查找的重点是遍历所找到的内容。
OrangeDog

2
一个辅助点-您可能希望将输出发送到文件,然后在脚本中稍后对其进行处理。如果您需要调试脚本,则可以通过这种方式查看文件列表。
2016年

Answers:


87

问题

for f in $(find .)

结合了两个不兼容的事物。

find打印以换行符分隔的文件路径列表。当您$(find .)在列表上下文中未加引号时调用的split + glob运算符将其拆分为字符$IFS(默认情况下包括换行符,还包括空格和制表符(以及中的NUL zsh)),并对每个结果单词执行遍历(除中的zsh)(甚至在ksh93或pdksh衍生物中大括号扩展!)。

即使您做到了:

IFS='
' # split on newline only
set -o noglob # disable glob (also disables brace expansion in pdksh
              # but not ksh93)
for f in $(find .) # invoke split+glob

这仍然是错误的,因为换行符与文件路径中的任何字符一样有效。的输出find -print根本无法可靠地进行后处理(除非使用一些复杂的技巧,如此处所示)。

这也意味着外壳程序需要存储完整的输出find,然后将其拆分+ glob(这意味着将输出再次存储在内存中),然后再开始循环遍历文件。

请注意,find . | xargs cmd存在类似的问题(存在空格,换行符,单引号,双引号和反斜杠(并且在某些xarg实现中,字节不构成有效字符的一部分)是一个问题)

更正确的选择

只有这样,才能使用for上的输出回路find是使用zsh支持IFS=$'\0'和:

IFS=$'\0'
for f in $(find . -print0)

(替换-print0-exec printf '%s\0' {} +find是不支持非标准(但相当普遍时下)实现-print0)。

在这里,正确且可移植的方法是使用-exec

find . -exec something with {} \;

或者,如果something可以接受多个参数:

find . -exec something with {} +

如果确实需要外壳处理该文件列表:

find . -exec sh -c '
  for file do
    something < "$file"
  done' find-sh {} +

(请注意,它可能启动不止一个sh)。

在某些系统上,您可以使用:

find . -print0 | xargs -r0 something with

虽然具有少许优于标准语法和装置somethingstdin要么是管或/dev/null

您可能要使用的一个原因可能是使用-PGNU选项xargs进行并行处理。该stdin问题也可以通过GNU来解决xargs,该-a选项带有支持进程替换的shell:

xargs -r0n 20 -P 4 -a <(find . -print0) something

例如,最多运行4个并发调用,something每个调用都带有20个文件参数。

使用zshbash,另一种循环输出的方法find -print0是:

while IFS= read -rd '' file <&3; do
  something "$file" 3<&-
done 3< <(find . -print0)

read -d '' 读取NUL分隔记录,而不是换行分隔记录。

bash-4.4及以上版本还可以将find -print0数组返回的文件存储为:

readarray -td '' files < <(find . -print0)

zsh当量(其中有保留的优点find的退出状态):

files=(${(0)"$(find . -print0)"})

使用zsh,您可以将大多数find表达式转换为递归glob和glob限定符的组合。例如,循环find . -name '*.txt' -type f -mtime -1将是:

for file (./**/*.txt(ND.m-1)) cmd $file

要么

for file (**/*.txt(ND.m-1)) cmd -- $file

(注意的需要的--,与**/*,文件路径不与开始./,所以可以与启动-例如)。

ksh93bash最终增加了对**/(尽管没有更多的递归形式的递归glob)支持,但仍然没有glob限定符,这使此处的使用**非常有限。还要注意,bash降级目录树时,4.3之前的版本会遵循符号链接。

像循环一样$(find .),这也意味着将整个文件列表存储在内存1中。在某些情况下,当您不希望对文件执行的操作对文件的查找有影响时(例如在添加更多可能最终被自己发现的文件时),这可能是理想的。

其他可靠性/安全性考虑

比赛条件

现在,如果我们谈论可靠性,我们必须提到时间find/ zsh找到文件并检查文件是否符合标准以及使用时间之间的竞争条件(TOCTOU race)。

即使降落在目录树上,也必须确保不遵循符号链接,并且在没有TOCTOU竞争的情况下这样做。find(GNU find至少)不会通过使用开放目录,openat()用正确的O_NOFOLLOW标志(如果支持),并保持文件描述符打开每个目录,zsh/ bash/ ksh不这样做。因此,面对攻击者能够在正确的时间用符号链接替换目录的情况,您最终可能会得出错误的目录。

即使find不恰当地降低目录,以-exec cmd {} \;更应如此有-exec cmd {} +,一旦cmd被执行,例如作为cmd ./foo/barcmd ./foo/bar ./foo/bar/baz由时间cmd利用的./foo/bar,属性bar可能不再符合条件匹配的find,但更糟的是,./foo可能已经替换为到其他位置的符号链接(并且-exec {} +find等待有足够文件调用的地方,竞赛窗口变得更大了cmd)。

一些find实现具有(尚未标准化的)-execdir谓词来缓解第二个问题。

拥有:

find . -execdir cmd -- {} \;

find chdir()在运行之前进入文件的父目录cmd。而不是调用cmd -- ./foo/bar,而是调用cmd -- ./barcmd -- bar在某些实现中,因此是--),因此./foo避免了更改为符号链接的问题。这样可以使用rm更安全的命令(它仍然可以删除其他文件,但不能删除其他目录中的文件),但是除非这些文件被设计为不遵循符号链接,否则它们不能修改这些文件的命令。

-execdir cmd -- {} +有时也可以使用,但是在一些实现中包括GNU的一些版本find,它等同于-execdir cmd -- {} \;

-execdir 还具有解决与目录树太深相关的一些问题的好处。

在:

find . -exec cmd {} \;

给定路径的大小cmd将随着文件所在目录的深度而增加。如果该大小变得更大PATH_MAX(类似于Linux上的4k),则cmd在该路径上执行的任何系统调用都会失败并显示ENAMETOOLONG错误。

使用时-execdir,仅文件名(可能带有./)被传递到cmd。在大多数文件系统上,文件名本身的限制(NAME_MAX)比更低得多PATH_MAX,因此ENAMETOOLONG不太可能遇到该错误。

字节与字符

另外,在考虑安全性时find,通常在处理文件名时通常会忽略一个事实,即在大多数类Unix系统上,文件名是字节序列(文件路径中除0以外的任何字节值,在大多数系统上,(基于ASCII的字符,我们现在将忽略基于EBCDIC的罕见字符)(0x2f是路径分隔符)。

由应用程序决定是否将这些字节视为文本。它们通常这样做,但是通常从字节到字符的转换是根据用户的语言环境(基于环境)完成的。

这意味着给定的文件名可能会根据区域设置具有不同的文本表示形式。例如,字节序列63 f4 74 e9 2e 74 78 74côté.txt用于在字符集为ISO-8859-1 cєtщ.txt的语言环境中以及在字符集为IS0-8859-5的语言环境中解释该文件名的应用程序。

更差。在字符集为UTF-8(当今的规范)的语言环境中,根本无法将63 f4 74 e9 2e 74 78 74映射到字符!

find是一个这样的应用程序,它将文件名视为其-name/ -path谓词的文本(以及更多类似-iname-regex带有某些实现的文本)。

这意味着例如具有多个find实现(包括GNU find)。

find . -name '*.txt'

63 f4 74 e9 2e 74 78 74在UTF-8语言环境中调用时,将无法在上方找到我们的文件,因为*(匹配0个或更多字符,而不是字节)不能匹配那些非字符。

LC_ALL=C find... 可以解决该问题,因为C语言环境意味着每个字符一个字节,并且(通常)保证所有字节值都映射到一个字符(尽管某些字节值可能是未定义的)。

现在,当涉及从外壳循环这些文件名时,该字节与字符之间的关系也会成为问题。在这方面,我们通常会看到4种主要的外壳类型:

  1. 仍不支持多字节的类似dash。对于他们来说,一个字节映射到一个字符。例如,在UTF-8中côté为4个字符,但为6个字节。在以UTF-8为字符集的语言环境中,

    find . -name '????' -exec dash -c '
      name=${1##*/}; echo "${#name}"' sh {} \;
    

    find将成功找到名称由UTF-8编码的4个字符组成的文件,但dash报告的长度在4到24之间。

  2. yash:相反。它只处理字符。它需要的所有输入都在内部翻译为字符。它构成了最一致的shell,但也意味着它不能应付任意字节序列(那些不转换为有效字符的序列)。即使在C语言环境中,它也无法处理0x7f以上的字节值。

    find . -exec yash -c 'echo "$1"' sh {} \;
    

    例如,在UTF-8语言环境中使用ISO-8859-1失败côté.txt

  3. 那些类似的bashzsh已逐步添加多字节支持的地方。这些将退回到考虑不能像字符一样映射到字符的字节。他们仍然在这里和那里仍然有一些错误,特别是对于不常见的多字节字符集,例如GBK或BIG5-HKSCS(它们非常讨厌,因为它们的许多多字节字符包含0-127范围内的字节(例如ASCII字符) )。

  4. 类似于shFreeBSD的(至少11个)或mksh -o utf8-mode支持多字节但仅适用于UTF-8的那些。

笔记

1为了完整起见,我们可以提到一种zsh使用递归glob遍历文件而不将整个列表存储在内存中的骇人方法:

process() {
  something with $REPLY
  false
}
: **/*(ND.m-1+process)

+cmd是一个glob限定符,它cmd使用中的当前文件路径调用(通常是一个函数)$REPLY。该函数返回true或false以决定是否应选择文件(并且还可以修改$REPLY或返回$reply数组中的多个文件)。在这里,我们在该函数中进行处理并返回false,因此不会选择该文件。


如果可以使用zsh和bash,最好只使用globlob和shell构造,而不要尝试扭曲find安全行为。默认情况下,通配是安全的,而查找默认情况下是不安全的。
凯文

@Kevin,请参阅编辑。
斯特凡Chazelas

182

为什么循环find输出不好的做法?

简单的答案是:

因为文件名可以包含任何字符。

因此,没有可以可靠地用来分隔文件名的可打印字符。


换行符经常使用(错误地)来分隔文件名,因为它是不寻常的,以包括在文件名中换行符。

但是,如果您根据任意假设构建软件,则充其量只能充其量处理异常情况,最坏的情况是容易受到恶意利用,从而无法控制系统。因此,这是坚固性和安全性的问题。

如果您可以用两种不同的方式编写软件,并且其中一种可以正确处理极端情况(异常输入),但另一种则更易于阅读,则您可能会认为这是一个折衷方案。(我不会。我更喜欢正确的代码。)

但是,如果正确且健壮的代码版本易于阅读,则没有理由编写在极端情况下失败的代码。就是这种情况,find并且需要在找到的每个文件上运行命令。


让我们更具体一些:在UNIX或Linux系统上,文件名可以包含除/(用作路径组件分隔符)之外的任何字符,并且它们不能包含空字节。

因此,空字节是分隔文件名的唯一正确方法。


由于GNU find包含一个-print0主节点,该主节点将使用空字节来分隔其打印的文件名,因此GNU find 可以安全地与GNU xargs及其-0标志(和-r标志)一起使用来处理以下输出find

find ... -print0 | xargs -r0 ...

但是,没有充分的理由使用此表单,因为:

  1. 它添加了对GNU findutils的依赖,该依赖不需要存在,并且
  2. find设计为能够在其上找到该文件运行命令。

另外,GNU xargs需要-0-r,而FreeBSD xargs仅需要-0(并且没有-r选项),有些xargs根本不支持-0。因此,最好坚持使用的POSIX功能find(请参阅下一节)并跳过xargs

关于第2点- find能够在找到的文件上运行命令的能力-我认为Mike Loukides说得最好:

find的业务是评估表达式-而不是查找文件。是的,find当然可以找到文件;但这实际上只是副作用。

--Unix电动工具


POSIX指定的用途 find

为每个find结果运行一个或多个命令的正确方法是什么?

要为找到的每个文件运行一个命令,请使用:

find dirname ... -exec somecommand {} \;

要为找到的每个文件依次运行多个命令(仅在第一个命令成功的情况下才应运行第二个命令),请使用:

find dirname ... -exec somecommand {} \; -exec someothercommand {} \;

要一次对多个文件运行一个命令:

find dirname ... -exec somecommand {} +

find 与...结合 sh

如果需要在命令中使用shell功能,例如重定向输出或从文件名中删除扩展名或类似内容,则可以使用该sh -c构造。您应该了解以下几点:

  • 切勿{}直接将其嵌入sh代码中。这允许从恶意制作的文件名中任意执​​行代码。另外,POSIX实际上甚至没有指定它可以工作。(请参阅下一点。)

  • 不要{}多次使用,也不要将它用作较长参数的一部分。 这不是便携式的。例如,不要这样做:

    find ... -exec cp {} somedir/{}.bak \;

    引用POSIX规范find

    如果某个Utility_name参数字符串包含两个字符“ {}”,而不仅仅是两个字符“ {}”,则由find替换这两个字符还是使用该字符串而无需更改由实现定义。

    ...如果存在多个包含两个字符“ {}”的参数,则行为未指定。

  • 传递给该-c选项的shell命令字符串后面的参数设置为shell的位置参数,以开头$0。不以开头$1

    因此,最好包含一个“虚拟” $0值,例如find-sh,该值将用于在生成的shell中进行错误报告。而且,这允许使用构造,例如"$@"在将多个文件传递到shell时,而省略值$0将意味着传递的第一个文件将设置为$0,因此不包含在中"$@"


要为每个文件运行一个shell命令,请使用:

find dirname ... -exec sh -c 'somecommandwith "$1"' find-sh {} \;

但是,在shell循环中处理文件通常会提供更好的性能,这样就不会为找到的每个文件生成shell:

find dirname ... -exec sh -c 'for f do somecommandwith "$f"; done' find-sh {} +

(请注意,for f do它等效于for f in "$@"; do并依次处理每个位置参数-换句话说,它使用由找到的每个文件find,而不管名称中是否有特殊字符。)


正确find用法的其他示例:

(注意:请随意扩展此列表。)


5
在一种情况下,我不知道解析find输出的另一种方法-您需要在当前shell中为每个文件运行命令(例如,因为要设置变量)。在这种情况下,while IFS= read -r -u3 -d '' file; do ... done 3< <(find ... -print0)是我所知道的最好的成语。注意:<( )不可移植-使用bash或zsh。此外,-u33<在那里的情况下,任何内部循环试图读取标准输入。
Gordon Davisson

1
@GordonDavisson,也许-但是您需要为这些变量设置什么呢?我认为,不管它是应该如何处理内部find ... -exec呼叫。或者,如果它可以处理您的用例,则仅使用Shell Glob。
2016年

1
我经常想在处理文件后打印摘要(“转换了2个,跳过了3个,以下文件有错误:...”),并且这些计数/列表必须累积在shell变量中。另外,在某些情况下,我想创建一个文件名数组,这样我可以执行比按顺序进行迭代更复杂的事情(在这种情况下为filelist=(); while ... do filelist+=("$file"); done ...)。
Gordon Davisson

3
您的回答是正确的。但是我不喜欢教条。尽管我知道的更多,但是在许多(特别是交互式)用例中,它是安全的,并且在find输出中键入循环更容易,甚至更糟地使用ls。我每天都这样做,没有问题。我知道各种工具的-print0,-null,-z或-0选项。但是除非真正需要,否则我不会浪费时间在交互式shell提示符下使用它们。这也可以在您的答案中注明。
rudimeier

16
@rudimeier,关于教条与最佳实践的争论已经to折。没兴趣。如果您以交互方式使用它并且可以正常工作,那么对您有好处,但是我不会促进这样做。脚本作者不愿学习健壮的代码,然后在编写生产脚本时才这样做,而不是仅仅做他们惯用的交互操作,所占的比例非常小。处理是为了始终推广最佳实践。人们需要学习有正确的做事方法。
2016年

10

该答案适用于非常大的结果集,并且主要涉及性能,例如,通过慢速网络获取文件列表时。对于少量文件(例如,本地磁盘上的几百个甚至1000个文件),大多数情况都没有意义。

并行性和内存使用

除了给出的其他与分离问题有关的答案外,还有另一个问题

for file in `find . -type f -name ...`; do smth with ${file}; done

反引号内的部分必须首先进行完全评估,然后才能在换行符上进行分割。这意味着,如果您获得大量文件,它可能会阻塞各种组件中存在的大小限制;如果没有限制,则可能会耗尽内存;在任何情况下,您都必须等待整个列表输出find,然后再通过解析,for甚至不能运行第一个smth

首选的unix方法是使用管道,这些管道本质上是并行运行的,并且通常也不需要任意大的缓冲区。这意味着:您非常希望可以与find并行运行smth,并且仅将当前文件名保留在RAM中,同时将其移交给smth

前述的一种至少部分可行的解决方案find -exec smth。它消除了将所有文件名保留在内存中的需求,并且可以很好地并行运行。不幸的是,它也smth为每个文件启动一个进程。如果smth只能处理一个文件,则必须这样。

如果可能的话,最佳解决方案是find -print0 | smthsmth并且能够在其STDIN上处理文件名。这样,smth无论有多少文件,您都只有一个进程,并且两个进程之间只需要缓冲少量字节(无论正在进行什么内部管道缓冲)。当然,如果smth是标准的Unix / POSIX命令,这是不现实的,但是如果您自己编写,则可能是一种方法。

如果这不可能,那么find -print0 | xargs -0 smth很可能是更好的解决方案之一。正如注释中提到的@ dave_thompson_085一样,xargs确实smth在达到系统限制时(默认情况下,在128 KB范围内或exec系统施加的任何限制)的多个运行过程中将参数拆分开,并且具有影响多少个选项的选项。将文件提供给的一个调用smth,从而在smth进程数量和初始延迟之间找到平衡。

编辑:删除了“最佳”的概念-很难说是否会有更好的东西出现。;)


find ... -exec smth {} +是解决方案。
通配符

find -print0 | xargs smth根本不起作用,但是find -print0 | xargs -0 smth(note -0)或find | xargs smth文件名不带空格引号或反斜杠运行的smth文件名中包含尽可能多的可用文件名,并放在一个参数列表中;如果超过maxargs,它将运行smth所需次数以处理所有给定的args(无限制)。您可以使用设置较小的“块”(因此,并行度要早一些)-L/--max-lines -n/--max-args -s/--max-chars
dave_thompson_085 '16


4

原因之一是空格在作品中抛出了一个扳手,使文件“ foo bar”被评估为“ foo”和“ bar”。

$ ls -l
-rw-rw-r-- 1 ec2-user ec2-user 0 Nov  7 18:24 foo bar
$ for file in `find . -type f` ; do echo filename $file ; done
filename ./foo
filename bar
$

如果使用-exec可以正常工作

$ find . -type f -exec echo filename {} \;
filename ./foo bar
$ find . -type f -exec stat {} \;
  File: ‘./foo bar’
  Size: 0               Blocks: 0          IO Block: 4096   regular empty file
Device: ca01h/51713d    Inode: 9109        Links: 1
Access: (0664/-rw-rw-r--)  Uid: (  500/ec2-user)   Gid: (  500/ec2-user)
Access: 2016-11-07 18:24:42.027554752 +0000
Modify: 2016-11-07 18:24:42.027554752 +0000
Change: 2016-11-07 18:24:42.027554752 +0000
 Birth: -
$

特别是在find由于可以在每个文件上执行命令的情况下,它很容易成为最佳选择。
Centimane

1
还要考虑-exec ... {} \;-exec ... {} +
thrig

1
如果您使用它,即使是空格也可以使用for file in "$(find . -type f)" echo "${file}"但我猜其他特殊字符也会引起更多麻烦
mazs'Nov

9
@mazs-不,引用不符合您的想法。在包含多个文件的目录中,请尝试for file in "$(find . -type f)";do printf '%s %s\n' name: "${file}";done(根据您的情况)在每个文件名的前一行打印每个文件名name:。没有。
don_crissti

2

因为任何命令的输出都是单个字符串,但是循环需要一个字符串数组来循环。它“起作用”的原因是外壳出卖了您为空格分割的字符串。

其次,除非您需要的特定功能,否则请find注意,您的shell很可能已经可以自己扩展递归glob模式,并且至关重要的是,它将扩展为适当的数组。

重击示例:

shopt -s nullglob globstar
for i in **
do
    echo «"$i"»
done

同样在鱼:

for i in **
    echo «$i»
end

如果确实需要使用的功能find,请确保仅在NUL上分割(例如find -print0 | xargs -r0成语)。

鱼可以迭代NUL分隔的输出。所以这个实际上不错:

find -print0 | while read -z i
    echo «$i»
end

最后一点,在许多外壳程序中(当然不是Fish),循环执行命令输出将使循环体成为子外壳程序(这意味着您不能以循环终止后可见的任何方式设置变量),即从来没有你想要的。


准确地@don_crissti。通常它不起作用。我试图讽刺地说它“有效”(带引号)。
user2394284 '16

请注意,递归globe起源于zsh90年代初(尽管您需要**/*在那里)。fish就像早期bash等效功能的实现一样,当下降目录树时也会遵循符号链接。有关实现之间的差异请参见ls *,ls **和ls ***的结果
斯特凡Chazelas

1

遍历find的输出不是一个坏习惯-什么坏习惯(在这种情况下和所有情况下)都是假设您的输入是一种特定格式,而不是知道(测试并确认)这是一种特定格式。

tldr / cbf: find | parallel stuff

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.