Comment puis-je vérifier si un fichier peut être créé ou tronqué / écrasé dans bash?

15

L'utilisateur appelle mon script avec un chemin de fichier qui sera soit créé soit écrasé à un moment donné du script, comme foo.sh file.txtou foo.sh dir/file.txt.

Le comportement de création ou d'écrasement ressemble beaucoup aux exigences pour placer le fichier du côté droit de l' >opérateur de redirection de sortie, ou le passer comme argument à tee(en fait, le passer comme argument à teeest exactement ce que je fais) ).

Avant d'entrer dans les tripes du script, je veux vérifier raisonnablement si le fichier peut être créé / écrasé, mais pas réellement le créer. Cette vérification ne doit pas être parfaite, et oui je me rends compte que la situation peut changer entre la vérification et le point où le fichier est réellement écrit - mais ici, je suis OK avec une solution de type meilleur effort afin que je puisse renflouer tôt dans le cas où le chemin du fichier n'est pas valide.

Exemples de raisons pour lesquelles le fichier n'a pas pu être créé:

  • le fichier contient un composant de répertoire, comme dir/file.txtmais le répertoire dirn'existe pas
  • l'utilisateur n'a pas d'autorisations d'écriture dans le répertoire spécifié (ou le CWD si aucun répertoire n'a été spécifié

Oui, je me rends compte que la vérification des autorisations "à l'avance" n'est pas The UNIX Way ™ , je devrais plutôt essayer l'opération et demander pardon plus tard. Cependant, dans mon script particulier, cela conduit à une mauvaise expérience utilisateur et je ne peux pas changer le composant responsable.

BeeOnRope
la source
L'argument sera-t-il toujours basé sur le répertoire actuel ou l'utilisateur pourrait-il spécifier un chemin complet?
Jesse_b
@Jesse_b - Je suppose que l'utilisateur pourrait spécifier un chemin absolu comme /foo/bar/file.txt. Fondamentalement, je passe le chemin pour teeaimer tee $OUT_FILEOUT_FILEest passé sur la ligne de commande. Cela devrait "simplement fonctionner" avec des chemins absolus et relatifs, non?
BeeOnRope
@BeeOnRope, non, vous en auriez besoin tee -- "$OUT_FILE"au moins. Que faire si le fichier existe déjà ou existe mais n'est pas un fichier normal (répertoire, lien symbolique, fifo)?
Stéphane Chazelas
@ StéphaneChazelas - et bien j'utilise tee "${OUT_FILE}.tmp". Si le fichier existe déjà, teeécrase, ce qui est le comportement souhaité dans ce cas. Si c'est un répertoire, teeéchouera (je pense). lien symbolique Je ne suis pas sûr à 100%?
BeeOnRope

Réponses:

11

Le test évident serait:

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

Mais il crée réellement le fichier s'il n'est pas déjà là. Nous pourrions nettoyer après nous:

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

Mais cela supprimerait un fichier qui existait déjà, ce que vous ne voulez probablement pas.

Nous avons cependant un moyen de contourner cela:

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

Vous ne pouvez pas avoir un répertoire portant le même nom qu'un autre objet dans ce répertoire. Je ne peux pas penser à une situation dans laquelle vous seriez en mesure de créer un répertoire mais pas de créer un fichier. Après ce test, votre script serait libre de créer un conventionnel /path/to/fileet de faire ce qui lui plaira.

DopeGhoti
la source
2
Cela gère très bien tant de problèmes! Un commentaire de code expliquant et justifiant qu'une solution inhabituelle pourrait bien dépasser la longueur de votre réponse. :-)
jpaugh
J'ai également utilisé if mkdir /var/run/lockdircomme test de verrouillage. C'est atomique, alors queif ! [[ -f /var/run/lockfile ]]; then touch /var/run/lockfile a une petite tranche de temps entre le test et touchoù une autre instance du script en question peut commencer.
DopeGhoti
8

D'après ce que je recueille, vous voulez vérifier que lors de l'utilisation

tee -- "$OUT_FILE"

(notez que le --ou cela ne fonctionnerait pas pour les noms de fichiers commençant par -), teeréussirait à ouvrir le fichier en écriture.

C'est ça:

  • la longueur du chemin du fichier ne dépasse pas la limite PATH_MAX
  • le fichier existe (après la résolution du lien symbolique) et n'est pas de type répertoire et vous y avez l'autorisation d'écriture.
  • si le fichier n'existe pas, le nom du fichier existe (après la résolution du lien symbolique) en tant que répertoire et vous y avez l'autorisation d'écrire et de rechercher et la longueur du nom de fichier ne dépasse pas la limite NAME_MAX du système de fichiers dans lequel se trouve le répertoire.
  • ou le fichier est un lien symbolique qui pointe vers un fichier qui n'existe pas et qui n'est pas une boucle de lien symbolique mais qui répond aux critères juste au-dessus

Nous ignorerons pour l'instant les systèmes de fichiers comme vfat, ntfs ou hfsplus qui ont des limitations sur les valeurs d'octets que les noms de fichiers peuvent contenir, quota de disque, limite de processus, selinux, apparmor ou autre mécanisme de sécurité, système de fichiers complet, pas d'inode restant, périphérique les fichiers qui ne peuvent pas être ouverts de cette façon pour une raison ou une autre, les fichiers exécutables actuellement mappés dans un espace d'adressage de processus, tous pouvant également affecter la possibilité d'ouvrir ou de créer le fichier.

Avec 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
}

