Почему обход результатов find — это плохая практика?

Вопрос или проблема

Этот вопрос вдохновлен

Почему использование цикла оболочки для обработки текста считается плохой практикой?

Я вижу эти конструкции

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?


Почему обработка вывода find в цикле является плохой практикой?

Простой ответ:

Потому что имена файлов могут содержать любые символы.

Следовательно, нет ни одного печатного символа, который можно было бы надежно использовать для разделения имен файлов.


Новые строки часто используются (неправильно) для разделения имен файлов, потому что незачастую в именах файлов встречаются символы новой строки.

Тем не менее, если вы строите свое программное обеспечение на произвольных предположениях, вы в лучшем случае просто не обрабатываете необычные случаи, а в худшем открываете себя для злонамеренных атак, которые передают контроль над вашей системой. Так что это вопрос надежности и безопасности.

Если вы можете написать программное обеспечение двумя разными способами, и один из них правильно обрабатывает крайние случаи (необычные вводы), но другой проще для чтения, вы могли бы утверждать, что это компромисс. (Я бы не так считал. Я предпочитаю правильный код.)

Однако, если правильная, надежная версия кода также легка для чтения, нет никаких оправданий для написания кода, который не справляется с крайними случаями. Это касается find и необходимости выполнять команду для каждого найденного файла.


Давайте будем более конкретными: в системе UNIX или Linux имена файлов могут содержать любые символы, кроме символа / (который используется как разделитель компонентов пути), и они не могут содержать нулевой байт.

Нулевой байт, следовательно, является единственным правильным способом разделения имен файлов.


Так как GNU find включает первичный ключ -print0, который будет использовать нулевой байт для разделения имен файлов, GNU find может безопасно использоваться с GNU xargs и его опцией -0 (и опцией -r), чтобы обработать вывод find:

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

Тем не менее, нет никакой серьезной причины использовать эту форму, потому что:

  1. Это добавляет зависимость от утилит GNU, которая не должна быть там, и
  2. find предназначен для того, чтобы выполнять команды над найденными файлами.

Также GNU xargs требует -0 и -r, в то время как xargs на FreeBSD требует только -0 (и не имеет опции -r), а некоторые xargs вообще не поддерживают -0. Поэтому лучше всего просто придерживаться стандартных возможностей find (см. следующий раздел) и избегать xargs.

Что касается пункта 2 — способности find выполнять команды на найденных файлах — я думаю, что Майк Лукидес сказал это лучше всего:

"Дело find — это оценка выражений — не поиск файлов. Да, find действительно находит файлы; но это на самом деле лишь побочный эффект."


Правильный способ выполнить одну или несколько команд для каждого из результатов find:

Чтобы выполнить одну команду для каждого найденного файла, используйте:

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

Чтобы выполнить несколько команд в последовательности для каждого найденного файла, где вторая команда должна выполняться только если первая команда успешно завершилась, используйте:

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

Чтобы выполнить одну команду над несколькими файлами одновременно:

find dirname ... -exec somecommand {} +

Если вам нужно использовать функции shell в команде, такие как перенаправление вывода или удаление расширения из имени файла или что-то похожее, вы можете воспользоваться конструкцией sh -c. Вам нужно знать несколько вещей об этом:

  1. Никогда не встраивайте {} непосредственно в код sh. Это позволяет выполнять произвольный код из злонамеренно созданных имен файлов. Также на самом деле это даже не указано в POSIX, что это вообще сработает.
  2. Не используйте {} несколько раз или используйте его в качестве части более длинного аргумента. Это не портативно. Например, не делайте так:
find ... -exec cp {} somedir/{}.bak \;

Чтобы цитировать спецификации POSIX для find:

Если строка имени_утилиты или аргумент содержит два символа "{}", но не только два символа "{}", поведение find будет определяться реализацией.

Если присутствует более одного аргумента, содержащего два символа "{}", поведение остается неопределенным.


