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

don*_*sti 193 find filenames files for

这个问题的灵感来自

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

我看到这些结构

for file in `find . -type f -name ...`; do smth with ${file}; done
Run Code Online (Sandbox Code Playgroud)

for dir in $(find . -type d -name ...); do smth with ${dir}; done
Run Code Online (Sandbox Code Playgroud)

几乎每天都在这里使用,即使有些人花时间对这些帖子发表评论,解释为什么应该避免这种东西......
看到此类帖子的数量(以及有时这些评论被简单地忽略的事实)我想我还不如问一个问题:

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

Wil*_*ard 197

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

简单的答案是:

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

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


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

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

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

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


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

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


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

find ... -print0 | xargs -r0 ...
Run Code Online (Sandbox Code Playgroud)

但是,没有充分的理由使用这种形式,因为:

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

此外,GNUxargs需要-0and -r,而 FreeBSDxargs只需要-0(并且没有-r选项),有些xargs根本不支持-0。所以最好只坚持find(见下一节)的POSIX 特性并跳过xargs.

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

find的业务是评估表达式——而不是定位文件。是的,find当然可以定位文件;但这真的只是一个副作用。

--Unix 电动工具


POSIX 指定的用途 find

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

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

find dirname ... -exec somecommand {} \;
Run Code Online (Sandbox Code Playgroud)

要为找到的每个文件依次运行多个命令,只有在第一个命令成功时才应运行第二个命令,请使用:

find dirname ... -exec somecommand {} \; -exec someothercommand {} \;
Run Code Online (Sandbox Code Playgroud)

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

find dirname ... -exec somecommand {} +
Run Code Online (Sandbox Code Playgroud)

find 结合 sh

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

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

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

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

    引用POSIX 规范find

    如果实用程序名称参数字符串包含两个字符“{}”,而不仅仅是两个字符“{}”,则find 是替换这两个字符还是不更改地使用字符串由实现定义。

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

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

    出于这个原因,最好包含一个“虚拟”$0值,例如find-sh,它将用于从生成的 shell 中报告错误。此外,这允许使用构建体如"$@"多个文件传递到外壳时,而省略值$0意味着通过了第一个文件将被设置为$0并且因此不包括在"$@"


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

find dirname ... -exec sh -c 'somecommandwith "$1"' find-sh {} \;
Run Code Online (Sandbox Code Playgroud)

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

find dirname ... -exec sh -c 'for f do somecommandwith "$f"; done' find-sh {} +
Run Code Online (Sandbox Code Playgroud)

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