Dans bashou n'importe quel shell de type Bourne, remplacez simplement le

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

avec:

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

Les zshfonctionnalités spécifiques ici sont $ERRNO(qui exposent le code d'erreur du dernier appel système) et un $errnos[]tableau associatif à traduire dans les noms de macro C standard correspondants. Et le$var:h (à partir de csh) et $var:P(nécessite zsh 5.3 ou supérieur).

bash n'a pas encore de fonctionnalités équivalentes.

$file:hpeut être remplacé par dir=$(dirname -- "$file"; echo .); dir=${dir%??}ou par GNU dirname:IFS= read -rd '' dir < <(dirname -z -- "$file") .

Car $errnos[ERRNO] == ENOENT, une approche pourrait consister à exécuterls -Ld le fichier et à vérifier si le message d'erreur correspond à l'erreur ENOENT. Il est cependant difficile de le faire de manière fiable et portable.

Une approche pourrait être:

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

(en supposant que le message d'erreur se termine par la syserror()traduction de ENOENTet que cette traduction n'inclut pas a :), puis, au lieu de [ -e "$file" ], procédez comme suit :

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

Et recherchez une erreur ENOENT avec

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

La $file:Ppartie est la plus délicate à réaliser en bashparticulier sur FreeBSD.

FreeBSD a une realpathcommande et une readlinkcommande qui accepte une -foption, mais elles ne peuvent pas être utilisées dans les cas où le fichier est un lien symbolique qui ne se résout pas. C'est la même chose avec perl« s Cwd::realpath().

python« s os.path.realpath()ne semble fonctionner de façon similaire à zsh $file:P, donc en supposant qu'au moins une version pythonest installée et qu'il ya une pythoncommande qui fait référence à l' un d'entre eux ( ce qui est une donnée sur FreeBSD), vous pouvez faire:

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

Mais alors, vous pourriez aussi bien faire tout cela python.

Ou vous pourriez décider de ne pas gérer tous ces cas d'angle.

Stéphane Chazelas
la source
Oui, vous avez bien compris mon intention. En fait, la question d'origine n'était pas claire: j'ai parlé à l'origine de la création du fichier, mais en fait je l'utilise avec teedonc l'exigence est vraiment que le fichier peut être créé s'il n'existe pas, ou peut être tronqué à zéro et écrasé si c'est le cas (ou sinon teegère cela).
BeeOnRope
On dirait que votre dernière modification a effacé la mise en forme
D. Ben Knoble
Merci, @ évêque, @ D.BenKnoble, voir modifier.
Stéphane Chazelas
1
@DennisWilliamson, eh bien oui, un shell est un outil pour appeler des programmes, et chaque programme que vous invoquez dans votre script doit être installé pour que votre script fonctionne, cela va sans dire. macOS est livré avec bash et zsh installés par défaut. FreeBSD n'est livré avec aucun des deux. L'OP peut avoir une bonne raison de vouloir le faire dans bash, c'est peut-être quelque chose qu'ils veulent ajouter à un script bash déjà écrit.
Stéphane Chazelas
1
@pipe, encore une fois, un shell est un interpréteur de commandes. Vous verrez que de nombreux extraits de code shell écrit ici Invoke interprètes d'autres langues comme perl, awk, sedou python(ou même sh, bash...). Les scripts shell consistent à utiliser la meilleure commande pour la tâche. Ici même perlne dispose pas d' un équivalent de zsh« de $var:P(ses Cwd::realpathde BSD se comporte comme realpathou readlink -fpendant que vous voudriez se comporter comme GNU readlink -fou python» s os.path.realpath())
Stéphane Chazelas
6

Une option que vous voudrez peut-être envisager est de créer le fichier au début, mais de le remplir plus tard dans votre script. Vous pouvez utiliser la execcommande pour ouvrir le fichier dans un descripteur de fichier (tel que 3, 4, etc.), puis utiliser ultérieurement une redirection vers un descripteur de fichier ( >&3, etc.) pour écrire du contenu dans ce fichier.

Quelque chose comme:

#!/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

Vous pouvez également utiliser un trappour nettoyer le fichier en cas d'erreur, c'est une pratique courante.

De cette façon, vous obtenez une véritable vérification des autorisations que vous pourrez créer le fichier, tout en étant en mesure de l'exécuter suffisamment tôt pour que si cela échoue, vous n'avez pas passé de temps à attendre les longues vérifications.


MISE À JOUR: Afin d'éviter de surcharger le fichier en cas d'échec des vérifications, utilisez la fd<>fileredirection de bash qui ne tronque pas immédiatement le fichier. (Nous ne nous soucions pas de la lecture du fichier, il s'agit simplement d'une solution de contournement, nous ne le tronquons donc pas.>> le tronquons fonctionnerait probablement aussi, mais j'ai tendance à trouver celui-ci un peu plus élégant, en gardant le drapeau O_APPEND sur de l'image.)

Lorsque nous sommes prêts à remplacer le contenu, nous devons d'abord tronquer le fichier (sinon, si nous écrivons moins d'octets qu'il n'y en avait auparavant, les octets de fin y resteront.) Nous pouvons utiliser la troncature (1) commande de coreutils à cet effet, et nous pouvons lui passer le descripteur de fichier ouvert que nous avons (en utilisant le /dev/fd/3pseudo-fichier) de sorte qu'il n'a pas besoin de rouvrir le fichier. (Encore une fois, quelque chose de plus simple sur le plan technique : >dir/file.txtfonctionnerait probablement, mais ne pas avoir à rouvrir le fichier est une solution plus élégante.)

