Paramètres, chaînes et calculs
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 -deleteCe 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 variablePRECEDENTvide : le fichier était parti sous2026//recapitulatif.csv, où personne ne le cherchait. Le 1er septembre, même chose. (Et les autres mois, le récapitulatif partait sous2026/1/,2026/2/... au lieu de2026/01/, et le 1er janvier sous2026/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 verss3:///2026/10/...,awsa 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 :
| Famille | Opérateurs | Question à 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 :
| Expression | var définie, non vide | var vide | var non définie |
|---|---|---|---|
${var:-mot} | valeur de var | mot | mot |
${var-mot} | valeur de var | vide | mot |
${var:=mot} | valeur de var | var reçoit mot | var reçoit mot |
${var=mot} | valeur de var | vide | var reçoit mot |
${var:?message} | valeur de var | erreur, sortie | erreur, sortie |
${var?message} | valeur de var | vide | erreur, sortie |
${var:+mot} | mot | vide | vide |
${var+mot} | mot | mot | vide |
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 modifiervar. 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}: sivarest vide ou non définie, écriremessagesur 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}: produiretexteseulement sivara 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 : *, ?, [...]) :
| Expression | Retire | Exemple 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 :
| Expression | Effet |
|---|---|
${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ébutetlongueursont des expressions arithmétiques. Undébutné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érateur | Résultat | Usage |
|---|---|---|
@Q | la valeur entre guillemets, réutilisable comme entrée du shell | journaliser une valeur sans ambiguïté (espaces, sauts de ligne) |
@A | une affectation (ou un declare) qui recrée la variable | sauvegarder un état, déboguer |
@a | les attributs de la variable (r lecture seule, x exportée, i entier...) | vérifier qu'une constante est bien en lecture seule |
@U, @u, @L | majuscules, première lettre en majuscule, minuscules (Bash 5.1) | équivalents lisibles de ^^, ^ et ,, |
@E | interprè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) :
| Commande | Attribut | Effet |
|---|---|---|
readonly V=1 ou declare -r V=1 | lecture seule | toute affectation ultérieure est une erreur (V: readonly variable), et le reste de la ligne en cours est abandonné |
export V ou declare -x V | exportée | transmise aux processus enfants |
declare -i n | entier | toute affectation est évaluée comme une expression arithmétique |
declare -l v, declare -u v | casse | la 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
-nou-e,echola prend pour une option et ne l'affiche pas ; - le traitement des barres obliques inverses (
\t,\n) varie selon le shell et ses options :echode 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écification | Affiche | Exemple |
|---|---|---|
%s | une chaîne, telle quelle | printf '%s\n' "$v" |
%d | un entier en décimal | printf '%d\n' 42 |
%5d, %05d, %-8s | largeur minimale, remplissage par des zéros, alignement à gauche | printf '%05d' 42 donne 00042 |
%x, %o | hexadécimal, octal | printf '%x' 255 donne ff |
%.1f | un nombre à virgule, une décimale | dépend de la locale (voir plus bas) |
%q | la valeur protégée pour le shell | leçon 2 |
%b | une chaîne dont on interprète les \n, \t | rare |
%(format)T | une date, selon un format strftime(3) | printf '%(%F)T' -1 donne la date du jour |
%% | un signe % littéral | printf '%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 caffiche trois lignes. C'est une boucle gratuite ; printf -v var format...écrit le résultat dans la variablevarau lieu de l'afficher, sans sous-shell ;%(format)T(Bash 4.1 et suivants) formate une date sans lancerdate: l'argument est un nombre de secondes depuis le 1er janvier 1970 en UTC (le temps Unix),-1désigne l'heure actuelle. Le fuseau utilisé est celui de la variableTZ, 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 ))affiche3.(( 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, donclet n=n*2peut 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 :
- La division tronque vers zéro :
7 / 2vaut 3,-7 / 2vaut -3. - Aucun contrôle de dépassement : le manuel le dit, l'évaluation se fait en entiers de taille fixe sans vérification.
2**63donne-9223372036854775808. Seule la division par zéro est détectée. - Un nombre qui commence par 0 est lu en octal (base 8), comme en C :
010vaut 8, et08ou09sont des erreurs, puisque 8 et 9 ne sont pas des chiffres octaux.0xintroduit l'hexadécimal, etbase#nombreforce une base :10#08vaut 8,2#1010vaut 10. - La valeur d'une variable est elle-même évaluée comme une expression : si
ncontient1+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 dash | Propre à 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 variablejourau lieu d'afficher ; aucun processus n'est créé, contrairement àjour=$(date +%F).%(%Y-%m-%d)Tapplique le formatstrftime;%Fen est l'abréviation (%(%F)Tdonne 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 écrives3://sig-exports-mairieous3://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##*/}remplacebasename "$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épond2026-03-31 15:00:00en heure de Paris (13 h en UTC), et non le 29 :datelit12:00 -1comme « midi, fuseau UTC moins une heure », puisdaycomme « plus un jour ». Écrivez la date seule, ou1 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'ailleursTZ=UTC0pour 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" >&2Ce 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 ; DESTINATIONest obligatoire : le script s'arrête avec un message clair au lieu de visers3:///;- plus de calcul sur
08, et10#protège la seule valeur numérique configurable ; basenamedisparaît,daten'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 -cdans 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"...], …) = 0La 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érations | Durée réelle |
|---|---|
b=${f##*/} | 0,009 s |
b=$(basename "$f") | 1,83 s |
printf -v j '%(%F)T' -1 | 0,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'exempleenv.exempledu 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 -dest 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##*/}remplacebasename. 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é,printfplutôt qu'echopour toute donnée, format constant, données en arguments.printf -vet%(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, sousLC_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 deprintf. - 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%(...)Tetdate +. - 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.
Sources
- GNU Bash Reference Manual, 3.5.3 Shell Parameter Expansion
- GNU Bash Reference Manual, 6.5 Shell Arithmetic, et page de manuel bash(1), ARITHMETIC EVALUATION, printf, shopt
- GNU Bash Reference Manual, 6.11.2 Bash POSIX Mode (arrêt sur erreur arithmétique et sur affectation refusée)
- POSIX.1-2024, Shell Command Language, 2.6.2 Parameter Expansion et 2.6.4 Arithmetic Expansion
- POSIX.1-2024, utilitaire echo, sections Application Usage et Rationale
- Bash, fichier NEWS (patsub_replacement en 5.2, transformations U, u, L et K en 5.1, EPOCHSECONDS en 5.0, %(...)T en 4.1)
- Greg's Wiki, BashFAQ/073 : manipuler des chaînes par l'expansion de paramètres
- Greg's Wiki, ArithmeticExpression
- ShellCheck, SC2059 : variables dans le format de printf
- ShellCheck, SC2295 : expansions à protéger à l'intérieur de ${..}
- ShellCheck, SC2004 : $ inutile dans une expression arithmétique
- Vidar Holen, « Bash's white collar eval: [[ $var -eq 42 ]] runs arbitrary code too » (2018)
- GNU Coreutils, Relative items in date strings
- The Register, « Scary code of the week: Valve cleans Linux PCs » (17 janvier 2015)
- Valve, steam-for-linux, ticket n° 3671
- Linux man-pages, strftime(3)