Aller au contenu principal

Unix & Shell · L1 · Section 4/7

Scripts Bash fiables

Progression

Points d’expérience : XPSérie de jours consécutifs : · —Progression du module : — / —compris

#Écrire des scripts Bash fiables

Un bon script Bash est prévisible, explicite et échoue tôt en cas de problème. Ce chapitre donne les garde-fous à installer dès la première ligne, puis un gabarit réutilisable.

#Prérequis et objectifs

Prérequis: chapitres précédents du module (redirections, codes de sortie, jobs). Objectifs: écrire un script qui s'arrête à la première erreur, protège ses variables, nettoie ses fichiers temporaires même interrompu, et se laisse déboguer.

#L'en-tête qui durcit tout

bashbash

1#!/usr/bin/env bash2set -Eeuo pipefail3IFS=$'\n\t'

-e arrête immédiatement le script si une commande échoue, ce qui évite les réactions en chaîne catastrophiques. -u détecte les variables non définies, souvent sources de bugs silencieux. -o pipefail fait remonter l'échec d'une commande au milieu d'un tube, au lieu d'être masqué par le succès de la dernière. -E propage les trap vers les fonctions et sous-shells.

Le piège classique reste l'expansion hasardeuse des variables. En règle générale, entourez vos variables de guillemets: "$var". Cela évite les surprises dues aux espaces et au globbing.

#Le shell officiel de l'UE : zsh

Bash est le shell par défaut de la plupart des distributions GNU/Linux, et le durcissement ci-dessus est une bonne pratique professionnelle. Le cours suivi à Valrose impose néanmoins zsh, et l'épreuve exige des scripts dont la première ligne est #!/bin/zsh. Les deux langages se ressemblent, mais quelques différences se paient comptant.

Le shebang n'est pas décoratif. Le cours est explicite sur le mécanisme: quand on demande au noyau d'exécuter un fichier qui est un script, le noyau appelle le shell pour l'interpréter si la première ligne est #!chemin_absolu_de_shell; sinon, c'est Sh. Un script écrit en zsh et privé de shebang — ou affligé d'un shebang fautif comme #/bin/zsh, sans le point d'exclamation — s'exécute donc sous sh, où [[ ... ]] n'existe pas.

bashbash

1#!/bin/zsh

Fonctions. zsh accepte deux syntaxes, et le cours attire l'attention sur la différence: les parenthèses ne sont présentes que lorsqu'il n'y a pas le mot-clé function.

zshzsh

1function bof { cd ../truc ; }2bof() { cd ../truc ; }

Une fonction shell s'appelle comme une commande ordinaire et ne lance pas de sous-shell — c'est précisément ce qui la rend utile pour modifier l'état du shell, comme le montre bof qui change de répertoire. unfunction nom supprime la définition.

Portée des variables. En shell, toutes les variables sont globales, y compris celles créées à l'intérieur d'une fonction. Le mot-clé local permet de s'en affranchir, mais c'est une exception à signaler explicitement. Le contraste avec Python est frappant: une fonction Python ne voit pas les variables locales de son appelant, une fonction shell les voit toutes.

Valeur de retour. Une fonction shell ne renvoie pas de valeur. L'instruction return sert uniquement à fournir un code de retour, valeur comprise entre 0 et 255, où 0 signifie vrai. Pour produire un résultat, la fonction l'écrit sur sa sortie standard et l'appelant le capture par substitution de commande:

zshzsh

1carre() { echo $(($1 * $1)) }2x2=$(carre 7)     # x2 vaut 49

Différence d'echo. En zsh, echo "a\nb" affiche deux lignes; en bash, il affiche le texte a\nb. Un script dont la sortie dépend de echo avec séquences d'échappement n'est donc pas portable.

Différence de redirection. En zsh, un fichier en sortie de > ne doit pas exister par défaut; en bash, > écrase. Ce comportement est commandé par l'option clobber, activée par défaut à Valrose.

