Команда 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 в каждом файле *.html. find
может передавать имена файлов напрямую другой команде. Обычно нет необходимости выводить имена файлов в строку, чтобы другая программа затем считала поток и снова разделила его на имена.
Получение метаданных файла
Если вам нужен размер файла, то портируемым способом будет использование 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
. Но стоит помнить о следующем:
Запускайте
ls
только для одного файла за раз (помните, что нельзя абсолютно точно сказать, где заканчивается первое имя файла, потому что не существует хорошего разделителя (и нет, перенос строки — это недостаточно хороший разделитель), поэтому невозможно понять, где начинаются метаданные второго файла).Не парсите метку времени/даты и то, что идёт после них (поля времени/даты обычно форматируются в очень зависящем от платформы и локали стиле, поэтому их нельзя спарсить надёжным образом).
Не забывайте опцию -d, без которой если файл имел тип directory, то вместо него будет перечислено содержимое этой папки; также не забывайте о разделителе --, позволяющем избегать проблем с именами файлов, начинающимися на
-
.Задайте для ls локаль C/POSIX, так как формат вывода не указывается вне этой локали. В частности, в общем случае от локали зависит формат метки времени, но от неё может зависеть и что-то ещё.
Помните, что поведение разделения при считывании зависит от текущего значения
$IFS
Выбирайте числовой вывод для 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 стала опцией по умолчанию при выводе в терминал), но она не так безопасна, поскольку создаёт вывод, который не всегда содержит кавычки или использует операторы заключения в кавычки, которые непортируемы или небезопасны при использовании в некоторых локалях.
Комментарии (10)
Electrohedgehog
27.06.2024 08:36+16Автор также не упоминает важный момент - если назвать файл rm -rf то в некоторых случаях могут произойти чудовищные недоразумения.
mpa4b
27.06.2024 08:36+1Так как очень немногие реализации
ls
позволяют завершать имена файлов символаи NULКакая интересная манипуляция... Стандартная
ls
из coreutils в линуксе вполне это делает c флагом--zero
, а остальныеls
... Ну, в принципе их проблемы, если из принципа "главное не как у GNU" не делают фичи :)
Shaman_RSHU
27.06.2024 08:36А если вместо ls пользуются exa? Всё?
Kenya-West
27.06.2024 08:36vtb_k
27.06.2024 08:36lsd была ещё задолго до exa и до сих пор поддерживается регулярно
https://github.com/lsd-rs/lsd
Kenya-West
27.06.2024 08:36Всё-таки я иногда так сильно радуюсь, что в PowerShell реализован полноценный ООП. И на своих Linux'овых хостах я могу вызвать православный и так хорошо знакомый командлет
Get-ChildItem
и работать с ним как с типичным объектом, перебирая его свойства и не заботясь ни о каком парсинге с учетом пробелов, каких-то там ещё символов и т. д. Работает, зарплаты не просит и не багует в типичных задачах администрирования. Всё, что не работает с PS напрямую и требует передать как строку, можно перемапить/перепайпить в строку. Легко и непринужденно.Осталось только в вывод
eza
,rg
,btop
,bat
завезти совместимость с PowerShell, и можно вообще отказаться от окружения Bash/Sh.
MKMatriX
27.06.2024 08:36Мде, я что-то в своих проектах файлы с переносами строк не учитывал. Даже как-то страшно стало. Вдруг раз, захочу создать файл с парой-тройкой переносов строк, а код сломается.
Einherjar
gxcreator
Справедливости ради, софт чаще всего пишут для работы на чужих системах в неизвестном состоянии.