Aller au contenu
Paramètres, chaînes et calculs

Paramètres, chaînes et calculs

100 Apprenti ⏱ 1 h 20 bashlinuxdebianubuntu

À la fin, vous saurez

  • Donner une valeur par défaut à un réglage et arrêter un script dont une variable obligatoire est vide, avec ${var:=} et ${var:?}
  • Extraire le nom, le répertoire, l'extension ou une partie d'une chaîne par l'expansion de paramètres, sans lancer basename, dirname, sed ni cut
  • Remplacer un motif, changer la casse et afficher une valeur sans ambiguïté avec ${var@Q}
  • Formater une sortie et une date avec printf, et expliquer pourquoi echo ne convient pas aux données
  • Calculer en arithmétique entière avec $(( )) et (( )), en évitant le piège des nombres à zéro en tête
  • Produire un résultat décimal par la virgule fixe ou par awk, en tenant compte de la locale
  • Repérer les expansions propres à Bash qui échouent sous dash, et l'arithmétique appliquée à une donnée non fiable

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) coreutils 9.4 (Ubuntu), 9.7 (Debian) mawk 1.3.4 , vérifié le 8 octobre 2026

Pourquoi

Voici le début de publier-export tel que Camille l'a laissé dans /opt/signalements/bin/ :

#!/bin/bash
REP=/srv/donnees/exports
FICHIER=$REP/signalements-`date +%Y-%m-%d`.csv
NOM=`basename $FICHIER`
ANNEE=`date +%Y`
MOIS=`date +%m`
PRECEDENT=$(( $MOIS - 1 ))
gzip -kf $FICHIER
aws s3 cp --endpoint-url https://s3.fr-par.scw.cloud $FICHIER.gz s3://$BUCKET/$ANNEE/$MOIS/$NOM.gz
# le 1er du mois, déposer aussi le récapitulatif du mois précédent
[ `date +%d` = 01 ] && aws s3 cp --endpoint-url https://s3.fr-par.scw.cloud $REP/recapitulatif.csv s3://$BUCKET/$ANNEE/$PRECEDENT/recapitulatif.csv
find $REP -name '*.csv.gz' -mtime +30 -delete

Ce script a fonctionné tout l'hiver. Puis trois incidents sont arrivés, et chacun tient à une seule ligne.

  • Le 1er août, le récapitulatif de juillet n'est jamais arrivé à la mairie. Le journal contenait un message incompréhensible, 08: value too great for base, et le script avait continué comme si de rien n'était, avec une variable PRECEDENT vide : le fichier était parti sous 2026//recapitulatif.csv, où personne ne le cherchait. Le 1er septembre, même chose. (Et les autres mois, le récapitulatif partait sous 2026/1/, 2026/2/... au lieu de 2026/01/, et le 1er janvier sous 2026/0/ : personne ne l'avait remarqué.)
  • Un soir de déploiement, la variable BUCKET, censée venir de /etc/signalements/env, était vide. L'archive est partie vers s3:///2026/10/..., aws a répondu par une erreur, personne ne l'a lue.
  • Une nuit de fin de mois, le script a démarré à 23 h 59 min 59 s. Le nom du fichier a été calculé le 30 septembre, l'année et le mois une seconde plus tard, le 1er octobre : l'archive de septembre a été rangée dans le dossier d'octobre.

Le script lance en outre une demi-douzaine de programmes (date quatre fois, basename) pour des opérations que Bash sait faire seul, en mémoire, cent fois plus vite.

Cette leçon traite de ce que Bash sait faire d'une valeur : la remplacer par un défaut, refuser qu'elle soit vide, la découper, la transformer, l'afficher sans la trahir, et calculer avec. Ce sont les expansions de paramètres et l'arithmétique du shell. À la fin, le début de publier-export lit l'horloge une seule fois, refuse de partir sans destination, construit tous ses chemins sans lancer de programme, et ne craint plus le mois d'août.

Les concepts

Paramètres et variables

Le manuel de Bash appelle paramètre (parameter) toute entité qui stocke une valeur. Il en distingue trois sortes :

  • les variables, désignées par un nom (jour, DESTINATION, PATH) ;
  • les paramètres positionnels, désignés par un nombre : $1, $2... sont les arguments du script ou de la fonction (leçon 8) ;
  • les paramètres spéciaux, désignés par un caractère : $? (code de sortie de la dernière commande), $$ (numéro du shell), $# (nombre d'arguments), etc.