filbranden
la source
J'avais pensé à cela, mais le problème est que si une autre erreur innocente se produit quelque part entre le moment où je crée le fichier et le moment où j'écrirais dessus, l'utilisateur sera probablement contrarié de découvrir que le fichier spécifié a été écrasé. .
BeeOnRope
1
@BeeOnRope une autre option qui n'encombrera pas le fichier est d'essayer de ne rien y ajouter : echo -n >>fileou true >> file. Bien sûr, ext4 a seulement des fichiers ajoutés, mais vous pouvez vivre avec ce faux positif.
mosvy
1
@BeeOnRope Si vous devez conserver (a) le fichier existant ou (b) le nouveau fichier (complet), c'est une question différente de celle que vous avez posée. Une bonne réponse consiste à déplacer le fichier existant vers un nouveau nom (par exemple my-file.txt.backup) avant d'en créer un nouveau. Une autre solution consiste à écrire le nouveau fichier dans un fichier temporaire dans le même dossier, puis à le copier sur l'ancien fichier après la réussite du reste du script --- si cette dernière opération a échoué, l'utilisateur pourrait résoudre manuellement le problème sans perdre ses progrès.
jpaugh
1
@BeeOnRope Si vous optez pour la route "atomic replace" (qui est une bonne!), Alors vous voudrez généralement écrire le fichier temporaire dans le même répertoire que le fichier final (ils doivent être dans le même système de fichiers pour être renommés (2) pour réussir) et utilisez un nom unique (donc s'il y a deux instances du script en cours d'exécution, elles ne s'encombreront pas mutuellement.) L'ouverture d'un fichier temporaire pour l'écriture dans le même répertoire est généralement une bonne indication que vous Je pourrai le renommer plus tard (enfin, sauf si la cible finale existe et est un répertoire), donc dans une certaine mesure, elle répond également à votre question.
filbranden
1
@jpaugh - Je suis assez réticent à le faire. Cela conduirait inévitablement à des réponses essayant de résoudre mon problème de niveau supérieur, ce qui pourrait admettre des solutions qui n'ont rien à voir avec la question "Comment puis-je vérifier ...". Indépendamment de mon problème de haut niveau, je pense que cette question est la seule chose que vous pourriez raisonnablement vouloir faire. Il serait injuste pour les personnes qui répondent à cette question spécifique si je la modifiais pour "résoudre ce problème spécifique que j'ai avec mon script". J'ai inclus ces détails ici parce qu'on m'a demandé, mais je ne veux pas changer la question principale.
BeeOnRope
4

Je pense que la solution de DopeGhoti est meilleure mais cela devrait également fonctionner:

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 première construction if vérifie si l'argument est un chemin complet (commence par /) et définit la dirvariable sur le chemin du répertoire jusqu'au dernier /. Sinon, si l'argument ne commence pas par un /mais contient un /(en spécifiant un sous-répertoire), il sera défini dirsur le répertoire de travail actuel + le chemin du sous-répertoire. Sinon, il suppose le répertoire de travail actuel. Il vérifie ensuite si ce répertoire est accessible en écriture.

Jesse_b
la source
4

Qu'en est-il de l'utilisation de la testcommande normale comme indiqué ci-dessous?

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
la source
J'ai presque dupliqué cette réponse! Belle introduction, mais vous pourriez mentionner man test, et cela [ ]équivaut à tester (ce n'est plus de notoriété publique). Voici le one-liner que j'allais utiliser dans ma réponse inférieure, pour couvrir le cas exact, pour la postérité:if [ -w $1] && [ ! -d $1 ] ; then echo "do stuff"; fi
Iron Gremlin
4

