问题

问题

这个问题的灵感来自于

为什么使用 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

答案1

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

简单的答案是:

因为文件名可以包含任何特点。

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


换行符是经常用于(错误地)分隔文件名,因为它是异常在文件名中包含换行符。

但是,如果您围绕任意假设构建软件,那么您充其量只是无法处理异常情况,而最坏的情况是容易遭受恶意攻击,从而失去对系统的控制权。所以这是一个稳健性和安全性的问题。

如果您可以用两种不同的方式编写软件,其中一种可以正确处理边缘情况(不寻常的输入),但另一种更易于阅读,您可能会认为这是一种权衡。 (我不会。我更喜欢正确的代码。)

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


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

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


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

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

然而,并没有什么好的办法原因使用这种形式,因为:

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

另外,GNUxargs需要-0-r,而 FreeBSDxargs仅需要-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

如果你需要使用命令中的功能,例如重定向输出或从文件名中删除扩展名或类似的东西,您可以使用该sh -c构造。关于这件事你应该知道一些事情:

  • 绝不{}直接嵌入到sh代码中。这允许从恶意制作的文件名执行任意代码。而且,实际上 POSIX 甚至没有指定它可以工作。 (参见下一点。)

  • 不要{}多次使用,或将其用作较长参数的一部分。 这不可移植。例如,不要这样做:

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

    引用POSIX 规范find

    如果一个实用程序名称或者争论string 包含两个字符“{}”,但不仅仅是两个字符“{}”,是否是实现定义的寻找替换这两个字符或不加更改地使用该字符串。

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

  • 传递给选项的 shell 命令字符串后面的参数-c被设置为 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用法的更多示例:

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

答案2

问题

for f in $(find .)

将两种不相容的事物结合在一起。

find打印由换行符分隔的文件路径列表。当您$(find .)在列表上下文中不加引号时,将调用 split+glob 运算符,将其拆分为 的字符$IFS(默认情况下包括换行符,但也包括空格和制表符(以及 中的 NUL zsh)),并对每个结果单词执行通配操作(除了)zsh(甚至 ksh93 中的大括号扩展(即使该braceexpand选项在旧版本中关闭)或 pdksh 衍生品!)。

即使你做到了:

IFS='
' # split on newline only
set -o noglob # disable glob (also disables brace expansion
              # done upon other expansions in ksh)
for f in $(find .) # invoke split+glob

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

这也意味着 shell 需要完全存储输出find,然后在开始循环文件之前对其进行 split+glob(这意味着在内存中再次存储该输出)。

请注意,也find . | xargs cmd有类似的问题(空白、换行符、单引号、双引号和反斜杠(以及某些xarg实现字节不构成有效字符的一部分)都是问题)

更正确的选择

for在 的输出上使用循环的唯一方法find是使用zsh支持IFS=$'\0'and :

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

(替换-print0为不支持非标准的实现(但现在很常见)-exec printf '%s\0' {} +)。find-print0

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

find . -exec something with {} \;

或者如果something可以采用多个参数:

find . -exec something with {} +

如果您确实需要由 shell 处理该文件列表:

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 个文件参数。

使用zshor bash,循环输出的另一种方法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表达式转换为递归通配符与通配符限定符的组合。例如,循环find . -name '*.txt' -type f -mtime -1将是:

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

或者

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