Аргументы после строки команды оболочки, переданной в опцию -c, устанавливаются в позиционные параметры оболочки, начиная с $0. Не начиная с $1.

По этой причине хорошо включить "фиктивное" значение $0, такое как find-sh, которое будет использоваться для сообщений об ошибках изнутри запущенной оболочки. Также это позволяет использовать конструкции, такие как "$@", при передаче нескольких файлов в оболочку, тогда как опущение значения для $0 означает, что первый переданный файл будет установлен в $0 и, следовательно, не будет включен в "$@".


Чтобы выполнить одну команду shell для каждого файла, используйте:

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

Тем не менее, обычно будет лучше обработать файлы в цикле shell, чтобы не запускать оболочку для каждого найденного файла:

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

(Обратите внимание, что for f do эквивалентно for f in "$@"; do и обрабатывает каждый из позиционных параметров по очереди — другими словами, он использует каждый из файлов, найденных find, независимо от любых специальных символов в их именах.)


Дополнительные примеры правильного использования find:

(Примечание: Не стесняйтесь расширять этот список.)


Проблема

for f in $(find .)

сочетает две несовместимые вещи.

find выводит список путей к файлам, разделенных символами новой строки. В то время как оператор split+glob, который вызывается, когда вы оставляете $(find .) без кавычек в этом контексте списка, разбивает его по символам $IFS (по умолчанию включает новую строку, но также пробел и табуляцию (и NUL в zsh)) и выполняет globbing для каждого полученного слова (за исключением zsh) (и даже расширение фигурных скобок в ksh93 (даже если опция braceexpand выключена в более старых версиях) или производные pdksh!).

Даже если вы сделаете это:

IFS='
' # разбивать только на новую строку
set -o noglob # отключить glob (также отключает расширение фигурных скобок,
              # выполненное при других расширениях в ksh)
for f in $(find .) # вызывает split+glob

Это все равно неверно, поскольку символ новой строки так же допустим, как и любой другой в пути к файлу. Вывод find -print просто ненадежно подлежит постобработке (за исключением использования каких-то запутанных трюков, как показано здесь).

Это также означает, что оболочка должна полностью сохранять вывод find, а затем разделять + расширять его (что подразумевает сохранение этого вывода второй раз в памяти) перед тем, как начать обрабатывать файлы.

Обратите внимание, что find . | xargs cmd имеет аналогичные проблемы (здесь пробелы, новая строка, одинарные и двойные кавычки и обратная косая черта (и с некоторыми реализациями xargs байты, которые не являются частью допустимых символов) представляют проблему).


Более правильные альтернативы

Единственный способ использовать for-цикл на выводе find — это использовать zsh, который поддерживает IFS=$'\0' и:

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

(замените -print0 на -exec printf '%s\0' {} + для реализаций find, которые не поддерживают нестандартный (но довольно распространенный в наши дни) -print0).

Здесь правильный и переносимый способ — использовать -exec:

find . -exec что-то с {} \;

Или, если что-то может принимать более одного аргумента:

find . -exec что-то с {} +

Если вам нужно, чтобы этот список файлов обрабатывался оболочкой:

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

(осторожно, это может запустить более одного sh).

На некоторых системах вы можете использовать:

find . -print0 | xargs -r0 что-то с

Хотя это имеет мало преимуществ по сравнению со стандартным синтаксисом и означает, что stdin что-то — это либо пайп, либо /dev/null.

Одной из причин, по которой вы можете захотеть использовать это, может быть возможность использования параметра -P GNU xargs для параллельной обработки. Проблему со stdin также можно обойти с помощью GNU xargs с опцией -a с оболочками, поддерживающими подстановку процессов:

xargs -r0n 20 -P 4 -a <(find . -print0) что-то

например, чтобы запустить до 4 параллельных вызовов что-то, каждый из которых принимает 20 файловых аргументов.

С zsh или bash другой способ пройтись по выводу find -print0 — это:

