¿Por qué bash?
Hay matrices y modo seguro en bash. Cuando se usa correctamente, bash es casi consistente con las prácticas de codificación seguras.
Es más difícil cometer un error en el pescado, pero no hay modo seguro. Por lo tanto, la creación de prototipos en peces y luego la traducción de peces a bash debería ser una buena idea si sabe cómo hacerlo correctamente.
Prólogo
Esta guía acompaña a ShellHarden, pero el autor también recomienda
ShellCheck para que las reglas de ShellHarden no difieran de ShellCheck.
Bash no es un lenguaje donde la forma
más correcta de resolver un problema al mismo tiempo es la más fácil . Si toma el examen de programación segura de bash, entonces la primera regla de
BashPitfalls sería: siempre use comillas.
Lo principal que debes saber sobre la programación en bash
Comillas maníacas! Una variable sin comillas debe considerarse como una bomba armada: explota al entrar en contacto con un espacio. Sí, explota en el sentido de
dividir una cadena en una matriz . En particular, las extensiones variables como
$var
y las sustituciones de comandos como
$(cmd)
se dividen en
palabras cuando la cadena interna se expande en una matriz debido a la división en una variable especial
$IFS
con un espacio predeterminado. Esto suele ser invisible, porque con mayor frecuencia el resultado es una matriz de 1 elemento, indistinguible de la cadena esperada.
No solo esto se expande, sino también comodines (
*?
). Este proceso ocurre después de que se divide la palabra, por lo que si hay al menos un comodín en la palabra, la palabra se convierte en un comodín que se aplica a cualquier ruta de archivo adecuada. ¡Entonces esta característica comienza a aplicarse al sistema de archivos!
La cita suprime la división de palabras y la expansión de patrones para variables y sustituciones de comandos.
Extensión Variable:
- Bien:
"$my_var"
- Malo:
$my_var
Sustitución de comando:
- Bien:
"$(cmd)"
- Malo:
$(cmd)
Hay excepciones con comillas opcionales, pero las comillas nunca afectarán, y la regla general es tener cuidado de no citar variables sin comillas, por lo que no buscaremos excepciones fronterizas para su beneficio. Parece incorrecto, y la práctica incorrecta está lo suficientemente extendida como para levantar sospechas: muchos scripts se han escrito con un procesamiento roto de nombres de archivos y espacios en ellos ...
ShellHarden menciona solo algunas excepciones: ¿son estas variables con contenido numérico como
$?
,
$#
y
${#array[@]}
.
¿Necesito usar backticks?
Las sustituciones de comandos también pueden tener la siguiente forma:
- Correcto:
"`cmd`"
- Malo:
`cmd`
Aunque este estilo se puede usar correctamente, parece menos conveniente entre comillas y menos legible cuando está anidado. El consenso aquí es bastante claro: evítelo.
ShellHarden reescribe tales marcas de verificación entre paréntesis en dólares.
¿Se deben usar llaves?
Los corchetes se utilizan para interpolar cadenas, por lo que suelen ser redundantes:
- Malo:
some_command $arg1 $arg2 $arg3
- Pobre y detallado:
some_command ${arg1} ${arg2} ${arg3}
- Bien, pero detallado:
some_command "${arg1}" "${arg2}" "${arg3}"
- Bien:
some_command "$arg1" "$arg2" "$arg3"
Teóricamente, el uso de llaves no es un problema, pero según la experiencia de su autor, existe una fuerte correlación negativa entre el uso innecesario de llaves y el uso correcto de comillas: ¡casi todos eligen la forma "mala y detallada" en lugar de la "buena pero detallada"!
Teorías de tu autor:
- Debido al temor de hacer algo mal: en lugar del peligro real (falta de comillas), los principiantes pueden preocuparse de que la variable
$prefix
haga que la variable "$prefix_postfix"
expanda, pero no funciona de esa manera. - Culto a la carga: escribir código en el pacto del miedo equivocado que lo precedió.
- Los corchetes compiten con comillas por el límite de verbosidad permisible.
Por lo tanto, se decidió prohibir llaves innecesarias: ShellHarden reemplaza estas opciones con la forma más simple.
Y ahora sobre la interpolación de cadenas, donde las llaves son realmente útiles:
- Malo (concatenación):
$var1"more string content"$var2
- Bien (concatenación):
"$var1""more string content""$var2"
- Bien (interpolación):
"${var1}more string content${var2}"
La concatenación y la interpolación en bash son equivalentes incluso en matrices (lo cual es ridículo).
Debido a que ShellHarden no formatea estilos, no se supone que cambie el código correcto. Esto es cierto para la opción "buena (interpolación)": desde el punto de vista de ShellHarden, esta será la forma canónicamente correcta.
ShellHarden ahora está agregando y eliminando llaves según sea necesario: en un mal ejemplo, var1 se suministra con corchetes, pero no están permitidos para var2 incluso en el caso de "bueno (interpolación)", ya que nunca son necesarios al final de la línea. El último requisito bien puede ser revertido.
Gotcha: argumentos numerados
A diferencia de los nombres de
identificadores de variables normales (en regex:
[_a-zA-Z][_a-zA-Z0-9]*
), los argumentos numerados requieren corchetes (la interpolación de línea no). ShellCheck dice:
echo "$10" ^-- SC1037: Braces are required for positionals over 9, eg ${10}.
ShellHarden se niega a arreglarlo (considera la diferencia demasiado sutil).
Dado que los paréntesis están permitidos hasta 9, ShellHarden les permite todos los argumentos numerados.
Usar matrices
Para poder citar todas las variables, debe usar matrices reales, no cadenas pseudo-masivas separadas por espacios.
La sintaxis es detallada, pero hay que manejarla. Este bashismo es solo una razón para abandonar la compatibilidad POSIX para la mayoría de los scripts de shell.
Bueno
array=( a b ) array+=(c) if [ ${#array[@]} -gt 0 ]; then rm -- "${array[@]}" fi
Malo:
pseudoarray=" \ a \ b \ " pseudoarray="$pseudoarray c" if ! [ "$pseudoarray" = '' ]; then rm -- $pseudoarray fi
Es por eso que las matrices son una función tan básica para un shell: los
argumentos de los comandos son fundamentalmente arrays (y los scripts de shell son comandos y argumentos). Podemos decir que el shell, que artificialmente hace imposible pasar varios argumentos, será cómico e inútil. Algunos proyectiles comunes de esta categoría incluyen
Dash y Busybox Ash. Estos son shells compatibles con POSIX mínimos, pero ¿de qué sirve la compatibilidad si lo más importante
no está en POSIX?
Casos excepcionales cuando realmente vas a romper una línea
Ejemplo con
\v
como separador de datos (observe la segunda aparición):
IFS=$'\v' read -d '' -ra a < <(printf '%s\v' "$s") || true
De esta forma, evitamos la expansión de la plantilla y el método funciona incluso si el separador de datos es
\n
. La segunda aparición del separador de datos protege el último elemento si resulta ser un espacio. Por alguna razón, la opción
-d
debería ir primero, por lo que
-rad ''
opciones en
-rad ''
tentador, pero no funcionará. Como read devuelve un valor distinto de cero en este caso, debería estar protegido de errexit (
|| true
), si está habilitado. Probado en bash 4.0, 4.1, 4.2, 4.3 y 4.4.
Alternativa para bash 4.4:
readarray -td $'\v' a < <(printf '%s\v' "$s")
Donde comenzar un script bash
De algo como esto:
Esto incluye:
- Shebang:
- Problemas de portabilidad: la ruta absoluta a
env
probablemente mejor para la portabilidad que la ruta absoluta a bash
. Puedes ver el ejemplo de NixOS . POSIX requiere env , pero no bash. - Problemas de seguridad: ¡Sin lenguaje, opciones como
-euo pipefail
no serán aceptadas favorablemente -euo pipefail
! Esto se vuelve imposible cuando se usa la redirección env
, pero incluso si su shebang comienza con #!/bin/bash
, este no es el lugar para los parámetros que afectan el valor del script, ya que pueden anularse, lo que hará posible ejecutar el script incorrectamente. Sin embargo, como beneficio adicional, las opciones que no afectan el valor del script, como set -x
, si se usan, pueden redefinirse.
- ¿Qué necesitamos del modo estricto no oficial de Bash , con la comprobación de funciones
set -u
? No necesitamos todo el modo estricto de Bash, porque la compatibilidad shellcheck / shellharden significa citar todo y todo lo que es mucho más estricto. Además, la opción set -u
no debe usarse en Bash 4.3 y versiones anteriores. Como esta opción considera que las matrices vacías se descartan en esas versiones, las matrices no se pueden usar para los fines descritos aquí. El uso de matrices es el segundo consejo más importante de esta guía (después de las comillas) y la única razón por la que sacrificamos la compatibilidad con POSIX, por lo que esto no es inaceptable: no use set -u
, o use Bash 4.4 u otro shell normal como Zsh. Esto es más fácil decirlo que hacerlo, porque existe la posibilidad de que alguien aún ejecute su script en la versión antigua de Bash. Afortunadamente, todo lo que funciona con set -u
funcionará sin él (para set -e
no se puede decir eso). Es por eso que es importante utilizar la verificación de versiones. Tenga cuidado con la suposición de que las pruebas y el desarrollo tienen lugar en un shell compatible con Bash 4.4 (por lo que se prueba el aspecto set -u
). Si esto le molesta, entonces otra opción es rechazar la compatibilidad (el script falla cuando falla la verificación de la versión) o rechazar set -u
. shopt -s nullglob
obliga for f in *.txt
a funcionar correctamente si *.txt
no encuentra archivos. El comportamiento predeterminado (también conocido como passglob ) pasa la plantilla sin cambios, lo que en caso de un resultado cero es peligroso por varias razones. Para globstar, esto activa la búsqueda recursiva. La sustitución es más fácil de usar que find
. Así que úsalo.
Pero no:
IFS='' set -f shopt -s failglob
- Establecer el delimitador de campo interno en una cadena vacía hace que sea imposible dividir la palabra. Suena como la solución perfecta. Desafortunadamente, este es un reemplazo incompleto para las variables de comillas y las sustituciones de comandos, y dado que va a usar comillas, no da nada. La razón por la cual las comillas aún deben usarse es porque de lo contrario las cadenas vacías se convierten en matrices vacías (como en la
test $x = ""
) y la expansión indirecta de la plantilla aún es posible. Además, los problemas con esta variable también causarán problemas con comandos como read
, que rompe construcciones como cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
cat /etc/fstab | while read -r dev mnt fs opt dump pass; do echo "$fs"; done'
. - La extensión de la plantilla está deshabilitada: no solo la infame extensión indirecta, sino también la extensión directa sin problemas, que, como dije, deberías usar. Entonces es difícil de aceptar. Y esto también es completamente opcional para un script compatible con shellcheck / shellharden.
- A diferencia de nullglob , failglob falla con un resultado nulo. Aunque para la mayoría de los comandos esto tiene sentido, por ejemplo,
rm -- *.txt
(porque para la mayoría de los comandos todavía no se espera que se ejecute con un resultado cero), obviamente, failglob solo puede usarse si no espera un resultado cero. Esto significa que generalmente no colocará plantillas de grupo en argumentos de comando a menos que asuma lo mismo. Pero lo que siempre puede suceder es usar nullglob y extender la plantilla a argumentos nulos en construcciones que puedan tomarlos, como un bucle o asignar valores a una matriz ( txt_files=(*.txt)
).
Cómo completar un script bash
El estado de salida del script es el estado del último comando ejecutado. Asegúrese de que represente un verdadero éxito o fracaso.
Lo peor es dejar la solución a una condición no relacionada en forma de una lista AND al final del script. Si la condición es falsa, el último comando ejecutado será la condición misma.
Para errexit, las condiciones en forma de una lista AND nunca se utilizan en primer lugar. Si no se usa errexit, considere manejar los errores incluso para el último comando, por lo que su estado de salida no se enmascarará si se agrega código adicional al script.
Malo:
condition && extra_stuff
Bueno (opción errexit):
if condition; then extra_stuff fi
Bueno (opción de manejo de errores):
if condition; then extra_stuff || exit fi exit 0
Cómo usar errexit
Como
set -e
.
Limpieza programada a nivel de programa
Si errexit funciona como debería, use esto para instalar cualquier limpieza necesaria al salir.
tmpfile="$(mktemp -t myprogram-XXXXXX)" cleanup() { rm -f "$tmpfile" } trap cleanup EXIT
Atrapado: errexit se ignora en los argumentos de comando
Aquí hay una "bomba" de ramificación muy complicada, cuya comprensión me fue muy querida. Mi script de compilación funcionó bien en diferentes máquinas de desarrollo, pero puso de rodillas al servidor de compilación:
set -e
Correcto (sustitución de comando en la tarea):
set -e
Advertencia:
local
comandos integrados
local
y de
export
siguen siendo comandos, por lo que esto sigue siendo incorrecto:
set -e
ShellCheck solo advierte sobre comandos especiales como
local
en este caso.
Para usar
local
, separe la declaración del trabajo:
set -e
Atrapado: errexit se ignora dependiendo del contexto de la persona que llama
A veces POSIX es terrible. Errexit se ignora en funciones, comandos de grupo e incluso subcapas si la persona que llama comprueba su éxito. Todos estos ejemplos imprimen
Unreachable
y
Great success
, por extraño que parezca.
Subshell:
( set -e false echo Unreachable ) && echo Great success
Equipo grupal:
{ set -e false echo Unreachable } && echo Great success
Función:
f() { set -e false echo Unreachable } f && echo Great success
Debido a esto, bash con errexit es prácticamente inadecuado para vincular: sí,
es posible ajustar las funciones de errexit para que funcionen, pero existen dudas de que el esfuerzo ahorrado (en el manejo explícito de errores) valga la pena. En cambio, considere dividir en scripts totalmente autónomos.
Evitar llamar al shell con comillas incorrectas
Al invocar comandos de otros lenguajes de programación, es más fácil cometer un error e invocar implícitamente el shell. Si este comando de shell es estático, es bueno, funciona o no. Pero si su programa de alguna manera procesa las líneas para construir este comando, entonces necesita comprender: ¡está
generando un script de shell ! Rara vez quiero hacer esto, y es muy agotador organizar todo correctamente:
- cita cada argumento;
- escapar de los caracteres correspondientes en los argumentos.
No importa en qué lenguaje de programación haga esto, hay al menos tres formas de construir un equipo correctamente. En orden de preferencia:
Plan A: prescindir de una cáscara
Si esto es solo un comando con argumentos (es decir, no hay funciones de shell como canalizar o redirigir), seleccione una opción de matriz.
- Malo (python3):
subprocess.check_call('rm -rf ' + path)
- Bien (python3):
subprocess.check_call(['rm', '-rf', path])
Malo (C ++):
std::string cmd = "rm -rf "; cmd += path; system(cmd);
Bueno (C / POSIX), menos manejo de errores:
char* const args[] = {"rm", "-rf", path, NULL}; pid_t child; posix_spawnp(&child, args[0], NULL, NULL, args, NULL); int status; waitpid(child, &status, 0);
Plan B: un script de shell estático
Si se requiere un shell, deje que los argumentos sean argumentos. Puede pensar que fue engorroso escribir un script de shell especial en su propio archivo y acceder a él hasta que vea ese truco:
Malo (python3):
subprocess.check_call('docker exec {} bash -ec "printf %s {} > {}"'.format(instance, content, path))
Bien (python3):
subprocess.check_call(['docker', 'exec', instance, 'bash', '-ec', 'printf %s "$0" > "$1"', content, path])
¿Puedes notar el script de shell?
Así es, el comando printf se redirige. Preste atención a los argumentos numerados correctamente citados. Implementar un script de shell estático está bien.
Estos ejemplos se ejecutan en Docker porque de lo contrario no serán tan útiles, pero Docker también es un gran ejemplo de un comando que ejecuta otros comandos basados en argumentos. A diferencia de Ssh, como veremos más adelante.
Última opción: procesamiento de línea
Si
debe ser una cadena (por ejemplo, porque debe funcionar a través de
ssh
), no se puede omitir. Tendrá que citar cada argumento y escapar de los caracteres necesarios para salir de estas citas. La forma más fácil es cambiar a comillas simples, porque tienen las reglas de escape más simples. Solo una regla:
'
→
'\"
.
Nombre de archivo típico entre comillas simples:
echo 'Don'\''t stop (12" dub mix).mp3'
¿Cómo usar este truco para ejecutar comandos ssh de forma segura? Esto es imposible! Bueno, aquí está la solución "a menudo correcta":
- La solución "a menudo correcta" (python3):
subprocess.check_call(['ssh', 'user@host', "sha1sum '{}'".format(path.replace("'", "'\\''"))])
Nosotros mismos debemos combinar todos los argumentos en una cadena para que Ssh no lo haga mal: si intenta pasar varios argumentos ssh, comenzará a combinar traicioneramente los argumentos sin comillas.
La razón por la que esto generalmente no es posible es porque la decisión correcta depende de las preferencias del usuario en el otro extremo, es decir, el shell remoto, que puede ser cualquier cosa. Básicamente, incluso podría ser tu madre. Es "a menudo correcto" suponer que el shell remoto es bash u otro shell compatible con POSIX, pero
fish es incompatible en esta etapa .