Guillemets et expansions
Pourquoi
Un lundi, la mairie signale qu'une photo de nid-de-poule de l'an dernier est toujours consultable dans le dossier d'un signalement clos, alors que la durée de conservation inscrite au registre du RGPD est d'un an. Le même jour, un collègue cherche en vain un fichier de travail qu'il avait laissé sur sig-outils. Rien dans le journal : la purge nocturne s'est terminée avec le code 0, comme toutes les nuits.
Voici le cœur de purger-pieces-jointes, tel que Camille l'a laissé :
#!/bin/bash
REP=/srv/donnees/pieces-jointes
ANCIENS=$(find $REP -type f -mtime +365)
rm -f $ANCIENSTrois lignes, aucune faute de syntaxe, et pourtant deux défauts graves. La photo s'appelait nid de poule.jpg. Pour rm, ce nom est devenu trois arguments : /srv/donnees/pieces-jointes/2025-09/nid, de et poule.jpg. Le premier et le troisième n'existent pas, et -f fait taire rm à leur sujet. Le deuxième, de, est un chemin relatif : rm l'a cherché dans le répertoire courant du script et, comme un fichier de ce nom s'y trouvait, l'a supprimé. La pièce jointe a survécu, un fichier sans rapport a disparu, et le code de sortie annonce un succès.
Ce n'est pas un défaut de rm ni de find. C'est Bash qui a fabriqué ces arguments, en appliquant à la ligne une série de transformations que Camille n'avait pas en tête. Cette leçon décrit ces transformations dans leur ordre exact, et en tire une méthode : savoir, avant d'appuyer sur Entrée, combien d'arguments recevra une commande et ce que contiendra chacun. C'est la compétence la plus rentable de toute l'écriture de scripts : la plupart des pièges de Bash répertoriés par Greg Wooledge dans sa liste BashPitfalls (les trois premiers en tête) en découlent.
Les concepts
Un programme reçoit une liste, jamais une ligne
Quand vous tapez rm -f "nid de poule.jpg", le programme rm ne voit jamais cette ligne. Il reçoit, par l'appel système execve, un tableau de chaînes, argv (argument vector), ici de trois éléments : rm, -f et nid de poule.jpg. Chaque élément peut contenir n'importe quel octet sauf l'octet nul : des espaces, des sauts de ligne, des étoiles, des guillemets. Le programme ne sait pas comment la liste a été écrite ; il n'a aucun moyen de « recoller » deux arguments que le shell aurait séparés à tort.
Tout le travail du shell consiste à passer d'une ligne de texte à ce tableau. Le manuel de Bash le découpe en étapes :
- Lecture et découpage lexical. Bash découpe la ligne en mots et en opérateurs (
|,;,&&,>, les parenthèses...), en respectant les guillemets : une espace entre guillemets ne sépare pas deux mots. À ce stade, les guillemets sont encore présents dans le texte des mots ; ils ont seulement servi à délimiter. - Analyse. Bash reconnaît la structure : commande simple, tube, liste,
if, boucle. Pour une commande simple, il met de côté les affectations qui la précèdent (LC_ALL=C sort) et les redirections. - Expansions. Chaque mot restant subit les expansions, dans un ordre fixe (section suivante). Certaines peuvent transformer un mot en plusieurs, ou en aucun.
- Redirections, puis recherche de la commande et exécution : le premier mot devient le nom de la commande, les suivants ses arguments.
Le point à retenir : les guillemets n'existent que pour le shell. Ils lui disent quelles transformations appliquer à quelle portion de texte, puis il les retire avant de construire argv. Le programme ne les voit jamais.
L'ordre des expansions
Le manuel de Bash, dans la section Shell Expansions, fixe l'ordre suivant :
| Étape | Expansion | Exemple | Peut changer le nombre de mots ? |
|---|---|---|---|
| 1 | Accolades | fichier.{csv,gz} donne fichier.csv fichier.gz | oui |
| 2 | Tilde, paramètres et variables, arithmétique, substitution de commande, substitution de processus (de gauche à droite, dans une même passe) | ~, $REP, $((n+1)), $(date +%F), <(commande) | non, sauf "$@" et "${t[@]}" |
| 3 | Découpage en mots | le résultat non protégé de l'étape 2 est coupé selon IFS | oui |
| 4 | Chemins (globbing) | *.csv est remplacé par la liste des fichiers correspondants | oui |
| 5 | Suppression des guillemets | ", ' et \ d'origine disparaissent | non |
Le manuel le dit en une phrase : seules l'expansion des accolades, le découpage en mots et l'expansion des chemins peuvent augmenter le nombre de mots ; les autres transforment un mot en un mot, avec pour seules exceptions "$@" et "${nom[@]}".
Trois conséquences se lisent directement dans ce tableau :
- Le découpage et les motifs ne s'appliquent qu'aux résultats d'expansion. Le texte écrit en clair dans le script a déjà été découpé à l'étape 1 du paragraphe précédent, selon les espaces que vous avez tapées. L'étape 3 ne coupe que ce qui vient d'une variable, d'une substitution de commande ou d'un calcul. Si aucune expansion n'a eu lieu, aucun découpage n'est fait.
- Les guillemets doubles arrêtent les étapes 3 et 4, pas l'étape 2.
"$REP"est bien remplacé par la valeur de la variable, mais cette valeur n'est ni coupée, ni interprétée comme un motif. - Une expansion n'est jamais relue comme du code. Si une variable contient
$(rm -rf ~)ou; reboot, ces caractères arrivent à l'étape 3 comme du texte ordinaire : ils peuvent être coupés en mots ou servir de motif, mais Bash ne les exécute pas. Il n'y a qu'une passe. On verra en Sécurité que ce sont précisément les contextes où une seconde passe a lieu (eval,sh -c,ssh) qui ouvrent la porte aux injections.
La norme POSIX (section 2.6 du Shell Command Language) décrit le même ordre, sans l'étape 1 : les accolades sont une extension de Bash (et de ksh, zsh), que POSIX se contente d'autoriser comme expansion « définie par l'implémentation ». Le sh de Debian et d'Ubuntu, dash, ne les connaît pas.
Trois façons de citer, et une quatrième
Citer (quoting) un texte, c'est retirer aux caractères leur sens spécial pour le shell. Bash en offre trois mécanismes, plus une variante :
| Forme | Ce qui reste actif à l'intérieur | Usage |
|---|---|---|
\c (barre oblique inverse) | rien : le caractère suivant est pris littéralement (sauf le saut de ligne, qui devient une continuation de ligne) | un caractère isolé : \$, \ , \* |
'...' (apostrophes) | rien du tout, pas même \ : une apostrophe ne peut pas figurer entre apostrophes | texte littéral, motifs pour grep, find, awk |
"..." (guillemets doubles) | $ (variables, $( ), $(( ))), ` et \ devant $, `, ", \ ou un saut de ligne ; ! en session interactive | toute expansion de variable ou de commande |
$'...' | séquences d'échappement à la manière du C : \n, \t, \e, \', \xHH, \uHHHH | caractères de contrôle, apostrophe dans un texte littéral |
Deux remarques sur ce tableau.
Les guillemets doubles ne protègent pas $. C'est leur raison d'être : laisser le shell remplacer la variable, mais garder le résultat d'un seul tenant. À l'inverse, les apostrophes protègent tout, ce qui en fait le bon choix pour un motif destiné à un autre programme : find "$REP" -name '*.jpg' doit arriver à find avec son étoile intacte.
$'...' est la seule façon simple d'écrire une tabulation ou un saut de ligne dans un argument. cut -d $'\t' -f2 passe un vrai caractère de tabulation à cut. La forme $'...' est entrée dans la norme POSIX en 2024 (section 2.2.4, Dollar-Single-Quotes), mais dash 0.5.12, celui d'Ubuntu 24.04 et de Debian 13, ne la reconnaît pas encore : sous sh, $'a\tb' reste le texte $a\tb.
Il existe aussi $"...", qui traduit la chaîne selon la locale par gettext. Vous ne la rencontrerez guère que dans des scripts internationalisés ; ailleurs, c'est une faute de frappe pour "$...".
Les guillemets se juxtaposent : un mot peut alterner les formes, et Bash recolle le tout en un seul argument. C'est ainsi qu'on écrit une apostrophe dans un texte entre apostrophes : 'l'\''export' est formé de 'l', de \' et de 'export', et donne l'export. Plus lisible : "l'export".
Le découpage en mots et IFS
Le découpage en mots (word splitting) coupe le résultat des expansions non protégées selon les caractères de la variable spéciale IFS (Internal Field Separator). Sa valeur par défaut est espace, tabulation, saut de ligne, et la règle est alors :
- les suites d'espaces, de tabulations et de sauts de ligne en début et en fin de résultat sont ignorées ;
- toute suite de ces caractères à l'intérieur sépare deux mots ;
- un résultat vide, ou fait uniquement de blancs, disparaît : il ne produit aucun argument.
Quand IFS contient d'autres caractères, le manuel distingue les blancs d'IFS (espace, tabulation, saut de ligne, s'ils figurent dans IFS), qui se comportent comme ci-dessus, et les autres caractères, dont chaque occurrence délimite un champ. Deux deux-points consécutifs produisent donc un champ vide :
IFS=: valeur /usr/bin:/bin::/sbin donne 4 mots : /usr/bin /bin (vide) /sbinEnfin, si IFS est vide (IFS=), aucun découpage n'a lieu ; si elle n'est pas définie (unset IFS), Bash fait comme si elle avait sa valeur par défaut. Bash et dash ignorent la valeur d'IFS reçue de l'environnement à leur démarrage : un script part toujours de la valeur par défaut.
Le découpage est l'étape qui a cassé la purge de Camille. Le résultat de $(find ...) contenait des sauts de ligne entre les chemins, ce qui était voulu, mais aussi des espaces à l'intérieur des noms, ce qui ne l'était pas. Le découpage ne fait pas la différence : pour lui, tous les blancs se valent.
Note
On lit souvent qu'il suffit de régler IFS=$'\n' pour traiter une liste de fichiers. Cela résout les espaces, pas les sauts de ligne, qui sont des caractères légaux dans un nom de fichier sous Linux, et l'expansion des chemins s'applique toujours aux morceaux. La leçon 5 montre les méthodes sûres, avec des séparateurs nuls.
L'expansion des chemins
Après le découpage, Bash examine chaque mot. S'il contient un *, un ? ou un [ non protégé, c'est un motif glob (vu dans Premiers pas, leçon 3), remplacé par la liste triée des fichiers qui y correspondent. Quatre détails comptent en script :
- Un motif sans correspondance reste tel quel. Si
/srv/donnees/exportsne contient aucun.csv,gzip /srv/donnees/exports/*.csvpasse àgzipl'argument littéral/srv/donnees/exports/*.csv, qui échoue avec « No such file or directory ». Deux options deshoptchangent ce comportement :nullglobfait disparaître le motif (zéro argument),failglobfait échouer la commande avant son lancement. On les détaille dans la pratique. - Les fichiers cachés sont exclus : un nom qui commence par un point n'est désigné que par un motif qui commence par un point, sauf avec l'option
dotglob. Depuis Bash 5.2, l'optionglobskipdots, active par défaut, exclut en plus toujours.et.., même du motif.*: la ligne historiquechown -R appli .*, qui remontait au répertoire parent par.., n'a plus cet effet sous Bash 5.2. Sous dash, elle l'a toujours. - Le tri dépend de la locale. L'ordre suit
LC_COLLATE: enC.UTF-8, les majuscules passent avant les minuscules (-f B a) ; enfr_FR.UTF-8, l'ordre est celui du dictionnaire (a B -f). Un script qui compte sur l'ordre des fichiers fixe sa locale. - Le résultat d'une variable est aussi un motif. Si
$nomcontientphoto?.jpget s'emploie sans guillemets, il sera développé enphoto2.jpgsi ce fichier existe. Un nom de fichier venu de l'extérieur peut contenir des crochets ou des étoiles.
set -f (ou set -o noglob) désactive entièrement l'expansion des chemins.
"$@", "$*" et les autres
Les paramètres positionnels $1, $2... contiennent les arguments du script (la leçon 8 y revient en détail). Deux paramètres spéciaux les désignent tous, et leur comportement dépend des guillemets :
| Forme | Résultat | Quand l'utiliser |
|---|---|---|
"$@" | un mot par argument, chacun intact : équivaut à "$1" "$2" ... ; aucun mot s'il n'y a pas d'argument | transmettre les arguments à une autre commande : presque toujours |
"$*" | un seul mot : tous les arguments joints par le premier caractère d'IFS (une espace par défaut) | fabriquer un message ou une ligne de texte |
$@ ou $* sans guillemets | les arguments, puis re-découpés et développés comme motifs | jamais : un argument nid de poule.jpg redevient trois mots |
"$@" est la seule expansion entre guillemets qui produise plusieurs mots. Le même principe vaut pour les tableaux, "${t[@]}" contre "${t[*]}", sujet de la leçon 7.
Un cas limite : "$@" au milieu d'un mot. "pre$@post" avec les arguments a b et c donne prea b et cpost : le préfixe se colle au premier argument, le suffixe au dernier. C'est rarement ce que l'on veut ; si vous écrivez cela, c'est probablement "$*" que vous cherchiez.
Les contextes sans découpage
Dans quelques contextes, Bash n'applique ni le découpage ni les motifs, même sans guillemets. Le savoir évite de s'étonner qu'une ligne sans guillemets fonctionne :
- une affectation simple :
copie=$nomcopie la valeur telle quelle, espaces comprises. Le manuel le précise : le découpage en mots et l'expansion des chemins ne sont pas effectués sur la valeur affectée. C'est aussi vrai, sous Bash, des arguments en forme d'affectation des commandes de déclaration (local,declare,export,readonly) ; - l'intérieur de
[[ ... ]]et le mot qui suitcase, que la leçon 4 détaille ; - la cible d'une redirection :
> $fn'est pas découpé ; si la valeur devrait l'être (elle contient une espace), Bash refuse avecambiguous redirectplutôt que de choisir.
Greg Wooledge résume la règle pratique ainsi : « When in doubt, double-quote every expansion », dans le doute, mettez des guillemets doubles autour de chaque expansion. Les guillemets ne coûtent rien dans ces contextes-là non plus, et ils évitent d'avoir à se demander si l'on est dans l'un d'eux.
Le tilde et les accolades, propres à certains endroits
Le tilde n'est développé qu'en début de mot et hors guillemets : ~/exports devient /home/vous/exports, "~/exports" reste tel quel, et ~signalements désigne le répertoire personnel du compte signalements. Bash le développe aussi après le = ou un : d'une affectation (PATH=~/bin:$PATH) et, hors du mode POSIX, dans un argument en forme d'affectation (x=~/b), mais pas dans --sortie=~/exports, qui arrive tel quel au programme. Dans un script, préférez "$HOME", qui se comporte comme toute variable.
Les accolades génèrent du texte, que les fichiers existent ou non : {01..12} produit les douze mois sur deux chiffres, signalements-2026-10-07.csv{,.sha256} produit le fichier et son empreinte. Elles ne sont pas développées entre guillemets, ni sous dash, ni quand elles ne contiennent ni virgule ni séquence ({x} reste {x}).
En pratique
Les sorties qui suivent ont été produites avec Bash 5.2.21 et LC_ALL=C.UTF-8 (messages en anglais). Travaillez dans un répertoire d'essais, jamais dans /srv/donnees.
L'outil montrer-args
Pour voir ce que reçoit une commande, il faut un programme qui affiche ses arguments un par un, avec des délimiteurs visibles. echo n'y suffit pas : il recolle ses arguments avec une espace, si bien que echo a b et echo "a b" affichent la même chose. Créez ce petit script dans ~/bin/montrer-args ; sur Debian et Ubuntu, le ~/.profile par défaut ajoute ~/bin au PATH s'il existe à l'ouverture de la session, donc reconnectez-vous après l'avoir créé :
#!/usr/bin/env bash
# montrer-args : affiche le nombre d'arguments reçus, puis chacun entre chevrons.
printf '%d argument(s)\n' "$#"
for argument in "$@"; do
printf '<%s>\n' "$argument"
done$ mkdir -p ~/bin && chmod +x ~/bin/montrer-args
$ montrer-args "nid de poule.jpg" lampadaire.jpg
2 argument(s)
<nid de poule.jpg>
<lampadaire.jpg>
$# est le nombre d'arguments, la boucle for en parcourt la liste (la leçon 5 détaille les boucles), et printf affiche chacun entre < et >, ce qui rend visibles les espaces de début et de fin. Gardez cet outil sous la main pendant tout le cours : à chaque doute, remplacez la commande par montrer-args.
Observer le découpage
$ f='pieces-jointes/2025-09/nid de poule.jpg'
$ montrer-args $f
3 argument(s)
<pieces-jointes/2025-09/nid>
<de>
<poule.jpg>
$ montrer-args "$f"
1 argument(s)
<pieces-jointes/2025-09/nid de poule.jpg>
Le découpage ignore les blancs de bord et fusionne les blancs intérieurs, ce qui détruit aussi la mise en forme :
$ v=' deux espaces '
$ montrer-args $v
2 argument(s)
<deux>
<espaces>
$ montrer-args "$v"
1 argument(s)
< deux espaces >
Une variable vide, non protégée, ne produit aucun argument ; protégée, elle produit un argument vide. La différence compte dès qu'une commande attend un argument à une position donnée :
$ commune=
$ montrer-args --commune $commune --format csv
3 argument(s)
<--commune>
<--format>
<csv>
$ montrer-args --commune "$commune" --format csv
4 argument(s)
<--commune>
<>
<--format>
<csv>
Dans le premier cas, le programme lirait --format comme le nom de la commune. Dans le second, il reçoit une commune vide, qu'il peut refuser avec un message clair.
Changer IFS change le découpage. C'est utile pour couper une chaîne à un séparateur connu, à condition de rétablir IFS ensuite :
$ IFS=:
$ chemin='/usr/bin:/bin::/sbin'
$ montrer-args $chemin
4 argument(s)
</usr/bin>
</bin>
<>
</sbin>
$ unset IFS
Et une valeur d'IFS mal choisie produit des effets déroutants. Avec IFS=0, l'année 2026 devient deux arguments, 2 et 26. Un script qui modifie IFS globalement pour une ligne et oublie de le rétablir casse toutes les lignes suivantes de la même façon. La leçon 5 montre la forme sûre, limitée à une seule commande : IFS=: read -r ....
Comparer "$@", "$*" et $@
set -- remplace les paramètres positionnels du shell courant, ce qui permet d'essayer sans écrire de script :
$ set -- 'nid de poule.jpg' lampadaire.jpg
$ montrer-args "$@"
2 argument(s)
<nid de poule.jpg>
<lampadaire.jpg>
$ montrer-args "$*"
1 argument(s)
<nid de poule.jpg lampadaire.jpg>
$ montrer-args $@
4 argument(s)
<nid>
<de>
<poule.jpg>
<lampadaire.jpg>
$ IFS=,; montrer-args "$*"; unset IFS
1 argument(s)
<nid de poule.jpg,lampadaire.jpg>
La dernière commande montre l'usage légitime de "$*" : joindre une liste avec un séparateur choisi, ici pour fabriquer une ligne CSV. Sans arguments du tout, "$@" disparaît (zéro argument) alors que "$*" donne un argument vide :
$ set --
$ montrer-args "$@"
0 argument(s)
$ montrer-args "$*"
1 argument(s)
<>
Rejouer le bug de Camille
Reconstituez la situation dans votre répertoire d'essais : un dossier de pièces jointes, deux photos de plus d'un an dont une avec des espaces, une récente, et un répertoire de travail qui contient un fichier nommé de.
$ mkdir -p essais/pieces-jointes/2025-09 essais/travail && cd essais
$ touch -d 2025-06-01 'pieces-jointes/2025-09/nid de poule.jpg' pieces-jointes/2025-09/lampadaire.jpg
$ touch pieces-jointes/2025-09/recent.jpg travail/de
$ cd travail
$ REP=../pieces-jointes
$ ANCIENS=$(find $REP -type f -mtime +365)
$ montrer-args $ANCIENS
4 argument(s)
<../pieces-jointes/2025-09/lampadaire.jpg>
<../pieces-jointes/2025-09/nid>
<de>
<poule.jpg>
L'ordre des deux chemins peut différer chez vous : find les rend dans l'ordre du répertoire sur le disque, pas dans l'ordre alphabétique. Le diagnostic est déjà complet, avant toute suppression : rm recevra de, chemin relatif. Exécutons maintenant la ligne fautive :
$ rm -f $ANCIENS
$ echo "code=$?"
code=0
$ ls
$ ls ../pieces-jointes/2025-09
nid de poule.jpg recent.jpg
Le fichier de du répertoire de travail a disparu, la photo est toujours là, et le code de sortie est 0. Sous cron ou sous un minuteur systemd, le répertoire courant du script est le répertoire personnel du compte ou / : rm y aurait cherché de, poule.jpg et tout autre fragment de nom.
Remarquez que mettre des guillemets à "$ANCIENS" ne règle rien : rm recevrait un seul argument contenant deux chemins séparés par un saut de ligne, qui ne désigne aucun fichier. Le problème de fond est d'avoir transformé une liste de noms en une chaîne. Une fois la liste aplatie, aucune règle de découpage ne sait la reconstituer, puisque les noms peuvent contenir n'importe quel séparateur.
Voir les expansions avec set -x
L'option xtrace (set -x, ou bash -x script) affiche chaque commande après expansion, précédée de +, avant de l'exécuter. Bash y entoure d'apostrophes les arguments qui contiennent des caractères spéciaux, ce qui rend le découpage visible :
$ bash -xc 'f="pieces-jointes/2025-09/nid de poule.jpg"; ls -l $f; ls -l "$f"'
+ f='pieces-jointes/2025-09/nid de poule.jpg'
+ ls -l pieces-jointes/2025-09/nid de poule.jpg
ls: cannot access 'pieces-jointes/2025-09/nid': No such file or directory
ls: cannot access 'de': No such file or directory
ls: cannot access 'poule.jpg': No such file or directory
+ ls -l 'pieces-jointes/2025-09/nid de poule.jpg'
Sur la deuxième ligne de trace, aucune apostrophe : ls a reçu trois arguments après -l, comme le confirment les trois messages d'erreur. Sur la dernière, un seul argument, entre apostrophes parce qu'il contient des espaces. La leçon 12 fait de set -x un véritable outil de débogage.
Les noms qui commencent par un tiret
Un programme distingue ses options de ses opérandes par le tiret initial. Or un nom de fichier peut commencer par un tiret, et l'expansion des chemins le place alors parmi les options :
$ mkdir tirets && cd tirets && touch -- -f B a
$ montrer-args *
3 argument(s)
<-f>
<B>
<a>
rm * exécuterait donc rm -f B a : B et a seraient supprimés, et -f resterait, lu comme l'option « forcer ». Avec un fichier nommé -rf dans un répertoire qui contient des sous-répertoires, l'effet devient franchement destructeur. ShellCheck signale ce motif par son avertissement SC2035 : « Use ./*glob* or -- *glob* so names with dashes won't become options. » Deux parades :
$ montrer-args -- *
4 argument(s)
<-->
<-f>
<B>
<a>
$ montrer-args ./*
3 argument(s)
<./-f>
<./B>
<./a>
--marque, par convention, la fin des options : tout ce qui suit est un opérande, même avec un tiret.rm -- *,cp -- "$source" "$destination". La quasi-totalité des outils GNU respectent cette convention, mais pas tous les programmes ;echone la connaît pas../*préfixe chaque nom par./, si bien qu'aucun ne commence par un tiret. Cela marche avec tous les programmes, mais change le nom transmis, ce qui gêne par exempletar, qui archiverait./Bau lieu deB.
Un chemin absolu ne commence jamais par un tiret : les chemins que construit un script à partir de "$REP" sont protégés par construction, ceux qui viennent de l'utilisateur ou d'un motif relatif ne le sont pas.
Les motifs sans correspondance
$ cd ~/essais
$ montrer-args pieces-jointes/*.txt
1 argument(s)
<pieces-jointes/*.txt>
$ shopt -s nullglob
$ montrer-args pieces-jointes/*.txt
0 argument(s)
$ shopt -u nullglob
$ shopt -s failglob
$ montrer-args pieces-jointes/*.txt
bash: no match: pieces-jointes/*.txt
$ echo "code=$?"
code=1
$ shopt -u failglob
Aucune des trois conduites n'est bonne partout :
- Par défaut, le motif littéral est passé à la commande, qui échoue d'habitude avec un message clair (« No such file or directory »). C'est le moins mauvais choix pour une commande qui exige au moins un fichier.
nullglobconvient à une boucle sur des fichiers (for f in *.csv) : aucun fichier, aucun tour de boucle. Mais il est dangereux pour une commande qui, sans opérande, agit sur autre chose :ls *.csvavecnullglobet aucun CSV devientls, qui liste tout le répertoire ;grep motif *.logsans fichier se met à lire l'entrée standard et attend indéfiniment.failglobabandonne la commande, et même le reste de la ligne en cours ; dans un script, l'exécution continue à la ligne suivante, avec$?à 1. C'est le choix le plus prudent pour un script, à condition de traiter cet échec.
shopt -s active une option, shopt -u la désactive, shopt nom affiche son état. Ces options sont propres à Bash : dash n'en a aucune.
Citer pour un autre shell : printf '%q'
Il arrive qu'une chaîne doive être relue par un shell : la commande passée à ssh, qui est exécutée par le shell de la machine distante ; celle de sh -c ; une ligne qu'un script écrit dans son journal pour qu'on puisse la rejouer à la main. Le format %q de la commande interne printf produit une version citée, que Bash relira comme la valeur d'origine :
$ printf '%q\n' 'nid de poule.jpg' "l'export" $'ligne1\nligne2' '*'
nid\ de\ poule.jpg
l\'export
$'ligne1\nligne2'
\*
C'est l'outil pour fabriquer une commande distante sans risque :
fichier="/srv/donnees/pieces-jointes/2025-09/nid de poule.jpg"
ssh sig-app-1 "ls -l -- $(printf '%q' "$fichier")"Une limite : %q produit de la syntaxe Bash. La forme $'...' qu'il emploie pour les caractères de contrôle n'est pas comprise par dash 0.5.12 ; si le shell distant n'est pas Bash, évitez ces caractères ou passez la valeur autrement (sur l'entrée standard, par exemple).
purger-pieces-jointes, version 1
La correction de fond consiste à ne jamais aplatir la liste. find sait lui-même supprimer ce qu'il trouve : les noms passent alors du noyau à find, puis à l'appel système unlink, sans jamais traverser un découpage en mots.
#!/usr/bin/env bash
# purger-pieces-jointes : supprime les pièces jointes plus anciennes que la durée de conservation.
# Version 1 : aucune liste de noms ne transite par le shell.
repertoire="/srv/donnees/pieces-jointes"
jours="365"
find "$repertoire" -type f -mtime "+$jours" -print -deleteChaque partie de la ligne a sa raison d'être :
"$repertoire"et"+$jours": entre guillemets, par principe, même si les valeurs actuelles n'ont pas d'espace. La valeur changera peut-être un jour, ou viendra d'un fichier de configuration.-type f: seulement les fichiers ordinaires, pas les répertoires vidés.-mtime +365: la pagefind(1)précise que la partie fractionnaire de l'âge est ignorée, et donne l'exemple de-atime +1, qui exige au moins deux jours ; ici, un fichier est retenu à partir de 366 jours pleins. La durée inscrite au registre est un minimum à ne pas dépasser : vérifiez que cette journée de marge est acceptable, ou écrivez+364.-print -delete:findévalue son expression de gauche à droite pour chaque fichier ; les tests d'abord, puis l'affichage du chemin, puis la suppression. La page de manuel met en garde : placer-deleteen premier ferait tout supprimer sous le point de départ.- La sortie de
-print(un chemin par ligne) part dans le journal du service quand le script tourne sous systemd : c'est la trace de ce qui a été supprimé.
Si une autre commande doit traiter les fichiers (les archiver avant suppression, par exemple), find sait aussi la lancer en lui passant les noms comme arguments distincts, sans découpage : find "$repertoire" -type f -mtime "+$jours" -exec rm -f -- {} +. Le + final regroupe autant de noms que possible par appel ; {} y est remplacé par les chemins, chacun comme un argument entier.
Il manque encore à ce script un mode d'essai (--dry-run, leçon 8), un traitement des erreurs (leçon 9) et la possibilité de faire autre chose de chaque fichier qu'une suppression, sans retomber dans le piège (leçon 5). Mais il ne supprimera plus jamais le mauvais fichier.
Sous le capot
Ce que voit le noyau
L'outil strace montre l'appel execve que fait Bash, et donc le tableau argv exact. On compare une variable non protégée, la même entre guillemets, et un motif, dans un répertoire qui ne contient qu'un fichier .txt, notes.txt :
$ strace -f -e trace=execve -s 80 \
bash -c 'f="nid de poule.jpg"; /bin/true $f "$f" *.txt' 2>&1 | grep true
La ligne utile, l'adresse et l'environnement abrégés :
execve("/bin/true", ["/bin/true", "nid", "de", "poule.jpg", "nid de poule.jpg", "notes.txt"], …) = 0$f a donné trois éléments, "$f" un seul, et *.txt le nom du seul fichier .txt du répertoire. Aucun guillemet ne figure dans le tableau : ils ont été retirés par la dernière étape. C'est ce tableau, et lui seul, que le programme reçoit.
Les étapes dans le code de Bash
Dans le code source de Bash 5.2, les expansions d'une commande simple passent par la fonction expand_word_list_internal du fichier subst.c. Son commentaire d'en-tête énumère ce qu'elle fait (« brace expansion, tilde expansion, parameter expansion, command substitution, arithmetic expansion, process substitution, word splitting, and pathname expansion ») et son corps suit l'ordre du manuel :
separate_out_assignmentsmet de côté les affectations placées devant la commande ;brace_expand_word_listapplique les accolades ;shell_expand_word_listfait, mot par mot, le tilde, les paramètres, l'arithmétique et les substitutions, puis appelle le découpage (word_list_split) uniquement pour les mots où une expansion a eu lieu. Un commentaire le dit sans détour : s'il n'y a eu ni expansion de paramètre, ni substitution de commande ou de processus, ni calcul, on ne découpe pas. Un autre précise que les mots marquésW_NOSPLITne sont jamais découpés : c'est ainsi que sont traitées les affectations ;glob_expand_word_listdéveloppe les motifs, et retire au passage les guillemets des mots qui n'en sont pas ; si l'expansion des chemins est désactivée (set -f), c'estdequote_listqui retire les guillemets.
Comment Bash sait-il, à l'étape 3, qu'une espace provient d'une partie protégée ? Pendant l'expansion, il marque les caractères issus d'une zone entre guillemets par un octet de contrôle interne, CTLESC, et la fonction qui découpe selon IFS respecte ces marques : son commentaire, dans subst.c, précise qu'elle obéit à la protection par CTLESC (« Obeys CTLESC quoting. Used to do splitting on $IFS »). Ces marques disparaissent avec la suppression des guillemets. C'est pourquoi une valeur protégée reste d'un seul tenant même si elle traverse plusieurs étapes, et pourquoi l'on ne peut pas « stocker des guillemets » dans une variable pour qu'ils agissent plus tard : des guillemets dans une valeur sont du texte, pas des marques.
Ce dernier point mérite une démonstration, parce que c'est une erreur classique : vouloir construire une ligne de commande dans une chaîne.
$ options='--nom "nid de poule.jpg"'
$ montrer-args $options
4 argument(s)
<--nom>
<"nid>
<de>
<poule.jpg">
Les guillemets contenus dans la valeur ne sont pas relus comme des guillemets : ce sont des caractères comme les autres, et le découpage coupe à chaque espace. La bonne structure pour une liste d'arguments est un tableau, sujet de la leçon 7 ; la mauvaise solution, eval, relit la chaîne comme du code, avec tous les risques décrits ci-dessous.
dash, ou le shell sans extensions
Sur Debian et Ubuntu, /bin/sh est dash, un shell POSIX minimal. C'est lui qui exécute un script lancé par sh script, les lignes de cron et system() en C ou en Python. Les différences qui touchent cette leçon :
$ dash -c 'echo fichier.{csv,gz}'
fichier.{csv,gz}
$ dash -c "printf '<%s>\n' \$'a\tb'"
<$a\tb>
$ cd tirets && touch .cache && dash -c 'echo .*'
. .. .cache
Pas d'accolades, pas de $'...', et .* désigne aussi . et ... Le découpage en mots, les motifs, "$@" et "$*" se comportent en revanche comme dans Bash : ce sont les règles POSIX. La leçon 1 explique comment s'assurer qu'un script est bien lancé par Bash.
Pièges courants
Une variable sans guillemets dans une commande. rm -f $fichier, cp $source $destination, cd $(dirname $f) : les trois premiers pièges de BashPitfalls. ShellCheck les signale par SC2086, « Double quote to prevent globbing and word splitting ». Correction : rm -f -- "$fichier", cd -- "$(dirname -- "$f")". Les guillemets intérieurs d'une substitution de commande sont indépendants des guillemets extérieurs : "$(dirname "$f")" est correct et nécessaire aux deux niveaux.
$@ sans guillemets. Il redécoupe les arguments que l'appelant avait pris soin de protéger. ShellCheck : SC2068, « Double quote array expansions to avoid re-splitting elements. »
Une liste de fichiers dans une chaîne. fichiers=$(ls *.csv) puis gzip $fichiers : la liste est aplatie, elle ne se reconstitue pas. Laissez le motif faire le travail (gzip -- *.csv), utilisez find -exec ou -delete, un tableau, ou une boucle sur des noms séparés par des octets nuls (leçon 5).
ambiguous redirect. gzip -c "$f" > $f.gz avec un nom contenant une espace échoue par bash: $f.gz: ambiguous redirect. Le message cite le texte d'origine, pas sa valeur. Protégez la cible : > "$f.gz".
Un nom de variable collé à du texte. "$commune_csv" désigne la variable commune_csv, pas $commune suivie de _csv ; si elle n'existe pas, le résultat est vide, sans erreur. Écrivez "${commune}_csv". Les accolades délimitent le nom.
echo et les valeurs qui commencent par un tiret. echo "$v" avec v=-n n'affiche rien : Bash lit -n comme l'option « pas de saut de ligne final ». Pour afficher une valeur quelconque, utilisez printf '%s\n' "$v" ; la leçon 3 explique pourquoi printf est préférable à echo en script.
Les apostrophes autour d'une variable. grep '$motif' fichier cherche littéralement le texte $motif. Pour combiner un texte littéral et une variable : grep "^${prefixe}-[0-9]" fichier, ou en juxtaposant '^'"$prefixe"'-[0-9]'.
Le ~ entre guillemets. cd "~/exports" cherche un répertoire nommé ~ dans le répertoire courant. Utilisez "$HOME/exports".
Un motif qui compte sur l'ordre. ls signalements-*.csv | tail -n 1 pour « le dernier export » dépend de la locale et du format des dates dans les noms. Avec des dates ISO (AAAA-MM-JJ) et LC_ALL=C, l'ordre alphabétique est chronologique ; sinon, il ne l'est pas.
IFS modifié et oublié. Une affectation IFS=, au milieu d'un script s'applique à toutes les expansions suivantes. Limitez la portée à une commande (IFS=, read -r ...) ou à une fonction (local IFS).
Sécurité
Un nom de fichier est une donnée venue de l'extérieur. Les pièces jointes de Signalements sont envoyées par des habitants depuis leur téléphone. Si l'API conserve le nom d'origine, il peut contenir des espaces, des sauts de ligne, des étoiles, un tiret initial, voire du texte qui ressemble à du code. David A. Wheeler, dans son essai Fixing Unix/Linux/POSIX Filenames, recense ces bords tranchants : sous Linux, un nom peut contenir tous les octets sauf / et l'octet nul. Un script qui manipule ces noms doit les traiter comme n'importe quelle saisie hostile : toujours entre guillemets, toujours derrière -- ou un chemin absolu. Côté application, la meilleure défense est en amont : stocker les fichiers sous un nom généré (un identifiant aléatoire) et garder le nom d'origine en base de données.
L'injection d'arguments. Faire passer une donnée pour une option (-rf, --output=/etc/passwd, -oProxyCommand=... pour ssh) est une famille d'attaques à part entière, répertoriée par MITRE sous le numéro CWE-88. La défense est mécanique : -- avant les opérandes, et validation des valeurs qui ne doivent pas commencer par un tiret.
Une expansion n'est pas relue, sauf si vous la faites relire. On l'a vu : $f qui contient $(touch PIRATE) n'exécute rien. Démonstration, avec un fichier au nom piégé :
$ touch 'photo$(touch PIRATE).jpg'
$ f=$(ls)
$ montrer-args "$f"
1 argument(s)
<photo$(touch PIRATE).jpg>
$ ls
photo$(touch PIRATE).jpg
Aucun fichier PIRATE : la substitution n'a pas été exécutée. Mais dès qu'un second shell relit la chaîne, elle redevient du code :
$ find . -name '*.jpg' -exec sh -c 'echo traitement {}' \;
traitement ./photo.jpg
$ ls
PIRATE photo$(touch PIRATE).jpg
find a remplacé {} par le nom à l'intérieur du texte du script passé à sh -c (GNU find le fait même quand {} n'est qu'une partie d'un argument, ce que POSIX laisse à l'appréciation de chaque implémentation) ; sh a relu ce texte, exécuté touch PIRATE, puis affiché ce qui restait. Un nom de fichier a exécuté une commande, avec les droits du compte qui lançait la purge. La forme sûre passe le nom comme argument du petit script, jamais dans son texte :
$ find . -name '*.jpg' -exec sh -c 'echo traitement "$1"' sh {} \;
traitement ./photo$(touch PIRATE).jpg
Le premier argument après le script (sh) devient $0, le nom devient $1, et "$1" est une expansion ordinaire, non relue. Les contextes de relecture à surveiller sont toujours les mêmes : eval, sh -c et bash -c avec une chaîne construite, la commande passée à ssh (relue par le shell distant), su -c, sudo sh -c, les lignes générées puis exécutées. Dans chacun, n'insérez une valeur qu'après printf '%q', ou mieux, passez-la comme argument ou sur l'entrée standard. C'est la même famille de failles que l'injection de script dans les workflows GitHub Actions, où ${{ }} insère du texte dans un script avant que Bash ne le lise.
Les motifs dans les données. Une variable non protégée qui contient * est développée en liste de fichiers. Un script qui fait rm -f $REP/$nom avec un $nom venu d'une requête, et un attaquant qui envoie *, vide le répertoire. Les guillemets désactivent cette expansion ; une validation du format attendu (la leçon 4 montre comment) la complète.
set -x affiche les valeurs. La trace montre chaque argument après expansion, y compris un mot de passe passé en argument ou une variable qui contient un jeton. Ne laissez pas set -x dans un script qui manipule des secrets, ou coupez la trace autour des lignes sensibles (leçon 12).
En production
- ShellCheck en intégration continue. SC2086, SC2068 et SC2035 attrapent l'essentiel des erreurs de cette leçon avant qu'elles n'atteignent un serveur. Dans
signalements-outils,make verifierlance ShellCheck sur tous les scripts, et la CI refuse une modification qui introduit un avertissement (leçon 12). Une exception à SC2086, quand le découpage est réellement voulu, se justifie par un commentaire sur la ligne. - Une locale fixée. L'ordre des motifs, le sens de
[a-z]et le texte des messages dépendent de la locale. Les scripts designalements-outilsfixentLC_ALL=C.UTF-8(ou le reçoivent de l'unité systemd) pour que leur comportement ne dépende pas de la session qui les lance. - Tester avec des noms hostiles. Les jeux d'essai des tests (leçon 12) contiennent systématiquement un nom avec espace, un nom qui commence par un tiret, un nom avec un saut de ligne et un nom avec une étoile. Un script qui passe ces quatre cas est à l'abri de presque tout ce que décrit cette leçon.
- Préférer les outils qui ne passent pas par le shell.
find -delete,find -exec ... {} +,xargs -0, les options de type--files-fromqui lisent des listes séparées par des octets nuls : chaque fois qu'un nom peut aller d'un programme à l'autre sans être découpé par le shell, c'est une classe d'erreurs en moins. - Penser au shell qui exécutera vraiment la ligne. Une ligne de
cron, unRUNde Dockerfile en forme « shell », unscript:de pipeline : chacun a son interpréteur, souvent/bin/sh. Les accolades et$'...'n'y fonctionnent pas sous Debian et Ubuntu.
Exercices
1. Compter les arguments (niveau 100). Sans les exécuter, donnez le nombre d'arguments reçus par montrer-args dans chaque cas, puis vérifiez. On a défini a='un deux', b= et c='*', dans un répertoire qui contient exactement trois fichiers.
(a) montrer-args $a $b ; (b) montrer-args "$a" "$b" ; (c) montrer-args "$a$b" ; (d) montrer-args $c ; (e) montrer-args "$c" ; (f) montrer-args '$a' ; (g) montrer-args export.{csv,sha256} ; (h) montrer-args "export.{csv,sha256}".
Solution
(a) 2 : un et deux ; $b vide et non protégé disparaît. (b) 2 : un deux et un argument vide. (c) 1 : un deux. (d) 3 : le motif *, issu de la variable, est développé en trois noms de fichiers. (e) 1 : l'étoile littérale. (f) 1 : le texte $a, non développé. (g) 2 : export.csv et export.sha256. (h) 1 : les accolades ne sont pas développées entre guillemets.
2. Corriger une copie (niveau 100). Ce fragment doit copier l'export du jour vers un répertoire d'archive dont le nom vient d'une variable :
archive=/srv/donnees/archives/mairie 2026
fichier=/srv/donnees/exports/signalements-2026-10-07.csv
cp $fichier $archiveIl échoue avec 2026: command not found. Expliquez ce message, puis corrigez le fragment pour qu'il fonctionne avec n'importe quels noms.
Solution
La première ligne n'est pas une affectation d'une valeur avec espace : archive=/srv/donnees/archives/mairie est une affectation placée devant une commande, et 2026 est le nom de la commande à lancer avec cette variable dans son environnement. Bash ne trouve pas de commande 2026. Il faut citer la valeur, puis protéger les expansions et marquer la fin des options :
archive="/srv/donnees/archives/mairie 2026"
fichier="/srv/donnees/exports/signalements-2026-10-07.csv"
cp -- "$fichier" "$archive/"Le / final de "$archive/" fait échouer cp si le répertoire n'existe pas, au lieu de créer un fichier nommé mairie 2026.
3. Le motif qui ne trouve rien (niveau 100). Dans un script, gzip /srv/donnees/exports/*.csv affiche parfois gzip: /srv/donnees/exports/*.csv: No such file or directory. Dans quel cas ? Que se passerait-il avec shopt -s nullglob, et avec shopt -s failglob ? Laquelle de ces conduites préférez-vous ici, et pourquoi ?
Solution
Le message apparaît quand aucun .csv n'est présent : le motif sans correspondance est passé tel quel à gzip, qui ne trouve pas de fichier de ce nom. Avec nullglob, le motif disparaît et la commande devient gzip sans argument : gzip compresse alors son entrée standard vers sa sortie standard. Dans un terminal, il refuse d'écrire des données compressées à l'écran ; sous un minuteur systemd, dont l'entrée standard est /dev/null, il produit sans erreur une archive vide sur la sortie. Rien n'a été compressé, et rien ne le signale. Avec failglob, la commande n'est pas lancée, Bash affiche no match et $? vaut 1. Pour une commande qui exige au moins un fichier, le comportement par défaut ou failglob conviennent ; nullglob est à réserver aux boucles. Dans tous les cas, le script doit décider quoi faire de l'absence de fichiers (est-ce normal ? une erreur ?), ce que permettra la leçon 4.
4. Une commande distante (niveau 200). Le script deployer doit lancer sur sig-app-1 la commande ls -l -- <chemin>, où le chemin est dans la variable version, par exemple /opt/signalements/versions/1.4.0 (correctif). Un collègue propose ssh sig-app-1 "ls -l -- $version". Montrez ce que recevra ls sur la machine distante, proposez une version correcte et dites ce qu'il advient si version vaut x; rm -rf ~.
Solution
Les guillemets doubles sont consommés par le shell local, qui transmet à ssh le texte ls -l -- /opt/signalements/versions/1.4.0 (correctif). ssh le fait exécuter par le shell distant, qui le relit : les parenthèses sont des opérateurs, et la ligne échoue avec une erreur de syntaxe (syntax error near unexpected token suivi de la parenthèse). Avec x; rm -rf ~, le shell distant exécuterait ls -l -- x puis rm -rf ~ : c'est une injection de commande. Version correcte :
ssh sig-app-1 "ls -l -- $(printf '%q' "$version")"printf '%q' produit /opt/signalements/versions/1.4.0\ \(correctif\), que le shell distant relit comme un seul argument littéral ; la valeur malveillante deviendrait x\;\ rm\ -rf\ ~, un nom de fichier inoffensif (le ~ n'est pas en début de mot, il n'est donc pas développé). Le compte distant doit avoir Bash comme shell de connexion si la valeur peut contenir des caractères de contrôle. Mieux encore, validez le format attendu de version (des chiffres et des points) avant tout usage.
Récapitulatif
- Un programme reçoit un tableau d'arguments (
argv), jamais une ligne. Le shell fabrique ce tableau ; les guillemets ne servent qu'à lui, et disparaissent avant l'exécution. - Ordre des expansions : accolades ; tilde, paramètres, arithmétique, substitutions (de gauche à droite) ; découpage en mots ; chemins ; suppression des guillemets. Seuls les accolades, le découpage et les chemins changent le nombre de mots (plus
"$@"). - Le découpage et les motifs ne s'appliquent qu'aux résultats d'expansion non protégés. Les guillemets doubles les empêchent tout en laissant agir
$. Les apostrophes protègent tout ;$'...'écrit tabulations et sauts de ligne. IFSrègle le découpage : blancs fusionnés et ignorés aux bords, autres séparateurs qui délimitent chacun un champ, éventuellement vide."$@"transmet les arguments intacts ;"$*"les joint en une chaîne ;$@et$*sans guillemets les abîment.- Un motif sans correspondance reste littéral, disparaît avec
nullglob, fait échouer la commande avecfailglob. Le tri suit la locale ;globskipdots(Bash 5.2) écarte.et... - Noms commençant par un tiret :
--ou./*. - Une expansion n'est jamais relue comme du code, sauf par un second shell (
eval,sh -c,ssh,find -exec sh -c '{}') : y passer les valeurs comme arguments, ou les citer avecprintf '%q'. - Règle de conduite : des guillemets doubles autour de chaque expansion,
montrer-argsetset -xpour vérifier, ShellCheck pour ne rien oublier.
Pour aller plus loin
- La section Shell Expansions du manuel de Bash, à lire d'une traite avec cette leçon en tête : chaque sous-section y décrit une étape, et Word Splitting tient en un paragraphe dense qui mérite plusieurs lectures.
- Les pages Quotes, WordSplitting et Arguments du wiki de Greg Wooledge, et la liste BashPitfalls, dont une bonne moitié se ramène à cette leçon.
- La section 2.6 Word Expansions de la norme POSIX 2024, pour savoir ce qui est garanti sous n'importe quel
sh. - L'essai de David A. Wheeler, Fixing Unix/Linux/POSIX Filenames, pour mesurer tout ce qu'un nom de fichier peut contenir, et les propositions pour y remédier.
- La leçon suivante, Paramètres, chaînes et calculs, qui détaille l'étape 2 : tout ce que Bash sait faire d'une variable avant de la remplacer.
Sources
- GNU Bash Reference Manual : Shell Expansions (ordre des expansions, Word Splitting, Filename Expansion, Quote Removal)
- GNU Bash Reference Manual : Quoting, ANSI-C Quoting
- GNU Bash Reference Manual : Special Parameters ($@ et $*)
- Bash, fichier NEWS de la version 5.2 (option globskipdots)
- Bash, code source 5.2 : subst.c (expand_word_list_internal, shell_expand_word_list)
- POSIX.1-2024, Shell Command Language : 2.2 Quoting (2.2.4 Dollar-Single-Quotes) et 2.6 Word Expansions
- Debian, page de manuel dash(1)
- Debian, page de manuel find(1), findutils 4.10
- Greg's Wiki : Quotes
- Greg's Wiki : WordSplitting
- Greg's Wiki : BashPitfalls (n° 1 à 3)
- ShellCheck : SC2086, SC2068 et SC2035
- MITRE, CWE-88 : Improper Neutralization of Argument Delimiters in a Command (Argument Injection)
- David A. Wheeler, Fixing Unix/Linux/POSIX Filenames (mis à jour en 2024)