--(请注意与 一样的需要**/*,文件路径不是以 开头,因此可能以例如./开头)。-

ksh93bash最终添加了对(虽然不是更先进的递归通配符形式)的支持**/,但仍然没有通配符限定符,这使得在那里的使用**非常有限。另请注意,bash4.3 之前的版本在目录树下降时遵循符号链接。

与循环一样$(find .),这也意味着将整个文件列表存储在内存中。尽管在某些情况下,当您不希望对文件的操作影响到文件时,这可能是可取的发现文件数量(例如,当您添加更多文件时,这些文件最终可能会被发现)。

其他可靠性/安全考虑因素

比赛条件

现在,如果我们谈论可靠性,我们必须提到find/zsh查找文件并检查它是否符合标准和使用它的时间之间的竞争条件(托克图比赛)。

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

即使find确实正确地下降目录, with ,-exec cmd {} \;甚至更是如此-exec cmd {} +,一旦cmd执行,例如 ascmd ./foo/bar或,当使用cmd ./foo/bar ./foo/bar/baz时, 的属性可能不再满足 匹配的条件,但更糟糕的是,可能已经被替换为到其他地方的符号链接(并且竞争窗口变得更大,其中等待有足够的文件来调用)。cmd./foo/barbarfind./foo-exec {} +findcmd

一些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 74适用cô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实现(包括findGNU 系统上的 GNU)。

find . -name '*.txt'

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

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

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

  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 {} \;
    

    côté.txt例如,在 UTF-8 语言环境中,我们之前的 ISO-8859-1 将会失败。

  3. 那些喜欢bashzsh已经逐渐添加多字节支持的地方。这些将回退到考虑无法映射到字符的字节,就好像它们是字符一样。它们仍然存在一些错误,特别是对于不太常见的多字节字符集,如 GBK 或 BIG5-HKSCS(这些字符集非常令人讨厌,因为它们的许多多字节字符包含 0-127 范围内的字节(如 ASCII 字符) )。

  4. 那些像shFreeBSD 的(至少 11)或mksh -o utf8-mode支持多字节但仅支持 UTF-8 的。

输出中断

如果被中断,解析 或 的输出find甚至可能会出现另一个问题,例如因为它触发了某些限制或由于某种原因被终止。find -print0find

例子:

$ (ulimit -t 1; find / -type f -print0 2> /dev/null) | xargs -r0 printf 'rm -rf "%s"\n' | tail -n 2
rm -rf "/usr/lib/x86_64-linux-gnu/guile/2.2/ccache/language/ecmascript/parse.go"
rm -rf "/usr/"
zsh: cpu limit exceeded (core dumped)  ( ulimit -t 1; find / -type f -print0 2> /dev/null; ) |
zsh: done                              xargs -r0 printf 'rm -rf "%s"\n' | tail -n 2

此处,find由于达到了 CPU 时间限制而被中断。由于输出被缓冲(当它进入管道时),因此已将多个块输出到标准输出,并且它在被杀死时写入的最后一个块的末尾恰好位于某个文件路径find的中间,这里/usr/lib/x86_64-linux-gnu/guile...不幸的是就在之后/usr/

xargs,刚刚看到一个非分隔/usr/记录,后跟 EOF 并将其传递给printf.如果这个命令是rm -rf相反的话,可能会产生严重的后果。


笔记

1 为了完整起见,我们可以提到一种使用zsh递归通配符循环文件的巧妙方法,而不将整个列表存储在内存中:

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

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

² GNUfind使用系统的fnmatch()libc 函数来进行模式匹配,因此那里的行为取决于该函数如何处理非文本数据。

答案3

这个答案适用于非常大的结果集,主要涉及性能,例如通过慢速网络获取文件列表时。对于少量文件(比如本地磁盘上的 100 个甚至 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 | smth能够smth处理其 STDIN 上的文件名。然后,无论有多少文件,您都只有一个smth进程,并且您只需要在两个进程之间缓冲少量字节(无论正在进行什么内部管道缓冲)。当然,如果这smth是一个标准的 Unix/POSIX 命令,这是相当不现实的,但如果你自己编写它,这可能是一种方法。

如果这不可能,那么这find -print0 | xargs -0 smth可能是更好的解决方案之一。正如 @dave_thompson_085 在评论中提到的,当达到系统限制时(默认情况下,在 128 KB 的范围内或系统施加的任何限制),xargs确实会在多次运行中分割参数,并且可以选择影响多少文件被赋予一次调用,因此在进程数量和初始延迟之间找到平衡。smthexecsmthsmth

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

答案4

循环查找的输出并不是不好的做法 - 不好的做法(在这种情况下以及所有情况下)是假设您的输入是特定格式而不是会心(测试和确认)这是一种特定的格式。

tldr/cbf:find | parallel stuff

相关内容