Vous avez mentionné que l'expérience utilisateur était à l'origine de votre question. Je répondrai sous un angle UX, car vous avez de bonnes réponses sur le plan technique.

Plutôt que d'effectuer la vérification à l'avance, que diriez-vous d'écrire les résultats dans un fichier temporaire puis à la fin, de placer les résultats dans le fichier souhaité par l'utilisateur? Comme:

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

Le bon avec cette approche: il produit l'opération souhaitée dans le scénario normal de chemin heureux, contourne le problème d'atomicité de test et de définition, préserve les autorisations du fichier cible lors de la création si nécessaire et est très simple à mettre en œuvre.

Remarque: si nous avions utilisé mv, nous conserverions les autorisations du fichier temporaire - nous ne voulons pas cela, je pense: nous voulons conserver les autorisations telles qu'elles sont définies dans le fichier cible.

Maintenant, le mauvais: il nécessite deux fois plus d'espace ( cat .. >construction), oblige l'utilisateur à effectuer un travail manuel si le fichier cible n'était pas accessible en écriture au moment où il devait l'être, et laisse le fichier temporaire en place (ce qui pourrait avoir la sécurité) ou problèmes de maintenance).

évêque
la source
En fait, c'est plus ou moins ce que je fais maintenant. J'écris la plupart des résultats dans un fichier temporaire, puis à la fin, je fais l'étape de traitement final et j'écris les résultats dans le fichier final. Le problème est que je veux renflouer tôt (au début du script) si cette dernière étape est susceptible d'échouer. Le script peut s'exécuter sans surveillance pendant des minutes ou des heures, vous devez donc vraiment savoir à l'avance qu'il est voué à l'échec!
BeeOnRope
Bien sûr, mais il y a tellement de façons que cela pourrait échouer: le disque pourrait se remplir, le répertoire en amont pourrait être supprimé, les autorisations pourraient changer, le fichier cible pourrait être utilisé pour d'autres choses importantes et l'utilisateur a oublié qu'il avait attribué ce même fichier à détruire par ce opération. Si nous en parlons d'un point de vue UX pur, alors peut-être que la bonne chose à faire est de le traiter comme une soumission de travail: à la fin, lorsque vous savez que cela a fonctionné correctement jusqu'à la fin, dites simplement à l'utilisateur où se trouve le contenu résultant et offrez une commande suggérée pour eux de le déplacer eux-mêmes.
évêque
En théorie, oui, il y a des façons infinies que cela puisse échouer. Dans la pratique, l'écrasante majorité du temps cela échoue parce que le chemin fourni n'est pas valide. Je ne peux pas raisonnablement empêcher un utilisateur voyou de modifier simultanément le FS pour interrompre le travail au milieu de l'opération, mais je peux certainement vérifier la cause d'échec # 1 d'un chemin non valide ou non accessible en écriture.
BeeOnRope
2

