¿Cómo puedo fortalecer los scripts de bash para que no causen daño cuando se cambien en el futuro?

46

Entonces, eliminé mi carpeta de inicio (o, más precisamente, todos los archivos a los que tenía acceso de escritura). Lo que pasó es que tuve

build="build"
...
rm -rf "${build}/"*
...
<do other things with $build>

en un script bash y, después de no necesitarlo más $build, eliminar la declaración y todos sus usos, pero el rm. Bash felizmente se expande a rm -rf /*. Sí.

Me sentí estúpido, instalé la copia de seguridad, rehice el trabajo que perdí. Tratando de pasar la vergüenza.

Ahora, me pregunto: ¿cuáles son las técnicas para escribir scripts de bash para que tales errores no puedan ocurrir, o al menos sean menos probables? Por ejemplo, si hubiera escrito

FileUtils.rm_rf("#{build}/*")

En un guión de Ruby, el intérprete se habría quejado de buildno haber sido declarado, por lo que el lenguaje me protege.

Lo que he considerado en bash, además de acorralar rm(que, como mencionan muchas respuestas en preguntas relacionadas, no es nada problemático):

  1. rm -rf "./${build}/"*
    Eso habría matado mi trabajo actual (un repositorio de Git) pero nada más.
  2. Una variante / parametrización de rmeso requiere interacción cuando se actúa fuera del directorio actual. (No se pudo encontrar ninguno). Efecto similar.

¿Es eso, o hay otras formas de escribir scripts de bash que son "robustos" en este sentido?

Rafael
fuente
3
No hay razón para rm -rf "${build}/*no importar a dónde van las comillas. rm -rf "${build} hará lo mismo por el f.
Monty Harder
1
La comprobación estática con shellcheck.net es un lugar muy sólido para comenzar. Hay integración de editor disponible, por lo que puede recibir una advertencia de sus herramientas tan pronto como elimine la definición de algo que todavía se usa.
Charles Duffy
@CharlesDuffy Para agregar a mi vergüenza, escribí ese script en un IDE de estilo IDEA con BashSupport instalado, lo que advierte en ese caso. Entonces sí, punto válido, pero realmente necesitaba una cancelación difícil.
Raphael
2
Gotcha Asegúrese de tener en cuenta las advertencias en BashFAQ # 112 . set -uno está tan mal visto como set -eestá, pero aún tiene sus problemas.
Charles Duffy
2
respuesta de broma: solo coloca #! /usr/bin/env rubyen la parte superior de cada script de shell y olvídate de bash;)
Pod

Respuestas:

75
set -u

o

set -o nounset

Esto haría que el shell actual trate las expansiones de variables no definidas como un error:

$ unset build
$ set -u
$ rm -rf "$build"/*
bash: build: unbound variable

set -uy set -o nounsetson opciones de shell POSIX .

Un vacío valor sería no provocar un error sin embargo.

Para eso, usa

$ rm -rf "${build:?Error, variable is empty or unset}"/*
bash: build: Error, variable is empty or unset

La expansión de ${variable:?word}se expandiría al valor de a variablemenos que esté vacía o sin establecer. Si está vacío o sin establecer, wordse mostrará en un error estándar y el shell tratará la expansión como un error (el comando no se ejecutará, y si se ejecuta en un shell no interactivo, esto finalizaría). Dejar la :salida activaría el error solo para un valor no establecido, al igual que debajo set -u.

${variable:?word}es una expansión de parámetros POSIX .

Ninguno de estos provocaría la terminación de un shell interactivo a menos que set -e(o set -o errexit) también estuviera en vigor. ${variable:?word}hace que las secuencias de comandos salgan si la variable está vacía o sin establecer. set -uprovocaría la salida de un script si se usa junto con set -e.


En cuanto a tu segunda pregunta. No hay forma de limitar rma no trabajar fuera del directorio actual.

La implementación de GNU rmtiene una --one-file-systemopción que evita que elimine de forma recursiva los sistemas de archivos montados, pero eso es lo más cercano que creo que podemos obtener sin ajustar la rmllamada en una función que realmente verifica los argumentos.


Como nota al margen: ${build}es exactamente equivalente a $buildmenos que la expansión se produzca como parte de una cadena donde el carácter inmediatamente siguiente es un carácter válido en un nombre de variable, como en "${build}x".

Kusalananda
fuente
¡Muy bueno gracias! 1) ¿Es ${build:?msg}o ${build?msg}? 2) En el contexto de algo así como crear scripts, creo que usar una herramienta diferente de rm para una eliminación más segura estaría bien: sabemos que no queremos trabajar fuera del directorio actual, por lo que lo hacemos explícito al usar un método restringido mando. No hay necesidad de hacer rm más seguro en general.
Raphael
1
@Raphael Lo siento, debería estar con:. Corregiré ese error tipográfico ahora y mencionaré su importancia más adelante cuando vuelva a mi computadora.
Kusalananda
2
@Raphael Ok, he añadido una frase corta sobre lo que ocurre sin el :(que desencadenaría el error sólo para no definidas las variables). No me atrevo a intentar escribir una herramienta que pueda hacer frente a la eliminación de archivos bajo una ruta específica exclusivamente, en todas las circunstancias. Analizar caminos y preocuparse / no preocuparse por enlaces simbólicos, etc., es un poco demasiado complicado para mí en este momento.
Kusalananda
1
Gracias, la explicación ayuda! Con respecto al "rm local": estaba pensando mucho, tal vez buscando un "seguro, ese es <nombre de la herramienta>", ¡pero ciertamente no estoy tratando de ayudarlo a vampiros a escribirlo! OO Todo bien, has ayudado lo suficiente! :)
Raphael
2
@MateuszKonieczny No creo que lo haga, lo siento. Creo que medidas de seguridad como estas deberían usarse cuando sea necesario . Al igual que con todo lo que hace que un entorno sea seguro, eventualmente hará que las personas sean cada vez más descuidadas y dependientes de las medidas de seguridad. Es mejor saber qué hacen todas y cada una de las medidas de seguridad y luego aplicarlas selectivamente según sea necesario.
Kusalananda
12

Voy a sugerir comprobaciones de validación normales usando test/[ ]

Hubiera estado a salvo si hubiera escrito su guión como tal:

build="build"
...
[ -n "${build}" ] || exit 1
rm -rf "${build}/"*
...

Los [ -n "${build}" ]controles que "${build}"es una cadena de longitud no cero .

El ||es el operador lógico OR en bash. Hace que se ejecute otro comando si falla el primero.

De esta manera, había ${build}estado vacío / indefinido / etc. el script habría salido (con un código de retorno de 1, que es un error genérico).

Esto también lo habría protegido en caso de que eliminara todos los usos ${build}porque [ -n "" ]siempre será falso.


La ventaja de usar test/ [ ]es que hay muchas otras comprobaciones más significativas que también puede usar.

Por ejemplo:

[ -f FILE ] True if FILE exists and is a regular file.
[ -d FILE ] True if FILE exists and is a directory.
[ -O FILE ] True if FILE exists and is owned by the effective user ID.
Centimane
fuente
Justo, pero un poco difícil de manejar. ¿Me estoy perdiendo algo o hace más o menos lo mismo que ${variable:?word}@Kusalananda propone?
Raphael
@Raphael funciona de manera similar en el caso de "¿la cadena tiene un valor" pero test(es decir, [tiene muchas otras comprobaciones que son relevantes, como -d(el argumento es un directorio), -f(el argumento es un archivo), -O(el archivo es propiedad del usuario actual) y así sucesivamente.
Centimane
1
@Raphael también, la validación debería ser una parte normal de cualquier código / script. También tenga en cuenta que si eliminó todas las instancias de compilación, ¿no lo habría eliminado también ${build:?word}? El ${variable:?word}formato no lo protege si elimina todas las instancias de la variable.
Centimane
1
1) Creo que hay una diferencia entre asegurarse de que las suposiciones se mantengan (existen archivos, etc.) y verificar si las variables están establecidas (en el trabajo de un compilador / intérprete). Si tengo que hacer esto último a mano, es mejor que tenga una sintaxis ultra elegante, que ifno es completa. 2) "¿no habría eliminado también $ {build:? Word} junto con él" - el escenario es que me perdí el uso de la variable. Usar ${v:?w}me habría protegido del daño. Si hubiera eliminado todos los usos, incluso el acceso simple no habría sido perjudicial, obviamente.
Raphael
Dicho todo esto, su respuesta es una respuesta justa y sincera a la pregunta general del titular: asegurarse de que los supuestos se mantengan es importante para los guiones que se mantienen. La cuestión específica en el cuerpo de la pregunta es, en mi opinión, mejor respondida por Kusalananda. ¡Gracias!
Raphael
4

En su caso específico, he reelaborado la 'eliminación' en el pasado para mover archivos / directorios en su lugar (suponiendo que / tmp esté en la misma partición que su directorio):

# mktemp -d is also a good, reliable choice
trashdir=/tmp/.trash-$USER/trash-`date`
mkdir -p "$trashdir"
...
mv "${build}"/* "$trashdir"
...

Detrás de escena, esto mueve las referencias de archivo / directorio de nivel superior desde las $trashdirestructuras de directorio de origen a destino en la misma partición, y no pasa tiempo recorriendo la estructura del directorio y liberando los bloques de disco por archivo en ese mismo momento. Esto produce una limpieza mucho más rápida mientras el sistema está en uso activo, a cambio de un reinicio un poco más lento (/ tmp se limpia en los reinicios).

Alternativamente, una entrada cron para limpiar periódicamente /tmp/.trash-$USER evitará que / tmp se llene, para procesos (por ejemplo, compilaciones) que consumen mucho espacio en disco. Si su directorio está en una partición diferente como / tmp, puede crear un directorio similar a / tmp-like en su partición y hacer que cron limpie eso en su lugar.

Sin embargo, lo más importante es que si arruinas las variables de alguna manera, puedes recuperar el contenido antes de que ocurra la limpieza.

usuario117529
fuente
2
"Esto produce una limpieza mucho más rápida mientras el sistema está en uso activo", ¿verdad? Pensé que ambos son solo para cambiar los inodes afectados. Ciertamente no es más rápido si /tmpestá en otra partición (creo que siempre lo es para mí, al menos para los scripts que se ejecutan en el espacio del usuario); entonces, la carpeta de la papelera debe cambiarse (y no se beneficiará del manejo del sistema operativo /tmp).
Raphael
1
Puede usarlo mktemp -dpara obtener ese directorio temporal sin pisar los dedos del pie de otro proceso (y honrarlo correctamente $TMPDIR).
Toby Speight
Agregó sus notas precisas y útiles de ambos, gracias. Creo que originalmente publiqué esto demasiado rápido sin considerar los puntos que mencionaste.
user117529
2

Use la sustitución de parámetros bash para asignar valores predeterminados cuando las variables no se inicializan, por ejemplo:

rm -rf $ {variable: - "/ nonexistent"}

rackandboneman
fuente
1

Siempre trato de comenzar mis scripts de Bash con una línea #!/bin/bash -ue.

-emedios "fallan en la primera uncathed e rror";

-umedios "fallan en primer uso de u ndeclared variable".

Encuentre más detalles en un excelente artículo Utilice el modo estricto de Bash no oficial (a menos que le guste la depuración) . El autor también recomienda usar, set -o pipefail; IFS=$'\n\t'pero para mi propósito esto es excesivo.

niya3
fuente
1

El consejo general para verificar si su variable está configurada es una herramienta útil para evitar este tipo de problemas. Pero en este caso había una solución más simple.

Es muy probable que no sea necesario englobar el contenido del $builddirectorio para eliminarlos, pero no el $builddirectorio en sí. Entonces, si omitió lo extraño *, se convertiría en un valor no establecido rm -rf /que, por defecto, la mayoría de las implementaciones de rm en la última década se negarán a realizar (a menos que desactive esta protección con la --no-preserve-rootopción de GNU rm ).

Omitir el seguimiento /también daría como rm ''resultado el mensaje de error:

rm: can't remove '': No such file or directory

Esto funciona incluso si su comando rm no implementa protección para /.

Eschwartz
fuente
-2

Estás pensando en términos de lenguaje de programación, pero bash es un lenguaje de script :-) Entonces, usa un paradigma de instrucción completamente diferente para un paradigma de lenguaje completamente diferente .

En este caso:

rmdir ${build}

Como rmdirse negará a eliminar un directorio no vacío, primero debe eliminar los miembros. Sabes cuáles son esos miembros, ¿verdad? Probablemente son parámetros para su script, o derivados de un parámetro, por lo tanto:

rm -rf ${build}/${parameter}
rmdir ${build}

Ahora, si coloca algunos otros archivos o directorios como un archivo temporal o algo que no debería tener, rmdirarrojará un error. Manejarlo adecuadamente, luego:

rmdir ${build} || build_dir_not_empty "${build}"

Esta forma de pensar me ha servido bien porque ... sí, he estado allí, hecho eso.

Rico
fuente
66
Gracias por su esfuerzo, pero esto está completamente fuera de lugar. La suposición "sabes cuáles son esos miembros" es incorrecta; Piense en la salida del compilador. make cleanutilizará algunos comodines (a menos que un compilador muy diligente cree una lista de cada archivo que crea). Además, me parece que tener rm -rf ${build}/${parameter}solo mueve el problema un poco hacia abajo.
Raphael
Hm. Mira cómo se lee eso. "Gracias, pero esto es por algo que no revelé en la pregunta original, así que no solo rechazaré su respuesta, sino que la rechazaré, a pesar de que sea más aplicable al caso general". Guau.
Rico
"Permítame hacer suposiciones adicionales más allá de lo que escribió en la pregunta y luego escribir una respuesta especializada". * encogimiento * (Para su información, no es que sea de su negocio: Yo no downvote Podría decirse que debería haber, porque la respuesta no fue útil (para mí, al menos)..)
Rafael
Hm. No hice suposiciones y escribí una respuesta general . No hay nada en absoluto makeen la pregunta. Escribir una respuesta que solo sea aplicable makesería una suposición muy específica y sorprendente. Mi respuesta funciona para otros casos make, que no sean el desarrollo de software, que no sea su problema de novato específico que tenía al eliminar sus cosas.
Rico
1
Kusalananda me enseñó a pescar. Viniste y dijiste: "Como vives en las llanuras, ¿por qué no comes carne de res en su lugar?"
Raphael