bash: uso de procedimiento seguro para espacios en blanco de find into select

12

Dados estos nombres de archivo:

$ ls -1
file
file name
otherfile

bash en sí mismo funciona perfectamente bien con espacios en blanco incrustados:

$ 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
#?

Sin embargo, a veces es posible que no quiera trabajar con cada archivo, o incluso estrictamente $PWD, que es donde findentra. Lo que también maneja los espacios en blanco nominalmente:

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

Estoy tratando de inventar una versión segura de whispace de este scriptlet que tome la salida findy la presente en select:

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

Sin embargo, esto explota con espacios en blanco en los nombres de archivo:

$ 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

Por lo general, evitaría esto jugando IFS. Sin embargo:

$ 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'

¿Cuál es la solución a esto?

DopeGhoti
fuente
1
Si solo está usando findsu capacidad para hacer coincidir un nombre de archivo en particular, simplemente puede usar select file in **/file*(después de la configuración shopt -s globstar) en bash4 o posterior.
chepner

Respuestas:

14

Si solo necesita manejar espacios y pestañas (no líneas nuevas incrustadas), puede usar mapfile(o su sinónimo readarray) para leer en una matriz, por ejemplo, dada

$ ls -1
file
other file
somefile

luego

$ 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

Si haces necesidad de nuevas líneas de mango, y su bashversión proporciona un nulo delimitado por mapfile1 , entonces se puede modificar para que IFS= mapfile -t -d '' files < <(find . -type f -print0). De lo contrario, ensamble una matriz equivalente a partir de una findsalida delimitada por nulos utilizando un readbucle:

$ 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 la -dopción se añadió a mapfileen bashla versión 4.4 IIRC

conductor de acero
fuente
2
+1 para otro verbo que no he usado antes
roaima
De hecho, mapfilees nuevo para mí también. Prestigio.
DopeGhoti
La while IFS= readversión funciona en bash v3 (que es importante para aquellos de nosotros que usamos macOS).
Gordon Davisson
3
+1 para la find -print0variante; refunfuñe por ponerlo después de una versión incorrecta conocida y describirlo solo para su uso si uno sabe que necesita manejar nuevas líneas. Si solo se maneja lo inesperado en lugares donde se espera, nunca se manejará lo inesperado en absoluto.
Charles Duffy
8

Esta respuesta tiene soluciones para cualquier tipo de archivos. Con nuevas líneas o espacios.
Hay soluciones para bash recientes, así como bash antiguas e incluso conchas posix antiguas.

El árbol que se detalla a continuación en esta respuesta [1] se utiliza para las pruebas.

Seleccione

Es fácil ponerse selecta trabajar con una matriz:

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

O con los parámetros posicionales:

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

Entonces, el único problema real es obtener la "lista de archivos" (delimitada correctamente) dentro de una matriz o dentro de los Parámetros Posicionales. Sigue leyendo.

golpetazo

No veo el problema que reportas con bash. Bash puede buscar dentro de un directorio dado:

$ 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>

O, si te gusta un bucle:

$ 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>

Tenga en cuenta que la sintaxis anterior funcionará correctamente con cualquier shell (razonable) (no al menos csh).

El único límite que tiene la sintaxis anterior es descender a otros directorios.
Pero bash podría hacer eso:

$ 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>

Para seleccionar solo algunos archivos (como los que terminan en archivo) simplemente reemplace el *:

$ 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>

robusto

Cuando coloque un "espacio seguro " en el título, voy a suponer que lo que quiso decir era " robusto ".

La forma más sencilla de ser robusto con respecto a los espacios (o nuevas líneas) es rechazar el procesamiento de la entrada que tiene espacios (o nuevas líneas). Una forma muy simple de hacer esto en el shell es salir con un error si algún nombre de archivo se expande con un espacio. Hay varias formas de hacer esto, pero la más compacta (y posix) (pero limitada al contenido de un directorio, incluidos los nombres de directorios y evitar los archivos de puntos) es:

$ 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.

Si la solución utilizada es robusta en alguno de esos elementos, elimine la prueba.

En bash, los subdirectorios podrían probarse de inmediato con el ** explicado anteriormente.

Hay un par de formas de incluir archivos de puntos, la solución de Posix es:

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

encontrar