TL; DR:

: >> "${userfile}"

Contrairement à <> "${userfile}"ou touch "${userfile}", cela n'apportera aucune modification parasite aux horodatages du fichier et fonctionnera également avec les fichiers en écriture seule.


Du PO:

Je veux faire une vérification raisonnable si le fichier peut être créé / écrasé, mais pas vraiment le créer.

Et de votre commentaire à ma réponse du point de vue UX :

La grande majorité du temps, cela échoue parce que le chemin d'accès fourni n'est pas valide. Je ne peux pas raisonnablement empêcher un utilisateur voyou de modifier simultanément le FS pour interrompre le travail au milieu de l'opération, mais je peux certainement vérifier la cause d'échec # 1 d'un chemin non valide ou non accessible en écriture.

Le seul test fiable concerne open(2)le fichier, car seul celui-ci résout toutes les questions sur l'écriture en écriture: chemin, propriété, système de fichiers, réseau, contexte de sécurité, etc. Tout autre test traitera une partie de l'écriture en écriture, mais pas d'autres. Si vous voulez un sous-ensemble de tests, vous devrez finalement choisir ce qui est important pour vous.

Mais voici une autre pensée. D'après ce que je comprends:

  1. le processus de création de contenu est long et
  2. le fichier cible doit être laissé dans un état cohérent.

Vous souhaitez effectuer cette pré-vérification en raison de # 1, et vous ne voulez pas jouer avec un fichier existant en raison de # 2. Alors pourquoi ne demandez-vous pas simplement au shell d'ouvrir le fichier pour l'ajout, mais n'ajoutez rien réellement?

$ 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

Sous le capot, cela résout la question de l'écriture exactement comme souhaité:

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

mais sans modifier le contenu ou les métadonnées du fichier. Maintenant, oui, cette approche:

  • ne vous indique pas si le fichier est ajouté uniquement, ce qui peut poser problème lorsque vous procédez à sa mise à jour à la fin de la création de votre contenu. Vous pouvez le détecter, dans une certaine mesure, avec lsattret réagir en conséquence.
  • crée un fichier qui n'existait pas auparavant, si tel est le cas: atténuez cela avec un sélectif rm.

Bien que je soutienne (dans mon autre réponse) que l' approche la plus conviviale consiste à créer un fichier temporaire que l'utilisateur doit déplacer, je pense que c'est l' approche la moins hostile à l' utilisateur pour contrôler pleinement son entrée.

évêque
la source