[Перевод] Почему нельзя парсить вывод ls(1)

c35256827155e0d1f0aa4644e687a372.png

Команда ls(1) достаточно хорошо справляется с отображением атрибутов одного файла (по крайней мере, в некоторых случаях), но когда просишь у неё список файлов, возникает огромная проблема: Unix позволяет использовать в имени файла почти любой символ, в том числе пробелы, переносы строк, точки, символы вертикальной черты, да и практически всё остальное, что вы можете использовать как разделитель, за исключением NUL. Существуют предложения по «исправлению» этой ситуации внутри POSIX, но они не помогут в решении текущей ситуации (см. также, как правильно работать с именами файлов). Если в качестве стандартного вывода не используется терминал, в режиме по умолчанию ls разделяет имена файлов переносами строк. И никаких проблем не возникает, пока не встретится файл, в имени которого есть перенос строки. Так как очень немногие реализации ls позволяют завершать имена файлов символаи NUL, а не переносами строк, это не позволяет получить безопасным образом список имён файлов при помощи ls (по крайней мере, портируемым способом).

$ touch 'a space' $'a\nnewline'
$ echo "don't taze me, bro" > a
$ ls | cat
a
a
newline
a space

Этот вывод показывает, что у нас есть два файла с именем a, один с именем newline и один с именем space.

Но если воспользоваться ls -l, то можно увидеть, что это совершенно не так:

$ ls -l
total 8
-rw-r-----  1 lhunath  lhunath  19 Mar 27 10:47 a
-rw-r-----  1 lhunath  lhunath   0 Mar 27 10:47 a?newline
-rw-r-----  1 lhunath  lhunath   0 Mar 27 10:47 a space

Проблема в том, что из вывода ls ни пользователь, ни компьютер не может сказать, какие его части составляют имя файла. Это каждое слово? Нет. Это каждая строка? Нет. На этот вопрос есть только один правильный ответ: мы не можем этого понять.

