这个问题的灵感来自于
我看到这些结构
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 ...
然而,并没有什么好的办法原因使用这种形式,因为:
- 它添加了对 GNU findutils 的依赖,而 GNU findutils 并不需要存在,并且
find
是设计的能够对它找到的文件运行命令。
另外,GNUxargs
需要-0
和-r
,而 FreeBSDxargs
仅需要-0
(并且没有-r
选项),有些根本xargs
不支持。-0
因此最好只坚持 POSIX 功能find
(请参阅下一节)并跳过xargs
.
至于第 2 点——find
在它找到的文件上运行命令的能力——我认为 Mike Loukides 说得最好:
find
的业务是评估表达式——而不是定位文件。是的,find
当然可以找到文件;但这实际上只是一个副作用。
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
尽管这比标准语法没有什么优势,并且意味着something
是stdin
管道或/dev/null
。
您可能想要使用它的原因之一可能是使用-P
GNU 选项xargs
进行并行处理。这个stdin
问题也可以通过 GNU 来解决,xargs
并使用-a
支持进程替换的 shell 选项:
xargs -r0n 20 -P 4 -a <(find . -print0) something
例如,运行最多 4 个并发调用,something
每个调用使用 20 个文件参数。
使用zsh
or 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
--
(请注意与 一样的需要**/*
,文件路径不是以 开头,因此可能以例如./
开头)。-
ksh93
并bash
最终添加了对(虽然不是更先进的递归通配符形式)的支持**/
,但仍然没有通配符限定符,这使得在那里的使用**
非常有限。另请注意,bash
4.3 之前的版本在目录树下降时遵循符号链接。
与循环一样$(find .)
,这也意味着将整个文件列表存储在内存中。尽管在某些情况下,当您不希望对文件的操作影响到文件时,这可能是可取的发现文件数量(例如,当您添加更多文件时,这些文件最终可能会被发现)。
其他可靠性/安全考虑因素
比赛条件
现在,如果我们谈论可靠性,我们必须提到find
/zsh
查找文件并检查它是否符合标准和使用它的时间之间的竞争条件(托克图比赛)。
即使在下降目录树时,也必须确保不遵循符号链接并且在没有 TOCTOU 竞争的情况下做到这一点。find
(find
至少 GNU)通过使用openat()
正确的O_NOFOLLOW
标志(如果支持)打开目录并为每个目录保持文件描述符打开来做到这一点,//zsh
不要这样做。因此,当攻击者能够在正确的时间用符号链接替换目录时,您最终可能会下降到错误的目录。bash
ksh
即使find
确实正确地下降目录, with ,-exec cmd {} \;
甚至更是如此-exec cmd {} +
,一旦cmd
执行,例如 ascmd ./foo/bar
或,当使用cmd ./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 -- ./bar
(cmd -- 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
实现(包括find
GNU 系统上的 GNU)。
find . -name '*.txt'
63 f4 74 e9 2e 74 78 74
当在 UTF-8 语言环境中调用时,将找不到上面的文件*
(匹配 0 个或多个人物,而不是字节)无法匹配那些非字符。
LC_ALL=C find...
可以解决这个问题,因为 C 语言环境意味着每个字符一个字节,并且(通常)保证所有字节值映射到一个字符(尽管对于某些字节值可能是未定义的)。
现在,当涉及到从 shell 循环这些文件名时,字节与字符也可能成为问题。在这方面,我们通常会看到 4 种主要类型的 shell:
那些仍然不支持多字节的,例如
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 之间。yash
: 相反。它只涉及人物。它所需的所有输入都会在内部转换为字符。它提供了最一致的 shell,但这也意味着它无法处理任意字节序列(那些不能转换为有效字符的字节序列)。即使在 C 语言环境中,它也无法处理高于 0x7f 的字节值。find . -exec yash -c 'echo "$1"' sh {} \;
côté.txt
例如,在 UTF-8 语言环境中,我们之前的 ISO-8859-1 将会失败。那些喜欢
bash
或zsh
已经逐渐添加多字节支持的地方。这些将回退到考虑无法映射到字符的字节,就好像它们是字符一样。它们仍然存在一些错误,特别是对于不太常见的多字节字符集,如 GBK 或 BIG5-HKSCS(这些字符集非常令人讨厌,因为它们的许多多字节字符包含 0-127 范围内的字节(如 ASCII 字符) )。那些像
sh
FreeBSD 的(至少 11)或mksh -o utf8-mode
支持多字节但仅支持 UTF-8 的。
输出中断
如果被中断,解析 或 的输出find
甚至可能会出现另一个问题,例如因为它触发了某些限制或由于某种原因被终止。find -print0
find
例子:
$ (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
确实会在多次运行中分割参数,并且可以选择影响多少文件被赋予一次调用,因此在进程数量和初始延迟之间找到平衡。smth
exec
smth
smth
编辑:删除了“最好”的概念 - 很难说是否会出现更好的东西。 ;)
答案4
循环查找的输出并不是不好的做法 - 不好的做法(在这种情况下以及所有情况下)是假设您的输入是特定格式而不是会心(测试和确认)这是一种特定的格式。
tldr/cbf:find | parallel stuff