Fichiers de configuration. zsh charge ses fichiers dans un ordre précis, et savoir lequel écrire évite bien des surprises.

  1. /etc/zshenv puis ~/.zshenv, toujours exécutés — alias, fonctions, options.
  2. /etc/zprofile puis ~/.zprofile, pour tout shell de connexion — variables d'environnement.
  3. /etc/zshrc puis ~/.zshrc, pour tout shell interactif — le reste, dont PS1.
  4. /etc/zlogin puis ~/.zlogin, pour tout shell de connexion.
  5. /etc/zlogout puis ~/.zlogout, en fin de shell de connexion.

C'est ~/.zshrc que l'épreuve attend comme réponse à la question « dans quel fichier mettre ses alias pour qu'ils soient persistants ».

#Structure et lisibilité

La lisibilité se gagne avec des fonctions courtes et des noms explicites. Préférez des case aux enchaînements de if fragiles, et regroupez les sorties dans une fonction die() qui affiche un message sur stderr puis quitte avec un code non nul. Pour le débogage ponctuel, set -x affiche les commandes exécutées; coupez-le une fois le problème résolu. L'analyseur statique shellcheck signale la plupart des pièges avant l'exécution.

Gabarit de script commenté:

bashbash

1#!/usr/bin/env bash2set -Eeuo pipefail3 4usage() { echo "Usage: $0 -f <fichier>" >&2; }5die() { echo "erreur: $*" >&2; exit 1; }6 7file=""8while getopts ":f:h" opt; do9  case "$opt" in10    f) file="$OPTARG" ;;11    h) usage; exit 0 ;;12    *) usage; exit 2 ;;13  esac14done

#Les briques du langage shell

Le shell est un langage de programmation à part entière, et le cours en donne les éléments. Trois avertissements préalables: il n'y a pas d'indentation significative — « on n'est pas sur Python » —, la syntaxe est stricte et les espaces sont significatifs, et le langage n'est pas typé: on ne déclare ni entier, ni chaîne, ni booléen.

#Variables, citation, paramètres

Le nom d'une variable ne contient que des caractères alphanumériques et l'underscore. Il ne doit pas y avoir d'espace autour du = dans une affectation, sous peine d'erreur de syntaxe. Les guillemets ne sont pas obligatoires pour une chaîne, mais fortement recommandés dès qu'elle contient des espaces ou des motifs. Une variable existe dès sa première mention, et elle n'est transmise à un sous-shell ou à un script que si elle est exportée avec export.

zshzsh

1nom=valeur2export CHEMIN=/opt/bin3echo "$nom"

La citation est le mécanisme qui empêche l'expansion. Les apostrophes figent tout, y compris le dollar. Les guillemets laissent leur sens à quatre caractères: le dollar, l'accent grave, l'antislash et le guillemet. L'antislash annule la signification du caractère suivant.

zshzsh

1echo '$HOME'    # littéral2echo "$HOME"    # développé3echo \$HOME     # littéral

Les paramètres positionnels sont les arguments du script ou de la fonction: $0 est le nom de la commande, $1 à $n les arguments, $# leur nombre, $* leur concaténation en un seul mot, $@ la liste en autant de mots. shift élimine $1 et décale les suivants.

zshzsh

