bash: безопасное процедурное использование find в select

12

Учитывая эти имена файлов:

$ ls -1
file
file name
otherfile

bash само по себе прекрасно работает со встроенными пробелами:

$ for file in *; do echo "$file"; done
file
file name
otherfile
$ select file in *; do echo "$file"; done
1) file
2) file name
3) otherfile
#?

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

$ find -type f -name file\*
./file
./file name
./directory/file
./directory/file name

Я пытаюсь придумать версию этого скриплета, безопасную для свободного пространства, которая возьмет выходные данные findи представит их в select:

$ select file in $(find -type f -name file); do echo $file; break; done
1) ./file
2) ./directory/file

Тем не менее, это взрывается с пробелами в именах файлов:

$ select file in $(find -type f -name file\*); do echo $file; break; done
1) ./file        3) name          5) ./directory/file
2) ./file        4) ./directory/file  6) name

Обычно я бы обошел это, возиться с IFS. Тем не мение:

$ IFS=$'\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'
$ IFS='\n' select file in $(find -type f -name file\*); do echo $file; break; done
-bash: syntax error near unexpected token `do'

Какое решение это?

DopeGhoti
источник
1
Если вы только с помощью findсвоей способности соответствовать определенное имя файла, вы можете просто использовать select file in **/file*(после установки shopt -s globstar) в bash4 или более поздней версии.
Чепнер

Ответы:

14

Если вам нужно обрабатывать только пробелы и символы табуляции (не вставлять символы новой строки), вы можете использовать mapfile(или его синоним readarray) для чтения в массив, например, заданный

$ ls -1
file
other file
somefile

тогда

$ IFS= mapfile -t files < <(find . -type f)
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
#? 3
./other file

Если делать нужно обрабатывать переводы строк, и ваша bashверсия обеспечивает нуль-разделители mapfile1 , то вы можете изменить что IFS= mapfile -t -d '' files < <(find . -type f -print0). В противном случае, собрать эквивалентный массив из findвывода с разделением нулями, используя readцикл:

$ touch $'filename\nwith\nnewlines'
$ 
$ files=()
$ while IFS= read -r -d '' f; do files+=("$f"); done < <(find . -type f -print0)
$ 
$ select f in "${files[@]}"; do ls "$f"; break; done
1) ./file
2) ./somefile
3) ./other file
4) ./filename
with
newlines
#? 4
./filename?with?newlines

1-d опция была добавлена к mapfileв bashверсии 4.4 IIRC

steeldriver
источник
2
+1 за другой глагол, которым я раньше не пользовался
roaima
Действительно, mapfileэто новый для меня тоже. Престижность.
DopeGhoti
while IFS= readВерсия работает еще в Баш v3 (что очень важно для тех , кто из нас с помощью MacOS).
Гордон Дэвиссон
3
+1 за find -print0вариант; ворчание за то, что оно было написано после известной неверной версии и описано только для использования, если известно, что им нужно обрабатывать переводы строки. Если человек обрабатывает неожиданное только в тех местах, где его ожидают, он никогда не будет обрабатывать неожиданное вообще.
Чарльз Даффи
8

Этот ответ имеет решения для любого типа файлов. С переводом строки или пробелами.
Есть решения для недавнего bash, а также древнего bash и даже старых posix-оболочек.

Дерево, перечисленное ниже в этом ответе [1] , используется для тестов.

Выбрать

selectРаботать с массивом легко :

$ dir='deep/inside/a/dir'
$ arr=( "$dir"/* )
$ select var in "${arr[@]}"; do echo "$var"; break; done

Или с позиционными параметрами:

$ set -- "$dir"/*
$ select var; do echo "$var"; break; done

Таким образом, единственной реальной проблемой является получение «списка файлов» (правильно разделенных) внутри массива или внутри Позиционных Параметров. Продолжай читать.

удар

Я не вижу проблемы, о которой вы сообщаете с bash. Bash может искать внутри заданного каталога:

$ dir='deep/inside/a/dir'
$ printf '<%s>\n' "$dir"/*
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Или, если вам нравится цикл:

$ set -- "$dir"/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Обратите внимание, что приведенный выше синтаксис будет работать правильно с любой (разумной) оболочкой (по крайней мере, не csh).

Единственный предел, который имеет синтаксис выше, это спуск в другие каталоги.
Но Bash может сделать это:

$ shopt -s globstar
$ set -- "$dir"/**/*
$ for f; do printf '<%s>\n' "$f"; done
<deep/inside/a/dir/directory>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/zz last file>

Чтобы выбрать только некоторые файлы (например, те, которые заканчиваются на файле), просто замените *:

$ set -- "$dir"/**/*file
$ printf '<%s>\n' "$@"
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/directory/zz last file>
<deep/inside/a/dir/file>
<deep/inside/a/dir/zz last file>

крепкий

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

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

$ set -- "$dir"/file*                            # read the directory
$ a="$(printf '%s' "$@" x)"                      # make it a long string
$ [ "$a" = "${a%% *}" ] || echo "exit on space"  # if $a has an space.
$ nl='
'                    # define a new line in the usual posix way.  

$ [ "$a" = "${a%%"$nl"*}" ] || echo "exit on newline"  # if $a has a newline.

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

В bash подкаталоги можно было проверить сразу с помощью **, описанного выше.

Есть несколько способов включить точечные файлы, решение Posix:

set -- "$dir"/* "$dir"/.[!.]* "$dir"/..?*

находить

Если по какой-либо причине необходимо использовать find, замените разделитель на NUL (0x00).

Баш 4.4+

$ readarray -t -d '' arr < <(find "$dir" -type f -name file\* -print0)
$ printf '<%s>\n' "${arr[@]}"
<deep/inside/a/dir/file name>
<deep/inside/a/dir/file with a
newline>
<deep/inside/a/dir/directory/file name>
<deep/inside/a/dir/directory/file with a
newline>
<deep/inside/a/dir/directory/file>
<deep/inside/a/dir/file>

Баш 2.05+

i=1  # lets start on 1 so it works also in zsh.
while IFS='' read -d '' val; do 
    arr[i++]="$val";
done < <(find "$dir" -type f -name \*file -print0)
printf '<%s>\n' "${arr[@]}"

POSIXLY

Чтобы сделать правильное решение POSIX, где find не имеет разделителя NUL и нет -d(или -a) для чтения, нам нужен совершенно иной подход.

Нам нужно использовать комплекс -execот find с вызовом оболочки:

find "$dir" -type f -exec sh -c '
    for f do
        echo "<$f>"
    done
    ' sh {} +

Или, если вам нужен выбор (select - это часть bash, а не sh):

$ find "$dir" -type f -exec bash -c '
      select f; do echo "<$f>"; break; done ' bash {} +

1) deep/inside/a/dir/file name
2) deep/inside/a/dir/zz last file
3) deep/inside/a/dir/file with a
newline
4) deep/inside/a/dir/directory/file name
5) deep/inside/a/dir/directory/zz last file
6) deep/inside/a/dir/directory/file with a
newline
7) deep/inside/a/dir/directory/file
8) deep/inside/a/dir/file
#? 3
<deep/inside/a/dir/file with a
newline>

[1] Это дерево (\ 012 - новые строки):

$ tree
.
└── deep
    └── inside
        └── a
            └── dir
                ├── directory
                   ├── file
                   ├── file name
                   └── file with a \012newline
                ├── file
                ├── file name
                ├── otherfile
                ├── with a\012newline
                └── zz last file

Может быть построен с помощью этих двух команд:

$ mkdir -p deep/inside/a/dir/directory/
$ touch deep/inside/a/dir/{,directory/}{file{,\ {name,with\ a$'\n'newline}},zz\ last\ file}
Исаак
источник
6

Вы не можете установить переменную перед циклической конструкцией, но вы можете установить ее перед условием. Вот сегмент со страницы руководства:

Среду для любой простой команды или функции можно временно дополнить, добавив к ней префиксы с помощью параметров, как описано выше в PARAMETERS.

(Цикл не простая команда .)

Вот часто используемая конструкция, демонстрирующая сценарии неудачи и успеха:

IFS=$'\n' while read -r x; do ...; done </tmp/file     # Failure
while IFS=$'\n' read -r x; do ...; done </tmp/file     # Success

К сожалению, я не вижу способа внедрить измененное IFSв selectконструкцию, в то время как это влияет на обработку связанного $(...). Тем не менее, ничто не мешает IFSбыть установленным вне цикла:

IFS=$'\n'; while read -r x; do ...; done </tmp/file    # Also success

и именно эта конструкция, которую я вижу, работает с select:

IFS=$'\n'; select file in $(find -type f -name 'file*'); do echo "$file"; break; done

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

OIFS="$IFS" IFS=$'\n'                     # Split on newline only
OSHELLOPTS="$SHELLOPTS"; set -o noglob    # Wildcards must not expand twice

select file in $(find -type f -name 'file*'); do echo $file; break; done

IFS="$OIFS"
[[ "$OSHELLOPTS" !~ noglob ]] && set +o noglob
roaima
источник
5
Предполагать, что IFS=$'\n'это безопасно, необоснованно. Имена файлов прекрасно могут содержать литералы новой строки.
Чарльз Даффи
4
Я, честно говоря, не решаюсь принимать такие утверждения о возможном наборе данных за чистую монету, даже если она присутствует. Наихудшее событие потери данных, в котором я участвовал, - это случай, когда скрипт обслуживания, отвечающий за очистку старых резервных копий, пытался удалить файл, созданный скриптом Python, с использованием модуля C с плохой разыменовкой указателя, которая выбрасывала случайный мусор - включая разделенный пробелами подстановочный знак - в имя.
Чарльз Даффи
2
Люди, создающие сценарий оболочки, выполняющие очистку этих файлов, не удосужились процитировать, потому что имена «не могли» не совпадать [0-9a-f]{24}. ТБ резервных копий данных, использованных для поддержки счетов клиентов, были потеряны.
Чарльз Даффи
4
Согласитесь с @CharlesDuffy полностью. Не обрабатывать крайние случаи - это хорошо, только когда вы работаете в интерактивном режиме и можете видеть, что вы делаете. selectПо своей сути он предназначен для решения на основе сценариев , поэтому он всегда должен быть разработан для обработки крайних случаев.
Wildcard
2
@ilkkachu, конечно - вы никогда не будете звонить selectиз оболочки, где вы вводите команды для запуска, но только из сценария, когда вы отвечаете на приглашение, предоставленное этим сценарием , и где этот сценарий находится выполнение предопределенной логики (созданной без знания имен файлов, с которыми ведется работа) на основе этого ввода.
Чарльз Даффи
4

Я могу быть вне моей юрисдикции здесь, но, возможно, вы можете начать с чего-то вроде этого, по крайней мере, это не имеет никаких проблем с пробелами:

find -maxdepth 1 -type f -printf '%f\000' | {
    while read -d $'\000'; do
            echo "$REPLY"
            echo
    done
}

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

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
flerb
источник
read -dэто умное решение; Спасибо за это.
DopeGhoti
2
read -d $'\000'это точно идентично read -d '', но вводит в заблуждение людей о возможностях в Bash (подразумевая, неправильно, что это может представлять буквальные NULs внутри строк). Запустите s1=$'foo\000bar'; s2='foo', а затем попытайтесь найти способ различения двух значений. (Будущая версия может нормализоваться с поведением подстановки команд, делая сохраненное значение эквивалентным foobar, но сегодня это не так).
Чарльз Даффи