Aller au contenu
Administration Linux medium

Conditions Bash : if, tests et case par l'exemple

53 min de lecture

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.

  • Écrire des blocs if / elif / else qui réagissent à l'état réel du système
  • Tester fichiers, chaînes et entiers avec le bon opérateur (-f, -z, -eq et les autres)
  • Distinguer [ ], [[ ]] et (( )) et savoir lequel utiliser quand
  • Reproduire puis corriger les erreurs too many arguments et unary operator expected
  • Dispatcher proprement avec case selon la distribution ou l'argument reçu
  • Éviter les pièges de set -e, du code de retour inversé de (( )) et du faux ternaire && ||

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 :

Fenêtre de terminal
getent passwd bob > /dev/null; echo "getent passwd bob -> $?"
getent passwd deployer > /dev/null; echo "getent passwd deployer -> $?"
systemctl is-active ssh
systemctl is-active nginx
getent passwd bob -> 0
getent passwd deployer -> 2
active
inactive

Le 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.

Fenêtre de terminal
if systemctl is-active --quiet ssh; then
echo "ssh est actif, rien à faire"
else
echo "ssh est arrêté, redémarrage requis"
fi
ssh est actif, rien à faire

L'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.

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).

Fenêtre de terminal
if CONDITION; then
# commandes si le code de retour vaut 0
elif AUTRE_CONDITION; then
# commandes si la première est fausse et celle-ci vraie
else
# commandes par défaut
fi

Le 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.

Fenêtre de terminal
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"
fi

Exé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 bob

Cet 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.

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.

Fenêtre de terminal
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"
fi
done
ssh : actif
docker : actif
cron : actif
postgresql : unité inconnue

systemctl 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.

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.

Fenêtre de terminal
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"
fi
done
bob : existe (uid 1000, shell /usr/bin/zsh)
www-data : existe (uid 33, shell /usr/sbin/nologin)
deployer : n'existe pas

Le 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 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.

