¿Cómo puedo verificar si un archivo se puede crear o truncar / sobrescribir en bash?

15

El usuario llama a mi script con una ruta de archivo que se creará o se sobrescribirá en algún momento del script, como foo.sh file.txto foo.sh dir/file.txt.

El comportamiento de crear o sobrescribir es muy similar a los requisitos para colocar el archivo en el lado derecho del >operador de redireccionamiento de salida, o pasarlo como argumento a tee(de hecho, pasarlo como argumento teees exactamente lo que estoy haciendo) )

Antes de entrar en las entrañas de la secuencia de comandos, quiero hacer una verificación razonable si el archivo se puede crear / sobrescribir, pero no crearlo realmente. Esta verificación no tiene que ser perfecta, y sí, me doy cuenta de que la situación puede cambiar entre la verificación y el punto en el que el archivo está realmente escrito, pero aquí estoy de acuerdo con una solución de tipo de mejor esfuerzo para poder rescatarme temprano en el caso de que la ruta del archivo no sea válida.

Ejemplos de razones por las que el archivo no se pudo crear:

  • el archivo contiene un componente de directorio, dir/file.txtpero el directorio dirno existe
  • el usuario no tiene permisos de escritura en el directorio especificado (o el CWD si no se especificó ningún directorio

Sí, me doy cuenta de que verificar los permisos "por adelantado" no es The UNIX Way ™ , sino que debería probar la operación y pedir perdón más tarde. Sin embargo, en mi script en particular, esto conduce a una mala experiencia de usuario y no puedo cambiar el componente responsable.

BeeOnRope
fuente
¿El argumento siempre se basará en el directorio actual o el usuario podría especificar una ruta completa?
Jesse_b
@Jesse_b: supongo que el usuario podría especificar una ruta absoluta como /foo/bar/file.txt. Básicamente paso la ruta a Me teegusta tee $OUT_FILEdonde OUT_FILEse pasa en la línea de comando. Eso debería "funcionar" con rutas absolutas y relativas, ¿verdad?
BeeOnRope
@BeeOnRope, no, tee -- "$OUT_FILE"al menos necesitarías . ¿Qué pasa si el archivo ya existe o existe pero no es un archivo normal (directorio, enlace simbólico, fifo)?
Stéphane Chazelas
@ StéphaneChazelas - bueno, estoy usando tee "${OUT_FILE}.tmp". Si el archivo ya existe, se teesobrescribe, que es el comportamiento deseado en este caso. Si es un directorio, teefallará (creo). enlace simbólico No estoy 100% seguro?
BeeOnRope

Respuestas:

11

La prueba obvia sería:

if touch /path/to/file; then
    : it can be created
fi

Pero en realidad crea el archivo si aún no está allí. Podríamos limpiar después de nosotros mismos:

if touch /path/to/file; then
    rm /path/to/file
fi

Pero esto eliminaría un archivo que ya existía, que probablemente no desee.

Sin embargo, tenemos una forma de evitar esto:

if mkdir /path/to/file; then
    rmdir /path/to/file
fi

No puede tener un directorio con el mismo nombre que otro objeto en ese directorio. No puedo pensar en una situación en la que pueda crear un directorio pero no crear un archivo. Después de esta prueba, su script tendrá libertad para crear un convencional /path/to/filey hacer lo que le plazca.

DopeGhoti
fuente
2
¡Eso maneja muy bien tantos problemas! Un comentario de código explicando y justificando que una solución inusual podría exceder la longitud de su respuesta. :-)
jpaugh
También lo he usado if mkdir /var/run/lockdircomo prueba de bloqueo. Esto es atómico, mientras que if ! [[ -f /var/run/lockfile ]]; then touch /var/run/lockfiletiene una pequeña porción de tiempo entre la prueba y touchdonde puede comenzar otra instancia del script en cuestión.
DopeGhoti
8

De lo que estoy reuniendo, quieres verificar eso cuando uses

tee -- "$OUT_FILE"

(tenga en cuenta que --o no funcionaría para los nombres de archivo que comienzan con -), teepodría abrir el archivo para escribir.

Eso es eso:

  • la longitud de la ruta del archivo no excede el límite PATH_MAX
  • el archivo existe (después de la resolución del enlace simbólico) y no es de tipo directorio y tiene permiso de escritura.
  • si el archivo no existe, el nombre de directorio del archivo existe (después de la resolución del enlace simbólico) como un directorio y tiene permiso de escritura y búsqueda y la longitud del nombre de archivo no excede el límite NAME_MAX del sistema de archivos en el que reside el directorio.
  • o el archivo es un enlace simbólico que apunta a un archivo que no existe y no es un bucle de enlace simbólico pero cumple con los criterios anteriores