1while [[ $# -ne 0 ]]; do2  echo "$1"3  shift4done

Les tableaux se déclarent en énumérant les valeurs entre parenthèses et s'indexent à partir de 1, particularité de zsh:

zshzsh

1table=(a b c d)2echo $table[2]     # b3echo $table[*]     # a b c d, en un seul mot4echo $table[@]     # a b c d, en autant de mots

Les expansions de paramètres couvrent la plupart des besoins de valeur par défaut et de découpe de chaîne:

zshzsh

1${p:-mot}      # valeur de p si elle existe et n'est pas vide, sinon mot2${p:=mot}      # affecte mot à p s'il est vide, puis fournit sa valeur3${p:+mot}      # mot si p a une valeur, vide sinon4${p:?mot}      # termine le shell avec mot comme message si p est vide5${#p}          # longueur de la valeur6${p#motif}     # supprime le plus COURT préfixe conforme7${p##motif}    # supprime le plus LONG préfixe conforme8${p%motif}     # supprime le plus COURT suffixe conforme9${p%%motif}    # supprime le plus LONG suffixe conforme

Ces quatre dernières formes remplacent élégamment les appels à basename et dirname. Exemple classique: ${0##*/} réduit le chemin d'appel d'un script à son nom de base, ce que $(basename $0) fait tout aussi bien.

#Substitution de commande et arithmétique

La substitution de commande remplace une notation par la sortie standard d'une commande. Deux écritures existent, l'accent grave et la forme moderne qui s'emboîte:

zshzsh

1aujourdhui=`date`2aujourdhui=$(date)

C'est le mécanisme qui permet d'affecter un résultat à une variable, de l'utiliser comme argument d'une autre commande, ou de l'employer dans une condition. Attention: les changements de ligne de la sortie sont transformés en séparateurs de mots.

L'arithmétique ne s'écrit pas directement: il faut passer par let et ses deux syntaxes, dont la différence est capitale.

zshzsh

1(( expression ))     # ne renvoie RIEN sur la sortie standard, seulement un code de retour2$(( expression ))    # renvoie la VALEUR du calcul

Les clauses if, for et while utilisent toujours le code de retour; $(( )) sert à produire une valeur. Confondre les deux est l'erreur classique. Les opérateurs disponibles couvrent l'arithmétique (+, -, *, /), la comparaison (<, <=, >, >=, ==, !=) et la logique (&&, ||, !).

zshzsh

1(( 3 * 2 == 6 )) && echo "vrai"      # comparaison, code de retour2x=$(( 3 * 2 ))                       # calcul, valeur affectée

Une règle de syntaxe à mémoriser, donnée telle quelle par le cours: avec == et = on n'utilise pas le dollar, alors qu'avec +, - et / on l'utilise.

#Tests et conditions

La notation [[ prédicat ]] est équivalente à la commande test prédicat, en plus lisible. Les crochets sont des commandes: ils doivent donc être précédés et suivis d'un blanc, et un espace manquant provoque une erreur. Le cours rappelle que test n'écrit rien sur sa sortie standard: c'est le code de retour qui détermine si la condition est vraie.

zshzsh

1[[ -e fichier ]]   # existe, quel que soit son type2[[ -f fichier ]]   # existe et est un fichier ordinaire3[[ -d dossier ]]   # existe et est un répertoire4[[ -s fichier ]]   # existe et n'est pas vide5[[ -r fichier ]]   # accessible en lecture6[[ -w fichier ]]   # accessible en écriture7[[ -x fichier ]]   # exécutable, ou traversable pour un répertoire8[[ -L fichier ]]   # lien symbolique9[[ -p fichier ]]   # tube nommé
zshzsh

1[[ -n chaine ]]              # la chaîne n'est pas vide2[[ -z chaine ]]              # la chaîne est vide3[[ c1 == c2 ]]               # chaînes identiques4[[ c1 != c2 ]]               # chaînes différentes5[[ n1 -eq n2 ]]              # nombres égaux6[[ n1 -ne n2 ]]              # nombres différents7[[ n1 -lt n2 ]]              # strictement inférieur8[[ n1 -le n2 ]]              # inférieur ou égal9[[ n1 -gt n2 ]]              # strictement supérieur10[[ n1 -ge n2 ]]              # supérieur ou égal

Les tests arithmétiques portent sur des nombres et les tests de chaîne sur du texte: le signe = n'est pas utilisable entre deux nombres, et -eq n'est pas utilisable entre deux chaînes. Deux extensions utiles complètent l'ensemble: ! inverse le résultat, -a et -o réalisent le ET et le OU logiques, et l'opérateur =~ compare à une expression rationnelle.

zshzsh

1if [[ $reponse =~ ^[0-9]+$ ]]; then echo "nombre"; fi

#Constructions de contrôle

Les constructions sont l'équivalent des énoncés structurés des autres langages. Le délimiteur est un mot (then, fi, do, done, esac), et ces mots-clés doivent apparaître en début de commande, donc après un point-virgule ou une fin de ligne.

zshzsh

1if [[ condition ]]2then3  commandes4elif [[ autre ]]5then6  commandes7else8  commandes9fi
zshzsh

1case $# in20) echo "$0 est sans paramètre" ;;31) echo "$0 possède un paramètre = $1" ;;4*) echo "$0 possède $# paramètres = $*" ;;5esac
zshzsh

1while [[ condition ]]2do3  commandes4done5 6until [[ condition ]]7do8  commandes9done10 11repeat 5 do echo bonjour done

Le cours distingue plusieurs formes de boucle for, dont la forme générique sur une liste et la forme « à la C » que zsh accepte en plus:

zshzsh

1for nom in a b c2do3  echo "$nom"4done5 6for (( i=1; i<=10; i++ ))7do8  echo "$i"9done10 11for i in {1..5}; do echo "$i"; done     # de 1 à 5 INCLUS

Le dernier exemple mérite une remarque: contrairement à Python, il n'existe pas de fonction range, et {1..5} va bien jusqu'à 5 inclus. Il existe aussi select, qui affiche une liste numérotée et lit le numéro choisi, et le regroupement de commandes: { liste } en fait une seule commande, tandis que ( liste ) exécute la liste dans un shell fils — moyen commode de faire des changements temporaires.

#Fonctions, alias, options

zshzsh

1function affiche {2  echo "argument : $1"3}4affiche bonjour

Une fonction est conservée dans le shell, s'appelle comme une commande ordinaire, ne nécessite pas de sous-shell, et se termine avant la fin par return. Les alias sont des abréviations pour une commande complète; si leur définition se termine par un blanc, le shell cherche d'éventuels alias dans ce qui suit, ce qui permet de composer. Pour appeler la commande redéfinie par un alias, on la précède d'un antislash.

zshzsh

1alias ll="ls -la"2alias exe="chmod u+x "3unalias ll4\date            # la commande date d'origine, malgré un alias

L'analyse des options se fait classiquement avec getopts, qui alimente OPTARG et OPTIND:

zshzsh

1while getopts ":abc:d:" option; do2  case $option in3    a) echo "Option -a" ;;4    c) echo "Option -c, argument $OPTARG" ;;5    :) echo "Argument manquant pour -$OPTARG" ;;6    ?) echo "Option -$OPTARG inconnue" ;;7  esac8done9shift $((OPTIND - 1))