Fenêtre de terminal
seuil=75
while 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%)"
fi
done < <(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.

$# 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.

Fenêtre de terminal
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 2
fi

Le 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.

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.

TestVrai quandUsage typique
-e cheminle chemin existe (fichier, répertoire, lien, socket…)vérifier avant d'écraser
-f cheminc'est un fichier ordinairelire une config
-d cheminc'est un répertoirevérifier une destination
-L cheminc'est un lien symboliquedétecter un lien brisé
-s cheminil existe et sa taille est > 0rejeter un dump vide
-r cheminle processus courant peut le lireanticiper un « Permission denied »
-w cheminle processus courant peut y écrirevérifier un répertoire de sortie
-x cheminil est exécutable (ou traversable pour un répertoire)valider un binaire avant appel
-O cheminil appartient à l'utilisateur courantcontrôler la propriété d'un secret
f1 -nt f2f1 est plus récent que f2régénérer un artefact périmé
f1 -ot f2f1 est plus vieux que f2détecter une conf non rechargée

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.

Fenêtre de terminal
mkdir -p /tmp/lc-demo
printf 'listen 8080;\n' > /tmp/lc-demo/app.conf
ln -sf /tmp/lc-demo/app.conf /tmp/lc-demo/lien.conf
ln -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.

-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.

Fenêtre de terminal
: > /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 : vrai

Point 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.

Fenêtre de terminal
sleep 1
printf '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 : vrai
app.conf -ot recent.log : vrai

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.

TestVrai 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" == motifla chaîne correspond au motif glob (dans [[ ]] uniquement)
"$var" =~ regexla chaîne correspond à la regex étendue (dans [[ ]] uniquement)
Fenêtre de terminal
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 : vrai
abc < 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.

Fenêtre de terminal
[[ "Zebre" < "abeille" ]] && echo "vrai" || echo "faux" # locale fr_FR.UTF-8
LC_ALL=C bash -c '[[ Zebre < abeille ]] && echo "vrai" || echo "faux"' # ordre ASCII strict
faux
vrai

Si 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.

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.

Fenêtre de terminal
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 : vrai

Le 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.

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
Fenêtre de terminal
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.

Fenêtre de terminal
a=5; b=10
[ $a > $b ] && echo "5 est supérieur à 10 ?!"
ls -l 10
5 est supérieur à 10 ?!
-rw-rw-r-- 1 bob bob 0 juil. 12 11:31 10

Le 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 :

Fenêtre de terminal
[ "$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)

-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.

Fenêtre de terminal
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 expected
bash: [[: 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.

Fenêtre de terminal
if [[ "$version" =~ ^[0-9]+$ ]] && (( version >= 9 )); then
echo "version majeure supportée"
else
echo "version non numérique ou trop ancienne : '$version'"
fi

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.

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.

Fenêtre de terminal
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 [.

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.

Fenêtre de terminal
reponse=""
[ $reponse = "oui" ] && echo "confirmé"
echo "code retour : $?"
service="nginx full"
[ $service = "nginx full" ] && echo "match"
echo "code retour : $?"
bash: [: =: unary operator expected
code retour : 2
bash: [: too many arguments
code retour : 2

Ces 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.

Fenêtre de terminal
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 guillemets

La 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.

Fenêtre de terminal
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 [[ ]]

(( )) é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.

Fenêtre de terminal
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 0

Pour 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.

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.

FormeNatureWord splittingMotifs / regexQuand l'utiliser
[ ]commande externe et primitiveoui, quoting obligatoirenonscripts POSIX (#!/bin/sh), portabilité BusyBox
[[ ]]mot-clé du shellnon== glob et =~ regextout le reste en Bash : fichiers, chaînes
(( ))évaluation arithmétiquenonnonentiers, 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.

Fenêtre de terminal
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ète
deux [ ] chaînés par && : la forme POSIX recommandée
[[ && ]] : la forme Bash, lisible et sûre

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 ;;
esac

Choisir 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.

Fenêtre de terminal
. /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-get

Notez 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.

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.

Fenêtre de terminal
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 :

Fenêtre de terminal
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" ;;
esac
done
rapport.csv : fichier texte
archive.tar.gz : archive compressée
app.log : journal
script.sh : script shell
notes.txt : fichier texte

L'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.

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 bash
set -uo pipefail
while true; do
cat <<'MENU'
=== Maintenance serveur ===
1) Espace disque
2) Services en échec
3) Dernières erreurs du journal
q) Quitter
MENU
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" ;;
esac
done

Le 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.

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.

Fenêtre de terminal
[[ -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.

faux-ternaire.sh
[[ -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 denied
ALERTE : /etc/hosts est introuvable

L'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 :

vrai-if.sh
if [[ -f /etc/hosts ]]; then
cp /etc/hosts /var/backups/hosts.bak
else
echo "ALERTE : /etc/hosts est introuvable"
fi
cp: cannot create regular file '/var/backups/hosts.bak': Permission denied

Le 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 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 bash
set -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 condition
echo "4. cette ligne ne s'affichera jamais"
1. test faux dans un if : le script continue
2. grep en échec DANS la condition : le script continue
3. toujours vivant

Le 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.

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 bash
set -euo pipefail
i=0
echo "avant : i=$i"
(( i++ )) # renvoie 1 car la valeur AVANT incrémentation vaut 0
echo "après : i=$i" # jamais atteint
avant : i=0
code de sortie du script : 1

Deux 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 bash
set -euo pipefail
i=0
(( ++i )) # renvoie 0 car la valeur APRÈS incrémentation vaut 1
echo "avec ++i : i=$i"
j=0
(( j++ )) || true # on neutralise explicitement le code de retour
echo "avec || true : j=$j"
avec ++i : i=1
avec || true : j=1

Une 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.

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 )) || usage
service="$1"
conf="$2"
if [[ ! "$service" =~ ^[a-zA-Z0-9@._-]+$ ]]; then
echo "ERREUR : nom de service invalide : $service" >&2
exit 2
fi
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 1
fi
echo "--> tous les contrôles passent"

Trois exécutions, trois issues distinctes. Le cas nominal :

$ bash verifier-service.sh ssh /etc/fstab
OK conf : /etc/fstab (14 lignes)
OK service : ssh actif
INFO compte : aucun utilisateur nommé ssh (ce n'est pas bloquant)
OK disque : / à 80% (seuil 90%)
--> tous les contrôles passent
code de sortie : 0

Le 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.conf
KO conf : /etc/absent.conf introuvable
KO 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 échec
code de sortie : 1

Et l'erreur d'appel, qui renvoie 2 pour se distinguer d'un contrôle métier en échec :

$ bash verifier-service.sh ssh
Usage : verifier-service.sh <service> <fichier-conf>
code de sortie : 2

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ômeCauseSolution
[: =: unary operator expectedvariable vide non quotée dans [ ] : la commande reçoit = en premier argumentquoter ([ "$var" = "x" ]) ou passer à [[ $var = "x" ]]
[: too many argumentsvariable contenant un espace non quotée : [ reçoit un argument de tropmême parade : quoter, ou [[ ]]
[: : integer expression expected-eq appliqué à une chaîne videvalider 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'entiercomparer en chaînes (=), ou passer par awk / bc
[[ "$mot" -eq 0 ]] renvoie vrai sur du texteen contexte arithmétique, un nom inconnu vaut 0comparer les chaînes avec =, jamais avec -eq
un fichier nommé 10 apparaît[ $a > $b ] : > est une redirection, pas une comparaisonutiliser -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 existefaux 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 machinela comparaison lexicographique suit la localeforcer LC_ALL=C en tête de script
case ne correspond à rien et le script continuepas de motif *) par défauttoujours terminer par *) , en échouant explicitement si besoin
=~ ne trouve jamais rienla regex est entre guillemets, donc traitée littéralementécrire la regex nue : [[ "$v" =~ ^[0-9]+$ ]]

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

6 questions
6 min.
70% requis

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

  • Un if teste un code de retour, pas un booléen : 0 vaut vrai, tout le reste vaut faux.
  • [ ] est une commande : le shell découpe la ligne avant de l'appeler, d'où too many arguments sur 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 || b n'est pas un if-then-else : si a échoue, b s'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.
  • case bat une pile de elif dè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.

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn