Unix & Shell · L1 · Section 4/7
Scripts Bash fiables
Progression
#É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
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.
1#!/bin/zshFonctions. 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.
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:
1carre() { echo $(($1 * $1)) }2x2=$(carre 7) # x2 vaut 49Diffé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.
/etc/zshenvpuis~/.zshenv, toujours exécutés — alias, fonctions, options./etc/zprofilepuis~/.zprofile, pour tout shell de connexion — variables d'environnement./etc/zshrcpuis~/.zshrc, pour tout shell interactif — le reste, dontPS1./etc/zloginpuis~/.zlogin, pour tout shell de connexion./etc/zlogoutpuis~/.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é:
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.
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.
1echo '$HOME' # littéral2echo "$HOME" # développé3echo \$HOME # littéralLes 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.
1while [[ $# -ne 0 ]]; do2 echo "$1"3 shift4doneLes tableaux se déclarent en énumérant les valeurs entre parenthèses et s'indexent à partir de 1, particularité de zsh:
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 motsLes expansions de paramètres couvrent la plupart des besoins de valeur par défaut et de découpe de chaîne:
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 conformeCes 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:
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.
1(( expression )) # ne renvoie RIEN sur la sortie standard, seulement un code de retour2$(( expression )) # renvoie la VALEUR du calculLes 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 (&&, ||, !).
1(( 3 * 2 == 6 )) && echo "vrai" # comparaison, code de retour2x=$(( 3 * 2 )) # calcul, valeur affectéeUne 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.
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é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 égalLes 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.
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.
1if [[ condition ]]2then3 commandes4elif [[ autre ]]5then6 commandes7else8 commandes9fi1case $# in20) echo "$0 est sans paramètre" ;;31) echo "$0 possède un paramètre = $1" ;;4*) echo "$0 possède $# paramètres = $*" ;;5esac1while [[ condition ]]2do3 commandes4done5 6until [[ condition ]]7do8 commandes9done10 11repeat 5 do echo bonjour doneLe 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:
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 INCLUSLe 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
1function affiche {2 echo "argument : $1"3}4affiche bonjourUne 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.
1alias ll="ls -la"2alias exe="chmod u+x "3unalias ll4\date # la commande date d'origine, malgré un aliasL'analyse des options se fait classiquement avec getopts, qui alimente OPTARG et OPTIND:
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.
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
#Exercice (20 minutes)
Écrivez un script qui:
- crée un dossier temporaire avec
mktemp -det le supprime automatiquement à la fin grâce à untrapsurEXIT; - simule le téléchargement d'un fichier (un simple
echosuffit) et vérifie son intégrité avecsha256sum; - gère l'interruption Ctrl+C en affichant un message d'annulation et en nettoyant les ressources;
- échoue proprement si une commande du tube échoue (vérifiez
pipefail).
Correction:
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.
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.
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"8fiDeux 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.
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 ;;11esacL'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.
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 | sortPuisque 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.
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"7doneLà 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.