Также стоит отметить, что иногда ls повреждает данные имён файлов (в нашем случае она превратила символ переноса строки между словами a и newline в вопросительный знак. (Некоторые системы вместо него ставят \n.) В некоторых системах команда не делает этого, когда вывод происходит не в терминал, а в других имя файла всегда повреждается. В конечном итоге, никогда не стоит считать, что вывод ls будет истинным представлением имён файлов, с которыми вы работаете.

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

Перечисление файлов или выполнение операций с файлами

Когда пользователи пытаются получить список имён файлов при помощи ls (или всех файлов, или файлов, соответствующих glob, или файлов, отсортированных неким образом), происходит катастрофа.

Если вы хотите просто итеративно обойти все файлы в текущей папке, то используйте цикл for и glob:

# Хорошо!
for f in *; do
    [ -e "$f" ] || [ -L "$f" ] || continue
    ...
done

Также стоит попробовать использовать shopt -s nullglob, чтобы пустая папка не выдавала вам литерал *.

# Хорошо! (Только для Bash)
shopt -s nullglob
for f in *; do
    ...
done

Никогда не делайте так:

# ПЛОХО! Не делайте этого!
for f in $(ls); do
    ...
done
# ПЛОХО! Не делайте этого!
for f in $(find . -maxdepth 1); do # в этом контексте find столь же плох, как и ls
    ...
done
# ПЛОХО! Не делайте этого!
arr=($(ls)) # Здесь разбиение на слова и globbing, та же ошибка, что и выше
for f in "${arr[@]}"; do
    ...
done
# ПЛОХО! Не делайте этого!! (Сама по себе функция корректна.)
f() {
    local f
    for f; do
        ...
    done
}

f $(ls) # Здесь разбиение на слова и globbing, та же ошибка, что и выше.

Подробнее см. BashPitfalls и DontReadLinesWithFor.

Ситуация становится сложнее, если вам нужна какая-то особая сортировка, которую способна выполнять только ls, например, упорядочивание по mtime. Если вам нужен самый старый или самый новый файл в папке, то не используйте ls -t | head -1; вместо этого прочитайте Bash FAQ 99. Если вам действительно нужен список всех файлов в папке в порядке mtime, чтобы их можно было обработать по порядку, то напишите программу на Perl, которая сама будет выполнять открытие и сортировку в папке. Затем выполняйте обработку в программе на Perl или (в худшем случае) сделайте так, чтобы эта программа выводила имена файлов с разделителями NUL.

Можно сделать ещё лучше: поместить время модификации в имя файла в формате YYYYMMDD, чтобы порядок glob был и порядком mtime. Тогда вам не понадобится ls, Perl или что-то ещё. (В подавляющем количестве случаев, когда нужен самый старый или самый новый файл в папке, задачу можно решить таким образом.)

Можно пропатчить ls, чтобы она поддерживала опцию --null, и отправить патч разработчику вашей операционной системы. Это стоило сделать примерно пятнадцать лет назад. (На самом деле, люди пытались, но патч отклонили!  См. ниже.)

Разумеется, это не было сделано потому, что очень немногим действительнно нужна сортировка ls в скриптах. Чаще всего, когда людям нужен список имён файлов, они пользуются find (1), потому что порядок им не важен. А find BSD/GNU уже давно имеет возможность завершения имён файлов NUL.

Так что вместо этого:

# Плохо!  Не делайте так!
ls | while read filename; do
  ...
done

Попробуйте это:

# Учтите, что здесь происходит не совсем то, что выше. Этот код выполняется рекурсивно и создаёт списки только обычных файлов (то есть не папок и не симлинков). В некоторых ситуациях это может подойти, но не будет полной заменой кода выше.
find . -type f -print0 | while IFS= read -r -d '' filename; do
  ...
done

Кроме того, большинству людей на самом деле не нужен список имён файлов. Им нужно выполнять операции с файлами. Список — это лишь промежуточный этап выполнения какой-то настоящей цели, например, замены www.mydomain.com на mydomain.com в каждом файле *.htmlfind может передавать имена файлов напрямую другой команде. Обычно нет необходимости выводить имена файлов в строку, чтобы другая программа затем считала поток и снова разделила его на имена.

Получение метаданных файла

Если вам нужен размер файла, то портируемым способом будет использование wc:

# POSIX
size=$(wc -c < "$file")

Большинство реализаций wc распознают, что stdin — это обычный файл и получает размер при помощи вызова fstat(2). Однако это не гарантировано. Некоторые реализации читают все байты.

Другие метаданные часто сложно получить портируемым образом. stat(1)  доступна не на всех платформах, а когда доступна, синтаксис аргументов часто сильно отличается. Невозможно использовать stat так, чтобы не поломать другую POSIX-систему, на которой будет запускаться скрипт. Однако если вас это устроит, очень хороший способ получения информации о файле — это обе реализации GNU stat(1) и find(1) (с использованием опции -printf), в зависимости от того, нужен ли вам один или несколько файлов. У find AST тоже есть -printf, но тоже с несовместимыми форматами, и она гораздо реже встречается, чем find GNU.

# GNU
size=$(stat -c %s -- "$file")
(( totalSize = $(find . -maxdepth 1 -type f -printf %s+)0 ))

Если больше ничего не помогает, можно попробовать спарсить некоторые метаданные из вывода ls -l. Но стоит помнить о следующем:

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

  2. Не парсите метку времени/даты и то, что идёт после них (поля времени/даты обычно форматируются в очень зависящем от платформы и локали стиле, поэтому их нельзя спарсить надёжным образом).

  3. Не забывайте опцию -d, без которой если файл имел тип directory, то вместо него будет перечислено содержимое этой папки; также не забывайте о разделителе --, позволяющем избегать проблем с именами файлов, начинающимися на -.

  4. Задайте для ls локаль C/POSIX, так как формат вывода не указывается вне этой локали. В частности, в общем случае от локали зависит формат метки времени, но от неё может зависеть и что-то ещё.

  5. Помните, что поведение разделения при считывании зависит от текущего значения $IFS

  6. Выбирайте числовой вывод для owner и group при помощи -n вместо -l, так как иногда имена пользователей и групп могут содержать пробелы. Кроме того, имена пользователей и групп иногда могут усекаться.

Вот это достаточно надёжно:

IFS=' ' read -r mode links owner _ < <(LC_ALL=C ls -nd -- "$file")

Стоит отметить, что строка mode тоже часто зависит от платформы. Например, OS X добавляет @ для файлов с xattrs и + для файлов с расширенной информацией о безопасности. GNU иногда добавляет символ . или +. То есть в зависимости от того, что вы делаете, может потребоваться ограничить поле mode первыми десятью символами.

mode=${mode:0:10}

Если вы не верите, приведу пример того, почему не стоит парсить метку времени:

# OpenBSD 4.4:
$ ls -l
-rwxr-xr-x  1 greg  greg  1080 Nov 10  2006 file1
-rw-r--r--  1 greg  greg  1020 Mar 15 13:57 file2

# Debian unstable (2009):
$ ls -l
-rw-r--r-- 1 wooledg wooledg       240 2007-12-07 11:44 file1
-rw-r--r-- 1 wooledg wooledg      1354 2009-03-13 12:10 file2

В OpenBSD, как и в большинстве версий Unix,  ls отображает метки времени в трёх полях (месяц, день и год-или-время) где последнее время становится временем (часы: минуты), если файлу меньше шести месяцев, или годом, когда файл старше шести месяцев.

На Debian unstable (примерно 2009 год) с современной версией coreutils GNU ls отображала метки времени в двух полях: первое было Г-М-Д, а второе — Ч: М, вне зависимости от возраста файла.

То есть достаточно очевидно, что нам никогда не стоит выполнять парсинг вывода ls, если требуется метка времени файла. Вам бы пришлось писать код для обработки всех трёх форматов времени/даты, а может, и других.

Но поля до даты/времени обычно достаточно надёжны.

(Примечание: некоторые версии ls по умолчанию не выводят групповое владение файлом и требуют для этого флаг -g. Другие выводят группу по умолчанию, а -g отключает это. В общем, вас предупредили.)

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

# Не делайте так
{ IFS=' ' read -r 'perms[0]' 'links[0]' 'owner[0]' 'group[0]' _
  IFS=' ' read -r 'perms[1]' 'links[1]' 'owner[1]' 'group[1]' _
} < <(LC_ALL=C ls -nd -- "$file1" "$file2")

Похожий код, использующий два отдельных вызова ls, вероятно, будет работать без проблем, потому что вторая команда read гарантированно начнёт считывание с начала вывода команды ls, а не с середины имени файла; при этом стоит помнить, что ls сортирует свой вывод и может не найти ни один из файлов, так что мы не можем быть уверены, что будет находиться в perms[1]… Первую проблему позволит обойти опция -q команды ls, но она не поможет с остальными.

Если всё это кажется вам большой головной болью, то вы правы. Вероятно, не стоит пытаться избежать всего этого отсутствия стандартизации. Способы получения метаданных файлов вообще без парсинга вывода ls см. в Bash FAQ 87.

Примечания о ls из GNU coreutils

В 2014 году был отклонён патч, добавляющий в GNU coreutils опцию -0 (аналогичную find -print0). Однако, как ни удивительно, в GNU coreutils 9.0 (2021 год) была добавлена опция --zero. Если вам повезло и вы пишете для платформ с ls --zero, то можете использовать её для задач типа «удалить пять самых старых файлов в этой папке».

# Bash 4.4 и coreutils 9.0
# Удаление пяти самых старых файлов в текущей папке.
readarray -t -d '' -n 5 sorted < <(ls --zero -tr)
(( ${#sorted[@]} == 0 )) || rm -- "${sorted[@]}"

В последних (примерно 2016 год) версиях GNU coreutils есть опция --quoting-style с различными вариантами.

Один из них очень полезен в сочетании с командой eval bash. В частности, --quoting-style=shell-always создаёт вывод, которые шеллы в стиле Борна могут парсить обратно в имена файлов.

$ touch zzz yyy $'zzz\nyyy'
$ ls --quoting-style=shell-always
'yyy'  'zzz'  'zzz?yyy'
$ ls --quoting-style=shell-always | cat
'yyy'
'zzz'
'zzz
yyy'

Она всегда использует одинарные кавычки для имён файлов (а сами одинарные кавычки кавычки вне кавычек рендерятся как \'), потому что это единственный безопасный способ использования кавычек.

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

Скомбинировав это с eval, мы можем решать некоторые задачи, например, «получить пять самых старых файлов в этой папке». Разумеется, eval следует использовать аккуратно.

# Bash + последние (примерно с 2016 года) GNU coreutils

# Получаем все файлы, отсортированные по mtime.
eval "sorted=( $(ls -rt --quoting-style=shell-always) )"

# Первые пять элементов массива - это пять самых старых файлов.
# Мы можем отобразить их пользователю:
(( ${#sorted[@]} == 0 )) || printf '<%s>\n' "${sorted[@]:0:5}"

# Или отправить их в xargs -r0:
print0() {
  [ "$#" -eq 0 ] || printf '%s\0' "$@"
}
print0 "${sorted[@]:0:5}" | xargs -r0 something

# Или сделать с ними ещё что угодно

Кроме того, ls GNU  поддерживает опцию --quoting-style=shell-escape (которая в версии 8.25 стала опцией по умолчанию при выводе в терминал), но она не так безопасна, поскольку создаёт вывод, который не всегда содержит кавычки или использует операторы заключения в кавычки, которые непортируемы или небезопасны при использовании в некоторых локалях.

© Habrahabr.ru