Files

為什麼循環查找的輸出是不好的做法?

  • January 24, 2022

這個問題的靈感來自

為什麼使用 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什麼?

問題

for f in $(find .)

結合了兩個不相容的東西。

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

這仍然是錯誤的,因為換行符與文件路徑中的任何字元一樣有效。的輸出find -print根本不能可靠地進行後處理(除非使用一些複雜的技巧,如此處所示)。

這也意味著shell需要find完全儲存輸出,然後在開始循環文件之前將其拆分+全域(這意味著將該輸出第二次儲存在記憶體中)。

請注意,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 {} +

如果您確實需要由 shell 處理的文件列表:

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

(注意它可能會啟動多個sh)。

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

find . -print0 | xargs -r0 something with

儘管這與標準語法相比幾乎沒有優勢,並且意味著something’sstdin要麼是管道,要麼是/dev/null.

您可能想要使用它的一個原因可能是使用-PGNU 的選項xargs進行並行處理。該stdin問題也可以通過 GNUxargs使用-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表達式轉換為遞歸萬用字元與 glob 限定符的組合。例如,循環find . -name '*.txt' -type f -mtime -1將是:

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

或者

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

(請注意--as with的需要**/*,文件路徑不是以 開頭的./,因此可能以-例如開頭)。

ksh93bash最終增加了對**/(雖然不是更多的遞歸 globbing 形式)的支持,但仍然不是 glob 限定符,這使得在**那裡的使用非常有限。另請注意,bash在 4.3 之前的版本中,在目錄樹下降時會遵循符號連結。

就像 for looping over 一樣$(find .),這也意味著將整個文件列表儲存在記憶體中¹。這可能是可取的,但在某些情況下,當您不希望您對文件的操作影響文件的查找時(例如當您添加更多可能最終自己被發現的文件時)。

其他可靠性/安全性考慮

比賽條件

現在,如果我們談論可靠性,我們必須提到時間find/zsh找到文件並檢查它是否符合標準以及它被使用的時間之間的競爭條件(TOCTOU 競爭)。

即使在下降目錄樹時,也必須確保不遵循符號連結並且在沒有 TOCTOU 競爭的情況下這樣做。(至少findGNU )通過使用正確的標誌(在支持的情況下)打開目錄並為每個目錄保持打開文件描述符來做到這一點,//不要這樣做。因此,面對能夠在正確的時間用符號連結替換目錄的攻擊者,您最終可能會下降到錯誤的目錄。find``openat()``O_NOFOLLOW``zsh``bash``ksh

即使find確實正確地下降了目錄, with-exec cmd {} \;甚至更是如此-exec cmd {} +,一旦cmd被執行,例如當cmd ./foo/barcmd ./foo/bar ./foo/bar/baz時,當cmd使用 時./foo/bar, 的屬性bar可能不再滿足匹配的條件find,但更糟糕的是,./foo可能已經替換為指向其他地方的符號連結(並且比賽視窗變得更大,-exec {} +因為 where findwaits to have enough files to call cmd)。

一些find實現有一個(非標準的)-execdir謂詞來緩解第二個問題。

和:

find . -execdir cmd -- {} \;

find chdir()s 執行前進入文件的父目錄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實現(包括 GNUfind系統上的 GNU²)。

find . -name '*.txt'

63 f4 74 e9 2e 74 78 74在 UTF-8 語言環境中呼叫時找不到上面的文件,因為*(匹配 0 個或多個字元,而不是字節)無法匹配那些非字元。

LC_ALL=C find...可以解決這個問題,因為 C 語言環境意味著每個字元一個字節,並且(通常)保證所有字節值都映射到一個字元(儘管對於某些字節值可能是未定義的)。

現在,當涉及到從外殼循環這些文件名時,字節與字元也可能成為問題。在這方面,我們通常會看到 4 種主要類型的 shell:

  1. 那些仍然不支持多字節的,比如dash. 對於他們來說,一個字節映射到一個字元。例如,在 UTF-8 中,côté是 4 個字元,但 6 個字節。在 UTF-8 是字元集的語言環境中,在
find . -name '????' -exec dash -c '
  name=${1##*/}; echo "${#name}"' sh {} \;

find將成功找到名稱由 4 個以 UTF-8 編碼的字元組成的文件,但dash會報告長度在 4 到 24 之間的文件。

  1. yash: 相反。它只處理字元。它需要的所有輸入都在內部轉換為字元。它提供了最一致的外殼,但這也意味著它無法處理任意字節序列(那些不轉換為有效字元的字節序列)。即使在 C 語言環境中,它也無法處理高於 0x7f 的字節值。
find . -exec yash -c 'echo "$1"' sh {} \;

例如,在 UTF-8 語言環境中,我們的 ISO-8859-1 將失敗côté.txt

  1. 那些喜歡bashzsh逐步添加多字節支持的人。這些將回退到考慮不能像字元一樣映射到字元的字節。他們仍然到處都有一些錯誤,特別是對於不太常見的多字節字元集,如 GBK 或 BIG5-HKSCS(這些非常討厭,因為它們的許多多字節字元包含 0-127 範圍內的字節(如 ASCII 字元) )。
  2. 那些像shFreeBSD(至少 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相反的,它可能會產生嚴重的後果。


筆記

¹ 為了完整起見,我們可以提到一種zsh使用遞歸全域遍歷文件而不將整個列表儲存在記憶體中的 hacky 方法:

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

+cmd是一個 glob 限定符,它cmd使用$REPLY. 該函式返回 true 或 false 以決定是否應該選擇文件(也可以修改或返回數組$REPLY中的多個文件)。$reply在這裡,我們在該函式中進行處理並返回 false,因此不選擇文件。

² GNUfind使用系統的fnmatch()libc 函式進行模式匹配,因此那裡的行為取決於該函式如何處理非文本數據。

為什麼循環find輸出是不好的做法?

簡單的答案是:

因為文件名可以包含任何字元。

因此,沒有可以可靠地用於分隔文件名的可列印字元。


換行符經常(錯誤地)用於分隔文件名,因為在文件名中包含換行符是不常見的。

但是,如果您圍繞任意假設建構軟體,您充其量只是無法處理不尋常的情況,而最壞的情況是讓自己暴露於惡意攻擊,從而失去對系統的控制權。所以這是一個穩健性和安全性的問題。

如果您可以用兩種不同的方式編寫軟體,其中一種正確處理邊緣情況(異常輸入),但另一種更易於閱讀,您可能會爭辯說這是一種權衡。(我不會。我更喜歡正確的程式碼。)

但是,如果正確、健壯的程式碼版本易於閱讀,則沒有理由編寫在邊緣情況下失敗的程式碼。情況就是這樣,find並且需要對找到的每個文件執行命令。


讓我們更具體一點:在 UNIX 或 Linux 系統上,文件名可以包含除 a /(用作路徑組件分隔符)之外的任何字元,並且它們可能不包含空字節。

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


由於 GNUfind包含一個-print0將使用空字節來分隔它列印的文件名的主節點,因此 GNUfind 可以安全地與 GNUxargs及其-0標誌(和-r標誌)一起使用來處理以下輸出find

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

但是,沒有充分的理由使用這種形式,因為:

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

要為找到的每個文件按順序執行多個命令,其中只有在第一個命令成功時才應執行第二個命令,請使用:

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

一次對多個文件執行單個命令:

find dirname ... -exec somecommand {} +

find結合sh

如果您需要在命令中使用shell功能,例如重定向輸出或從文件名中刪除副檔名或類似的東西,您可以使用該sh -c構造。你應該知道一些事情:

  • 永遠不要{}直接嵌入sh程式碼中。這允許從惡意製作的文件名執行任意程式碼。此外,POSIX 實際上甚至沒有指定它可以工作。(見下一點。)
  • 不要{}多次使用,或將其用作較長參數的一部分。 這不是攜帶式的。例如,不要這樣做:

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

引用POSIX 規範find

如果實用程序名稱參數字元串包含兩個字元“{}”,而不僅僅是兩個字元“{}”,則由實現定義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用法範例:

(注意:請隨意擴展此列表。)

引用自:https://unix.stackexchange.com/questions/321697