Enfin, deux constructions complètent la boîte à outils: l'entrée sur place (<< mot) et eval, qui analyse deux fois sa commande — une première fois pour les expansions et substitutions, une seconde fois pour l'exécuter.

zshzsh

1cat << FIN21 - Première option32 - Seconde option40 - Fin5FIN

#Organisation et fichiers temporaires

Organisez vos scripts: un dossier bin/ pour les exécutables, un logs/ pour les traces, et des variables d'environnement chargées depuis un .env (à ne jamais versionner s'il contient des secrets).

Les fichiers temporaires doivent être uniques et nettoyés. Utilisez mktemp pour éviter les collisions et installez un trap sur EXIT pour supprimer les artefacts, quel que soit le chemin de sortie. Quand un script manipule des chemins avec des espaces, double-citez systématiquement et parcourez les fichiers avec find -print0 | xargs -0 pour rester robuste.

#Animation: garde-fous d'un script

Options
set -Eeuo pipefail; IFS strict
Quoting
Toujours "$var"; globs maîtrisés
Trap
trap sur EXIT/ERR/INT pour le nettoyage
Temp
mktemp et dossiers uniques
Erreurs
die(), codes explicites

#Exercice (20 minutes)

Écrivez un script qui:

  1. crée un dossier temporaire avec mktemp -d et le supprime automatiquement à la fin grâce à un trap sur EXIT;
  2. simule le téléchargement d'un fichier (un simple echo suffit) et vérifie son intégrité avec sha256sum;
  3. gère l'interruption Ctrl+C en affichant un message d'annulation et en nettoyant les ressources;
  4. échoue proprement si une commande du tube échoue (vérifiez pipefail).