while IFS= read -u3 -rd '' file; do
  что-то "$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 в комбинацию рекурсивного globbing с квалификаторами glob. Например, пройтись по 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 в конечном итоге добавили поддержку **/ (хотя не более продвинутые формы рекурсивного globbing), но все еще не было квалификаторов glob, что делает использование ** там весьма ограниченным. Также будьте осторожны с тем, что bash до 4.3 следует за символическими ссылками при спуске по дереву каталогов. Хотя он улучшил свою работу в 4.3, полностью это не было исправлено до 5.0.

Как и при прохождении по $(find .), это также означает хранение всего списка файлов в памяти¹. Однако это может быть желаемым в некоторых случаях, когда вы не хотите, чтобы ваши действия с файлами повлияли на поиск файлов (например, когда вы добавляете больше файлов, которые могут быть найдены).


Другие соображения надежности/безопасности

Условия гонки

Теперь, если мы говорим о надежности, нам нужно упомянуть условия гонки между временем, когда find/zsh находит файл и проверяет, что он соответствует критериям, и временем, когда он используется (условие гонки TOCTOU).

Даже при спуске по дереву каталогов необходимо убедиться, что вы не следуете за символическими ссылками, и делать это без условия гонки TOCTOU. find (по крайней мере GNU find) делает это, открывая каталоги, используя openat() с правильными флагами O_NOFOLLOW (где это поддерживается) и поддерживая открытым дескриптор файла для каждого каталога; zsh/bash/ksh этого не делают. Так что перед лицом злоумышленника, способного заменить каталог символической ссылкой в нужное время, вы можете в конечном итоге спуститься не в тот каталог.

Даже если find спускается по каталогу правильно, с помощью -exec cmd {} \; и еще больше с -exec cmd {} +, как только выполняется cmd, например, как в cmd ./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 (что-то около 4k в Linux), тогда любой вызов системного вызова, который 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 (включая GNU find на системах GNU²).

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 успешно найдет файлы, имя которых состоит из 4 символов, закодированных в UTF-8, но dash будет сообщать длины от 4 до 24.

  1. yash: наоборот. Он работает только с символами. Все входные данные, которые он принимает, внутренне преобразуются в символы. Это делает yash самой последовательной оболочкой, но это также означает, что он не может обрабатывать произвольные последовательности байтов (те, которые не преобразуются в допустимые символы). Даже в локали C он не может обрабатывать байтовые значения выше 0x7f.
find . -exec yash -c 'echo "$1"' sh {} \;

в локали UTF-8 завершится ошибкой для нашего ISO-8859-1 côté.txt из предыдущего примера.

  1. Те, которые такие, как bash или zsh, где поддержка многобайтовых строк была постепенно добавлена. Они будут вернуться к рассмотрению байтов, которые не могут быть сопоставлены с символами как символы. У них все еще есть несколько ошибок здесь и там, особенно с менее распространенными многобайтовыми наборами символов, такими как GBK или BIG5-HKSCS (из-за того, что многие из их многобайтовых символов содержат байты в диапазоне от 0 до 127 (например, символы ASCII)).

  2. Те, которые такие, как 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: превышен лимит ЦП (сбой ядра)  (ulimit -t 1; find / -type f -print0 2> /dev/null; ) |
zsh: завершено                              xargs -r0 printf 'rm -rf "%s"\n' | tail -n 2

Здесь команда find была прервана, потому что она достигла предела времени ЦП. Поскольку вывод буферизуется (так как он идет в канал), find вывел несколько блоков на стандартный вывод, и конец последнего блока, который он записал на момент его убийства, оказался посреди какого-то пути к файлу /usr/lib/x86_64-linux-gnu/guile..., к сожалению, прямо после /usr/.

xargs, просто увидел неделимый /usr/ запись и передал это команде printf. Если бы команда была rm -rf, это могло бы иметь серьезные последствия.


Примечания