Si debe usarse find por alguna razón, reemplace el delimitador con un NUL (0x00).

bash 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>

bash 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

Para hacer una solución POSIX válida donde find no tiene un delimitador NUL y no hay -d(ni -a) para leer, necesitamos un enfoque completamente diferente.

Necesitamos usar un complejo -execde find con una llamada a un shell:

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

O, si lo que se necesita es un select (select es parte de bash, no 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] Este árbol (los \ 012 son líneas nuevas):

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

Podría construirse con estos dos comandos:

$ mkdir -p deep/inside/a/dir/directory/
$ touch deep/inside/a/dir/{,directory/}{file{,\ {name,with\ a$'\n'newline}},zz\ last\ file}
Isaac
fuente
6

No puede establecer una variable frente a una construcción en bucle, pero puede establecerla frente a la condición. Aquí está el segmento de la página del manual:

El entorno para cualquier comando o función simple puede aumentarse temporalmente con el prefijo de asignaciones de parámetros, como se describe anteriormente en PARÁMETROS.

(Un bucle no es un comando simple ).

Aquí hay una construcción de uso común que demuestra los escenarios de fracaso y éxito:

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

Desafortunadamente, no puedo ver una manera de incrustar un cambio IFSen la selectconstrucción mientras afecta el procesamiento de un asociado $(...). Sin embargo, no hay nada que evite que IFSse establezca fuera del ciclo:

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

y es esta construcción con la que puedo ver que funciona select:

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

Al escribir código defensiva Me gustaría recomendar que la cláusula o bien puede ejecutar en un subnivel, o IFSy SHELLOPTSguardada y restaurada por la manzana:

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
fuente
55
Asumir que IFS=$'\n'es seguro es infundado. Los nombres de archivo son perfectamente capaces de contener literales de nueva línea.
Charles Duffy
44
Francamente dudo en aceptar tales afirmaciones sobre el posible conjunto de datos a su valor nominal, incluso cuando están presentes. El peor evento de pérdida de datos por el que he estado presente fue un caso en el que un script de mantenimiento responsable de la limpieza de las copias de seguridad antiguas intentó eliminar un archivo que había sido creado por un script de Python usando un módulo C con una mala referencia de puntero que arrojó basura aleatoria - incluyendo un comodín separado por espacios en blanco - en el nombre.
Charles Duffy
2
La gente que construyó el script de shell haciendo la limpieza de esos archivos no se molestó en citar porque los nombres "no podían" no coincidir [0-9a-f]{24}. Se perdieron TB de copias de seguridad de los datos utilizados para respaldar la facturación del cliente.
Charles Duffy
44
De acuerdo con @CharlesDuffy por completo. No manejar casos extremos solo está bien cuando trabajas de forma interactiva y puedes ver lo que estás haciendo. selectpor su propio diseño es para soluciones escritas , por lo que siempre debe estar diseñado para manejar casos extremos.
Comodín
2
@ilkkachu, por supuesto: nunca llamaría selectdesde un shell donde está escribiendo los comandos para ejecutar, sino solo en un script, donde está respondiendo un mensaje proporcionado por ese script y dónde está ese script ejecutar una lógica predefinida (construida sin el conocimiento de los nombres de archivo en los que se opera) basada en esa entrada.
Charles Duffy
4

Puede que esté fuera de mi jurisdicción aquí, pero tal vez puedas comenzar con algo como esto, al menos no tiene ningún problema con el espacio en blanco:

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

Para evitar posibles suposiciones falsas, como se señala en los comentarios, tenga en cuenta que el código anterior es equivalente a:

   find -maxdepth 1 -type f -printf '%f\0' | {
        while read -d ''; do
                echo "$REPLY"
                echo
        done
    }
flerb
fuente
read -des una solución inteligente gracias por esto.
DopeGhoti
2
read -d $'\000'es exactamente idéntico a read -d '', pero para la gente engañosa acerca de las capacidades de bash (lo que implica, incorrectamente, que es capaz de representar NUL literales dentro de las cadenas). Ejecute s1=$'foo\000bar'; s2='foo'y luego intente encontrar una manera de distinguir entre los dos valores. (Una versión futura puede normalizarse con el comportamiento de sustitución de comandos haciendo que el valor almacenado sea equivalente a foobar, pero ese no es el caso hoy).
Charles Duffy