Une variable s'affecte par nom=valeur, sans espace autour du = : avec des espaces, nom = valeur lance la commande nom avec les arguments = et valeur. La valeur est toujours une chaîne de caractères ; Bash ne connaît pas de type « nombre », il interprète la chaîne comme un nombre quand le contexte l'exige (l'arithmétique, plus bas). Une variable non affectée et une variable affectée à la chaîne vide sont deux états différents, et l'on verra que la différence compte.

La distinction entre variable de shell et variable d'environnement, et la convention des majuscules pour ces dernières, ont été vues dans la leçon L'environnement du shell. Dans un script, on s'y tient : minuscules pour les variables internes (jour, fichier), majuscules pour les réglages qui viennent de l'environnement (DESTINATION, REPERTOIRE_EXPORTS). On évite ainsi d'écraser par mégarde PATH, HOME ou IFS.

Expansion de paramètres

Une expansion de paramètre (parameter expansion) est le remplacement, par le shell lui-même, d'une expression qui commence par $ par une valeur. $jour est la forme la plus simple ; ${jour} est la même avec des accolades, obligatoires quand le nom est suivi d'un caractère qui pourrait en faire partie (${jour}_bis) ou pour un paramètre positionnel à deux chiffres (${10}).

Entre les accolades, on peut ajouter un opérateur qui transforme la valeur au passage : ${jour:0:4} donne les quatre premiers caractères, ${fichier##*/} le nom sans le répertoire. Le point essentiel est le suivant : toutes ces opérations se font dans le processus Bash, sans créer de processus ni lancer de programme. La leçon 2 a placé l'expansion de paramètres dans l'ordre général des expansions ; on y retient qu'elle a lieu avant le découpage en mots, d'où la règle des guillemets : "${fichier##*/}", comme "$fichier".

On peut ranger les opérateurs en cinq familles :

FamilleOpérateursQuestion à laquelle ils répondent
Valeur absente:- := :? :+ (et sans :)Que faire si la variable est vide ou non définie ?
Découpage par motif# ## % %%Comment retirer un préfixe ou un suffixe ?
Remplacement et casse/ // /# /% ^ ^^ , ,,Comment modifier le contenu ?
Position et longueur:début:longueur # (devant le nom)Quelle partie, quelle taille ?
Transformation@Q @A @a @U @L...Comment présenter la valeur, quels sont ses attributs ?

Valeur absente : défaut, obligation, alternative

Les quatre premiers opérateurs viennent de la norme POSIX et existent dans tous les shells. Chacun existe en deux variantes : avec deux-points, il réagit à une variable non définie ou vide ; sans deux-points, à une variable non définie seulement. Le tableau suivant reprend celui de la norme (section 2.6.2), pour var dans ses trois états possibles :

Expressionvar définie, non videvar videvar non définie
${var:-mot}valeur de varmotmot
${var-mot}valeur de varvidemot
${var:=mot}valeur de varvar reçoit motvar reçoit mot
${var=mot}valeur de varvidevar reçoit mot
${var:?message}valeur de varerreur, sortieerreur, sortie
${var?message}valeur de varvideerreur, sortie
${var:+mot}motvidevide
${var+mot}motmotvide

En pratique, presque toujours, on veut la variante avec deux-points : un réglage vide est aussi inutilisable qu'un réglage absent. Les quatre usages :

  • ${var:-défaut} : utiliser une valeur de repli, sans modifier var. Pour un usage ponctuel.
  • ${var:=défaut} : affecter la valeur de repli à var, qui la garde pour la suite. Pour poser la configuration d'un script, une fois, en tête. Ne fonctionne pas sur les paramètres positionnels ni spéciaux (${1:=x} est une erreur).
  • ${var:?message} : si var est vide ou non définie, écrire message sur la sortie d'erreur et, dans un shell non interactif (un script), arrêter le shell avec un code non nul. Pour les réglages obligatoires.
  • ${var:+texte} : produire texte seulement si var a une valeur. Pour ajouter une option conditionnelle : ${VERBEUX:+--verbose}.

Le mot de droite (défaut, message, texte) subit lui-même les expansions : ${jour:-$(date +%F)} ne lance date que si jour est vide. Le manuel le précise : le mot est soumis aux expansions de tilde, de paramètres, de commande et arithmétique, seulement si nécessaire.

Découper par motif : #, ##, %, %%

Ces quatre opérateurs retirent une partie de la valeur, au début ou à la fin, selon un motif du même type que ceux des noms de fichiers (motifs glob : *, ?, [...]) :

ExpressionRetireExemple avec f=/srv/donnees/exports/signalements-2026-10-07.csv
${f#motif}le plus court préfixe qui correspond${f#*/} donne srv/donnees/exports/signalements-2026-10-07.csv
${f##motif}le plus long préfixe qui correspond${f##*/} donne signalements-2026-10-07.csv
${f%motif}le plus court suffixe qui correspond${f%/*} donne /srv/donnees/exports
${f%%motif}le plus long suffixe qui correspond${f%%-*} donne /srv/donnees/exports/signalements

Moyen mnémotechnique cité par la FAQ de Greg's Wiki : sur un clavier américain, # est à gauche de %, et l'opérateur # agit à gauche. Un symbole retire le moins possible, deux symboles le plus possible.

Avec ces quatre opérateurs, on remplace basename (${f##*/}), dirname (${f%/*}, avec une réserve vue dans les pièges) et la plupart des usages de cut et sed sur une seule valeur.

Remplacer, changer la casse

Propres à Bash (et à ksh93), absents de POSIX :

ExpressionEffet
${v/motif/texte}remplace la première correspondance la plus longue de motif par texte
${v//motif/texte}remplace toutes les correspondances
${v/#motif/texte}remplace seulement si motif correspond au début
${v/%motif/texte}remplace seulement si motif correspond à la fin
${v/motif}supprime la première correspondance (texte vide)
${v^} ${v^^}met en majuscule la première lettre, ou toutes
${v,} ${v,,}met en minuscule la première lettre, ou toutes

Bash 5.2 a introduit un changement qui peut surprendre des scripts plus anciens : l'option patsub_replacement, activée par défaut. Avec elle, un & non protégé dans le texte de remplacement est remplacé par la partie qui a correspondu au motif, comme dans sed. On y revient dans les pièges, parce que cela touche aussi les & qui arrivent par une variable.

Position et longueur

  • ${#v} donne la longueur de la valeur, en caractères selon la locale courante (et non en octets).
  • ${v:début} et ${v:début:longueur} extraient une sous-chaîne. Les positions commencent à 0. début et longueur sont des expressions arithmétiques. Un début négatif compte depuis la fin, mais doit être séparé du deux-points par une espace, ${v: -2}, sinon Bash lit ${v:-2}, l'opérateur de valeur par défaut.

Transformations et indirection

Depuis Bash 4.4, et enrichie en 5.1, la forme ${v@opérateur} transforme la valeur ou renseigne sur la variable :

OpérateurRésultatUsage
@Qla valeur entre guillemets, réutilisable comme entrée du shelljournaliser une valeur sans ambiguïté (espaces, sauts de ligne)
@Aune affectation (ou un declare) qui recrée la variablesauvegarder un état, déboguer
@ales attributs de la variable (r lecture seule, x exportée, i entier...)vérifier qu'une constante est bien en lecture seule
@U, @u, @Lmajuscules, première lettre en majuscule, minuscules (Bash 5.1)équivalents lisibles de ^^, ^ et ,,
@Einterprète les séquences \n, \t... comme $'...'rare

Enfin, ${!préfixe*} liste les noms des variables qui commencent par préfixe, et ${!nom} est une indirection : la valeur de la variable dont le nom est contenu dans nom. La leçon 6 lui préférera les références nommées (declare -n), plus lisibles.

Attributs : readonly et declare

Une variable peut porter des attributs, posés par declare (ou local dans une fonction, leçon 6) :

CommandeAttributEffet
readonly V=1 ou declare -r V=1lecture seuletoute affectation ultérieure est une erreur (V: readonly variable), et le reste de la ligne en cours est abandonné
export V ou declare -x Vexportéetransmise aux processus enfants
declare -i nentiertoute affectation est évaluée comme une expression arithmétique
declare -l v, declare -u vcassela valeur est convertie en minuscules ou majuscules à chaque affectation

readonly sert aux constantes d'un script : sa version, ses chemins fixes. Une affectation refusée se comporte comme une erreur d'expansion arithmétique : hors mode POSIX, Bash affiche V: readonly variable, abandonne la ligne et poursuit à la ligne suivante (le manuel range l'arrêt du script parmi les comportements du seul mode POSIX). À la différence de l'erreur arithmétique, l'option set -e de la leçon 9 l'intercepte : l'affectation renvoie alors un code non nul et le script s'arrête. declare -p nom affiche la variable et ses attributs sous une forme réutilisable.

declare -i paraît pratique et pose plus de problèmes qu'il n'en résout : n+=3 ajoute 3 si n est entier, mais concatène le texte 3 sinon. Le comportement d'une ligne dépend alors d'une déclaration écrite ailleurs, et la page ArithmeticExpression de Greg's Wiki qualifie cette pratique de nuisible. On l'évite : les calculs se font explicitement, dans $(( )).

printf plutôt qu'echo

echo affiche ses arguments séparés par des espaces. Il convient à un message fixe tapé au clavier, pas à une donnée :

  • si la valeur affichée commence par -n ou -e, echo la prend pour une option et ne l'affiche pas ;
  • le traitement des barres obliques inverses (\t, \n) varie selon le shell et ses options : echo de Bash les laisse telles quelles, celui de dash les interprète.

La norme POSIX tire elle-même la conclusion, dans la page de l'utilitaire echo : il n'est pas possible d'utiliser echo de façon portable si un argument contient une barre oblique inverse ou si le premier commence par -n, et les nouvelles applications sont encouragées à utiliser printf.

printf prend un format, puis des arguments qui remplissent les spécifications du format, dans l'ordre :

SpécificationAfficheExemple
%sune chaîne, telle quelleprintf '%s\n' "$v"
%dun entier en décimalprintf '%d\n' 42
%5d, %05d, %-8slargeur minimale, remplissage par des zéros, alignement à gaucheprintf '%05d' 42 donne 00042
%x, %ohexadécimal, octalprintf '%x' 255 donne ff
%.1fun nombre à virgule, une décimaledépend de la locale (voir plus bas)
%qla valeur protégée pour le shellleçon 2
%bune chaîne dont on interprète les \n, \trare
%(format)Tune date, selon un format strftime(3)printf '%(%F)T' -1 donne la date du jour
%%un signe % littéralprintf '%d %%\n' 12

Trois propriétés à connaître :

  • le format est réutilisé tant qu'il reste des arguments : printf '%s\n' a b c affiche trois lignes. C'est une boucle gratuite ;
  • printf -v var format... écrit le résultat dans la variable var au lieu de l'afficher, sans sous-shell ;
  • %(format)T (Bash 4.1 et suivants) formate une date sans lancer date : l'argument est un nombre de secondes depuis le 1er janvier 1970 en UTC (le temps Unix), -1 désigne l'heure actuelle. Le fuseau utilisé est celui de la variable TZ, sinon celui du système.

La variable EPOCHSECONDS (Bash 5.0) donne aussi l'heure actuelle en temps Unix, sans lancer de programme ; EPOCHREALTIME y ajoute les microsecondes.

L'arithmétique entière

Bash calcule sur des entiers signés de 64 bits, et sur rien d'autre. Trois formes :

  • $(( expression )), l'expansion arithmétique : elle est remplacée par le résultat. echo $(( 7 / 2 )) affiche 3.
  • (( expression )), la commande arithmétique : elle ne produit rien, mais renvoie un code de sortie : 0 (vrai) si le résultat est non nul, 1 (faux) s'il est nul. On s'en sert pour les tests numériques (leçon 4) et les affectations ((( total += n ))).
  • let expression, ancienne forme équivalente à (( )), à éviter : ses arguments subissent le découpage en mots et l'expansion des chemins, donc let n=n*2 peut se transformer en noms de fichiers.

Les opérateurs sont ceux du langage C, avec leurs priorités : + - * / % (reste), ** (puissance, propre à Bash), ++ --, les comparaisons < <= > >= == !=, les opérateurs logiques && || !, le conditionnel a ? b : c, les affectations = += -= *= /=. Dans l'expression, une variable peut s'écrire sans $ : $(( total + n )). ShellCheck signale d'ailleurs le $ superflu (avertissement SC2004).

Quatre règles qui expliquent la plupart des surprises :

  1. La division tronque vers zéro : 7 / 2 vaut 3, -7 / 2 vaut -3.
  2. Aucun contrôle de dépassement : le manuel le dit, l'évaluation se fait en entiers de taille fixe sans vérification. 2**63 donne -9223372036854775808. Seule la division par zéro est détectée.
  3. Un nombre qui commence par 0 est lu en octal (base 8), comme en C : 010 vaut 8, et 08 ou 09 sont des erreurs, puisque 8 et 9 ne sont pas des chiffres octaux. 0x introduit l'hexadécimal, et base#nombre force une base : 10#08 vaut 8, 2#1010 vaut 10.
  4. La valeur d'une variable est elle-même évaluée comme une expression : si n contient 1+1, $(( n * 3 )) vaut 6. Cette propriété a des conséquences de sécurité sérieuses (voir la section Sécurité).

POSIX n'exige que l'arithmétique $(( )) sur des entiers de type long ; (( )), let, ** et les bases n# sont propres à Bash, et la norme précise que ++ et -- ne sont pas requis.

Ce que dash ne connaît pas

Un script lancé par sh script ou par une ligne de crontab sans précaution est exécuté par dash sur Debian et Ubuntu (leçon 1). Voici ce qui est portable et ce qui ne l'est pas :

POSIX, fonctionne sous dashPropre à Bash, Bad substitution sous dash
${v:-x} ${v:=x} ${v:?x} ${v:+x} et sans :${v:0:4}, ${v: -2}
${#v}${v/a/b}, ${v//a/b}
${v#x} ${v##x} ${v%x} ${v%%x}${v^^}, ${v,,}, ${v@Q}, ${!v}
$(( ))(( )), let, **, 10#, printf -v, %(...)T, EPOCHSECONDS

En pratique

Les sorties de cette section ont été produites avec Bash 5.2.21 et LC_ALL=C.UTF-8 (messages en anglais, comme sur la plupart des serveurs). Travaillez dans le dépôt signalements-outils de la leçon 1, ou dans un répertoire d'essai : aucune commande de cette section ne modifie le système.

Lire l'horloge une seule fois

Le premier défaut du script de Camille est d'appeler date trois fois. Entre deux appels, le temps passe ; à minuit, le jour change entre le nom du fichier et le nom du dossier. La règle est simple : on lit l'horloge une fois, et l'on dérive toutes les dates de cette lecture.

$ printf -v jour '%(%Y-%m-%d)T' -1
$ echo "$jour"
2026-10-08
$ annee=${jour:0:4}
$ mois=${jour:5:2}
$ echo "$annee / $mois"
2026 / 10
  • printf -v jour écrit dans la variable jour au lieu d'afficher ; aucun processus n'est créé, contrairement à jour=$(date +%F).
  • %(%Y-%m-%d)T applique le format strftime ; %F en est l'abréviation (%(%F)T donne la même chose).
  • ${jour:0:4} : quatre caractères à partir de la position 0 ; ${jour:5:2} : deux caractères à partir de la position 5 (le - est en position 4).

La date est calculée dans le fuseau du système. Sur sig-outils, comme sur tous les serveurs de Signalements, c'est UTC (L'heure et sa synchronisation), le même fuseau que l'export Python qui nomme le fichier. Pour un calcul ponctuel dans un autre fuseau, on préfixe la commande : TZ=Europe/Paris printf '%(%F %T)T\n' -1.

On peut aussi découper la date par les motifs, ce qui ne dépend pas des positions :

$ echo "${jour%%-*} ${jour#*-}"
2026 10-08

${jour%%-*} retire le plus long suffixe qui commence par un tiret, il reste l'année ; ${jour#*-} retire le plus court préfixe qui se termine par un tiret.

Configurer avec des défauts et des obligations

Le script reçoit sa configuration de l'environnement : sous systemd, l'unité qui le lance charge /etc/signalements/env par EnvironmentFile= (Planifier des tâches). Certains réglages ont une valeur raisonnable par défaut, d'autres n'en ont pas. On l'écrit en tête du script, une ligne par réglage :

: "${REPERTOIRE_EXPORTS:=/srv/donnees/exports}"
: "${POINT_ACCES:=https://s3.fr-par.scw.cloud}"
: "${CONSERVATION_JOURS:=30}"
: "${DESTINATION:?à définir dans /etc/signalements/env, par exemple s3://sig-exports-mairie}"
  • : est une commande interne qui ne fait rien et réussit toujours. On ne l'appelle que pour ses arguments : leur expansion fait le travail, c'est-à-dire l'affectation par := ou l'arrêt par :?.
  • Les guillemets évitent que la valeur ne subisse le découpage en mots et l'expansion des chemins, qui seraient inutiles ici et dangereuses si la valeur contenait un *.
  • L'ensemble se lit comme la documentation des réglages du script : un lecteur voit d'un coup d'œil ce qui est configurable et ce qui est obligatoire.

Essayons sans DESTINATION, avec un petit script essai-config qui contient ces quatre lignes suivies de echo "destination : $DESTINATION" :

$ bash essai-config
essai-config: line 4: DESTINATION: à définir dans /etc/signalements/env, par exemple s3://sig-exports-mairie
$ echo $?
1
$ DESTINATION=s3://sig-exports-mairie bash essai-config
destination : s3://sig-exports-mairie

Le script s'arrête avant d'avoir fait quoi que ce soit, avec le nom de la variable et le message. Sans message, Bash en fournit un, moins parlant : DESTINATION: parameter null or not set. Une variable affectée à vide (DESTINATION=) produit la même erreur, grâce au deux-points.

Le second essai montre au passage comment donner une variable à une seule commande, vu dans L'environnement du shell.

Tip

${var:?} est la protection la plus simple contre la catastrophe classique : un rm -rf "$REPERTOIRE/"* exécuté alors que REPERTOIRE est vide, qui devient rm -rf /*. Écrire rm -rf "${REPERTOIRE:?}/"* arrête le script au lieu de vider la machine. La section Sécurité raconte le cas réel qui a rendu cet exemple célèbre.

Construire les chemins sans lancer de programme

$ REPERTOIRE_EXPORTS=/srv/donnees/exports
$ DESTINATION=s3://sig-exports-mairie/
$ fichier="${REPERTOIRE_EXPORTS%/}/signalements-$jour.csv"
$ archive="$fichier.gz"
$ cible="${DESTINATION%/}/$annee/$mois/${archive##*/}"
$ printf '%s\n' "$fichier" "$archive" "$cible"
/srv/donnees/exports/signalements-2026-10-08.csv
/srv/donnees/exports/signalements-2026-10-08.csv.gz
s3://sig-exports-mairie/2026/10/signalements-2026-10-08.csv.gz
  • ${DESTINATION%/} retire une éventuelle barre oblique finale. Que la configuration écrive s3://sig-exports-mairie ou s3://sig-exports-mairie/, le résultat est le même, sans double barre. Si la valeur ne se termine pas par /, le motif ne correspond pas, et la valeur est rendue intacte : c'est la propriété qui rend ces opérateurs si commodes.
  • ${archive##*/} remplace basename "$archive".
  • printf '%s\n' réutilise son format pour chacun des trois arguments.

Le mouvement inverse, retrouver la date à partir d'un nom de fichier, sert pour la conservation locale :

$ nom=signalements-2026-10-07.csv.gz
$ d=${nom#signalements-}
$ d=${d%%.*}
$ echo "$d"
2026-10-07

On ne peut pas imbriquer deux opérateurs dans une même expansion (${${nom#x}%y} n'existe pas en Bash) : on passe par une variable intermédiaire, comme ici.

Pour les extensions, retenez la différence entre un et deux symboles :

$ x=archive.tar.gz
$ echo "${x%.*} | ${x%%.*} | ${x#*.} | ${x##*.}"
archive.tar | archive | tar.gz | gz

Remplacer et changer la casse

Le rapport de fin de traitement affiche le type de signalement de façon lisible :

$ type=nid-de-poule
$ echo "${type/-/ } | ${type//-/ } | ${type^} | ${type^^}"
nid de-poule | nid de poule | Nid-de-poule | NID-DE-POULE

Pour normaliser une valeur saisie (--format CSV ou --format csv), ${format,,} met tout en minuscules avant la comparaison.

Afficher une valeur sans ambiguïté

Dans un journal, fichier : /srv/donnees/exports/signalements 2026.csv ne dit pas si le nom contient une espace, deux, ou une tabulation. @Q lève le doute :

$ v=$'nom avec  deux espaces\net un saut de ligne'
$ echo "valeur : ${v@Q}"
valeur : $'nom avec  deux espaces\net un saut de ligne'
$ nom="d'Exempleville"
$ echo "${nom@Q}"
'd'\''Exempleville'

La valeur est présentée sous une forme que l'on pourrait recopier telle quelle dans un shell. Un saut de ligne devient \n : une valeur ne peut plus fabriquer une fausse ligne dans le journal. printf '%q' donne un résultat équivalent, avec un style de protection parfois différent (d\'Exempleville), vu en leçon 2.

Les autres transformations renseignent sur la variable :

$ readonly VERSION=0.3.0
$ echo "${VERSION@a} | ${VERSION@A}"
r | declare -r VERSION='0.3.0'
$ VERSION=0.4.0
bash: VERSION: readonly variable

Formater avec printf

Une ligne de journal horodatée, sans lancer date :

$ printf '%(%Y-%m-%dT%H:%M:%S%z)T publier-export : %s\n' -1 "début du dépôt"
2026-10-08T05:02:13+0000 publier-export : début du dépôt

Le premier argument, -1, alimente %(...)T ; le second, %s. Quelques formats utiles pour un rapport :

$ printf '%-12s %6d %6s\n' sig-app-1 1284 OK sig-app-2 97 LENT
sig-app-1      1284     OK
sig-app-2        97   LENT
$ printf '%05d|%x|%o\n' 42 255 8
00042|ff|10

Le format a été réutilisé pour la seconde ligne du tableau, et les colonnes sont alignées. Pour l'ordre des arguments, il n'y a pas de magie : ils remplissent les spécifications dans l'ordre où elles apparaissent.

Comparez maintenant echo et printf sur deux valeurs piégées :

$ v=-n
$ echo "$v"
$ printf '%s\n' "$v"
-n
$ w='a\tb'
$ echo "$w"
a\tb
$ printf '%s\n' "$w"
a\tb

Le premier echo n'a rien affiché, pas même un saut de ligne : il a pris -n pour son option « pas de saut de ligne final ». printf '%s\n' affiche toujours la donnée telle quelle. Avec dash (sh -c 'echo "a\tb"'), le second echo aurait affiché une tabulation.

Calculer

La taille de l'archive, en kibioctets arrondis à l'unité supérieure, sans virgule :

$ taille=217413
$ echo "$(( (taille + 1023) / 1024 )) Kio"
213 Kio

Ajouter diviseur - 1 avant de diviser arrondit vers le haut : c'est le procédé habituel en arithmétique entière. Dans le script, taille viendra de stat -c %s "$archive", qui renvoie la taille en octets.

Un compteur se manipule avec (( )) :

$ (( lignes = 1284, lignes_veille = 1241 ))
$ (( ecart = lignes - lignes_veille ))
$ echo "$ecart"
43

La virgule, comme en C, sépare deux expressions dans une même commande.

Le piège du mois d'août

Reproduisons le calcul de Camille un 1er août :

$ mois=08
$ echo $(( mois - 1 ))
bash: 08: value too great for base (error token is "08")
$ echo $(( 10#$mois - 1 ))
7

08 commence par un zéro : Bash le lit en octal, où le chiffre 8 n'existe pas. 10# impose la base 10. Notez le $ obligatoire dans 10#$mois : sans lui, Bash lirait 10#mois, un nombre en base 10 écrit avec des lettres, ce qui est une autre erreur. ShellCheck connaît cette exception et ne signale pas ce $.

Ce qui rend l'erreur si traîtresse, c'est ce qu'elle déclenche dans un script. Bash ne s'arrête pas : il affiche le message, abandonne la commande en cours et passe à la suivante. Un petit fichier essai-aout le montre :

MOIS=08
PRECEDENT=$(( $MOIS - 1 ))
echo "s3://sig-exports-mairie/2026/$PRECEDENT/recapitulatif.csv"
$ bash essai-aout
essai-aout: line 2: 08: value too great for base (error token is "08")
s3://sig-exports-mairie/2026//recapitulatif.csv
$ echo $?
0

L'affectation n'a pas eu lieu, PRECEDENT est restée vide, le script s'est poursuivi et s'est terminé avec le code 0. Ce qui est abandonné, c'est la commande de premier niveau tout entière : toute la ligne si plusieurs commandes y sont séparées par ;, tout un bloc { ... } ou tout un appel de fonction si l'erreur s'y produit. Nous avons vérifié avec Bash 5.2.21 que même l'option set -e, présentée en leçon 9, n'arrête pas le script dans ce cas. Seul le mode POSIX (bash --posix) le termine, avec le code 1 : la section du manuel qui liste les différences de ce mode (Bash POSIX Mode) précise que les shells non interactifs s'arrêtent si une erreur de syntaxe dans une expansion arithmétique produit une expression invalide (« Non-interactive shells exit if a syntax error in an arithmetic expansion results in an invalid expression »), ce qui veut dire qu'en mode normal, ils ne s'arrêtent pas. C'est exactement ce qui est arrivé au récapitulatif de juillet.

Le danger symétrique est plus sournois, parce qu'il ne produit aucune erreur :

$ echo $(( 010 + 0 ))
8

Un identifiant 010 lu dans un fichier devient silencieusement 8. Retenez la règle : une valeur qui peut commencer par un zéro passe par 10# avant tout calcul. Elle ne gère pas le signe (10#-08 est une erreur, -10#08 est correct, comme le note la page ArithmeticExpression) ; pour un mois, un jour, une heure ou un identifiant, ce n'est pas un problème. Autre solution pour les dates : demander à date une valeur sans zéro, date +%-m (une extension GNU).

Les dates relatives

Pour la conservation locale de trente jours, il faut la date d'il y a trente jours. Le réflexe « maintenant moins 30 fois 86 400 secondes » est faux deux fois par an dans un fuseau à heure d'été. Démonstration, en heure de Paris, le lendemain du passage à l'heure d'été, à 0 h 30 :

$ export TZ=Europe/Paris
$ t=$(date -d '2026-03-30 00:30' +%s)
$ printf '%(%F %T %Z)T\n' "$t" "$(( t - 86400 ))"
2026-03-30 00:30:00 CEST
2026-03-28 23:30:00 CET

La journée du 29 mars n'a duré que 23 heures : reculer de 24 heures fait sauter un jour. La « veille » calculée ainsi est le 28. GNU date sait faire ce calcul dans le calendrier :

$ date -d '2026-03-30 -1 day' +%F
2026-03-29
$ date -d '2026-10-08 -30 days' +%F
2026-09-08

Deux précautions, que nous avons vérifiées avec date de coreutils 9.4 :

  • Ne mettez pas d'heure devant un nombre signé. date -d '2026-03-30 12:00 -1 day' répond 2026-03-31 15:00:00 en heure de Paris (13 h en UTC), et non le 29 : date lit 12:00 -1 comme « midi, fuseau UTC moins une heure », puis day comme « plus un jour ». Écrivez la date seule, ou 1 day ago.
  • Calculez sur des dates, pas sur des instants. Partir de AAAA-MM-JJ (minuit) et ajouter des jours reste juste. Et sur un serveur en UTC, le problème des heures d'été disparaît : c'est une des raisons de régler les serveurs en UTC. La documentation de coreutils recommande d'ailleurs TZ=UTC0 pour les calculs de calendrier.

Les décimales

Bash ne connaît pas les décimales. Pour afficher « + 3,4 % de lignes par rapport à la veille », deux méthodes.

La virgule fixe : on calcule en dixièmes (ou centièmes), puis on place la virgule à l'affichage.

$ avant=1241 apres=1284
$ d=$(( (apres - avant) * 1000 / avant ))
$ printf '%d,%d %%\n' $(( d / 10 )) $(( d % 10 ))
3,4 %

d vaut 34 dixièmes de pour cent ; d / 10 donne la partie entière, d % 10 la décimale. La division tronque : la valeur exacte est 3,46. Pour arrondir, ajoutez la moitié du diviseur avant de diviser, comme pour les kibioctets. Et si l'écart est négatif, d % 10 l'est aussi : -3,-3 %. Le signe se traite à part, voir l'exercice 4.

awk, quand les calculs se multiplient :

$ awk -v a="$avant" -v b="$apres" 'BEGIN { printf "%+.1f\n", (b - a) * 100 / a }'
+3.5

Sur Debian et Ubuntu, awk est par défaut mawk. -v passe les valeurs proprement (pas de collage de texte dans le programme awk), BEGIN exécute le bloc sans lire d'entrée. bc existe aussi, mais il n'est pas installé par défaut sur les images de serveur.

Warning

Les décimales dépendent de la locale. Avec LC_ALL=fr_FR.UTF-8, la commande interne printf '%.1f' 3.5 échoue (« nombre non valable », invalid number en anglais) et affiche 3,0, parce qu'elle attend 3,5 ; et mawk affiche 42,9 au lieu de 42.9. Un script qui produit des nombres pour une machine (un CSV, une métrique) fixe sa locale : export LC_ALL=C.UTF-8 en tête, ou LC_NUMERIC=C pour la seule partie numérique.

L'état du script après cette leçon

Voici le début de bin/publier-export réécrit avec ce que nous savons. Les tests (if, leçon 4), les fonctions (leçon 6) et la gestion des erreurs (leçon 9) viendront ; pour l'instant, les commandes s'enchaînent par &&, vu dans Redirections, tubes et codes de sortie, pour qu'une étape ratée arrête la suite.

#!/usr/bin/env bash
# publier-export : dépose l'export quotidien de Signalements pour la mairie.
# État après la leçon 3 du cours « Bash pour l'automatisation ».

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

# Configuration : valeur de l'environnement (/etc/signalements/env), sinon défaut.
# DESTINATION n'a pas de défaut : sans elle, le script s'arrête ici.
: "${REPERTOIRE_EXPORTS:=/srv/donnees/exports}"
: "${POINT_ACCES:=https://s3.fr-par.scw.cloud}"
: "${CONSERVATION_JOURS:=30}"
: "${DESTINATION:?à définir dans /etc/signalements/env, par exemple s3://sig-exports-mairie}"

# Une seule lecture de l'horloge. Une date peut être passée en argument ;
# elle n'est pas encore vérifiée (leçons 4 et 8).
printf -v aujourdhui '%(%Y-%m-%d)T' -1
jour=${1:-$aujourdhui}
annee=${jour:0:4}
mois=${jour:5:2}

fichier="${REPERTOIRE_EXPORTS%/}/signalements-$jour.csv"
archive="$fichier.gz"
cible="${DESTINATION%/}/$annee/$mois/${archive##*/}"
# CONSERVATION_JOURS vient d'un fichier de root ; sa validation viendra en leçon 4.
limite=$(date -d "$jour -$(( 10#$CONSERVATION_JOURS )) days" +%F)

printf '%(%Y-%m-%dT%H:%M:%S%z)T publier-export %s : %s vers %s\n' \
    -1 "$VERSION" "${fichier@Q}" "${cible@Q}" >&2

# Compresser, calculer l'empreinte, déposer l'archive puis son empreinte.
# sha256sum lit l'entrée standard et affiche « <empreinte>  - » :
# on garde l'empreinte, et l'on écrit la ligne au format attendu par sha256sum -c.
gzip --keep --force -- "$fichier" &&
    empreinte=$(sha256sum < "$archive") &&
    printf '%s  %s\n' "${empreinte%% *}" "${archive##*/}" > "$archive.sha256" &&
    aws s3 cp --endpoint-url "$POINT_ACCES" "$archive" "$cible" &&
    aws s3 cp --endpoint-url "$POINT_ACCES" "$archive.sha256" "$cible.sha256" &&
    taille=$(stat -c %s "$archive") &&
    printf 'publier-export : %s déposé (%d Kio), conservation locale après le %s\n' \
        "${archive##*/}" $(( (taille + 1023) / 1024 )) "$limite" >&2

Ce qui a changé par rapport à la version de Camille :

  • l'horloge est lue une fois, par printf -v, et l'année et le mois en sont extraits : plus d'archive rangée dans le mauvais mois ;
  • DESTINATION est obligatoire : le script s'arrête avec un message clair au lieu de viser s3:/// ;
  • plus de calcul sur 08, et 10# protège la seule valeur numérique configurable ;
  • basename disparaît, date n'est plus appelé que pour le calcul de calendrier qu'il est seul à savoir faire ;
  • toutes les expansions sont entre guillemets ;
  • le fichier d'empreinte contient le nom de l'archive sans son répertoire, pour que la mairie puisse le vérifier avec sha256sum -c dans son propre répertoire. ${empreinte%% *} retire le plus long suffixe qui commence par une espace, c'est-à-dire -.

La purge des archives de plus de trente jours n'est pas encore là : elle demande une boucle sur des noms de fichiers, en leçon 5.

Sous le capot

Une expansion ne crée pas de processus

L'outil strace montre les appels système d'un programme. On lui fait suivre Bash sur un petit script de trois lignes, en ne gardant que les créations de processus et les lancements de programmes :

f=/srv/donnees/exports/signalements-2026-10-07.csv
b=${f##*/}
c=$(basename "$f")
printf -v j '%(%F)T' -1
$ strace -f -e trace=clone,clone3,execve,pipe2 -o trace.txt bash --norc essai.sh
$ grep -v -e exited -e '--- SIGCHLD' trace.txt

Résultat sur notre machine, adresses abrégées :

967133 execve("/usr/bin/bash", ["bash", "--norc", "essai.sh"], … /* 67 vars */) = 0
967133 pipe2([3, 4], 0)                 = 0
967133 clone(child_stack=NULL, flags=CLONE_CHILD_CLEARTID|CLONE_CHILD_SETTID|SIGCHLD, …) = 967134
967134 execve("/usr/bin/basename", ["basename", "/srv/donnees/exports/signalement"...], …) = 0

La ligne b=${f##*/} et la ligne printf -v n'apparaissent pas : elles n'ont fait aucun appel système de ce type, tout s'est passé dans la mémoire de Bash. La substitution $(basename "$f"), elle, a coûté un tube pour récupérer la sortie (pipe2), un nouveau processus (clone) et le chargement d'un programme (execve), sans compter la lecture de la sortie et l'attente de la fin du processus.

Le coût se mesure. Mille itérations de chaque forme, avec la commande interne time, sur notre poste :

Boucle de 1 000 itérationsDurée réelle
b=${f##*/}0,009 s
b=$(basename "$f")1,83 s
printf -v j '%(%F)T' -10,017 s
j=$(date +%F)1,82 s

Un facteur de l'ordre de cent à deux cents. Pour un script qui traite une valeur, la différence est imperceptible ; pour une boucle sur cent mille noms de pièces jointes, c'est la différence entre une seconde et trois minutes, et entre une machine calme et une machine qui passe son temps à créer des processus (la colonne sys de time est d'ailleurs dominante pour les versions avec substitution). La leçon 11 revient sur le coût des processus.

Les variables dynamiques

Certaines variables de Bash ne stockent rien : leur valeur est calculée à chaque lecture par une fonction interne au shell. EPOCHSECONDS, EPOCHREALTIME, SECONDS (secondes depuis le démarrage du shell), RANDOM, LINENO, BASHPID en font partie. C'est pour cela que EPOCHSECONDS donne l'heure sans appel à date, et que deux lectures successives peuvent différer. Corollaire pour notre règle « une seule lecture de l'horloge » : copiez la valeur dans une variable ordinaire (debut=$EPOCHSECONDS) au lieu de relire EPOCHSECONDS à plusieurs endroits.

L'évaluateur arithmétique

Quand Bash rencontre $(( ... )), il commence par appliquer à l'expression les expansions d'une chaîne entre guillemets doubles (paramètres, substitutions de commande), puis confie le texte obtenu à son évaluateur arithmétique, qui suit la grammaire et les priorités du C. Deux conséquences découlent de cette organisation.

Premièrement, quand l'évaluateur rencontre un nom de variable sans $, il lit sa valeur et, si cette valeur n'est pas un simple nombre, l'évalue à son tour comme une expression. Le manuel le dit : la valeur d'une variable est évaluée comme une expression arithmétique quand elle est référencée. D'où :

$ n='1+1'
$ echo "$(( n * 3 )) $(( $n * 3 ))"
6 4

Avec n sans $, l'évaluateur calcule n d'abord (2), puis multiplie : 6. Avec $n, l'expansion a lieu avant, et l'évaluateur reçoit le texte 1+1 * 3, où la multiplication passe en premier : 4. Une variable vide ou non définie vaut 0, et une variable qui contient un autre nom de variable renvoie à celle-ci, récursivement.

Deuxièmement, les indices de tableau (t[expression]) sont eux aussi des expressions arithmétiques, avec leurs propres expansions. C'est la porte d'entrée de l'injection décrite ci-dessous.

Pièges courants

patsub_replacement et le &. Depuis Bash 5.2, ${v//x/&} remplace x par... x, le texte qui a correspondu. Plus surprenant, cela vaut aussi pour un & qui arrive par une variable non protégée :

$ t=x r='&'
$ echo "${t//x/$r} | ${t//x/"$r"} | ${t//x/\&}"
x | & | &

Mettez le texte de remplacement entre guillemets quand il vient d'une variable ("${v//motif/"$remplacement"}"), ou échappez le &. Un script écrit pour Bash 5.1 qui remplaçait et par & change de comportement sur Ubuntu 24.04 et Debian 13. Pour retrouver l'ancien comportement dans un script hérité : shopt -u patsub_replacement.

Une variable dans un motif. Dans ${f%$suffixe}, le contenu de suffixe est un motif : s'il contient *, ? ou [, il ne correspond plus littéralement. ShellCheck le signale (SC2295, « Expansions inside ${..} need to be quoted separately, otherwise they will match as a pattern ») :

$ f='a*b.csv'
$ echo "${f#a*} | ${f#"a*"}"
*b.csv | b.csv

Protégez la variable intérieure : "${f%"$suffixe"}".

${v:-2} au lieu de ${v: -2}. Sans espace, c'est l'opérateur de valeur par défaut : vous obtenez la valeur entière de v, ou 2 si elle est vide. Aucune erreur ne vous alertera.

${f%/*} n'est pas dirname. Pour un nom sans barre oblique (export.csv), le motif ne correspond pas et l'on obtient export.csv au lieu de . ; pour /export.csv, on obtient la chaîne vide au lieu de /. Dans un script où le chemin est toujours absolu et construit par vous, c'est sans conséquence ; pour un chemin fourni par l'utilisateur, utilisez dirname ou traitez ces cas.

Le zéro en tête. 08 et 09 font tomber le script, 010 vaut silencieusement 8 : 10# avant tout calcul sur une valeur qui peut commencer par zéro (mois, jour, heure, identifiant).

$(( )) qui échoue en silence. Une erreur d'expansion arithmétique (division par zéro, nombre invalide) affiche un message, abandonne la commande en cours et laisse le script continuer avec une variable non affectée, même sous set -e. La même erreur dans (( )) renvoie simplement le code 1. Ni l'un ni l'autre n'est une manière de valider une donnée : validez avant de calculer (leçon 4).

(( n++ )) qui « échoue ». (( )) renvoie 1 quand le résultat vaut zéro. (( n++ )) avec n=0 vaut 0 (post-incrément : la valeur d'avant), donc renvoie 1, alors que n est bien passé à 1. Sans conséquence aujourd'hui ; avec set -e (leçon 9), cette ligne arrête le script. Écrivez (( n += 1 )) ou n=$(( n + 1 )).

Les décimales et la locale. printf '%.1f' 3.5 échoue sous une locale française, mawk écrit des virgules. Fixez LC_ALL=C.UTF-8 dans les scripts qui produisent des nombres.

printf "$message". Le message devient le format : un % qu'il contient est interprété. Avec fmt="100% sûr", printf "$fmt\n" affiche 100ûr : % s a été pris pour une spécification de chaîne, avec le drapeau « espace », et a consommé le s. ShellCheck : SC2059, « Don't use variables in the printf format string. Use printf "..%s.." "$foo" ».

$((08)) sous dash, ${v:0:4} sous dash. Erreurs différentes (arithmetic expression: expecting EOF: "08", Bad substitution), même cause : le script n'est pas exécuté par Bash. Vérifiez la ligne #! et la façon dont le script est lancé (leçon 1).

n+=3 sur une variable non entière. Concatène : 5 devient 53. N'utilisez += sur des nombres qu'à l'intérieur de (( )).

Sécurité

Une variable vide devant /*. En janvier 2015, le script de lancement du client Steam pour Linux contenait, sous un commentaire # Scary!, la ligne rm -rf "$STEAMROOT/"*. Quand STEAMROOT se retrouvait vide (après un déplacement du répertoire de Steam), la commande devenait rm -rf /*, et le ticket n° 3671 de Valve rapporte la suppression de tous les fichiers appartenant à l'utilisateur, disque de sauvegarde monté compris. Les guillemets étaient là ; c'est la vérification de la valeur qui manquait. Toute variable qui forme le début d'un chemin passé à rm, chmod -R, chown -R, find -delete ou rsync --delete s'écrit ${VAR:?}.

L'arithmétique exécute du code. Puisque l'évaluateur évalue la valeur d'une variable comme une expression, et que les indices de tableau subissent des substitutions de commande, une donnée non fiable placée dans un contexte arithmétique peut lancer une commande. Démonstration sans danger, la commande injectée se contentant d'écrire un message :

$ jours='a[$(echo "commande exécutée" >&2)]'
$ echo $(( jours + 1 ))
commande exécutée
1
$ [[ $jours -gt 30 ]] && echo plus
commande exécutée
$ [ "$jours" -gt 30 ]
bash: [: a[$(echo "commande exécutée" >&2)]: integer expression expected

Vidar Holen, l'auteur de ShellCheck, a décrit cette famille sous le nom d'« eval en col blanc » : $(( )), (( )), les indices de tableaux, les positions de ${v:début:longueur} et les comparaisons numériques de [[ ]] (-eq, -gt...) sont concernés. La commande [ (ou test), elle, refuse la valeur, parce qu'elle exige un entier littéral. La parade : valider qu'une donnée est un entier avant tout usage arithmétique, par un motif comme [[ $jours =~ ^[0-9]+$ ]] (leçon 4). Sont concernés au premier chef les arguments d'un script lancé avec des privilèges (sudo, un service), le contenu de fichiers que d'autres comptes peuvent écrire, et tout ce qui arrive par le réseau.

Le format de printf est un langage. Comme pour l'arithmétique, une donnée placée dans le format de printf est interprétée. Les conséquences sont moins graves qu'en C (pas d'écriture en mémoire), mais une donnée peut fausser ou tronquer un journal. Le format est toujours une constante ; les données passent en arguments.

Les journaux et les sauts de ligne. Un nom de fichier ou une valeur reçue qui contient un saut de ligne peut fabriquer une fausse ligne de journal (« export réussi »), et tromper la personne qui lit ou l'outil qui analyse. ${v@Q} ou printf '%q' rendent les caractères de contrôle visibles.

Les secrets dans les variables. declare -p, set sans argument et ${v@A} affichent les valeurs : ne les utilisez pas pour déboguer un script qui manipule une clé d'API. Une variable lue dans /etc/signalements/env est héritée par tous les programmes que lance le script, si elle est exportée ; n'exportez que ce dont un programme enfant a besoin (AWS_ACCESS_KEY_ID pour aws, pas pour gzip). Le cours Gestion des secrets traitera le sujet à fond.

readonly n'est pas une protection. Il évite qu'une ligne du script n'écrase une constante par erreur, rien de plus. C'est l'appelant qui fournit l'environnement de départ : toute variable que le script ne redéfinit pas lui-même (PATH, IFS, LC_ALL, un réglage sans défaut) vient de lui. Bash non interactif lit même, avant la première ligne du script, le fichier désigné par la variable BASH_ENV, si elle est définie. Un script lancé avec des privilèges ne fait donc pas confiance à son environnement : sudo le nettoie (env_reset), systemd n'en transmet qu'un minimum, et le script fixe lui-même ce dont il dépend (export LC_ALL=C.UTF-8, un PATH explicite).

En production

  • Une date de référence par exécution. Un traitement nocturne qui dure dix minutes et passe minuit doit garder la date de son démarrage du début à la fin. Calculez-la une fois, passez-la aux étapes suivantes, et journalisez-la.
  • UTC sur les serveurs, Paris pour les humains. Les calculs et les noms de fichiers se font en UTC. Si la mairie veut voir des dates de Paris dans un rapport, convertissez à l'affichage seulement : TZ=Europe/Paris printf '%(%d/%m/%Y %H:%M)T' "$instant".
  • La configuration en tête, documentée par le code. Le bloc de lignes : "${VAR:=défaut}" et : "${VAR:?message}" sert de référence à l'équipe d'exploitation. Reportez-le dans le fichier d'exemple env.exemple du dépôt et dans l'aide du script (leçon 8).
  • Les expansions dans les boucles, les programmes hors des boucles. Sur 100 000 pièces jointes, chaque $(basename ...) coûte. Une règle de revue de code : pas de substitution de commande dans une boucle quand une expansion fait le même travail.
  • Mesurer avant d'optimiser. Pour un traitement d'un fichier, la lisibilité prime : date -d est plus clair qu'un calcul de calendrier fait main. L'expansion s'impose quand la boucle est longue.
  • Quand les calculs se compliquent, changez d'outil. Des statistiques avec décimales, des pourcentages arrondis, des dates dans plusieurs fuseaux : awk pour quelques lignes, Python au-delà. Bash n'a pas été conçu pour le calcul, et la leçon 12 donne les critères pour basculer.
  • ShellCheck en intégration continue relève SC2059, SC2295, SC2004 et bien d'autres avant qu'ils n'arrivent sur sig-outils ; la leçon 12 le met en place.

Exercices

1. Découper un chemin (niveau 100). Avec f=/srv/donnees/journaux/sig-app-2/syslog.log.3.gz, obtenez par expansion de paramètres seulement : (a) syslog.log.3.gz ; (b) /srv/donnees/journaux/sig-app-2 ; (c) sig-app-2 ; (d) syslog ; (e) gz ; (f) syslog.log.3.

Solution
f=/srv/donnees/journaux/sig-app-2/syslog.log.3.gz
nom=${f##*/}          # (a) syslog.log.3.gz
rep=${f%/*}           # (b) /srv/donnees/journaux/sig-app-2
hote=${rep##*/}       # (c) sig-app-2
base=${nom%%.*}       # (d) syslog
ext=${nom##*.}        # (e) gz
sans_ext=${nom%.*}    # (f) syslog.log.3

(c) demande deux étapes : on ne peut pas imbriquer les opérateurs. On aurait aussi pu écrire hote=${f%/*}; hote=${hote##*/}. Pour (d), un seul % donnerait syslog.log.3, puisqu'il retire le plus court suffixe.

2. Défauts et obligations (niveau 100). Écrivez l'en-tête de configuration de purger-pieces-jointes : REPERTOIRE_PIECES vaut /srv/donnees/pieces-jointes par défaut ; CONSERVATION_JOURS vaut 365 par défaut ; JOURNAL_TAG est obligatoire. Puis prévoyez, sans exécuter, ce qu'affichent ces lignes, et pourquoi la troisième diffère de la deuxième :

unset a; b=
echo "[${a-x}] [${b-x}] [${b:-x}] [${a:+y}] [${b+y}]"
Solution
: "${REPERTOIRE_PIECES:=/srv/donnees/pieces-jointes}"
: "${CONSERVATION_JOURS:=365}"
: "${JOURNAL_TAG:?à définir, par exemple signalements-purge}"

La ligne affiche [x] [] [x] [] [y]. a est non définie : ${a-x} donne x. b est définie mais vide : sans deux-points, ${b-x} ne réagit qu'à « non définie » et donne la chaîne vide ; avec deux-points, ${b:-x} réagit aussi à « vide » et donne x. ${a:+y} est vide puisque a n'a pas de valeur ; ${b+y} donne y car b est définie, même vide.

3. Le mois précédent (niveau 100). On corrige le récapitulatif mensuel de Camille : il doit être rangé sous AAAA/MM du mois précédent la date jour=2026-01-15, avec le zéro en tête. Écrivez le calcul en Bash pur (sans date), en gérant janvier, puis vérifiez avec jour=2026-08-03 et jour=2026-10-08. Expliquez pourquoi $(( ${jour:5:2} - 1 )) échoue pour l'un de ces trois cas.

Solution
annee=${jour:0:4}
mois=${jour:5:2}
(( mois_prec = 10#$mois - 1, annee_prec = annee ))
(( mois_prec == 0 )) && (( mois_prec = 12, annee_prec -= 1 ))
printf -v dossier '%04d/%02d' "$annee_prec" "$mois_prec"
echo "$dossier"

Résultats : 2025/12, 2026/07, 2026/09. $(( ${jour:5:2} - 1 )) échoue pour 2026-08-03 : 08 est lu en octal, et 8 n'est pas un chiffre octal (value too great for base) ; la commande est abandonnée et le script continue avec un résultat vide, comme dans le piège du mois d'août. 10# impose la base 10. printf '%02d' remet le zéro en tête pour l'affichage. Remarquez que annee (2026) n'a pas besoin de 10# : une année ne commence pas par zéro. La version avec date, plus lisible, serait date -d "${jour:0:7}-01 -1 month" +%Y/%m : partir du premier du mois évite les approximations de -1 month sur un 31.

4. Un pourcentage signé (niveau 100). Écrivez, en arithmétique entière, l'affichage de l'écart en pour cent entre avant et apres, avec une décimale arrondie et un signe explicite : +3,5 % pour 1 241 puis 1 284, -3,3 % pour 1 284 puis 1 241, +0,0 % pour deux valeurs égales.

Solution
d=$(( (apres - avant) * 1000 ))
signe=+
(( d < 0 )) && { signe=-; d=$(( -d )); }
d=$(( (d + avant / 2) / avant ))      # dixièmes de pour cent, arrondis
printf '%s%d,%d %%\n' "$signe" $(( d / 10 )) $(( d % 10 ))

On met le signe de côté et l'on travaille sur la valeur absolue : la troncature et le reste se comportent alors comme attendu. Ajouter avant / 2 avant de diviser par avant arrondit au plus proche. 43 000 / 1 241 vaut 34,65 : arrondi à 35 dixièmes, on affiche +3,5 %. Dans l'autre sens, 43 000 / 1 284 vaut 33,49 : arrondi à 33 dixièmes, on affiche -3,3 %. Pour deux valeurs égales, d vaut 0 et le signe reste +. Pour un vrai rapport, awk avec %+.1f est plus simple, sous LC_ALL=C.UTF-8.

5. Revue de code (niveau 200). Un collègue propose cette ligne pour rapport-journaux, qui reçoit en argument le nombre de jours à analyser et supprime ensuite les rapports intermédiaires : jours=$1; (( jours > 0 )) && rm -rf "$RAPPORTS/"*. Relevez les deux défauts de sécurité, expliquez chacun par un exemple d'argument ou d'environnement, et corrigez.

Solution

Premier défaut : l'injection arithmétique. $1 arrive tel quel dans (( )). Un argument comme 'a[$(id >&2)]+1' exécute id, et n'importe quelle autre commande, avec les droits du script. Si le script est lancé par sudo ou par un service, c'est une élévation de privilèges. Second défaut : la variable vide. Si RAPPORTS n'est pas définie, la commande devient rm -rf /*.

Correction :

: "${RAPPORTS:?à définir}"
jours=${1:?nombre de jours attendu}
case $jours in
    ''|*[!0-9]*) printf 'rapport-journaux : %s n'\''est pas un entier\n' "${jours@Q}" >&2; exit 2 ;;
esac
(( 10#$jours > 0 )) && rm -rf -- "${RAPPORTS:?}/"*

Le case (détaillé en leçon 4) refuse toute valeur vide ou contenant autre chose qu'un chiffre, avant le premier usage arithmétique ; le code 2 signale une mauvaise utilisation (leçon 8). 10# neutralise un éventuel zéro en tête, ${RAPPORTS:?} est répété au plus près du rm, et -- empêche un nom de fichier commençant par - d'être pris pour une option (leçon 2).

Récapitulatif

  • Une expansion de paramètre transforme une valeur dans le processus Bash, sans lancer de programme : cent fois plus rapide qu'une substitution de commande, ce qui compte dans les boucles.
  • Valeur absente : ${v:-défaut} (utiliser), ${v:=défaut} (affecter), ${v:?message} (arrêter le script), ${v:+texte} (alternative). Avec deux-points, « vide » compte comme « absente ». : "${VAR:=...}" en tête de script documente la configuration.
  • Découper : # et ## au début, % et %% à la fin, un symbole pour le plus court, deux pour le plus long. ${f##*/} remplace basename. Une variable dans un motif se protège : ${f%"$suffixe"}.
  • Modifier : ${v//a/b}, ${v^^}, ${v,,}, ${v:début:longueur}, ${#v}. Depuis Bash 5.2, un & non protégé dans le remplacement vaut le texte trouvé.
  • Présenter : ${v@Q} pour journaliser sans ambiguïté, printf plutôt qu'echo pour toute donnée, format constant, données en arguments. printf -v et %(format)T évitent des processus.
  • Calculer : $(( )) donne un résultat, (( )) un code de sortie ; entiers 64 bits, division tronquée, pas de contrôle de dépassement. Zéro en tête = octal : 10#$v. Décimales par virgule fixe ou awk, sous LC_ALL=C.UTF-8.
  • Dates : une lecture de l'horloge par exécution ; calculs de calendrier avec date -d 'AAAA-MM-JJ -N days', jamais par soustraction de 86 400 secondes.
  • Sécurité : ${VAR:?} devant tout chemin destructeur ; valider qu'une donnée est un entier avant tout contexte arithmétique, qui peut exécuter du code ; ne jamais placer une donnée dans un format de printf.
  • Sous dash, seules les formes POSIX fonctionnent : :-, :=, :?, :+, #, ##, %, %%, ${#v}, $(( )).

Pour aller plus loin

  • La section Shell Parameter Expansion du manuel de Bash, à relire une fois cette leçon digérée : elle décrit aussi le comportement de chaque opérateur sur $@ et sur les tableaux, que la leçon 7 utilisera.
  • La FAQ n° 73 de Greg's Wiki, et sa page ArithmeticExpression, pour de nombreux exemples et les cas limites des bases.
  • Le billet de Vidar Holen sur l'arithmétique comme « eval en col blanc », court et édifiant.
  • La page strftime(3) pour tous les formats de date utilisables dans %(...)T et date +.
  • La documentation de coreutils sur les éléments relatifs des dates (Relative items in date strings), qui explique les approximations de -1 month.
  • La leçon suivante, Tests et conditions, qui apprend au script à vérifier ce qu'il reçoit : que l'export existe, qu'il n'est pas vide, qu'une date a la bonne forme.
+10 XP Carte du ciel →Mon cosmonaute →

Sources