Ignoraremos por ahora los sistemas de archivos como vfat, ntfs o hfsplus que tienen limitaciones sobre los valores de byte que pueden contener los nombres de archivos, la cuota de disco, el límite de proceso, selinux, apparmor u otro mecanismo de seguridad en el camino, sistema de archivos completo, no queda ningún inodo, dispositivo archivos que no se pueden abrir de esa manera por una razón u otra, archivos que son ejecutables actualmente asignados en algún espacio de direcciones de proceso, todo lo cual también podría afectar la capacidad de abrir o crear el archivo.

Con zsh:

zmodload zsh/system
tee_would_likely_succeed() {
  local file=$1 ERRNO=0 LC_ALL=C
  if [ -d "$file" ]; then
    return 1 # directory
  elif [ -w "$file" ]; then
    return 0 # writable non-directory
  elif [ -e "$file" ]; then
    return 1 # exists, non-writable
  elif [ "$errnos[ERRNO]" != ENOENT ]; then
    return 1 # only ENOENT error can be recovered
  else
    local dir=$file:P:h base=$file:t
    [ -d "$dir" ] &&    # directory
      [ -w "$dir" ] &&  # writable
      [ -x "$dir" ] &&  # and searchable
      (($#base <= $(getconf -- NAME_MAX "$dir")))
    return
  fi
}

En basho cualquier shell similar a Bourne, simplemente reemplace el

zmodload zsh/system
tee_would_likely_succeed() {
  <zsh-code>
}

con:

tee_would_likely_succeed() {
  zsh -s -- "$@" << 'EOF'
  zmodload zsh/system
  <zsh-code>
EOF
}

Las zshcaracterísticas específicas aquí son $ERRNO(que expone el código de error de la última llamada al sistema) y una $errnos[]matriz asociativa para traducir a los nombres de macro C estándar correspondientes. Y el $var:h(de csh) y $var:P(necesita zsh 5.3 o superior).

bash aún no tiene características equivalentes.

$file:hpuede ser reemplazado con dir=$(dirname -- "$file"; echo .); dir=${dir%??}, o con GNU dirname: IFS= read -rd '' dir < <(dirname -z -- "$file").

Para $errnos[ERRNO] == ENOENT, un enfoque podría ser ejecutar ls -Lden el archivo y verificar si el mensaje de error corresponde al error ENOENT. Sin embargo, hacer eso de manera confiable y portátil es complicado.

Un enfoque podría ser:

msg_for_ENOENT=$(LC_ALL=C ls -d -- '/no such file' 2>&1)
msg_for_ENOENT=${msg_for_ENOENT##*:}

(suponiendo que el mensaje de error termina con la syserror()traducción de ENOENTy que esa traducción no incluye a :) y luego, en lugar de [ -e "$file" ], hacer:

err=$(ls -Ld -- "$file" 2>&1)

Y compruebe si hay un error ENOENT con

case $err in
  (*:"$msg_for_ENOENT") ...
esac

La $file:Pparte es la más difícil de lograr bash, especialmente en FreeBSD.

FreeBSD tiene un realpathcomando y un readlinkcomando que acepta una -fopción, pero no se pueden usar en los casos en que el archivo es un enlace simbólico que no se resuelve. Eso es lo mismo con perl's Cwd::realpath().

python'S os.path.realpath()no parece funcionar de manera similar a zsh $file:P, por lo que suponiendo que al menos una versión de pythonestá instalado y que hay un pythoncomando que se refiere a uno de ellos, se puede hacer (que no es un hecho en FreeBSD es):

dir=$(python -c '
import os, sys
print(os.path.realpath(sys.argv[1]) + ".")' "$dir") || return
dir=${dir%.}

Pero entonces, también podrías hacer todo el asunto python.

O podría decidir no manejar todos esos casos de esquina.

Stéphane Chazelas
fuente
Sí, entendiste mi intención correctamente. De hecho, la pregunta original no estaba clara: originalmente hablé sobre la creación del archivo, pero en realidad lo estoy usando, por teelo que el requisito es realmente que el archivo se pueda crear si no existe, o se puede truncar a cero y sobrescribir si lo hace (o de lo contrario teemaneja eso)
BeeOnRope
Parece que su última edición borró el formato
D. Ben Knoble
Gracias, @ obispo, @ D.BenKnoble, ver edición.
Stéphane Chazelas
1
@DennisWilliamson, bueno, sí, un shell es una herramienta para invocar programas, y cada programa que invoques en tu script tiene que estar instalado para que funcione, eso no hace falta decirlo. macOS viene con bash y zsh instalados de manera predeterminada. FreeBSD viene con ninguno de los dos. El OP puede tener una buena razón para querer hacer eso en bash, tal vez sea algo que quieran agregar a un script de bash ya escrito.
Stéphane Chazelas
1
@pipe, nuevamente, un shell es un intérprete de comandos. Verá que muchos fragmentos de código shell escriben aquí invocación de intérpretes de otros idiomas como perl, awk, sedo python(o incluso sh, bash...). El scripting de Shell se trata de usar el mejor comando para la tarea. Aquí incluso perlno tiene un equivalente de zsh's $var:P(se Cwd::realpathcomporta como BSD realpatho readlink -fmientras desea que se comporte como GNU readlink -fo python' s os.path.realpath())
Stéphane Chazelas
6

Una opción que quizás desee considerar es crear el archivo desde el principio, pero solo completarlo más adelante en su secuencia de comandos. Puede usar el execcomando para abrir el archivo en un descriptor de archivo (como 3, 4, etc.) y luego usar una redirección a un descriptor de archivo ( >&3, etc.) para escribir contenidos en ese archivo.

Algo como:

#!/bin/bash

# Open the file for read/write, so it doesn't get
# truncated just yet (to preserve the contents in
# case the initial checks fail.)
exec 3<>dir/file.txt || {
    echo "Error creating dir/file.txt" >&2
    exit 1
}

# Long checks here...
check_ok || {
    echo "Failed checks" >&2
    # cleanup file before bailing out
    rm -f dir/file.txt
    exit 1
}

# We're ready to write, first truncate the file.
# Use "truncate(1)" from coreutils, and pass it
# /dev/fd/3 so the file doesn't need to be reopened.
truncate -s 0 /dev/fd/3

# Now populate the file, use a redirection to write
# to the previously opened file descriptor.
populate_contents >&3

También puede usar a trappara limpiar el archivo por error, es una práctica común.

De esta manera, obtienes una verificación real de los permisos para poder crear el archivo, al mismo tiempo que puedes realizarlo lo suficientemente temprano como para que si eso falla, no hayas pasado tiempo esperando las largas verificaciones.


ACTUALIZADO: para evitar tropezar con el archivo en caso de que las comprobaciones fallen, utilice la fd<>fileredirección de bash que no trunca el archivo de inmediato. (No nos importa leer el archivo, esto es solo una solución alternativa, por lo que no lo truncamos. Anexar con >>probablemente probablemente también funcione, pero tiendo a encontrar este un poco más elegante, manteniendo el indicador O_APPEND fuera de la foto.)

Cuando estemos listos para reemplazar el contenido, primero debemos truncar el archivo (de lo contrario, si estamos escribiendo menos bytes de los que había antes en el archivo, los bytes finales permanecerían allí). Podemos usar el truncado (1) comando de coreutils para ese propósito, y podemos pasarle el descriptor de archivo abierto que tenemos (usando el /dev/fd/3pseudo-archivo) para que no necesite volver a abrir el archivo. (Nuevamente, técnicamente algo más simple como : >dir/file.txtprobablemente funcionaría, pero no tener que volver a abrir el archivo es una solución más elegante).

filbranden
fuente
Había considerado esto, pero el problema es que si se produce otro error inocente en algún momento entre la creación del archivo y el momento en que más tarde escribiría en él, el usuario probablemente se molestará al descubrir que el archivo especificado se sobrescribió .
BeeOnRope
1
@BeeOnRope otra opción que no bloqueará el archivo es intentar no agregarle nada : echo -n >>fileo true >> file. Por supuesto, ext4 tiene archivos de solo agregado, pero podría vivir con ese falso positivo.
Mosvy
1
@BeeOnRope Si necesita preservar (a) el archivo existente o (b) el archivo nuevo (completo), esa es una pregunta diferente de la que ha formulado. Una buena respuesta es mover el archivo existente a un nuevo nombre (por ejemplo my-file.txt.backup) antes de crear el nuevo. Otra solución es escribir el nuevo archivo en un archivo temporal en la misma carpeta, y luego copiarlo sobre el archivo anterior después de que el resto del script tenga éxito --- si esa última operación falla, el usuario puede solucionar el problema manualmente sin perder su progreso
jpaugh
1
@BeeOnRope Si opta por la ruta de "reemplazo atómico" (¡que es una buena!), Normalmente querrá escribir el archivo temporal en el mismo directorio que el archivo final (deben estar en el mismo sistema de archivos para cambiar el nombre) (2) para tener éxito) y use un nombre único (por lo tanto, si hay dos instancias de la secuencia de comandos ejecutándose, no se golpearán entre sí los archivos temporales). Abrir un archivo temporal para escribir en el mismo directorio suele ser una buena indicación de que Podrá cambiarle el nombre más tarde (bueno, excepto si el objetivo final existe y es un directorio), por lo que en cierta medida también responde a su pregunta.
filbranden
1
@jpaugh: soy bastante reacio a hacer eso. Inevitablemente conduciría a respuestas que intentan resolver mi problema de nivel superior, lo que podría admitir soluciones que no tienen nada que ver con la pregunta "¿Cómo puedo verificar ...". Independientemente de cuál sea mi problema de alto nivel, creo que esta pregunta es única como algo que razonablemente quieras hacer. Sería injusto para las personas que están respondiendo esa pregunta específica si la cambiara para "resolver este problema específico que tengo con mi script". Incluí esos detalles aquí porque me lo pidieron, pero no quiero cambiar la pregunta principal.
BeeOnRope
4

Creo que la solución de DopeGhoti es mejor, pero esto también debería funcionar:

file=$1
if [[ "${file:0:1}" == '/' ]]; then
    dir=${file%/*}
elif [[ "$file" =~ .*/.* ]]; then
    dir="$(PWD)/${file%/*}"
else
    dir=$(PWD)
fi

if [[ -w "$dir" ]]; then
    echo "writable"
    #do stuff with writable file
else
    echo "not writable"
    #do stuff without writable file
fi

La primera construcción if comprueba si el argumento es una ruta completa (comienza con /) y establece la dirvariable en la ruta del directorio hasta la última /. De lo contrario, si el argumento no comienza con a /pero contiene un /(especificando un subdirectorio) se establecerá diren el directorio de trabajo actual + la ruta del subdirectorio. De lo contrario, asume el directorio de trabajo actual. Luego verifica si ese directorio es grabable.

Jesse_b
fuente
4

¿Qué pasa con el uso del testcomando normal como se describe a continuación?

FILE=$1

DIR=$(dirname $FILE) # $DIR now contains '.' for file names only, 'foo' for 'foo/bar'

if [ -d $DIR ] ; then
  echo "base directory $DIR for file exists"
  if [ -e $FILE ] ; then
    if [ -w $FILE ] ; then
      echo "file exists, is writeable"
    else
      echo "file exists, NOT writeable"
    fi
  elif [ -w $DIR ] ; then
    echo "directory is writeable"
  else
    echo "directory is NOT writeable"
  fi
else
  echo "can NOT create file in non-existent directory $DIR "
fi
Jaleks
fuente
¡Casi duplico esta respuesta! Buena introducción, pero podría mencionar man test, y eso [ ]es equivalente a la prueba (ya no es de conocimiento común). Aquí hay una frase que estaba a punto de usar en mi respuesta inferior, para cubrir el caso exacto, para la posteridad:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Iron Gremlin
4

Usted mencionó que la experiencia del usuario estaba impulsando su pregunta. Contestaré desde un ángulo UX, ya que tienes buenas respuestas en el aspecto técnico.

En lugar de realizar la verificación por adelantado, ¿qué hay de escribir los resultados en un archivo temporal y luego, al final, colocar los resultados en el archivo deseado por el usuario? Me gusta:

userfile=${1:?Where would you like the file written?}
tmpfile=$(mktemp)

# ... all the complicated stuff, writing into "${tmpfile}"

# fill user's file, keeping existing permissions or creating anew
# while respecting umask
cat "${tmpfile}" > "${userfile}"
if [ 0 -eq $? ]; then
    rm "${tmpfile}"
else
    echo "Couldn't write results into ${userfile}." >&2
    echo "Results available in ${tmpfile}." >&2
    exit 1
fi

Lo bueno de este enfoque: produce la operación deseada en el escenario normal de ruta feliz, evita el problema de atomicidad de prueba y configuración, conserva los permisos del archivo de destino mientras se crea si es necesario, y es muy simple de implementar.

Nota: si lo hubiéramos usado mv, estaríamos manteniendo los permisos del archivo temporal; creo que no queremos eso: queremos mantener los permisos tal como están establecidos en el archivo de destino.

Ahora, lo malo: requiere el doble de espacio ( cat .. >construcción), obliga al usuario a hacer un trabajo manual si el archivo de destino no se puede escribir en el momento en que lo necesitaba, y deja el archivo temporal por ahí (lo que podría tener seguridad o problemas de mantenimiento).

obispo
fuente
De hecho, esto es más o menos lo que estoy haciendo ahora. Escribo la mayoría de los resultados en un archivo temporal y luego al final hago el paso final de procesamiento y escribo los resultados en el archivo final. El problema es que quiero rescatarme temprano (al comienzo del script) si es probable que ese paso final falle. El script puede ejecutarse desatendido durante minutos u horas, por lo que realmente desea saber de antemano que está condenado al fracaso.
BeeOnRope
Claro, pero hay muchas maneras en que esto podría fallar: el disco podría llenarse, el directorio ascendente podría eliminarse, los permisos podrían cambiar, el archivo de destino podría usarse para otras cosas importantes y el usuario olvidó que asignó ese mismo archivo para ser destruido por esto operación. Si hablamos de esto desde una perspectiva pura de UX, entonces quizás lo correcto sea tratarlo como un envío de trabajo: al final, cuando sepa que funcionó correctamente hasta su finalización, solo dígale al usuario dónde reside el contenido resultante y ofrezca un comando sugerido para que lo muevan ellos mismos.
obispo
En teoría, sí, hay infinitas formas en que esto podría fallar. En la práctica, la gran mayoría de las veces esto falla porque la ruta proporcionada no es válida. No puedo evitar razonablemente que algún usuario malintencionado modifique simultáneamente el FS para interrumpir el trabajo en el medio de la operación, pero ciertamente puedo verificar la causa de falla # 1 de una ruta inválida o no editable.
BeeOnRope
2

TL; DR:

: >> "${userfile}"

A diferencia de <> "${userfile}"o touch "${userfile}", esto no hará modificaciones espurias a las marcas de tiempo del archivo y también funcionará con archivos de solo escritura.


Desde el OP:

Quiero hacer una comprobación razonable si el archivo se puede crear / sobrescribir, pero no crearlo realmente.

Y de tu comentario a mi respuesta desde una perspectiva UX :

La gran mayoría de las veces esto falla porque la ruta proporcionada no es válida. No puedo evitar razonablemente que algún usuario deshonesto modifique simultáneamente el FS para interrumpir el trabajo en medio de la operación, pero ciertamente puedo verificar la causa de falla n. ° 1 de una ruta no válida o no editable.

La única prueba confiable es open(2)el archivo, porque solo eso resuelve todas las preguntas sobre la capacidad de escritura: ruta, propiedad, sistema de archivos, red, contexto de seguridad, etc. Cualquier otra prueba abordará una parte de la capacidad de escritura, pero no otras. Si desea un subconjunto de pruebas, finalmente tendrá que elegir lo que es importante para usted.

Pero aquí hay otro pensamiento. Por lo que entiendo:

  1. el proceso de creación de contenido es de larga duración y
  2. El archivo de destino debe dejarse en un estado coherente.

Desea hacer esta verificación previa debido al n. ° 1, y no quiere jugar con un archivo existente debido al n. ° 2. Entonces, ¿por qué no le pide al shell que abra el archivo para agregarlo, pero en realidad no agrega nada?

$ tree -ps
.
├── [dr-x------        4096]  dir_r
├── [drwx------        4096]  dir_w
├── [-r--------           0]  file_r
└── [-rw-------           0]  file_w

$ for p in file_r dir_r/foo file_w dir_w/foo; do : >> $p; done
-bash: file_r: Permission denied
-bash: dir_r/foo: Permission denied

$ tree -ps
.
├── [dr-x------        4096]  dir_r
├── [drwx------        4096]  dir_w
   └── [-rw-rw-r--           0]  foo
├── [-r--------           0]  file_r
└── [-rw-------           0]  file_w

Debajo del capó, esto resuelve la pregunta de escritura exactamente como se quería:

open("dir_w/foo", O_WRONLY|O_CREAT|O_APPEND, 0666) = 3

pero sin modificar los contenidos o metadatos del archivo. Ahora, sí, este enfoque:

  • no te dice si el archivo es solo agregar, lo que podría ser un problema cuando actualizas al final de tu creación de contenido. Puede detectar esto, hasta cierto punto, lsattry reaccionar en consecuencia.
  • crea un archivo que no existía anteriormente, si ese es el caso: mitigue esto con un selectivo rm.

Si bien afirmo (en mi otra respuesta) que el enfoque más fácil de usar es crear un archivo temporal que el usuario tenga que mover, creo que este es el enfoque menos hostil para el usuario para examinar completamente su entrada.

obispo
fuente