Aller au contenu

Tests et conditions

100 Apprenti ⏱ 1 h 15 bashlinuxdebianubuntu

À la fin, vous saurez

  • Écrire une condition à partir du code de sortie de n'importe quelle commande, avec if, elif, else, && et ||
  • Choisir entre test, [ et [[, et expliquer les messages « unary operator expected » et « too many arguments »
  • Comparer correctement des chaînes, des entiers et des dates, sans confondre comparaison de texte et comparaison numérique
  • Tester l'existence, le type, la taille et les droits d'un fichier, et l'existence d'une variable
  • Valider une entrée par un motif ou une expression régulière, et en extraire des morceaux avec BASH_REMATCH
  • Aiguiller un traitement avec case selon la forme d'une valeur
  • Empêcher un script de publier un fichier vide ou invalide, et diagnostiquer une réponse HTTP par son code et son contenu
  • Reconnaître les injections par motif non cité et par évaluation arithmétique, et s'en protéger

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) coreutils 9.4 (Ubuntu), 9.7 (Debian) curl 8.5.0 (Ubuntu), 8.14.1 (Debian) jq 1.7.1 , vérifié le 8 octobre 2026

Pourquoi

Le 21 mars, la mairie appelle : le fichier des signalements du 14 est vide. Pas illisible, pas corrompu : il contient une seule ligne, l'en-tête id,type,commune,date, et rien d'autre. Ce soir-là, la réplique de lecture de sig-db venait d'être recréée et n'avait pas encore reçu ses données ; l'export Python a lu une table vide, a écrit consciencieusement un CSV sans signalement, et s'est terminé avec succès. Puis le script de Camille, publier-export, a compressé ce fichier, l'a déposé dans le bucket de la mairie et a écrit dans le journal « Export publié ». Personne n'a rien vu pendant une semaine, et une semaine de nids-de-poule n'a pas été transmise aux services techniques.

Voici le cœur de ce script, tel qu'il tournait ce soir-là dans /opt/signalements/bin/publier-export :

#!/bin/bash
cd /srv/donnees/exports
f=signalements-$(date +%F).csv
gzip -kf $f
aws s3 cp $f.gz s3://sig-exports-mairie/$(date +%Y/%m)/ --endpoint-url https://s3.fr-par.scw.cloud
echo "Export publié"

Il n'y a aucune décision dans ce script. Il ne se demande jamais si le fichier existe, s'il contient quelque chose, si la compression a réussi, si le dépôt a été accepté. Pire : si le fichier manque, gzip échoue, aws échoue, et le script affiche quand même « Export publié » et se termine avec le code 0, celui de son dernier echo. Pour le minuteur systemd qui le lance, et pour la supervision qui lit ce code, tout va bien.

La réécriture de la leçon 3 a corrigé les dates et enchaîné les étapes par &&, ce qui arrête le dépôt quand gzip échoue. Mais elle n'aurait rien changé au 14 mars : un fichier qui ne contient que son en-tête se compresse et se dépose parfaitement. Aucune commande n'échoue ; c'est le contenu qui est faux, et seul un test explicite peut le voir.

Un script d'automatisation remplace une personne attentive. Cette personne, avant d'envoyer un fichier à un client, l'aurait ouvert. Cette leçon donne au script les moyens de faire la même chose : vérifier des faits, puis décider. Elle montre la façon dont Bash représente le vrai et le faux, les trois syntaxes de test qui coexistent (et pourquoi l'une d'elles produit tant de messages d'erreur incompréhensibles), les comparaisons de chaînes, de nombres et de fichiers, la validation par motif et par expression régulière, et case. À la fin, publier-export refuse de publier un export absent, vide, mal formé ou sans signalement, et un second script, verifier-sante, sait distinguer une machine qui répond « tout va bien » d'une machine qui répond, mais mal.

Les concepts

En shell, une condition est une commande

Dans la plupart des langages, une condition est une expression qui vaut vrai ou faux. En shell, c'est une commande qui réussit ou échoue. Le vrai, c'est le code de sortie 0 ; le faux, c'est tout le reste. Vous connaissez ces codes depuis la leçon Redirections, tubes et codes de sortie : $?, &&, ||. La structure if n'ajoute rien d'autre que de la lisibilité :

if grep -q ' 500 ' acces.log; then
    echo "des erreurs 500"
fi

Il n'y a pas de crochets, et il n'en faut pas : if exécute grep -q, regarde son code de sortie, et entre dans le bloc then s'il vaut 0. Toute commande peut servir de condition : grep -q, cmp -s, id -u signalements, systemctl is-active --quiet nginx, gzip -t archive.gz, ping -c1 -W1 sig-db. C'est l'idée la plus importante de la leçon, et celle que l'on oublie le plus : avant d'écrire un test entre crochets, demandez-vous si une commande ne répond pas déjà à la question par son code de sortie.

Cette convention a une conséquence qui déroute au début : en shell, 0 est vrai, alors qu'en C ou en Python 0 est faux. Elle vient de ce qu'un programme n'a qu'une manière de réussir, mais beaucoup de raisons d'échouer : le code 0 dit « tout va bien », les autres valeurs disent pourquoi ça ne va pas (grep : 1 pour « rien trouvé », 2 pour « erreur »). Le manuel de Bash le formule ainsi pour les commandes composées : le statut de retour est celui de la dernière commande exécutée.

if, elif, else

La forme complète :

if commande_1; then
    ...                  # si commande_1 a réussi
elif commande_2; then
    ...                  # sinon, si commande_2 a réussi
else
    ...                  # sinon
fi

Quelques règles, tirées de la grammaire de Bash :

  • then, elif, else et fi sont des mots-clés (reserved words) : ils ne sont reconnus qu'en position de début de commande, d'où le ; (ou le saut de ligne) avant then. if grep -q x f then cherche le motif x dans les fichiers f et then, et le shell attend toujours son then.
  • Ce qui suit if est une liste de commandes : if cd /srv/donnees && [ -w . ]; then est valide. C'est le code de la dernière commande exécutée de la liste qui décide.
  • Le code de sortie d'un if complet est celui de la dernière commande exécutée dans le bloc choisi, ou 0 si aucune condition n'était vraie et qu'il n'y a pas de else. Un if qui ne fait rien réussit.
  • ! devant une commande inverse son code : if ! grep -q 'id,type' f; then entre dans le bloc si grep échoue. Attention, ! transforme tout échec en succès : un grep qui renvoie 2 parce que le fichier n'existe pas devient lui aussi « vrai ».

Trois façons d'écrire un test : test, [ et [[

Pour comparer deux chaînes ou interroger un fichier, il faut une commande dont c'est le métier. Bash en offre trois formes, qui ne se valent pas.

test est une commande, décrite par POSIX, qui évalue l'expression formée par ses arguments et renvoie 0 (vrai), 1 (faux) ou plus de 1 (erreur) :

test -f /srv/donnees/exports/signalements-2026-10-07.csv

[ est la même commande, sous un autre nom. Son seul caprice est d'exiger que son dernier argument soit ]. Ce n'est pas de la syntaxe : c'est un argument comme un autre, qui doit donc être séparé par une espace. [ -f fichier ] lance la commande [ avec trois arguments : -f, fichier et ]. Bash fournit test et [ comme commandes internes (builtins, exécutées par le shell lui-même, sans créer de processus), et coreutils fournit aussi /usr/bin/test et /usr/bin/[, pour les programmes qui ne passent pas par un shell.

[[ est d'une autre nature : c'est un mot-clé de Bash, venu de KornShell, comme if ou while. Le shell le reconnaît au moment d'analyser la ligne, avant toute expansion, et traite son contenu avec des règles propres. Le manuel le dit sans ambiguïté : entre [[ et ]], les mots ne subissent ni découpage en mots ni développement des chemins. Les variables sont développées, les substitutions de commande aussi, mais le résultat reste un seul mot, quoi qu'il contienne.

Ce que cela change, en pratique :

test / [[[
Naturecommande (interne ou externe)mot-clé, analysé par le shell
NormePOSIX, fonctionne sous dashBash, Zsh, ksh ; absent de dash
Variable non citée, vide ou avec espaceserreur ou résultat fauxfonctionne
== motif globnon (== y est une égalité de chaînes, extension Bash)oui, si le membre droit n'est pas cité
=~ expression régulièrenonoui
< et >à échapper (\<), sinon redirection ; ordre ASCIIordre de la locale courante
Combiner[ a ] && [ b ] (-a et -o retirés de POSIX)&&, `
Opérateur dans une variablepossibleimpossible : reconnu à l'analyse

La règle de travail, que l'on trouve aussi dans le BashGuide de Greg Wooledge : dans un script Bash, utilisez [[ ]] pour les chaînes et les fichiers, (( )) pour les nombres, et [ ] seulement dans un script qui doit tourner sous sh. Le reste de la leçon emploie [[ ]], en signalant ce qui diffère avec [.

Important

Rappel de la leçon 1 : un script lancé par sh script, ou une ligne de crontab, est exécuté par dash sur Debian et Ubuntu. dash ne connaît ni [[, ni ==, ni =~. Le message [[: not found dans un journal signale presque toujours un script Bash lancé par sh.

Comparer des chaînes

ExpressionVraie si
[[ $a == "$b" ]]a et b sont identiques (= est un synonyme)
[[ $a != "$b" ]]elles diffèrent
[[ $a < "$b" ]]a se classe avant b dans l'ordre de la locale
[[ $a > "$b" ]]a se classe après b
[[ -z $a ]]a est vide (longueur nulle, zero)
[[ -n $a ]]a n'est pas vide (non-zero)

Les guillemets autour de $b ne sont pas décoratifs. Dans [[ ]], le membre droit de == et != est un motif, au sens des motifs glob des noms de fichiers : *, ?, [...] y gardent leur sens. Le manuel le précise : toute partie citée du motif est comparée comme une chaîne littérale. Si b contient * et que vous ne le citez pas, la comparaison devient « a correspond-il au motif contenu dans b ? ». ShellCheck signale ce cas (avertissement SC2053, Quote the rhs of = in [[ ]] to prevent glob matching). Le membre gauche, lui, n'a pas besoin de guillemets dans [[ ]].

Pour < et >, le manuel de Bash indique que [[ classe selon la locale courante (les réglages de langue et de pays du processus, qui fixent entre autres l'ordre alphabétique), alors que test et [ classent selon l'ordre ASCII. Dans la locale C.UTF-8, a vient après B (les majuscules ont des codes plus petits) ; dans fr_FR.UTF-8, il vient avant. Un même script peut donc donner deux résultats selon le compte qui le lance. Ces opérateurs comparent des textes, jamais des nombres : [[ 10 < 9 ]] est vrai, parce que le caractère 1 se classe avant 9.

Comparer des nombres

Pour les entiers, test et [[ utilisent des opérateurs en lettres, hérités du shell Bourne :

OpérateurSens
-eqégal (equal)
-nedifférent (not equal)
-ltstrictement inférieur (less than)
-leinférieur ou égal
-gtstrictement supérieur (greater than)
-gesupérieur ou égal

Bash offre une forme plus naturelle, la condition arithmétique (( )), qui comprend les opérateurs du C : (( lignes >= 2 )), (( code != 0 )), (( a < b && b < c )). D'après le manuel, (( expression )) renvoie 0 si l'expression vaut une valeur non nulle, et 1 si elle vaut zéro. On y écrit les variables sans $. C'est la forme recommandée pour les nombres dans un script Bash ; la leçon 3 a présenté l'arithmétique elle-même, avec ses limites : entiers seulement, et un zéro en tête qui fait lire le nombre en octal.

Les deux familles diffèrent dans la façon de traiter un opérande qui n'est pas un nombre :

  • [ abc -eq 1 ] refuse : [: abc: integer expression expected, code 2. C'est le comportement qu'on souhaite.
  • [[ abc -eq 1 ]] et (( abc == 1 )) acceptent : le manuel précise que, dans [[, les opérandes de -eq et consorts sont évalués comme des expressions arithmétiques. abc y est le nom d'une variable, et une variable inexistante vaut 0. Le test renvoie simplement « faux », sans erreur.

Ce second comportement est pratique (on peut écrire [[ n -gt 3 ]]), mais il a un revers de sécurité sérieux, que la section Sécurité démonte.

Tester des fichiers

Les opérateurs de fichiers sont les mêmes pour test, [ et [[. Les plus utiles :

ExpressionVraie si le fichier...
-e fexiste, quel que soit son type
-f fexiste et est un fichier ordinaire (pas un répertoire, pas un périphérique)
-d fexiste et est un répertoire
-L f (ou -h f)est un lien symbolique, même cassé
-s fexiste et sa taille est supérieure à zéro
-r f, -w f, -x fexiste et est lisible, modifiable, exécutable par le processus courant
-O fappartient à l'utilisateur effectif courant
f1 -nt f2f1 est plus récent que f2 (date de modification), ou f1 existe et f2 non
f1 -ot f2f1 est plus ancien que f2, ou f2 existe et f1 non
f1 -ef f2les deux noms désignent le même fichier (même périphérique et même inode)

Deux précisions du manuel comptent en pratique. D'abord, sauf mention contraire, ces opérateurs suivent les liens symboliques : -e lien teste la cible. Un lien cassé n'« existe » donc pas pour -e, mais existe pour -L (piège n° 37 de la liste BashPitfalls). Ensuite, -r, -w et -x interrogent le noyau sur les droits du processus qui fait le test, pas sur ceux du compte qui exécutera la suite ; on y revient en Sécurité.

-nt, -ot et -ef étaient longtemps des extensions ; POSIX les a intégrés dans sa version de 2024, en même temps que < et > pour test.

Tester des variables : -z, -n et -v

Une variable vide et une variable inexistante sont deux choses différentes, que -z ne distingue pas :

[[ -z $jour ]]       # vrai si jour est vide OU n'existe pas
[[ -v jour ]]        # vrai si jour existe (a reçu une valeur, même vide)

-v prend le nom de la variable, sans $. Il est utile quand l'absence a un sens (« l'opérateur n'a rien précisé ») différent de la valeur vide (« l'opérateur a explicitement demandé rien »). La plupart du temps, la forme ${var:-défaut} de la leçon 3 suffit. Sous set -u (leçon 9), [[ -n $absente ]] provoque une erreur : on écrit alors [[ -n ${absente:-} ]], ou [[ -v absente ]].

Motifs et expressions régulières dans [[ ]]

[[ ]] sait reconnaître une forme de deux façons.

Par motif glob, avec == et !=, membre droit non cité :

[[ $fichier == *.csv ]]                       # se termine par .csv
[[ $hote == sig-app-[0-9] ]]                  # sig-app- suivi d'un chiffre
[[ $ligne == *$'\r' ]]                        # se termine par un retour chariot
[[ $f == "rapport du "*.txt ]]                # partie citée = littérale, * = motif

Le motif doit correspondre à toute la chaîne. Le manuel ajoute que les motifs étendus (@(a|b), +([0-9]), ceux de l'option extglob) sont toujours actifs dans [[ ]], et que l'option nocasematch rend la comparaison insensible à la casse.

Par expression régulière (regular expression, un langage de description de formes plus puissant que le glob : répétitions, alternatives, groupes), avec =~. Bash utilise la syntaxe étendue de POSIX (ERE), celle de grep -E, en appelant les fonctions regcomp et regexec de la bibliothèque C. Contrairement au motif, l'expression régulière correspond si elle trouve sa forme n'importe où dans la chaîne : il faut l'ancrer par ^ (début) et $ (fin) pour exiger une correspondance complète.

Le grand intérêt de =~ est le tableau BASH_REMATCH : l'élément 0 contient la partie de la chaîne qui a correspondu, et l'élément n la partie capturée par le n-ième groupe entre parenthèses.

format_date='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
if [[ $jour =~ $format_date ]]; then
    annee=${BASH_REMATCH[1]}
    mois=${BASH_REMATCH[2]}
fi

Une règle de citation à connaître par cœur : le membre droit de =~ ne se cite pas. Le manuel est explicite : toute partie citée est comparée littéralement, et citer une variable qui contient l'expression la rend entièrement littérale. [[ $jour =~ "^[0-9]{4}" ]] cherche le texte ^[0-9]{4} tel quel. ShellCheck le signale par SC2076 (Don't quote rhs of =~, it'll match literally rather than as a regex). La bonne pratique, recommandée par Greg's Wiki, est de placer l'expression dans une variable, entre apostrophes au moment de l'affectation, puis de l'utiliser sans guillemets : on évite ainsi toutes les questions d'échappement des espaces, des | et des parenthèses, que le shell interpréterait sinon.

Si l'expression est syntaxiquement fausse, [[ ]] renvoie 2, et non 1 : une erreur ne se confond pas avec un « ne correspond pas ».

case : aiguiller selon un motif

Quand une même valeur se compare à plusieurs formes, case remplace une cascade de elif :

case $code_http in
    200)          echo "réponse normale" ;;
    301|302|308)  echo "redirection" ;;
    4??)          echo "erreur du client" ;;
    5[0-9][0-9])  echo "erreur du serveur" ;;
    *)            echo "code inattendu : $code_http" ;;
esac

Chaque branche est un ou plusieurs motifs glob séparés par |, suivis de ), d'une liste de commandes et de ;;. Le manuel décrit le fonctionnement : le mot est comparé aux motifs dans l'ordre, et la première correspondance gagne. Deux terminaisons supplémentaires existent depuis Bash 4 : ;& exécute aussi le bloc suivant sans tester son motif, et ;;& continue à tester les motifs suivants. Elles sont rares et surprennent le lecteur : réservez-les aux cas où elles simplifient vraiment.

case a trois qualités qui en font l'outil préféré pour valider une entrée :

  • il est POSIX : il fonctionne sous dash, dans un script sh, dans un Dockerfile ;
  • le mot examiné n'est pas découpé en mots : case $x in n'a pas besoin de guillemets ;
  • le dernier motif *) attrape tout le reste, ce qui oblige à penser au cas imprévu.

Son code de sortie est 0 si aucun motif ne correspond. Comme pour [[ == ]], une partie citée d'un motif est littérale : case $f in "$motif") compare au texte de la variable, case $f in $motif) l'utilise comme motif.

&&, || et !

Pour une condition courte, && et || évitent un bloc if :

[[ -d $repertoire ]] || mkdir -p -- "$repertoire"
[[ -n $verbeux ]] && echo "dépôt vers $cible"

Ils sont évalués de gauche à droite, avec la même priorité, et une commande sautée ne change pas le code de sortie courant. D'où le piège le plus fréquent de cette leçon, n° 22 de BashPitfalls et avertissement SC2015 de ShellCheck (Note that A && B || C is not if-then-else. C may run when A is true.) :

[[ -n $essai ]] && echo "j'aurais supprimé $f" || rm -- "$f"

Si essai est défini mais que echo échoue (sortie standard fermée, disque plein pour une redirection), rm s'exécute. L'exemple de ShellCheck est exactement celui-ci. A && B || C signifie « si A échoue ou si B échoue, alors C ». Pour un « si, alors, sinon », écrivez un if.

À l'intérieur de [[ ]], &&, ||, ! et les parenthèses sont des opérateurs de l'expression, avec court-circuit : [[ -f $f && -s $f ]] ne teste pas la taille si le fichier n'existe pas. Avec [, on combine deux commandes : [ -f "$f" ] && [ -s "$f" ]. Les opérateurs -a et -o de test, que l'on trouve dans de vieux scripts ([ -f "$f" -a -s "$f" ]), ont été retirés de POSIX en 2024 parce que leur grammaire était ambiguë ; ShellCheck les signale (SC2166).

En pratique

Les essais ont été faits avec Bash 5.2.21 et LC_ALL=C.UTF-8, dans un répertoire essais/ de votre poste ou d'une machine jetable : rien ici ne demande d'accéder aux vrais serveurs. Les messages d'erreur sont ceux d'un shell interactif ; dans un script, Bash les préfixe du nom du script et du numéro de ligne (publier-export: line 12: ...).

Lire les messages de [

Commençons par les erreurs, car vous les rencontrerez dans tous les scripts hérités. Une variable vide, non citée, avec [ :

$ fichier=
$ [ $fichier = signalements.csv ]
bash: [: =: unary operator expected
$ echo $?
2

Pourquoi ce message ? Le shell développe $fichier en rien (pas même un mot vide, puisqu'il n'est pas cité), et [ reçoit trois arguments : =, signalements.csv, ]. La règle de POSIX pour trois arguments (hors ], il en reste deux) dit : si le premier est un opérateur unaire, appliquer le test unaire. = n'en est pas un, d'où unary operator expected. Le code 2 signale une erreur, pas un « faux » : dans un if, il conduit pourtant au else, comme un faux.

Une variable qui contient des espaces :

$ nom="export du jour.csv"
$ [ -f $nom ]
bash: [: too many arguments
$ echo $?
2

[ a reçu -f, export, du, jour.csv et ] : quatre arguments utiles, qu'aucune règle ne sait interpréter. Avec un nom à deux mots, le message aurait été binary operator expected. Les deux remèdes : citer ([ -f "$nom" ]), ou utiliser [[ -f $nom ]], qui ne découpe pas.

Un nombre qui n'en est pas un, puis un nombre avec un zéro en tête :

$ [ abc -eq 1 ]
bash: [: abc: integer expression expected
$ [[ 08 -lt 10 ]]
bash: [[: 08: value too great for base (error token is "08")
$ [[ 10#08 -lt 10 ]]; echo $?
0

[[ évalue ses opérandes en arithmétique, et une constante qui commence par 0 y est lue en octal : 8 n'est pas un chiffre octal. Le cas arrive avec les mois et les jours (08, 09) découpés dans une date. La notation 10#08 impose la base 10 (leçon 3).

Le script de Camille, pas à pas

Reprenons publier-export. Avant d'envoyer quoi que ce soit, il doit établir quatre faits : la date demandée est une vraie date ; le fichier correspondant existe, est un fichier ordinaire, lisible et non vide ; son en-tête est celle qu'attend la mairie ; il contient au moins un signalement. Chaque échec doit produire un message sur la sortie d'erreur et un code de sortie qui dit à la supervision ce qui s'est passé. On anticipe ici les codes que la leçon 8 fixera pour tout le dépôt : 2 pour une mauvaise utilisation (date invalide), 3 pour un export introuvable ou invalide, 4 pour un échec du dépôt.

On reste à ce stade sur un script simple, qui accepte la date en premier argument. Les options (--date, --dry-run) viendront à la leçon 8 ; pour l'instant, la variable d'environnement SIMULATION=1 permet de tout valider sans rien déposer, et REPERTOIRE_EXPORTS, déjà lue par la version de la leçon 3, de pointer vers un répertoire d'essai.

Valider la date

Première décision : jour vaut le premier argument, ou la date du jour (forme ${1:-...} de la leçon 3). Il faut s'assurer qu'il a la forme AAAA-MM-JJ avant de l'utiliser pour construire un chemin. Une expression régulière ancrée, dans une variable :

format_date='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'
jour=${1:-$(date +%F)}

if [[ ! $jour =~ $format_date ]]; then
    printf 'publier-export : date invalide « %s » (attendu AAAA-MM-JJ)\n' "$jour" >&2
    exit 2
fi
annee=${BASH_REMATCH[1]}
mois=${BASH_REMATCH[2]}

Le ! est ici à l'intérieur de [[ ]] : il nie l'expression. Les groupes capturés donnent l'année et le mois, qui serviront au chemin de dépôt AAAA/MM/ sans relancer date.

La forme ne suffit pas : 2026-02-30 a la bonne forme et n'existe pas. On demande à date de GNU coreutils de relire la date et de la réécrire ; si elle échoue, ou si elle ne rend pas exactement la même chaîne, la date est refusée :

if [[ $(date -d "$jour" +%F 2>/dev/null) != "$jour" ]]; then
    printf 'publier-export : %s n'\''existe pas dans le calendrier\n' "$jour" >&2
    exit 2
fi
$ date -d 2026-02-30 +%F
date: invalid date ‘2026-02-30’
$ date -d 2026-2-5 +%F
2026-02-05
$ date -d '' +%F
2026-10-08

Les trois lignes justifient la double vérification. date refuse le 30 février, mais accepte 2026-2-5, qu'elle normalise : la comparaison avec la chaîne d'origine le détecte. Et surtout, date -d '' réussit : le manuel de coreutils précise que la chaîne vide désigne le début de la journée en cours. Sans l'expression régulière préalable, une date vide passerait pour aujourd'hui. Chaque outil a ses tolérances ; la validation les compense.

Tip

La comparaison != "$jour" cite le membre droit : sans guillemets, jour serait un motif, sans conséquence ici puisque la forme a déjà été validée, mais c'est un réflexe à prendre partout.

Valider le fichier

Une cascade de if et elif, du plus général au plus précis, pour que le message dise exactement ce qui ne va pas :

fichier="${REPERTOIRE_EXPORTS%/}/signalements-$jour.csv"

if [[ ! -e $fichier ]]; then
    printf 'publier-export : export introuvable : %s\n' "$fichier" >&2
    exit 3
elif [[ ! -f $fichier ]]; then
    printf 'publier-export : %s n'\''est pas un fichier ordinaire\n' "$fichier" >&2
    exit 3
elif [[ ! -r $fichier ]]; then
    printf 'publier-export : %s illisible pour %s\n' "$fichier" "$(id -un)" >&2
    exit 3
elif [[ ! -s $fichier ]]; then
    printf 'publier-export : %s est vide (0 octet)\n' "$fichier" >&2
    exit 3
fi

L'ordre a un sens. -f après -e distingue « rien à cet endroit » de « un répertoire à cet endroit », deux pannes différentes. -r dit au compte signalements qu'il lui manque un droit, avec son nom dans le message, plutôt que de laisser gzip échouer plus loin sur Permission denied. Et -s attrape le fichier de 0 octet, laissé par une exportation interrompue d'une ancienne version.

Valider le contenu

Un fichier non vide peut encore être faux. On lit sa première ligne avec read, que la leçon 5 détaillera (IFS= et -r gardent la ligne intacte) :

IFS= read -r entete < "$fichier"

entete=${entete%$'\r'}       # fin de ligne CRLF tolérée
if [[ $entete != "$entete_attendue" ]]; then
    printf 'publier-export : en-tête inattendue dans %s : « %s »\n' "$fichier" "$entete" >&2
    exit 3
fi

La ligne du milieu traite un cas qui rend fou : un fichier dont les lignes se terminent en CRLF a une première ligne qui finit par un retour chariot invisible. À l'écran, l'en-tête semble parfaite ; pour !=, elle a un caractère de trop. printf '%q\n' "$entete" le rend visible : $'id,type,commune,date\r'. Ce n'est pas une curiosité de Windows : le module csv de Python, qui produit l'export de Signalements, termine par défaut chaque ligne par \r\n, comme le recommande la RFC 4180 qui décrit le format CSV. Rejeter ces fichiers rejetterait donc tous les exports. On retire le retour chariot final avec ${entete%$'\r'} (l'opérateur % de la leçon 3, la notation $'...' de la leçon 2) avant de comparer.

Enfin, le cas du 14 mars : l'en-tête seule. wc -l compte les sauts de ligne ; il en faut au moins deux (l'en-tête et un signalement) :

lignes=$(wc -l < "$fichier")
if (( lignes < 2 )); then
    printf 'publier-export : %s ne contient aucun signalement\n' "$fichier" >&2
    exit 3
fi

wc -l < fichier plutôt que wc -l fichier : lu sur l'entrée standard, wc n'affiche que le nombre, sans le nom. Et (( )) pour une comparaison numérique, sans $ ni guillemets.

Le script complet

Voici bin/publier-export à l'issue de cette leçon, dans le dépôt signalements-outils. La configuration et la chaîne de dépôt sont celles de la leçon 3 ; les validations viennent s'intercaler avant tout effet :

#!/usr/bin/env bash
# publier-export : dépose l'export quotidien de Signalements pour la mairie.
# État après la leçon 4 : rien n'est déposé sans validation de la date et du fichier.

export LC_ALL=C.UTF-8
readonly VERSION="0.4.0"

# Configuration (leçon 3) ; SIMULATION=1 valide tout sans rien déposer.
: "${REPERTOIRE_EXPORTS:=/srv/donnees/exports}"
: "${POINT_ACCES:=https://s3.fr-par.scw.cloud}"
: "${DESTINATION:?à définir dans /etc/signalements/env, par exemple s3://sig-exports-mairie}"
entete_attendue='id,type,commune,date'
format_date='^([0-9]{4})-([0-9]{2})-([0-9]{2})$'

printf -v aujourdhui '%(%Y-%m-%d)T' -1
jour=${1:-$aujourdhui}

# 1. La date : la forme d'abord, puis la réalité du calendrier.
if [[ ! $jour =~ $format_date ]]; then
    printf 'publier-export : date invalide « %s » (attendu AAAA-MM-JJ)\n' "$jour" >&2
    exit 2
fi
annee=${BASH_REMATCH[1]}
mois=${BASH_REMATCH[2]}
if [[ $(date -d "$jour" +%F 2>/dev/null) != "$jour" ]]; then
    printf 'publier-export : %s n'\''existe pas dans le calendrier\n' "$jour" >&2
    exit 2
fi

fichier="${REPERTOIRE_EXPORTS%/}/signalements-$jour.csv"

# 2. Le fichier : présent, régulier, lisible, non vide.
if [[ ! -e $fichier ]]; then
    printf 'publier-export : export introuvable : %s\n' "$fichier" >&2
    exit 3
elif [[ ! -f $fichier ]]; then
    printf 'publier-export : %s n'\''est pas un fichier ordinaire\n' "$fichier" >&2
    exit 3
elif [[ ! -r $fichier ]]; then
    printf 'publier-export : %s illisible pour %s\n' "$fichier" "$(id -un)" >&2
    exit 3
elif [[ ! -s $fichier ]]; then
    printf 'publier-export : %s est vide (0 octet)\n' "$fichier" >&2
    exit 3
fi

# 3. Le contenu : la bonne en-tête, et au moins une ligne de données.
IFS= read -r entete < "$fichier"
entete=${entete%$'\r'}       # fin de ligne CRLF tolérée
if [[ $entete != "$entete_attendue" ]]; then
    printf 'publier-export : en-tête inattendue dans %s : « %s »\n' "$fichier" "$entete" >&2
    exit 3
fi
lignes=$(wc -l < "$fichier")
if (( lignes < 2 )); then
    printf 'publier-export : %s ne contient aucun signalement\n' "$fichier" >&2
    exit 3
fi

# 4. Le dépôt, seulement maintenant.
archive="$fichier.gz"
cible="${DESTINATION%/}/$annee/$mois/${archive##*/}"
printf 'publier-export %s : %s validé (%d signalements), dépôt vers %s\n' \
    "$VERSION" "$fichier" "$(( lignes - 1 ))" "$cible" >&2
if [[ ${SIMULATION:-0} == 1 ]]; then
    exit 0
fi
if ! gzip --keep --force -- "$fichier"; then
    printf 'publier-export : échec de la compression de %s\n' "$fichier" >&2
    exit 1
fi
empreinte=$(sha256sum < "$archive")
printf '%s  %s\n' "${empreinte%% *}" "${archive##*/}" > "$archive.sha256"
if ! aws s3 cp --endpoint-url "$POINT_ACCES" "$archive" "$cible" ||
   ! aws s3 cp --endpoint-url "$POINT_ACCES" "$archive.sha256" "$cible.sha256"; then
    printf 'publier-export : échec du dépôt de %s\n' "$archive" >&2
    exit 4
fi

Trois remarques sur la fin. Le message de validation remplace la ligne de journal de la leçon 3 : il dit en plus combien de signalements partent, ce qui aurait suffi, le 14 mars, à voir le zéro. Le dépôt est testé par la commande elle-même : if ! aws s3 cp ... n'a besoin d'aucun crochet, aws renvoie un code non nul quand le dépôt échoue, et le || entre les deux aws fait que l'échec de l'un ou de l'autre mène au même message et au code 4. Enfin, le calcul de l'empreinte n'est toujours pas vérifié : la leçon 9 généralisera le traitement des erreurs avec set -euo pipefail et ses limites. Le -- avant le nom de fichier de gzip marque la fin des options (leçon 2) : un nom qui commencerait par un tiret ne serait pas pris pour une option.

L'essayer sur de faux exports

Dans essais/, créez quelques exports, dont plusieurs volontairement faux :

mkdir -p exports
printf 'id,type,commune,date\n1,nid-de-poule,Exempleville,2026-10-07\n2,lampadaire,Exempleville,2026-10-07\n' \
    > exports/signalements-2026-10-07.csv
printf 'id,type,commune,date\n' > exports/signalements-2026-10-06.csv        # la nuit du 14 mars
: > exports/signalements-2026-10-05.csv                                       # 0 octet
printf 'id;type;commune;date\n1;x;y;z\n' > exports/signalements-2026-10-04.csv        # séparateur ;

: > fichier crée un fichier vide : : est la commande interne qui ne fait rien et réussit, et la redirection fait le reste. Puis lancez le script sur chaque date, en simulation :

$ export REPERTOIRE_EXPORTS=exports DESTINATION=s3://sig-exports-mairie SIMULATION=1
$ ./publier-export 2026-10-07; echo "code=$?"
publier-export 0.4.0 : exports/signalements-2026-10-07.csv validé (2 signalements), dépôt vers s3://sig-exports-mairie/2026/10/signalements-2026-10-07.csv.gz
code=0
$ ./publier-export 2026-10-06; echo "code=$?"
publier-export : exports/signalements-2026-10-06.csv ne contient aucun signalement
code=3
$ ./publier-export 2026-10-05; echo "code=$?"
publier-export : exports/signalements-2026-10-05.csv est vide (0 octet)
code=3
$ ./publier-export 2026-10-04; echo "code=$?"
publier-export : en-tête inattendue dans exports/signalements-2026-10-04.csv : « id;type;commune;date »
code=3
$ ./publier-export 2026-10-02; echo "code=$?"
publier-export : export introuvable : exports/signalements-2026-10-02.csv
code=3
$ ./publier-export 2026-02-30; echo "code=$?"
publier-export : 2026-02-30 n'existe pas dans le calendrier
code=2
$ ./publier-export ../../etc/passwd; echo "code=$?"
publier-export : date invalide « ../../etc/passwd » (attendu AAAA-MM-JJ)
code=2

La deuxième ligne est le 14 mars, arrêté. La dernière montre un bénéfice que l'on n'avait pas cherché : la validation de la date interdit du même coup de fabriquer un chemin arbitraire à partir de l'argument.

verifier-sante : une machine qui répond n'est pas une machine qui va bien

Le second script de Camille, verifier-sante, se contentait de curl http://sig-app-1:8000/sante && echo OK. Or curl réussit dès qu'il a obtenu une réponse HTTP, même si cette réponse est une erreur 503 : pour lui, le transfert s'est bien passé. Il y a deux façons d'obtenir une décision juste.

La plus simple : l'option --fail, qui, d'après la page de manuel de curl, fait échouer la commande avec le code 22 et sans afficher le corps pour toute réponse de code 400 ou plus (--fail-with-body, depuis curl 7.76, garde le corps). Le code de sortie suffit alors à un if :

if curl -sS --fail --max-time 5 -o /dev/null "http://sig-app-1:8000/sante"; then
    echo "sig-app-1 répond"
fi

La plus précise : récupérer le code HTTP et le corps, puis décider avec case. L'option -w '%{http_code}' (write-out) fait écrire par curl le code de la réponse après le corps ; on le sépare par un saut de ligne, puis on découpe avec les opérateurs de la leçon 3. Quand aucune réponse n'arrive, curl écrit 000 et c'est son propre code de sortie qui dit pourquoi : la liste des erreurs de libcurl donne 6 pour un nom introuvable, 7 pour une connexion impossible, 28 pour un délai dépassé.

#!/usr/bin/env bash
# verifier-sante : interroge /sante sur chaque machine applicative.
# Code de sortie : 0 si toutes répondent « ok », 1 sinon.

hotes=${VERIFIER_SANTE_HOTES:-"sig-app-1 sig-app-2"}   # découpé exprès en mots (leçon 2) ; un tableau en leçon 7
port=${VERIFIER_SANTE_PORT:-8000}
echecs=0

for hote in $hotes; do
    reponse=$(curl -s --max-time 5 -w '\n%{http_code}' "http://$hote:$port/sante")
    code_curl=$?
    code_http=${reponse##*$'\n'}     # après le dernier saut de ligne
    corps=${reponse%$'\n'*}          # avant le dernier saut de ligne

    if (( code_curl != 0 )); then
        case $code_curl in
            6)  raison="nom inconnu" ;;
            7)  raison="connexion refusée" ;;
            28) raison="pas de réponse en 5 s" ;;
            *)  raison="erreur curl $code_curl" ;;
        esac
        printf '%-10s KO  %s\n' "$hote" "$raison"
        echecs=$(( echecs + 1 ))
        continue
    fi

    case $code_http in
        200)
            etat=$(jq -r '.etat // "absent"' <<< "$corps" 2>/dev/null) || etat="illisible"
            if [[ $etat == ok ]]; then
                printf '%-10s OK  version %s\n' "$hote" "$(jq -r '.version // "?"' <<< "$corps")"
            else
                printf '%-10s KO  répond 200, état « %s »\n' "$hote" "$etat"
                echecs=$(( echecs + 1 ))
            fi
            ;;
        5[0-9][0-9])
            printf '%-10s KO  erreur serveur %s\n' "$hote" "$code_http"
            echecs=$(( echecs + 1 ))
            ;;
        *)
            printf '%-10s KO  code HTTP inattendu %s\n' "$hote" "$code_http"
            echecs=$(( echecs + 1 ))
            ;;
    esac
done

if (( echecs > 0 )); then
    exit 1
fi

Points à relever :

  • code_curl=$? immédiatement après la substitution : le code d'une affectation v=$(cmd) est celui de cmd, et la ligne suivante l'écraserait.
  • Le for et le continue sont utilisés simplement ici ; la leçon 5 les détaille, et la leçon 7 remplacera la liste en chaîne par un vrai tableau.
  • Un code 200 ne suffit pas : l'API de Signalements répond {"etat": "ok", "version": "..."}, et un serveur mandataire en maintenance peut répondre 200 avec une page HTML. jq -r '.etat // "absent"' extrait l'état (ou absent si la clé manque) ; si le corps n'est pas du JSON, jq échoue et le || donne illisible.
  • echecs=$(( echecs + 1 )) plutôt que (( echecs++ )), pour une raison expliquée dans les pièges.
  • Le script ne s'arrête pas au premier échec : il examine toutes les machines, puis rend un verdict global. C'est ce qu'on attend d'une vérification de santé : un état complet, pas seulement la première panne.

Contre un petit serveur de test local qui imite chaque situation (réponse normale, état dégradé, page HTML, erreur 503, port fermé), le script affiche respectivement :

127.0.0.1  OK  version 1.4.2
127.0.0.1  KO  répond 200, état « degrade »
127.0.0.1  KO  répond 200, état « illisible »
127.0.0.1  KO  erreur serveur 503
127.0.0.1  KO  connexion refusée

avec le code de sortie 0 pour la première situation et 1 pour toutes les autres. Notez que le script n'utilise aucun [ ] : (( )) pour les nombres, case pour les formes, [[ ]] pour une chaîne, et le code de sortie des commandes pour le reste.

Sous le capot

[ est une commande, [[ est de la grammaire

type montre la différence de nature :

$ type -a [ test [[
[ is a shell builtin
[ is /usr/bin/[
test is a shell builtin
test is /usr/bin/test
[[ is a shell keyword

(Sur Ubuntu, où /bin est un lien vers /usr/bin, chaque commande externe apparaît une seconde fois sous /bin/.) Les deux versions de [ existent parce que certains programmes lancent des commandes sans passer par un shell : find -exec, xargs, env. Dans find exports -type f -exec test -s {} \; -print, c'est /usr/bin/test qui est exécuté pour chaque fichier, dans un processus à part ; la commande interne n'est utilisée que lorsque Bash lui-même lit la ligne.

Puisque [ est une commande, ses arguments passent par toutes les expansions du shell (leçon 2) avant de lui parvenir : découpage en mots, développement des chemins, suppression des guillemets. [ ne voit jamais vos guillemets, seulement le nombre d'arguments qui en résulte. Et il décide de leur sens par leur nombre. Le manuel de Bash et la norme POSIX décrivent le même algorithme :

Arguments (hors ])Interprétation
0faux
1vrai si l'argument n'est pas vide
2si le premier est !, vrai si le second est vide ; si le premier est un opérateur unaire (-f, -z...), ce test ; sinon, non spécifié (POSIX), erreur unary operator expected et code 2 (Bash)
3si le deuxième est un opérateur binaire, ce test ; si le premier est !, la négation du test à deux arguments ; sinon, non spécifié (POSIX), erreur binary operator expected et code 2 (Bash, qui accepte aussi ( chaîne ))
4si le premier est !, la négation du test à trois arguments ; sinon, non spécifié (POSIX) ou analyse par priorité (Bash)
5 et plusnon spécifié (POSIX), analyse par priorité (Bash)

Ce tableau explique tous les comportements étranges de [ :

  • [ $vide = x ] → 2 arguments, = n'est pas unaire : unary operator expected.
  • [ -n $vide ] → 1 argument, -n, qui n'est pas vide : vrai. Le test « la variable est-elle non vide ? » répond oui pour une variable vide (piège n° 36). Avec des guillemets, [ -n "" ] reçoit deux arguments et répond correctement faux.
  • test --help ne fait rien et réussit : un argument non vide. La commande interne de Bash, comme /usr/bin/test, ne reconnaît aucune option, conformément à POSIX. Seul /usr/bin/[ accepte --help et --version.

[[, lui, n'est pas une commande : il n'a pas d'arguments, il a une syntaxe. Le shell repère -f, == ou && en lisant le texte du script, avant de développer les variables. C'est pour cela qu'un [[ $vide == x ]] ne peut pas perdre son opérande (il reste un mot vide), et c'est aussi pour cela qu'on ne peut pas mettre un opérateur dans une variable (op='-f'; [[ $op $f ]] est une erreur de syntaxe), ce que [ accepte.

Les erreurs de syntaxe sont détectées à la lecture

Conséquence directe : une faute dans un [[ ]] est une erreur de syntaxe, signalée au moment où Bash lit la commande composée qui la contient, même si cette branche ne doit jamais s'exécuter. Bash lit et exécute un script commande complète par commande complète. Soit ce fichier :

echo "début"
if false; then
  [[ "a b" =~ a b ]]
fi
echo "fin"
$ bash parse.sh; echo "code=$?"
début
parse.sh: line 3: syntax error in conditional expression
parse.sh: line 3: syntax error near `b'
parse.sh: line 3: `  [[ "a b" =~ a b ]]'
code=2

début s'affiche (la première commande a été lue et exécutée), puis Bash lit le if entier, trouve l'erreur et abandonne le script : fin ne s'affiche jamais, bien que la ligne fautive soit dans une branche morte. Avec [ "a b" =~ a b ] à la place, le script s'exécute sans broncher, la branche n'étant jamais atteinte. bash -n script (leçon 1) détecte les erreurs du premier type sans rien exécuter ; celles du second ne se révèlent qu'à l'exécution, d'où l'intérêt de l'analyse statique et des tests (leçon 12).

=~ délègue à la bibliothèque C

Bash n'implémente pas lui-même les expressions régulières : il appelle regcomp et regexec, les fonctions de la bibliothèque C décrites dans regex(3). Deux conséquences :

  • la syntaxe est l'ERE de POSIX, pas celle de Perl ou de Python : pas de \d (écrivez [0-9] ou [[:digit:]]), pas de quantificateur non gourmand *?, pas de groupe non capturant (?:...) ;
  • les classes de caractères et les intervalles dépendent de la locale. [a-z] peut, selon la locale et la version de la glibc, inclure des lettres accentuées ou des majuscules ; [[:lower:]] et [0-9] sont plus prévisibles. Pour un format technique, fixer LC_ALL=C dans le script supprime l'incertitude.

Le manuel signale aussi que Bash place BASH_REMATCH dans la portée globale : chaque =~ réussi l'écrase. Copiez les groupes dans des variables nommées juste après le test, comme le fait publier-export avec annee et mois.

L'arithmétique de [[ et (( )) est un interpréteur

Quand [[ $x -eq 0 ]] évalue $x « comme une expression arithmétique », Bash ne se contente pas de lire un nombre : il interprète la valeur comme une expression, qui peut contenir des noms de variables, des opérateurs, des affectations (n=5) et des indices de tableaux (t[...]). L'indice d'un tableau est lui-même développé, substitution de commande comprise. C'est le mécanisme de l'injection décrite plus bas. [ "$x" -eq 0 ], lui, exige un entier littéral (des blancs autour sont tolérés) et refuse tout le reste.

Pièges courants

if [ grep -q x f ]. [ n'est pas une parenthèse de if : c'est une commande. Ici, test reçoit grep -q x f, quatre mots qu'il ne sait pas interpréter. Écrivez if grep -q x f; then (BashPitfalls n° 9).

Oublier les espaces. [$a = b] cherche une commande nommée [ suivi du contenu de a ; [[$a == b]] aussi. [ "$a"="b" ] est un test à un argument (avec a=x, la chaîne x=b, jamais vide), toujours vrai. Les espaces autour de [, ], [[, ]] et des opérateurs sont obligatoires.

Une variable non citée dans [ ]. Vide, elle disparaît ; avec des espaces, elle se multiplie ; avec *, elle devient une liste de fichiers. Citez toujours, ou utilisez [[ ]].

[ -n $var ] toujours vrai. Variante du précédent, vue sous le capot : un seul argument, non vide.

== dans un script sh. [ a == a ] fonctionne sous Bash (extension), mais sous dash : [: a: unexpected operator. POSIX ne connaît que = pour test (BashPitfalls n° 20).

Comparer des nombres avec <, > ou ==. [[ 10 < 9 ]] est vrai (ordre des textes) ; [[ 007 == 7 ]] est faux (textes différents). Pour des nombres : (( 10 < 9 )), (( 007 == 7 )). Et dans [ ], > non échappé est une redirection : [ 10 > 9 ] crée un fichier nommé 9 et réussit (BashPitfalls n° 7).

Le membre droit non cité de ==. [[ $a == $b ]] est une correspondance de motif : faux pour b='[a]' et a='[a]', vrai pour b='*' quel que soit a (SC2053).

Le membre droit cité de =~. [[ $x =~ "^[0-9]+$" ]] cherche ce texte littéral et ne correspond presque jamais (SC2076). Mettez l'expression dans une variable, utilisée sans guillemets.

A && B || C pris pour un if. C s'exécute aussi quand B échoue (SC2015).

(( n++ )) qui « échoue ». (( )) renvoie 1 quand l'expression vaut 0. Or n++ vaut l'ancienne valeur de n : si n valait 0, (( n++ )) incrémente bien n, et renvoie 1. Sans conséquence aujourd'hui, mais sous set -e (leçon 9), cette ligne arrête le script la première fois qu'elle s'exécute. Préférez n=$(( n + 1 )) ou (( ++n )) (qui vaut la nouvelle valeur).

Un lien symbolique cassé qui « n'existe pas ». -e, -f et -d suivent les liens. Pour savoir si un nom est occupé, quel qu'il soit, testez [[ -e $f || -L $f ]] (BashPitfalls n° 37).

Une date avec un zéro en tête dans (( )). mois=08; (( mois > 6 )) : value too great for base. 10#$mois.

Le retour chariot invisible. Une valeur lue dans un fichier Windows ou dans la sortie d'une commande Windows se termine par \r. Les comparaisons échouent sans raison apparente. printf '%q\n' "$v" le montre, ${v%$'\r'} le retire (leçon 3).

Sécurité

Un motif non cité accepte tout

Imaginez un script de maintenance qui demande un jeton avant une opération sensible :

if [[ $jeton_attendu == $saisie ]]; then    # membre droit non cité : c'est un motif
    echo "accès accordé"
fi

Avec saisie='*', la condition est vraie quel que soit le jeton : * correspond à tout. Avec saisie='s3*', l'attaquant apprend s'il a deviné le début du jeton, caractère par caractère. Une seule paire de guillemets (== "$saisie") transforme la vérification en comparaison littérale. Règle générale : une donnée qui vient de l'extérieur ne se trouve jamais à droite d'un == ou d'un case sans guillemets, à moins que vous ne vouliez explicitement qu'elle soit un motif.

L'injection arithmétique

Plus surprenant, et plus grave. Dans [[ $x -eq 0 ]], (( x == 0 )) ou $(( x + 1 )), la valeur de x est évaluée comme une expression, et les indices de tableaux y sont développés, substitution de commande comprise. Si x vient d'un fichier, d'un argument, d'un en-tête HTTP ou d'une ligne de journal, quiconque contrôle cette donnée peut faire exécuter une commande :

$ x='a[$(echo INJECTION >&2)]'
$ [[ $x -eq 0 ]] && echo "égal à zéro"
INJECTION
égal à zéro
$ [ "$x" -eq 0 ]
bash: [: a[$(echo INJECTION >&2)]: integer expression expected

Le echo a été exécuté par le simple fait de tester la valeur. À sa place, un attaquant mettrait curl ... | sh ou rm -rf. C'est une injection arithmétique, documentée par Greg's Wiki (BashPitfalls n° 46 et suivants). [ ], qui exige un entier littéral, refuse la valeur. La parade, en Bash, est de valider la forme avant toute arithmétique :

if [[ ! $valeur =~ ^[0-9]+$ ]]; then
    printf 'valeur non numérique : %q\n' "$valeur" >&2
    exit 2
fi
(( valeur > seuil )) && ...

Ou, de façon portable, avec case :

case $valeur in
    ''|*[!0-9]*) printf 'valeur non numérique\n' >&2; exit 2 ;;
esac

Le motif *[!0-9]* correspond à toute chaîne qui contient au moins un caractère qui n'est pas un chiffre ; avec '', il couvre aussi la chaîne vide. Dans verifier-sante, code_curl vient de $? (toujours un entier) et code_http passe par un case avant tout calcul : il n'y a rien à injecter. Dans publier-export, lignes vient de wc -l, sûr lui aussi.

Valider, c'est aussi borner les chemins

La validation de la date de publier-export refuse ../../etc/passwd sans qu'on l'ait cherché. C'est la bonne façon de construire un chemin à partir d'une entrée : décrire ce qui est permis (une liste blanche : quatre chiffres, un tiret, deux chiffres...) plutôt que chercher ce qui est interdit (.., /, caractères spéciaux), liste que l'on n'a jamais fini d'établir.

Vérifier puis agir laisse une fenêtre

[[ -w $f ]] && echo x > "$f" paraît prudent. Entre le test et l'écriture, cependant, le fichier peut changer : être remplacé par un lien vers /etc/shadow dans un répertoire ouvert à tous, par exemple. Pour -r, -w et -x, Bash interroge le noyau par l'appel faccessat(2) avec l'option AT_EACCESS (droits de l'identité effective), et la page access(2) qui le décrit met en garde contre cet écart entre vérification et usage, la faille dite TOCTOU (time of check to time of use). Pour tout ce qui touche à la sécurité, faites l'opération et testez son résultat (if ! echo x > "$f"; then), dans un répertoire qui n'appartient qu'à vous. Les tests de fichiers servent à produire de meilleurs messages, pas à garantir une sécurité.

Et sous root, ils mentent par omission. access(2) précise qu'un processus privilégié obtient un test d'exécution positif dès qu'un des bits x est présent, et root contourne les droits de lecture et d'écriture (sa capacité CAP_DAC_OVERRIDE) : [[ -w /etc/signalements/env ]] est vrai pour root même si le fichier était en 0440. Seuls un système de fichiers monté en lecture seule ou un attribut immuable (chattr +i) font alors échouer -w. Si un script lancé par root doit vérifier ce que pourra faire le compte de service, il faut faire le test sous cette identité : sudo -u signalements test -r "$fichier".

Un test ne remplace pas l'authentification de la donnée

publier-export vérifie la forme de l'export, pas son origine. Si un autre compte pouvait écrire dans /srv/donnees/exports, il pourrait y déposer un CSV parfaitement formé et le faire publier. Les droits du répertoire (0750 signalements:signalements, cours d'administration) font partie de la condition, même si aucun if ne les vérifie.

En production

  • Valider à la frontière. Tout ce qui entre dans un script (arguments, variables d'environnement, fichiers, réponses réseau) est validé une fois, au début, par des listes blanches ; la suite du script travaille sur des valeurs sûres. C'est ce que fait publier-export, et ce que généralisera l'analyse des options de la leçon 8.
  • Un message par cause, un code par famille. Un message qui dit « export introuvable : chemin » se diagnostique en dix secondes depuis le journal du minuteur ; un « erreur » générique demande une session SSH. Les codes de sortie distincts (2, 3, 4) permettent à la supervision de router l'alerte : un 3 concerne l'export Python ou la base, un 4 le stockage objet.
  • Des contrôles de vraisemblance. « Au moins une ligne » arrête le cas extrême. En production, l'équipe a ajouté ensuite une comparaison avec la veille : un export qui contient moins de la moitié des signalements de la veille, un jour ouvré, déclenche un avertissement sans bloquer. Une condition métier vaut souvent mieux qu'un contrôle technique.
  • Une vérification de santé regarde le contenu. Un code 200 prouve qu'un programme a répondu, pas que l'application va bien. La vérification de santé de Signalements répond un état explicite, et les sondes de Kubernetes ou de l'équilibreur de charge de Scaleway lisent le code HTTP : à l'application de renvoyer un code d'erreur quand elle ne va pas bien, au script de vérifier les deux.
  • Le délai fait partie du test. Sans --max-time, un curl vers une machine qui ne répond plus peut attendre plusieurs minutes, et la vérification de santé elle-même tombe en panne. La leçon 11 borne la durée de n'importe quelle commande avec timeout.
  • Portabilité assumée. Les scripts du dépôt signalements-outils déclarent #!/usr/bin/env bash et utilisent [[ ]] librement. Les quelques lignes de shell qui doivent rester en sh (un ENTRYPOINT de conteneur basé sur Alpine, un crochet de paquet Debian) n'utilisent que [ ], = et case.

Exercices

1. Prévoir des tests (niveau 100). Sans les exécuter, donnez le code de sortie de chaque ligne (0, 1 ou 2), avec vide='' et nom='mon export.csv' (fichier inexistant). Puis vérifiez.

[ -n $vide ]
[ -n "$vide" ]
[[ -n $vide ]]
[ -f $nom ]
[[ -f $nom ]]
[[ 10 < 9 ]]
(( 10 < 9 ))
[[ abc == a* ]]
[[ abc == "a*" ]]
[[ 2026-10-07 =~ ^[0-9]{4}- ]]
Solution
  1. [ -n $vide ] : 0. $vide disparaît, [ reçoit un seul argument, -n, non vide : vrai. C'est le piège.
  2. [ -n "$vide" ] : 1. Deux arguments, test unaire sur une chaîne vide.
  3. [[ -n $vide ]] : 1. Pas de découpage dans [[ ]].
  4. [ -f $nom ] : 2, binary operator expected : [ reçoit -f, mon, export.csv, trois arguments dont le deuxième n'est pas un opérateur binaire.
  5. [[ -f $nom ]] : 1, le fichier n'existe pas.
  6. [[ 10 < 9 ]] : 0, comparaison de textes : 1 se classe avant 9.
  7. (( 10 < 9 )) : 1, comparaison numérique.
  8. [[ abc == a* ]] : 0, motif.
  9. [[ abc == "a*" ]] : 1, texte littéral a*.
  10. 0 : l'expression n'est ancrée qu'au début, et 2026- correspond.

2. Aiguiller avec case (niveau 100). Écrivez un fragment qui, selon la valeur de fichier, affiche « export » pour un nom de la forme signalements-AAAA-MM-JJ.csv, « archive » pour la même forme suivie de .gz, « caché » pour un nom qui commence par un point, et « inconnu » sinon. Utilisez uniquement case, pour qu'il fonctionne aussi sous dash.

Solution
date_glob='[0-9][0-9][0-9][0-9]-[0-9][0-9]-[0-9][0-9]'
case $fichier in
    .*)                                  echo "caché" ;;
    signalements-$date_glob.csv)         echo "export" ;;
    signalements-$date_glob.csv.gz)      echo "archive" ;;
    *)                                   echo "inconnu" ;;
esac

La variable date_glob est volontairement non citée dans les motifs : on veut que ses crochets gardent leur sens de motif. Comme un motif de case doit correspondre au nom entier, .signalements-2026-10-07.csv ne peut pas être pris pour un export ; la branche .* est placée en tête pour que le cas des fichiers cachés se lise d'abord, et parce que case s'arrête à la première correspondance : si l'on ajoutait plus tard une branche plus large (*.csv), les fichiers cachés resteraient bien classés. Les motifs glob ne savent pas exprimer « quatre chiffres » autrement qu'en répétant [0-9] : c'est la limite où =~ devient plus lisible, au prix de la portabilité.

3. Le code HTTP (niveau 100). Un collègue propose de remplacer la décision de verifier-sante par :

code=$(curl -s -o /dev/null -w '%{http_code}' "http://$hote:8000/sante")
[ $code -eq 200 ] && echo OK || echo KO

Relevez au moins trois défauts, puis proposez une version correcte en quelques lignes.

Solution
  • $code non cité dans [ ] : s'il était vide, unary operator expected. (Ici curl écrit 000 faute de réponse, mais il ne faut pas en dépendre.)
  • A && B || C : si echo OK échoue, « KO » s'affiche aussi (SC2015). Et surtout, la ligne ne produit aucun code de sortie exploitable : elle réussit toujours, puisque echo KO réussit.
  • Aucun délai : sans --max-time, une machine figée bloque la vérification.
  • Un 200 ne dit rien de l'état de l'application.
  • Le code de sortie de curl est perdu : on ne sait pas si l'échec vient du DNS, de la connexion ou du délai.

Une version courte et correcte, si l'on se contente du code HTTP :

if curl -sS --fail --max-time 5 -o /dev/null "http://$hote:8000/sante"; then
    echo "$hote OK"
else
    echo "$hote KO (curl : $?)" >&2
    exit 1
fi

Dans le else, $? vaut encore le code de curl (22 pour une réponse 4xx ou 5xx, 7, 28...), car aucune commande ne s'est exécutée entre-temps. Pour vérifier aussi le contenu, il faut la version de la leçon.

4. Auditer une validation (niveau 200). Ce fragment, trouvé dans purger-pieces-jointes, lit le nombre de jours de rétention dans /etc/signalements/env :

jours=$(grep '^RETENTION_JOURS=' /etc/signalements/env | cut -d= -f2)
if [[ $jours -lt 30 ]]; then
    echo "rétention trop courte, refus" >&2
    exit 2
fi
find /srv/donnees/pieces-jointes -type f -mtime +"$jours" -delete

Le fichier est en 0640 root:signalements. Identifiez les risques (sécurité et fiabilité), en considérant ce qui se passe si la ligne manque, si elle vaut 365 (avec une espace), 0365, ou a[$(id>/tmp/x)]. Réécrivez la validation.

Solution
  • Ligne absente : jours est vide ; [[ '' -lt 30 ]] évalue la chaîne vide comme 0, donc « trop courte » : refus. Bon résultat, mais par accident, et avec un message trompeur.
  • 365 : [[ ]] tolère les blancs, le test passe ; -mtime +"365 " fait échouer find (argument invalide). Rien n'est supprimé, et le script ne le signale pas.
  • 0365 : lu en octal par [[ ]], soit 245 : le test passe, mais find reçoit 0365 et l'interprète en décimal, 365. Incohérence silencieuse entre le contrôle et l'action.
  • a[$(id>/tmp/x)] : injection arithmétique. id s'exécute, avec les droits du compte qui lance la purge, dès le test. Le fichier n'est modifiable que par root, ce qui limite le risque ; mais un fichier d'environnement est souvent généré par un outil de déploiement, voire copié d'un dépôt, et la règle reste : jamais d'arithmétique sur une donnée non validée.
  • Plus généralement, ce code ne distingue pas « ligne absente » de « valeur fausse ».

Validation réécrite :

ligne=$(grep -m1 '^RETENTION_JOURS=' /etc/signalements/env) || {
    echo "RETENTION_JOURS absente de /etc/signalements/env" >&2
    exit 2
}
jours=${ligne#RETENTION_JOURS=}
if [[ ! $jours =~ ^[1-9][0-9]*$ ]]; then
    printf 'RETENTION_JOURS invalide : %q\n' "$jours" >&2
    exit 2
fi
if (( jours < 30 )); then
    echo "rétention de $jours jours trop courte (minimum 30), refus" >&2
    exit 2
fi

L'expression ^[1-9][0-9]*$ interdit le vide, les blancs, le zéro en tête et tout caractère non numérique ; seulement ensuite, (( )) compare. grep -m1 s'arrête à la première occurrence, et le || traite l'absence. La leçon 8 montrera comment lire un fichier cle=valeur proprement sans le source.

Récapitulatif

  • En shell, une condition est une commande : 0 est vrai, tout le reste est faux. if, elif, else, &&, || et ! lisent des codes de sortie. Avant d'écrire des crochets, demandez-vous si une commande (grep -q, cmp -s, curl --fail) ne répond pas déjà.
  • test et [ sont une commande : leurs arguments sont développés, découpés, et interprétés selon leur nombre, d'où unary operator expected et too many arguments. Citez toutes les variables. Sous dash, seuls [, = et case existent.
  • [[ ]] est un mot-clé : pas de découpage ni de développement des chemins, && et || internes, motifs avec ==, expressions régulières avec =~. Les erreurs y sont détectées à la lecture.
  • Chaînes : ==, !=, -z, -n ; le membre droit de == est un motif s'il n'est pas cité. < et > comparent des textes selon la locale. Nombres : (( )) ou -eq, -lt... ; attention aux zéros en tête.
  • Fichiers : -e, -f, -d, -L, -s, -r, -w, -x, -nt ; ils suivent les liens et testent les droits du processus courant. Variables : -v nom distingue l'absence du vide.
  • =~ : ERE de POSIX, à ancrer avec ^ et $, à placer dans une variable utilisée sans guillemets ; les groupes sont dans BASH_REMATCH, à copier aussitôt.
  • case aiguille selon des motifs, est portable et ne découpe pas ; terminez toujours par *).
  • A && B || C n'est pas un if.
  • Sécurité : une donnée externe à droite d'un == non cité accepte tout ; une donnée externe dans [[ -eq ]] ou (( )) peut exécuter des commandes ; validez la forme par liste blanche avant de l'utiliser, et préférez agir puis tester le résultat plutôt que tester puis agir.
  • publier-export refuse désormais une date invalide (code 2), un export absent, vide, mal formé ou sans signalement (code 3), et signale un dépôt échoué (code 4). verifier-sante distingue une machine injoignable, en erreur, dégradée ou saine.

Pour aller plus loin

  • Les sections Conditional Constructs et Bash Conditional Expressions du manuel de Bash, à relire avec la description de la commande test et de son algorithme par nombre d'arguments.
  • La page test de POSIX.1-2024 : la section Rationale explique pourquoi -a, -o et les parenthèses ont été retirés, et pourquoi le comportement à quatre arguments ou plus n'est pas spécifié.
  • BashFAQ/031 de Greg's Wiki, la meilleure comparaison de test, [ et [[, et la liste BashPitfalls, dont un bon tiers concerne les tests.
  • La page regex(7) du projet Linux man-pages, qui décrit précisément la syntaxe des expressions régulières étendues utilisée par =~.
  • La leçon suivante, Boucles et lecture de données, qui répète ces tests sur chaque fichier d'un répertoire et chaque ligne d'un journal.
+10 XP Carte du ciel →Mon cosmonaute →

Sources