Un if en Bash ne teste pas une expression booléenne : il teste le code de retour d'une commande. Cette page vous donne les tests que vous écrirez tous les jours en administration (le fichier de conf existe-t-il, le service tourne-t-il, le disque est-il plein, l'argument est-il valide), le tableau complet des opérateurs sur fichiers, chaînes et nombres, et surtout les pièges de quoting qui font qu'un [ $var = "x" ] explose dès que la variable est vide. Chaque exemple ci-dessous a été exécuté sur Ubuntu 24.04 avec Bash 5.2 et passé à shellcheck.
Ce que vous allez apprendre
Section intitulée « Ce que vous allez apprendre »- Écrire des blocs
if/elif/elsequi réagissent à l'état réel du système - Tester fichiers, chaînes et entiers avec le bon opérateur (
-f,-z,-eqet les autres) - Distinguer
[ ],[[ ]]et(( ))et savoir lequel utiliser quand - Reproduire puis corriger les erreurs
too many argumentsetunary operator expected - Dispatcher proprement avec
caseselon la distribution ou l'argument reçu - Éviter les pièges de
set -e, du code de retour inversé de(( ))et du faux ternaire&& ||
Une condition, c'est un code de retour
Section intitulée « Une condition, c'est un code de retour »Voilà le modèle mental qui débloque tout le reste. En Bash, toute commande renvoie un code de retour : 0 quand elle a réussi, une valeur différente de zéro quand elle a échoué. Le if ne fait rien d'autre que lancer une commande et regarder ce code. C'est l'inverse de la convention de la plupart des langages, où 0 est faux.
La variable spéciale $? contient le code de la dernière commande. Regardez ce que renvoient deux commandes d'administration très courantes :
getent passwd bob > /dev/null; echo "getent passwd bob -> $?"getent passwd deployer > /dev/null; echo "getent passwd deployer -> $?"systemctl is-active sshsystemctl is-active nginxgetent passwd bob -> 0getent passwd deployer -> 2activeinactiveLe compte bob existe donc getent renvoie 0, le compte deployer n'existe pas donc 2. C'est exactement ce que le if va exploiter : pas besoin de comparer une sortie texte, le code de retour suffit.
if systemctl is-active --quiet ssh; then echo "ssh est actif, rien à faire"else echo "ssh est arrêté, redémarrage requis"fissh est actif, rien à faireL'option --quiet supprime l'affichage active/inactive : on ne garde que le code de retour, ce qui est précisément ce qui nous intéresse. Retenez ce réflexe, il évite des grep inutiles sur des sorties de commandes.
if / elif / else : la structure
Section intitulée « if / elif / else : la structure »La syntaxe demande deux mots-clés que les débutants oublient systématiquement : then ouvre le bloc, fi le ferme (c'est if à l'envers, une plaisanterie des auteurs du shell Bourne restée dans le langage).
if CONDITION; then # commandes si le code de retour vaut 0elif AUTRE_CONDITION; then # commandes si la première est fausse et celle-ci vraieelse # commandes par défautfiLe point-virgule après la condition remplace un saut de ligne : if CONDITION; then et if CONDITION suivi de then sur la ligne suivante sont strictement équivalents. Passons tout de suite à un cas réel : un fichier de configuration peut exister sans être lisible, et les deux situations demandent un message différent.
conf="/etc/ssh/sshd_config"
if [[ -r "$conf" ]]; then echo "OK : $conf lisible, $(wc -l < "$conf") lignes"elif [[ -e "$conf" ]]; then echo "ATTENTION : $conf existe mais n'est pas lisible par $(id -un)"else echo "ERREUR : $conf introuvable"fiExécuté avec un compte non privilégié, le résultat tombe dans la branche du milieu, parce que sshd_config est en 600 root:root sur Ubuntu :
ATTENTION : /etc/ssh/sshd_config existe mais n'est pas lisible par bobCet enchaînement -r puis -e est un patron à mémoriser. Un script qui teste seulement l'existence puis tente de lire échoue plus loin, avec un message obscur ; un script qui distingue existence et lisibilité vous dit immédiatement qu'il manque un sudo.
Six vérifications que vous écrirez tous les jours
Section intitulée « Six vérifications que vous écrirez tous les jours »Les conditions ne servent pas à faire des exercices d'algèbre booléenne, elles servent à interroger l'état d'un serveur. Voici les six questions qui reviennent dans la quasi-totalité des scripts d'administration, avec la forme canonique de chacune.
Le service tourne-t-il ?
Section intitulée « Le service tourne-t-il ? »systemctl is-active --quiet renvoie 0 si l'unité est active. Mais une unité peut aussi être inconnue (paquet non installé), et ce n'est pas la même chose qu'un service arrêté. Traitez les trois cas séparément si vous voulez un diagnostic exploitable.
for svc in ssh docker cron postgresql; do if systemctl is-active --quiet "$svc"; then echo "$svc : actif" elif systemctl cat "$svc" &>/dev/null; then echo "$svc : installé mais arrêté" else echo "$svc : unité inconnue" fidonessh : actifdocker : actifcron : actifpostgresql : unité inconnuesystemctl cat affiche le fichier d'unité et échoue si celui-ci n'existe pas : c'est le test le plus court pour savoir si le service est installé. Si le diagnostic doit aller plus loin, Un service qui ne démarre pas détaille la lecture du journal.
L'utilisateur existe-t-il ?
Section intitulée « L'utilisateur existe-t-il ? »Avant de faire un chown sur un compte de service, vérifiez qu'il existe. id -u renvoie 0 et l'UID si le compte est connu, un code non nul sinon. La redirection &>/dev/null jette à la fois la sortie standard et l'erreur, car seul le code de retour compte.
for u in bob www-data deployer; do if id -u "$u" &>/dev/null; then echo "$u : existe (uid $(id -u "$u"), shell $(getent passwd "$u" | cut -d: -f7))" else echo "$u : n'existe pas" fidonebob : existe (uid 1000, shell /usr/bin/zsh)www-data : existe (uid 33, shell /usr/sbin/nologin)deployer : n'existe pasLe shell /usr/sbin/nologin de www-data est le marqueur d'un compte de service : il ne peut pas ouvrir de session interactive. C'est une information que votre script peut vouloir tester à son tour.
Le disque dépasse-t-il un seuil ?
Section intitulée « Le disque dépasse-t-il un seuil ? »Le taux de remplissage sort de df avec un % collé au nombre. Il faut donc retirer le pourcentage avant de comparer, sinon vous comparez une chaîne. L'expansion ${pourcent%\%} supprime le % final ; les expansions de paramètre couvrent cette syntaxe en détail.
seuil=75while read -r _ _ _ _ pourcent point; do usage=${pourcent%\%} if (( usage >= seuil )); then echo "CRITIQUE : $point rempli à $usage% (seuil $seuil%)" else echo "OK : $point à $usage% (seuil $seuil%)" fidone < <(df -P / /boot | tail -n +2)CRITIQUE : / rempli à 80% (seuil 75%)OK : /boot à 11% (seuil 75%)L'option -P de df force le format POSIX sur une seule ligne par système de fichiers : sans elle, un nom de périphérique long provoque un retour à la ligne et casse la lecture. La comparaison se fait dans (( )), la forme arithmétique naturelle pour des entiers.
L'argument passé au script est-il valide ?
Section intitulée « L'argument passé au script est-il valide ? »$# contient le nombre d'arguments reçus. Un script d'administration valide toujours ses entrées avant d'agir, ne serait-ce que pour afficher un message d'usage plutôt qu'une erreur incompréhensible trois lignes plus bas.
usage() { echo "Usage : $(basename "$0") <service> <fichier-conf>" >&2 exit 2}
(( $# == 2 )) || usage
service="$1"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then echo "ERREUR : nom de service invalide : $service" >&2 exit 2fiLe message d'usage part sur >&2 (la sortie d'erreur), pas sur la sortie standard : un script appelé dans un pipeline ne doit pas polluer les données. Le code exit 2 signale une erreur d'utilisation, distincte d'un exit 1 qui signalerait un contrôle métier en échec. La validation par regex du nom de service empêche qu'un argument tordu se retrouve concaténé dans une commande systemctl.
Tester des fichiers : le tableau complet
Section intitulée « Tester des fichiers : le tableau complet »Tous les tests de fichiers suivent la même forme : un opérateur unaire suivi d'un chemin. Ils renvoient vrai si le fichier existe et satisfait la propriété demandée. Un fichier absent fait donc échouer tous les tests, ce qui est le comportement voulu.
| Test | Vrai quand | Usage typique |
|---|---|---|
-e chemin | le chemin existe (fichier, répertoire, lien, socket…) | vérifier avant d'écraser |
-f chemin | c'est un fichier ordinaire | lire une config |
-d chemin | c'est un répertoire | vérifier une destination |
-L chemin | c'est un lien symbolique | détecter un lien brisé |
-s chemin | il existe et sa taille est > 0 | rejeter un dump vide |
-r chemin | le processus courant peut le lire | anticiper un « Permission denied » |
-w chemin | le processus courant peut y écrire | vérifier un répertoire de sortie |
-x chemin | il est exécutable (ou traversable pour un répertoire) | valider un binaire avant appel |
-O chemin | il appartient à l'utilisateur courant | contrôler la propriété d'un secret |
f1 -nt f2 | f1 est plus récent que f2 | régénérer un artefact périmé |
f1 -ot f2 | f1 est plus vieux que f2 | détecter une conf non rechargée |
Existence, nature et liens
Section intitulée « Existence, nature et liens »La distinction entre -e, -f et -L se voit sur un lien brisé, cas très fréquent après une désinstallation. Un lien dont la cible a disparu existe toujours en tant qu'entrée de répertoire, mais -e répond faux parce qu'il suit le lien avant de tester.
mkdir -p /tmp/lc-demoprintf 'listen 8080;\n' > /tmp/lc-demo/app.confln -sf /tmp/lc-demo/app.conf /tmp/lc-demo/lien.confln -sf /tmp/lc-demo/nexistepas /tmp/lc-demo/lien-brise.conf
[[ -f /tmp/lc-demo/app.conf ]] && echo "-f app.conf : vrai (fichier ordinaire)"[[ -f /tmp/lc-demo/lien.conf ]] && echo "-f lien.conf : vrai (lien suivi vers un fichier)"[[ -L /tmp/lc-demo/lien.conf ]] && echo "-L lien.conf : vrai (c'est bien un lien)"[[ -e /tmp/lc-demo/lien-brise.conf ]] || echo "-e lien-brise.conf : faux (la cible n'existe plus)"[[ -L /tmp/lc-demo/lien-brise.conf ]] && echo "-L lien-brise.conf : vrai (le lien lui-même existe)"-f app.conf : vrai (fichier ordinaire)-f lien.conf : vrai (lien suivi vers un fichier)-L lien.conf : vrai (c'est bien un lien)-e lien-brise.conf : faux (la cible n'existe plus)-L lien-brise.conf : vrai (le lien lui-même existe)Un script de nettoyage qui cherche les liens brisés combine donc les deux : [[ -L "$f" && ! -e "$f" ]]. C'est le seul moyen de les attraper, et c'est un classique des scripts de maintenance de /etc/alternatives ou d'un répertoire de modules.
Taille, droits et fraîcheur
Section intitulée « Taille, droits et fraîcheur »-s est le test qui manque à beaucoup de scripts de sauvegarde : un mysqldump qui échoue laisse souvent un fichier de taille zéro, et le script suivant l'archive fièrement. Tester l'existence ne suffit pas, il faut tester le contenu.
: > /tmp/lc-demo/vide.log # fichier vide
[[ -e /tmp/lc-demo/vide.log ]] && echo "-e vide.log : vrai (il existe)"[[ -s /tmp/lc-demo/vide.log ]] || echo "-s vide.log : faux (taille 0)"[[ -s /tmp/lc-demo/app.conf ]] && echo "-s app.conf : vrai (contient des données)"
[[ -w /etc/passwd ]] || echo "-w /etc/passwd : faux (utilisateur non root)"[[ -r /etc/shadow ]] || echo "-r /etc/shadow : faux (droits 640 root:shadow)"[[ -x /usr/bin/systemctl ]] && echo "-x /usr/bin/systemctl : vrai"-e vide.log : vrai (il existe)-s vide.log : faux (taille 0)-s app.conf : vrai (contient des données)-w /etc/passwd : faux (utilisateur non root)-r /etc/shadow : faux (droits 640 root:shadow)-x /usr/bin/systemctl : vraiPoint crucial : -r, -w et -x ne décrivent pas le fichier, ils décrivent le processus courant. Le même test renvoie vrai sous root et faux sous un compte ordinaire. C'est exactement ce qu'on veut, puisque la question posée est « est-ce que je peux lire ce fichier », mais cela signifie qu'un test qui passe dans votre terminal peut échouer dans un cron exécuté sous un autre compte.
Enfin, -nt et -ot comparent les dates de modification. C'est la logique d'un make en une ligne : si la source est plus récente que l'artefact, on régénère.
sleep 1printf 'rotation\n' > /tmp/lc-demo/recent.log
[[ /tmp/lc-demo/recent.log -nt /tmp/lc-demo/app.conf ]] && echo "recent.log -nt app.conf : vrai"[[ /tmp/lc-demo/app.conf -ot /tmp/lc-demo/recent.log ]] && echo "app.conf -ot recent.log : vrai"recent.log -nt app.conf : vraiapp.conf -ot recent.log : vraiTester des chaînes
Section intitulée « Tester des chaînes »Deux tests unaires et deux tests binaires couvrent 95 % des besoins : -z (la chaîne est vide), -n (elle ne l'est pas), = (égalité) et != (différence). Bash accepte aussi == comme synonyme de = dans [[ ]] ; les deux sont corrects, = reste la forme portable.
| Test | Vrai quand |
|---|---|
-z "$var" | la chaîne est vide (ou la variable non définie) |
-n "$var" | la chaîne est non vide |
"$a" = "$b" | les deux chaînes sont identiques |
"$a" != "$b" | elles diffèrent |
"$a" < "$b" | $a précède $b dans l'ordre lexicographique |
"$a" > "$b" | $a suit $b dans l'ordre lexicographique |
"$var" == motif | la chaîne correspond au motif glob (dans [[ ]] uniquement) |
"$var" =~ regex | la chaîne correspond à la regex étendue (dans [[ ]] uniquement) |
utilisateur="deployer"vide=""
[[ -z "$vide" ]] && echo '-z $vide : vrai (chaîne vide)'[[ -n "$utilisateur" ]] && echo '-n $utilisateur : vrai (non vide)'[[ "$utilisateur" = "deployer" ]] && echo '= deployer : vrai'[[ "$utilisateur" != "root" ]] && echo '!= root : vrai'[[ "abc" < "abd" ]] && echo 'abc < abd : vrai (ordre lexicographique)'-z $vide : vrai (chaîne vide)-n $utilisateur : vrai (non vide)= deployer : vrai!= root : vraiabc < abd : vrai (ordre lexicographique)Attention à un détail qui surprend : la comparaison lexicographique dépend de la locale, pas de la table ASCII. Sur un système en fr_FR.UTF-8, la casse est ignorée dans le tri ; en LC_ALL=C, les majuscules passent avant les minuscules. Un script qui trie des noms d'hôtes peut donc changer de comportement selon l'environnement qui l'exécute.
[[ "Zebre" < "abeille" ]] && echo "vrai" || echo "faux" # locale fr_FR.UTF-8LC_ALL=C bash -c '[[ Zebre < abeille ]] && echo "vrai" || echo "faux"' # ordre ASCII strictfauxvraiSi le résultat doit être reproductible, forcez LC_ALL=C en tête de script. C'est la même règle que pour sort, qui suit exactement la même logique de collation.
Motifs et expressions régulières
Section intitulée « Motifs et expressions régulières »Dans [[ ]], l'opérateur == active le filtrage par motif (glob) quand la partie droite n'est pas entre guillemets. Et =~ déclenche une expression régulière POSIX étendue. Ces deux capacités n'existent pas dans [ ], et c'est l'une des meilleures raisons de préférer les doubles crochets.
hote="web-prod-03"
[[ "$hote" == web-* ]] && echo '== web-* : vrai (glob, partie droite sans guillemets)'[[ "$hote" == "web-*" ]] || echo '== "web-*" : faux (guillemets = comparaison littérale)'
[[ "$hote" =~ ^web-(prod|preprod)-[0-9]+$ ]] && echo "=~ motif d'hôte : vrai"echo " capture BASH_REMATCH[1] = ${BASH_REMATCH[1]}"
ip="10.42.0.7"[[ "$ip" =~ ^([0-9]{1,3}\.){3}[0-9]{1,3}$ ]] && echo "=~ format IPv4 : vrai"== web-* : vrai (glob, partie droite sans guillemets)== "web-*" : faux (guillemets = comparaison littérale)=~ motif d'hôte : vrai capture BASH_REMATCH[1] = prod=~ format IPv4 : vraiLe tableau BASH_REMATCH récupère les groupes capturés : l'indice 0 contient la correspondance complète, 1 le premier groupe entre parenthèses. C'est un mini-parseur gratuit, sans appel à sed ni awk.
Tester des nombres
Section intitulée « Tester des nombres »Les entiers utilisent six opérateurs à deux lettres hérités de la commande test : ils ne se lisent pas naturellement, mais ils sont incontournables dans [ ] comme dans [[ ]].
Opérateur test | Équivalent dans (( )) | Signification |
|---|---|---|
-eq | == | égal |
-ne | != | différent |
-lt | < | strictement inférieur |
-le | <= | inférieur ou égal |
-gt | > | strictement supérieur |
-ge | >= | supérieur ou égal |
charge=42
[[ $charge -eq 42 ]] && echo "-eq 42 : vrai"[[ $charge -gt 10 ]] && echo "-gt 10 : vrai"[[ $charge -lt 100 ]] && echo "-lt 100 : vrai"[[ $charge -ne 0 ]] && echo "-ne 0 : vrai"
(( charge > 10 )) && echo "(( charge > 10 )) : vrai"(( charge == 42 )) && echo "(( charge == 42 )) : vrai"La question que tout le monde se pose : pourquoi ne pas écrire [ 5 > 10 ] ? Parce que [ ] est une commande ordinaire, et que > y garde son sens habituel de redirection de sortie. Bash ne compare rien du tout : il exécute [ 5 ] (qui est vrai, la chaîne 5 est non vide) et crée un fichier nommé 10 dans le répertoire courant.
a=5; b=10[ $a > $b ] && echo "5 est supérieur à 10 ?!"ls -l 105 est supérieur à 10 ?!-rw-rw-r-- 1 bob bob 0 juil. 12 11:31 10Le test renvoie vrai, le message absurde s'affiche, et un fichier parasite 10 apparaît. Aucune erreur, aucun avertissement : le bug est silencieux, c'est le pire cas. Les deux formes correctes :
[ "$a" -gt "$b" ] && echo "5 > 10" || echo "5 -gt 10 : faux (correct)"(( a > b )) && echo "5 > 10" || echo "(( 5 > 10 )) : faux (correct)"5 -gt 10 : faux (correct)(( 5 > 10 )) : faux (correct)Le piège de -eq sur une chaîne
Section intitulée « Le piège de -eq sur une chaîne »-eq force une évaluation arithmétique de ses deux opérandes. Sur une variable qui contient du texte, le résultat varie de l'erreur franche au faux positif dangereux, selon le contexte.
version=""[ "$version" -eq 9 ] && echo "version 9"
version="9.2"[[ "$version" -eq 9 ]] && echo "9.2 -eq 9 ?"
release="stable"[[ "$release" -eq 0 ]] && echo "stable -eq 0 : VRAI (!!)"bash: [: : integer expression expectedbash: [[: 9.2: syntax error: invalid arithmetic operator (error token is ".2")stable -eq 0 : VRAI (!!)Les deux premiers cas produisent une erreur visible, tant mieux. Le troisième est un poison : dans un contexte arithmétique, Bash traite stable comme un nom de variable, la trouve non définie, lui affecte la valeur 0, et conclut que 0 -eq 0 est vrai. Votre test « la release vaut-elle zéro » répond oui sur une chaîne qui n'a rien de numérique.
La parade tient en une règle : comparer des chaînes avec =, des nombres avec -eq ou (( )), et jamais l'inverse. Si la valeur vient de l'extérieur (argument, fichier, API), validez qu'elle est numérique avant de la comparer.
if [[ "$version" =~ ^[0-9]+$ ]] && (( version >= 9 )); then echo "version majeure supportée"else echo "version non numérique ou trop ancienne : '$version'"fi[ ], [[ ]], (( )) : lequel choisir
Section intitulée « [ ], [[ ]], (( )) : lequel choisir »C'est le cœur du sujet, et ce qui distingue un script fragile d'un script solide. Les trois formes se ressemblent visuellement mais n'ont rien à voir sous le capot.
[ ] est une commande, pas une syntaxe
Section intitulée « [ ] est une commande, pas une syntaxe »Vérifiez-le vous-même : [ est un exécutable réel, doublé d'une primitive interne du shell. Il existe sur le disque, avec des droits et une taille.
type [ls -l /usr/bin/[[ is a shell builtin-rwxr-xr-x 1 root root 55744 avril 5 2024 /usr/bin/[Deux conséquences immédiates. D'abord, les espaces sont obligatoires : [$a collé ne serait pas reconnu comme la commande [ suivie d'un argument. Ensuite, et c'est le point décisif, le shell découpe la ligne en mots avant de lancer la commande. Une variable non protégée qui contient un espace, ou rien du tout, change le nombre d'arguments reçus par [.
La démonstration qui fait mal
Section intitulée « La démonstration qui fait mal »Deux cas d'école, tous les deux reproduits ci-dessous. Une variable vide : la commande [ reçoit = en premier argument et ne comprend plus rien. Une variable avec un espace : elle reçoit un argument de trop.
reponse=""[ $reponse = "oui" ] && echo "confirmé"echo "code retour : $?"
service="nginx full"[ $service = "nginx full" ] && echo "match"echo "code retour : $?"bash: [: =: unary operator expectedcode retour : 2bash: [: too many argumentscode retour : 2Ces deux messages, unary operator expected et too many arguments, sont les erreurs Bash les plus fréquentes sur les forums. Vous savez maintenant qu'elles ne signalent pas un bug de votre logique, mais une variable non quotée. Le code de retour 2 distingue d'ailleurs l'erreur de syntaxe (2) du simple « faux » (1).
Deux parades existent, et elles ne sont pas équivalentes. La première consiste à quoter systématiquement : c'est obligatoire si votre script doit tourner sous #!/bin/sh (Dash, BusyBox), où [[ ]] n'existe pas.
reponse=""[ "$reponse" = "oui" ] && echo "confirmé" || echo "pas de confirmation (aucune erreur)"
service="nginx full"[ "$service" = "nginx full" ] && echo "match avec guillemets"pas de confirmation (aucune erreur)match avec guillemetsLa seconde consiste à passer à [[ ]], qui est un mot-clé du shell et non une commande : Bash l'analyse avant le découpage en mots, donc il n'y a ni word splitting ni expansion de glob sur les variables. Le même code fonctionne sans un seul guillemet.
reponse=""[[ $reponse = "oui" ]] && echo "confirmé" || echo "pas de confirmation (aucune erreur)"
service="nginx full"[[ $service = "nginx full" ]] && echo "match sans guillemets dans [[ ]]"pas de confirmation (aucune erreur)match sans guillemets dans [[ ]](( )) et son code de retour inversé
Section intitulée « (( )) et son code de retour inversé »(( )) évalue une expression arithmétique avec la syntaxe mathématique habituelle : <, >, ==, +, %, et les variables s'écrivent sans $. Mais il applique une règle contre-intuitive héritée du langage C : le code de retour est 0 (vrai) quand le résultat est différent de zéro, et 1 (faux) quand le résultat vaut zéro.
compteur=0(( compteur )); echo "(( 0 )) -> code retour $?"compteur=3(( compteur )); echo "(( 3 )) -> code retour $?"(( 5 > 10 )); echo "(( 5 > 10 )) -> code retour $?"(( 10 > 5 )); echo "(( 10 > 5 )) -> code retour $?"(( 0 )) -> code retour 1(( 3 )) -> code retour 0(( 5 > 10 )) -> code retour 1(( 10 > 5 )) -> code retour 0Pour les comparaisons, ce comportement est exactement celui qu'on attend : (( 5 > 10 )) est faux, code 1. Le piège apparaît quand (( )) sert à calculer et non à tester, parce que le résultat du calcul devient un code de retour. Nous y revenons dans la section set -e, où cette subtilité tue littéralement des scripts.
Le tableau de décision
Section intitulée « Le tableau de décision »Résumons ce qui précède en une grille de choix. La colonne qui compte vraiment est word splitting : c'est elle qui explique pourquoi [ ] exige un quoting rigoureux et pourquoi [[ ]] vous pardonne un oubli. La colonne nature rappelle la raison profonde : une commande subit les expansions du shell, un mot-clé les précède.
| Forme | Nature | Word splitting | Motifs / regex | Quand l'utiliser |
|---|---|---|---|---|
[ ] | commande externe et primitive | oui, quoting obligatoire | non | scripts POSIX (#!/bin/sh), portabilité BusyBox |
[[ ]] | mot-clé du shell | non | == glob et =~ regex | tout le reste en Bash : fichiers, chaînes |
(( )) | évaluation arithmétique | non | non | entiers, compteurs, seuils |
La règle pratique tient en une phrase : en Bash, [[ ]] pour les fichiers et les chaînes, (( )) pour les nombres, et [ ] uniquement quand le shebang impose sh. Les opérateurs logiques suivent la même logique : && et || dans [[ ]], alors que [ ] utilise les vieux -a et -o que POSIX déclare désormais obsolètes.
f=/etc/fstab[ -f "$f" -a -r "$f" ] && echo "[ -a ] : fonctionne encore, mais obsolète"[ -f "$f" ] && [ -r "$f" ] && echo "deux [ ] chaînés par && : la forme POSIX recommandée"[[ -f "$f" && -r "$f" ]] && echo "[[ && ]] : la forme Bash, lisible et sûre"[ -a ] : fonctionne encore, mais obsolètedeux [ ] chaînés par && : la forme POSIX recommandée[[ && ]] : la forme Bash, lisible et sûrecase : le dispatch propre
Section intitulée « case : le dispatch propre »Quand vous testez la même variable contre plusieurs valeurs, une chaîne de elif devient vite illisible. case compare une expression à une liste de motifs glob (pas de regex ici) et exécute le premier bloc qui correspond. Chaque bloc se termine par ;;, et le motif *) capture tout le reste.
case EXPRESSION in MOTIF1) commandes ;; MOTIF2|MOTIF3) # plusieurs motifs séparés par | commandes ;; *) # cas par défaut, toujours en dernier commandes ;;esacChoisir le gestionnaire de paquets selon la distribution
Section intitulée « Choisir le gestionnaire de paquets selon la distribution »C'est le cas d'usage numéro un. Le fichier /etc/os-release (présent sur toutes les distributions modernes) expose une variable ID que l'on peut sourcer directement, puis dispatcher.
. /etc/os-release
case "$ID" in ubuntu|debian) gest="apt-get" ;; rhel|rocky|almalinux|fedora|centos) gest="dnf" ;; opensuse*|sles) gest="zypper" ;; alpine) gest="apk" ;; *) echo "Distribution non prise en charge : $ID" >&2; exit 1 ;;esac
echo "ID détecté : $ID ($PRETTY_NAME)"echo "Gestionnaire de paquets : $gest"ID détecté : ubuntu (Ubuntu 24.04.2 LTS)Gestionnaire de paquets : apt-getNotez le motif opensuse* : les identifiants openSUSE varient (opensuse-leap, opensuse-tumbleweed), le glob les couvre tous d'un coup. Et le cas par défaut échoue explicitement au lieu de laisser $gest vide, ce qui produirait une erreur bien plus obscure plus loin.
Parser l'action passée en argument
Section intitulée « Parser l'action passée en argument »Le second usage classique, c'est le script qui accepte start, stop, restart. case gère la valeur vide (aucun argument) et la valeur inconnue dans deux branches distinctes, ce qu'un if ferait beaucoup plus lourdement.
action="${1:-}"
case "$action" in start) systemctl start nginx ;; stop) systemctl stop nginx ;; restart) systemctl restart nginx ;; status) systemctl status nginx ;; "") echo "Usage : deploy.sh {start|stop|restart|status}" >&2; exit 2 ;; *) echo "Action inconnue : $action" >&2; exit 2 ;;esac"${1:-}" fournit une chaîne vide si $1 n'existe pas, ce qui évite une erreur sous set -u. Le même motif s'applique au tri par extension, avec des globs plus larges :
for f in rapport.csv archive.tar.gz app.log script.sh notes.txt; do case "$f" in *.tar.gz|*.tgz) echo "$f : archive compressée" ;; *.log) echo "$f : journal" ;; *.sh) echo "$f : script shell" ;; *.csv|*.txt) echo "$f : fichier texte" ;; *) echo "$f : type inconnu" ;; esacdonerapport.csv : fichier textearchive.tar.gz : archive compresséeapp.log : journalscript.sh : script shellnotes.txt : fichier texteL'ordre compte : *.tar.gz doit passer avant un éventuel *.gz, car case s'arrête au premier motif qui correspond. C'est la différence majeure avec une suite de if indépendants.
Un menu de maintenance
Section intitulée « Un menu de maintenance »Combiné à une boucle while et à read, case construit un menu interactif en une quinzaine de lignes. Le script ci-dessous passe shellcheck sans avertissement.
#!/usr/bin/env bashset -uo pipefail
while true; do cat <<'MENU'
=== Maintenance serveur === 1) Espace disque 2) Services en échec 3) Dernières erreurs du journal q) QuitterMENU read -rp "Choix : " choix case "$choix" in 1) df -h / | tail -1 ;; 2) systemctl --failed --no-legend --no-pager | head -3 ;; 3) journalctl -p err -n 3 --no-pager -q ;; q|Q|quit) echo "Au revoir."; break ;; "") echo "Choix vide, recommencez." ;; *) echo "Choix invalide : $choix" ;; esacdoneLe motif q|Q|quit accepte trois écritures de la même intention, et read -rp affiche l'invite sans interpréter les antislashs. Notez set -uo pipefail sans le -e : dans un menu, une commande qui échoue (aucun service en échec, par exemple) ne doit pas faire quitter le programme.
Chaîner avec && et ||
Section intitulée « Chaîner avec && et || »Pour une action unique, && et || sont plus concis qu'un if complet. cmd1 && cmd2 exécute cmd2 seulement si cmd1 réussit ; cmd1 || cmd2 exécute cmd2 seulement si cmd1 échoue.
[[ -f /etc/nginx/nginx.conf ]] && echo "Nginx est configuré"[[ -d /opt/myapp ]] || echo "Répertoire /opt/myapp manquant, installation requise"En revanche, la forme cmd && action_si_vrai || action_si_faux que vous croiserez partout n'est pas un if-then-else. C'est une des erreurs les plus coûteuses en Bash, parce qu'elle passe les revues de code : elle ressemble à un ternaire, elle n'en est pas un. Si action_si_vrai échoue, action_si_faux s'exécute aussi.
Démonstration avec un scénario réaliste : sauvegarder /etc/hosts s'il existe, alerter sinon. Le fichier existe bel et bien, mais l'utilisateur n'a pas le droit d'écrire dans /var/backups.
[[ -f /etc/hosts ]] && cp /etc/hosts /var/backups/hosts.bak || echo "ALERTE : /etc/hosts est introuvable"cp: cannot create regular file '/var/backups/hosts.bak': Permission deniedALERTE : /etc/hosts est introuvableL'alerte est entièrement fausse. /etc/hosts existe, le vrai problème est un droit d'écriture, mais le script accuse un fichier manquant. Un administrateur qui reçoit cette alerte cherchera au mauvais endroit. La version if/else dit la vérité et remonte le bon code de sortie :
if [[ -f /etc/hosts ]]; then cp /etc/hosts /var/backups/hosts.bakelse echo "ALERTE : /etc/hosts est introuvable"ficp: cannot create regular file '/var/backups/hosts.bak': Permission deniedLe script se termine ici avec le code de sortie 1 de cp, sans message trompeur. Règle : && || en une ligne est acceptable quand action_si_vrai est un echo qui ne peut pas échouer. Dès que c'est une commande réelle (cp, mv, systemctl), écrivez un vrai if.
set -e et les conditions
Section intitulée « set -e et les conditions »set -e demande à Bash de quitter dès qu'une commande échoue. Beaucoup pensent qu'un test faux dans un if va donc tuer le script. C'est faux, et c'est heureux : une commande évaluée dans une condition (if, while, &&, ||, !) est explicitement exemptée de set -e. Sans cette exemption, aucun if ne serait utilisable.
#!/usr/bin/env bashset -euo pipefail
if [[ -f /etc/keepalived/keepalived.conf ]]; then echo "Keepalived est configuré"else echo "1. test faux dans un if : le script continue"fi
if grep -q "^admin:" /etc/passwd; then echo "le compte admin existe"else echo "2. grep en échec DANS la condition : le script continue"fi
echo "3. toujours vivant"
grep -q "^admin:" /etc/passwd # même grep, mais HORS conditionecho "4. cette ligne ne s'affichera jamais"1. test faux dans un if : le script continue2. grep en échec DANS la condition : le script continue3. toujours vivantLe script sort avec le code 1 juste après la ligne 3, et la ligne 4 ne s'affiche jamais. Le même grep, avec le même code de retour, est inoffensif dans un if et fatal en dehors. C'est la clé pour comprendre les scripts qui « s'arrêtent sans rien dire » : cherchez la première commande hors condition qui peut renvoyer un code non nul.
(( i++ )) tue votre script
Section intitulée « (( i++ )) tue votre script »Voici la conséquence la plus vicieuse du code de retour de (( )). L'opérateur post-incrément i++ renvoie la valeur avant incrémentation. Quand i vaut 0, (( i++ )) renvoie donc 0, ce qui en arithmétique Bash signifie faux, donc un code de retour 1. Avec set -e, le script meurt sur une simple incrémentation de compteur.
#!/usr/bin/env bashset -euo pipefaili=0echo "avant : i=$i"(( i++ )) # renvoie 1 car la valeur AVANT incrémentation vaut 0echo "après : i=$i" # jamais atteintavant : i=0code de sortie du script : 1Deux parades. Le pré-incrément (( ++i )) renvoie la valeur après incrémentation, donc 1, donc vrai. Ou bien vous neutralisez explicitement le code de retour avec || true, ce qui a le mérite d'être lisible pour le relecteur.
#!/usr/bin/env bashset -euo pipefaili=0(( ++i )) # renvoie 0 car la valeur APRÈS incrémentation vaut 1echo "avec ++i : i=$i"j=0(( j++ )) || true # on neutralise explicitement le code de retourecho "avec || true : j=$j"avec ++i : i=1avec || true : j=1Une troisième option, souvent la plus claire dans un script de comptage : erreurs=$(( erreurs + 1 )). C'est une affectation, pas une commande de test, donc son code de retour vaut toujours 0.
Un script complet, validé par shellcheck
Section intitulée « Un script complet, validé par shellcheck »Réunissons tout : validation des arguments, test de configuration, état du service, existence du compte, seuil disque, et un verdict avec code de sortie distinct selon le type d'échec. Ce script passe shellcheck 0.11 sans un seul avertissement.
#!/usr/bin/env bash# verifier-service.sh : contrôle qu'un service est prêt à tourner.set -euo pipefail
SEUIL_DISQUE=90
usage() { echo "Usage : $(basename "$0") <service> <fichier-conf>" >&2 exit 2}
(( $# == 2 )) || usageservice="$1"conf="$2"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then echo "ERREUR : nom de service invalide : $service" >&2 exit 2fi
erreurs=0
if [[ ! -e "$conf" ]]; then echo "KO conf : $conf introuvable" erreurs=$(( erreurs + 1 ))elif [[ ! -r "$conf" ]]; then echo "KO conf : $conf existe mais n'est pas lisible par $(id -un)" erreurs=$(( erreurs + 1 ))elif [[ ! -s "$conf" ]]; then echo "KO conf : $conf est vide" erreurs=$(( erreurs + 1 ))else echo "OK conf : $conf ($(wc -l < "$conf") lignes)"fi
if systemctl is-active --quiet "$service"; then echo "OK service : $service actif"else echo "KO service : $service inactif (systemctl is-active renvoie $(systemctl is-active "$service" || true))" erreurs=$(( erreurs + 1 ))fi
if id -u "$service" &>/dev/null; then echo "OK compte : l'utilisateur $service existe (uid $(id -u "$service"))"else echo "INFO compte : aucun utilisateur nommé $service (ce n'est pas bloquant)"fi
usage_pct=$(df -P / | awk 'NR==2 {gsub(/%/, "", $5); print $5}')if (( usage_pct >= SEUIL_DISQUE )); then echo "KO disque : / rempli à ${usage_pct}% (seuil ${SEUIL_DISQUE}%)" erreurs=$(( erreurs + 1 ))else echo "OK disque : / à ${usage_pct}% (seuil ${SEUIL_DISQUE}%)"fi
if (( erreurs > 0 )); then echo "--> ${erreurs} contrôle(s) en échec" exit 1fiecho "--> tous les contrôles passent"Trois exécutions, trois issues distinctes. Le cas nominal :
$ bash verifier-service.sh ssh /etc/fstabOK conf : /etc/fstab (14 lignes)OK service : ssh actifINFO compte : aucun utilisateur nommé ssh (ce n'est pas bloquant)OK disque : / à 80% (seuil 90%)--> tous les contrôles passentcode de sortie : 0Le cas dégradé, où le compteur erreurs permet de tout signaler d'un coup plutôt que de sortir au premier problème :
$ bash verifier-service.sh nginx /etc/absent.confKO conf : /etc/absent.conf introuvableKO service : nginx inactif (systemctl is-active renvoie inactive)INFO compte : aucun utilisateur nommé nginx (ce n'est pas bloquant)OK disque : / à 80% (seuil 90%)--> 2 contrôle(s) en écheccode de sortie : 1Et l'erreur d'appel, qui renvoie 2 pour se distinguer d'un contrôle métier en échec :
$ bash verifier-service.sh sshUsage : verifier-service.sh <service> <fichier-conf>code de sortie : 2Dépannage
Section intitulée « Dépannage »Tous les symptômes ci-dessous ont été reproduits sur Bash 5.2 en préparant cette page. Ils couvrent la quasi-totalité des messages d'erreur liés aux conditions.
| Symptôme | Cause | Solution |
|---|---|---|
[: =: unary operator expected | variable vide non quotée dans [ ] : la commande reçoit = en premier argument | quoter ([ "$var" = "x" ]) ou passer à [[ $var = "x" ]] |
[: too many arguments | variable contenant un espace non quotée : [ reçoit un argument de trop | même parade : quoter, ou [[ ]] |
[: : integer expression expected | -eq appliqué à une chaîne vide | valider avec [[ "$v" =~ ^[0-9]+$ ]] avant de comparer |
[[: 9.2: syntax error: invalid arithmetic operator | -eq sur un décimal : Bash ne fait que de l'entier | comparer en chaînes (=), ou passer par awk / bc |
[[ "$mot" -eq 0 ]] renvoie vrai sur du texte | en contexte arithmétique, un nom inconnu vaut 0 | comparer les chaînes avec =, jamais avec -eq |
un fichier nommé 10 apparaît | [ $a > $b ] : > est une redirection, pas une comparaison | utiliser -gt dans [ ], ou (( a > b )) |
le script s'arrête sur (( i++ )) | (( )) renvoie 1 quand le résultat est 0, et set -e quitte | écrire (( ++i )), (( i++ )) || true ou i=$(( i + 1 )) |
| l'alerte « fichier introuvable » se déclenche alors que le fichier existe | faux ternaire cmd && a || b : b s'exécute aussi si a échoue | écrire un vrai if / else |
[: unexpected operator sous sh | [[ ]] ou == utilisés avec un shebang #!/bin/sh (Dash) | passer le shebang à #!/usr/bin/env bash, ou n'utiliser que [ ] et = |
le test de tri (<) change de résultat selon la machine | la comparaison lexicographique suit la locale | forcer LC_ALL=C en tête de script |
case ne correspond à rien et le script continue | pas de motif *) par défaut | toujours terminer par *) , en échouant explicitement si besoin |
=~ ne trouve jamais rien | la regex est entre guillemets, donc traitée littéralement | écrire la regex nue : [[ "$v" =~ ^[0-9]+$ ]] |
Contrôle de connaissances
Section intitulée « Contrôle de connaissances »Vérifiez que l'essentiel de ce guide est acquis. Les questions portent uniquement sur ce qui vient d'être expliqué ici.
Contrôle de connaissances
Validez vos connaissances avec ce quiz interactif
Informations
- Le chronomètre démarre au clic sur Démarrer
- Questions à choix multiples, vrai/faux et réponses courtes
- Vous pouvez naviguer entre les questions
- Les résultats détaillés sont affichés à la fin
Lance le quiz et démarre le chronomètre
Vérification
(0/0)Profil de compétences
Quoi faire maintenant
Ressources pour progresser
Des indices pour retenter votre chance ?
Nouveau quiz complet avec des questions aléatoires
Retravailler uniquement les questions ratées
Retour à la liste des certifications
À retenir
Section intitulée « À retenir »- Un
ifteste un code de retour, pas un booléen :0vaut vrai, tout le reste vaut faux. [ ]est une commande : le shell découpe la ligne avant de l'appeler, d'oùtoo many argumentssur une variable non quotée.[[ ]]est un mot-clé : ni word splitting ni glob sur les variables, plus&&,||, les motifs et=~. C'est la forme à préférer en Bash.(( ))pour les entiers, avec la syntaxe mathématique et sans$. Son code de retour est inversé :(( 0 ))renvoie 1.- Fichiers :
-e(existe),-f(fichier),-d(répertoire),-L(lien),-s(non vide),-r/-w/-x(droits du processus courant),-nt/-ot(fraîcheur). - Chaînes :
-z(vide),-n(non vide),=,!=. Nombres :-eq,-ne,-lt,-le,-gt,-ge. Ne jamais croiser les deux familles. cmd && a || bn'est pas un if-then-else : siaéchoue,bs'exécute aussi et vous alerte sur le mauvais problème.- Sous
set -e, un test faux dans une condition n'arrête pas le script, mais une commande qui échoue hors condition, si. casebat une pile deelifdès qu'on teste une même variable : dispatch par distribution, par action, par extension.- Faites relire chaque script par shellcheck avant de le committer.
Pour aller plus loin
Section intitulée « Pour aller plus loin »- Scripts Bash robustes :
set -euo pipefail,trap,mktempet les codes de sortie propres. - Déboguer un script Bash :
bash -x,set -x, shellcheck et la lecture d'une trace. - Un service qui ne démarre pas : ce que vos conditions
systemctl is-activedoivent déclencher. - Planifier avec cron : pourquoi un script qui passe dans votre terminal peut échouer sous cron.