正确find用法的更多示例:

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

  • @rudimeier,关于教条与最佳实践的争论已经[完结](http://unix.stackexchange.com/q/128985/135943)。没兴趣。如果您以交互方式使用它并且它有效,那很好,对您有好处——但我不会提倡这样做。脚本作者费心去了解什么是健壮的代码,然后在编写生产脚本时*只这样做*,而不是只做他们*习惯*交互式地做的任何事情,这是极少的。处理是为了*始终促进最佳实践。*人们需要了解**有正确的做事方法。** (20认同)
  • 在一种情况下,我不知道解析 `find` 输出的替代方法——在这种情况下,您需要*在当前 shell* 中为每个文件运行命令(例如,因为您想设置变量)。在这种情况下,`while IFS= read -r -u3 -d '' 文件;do ... done 3< <(find ... -print0)` 是我所知道的最好的习语。注意:`<()` 不可移植——使用 bash 或 zsh。此外,`-u3` 和 `3<` 存在,以防循环内的任何内容尝试读取标准输入。 (5认同)
  • 你的回答是正确的。但是我不喜欢教条。尽管我知道得更好,但有许多(特别是交互式的)用例是安全的,并且更容易在 `find` 输出上输入循环,或者使用 `ls` 甚至更糟。我每天都这样做,没有问题。我知道各种工具的 -print0、--null、-z 或 -0 选项。但除非真的需要,否则我不会浪费时间在我的交互式 shell 提示中使用它们。这也可以在您的回答中注明。 (3认同)
  • @AdrianPronk 不,因为没有“in”。显然,“for f in abc do do do echo”不起作用。但是“for f do echo;done”就可以了。正如“for f in abc do do; do echo”。 (2认同)

Sté*_*las 114

问题

for f in $(find .)
Run Code Online (Sandbox Code Playgroud)

结合了两个不相容的东西。

find打印由换行符分隔的文件路径列表。当您$(find .)在该列表上下文中不加引号时调用的 split+glob 运算符将其拆分为$IFS(默认情况下包括换行符,但也包括空格和制表符 (以及 NUL in zsh)) 并在每个结果单词上执行通配符(除了in 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
Run Code Online (Sandbox Code Playgroud)

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

这也意味着 shell 需要存储findfull的输出,然后在开始循环文件之前 split+glob 它(这意味着第二次将该输出存储在内存中)。

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

更正确的选择

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

IFS=$'\0'
for f in $(find . -print0)
Run Code Online (Sandbox Code Playgroud)

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

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

find . -exec something with {} \;
Run Code Online (Sandbox Code Playgroud)

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

find . -exec something with {} +
Run Code Online (Sandbox Code Playgroud)

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

find . -exec sh -c '
  for file do
    something < "$file"
  done' find-sh {} +
Run Code Online (Sandbox Code Playgroud)

(当心它可能开始不止一个sh)。

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

find . -print0 | xargs -r0 something with
Run Code Online (Sandbox Code Playgroud)

尽管这与标准语法相比没有什么优势,并且意味着something'sstdin要么是管道要么是/dev/null.

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

xargs -r0n 20 -P 4 -a <(find . -print0) something
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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

readarray -td '' files < <(find . -print0)
Run Code Online (Sandbox Code Playgroud)

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

files=(${(0)"$(find . -print0)"})
Run Code Online (Sandbox Code Playgroud)

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

for file (./**/*.txt(ND.m-1)) cmd $file
Run Code Online (Sandbox Code Playgroud)

或者

for file (**/*.txt(ND.m-1)) cmd -- $file
Run Code Online (Sandbox Code Playgroud)

(注意--as with的需要**/*,文件路径不以 开头./,因此可能以-例如开头)。

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

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

其他可靠性/安全性考虑

比赛条件

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

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

即使find确实正确下降了目录,-exec cmd {} \;甚至更是如此-exec cmd {} +,一旦cmd被执行,例如 ascmd ./foo/barcmd ./foo/bar ./foo/bar/baz,到时候cmd使用./foo/bar, 的属性bar可能不再满足匹配的标准find,但更糟糕的是,./foo可能已经由指向其他地方的符号链接替换(并且比赛窗口变得更大-exec {} +find等待有足够的文件可以调用cmd)。

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

和:

find . -execdir cmd -- {} \;
Run Code Online (Sandbox Code Playgroud)

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

-execdir cmd -- {} +有时也能工作,但有几个实现,包括某些版本的 GNU find,它相当于-execdir cmd -- {} \;.

-execdir 还可以解决一些与目录树过深相关的问题。

在:

find . -exec cmd {} \;
Run Code Online (Sandbox Code Playgroud)

给定的路径的大小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'
Run Code Online (Sandbox Code Playgroud)

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 {} \;
    
    Run Code Online (Sandbox Code Playgroud)

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

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

     find . -exec yash -c 'echo "$1"' sh {} \;
    
    Run Code Online (Sandbox Code Playgroud)

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

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

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

笔记

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

process() {
  something with $REPLY
  false
}
: **/*(ND.m-1+process)
Run Code Online (Sandbox Code Playgroud)

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


Ano*_*noE 12

此答案适用于非常大的结果集,主要关注性能,例如在通过慢速网络获取文件列表时。对于少量文件(比如本地磁盘上的几个 100 甚至 1000 个),大部分都是没有实际意义的。

并行度和内存使用

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

for file in `find . -type f -name ...`; do smth with ${file}; done
Run Code Online (Sandbox Code Playgroud)

反引号内的部分必须首先完全评估,然后在换行符处拆分。这意味着,如果您获得大量文件,它可能会因各种组件中存在的任何大小限制而窒息;如果没有限制,您可能会耗尽内存;并且在任何情况下,您都必须等到整个列表被输出find然后被解析,for然后才能运行您的第一个smth.

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

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

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

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

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

  • `find ... -exec smth {} +` 是解决方案。 (2认同)
  • 相关:[递归grep vs find / -type f -exec grep {} \; 哪个更高效/更快?](//unix.stackexchange.com/a/131576) (2认同)

ste*_*eve 5

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

\n\n
$ ls -l\n-rw-rw-r-- 1 ec2-user ec2-user 0 Nov  7 18:24 foo bar\n$ for file in `find . -type f` ; do echo filename $file ; done\nfilename ./foo\nfilename bar\n$\n
Run Code Online (Sandbox Code Playgroud)\n\n

如果使用 -exec 则工作正常

\n\n
$ find . -type f -exec echo filename {} \\;\nfilename ./foo bar\n$ find . -type f -exec stat {} \\;\n  File: \xe2\x80\x98./foo bar\xe2\x80\x99\n  Size: 0               Blocks: 0          IO Block: 4096   regular empty file\nDevice: ca01h/51713d    Inode: 9109        Links: 1\nAccess: (0664/-rw-rw-r--)  Uid: (  500/ec2-user)   Gid: (  500/ec2-user)\nAccess: 2016-11-07 18:24:42.027554752 +0000\nModify: 2016-11-07 18:24:42.027554752 +0000\nChange: 2016-11-07 18:24:42.027554752 +0000\n Birth: -\n$\n
Run Code Online (Sandbox Code Playgroud)\n

  • @mazs - 不,引用并不符合你的想法。在包含多个文件的目录中,尝试 `for file in "$(find . -type f)";do printf '%s %s\n' name: "${file}";done` 应该(根据您的说法)在单独的行上打印每个文件名,前面加上“name:”。事实并非如此。 (10认同)

归档时间:

查看次数:

26792 次

最近记录:

4 年,5 月 前