Correction:

bashbash

1#!/usr/bin/env bash2set -Eeuo pipefail3 4tmpdir=$(mktemp -d)5cleanup() {6  echo "Nettoyage de $tmpdir" >&27  rm -rf "$tmpdir"8}9trap cleanup EXIT10trap 'echo "Annulation demandée" >&2; exit 130' INT TERM11 12echo "donnees simulees" | tee "$tmpdir/archive.tar" >/dev/null13sha256sum "$tmpdir/archive.tar" > "$tmpdir/archive.sha256"14sha256sum -c "$tmpdir/archive.sha256"

Vérifications attendues: la ligne archive.tar: OK s'affiche; après exécution, ls /tmp | grep tmp. ne montre plus le dossier créé; si vous interrompez avec Ctrl+C pendant une pause insérée pour l'occasion, le message d'annulation apparaît et le code de sortie vaut 130. Pour vérifier pipefail, remplacez la ligne tee par echo x | false | cat: le script doit s'arrêter avec un code non nul au lieu de se terminer « avec succès ».

#Exercices d'entraînement (zsh)

Les cinq exercices suivants sont ceux de la fiche de révision du cours. Ils sont volontairement contraints: deux d'entre eux interdisent l'usage des commandes les plus évidentes, ce qui oblige à s'appuyer sur les mécanismes du langage.

1. pair.sh — déterminer si un nombre est pair ou impair. S'il n'y a pas d'argument, afficher un message d'erreur.

2. utilisateur.sh — prendre un nom d'utilisateur en argument et déterminer s'il s'agit de l'utilisateur courant. Afficher un message spécial si l'utilisateur courant est root.

3. operation.sh — prendre trois arguments: un nombre, un opérateur parmi +, -, *, /, puis un autre nombre, et effectuer le calcul. L'une des opérations « ne fonctionne pas »: trouver la raison et corriger. Reprendre ensuite l'exercice en gérant le cas où l'un des arguments est vide.

4. ls.sh — prendre en argument un répertoire (le répertoire courant étant désigné par *) et renvoyer la liste des fichiers par ordre alphabétique. Interdiction d'utiliser ls, find et grep.

5. trouve_et_remplace.sh — prendre trois arguments: un répertoire, un mot, puis un remplacement; chercher le mot dans tous les fichiers du répertoire et le remplacer. Interdiction d'utiliser ls, grep, find et cut.

Corrections détaillées

1. pair.sh.

zshzsh

1#!/bin/zsh2if [[ $# -eq 0 ]]; then3  echo "Usage : $0 nombre" >&24  exit 15fi6if (( $1 % 2 == 0 )); then7  echo "$1 est pair"8else9  echo "$1 est impair"10fi

$# teste la présence du paramètre; l'opérateur % fournit le reste de la division entière, donc le test de parité. Le message d'erreur part sur la sortie d'erreur (>&2) et le script sort avec un code non nul, ce qui permet à un appelant de détecter l'échec. Vérification: ./pair.sh 7 affiche 7 est impair, ./pair.sh 8 affiche 8 est pair, ./pair.sh affiche l'usage et rend 1.

2. utilisateur.sh.

zshzsh