¹ Для полноты картины мы могли бы упомянуть хитрый способ в zsh, чтобы пройтись по файлам, используя рекурсивное globbing, не сохраняя весь список в памяти:

process() {
  с чем-то с $REPLY
  false
}
: **/*(ND.m-1+process)

+cmd — это квалификатор glob, который вызывает cmd (обычно функцию) с текущим путем к файлу в $REPLY. Функция возвращает true или false, чтобы решить, следует ли выбирать файл (и может также изменить $REPLY или вернуть несколько файлов в массиве $reply). Здесь мы обрабатываем данные в этой функции и возвращаем false, так что файл не будет выбран.

² GNU find использует системную функцию libc fnmatch() для выполнения сопоставления паттернов, так что поведение здесь зависит от того, как эта функция справляется с нетекстовыми данными.

Ответ или решение

Looping over the output of find is considered bad practice for several key technical reasons that stem from the structure of filenames on UNIX-like systems and the characteristics of shell scripting. Below are the main points elucidating why this practice can lead to problematic outcomes, alongside the recommended alternatives for handling files found by find.

1. Неопределенность разделителей

Файлы в UNIX могут содержать практически любые символы, кроме символа / (разделитель каталогов) и нулевых байтов. Это означает, что предположение о том, что файлы можно разделить по символам новой строки или пробелам, приводит к уязвимостям в коде. Например, если файл содержит пробелы или новые строки, команда for file in $(find .) разорвёт имя файла на несколько частей:

for file in $(find .); do
    echo "Обрабатываю файл: $file"
done

Если у вас есть файл с именем "файл с пробелами.txt", то это будет интерпретировано как два отдельных имени файла. Это приводит к сбоям в работе скрипта и нежелательным последствиям.

2. Ограничение по памяти и производительность

Когда вы используете конструкцию типа for f in $(find .), оболочка должна сначала выполнить команду find, чтобы получить весь вывод. Это создаёт потребность в память для хранения всех имён файлов одновременно. В случае, когда файлов очень много, это может привести к исчерпанию памяти или превышению лимитов на размер аргументов командной строки. В отличие от этого, использование конструкции с -exec или xargs позволяет обрабатывать файлы по одному, экономя ресурсы.

3. Проблемы с безопасностью

Использование вывода find в циклах может сделать ваш код уязвимым для атак через специально сформированные имена файлов. Например, если имя файла содержит символы, которые могут использоваться для SQL-инъекций или выполнения вредоносного кода, это может привести к потенциальным уязвимостям вашего кода.

4. Race Condition (Условия гонки)

При использовании командной строки, такие как find, может возникнуть ситуация гонки между моментом, когда find находит файл, и моментом, когда выполняется команда. Например, файл может быть удалён, или его атрибуты могут измениться, прежде чем команда с ним будет выполнена. Это может привести к ошибкам или выполнять команды над несуществующими файлами.

Рекомендации по корректному использованию find

Чтобы избежать вышеописанных проблем, рекомендуется использовать следующие подходы:

  1. Используйте -exec напрямую с find:

    Это позволяет выполнять команды для каждого найденного файла без необходимости явного цикла:

    find . -type f -exec обработать_файл {} \;
  2. Обработка нескольких файлов за раз:

    Если требуется передать несколько файлов в одну команду, можно использовать + в конце команды -exec:

    find . -type f -exec обработать_файлы {} +
  3. Используйте find ... -print0 | xargs -0:

    Если хотите обрабатывать много файлов с помощью утилиты, которая может принять списки файлов, как аргументы, это лучший подход:

    find . -print0 | xargs -0 обработать_файлы

Заключение

В работе с файлами и каталогами важно использовать надежные методы, которые учитывают все потенциальные опасности и ограничения системы. Избегание оберток вокруг вывода find в циклах делает ваш код более безопасным и эффективным. Понимание этих основ обеспечит работу ваших скриптов с файлами без непреднамеренных сбоев и уязвимостей.

Оцените материал
Добавить комментарий

Капча загружается...