1#!/bin/zsh2if [[ $# -eq 0 ]]; then echo "Usage : $0 utilisateur" >&2; exit 1; fi3if [[ $(whoami) == "root" ]]; then echo "Attention, vous êtes root"; fi4if [[ "$1" == "$(whoami)" ]]; then5  echo "$1 est bien l'utilisateur courant"6else7  echo "$1 n'est pas l'utilisateur courant"8fi

Deux points: la comparaison de chaînes utilise == à l'intérieur de [[ ]], et la substitution de commande $(whoami) insère le nom de l'utilisateur courant dans le test. Le message spécial pour root est indépendant du résultat de la comparaison.

3. operation.sh.

zshzsh

1#!/bin/zsh2if [[ $# -ne 3 ]]; then echo "Usage : $0 nombre operateur nombre" >&2; exit 1; fi3a=$1; op=$2; b=$34case $op in5  "+") echo $(( a + b )) ;;6  "-") echo $(( a - b )) ;;7  "*") echo $(( a * b )) ;;8  "/") if (( b == 0 )); then echo "Division par zéro" >&2; exit 1; fi9       echo $(( a / b )) ;;10  *) echo "Operateur inconnu : $op" >&2; exit 1 ;;11esac

L'opération qui ne fonctionne pas est la multiplication, et la raison n'est pas dans le script mais dans l'appel. Écrite ./operation.sh 6 * 7, la ligne de commande est développée par le shell avant l'exécution: * est un joker, il est remplacé par la liste des fichiers du répertoire courant, et le script reçoit beaucoup plus de trois arguments — il affiche donc son message d'usage. La correction tient en un caractère: ./operation.sh 6 '*' 7. C'est la démonstration la plus nette de la frontière entre le shell et le programme: les jokers sont du ressort du shell, et un programme ne les voit jamais.

La seconde version gère les arguments vides en testant $# et en vérifiant chaque paramètre, et traite explicitement la division par zéro, qui provoquerait sinon une erreur du shell.

4. ls.sh.

zshzsh

1#!/bin/zsh2rep=${1:-.}3if [[ ! -d "$rep" ]]; then echo "$rep n'est pas un répertoire" >&2; exit 1; fi4for f in $rep/*; do5  echo "${f##*/}"6done | sort

Puisque ls est interdit, c'est le développement des jokers par le shell qui fournit la liste: $rep/* est remplacé par les chemins des fichiers du répertoire. ${f##*/} en extrait le nom de base en supprimant le plus long préfixe conforme à */. Le tube final trie en ordre alphabétique. Le répertoire courant est bien désigné par * dans l'énoncé, et la valeur par défaut ${1:-.} rend le script utilisable sans argument. Limite à signaler: le joker ne voit pas les fichiers cachés, conformément à la règle du cours.

5. trouve_et_remplace.sh.

zshzsh

1#!/bin/zsh2if [[ $# -ne 3 ]]; then echo "Usage : $0 repertoire mot remplacement" >&2; exit 1; fi3rep=$1; avant=$2; apres=$34for f in $rep/*; do5  [[ -f "$f" ]] || continue6  sed -i "s/$avant/$apres/g" "$f"7done

Là encore, le parcours des fichiers se fait par joker et non par find ou ls, et le remplacement par sed en mode non interactif. Le test [[ -f "$f" ]] || continue évite de traiter les sous-répertoires. Vérification: un fichier contenant le chat et le chien et chat devient le loup et le chien et loup après ./trouve_et_remplace.sh t chat loup.

Ce que ces cinq exercices font travailler. Le contrôle des paramètres ($#, valeur par défaut), la comparaison de chaînes dans [[ ]], l'arithmétique (( )) et $(( )), l'analyse d'options par case, le développement des jokers, et la manipulation de chaînes par les expansions ${f##*/}. Ce sont les cinq briques que l'épreuve réutilise dans tous ses scripts.