Aller au contenu
Boucles et lecture de données

Boucles et lecture de données

200 Compagnon ⏱ 1 h 40 bashlinuxdebianubuntu

À la fin, vous saurez

  • Choisir la bonne boucle (for, while, until) selon que l'on parcourt une liste connue, un flux de données ou une attente
  • Lire un fichier ou la sortie d'une commande ligne à ligne et champ par champ avec while IFS= read -r, sans perdre d'espaces, de barres obliques inverses ni de dernière ligne
  • Expliquer pourquoi une variable modifiée dans une boucle alimentée par un tube est perdue, et corriger le script par une substitution de processus ou lastpipe
  • Parcourir des noms de fichiers quelconques (espaces, sauts de ligne, tirets, caractères de motif) avec des motifs et nullglob, ou avec find -print0 et read -d ''
  • Reconnaître les données que read ne sait pas découper correctement (CSV entre guillemets, JSON) et basculer sur l'outil adapté
  • Mesurer le coût d'une boucle Bash et la remplacer par find -exec, find -delete ou awk quand le volume l'exige

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) findutils 4.9.0 (Ubuntu 24.04), 4.10.0 (Debian 13) , vérifié le 8 octobre 2026

Pourquoi

Voici, recopié tel quel, le cœur du script de purge que Camille a laissé dans /opt/signalements/bin/purger-pieces-jointes :

for f in $(find /srv/donnees/pieces-jointes -type f -mtime +365); do
    rm $f
done

Trois lignes, lisibles, et qui ont tourné des mois sans incident visible. Elles reposent pourtant sur une hypothèse que personne n'a écrite : aucun nom de fichier ne contient d'espace, de saut de ligne ni de caractère de motif. Or les pièces jointes de Signalements sont des photos envoyées par des habitants, et leur nom d'origine est conservé : photo nid de poule.jpg, IMG 2041 (2).jpg, et un jour, un nom contenant une étoile. Ce jour-là, le script supprime une photo récente, que la durée de conservation obligeait à garder, et laisse en place des photos périmées que le RGPD obligeait à effacer. Aucune erreur dans le journal : rm a fait exactement ce qu'on lui demandait.

Le second script hérité, celui qui résume les journaux des serveurs, affiche tous les matins « 0 requête » depuis qu'un collègue l'a « simplifié » en remplaçant une redirection par un cat fichier | while read .... Il ne plante pas : il compte dans une variable qui disparaît à la fin de la boucle.

Les boucles sont l'endroit où un script rencontre les données : des noms de fichiers, des lignes de journal, des enregistrements CSV, des listes d'hôtes. C'est là que les hypothèses implicites cassent, et là aussi que se joue la performance. Cette leçon apprend à parcourir une liste, un fichier ou la sortie d'une commande sans rien perdre ni rien inventer, à reconnaître les données que Bash ne sait pas découper, et à savoir quand ne pas écrire de boucle du tout.

Les concepts

Trois boucles, deux familles

Bash offre trois boucles d'usage courant, qui se rangent en deux familles :

  • for parcourt une liste de mots connue d'avance. La liste est entièrement construite avant le premier tour : c'est le résultat des expansions de la leçon 2 (accolades, paramètres, substitution de commande, découpage en mots, motifs).
  • while et until répètent tant qu'une commande réussit (while) ou échoue (until). La condition n'est pas une expression booléenne : c'est une commande quelconque dont on lit le code de sortie, comme pour if à la leçon 4. Ces boucles conviennent quand on ne sait pas d'avance combien de tours il y aura : lire un flux jusqu'à sa fin, attendre qu'un service réponde.

Cette distinction est la clé de toute la leçon. Une liste de mots se prête mal aux données qui contiennent des espaces ou des sauts de ligne, puisque ce sont précisément les séparateurs de mots ; un flux lu par while read se découpe là où vous le décidez.

for sur une liste de mots

La forme générale est for nom in mots ; do commandes ; done. Les mots peuvent venir de partout :

for hote in sig-app-1 sig-app-2; do ...; done        # mots écrits en dur
for fichier in /srv/donnees/exports/*.csv; do ...; done  # motif de chemins
for mois in {01..12}; do ...; done                    # expansion d'accolades
for argument in "$@"; do ...; done                    # arguments du script
for argument; do ...; done                            # même chose : sans « in », c'est "$@"

Deux formes à connaître, et une à fuir :

  • Le motif de chemins (*.csv) est la bonne façon de parcourir les fichiers d'un répertoire : chaque nom correspondant devient un mot, espaces et sauts de ligne compris, parce que l'expansion des chemins a lieu après le découpage en mots. C'est la seule expansion qui produit des mots sûrs sans guillemets.
  • L'expansion d'accolades {1..12} produit des intervalles, avec zéros en tête si on les écrit ({01..12}), dans l'ordre décroissant si besoin ({2026..2024}), et un pas ({0..100..10}). Mais elle a lieu avant l'expansion des variables : {1..$n} reste littéralement {1..$n}. C'est le piège n° 33 de la page BashPitfalls de Greg's Wiki.
  • La substitution de commande non protégée (for f in $(find ...), for ligne in $(cat fichier)) est la forme à fuir : sa sortie subit le découpage en mots et l'expansion des chemins. On obtient des mots, pas des lignes, et chaque mot qui contient *, ? ou [ est réinterprété comme un motif. ShellCheck la signale par SC2044 (« For loops over find output are fragile ») et SC2013 (« To read lines rather than words, pipe/redirect to a while read loop »).

for en style C

Pour compter, Bash reprend la boucle du langage C, avec une évaluation arithmétique (la leçon 3 en décrit les règles) :

for (( i = 1; i <= n; i++ )); do ...; done

Les trois expressions sont l'initialisation, la condition et le pas. Le manuel de Bash précise qu'une expression omise vaut 1, ce qui fait de for (( ; ; )) une boucle infinie. Contrairement à {1..$n}, cette forme accepte des variables, et elle ne construit aucune liste en mémoire : un million de tours ne coûtent pas un million de mots.

while et until

while commande; do corps; done     # tant que commande renvoie 0
until commande; do corps; done     # tant que commande renvoie autre chose que 0

La condition peut être une liste de commandes ; seul compte le code de la dernière. while true ou while : (la commande interne : ne fait rien et réussit) écrivent une boucle sans fin, que l'on quitte par break. Le code de sortie d'une boucle est celui de la dernière commande exécutée dans son corps, ou 0 si le corps ne s'est jamais exécuté.

break et continue

break sort de la boucle, continue passe au tour suivant. Les deux acceptent un nombre : break 2 sort de deux boucles imbriquées, continue 2 reprend au tour suivant de la boucle englobante. On s'en sert pour écarter tôt les cas sans intérêt (continue sur une ligne de commentaire) plutôt que d'imbriquer des if sur plusieurs niveaux.

read, la commande qui lit une ligne

read est une commande interne de Bash. Telle que le manuel la décrit, elle lit une ligne sur son entrée standard (ou sur le descripteur donné par -u), la découpe en champs selon la variable IFS, et affecte le premier champ à la première variable, le deuxième à la deuxième, et tout le reste à la dernière. Sans nom de variable, la ligne va dans REPLY. Elle renvoie 0 tant qu'elle a lu une ligne complète, et 1 à la fin du fichier : c'est ce code que while utilise pour s'arrêter.

Par défaut, read fait trois choses que l'on ne veut presque jamais dans un script :

Comportement par défautConséquenceRemède
La barre oblique inverse est un caractère d'échappement : \ suivi d'un caractère le rend littéral, \ en fin de ligne colle la ligne suivanteC:\temp\a devient C:tempa ; deux lignes deviennent une-r (raw)
Les blancs (espaces, tabulations) de IFS en début et en fin de ligne sont supprimésles indentations, les noms qui commencent par une espace disparaissentIFS= placé devant read, pour cette commande seulement
La ligne s'arrête au saut de ligneimpossible de lire un nom de fichier qui en contient un-d '' : l'octet nul comme fin d'enregistrement

D'où la forme canonique, celle de la FAQ n° 1 de Greg's Wiki et de tous les scripts sérieux :

while IFS= read -r ligne; do
    ...
done < fichier

IFS= devant read est une affectation temporaire : elle ne vaut que pour cette commande, IFS reste inchangé pour le reste du script. ShellCheck signale tout read sans -r par SC2162 (« read without -r will mangle backslashes »).

Découper en champs

Avec plusieurs variables, read découpe la ligne selon les caractères de IFS. Deux règles, qui surprennent :

  • Les blancs de IFS (espace, tabulation, saut de ligne) se regroupent : plusieurs espaces consécutives forment un seul séparateur, et ceux du début et de la fin sont ignorés. Les autres caractères (,, :) sont des séparateurs stricts : a,,b donne trois champs, dont un vide.
  • La dernière variable reçoit le reste de la ligne, séparateurs compris. Avec IFS=, read -r a b c sur x,y,z,w, c vaut z,w. Pour détecter une ligne qui a trop de champs, on ajoute une variable de plus, conventionnellement reste, et l'on vérifie qu'elle est vide.

Le nom _ sert de variable poubelle pour les champs inutiles (read -r _ _ code _). Greg's Wiki note que c'est une convention fiable en Bash mais pas dans tous les shells.

D'où vient l'entrée de la boucle

Une boucle est une commande composée : on peut rediriger son entrée et sa sortie après done, et la redirection vaut pour toute la boucle. C'est ainsi que l'on alimente read. Il existe cinq sources, qui ne se valent pas :

ÉcritureSourceLa boucle tourne dansRemarque
done < fichierun fichierle shell courantla forme de base
commande | while ...; donela sortie d'une commandeun sous-shellles variables modifiées sont perdues à la sortie
done < <(commande)la sortie d'une commandele shell courantsubstitution de processus, propre à Bash, ksh et zsh
done <<< "$texte"le contenu d'une variablele shell couranthere-string (leçon 6 de Premiers pas)
done 3< fichier avec read -u 3un fichier, sur le descripteur 3le shell courantlaisse l'entrée standard libre pour les commandes du corps

Le deuxième cas est celui du compteur de Camille. Le manuel de Bash est explicite : dans un tube de plusieurs commandes, chaque commande s'exécute dans un sous-shell, c'est-à-dire un processus séparé, copie du shell. La boucle incrémente sa copie du compteur ; quand le tube se termine, la copie disparaît avec son processus, et le shell principal n'a jamais vu le moindre changement. Ce n'est pas un bogue : la norme POSIX décrit ce comportement, tout en autorisant celui de ksh, qui exécute la dernière commande d'un tube dans le shell courant.

Un sous-shell (subshell) est un processus créé par fork à partir du shell : il hérite d'une copie de toutes les variables, fonctions et options, mais rien de ce qu'il modifie ne remonte au parent. Les parenthèses ( ... ), les tubes, les substitutions de commande $( ... ) et les tâches en arrière-plan en créent ; la leçon 11 y revient en détail.

Trois corrections, par ordre de préférence :

  1. La substitution de processus <(commande) lance la commande en parallèle et la remplace par un nom de fichier dont la lecture donne sa sortie. done < <(commande) redirige donc la boucle depuis ce fichier : plus de tube, la boucle reste dans le shell courant. L'espace entre les deux < est obligatoire : <<( serait lu comme le début d'un here-document.
  2. L'option lastpipe (Bash 4.2 et plus) : avec shopt -s lastpipe, la dernière commande d'un tube s'exécute dans le shell courant, à condition que le contrôle des tâches soit désactivé. C'est le cas dans un script, pas dans un terminal interactif. Pratique pour garder la lisibilité d'un tube, mais l'effet dépend du contexte, ce qui en fait un choix moins robuste.
  3. Tout faire dans le sous-shell : commande | { while ...; done; echo "$compteur"; }. Portable, mais le résultat reste prisonnier des accolades.

Les noms de fichiers et l'octet nul

Sous Linux, un nom de fichier peut contenir n'importe quel octet, sauf la barre oblique et l'octet nul (le caractère de code 0, noté \0). Espaces, tabulations, sauts de ligne, étoiles, tirets en tête, octets invalides en UTF-8 : tout est permis. Il en découle une règle simple : le seul séparateur sûr entre deux noms de fichiers est l'octet nul. Tout format qui sépare les noms par des sauts de ligne (la sortie de ls, de find sans option) est ambigu dès qu'un nom en contient un.

La chaîne d'outils se met d'accord sur ce séparateur :

  • find ... -print0 écrit chaque nom suivi d'un octet nul ;
  • read -r -d '' lit jusqu'à l'octet nul (un délimiteur vide signifie « l'octet nul ») ;
  • xargs -0, sort -z, grep -z, mapfile -d '' (Bash 4.4 et plus) savent aussi lire des enregistrements séparés par des octets nuls.

Longtemps des extensions de GNU et des BSD, find -print0, xargs -0 et read -d sont entrés dans la norme POSIX en 2024. Mais un /bin/sh donné ne les connaît pas forcément : le dash de Debian et d'Ubuntu répond read: Illegal option -d. Encore une raison de lancer ces scripts avec Bash.

Une variable Bash ne peut pas contenir d'octet nul (les chaînes de Bash sont des chaînes C, terminées par cet octet). C'est pourquoi on n'écrit pas -d $'\0', qui vaut la chaîne vide de toute façon, mais -d '' ; et c'est pourquoi on ne peut pas stocker une liste de noms séparés par des octets nuls dans une seule variable : il faut un tableau (leçon 7) ou un flux.

Les options de motifs

Quatre options de shopt changent le comportement de l'expansion des chemins, et donc des boucles for sur des motifs :

OptionEffet quand aucun fichier ne correspond, ou sur les fichiers cachésUsage
(aucune)le motif est laissé tel quel : for f in *.csv fait un tour avec f='*.csv'le comportement par défaut, piégeux
nullgloble motif disparaît : la boucle ne fait aucun tourpresque toujours ce que l'on veut dans un script
failgloberreur d'expansion, la commande n'est pas exécutée, code 1quand l'absence de fichier est une anomalie
dotglobles noms qui commencent par un point sont inclus (jamais . ni ..)parcourir aussi les fichiers cachés
globstar** correspond à tous les fichiers et sous-répertoires, à toute profondeurparcours récursif sans find

Le manuel ajoute que globskipdots, active par défaut depuis Bash 5.2, empêche tout motif de correspondre à . et ... Et le tri des noms produits par un motif suit l'ordre de la locale (LC_COLLATE) : sous LC_ALL=C, il suit l'ordre des octets.

mapfile

mapfile (synonyme readarray) lit toutes les lignes de son entrée dans un tableau indexé, une ligne par case. Avec -t, le saut de ligne final de chaque ligne est retiré ; sans lui, chaque case garde son \n, ce qui n'est presque jamais voulu. -d '' lit des enregistrements séparés par des octets nuls. Les tableaux eux-mêmes sont l'objet de la leçon 7 ; retenez ici que mapfile est la façon rapide de charger une petite liste (des hôtes, des identifiants) et que "${tableau[@]}" la restitue, un élément par mot.

Ce que read ne sait pas lire

read découpe selon des caractères, sans aucune notion de guillemets. Or le format CSV, tel que le décrit la RFC 4180, permet d'entourer un champ de guillemets doubles, et ce champ peut alors contenir des virgules, des guillemets doublés ("") et même des sauts de ligne. Le module csv de Python, qui produit l'export de Signalements, entoure de guillemets tout champ qui contient une virgule. Une commune nommée Saint-Exemple, centre donne donc la ligne :

2,lampadaire,"Saint-Exemple, centre",2026-10-07

que IFS=, read découpe en cinq morceaux au lieu de quatre. Même chose pour le JSON, dont la structure est imbriquée. La règle est simple : Bash découpe des formats simples dont vous maîtrisez le séparateur (journaux à champs séparés par des espaces, fichiers clé=valeur, /etc/passwd) ; pour du CSV général ou du JSON, on délègue à un outil qui connaît le format (python3 et son module csv, Miller, jq), puis on lit sa sortie, simplifiée, dans Bash.

En pratique

Les sorties ont été produites avec Bash 5.2.21 et LC_ALL=C.UTF-8, dans un répertoire d'essais qui reproduit l'arborescence de sig-outils. Rien ne s'exécute sur le serveur réel : on prépare un bac à sable.

Préparer des noms de fichiers hostiles

mkdir -p ~/essais-bash && cd ~/essais-bash
mkdir -p pj/2025 pj/2026
touch -d '2025-03-02 10:00' 'pj/2025/photo nid de poule.jpg' 'pj/2025/-rf.jpg' \
    'pj/2025/photo*.jpg' "pj/2025/$(printf 'deux\nlignes.jpg')"
touch -d '2026-10-05 10:00' pj/2025/photo-recente.jpg pj/2026/recent.jpg
  • touch -d fixe la date de modification : quatre photos ont plus d'un an, deux sont récentes.
  • "pj/2025/$(printf 'deux\nlignes.jpg')" crée un nom qui contient un vrai saut de ligne. La substitution de commande est entre guillemets, sinon le découpage en mots couperait le nom en deux.
  • photo-recente.jpg est rangée dans le répertoire de 2025, mais elle a été modifiée il y a trois jours (une photo remplacée par l'habitant, par exemple). Elle doit survivre à la purge.

ls -b affiche les caractères spéciaux sous forme échappée :

$ ls -b pj/2025
-rf.jpg  deux\nlignes.jpg  photo\ nid\ de\ poule.jpg  photo*.jpg  photo-recente.jpg

Le bogue de Camille, démontré

Remplaçons rm par un affichage inoffensif, chaque mot entre chevrons comme avec l'outil montrer-args de la leçon 2 :

$ for f in $(find pj -type f -mtime +365); do printf 'rm <%s>\n' "$f"; done
rm <pj/2025/deux>
rm <lignes.jpg>
rm <pj/2025/photo>
rm <nid>
rm <de>
rm <poule.jpg>
rm <pj/2025/photo nid de poule.jpg>
rm <pj/2025/photo*.jpg>
rm <pj/2025/photo-recente.jpg>
rm <pj/2025/-rf.jpg>

L'ordre des lignes dépend de l'ordre des entrées dans le répertoire et peut varier chez vous ; le contenu, non. Décortiquons :

  • find a écrit quatre noms, séparés par des sauts de ligne. La substitution de commande, non protégée, a été découpée en mots sur les espaces et les sauts de ligne : deux et lignes.jpg, photo, nid, de, poule.jpg. Aucun de ces chemins n'existe ; rm aurait affiché six erreurs et la photo n'aurait jamais été supprimée.
  • Le mot pj/2025/photo*.jpg contient une étoile : il a subi l'expansion des chemins et s'est transformé en trois noms, dont photo nid de poule.jpg (que l'on supprime alors par accident, ce qui masque l'erreur précédente) et surtout photo-recente.jpg, modifiée il y a trois jours.

La boucle a fait le contraire de ce qu'on voulait : elle a raté les fichiers à supprimer et supprimé celui qu'il fallait garder.

Important

Ce n'est pas un défaut de find, qui a écrit des noms exacts. C'est le passage par une liste de mots qui détruit l'information. Toute la suite de la leçon consiste à ne jamais faire passer des noms de fichiers par le découpage en mots.

Parcourir un répertoire avec un motif

Pour un seul répertoire, sans critère d'âge, le motif suffit et il est sûr :

$ for f in pj/2025/*.jpg; do printf '<%s>\n' "$f"; done
<pj/2025/-rf.jpg>
<pj/2025/deux
lignes.jpg>
<pj/2025/photo nid de poule.jpg>
<pj/2025/photo*.jpg>
<pj/2025/photo-recente.jpg>

Chaque nom est un mot, saut de ligne compris. Deux précautions, cependant. D'abord "$f" entre guillemets dans le corps, sans quoi le découpage en mots reprendrait ses droits à l'utilisation. Ensuite le cas où rien ne correspond :

$ for f in pj/2025/*.png; do printf '<%s>\n' "$f"; done
<pj/2025/*.png>

Sans fichier PNG, le motif est resté tel quel, et le corps de la boucle s'exécute une fois sur un fichier qui n'existe pas. Trois façons de s'en protéger :

# 1. nullglob : le motif sans correspondance disparaît, la boucle ne tourne pas
shopt -s nullglob
for f in pj/2025/*.png; do ...; done

# 2. vérifier l'existence au début du corps (portable, y compris en sh)
for f in pj/2025/*.png; do
    [[ -e $f || -L $f ]] || continue
    ...
done

# 3. failglob : l'absence de correspondance est une erreur
shopt -s failglob

Avec failglob, dans un script :

$ bash essai-failglob.sh
essai-failglob.sh: line 2: no match: pj/2025/*.png

La commande qui contient le motif n'est pas exécutée et son code vaut 1 ; le script continue à la ligne suivante (la leçon 9 verra comment arrêter le script sur une erreur). Le test -L de la deuxième méthode couvre le cas d'un lien symbolique cassé, que -e déclare inexistant alors que le motif l'a bien trouvé.

Pour descendre dans les sous-répertoires, globstar :

$ shopt -s globstar nullglob
$ for f in pj/**/*.jpg; do printf '<%q>\n' "$f"; done
<pj/2025/-rf.jpg>
<$'pj/2025/deux\nlignes.jpg'>
<pj/2025/photo\ nid\ de\ poule.jpg>
<pj/2025/photo\*.jpg>
<pj/2025/photo-recente.jpg>
<pj/2026/recent.jpg>

printf '%q' affiche chaque nom sous une forme réutilisable par le shell, ce qui rend visibles le saut de ligne et l'étoile. Depuis Bash 4.3, d'après le fichier CHANGES de Bash, ** ne traverse plus les liens symboliques qui pointent vers des répertoires, ce qui évite les doublons et les boucles sans fin. Mais un motif ne sait filtrer que sur le nom : pour un critère d'âge, de taille ou de type, il faut find.

Parcourir le résultat de find, octet nul compris

La forme sûre, celle que recommandent la FAQ n° 20 de Greg's Wiki et la page de l'avertissement SC2044 :

while IFS= read -r -d '' f; do
    printf 'rm <%s>\n' "$f"
done < <(find pj -type f -mtime +365 -print0)
rm <pj/2025/deux
lignes.jpg>
rm <pj/2025/photo nid de poule.jpg>
rm <pj/2025/photo*.jpg>
rm <pj/2025/-rf.jpg>

Quatre fichiers, les bons, et photo-recente.jpg épargnée. Chaque élément a son rôle :

  • -mtime +365 : d'après find(1), la date de modification est convertie en nombre de périodes de 24 heures en ignorant la partie fractionnaire, et +365 demande « strictement plus de 365 ». Un fichier n'est donc retenu qu'à partir de 366 jours pleins. C'est voulu ici (on ne supprime jamais trop tôt), mais à connaître.
  • -print0 : chaque nom est suivi d'un octet nul.
  • < <(...) : la boucle lit depuis une substitution de processus, donc reste dans le shell courant ; les compteurs que l'on y ajoutera survivront.
  • IFS= et -r : aucune espace ni barre oblique inverse n'est altérée.
  • -d '' : la lecture s'arrête à l'octet nul et non au saut de ligne.

Quand la meilleure boucle est de ne pas en écrire

Si le traitement de chaque fichier se résume à une commande, find sait l'exécuter lui-même, sans que les noms passent jamais par le shell :

find /srv/donnees/pieces-jointes -xdev -type f -mtime +365 -exec rm -f -- {} +
find /srv/donnees/pieces-jointes -xdev -type f -mtime +365 -delete
  • -exec ... {} + (POSIX) remplace {} par autant de noms que possible à chaque appel, comme xargs : quelques appels de rm pour des milliers de fichiers. La forme {} \; lance une commande par fichier, beaucoup plus lentement.
  • -delete (GNU et BSD) supprime directement, sans lancer aucun programme. La page de manuel prévient qu'elle active -depth et qu'il ne faut jamais la placer avant les tests : find rep -delete -mtime +365 supprimerait tout.
  • -- signale à rm la fin des options : un nom qui commencerait par un tiret ne serait pas pris pour une option. Ici les noms commencent par le chemin de départ, donc par /, mais l'habitude ne coûte rien.

On garde une boucle quand chaque fichier demande de la logique : un mode simulation, un compteur, un message par fichier, une décision au cas par cas. C'est le cas de la purge de Signalements, qui doit annoncer ce qu'elle va faire avant de le faire.

Lire un fichier ligne à ligne

Le fichier de test suivant contient une ligne indentée, des barres obliques inverses et une ligne qui se termine par une barre oblique inverse :

$ printf '  deux espaces devant\nune\\tbarre\\\nsuite\n' > t.txt
$ cat -A t.txt
  deux espaces devant$
une\tbarre\$
suite$

Comparons les trois façons d'appeler read :

$ while read l; do printf '<%s>\n' "$l"; done < t.txt
<deux espaces devant>
<unetbarresuite>
$ while read -r l; do printf '<%s>\n' "$l"; done < t.txt
<deux espaces devant>
<une\tbarre\>
<suite>
$ while IFS= read -r l; do printf '<%s>\n' "$l"; done < t.txt
<  deux espaces devant>
<une\tbarre\>
<suite>

Sans -r, les barres obliques inverses ont été consommées et la troisième ligne collée à la deuxième. Sans IFS=, l'indentation a disparu. Seule la dernière forme restitue le fichier tel quel.

La dernière ligne sans saut de ligne

Un fichier produit par un programme négligent, ou édité sur certains éditeurs, peut ne pas se terminer par un saut de ligne. read lit alors la dernière ligne, l'affecte à la variable, mais renvoie 1, puisqu'il a rencontré la fin du fichier avant le délimiteur. La norme POSIX précise ce comportement. Et while s'arrête sans exécuter le corps :

$ printf 'a\nb' | while IFS= read -r l; do printf '<%s>\n' "$l"; done
<a>
$ printf 'a\nb' | while IFS= read -r l || [[ -n $l ]]; do printf '<%s>\n' "$l"; done
<a>
<b>

La condition || [[ -n $l ]] fait faire un dernier tour si read a échoué mais a lu quelque chose. Pour un export transmis à la mairie, perdre silencieusement le dernier enregistrement est exactement le genre d'erreur qui ne se voit qu'au contrôle de la mairie.

Les fins de ligne Windows

Un fichier édité sous Windows termine ses lignes par \r\n (CRLF) au lieu de \n. read coupe au \n et laisse le \r à la fin de la variable, invisible à l'affichage :

$ printf 'id,type,commune,date\r\n' > crlf.csv
$ IFS= read -r entete < crlf.csv
$ [[ $entete == 'id,type,commune,date' ]] && echo égal || printf 'différent : %q\n' "$entete"
différent : $'id,type,commune,date\r'

Le %q révèle le coupable. On le retire par une expansion de la leçon 3, entete=${entete%$'\r'}, ou l'on refuse le fichier avec un message clair si le format exige des fins de ligne Unix. cat -A affiche les \r sous la forme ^M.

Lire des champs : l'export CSV

L'export de Signalements a une ligne d'en-tête suivie d'un enregistrement par signalement. Pour compter les enregistrements et repérer les lignes mal formées, on lit l'en-tête à part, puis les données, en redirigeant un bloc entre accolades :

{
    IFS= read -r entete
    n=0
    while IFS=, read -r id type commune date reste || [[ -n $id ]]; do
        n=$((n + 1))
        printf '%s|%s|%s|%s|reste=%s\n' "$id" "$type" "$commune" "$date" "$reste"
    done
    echo "n=$n"
} < ex.csv

Le premier read consomme la première ligne ; la boucle lit les suivantes, sur la même ouverture du fichier, donc à partir de la deuxième ligne. Avec un fichier ex.csv qui contient le cas de la commune avec virgule et une dernière ligne sans saut de ligne :

1|nid-de-poule|Exempleville|2026-10-07|reste=
2|lampadaire|"Saint-Exemple| centre"|reste=2026-10-07
3|graffiti|Exempleville|2026-10-07|reste=
n=3
  • IFS=, ne vaut que pour read : la virgule devient le séparateur, et rien d'autre (les espaces ne séparent plus).
  • La variable reste est vide pour les lignes correctes. Pour la ligne 2, elle contient 2026-10-07 : la ligne a un champ de trop, signe d'un champ entre guillemets que read ne sait pas interpréter. Le script peut ainsi détecter le problème, à défaut de le résoudre.
  • La troisième ligne, sans saut de ligne final, est bien comptée grâce à || [[ -n $id ]].

Pour publier-export, cette lecture suffit à valider la forme du fichier : en-tête attendu, au moins une ligne de données, aucune ligne vide. Pour interpréter les champs, il faudrait un vrai lecteur CSV. Python est installé sur sig-outils puisque l'export lui-même y est écrit :

python3 -c 'import csv, sys; print(sum(1 for _ in csv.reader(sys.stdin)) - 1)' < ex.csv

Le module csv compte trois enregistrements, quelle que soit la façon dont les champs sont protégés. Retenez aussi qu'un enregistrement CSV qui contient un saut de ligne entre guillemets occupe deux lignes physiques : wc -l ne compte pas des enregistrements.

Ne pas perdre le compteur

Le résumé quotidien de Camille, après la « simplification » :

$ j=journaux/sig-app-1.pn-signalements.internal/syslog.log
$ n=0; cat "$j" | while IFS= read -r l; do n=$((n + 1)); done; echo "n=$n"
n=0

Le fichier compte pourtant 1 000 lignes. La boucle a compté dans un sous-shell. Les trois corrections :

$ n=0; while IFS= read -r l; do n=$((n + 1)); done < "$j"; echo "n=$n"
n=1000
$ n=0; while IFS= read -r l; do n=$((n + 1)); done < <(grep 'python3\[' "$j"); echo "n=$n"
n=1000

et, dans un script (pas dans un terminal interactif, où le contrôle des tâches est actif) :

shopt -s lastpipe
n=0
grep 'python3\[' journaux/sig-app-1.pn-signalements.internal/syslog.log | while IFS= read -r l; do n=$((n + 1)); done
echo "n=$n"     # 1000

ShellCheck repère la version fautive : il signale l'affectation dans le tube par SC2030 (« Modification of n is local (to subshell caused by pipeline) ») et la lecture après la boucle par son avertissement compagnon, SC2031.

La substitution de processus a un inconvénient : son code de sortie n'apparaît nulle part, ni dans $? ni dans PIPESTATUS. Si grep échoue parce que le fichier a disparu, la boucle lit simplement un flux vide. Depuis Bash 4.4, $! contient le numéro du processus de la dernière substitution de processus, et depuis Bash 5.0, wait peut l'attendre :

$ while IFS= read -r l; do :; done < <(printf 'a\n'; exit 3); wait $!; echo "code du producteur : $?"
code du producteur : 3

C'est la façon de savoir, après la boucle, si la commande qui l'alimentait a réussi. La leçon 9 en fait une vérification systématique.

La commande du corps qui avale l'entrée

Une liste d'hôtes, une commande distante par hôte :

while IFS= read -r hote; do
    ssh "$hote" systemctl is-active signalements
done < hotes.txt

Seul le premier hôte est interrogé. ssh lit son entrée standard pour la transmettre à la commande distante ; or son entrée standard, héritée de la boucle, est hotes.txt. Il en consomme tout le reste, et le read suivant trouve la fin du fichier. Même effet avec toute commande qui lit l'entrée standard : ffmpeg, psql sans -f, un read de confirmation. Deux remèdes :

# Faire lire la boucle sur un autre descripteur, laisser l'entrée standard tranquille
while IFS= read -r -u 3 hote; do
    ssh "$hote" systemctl is-active signalements
done 3< hotes.txt

# Ou couper l'entrée de la commande gourmande
ssh -n "$hote" ...            # -n : entrée standard depuis /dev/null
commande < /dev/null

La première forme est la plus générale, parce qu'elle protège contre toutes les commandes du corps, y compris celles que l'on ajoutera plus tard. La leçon 11 reprend ssh dans les boucles et leur parallélisation.

Charger une petite liste avec mapfile

verifier-sante a besoin de la liste des hôtes, tenue dans un fichier avec des commentaires :

$ cat hotes.txt
# hôtes de Signalements
sig-app-1

  # sig-app-3 retiré le 2026-09-30
sig-app-2
$ mapfile -t hotes < <(grep -Ev '^[[:space:]]*(#|$)' hotes.txt)
$ declare -p hotes
declare -a hotes=([0]="sig-app-1" [1]="sig-app-2")
$ for hote in "${hotes[@]}"; do printf '<%s>\n' "$hote"; done
<sig-app-1>
<sig-app-2>

grep -Ev écarte les lignes vides et les commentaires, même indentés ; mapfile -t range chaque ligne restante dans une case, sans son saut de ligne. Sans -t, declare -p montrerait des valeurs comme $'sig-app-1\n', et ssh chercherait un hôte dont le nom finit par un saut de ligne. Notez encore < <(...) : grep ... | mapfile -t hotes remplirait le tableau dans un sous-shell, et hotes resterait vide.

Attendre avec until

Après un déploiement, on attend que l'API réponde, mais pas indéfiniment :

essais=0
until curl --fail --silent --max-time 2 http://172.16.8.11:8000/sante > /dev/null; do
    essais=$((essais + 1))
    if (( essais >= 10 )); then
        printf 'sig-app-1 ne répond toujours pas après %d essais\n' "$essais" >&2
        exit 1
    fi
    sleep 3
done

until répète tant que curl échoue ; --fail fait échouer curl sur un code HTTP d'erreur (vu à la leçon 4), --max-time borne chaque essai. Le compteur garantit une fin : une boucle d'attente sans borne est un script qui peut ne jamais se terminer, et une tâche planifiée qui s'empile sur la précédente.

La purge, réécrite

Tout assemblé, voici bin/purger-pieces-jointes à la fin de cette leçon. L'interface en ligne de commande est encore rudimentaire (la leçon 8 la refera) et la gestion d'erreurs sommaire (la leçon 9 la complétera), mais le parcours des fichiers est désormais sûr :

#!/usr/bin/env bash
# purger-pieces-jointes : supprime les pièces jointes plus anciennes que la durée de conservation.
# Usage : purger-pieces-jointes [--dry-run] [jours]

racine=${PURGER_REPERTOIRE:-/srv/donnees/pieces-jointes}
jours=365
simulation=non

if [[ ${1:-} == --dry-run ]]; then
    simulation=oui
    shift
fi
if [[ -n ${1:-} ]]; then
    jours=$1
fi
# Garde-fou : une durée absurde (« 3 » au lieu de « 365 ») viderait le répertoire.
if [[ ! $jours =~ ^[0-9]+$ ]] || (( 10#$jours < 30 )); then
    printf 'purger-pieces-jointes : durée invalide : %s (entier >= 30 attendu)\n' "$jours" >&2
    exit 2
fi
jours=$((10#$jours))
if [[ ! -d $racine ]]; then
    printf 'purger-pieces-jointes : répertoire absent : %s\n' "$racine" >&2
    exit 1
fi

nombre=0
octets=0
echecs=0

# Pour chaque fichier, find écrit sa taille puis son chemin, chacun suivi d'un octet nul.
while IFS= read -r -d '' taille && IFS= read -r -d '' chemin; do
    if [[ $simulation == oui ]]; then
        printf 'à supprimer : %q (%d octets)\n' "$chemin" "$taille"
    elif rm -f -- "$chemin"; then
        printf 'supprimé : %q\n' "$chemin"
    else
        echecs=$((echecs + 1))
        continue
    fi
    nombre=$((nombre + 1))
    octets=$((octets + taille))
done < <(find "$racine" -xdev -type f -mtime "+$jours" -printf '%s\0%p\0')

suffixe=
if [[ $simulation == oui ]]; then
    suffixe=' (simulation)'
fi
printf 'bilan : %d fichier(s), %d octet(s), %d échec(s)%s\n' \
    "$nombre" "$octets" "$echecs" "$suffixe"

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

Les choix qui méritent une explication :

  • Deux enregistrements par fichier. -printf '%s\0%p\0' écrit la taille (%s), un octet nul, le chemin (%p), un octet nul. La condition de la boucle lit les deux à la suite avec && : le tour n'a lieu que si les deux lectures réussissent. On évite ainsi de lancer stat pour chaque fichier, et l'on n'a besoin d'aucun séparateur à l'intérieur d'un enregistrement (un séparateur espace aurait fait perdre les espaces en fin de nom, puisque read supprime les blancs de IFS aux extrémités du dernier champ).
  • 10#$jours : une valeur comme 0400 serait lue en octal dans (( )), et 08 provoquerait une erreur (la leçon 3 détaille ce piège). On force la base 10, puis on normalise la valeur pour find.
  • -xdev : find ne quitte pas le système de fichiers de départ. Si quelqu'un monte un jour un autre volume sous /srv/donnees/pieces-jointes, la purge ne s'y aventure pas.
  • printf '%q' dans les messages : un nom contenant un saut de ligne s'affiche sur une ligne, sous une forme non ambiguë. On y revient dans la partie Sécurité.
  • Le code de sortie : 2 pour une mauvaise utilisation, 1 si le répertoire manque ou si au moins une suppression a échoué. Le minuteur systemd qui lance la purge (cours d'administration, leçon 10) peut ainsi déclencher sa notification d'échec.

Essayons-le sur le bac à sable, en simulation puis pour de bon :

$ PURGER_REPERTOIRE=pj ./purger-pieces-jointes --dry-run
à supprimer : $'pj/2025/deux\nlignes.jpg' (0 octets)
à supprimer : pj/2025/photo\ nid\ de\ poule.jpg (0 octets)
à supprimer : pj/2025/photo\*.jpg (0 octets)
à supprimer : pj/2025/-rf.jpg (0 octets)
bilan : 4 fichier(s), 0 octet(s), 0 échec(s) (simulation)
$ PURGER_REPERTOIRE=pj ./purger-pieces-jointes 0400
supprimé : $'pj/2025/deux\nlignes.jpg'
supprimé : pj/2025/photo\ nid\ de\ poule.jpg
supprimé : pj/2025/photo\*.jpg
supprimé : pj/2025/-rf.jpg
bilan : 4 fichier(s), 0 octet(s), 0 échec(s)
$ ls pj/2025
photo-recente.jpg
$ PURGER_REPERTOIRE=pj ./purger-pieces-jointes 10; echo "code : $?"
purger-pieces-jointes : durée invalide : 10 (entier >= 30 attendu)
code : 2

Les fichiers créés par touch sont vides, d'où les zéros octets. Et si un fichier ne peut pas être supprimé (répertoire en lecture seule, par exemple), rm le dit, le compteur d'échecs augmente, et le script se termine avec le code 1 :

$ touch -d 2025-01-01 pj/2025/vieux.jpg && chmod 555 pj/2025
$ PURGER_REPERTOIRE=pj ./purger-pieces-jointes; echo "code : $?"
rm: cannot remove 'pj/2025/vieux.jpg': Permission denied
bilan : 0 fichier(s), 0 octet(s), 1 échec(s)
code : 1
$ chmod 755 pj/2025

Le résumé des journaux, première version

bin/rapport-journaux compte, pour chaque hôte, les requêtes de l'API et les erreurs 5xx dans les journaux reçus sur sig-outils. rsyslog y range un répertoire par machine émettrice, nommé d'après son nom complet (/srv/donnees/journaux/sig-app-1.pn-signalements.internal/syslog.log). L'API, un serveur HTTP écrit en Python, y écrit une ligne par requête :

2026-10-07T08:41:07.214311+00:00 sig-app-1 python3[812]: 172.16.8.20 - - [07/Oct/2026 08:41:07] "GET /signalements HTTP/1.1" 200 -

Les champs sont séparés par des espaces simples. La date entre crochets et la requête entre guillemets en contiennent, mais toujours le même nombre : la ligne compte treize champs, et le code HTTP est toujours le douzième. C'est un format que read découpe très bien, à condition de compter ses champs. (La leçon 7 le lira autrement, par une expression régulière, plus robuste si le format bouge.)

#!/usr/bin/env bash
# rapport-journaux : nombre de requêtes et d'erreurs 5xx par hôte, d'après les journaux reçus.
# Version de la leçon 5 ; les tableaux associatifs de la leçon 7 la compléteront.

racine=${REPERTOIRE_JOURNAUX:-/srv/donnees/journaux}
shopt -s nullglob

printf '%-12s %10s %8s\n' hote requetes 5xx
trouve=non
for repertoire in "$racine"/*/; do
    trouve=oui
    hote=${repertoire%/}
    hote=${hote##*/}
    hote=${hote%%.*}
    journal=${repertoire}syslog.log
    if [[ ! -r $journal ]]; then
        printf 'rapport-journaux : %s illisible, ignoré\n' "$journal" >&2
        continue
    fi
    requetes=0
    erreurs=0
    while read -r _ _ processus _ _ _ _ _ _ _ _ code _; do
        [[ $processus == python3\[* ]] || continue
        requetes=$((requetes + 1))
        if [[ $code == 5[0-9][0-9] ]]; then
            erreurs=$((erreurs + 1))
        fi
    done < "$journal"
    printf '%-12s %10d %8d\n' "$hote" "$requetes" "$erreurs"
done

if [[ $trouve == non ]]; then
    printf 'rapport-journaux : aucun hôte sous %s\n' "$racine" >&2
    exit 1
fi
  • "$racine"/*/ : la barre oblique finale ne retient que les répertoires. nullglob évite le tour fantôme quand il n'y en a aucun, et la variable trouve permet de le signaler.
  • ${repertoire%/}, ${hote##*/} puis ${hote%%.*} extraient le nom court de l'hôte (sig-app-1) sans lancer basename (expansions de la leçon 3).
  • Ici, on veut le découpage sur les blancs : pas de IFS=, et -r reste de rigueur. Les _ absorbent les champs inutiles, le dernier _ absorbe le reste de la ligne. Onze _ avant code : c'est lisible tant que le format est stable, fragile s'il change, et c'est une bonne raison de passer à une expression régulière ou à awk pour un format plus riche.
  • continue écarte les lignes qui ne viennent pas de l'API, reconnues à leur troisième champ (démarrages de service par systemd[1]:, messages du noyau).
$ REPERTOIRE_JOURNAUX=journaux ./rapport-journaux
hote           requetes      5xx
sig-app-1          1000      250
sig-app-2           600      141

Le fichier de sig-app-2 contient aussi une ligne de systemd, correctement écartée.

Mesurer : quand passer à awk

Sur un journal de 200 000 lignes, un poste de travail récent donne les ordres de grandeur suivants (mesures prises avec time, à refaire chez vous) :

MéthodeDurée
boucle while read qui compte en Bash2,7 s
awk '$3 ~ /^python3\[/ { n++; if ($12 ~ /^5[0-9][0-9]$/) e++ } END { print n, e }'0,13 s
boucle qui lance cut pour chaque ligne (c=$(echo "$l" | cut -d' ' -f12)), sur 2 000 lignes seulement6 à 10 s selon la charge

Deux enseignements. D'abord, une boucle Bash pure n'est pas catastrophique pour quelques milliers de lignes, mais awk est environ vingt fois plus rapide : au-delà de quelques dizaines de milliers de lignes, ou dans une tâche qui tourne souvent, le choix est vite fait. Ensuite, et surtout, le coût d'une boucle vient des processus qu'elle lance : chaque substitution de commande et chaque tube créent des processus, à quelques millisecondes pièce. La troisième méthode, extrapolée à 200 000 lignes, prendrait de dix à vingt minutes. Dans un corps de boucle, préférez les expansions de Bash (${var##*/}, [[ ... ]], $(( ))) aux commandes externes.

La version awk du rapport complet tient en une ligne, et sert de référence pour vérifier le script Bash :

$ awk '$3 ~ /^python3\[/ { n[$2]++; if ($12 ~ /^5[0-9][0-9]$/) e[$2]++ }
       END { for (h in n) printf "%-12s %10d %8d\n", h, n[h], e[h] }' journaux/*/syslog.log
sig-app-2           600      141
sig-app-1          1000      250

Notez l'ordre des hôtes, différent : awk ne garantit pas d'ordre de parcours de ses tableaux, comme les tableaux associatifs de Bash (leçon 7). Sur Debian et Ubuntu, awk est par défaut mawk, rapide et conforme à POSIX ; gawk, la version GNU, apporte des extensions (tri, fonctions réseau) et s'installe à part.

Sous le capot

Une liste construite d'avance, un flux lu au fil de l'eau

for f in pj/**/* construit toute la liste en mémoire avant le premier tour. Comme for est une construction du shell, qui ne passe pas par execve, la limite de taille des arguments (celle qui produit Argument list too long avec rm *) ne s'applique pas ; mais cent mille noms occupent de la mémoire, et le parcours ne commence qu'une fois l'arborescence entièrement explorée. À l'inverse, find ... -print0 | ... ou < <(find ...) produit un flux : le premier fichier est traité pendant que find explore encore le reste. Pour une arborescence de taille inconnue, le flux est le bon choix.

read lit octet par octet sur un tube

read doit s'arrêter exactement au délimiteur, sans consommer un octet de plus, parce que la suite appartient au prochain read, ou à une autre commande qui partage la même entrée. Comment fait-il ? Observons-le avec strace, qui affiche les appels système, d'abord sur un fichier ordinaire :

$ printf 'ligne un\nligne deux\nligne trois\n' > l.txt
$ strace -e trace=read,lseek bash --norc -c 'IFS= read -r a < l.txt'
...
lseek(0, 0, SEEK_CUR)                   = 0
read(0, "ligne un\nligne deux\nligne trois\n", 4096) = 32
lseek(0, -23, SEEK_CUR)                 = 9

Sur un fichier, Bash lit un bloc de 4 096 octets d'un coup, trouve le saut de ligne au neuvième octet, puis recule la position du fichier de 23 octets avec lseek, pour la laisser juste après la ligne lue. Le premier lseek(0, 0, SEEK_CUR), qui ne déplace rien, permet de savoir si le descripteur accepte ce retour en arrière.

Sur un tube, ou sur une substitution de processus, ce n'est pas possible :

$ strace -e trace=read,lseek bash --norc -c 'IFS= read -r a < <(cat l.txt)'
...
lseek(0, 0, SEEK_CUR)                   = -1 ESPIPE (Illegal seek)
read(0, "l", 1)                         = 1
read(0, "i", 1)                         = 1
read(0, "g", 1)                         = 1
...
read(0, "\n", 1)                        = 1

ESPIPE : on ne peut pas reculer dans un tube. Bash se rabat sur la seule méthode sûre, un appel système par octet. C'est la raison profonde de la lenteur des boucles while read sur de gros flux, et la raison pour laquelle mapfile, qui n'a pas à s'arrêter au milieu, ou awk, qui lit par blocs, vont tellement plus vite. Lire depuis un fichier (done < fichier) plutôt que depuis un tube (cat fichier | while) est donc aussi un gain de performance.

Ce qu'est vraiment <(commande)

$ echo <(true)
/dev/fd/63

La substitution de processus est remplacée par un nom de fichier. Le manuel de Bash indique qu'elle repose sur les tubes nommés (FIFO) ou sur la méthode /dev/fd, selon le système. Sous Linux, Bash crée un tube anonyme, lance la commande en arrière-plan avec la sortie branchée sur l'extrémité d'écriture, et garde l'extrémité de lecture ouverte sur un descripteur élevé (ici 63). Le chemin /dev/fd/63, qui pointe vers /proc/self/fd/63, permet à n'importe quel programme d'ouvrir ce descripteur comme un fichier. done < <(cmd) revient donc à une redirection de fichier ordinaire, dont le contenu est un tube : la boucle n'est pas dans un tube, elle reste dans le shell courant. Le manuel précise aussi que la commande est lancée de façon asynchrone, en même temps que les autres expansions : c'est pourquoi son code de sortie n'est pas recueilli automatiquement, et qu'il faut wait $!.

Pourquoi un tube crée des sous-shells

Pour connecter deux commandes par un tube, le shell crée le tube, puis un processus pour chaque commande, avec fork (leçon 6 de Premiers pas, partie Sous le capot). Quand la commande est un programme externe (grep), le processus enfant le charge par execve. Quand c'est une commande interne ou composée (while, { ... }), il n'y a rien à charger : l'enfant, copie du shell, exécute lui-même la boucle. C'est ce processus enfant qui porte la copie des variables. lastpipe évite simplement de créer l'enfant pour la dernière commande, que le shell principal exécute lui-même en lisant le tube ; le manuel réserve ce fonctionnement au cas où le contrôle des tâches est inactif, c'est-à-dire, en pratique, aux scripts.

Pièges courants

for f in $(ls), for f in $(find ...), for ligne in $(cat fichier). Découpage en mots et expansion des chemins sur des données : noms coupés, motifs réinterprétés, lignes transformées en mots. Motif (for f in rep/*), find -print0 avec read -d '', ou while IFS= read -r pour des lignes.

read sans -r, ou sans IFS=. Barres obliques inverses mangées, indentation perdue, lignes recollées. while IFS= read -r ligne, toujours, sauf découpage en champs voulu.

Le compteur qui reste à zéro. Boucle alimentée par un tube, donc dans un sous-shell. done < fichier ou done < <(commande).

Le motif sans correspondance. for f in *.csv fait un tour avec f='*.csv' quand le répertoire est vide. shopt -s nullglob, ou [[ -e $f ]] || continue.

Le motif entre guillemets. for f in "$rep/*.csv" ne fait qu'un tour, avec le motif littéral. Les guillemets entourent la variable, pas le motif : "$rep"/*.csv.

{1..$n}. L'expansion d'accolades précède celle des variables. for (( i = 1; i <= n; i++ )).

La dernière ligne perdue. Fichier sans saut de ligne final : read renvoie 1 avec une variable remplie. while IFS= read -r l || [[ -n $l ]].

Les \r invisibles. Un fichier venu de Windows fait échouer les comparaisons. printf '%q' ou cat -A pour le voir, ${ligne%$'\r'} pour le retirer.

Une commande du corps qui lit l'entrée standard. ssh, psql, un read de confirmation : ils consomment les données de la boucle. read -u 3 ... done 3< fichier, ou ssh -n, ou < /dev/null.

Écrire dans le fichier que l'on lit. while read ...; do echo ... >> fichier; done < fichier peut ne jamais se terminer, puisque la boucle lit ce qu'elle ajoute. Écrivez dans un autre fichier, puis renommez.

Rediriger dans la boucle au lieu d'après. echo ... >> rapport.txt dans le corps ouvre et ferme le fichier à chaque tour. done > rapport.txt l'ouvre une fois, et vide le fichier une fois au début, ce qui est généralement ce que l'on veut.

Un find -delete mal placé. find rep -delete -name '*.tmp' supprime tout : les actions s'évaluent dans l'ordre. Les tests d'abord, -delete en dernier, et -print à la place de -delete pour une répétition.

Sécurité

  • Les noms de fichiers sont des données fournies par des tiers. Les pièces jointes de Signalements portent un nom choisi par un habitant ; les journaux reçus contiennent des chemins demandés par n'importe quel client HTTP. Traitez-les comme des entrées hostiles : jamais de découpage en mots, guillemets partout, -- devant les noms passés à une commande, octet nul comme séparateur. Le piège n° 3 de BashPitfalls rappelle qu'un fichier nommé -rf devient une option pour rm * si le motif ne commence pas par un chemin (./*).
  • L'injection dans les journaux. Un nom de fichier qui contient un saut de ligne suivi de supprimé : /etc/passwd produirait, avec un printf '%s\n' naïf, une fausse ligne dans le journal de la purge. printf '%q' rend le saut de ligne visible et garde chaque entrée sur une ligne : c'est ce que fait le script de la leçon. Même précaution quand un script recopie une donnée lue dans un journal ou un formulaire.
  • Ne jamais exécuter ce que l'on lit. Une ligne lue par read ne doit jamais passer par eval, source, bash -c ou une substitution non protégée. Un fichier de données reste une donnée ; la leçon 8 montre comment lire un fichier de configuration sans l'exécuter.
  • Les liens symboliques. find ne suit pas les liens par défaut (-P, dit sa page de manuel), et rm sur un lien supprime le lien, pas sa cible : la purge ne peut pas être détournée vers /etc par un lien déposé dans l'arborescence. En revanche, si un compte malveillant peut écrire dans les répertoires parcourus, il peut remplacer un répertoire par un lien entre le moment où find le liste et celui où rm agit. La vraie protection est en amont : seul le compte signalements écrit dans /srv/donnees/pieces-jointes, et la purge tourne sous ce compte, jamais en root.
  • Les garde-fous de destruction. Une purge est un rm automatisé : validation stricte de la durée (le minimum de 30 jours du script), mode simulation, bilan chiffré, -xdev pour ne pas déborder sur un autre volume, et chemin de départ testé (-d) avant de lancer find. Un find "$racine" ... avec une variable racine vide partirait du répertoire courant : la leçon 3 montre ${racine:?} pour l'interdire.
  • L'épuisement des ressources. Charger un fichier non maîtrisé avec mapfile, ou une arborescence entière dans un for, consomme une mémoire proportionnelle à sa taille. Pour des données externes de taille inconnue, préférez un flux, et bornez ce qui peut l'être (find -maxdepth, head -n).

En production

  • Choisir l'outil selon le volume et la logique. Une action simple sur des fichiers : find -exec ... {} + ou find -delete. Une logique par élément (simulation, compteurs, décisions) : une boucle while read -d ''. Des centaines de milliers de lignes de journal : awk. Du JSON : jq. Du CSV avec des champs protégés : Python ou Miller. Bash orchestre ; il n'a pas à tout faire.
  • La purge sous un minuteur. purger-pieces-jointes est lancée par un minuteur systemd, comme les tâches de la leçon 10 du cours d'administration : sa sortie standard (le bilan et la liste des fichiers) part dans le journal, son code de sortie déclenche OnFailure=. Sur un gros volume, IOSchedulingClass=idle et Nice= dans l'unité évitent que la purge ne dégrade les entrées-sorties de l'export ou de la sauvegarde.
  • Simuler avant d'activer. Avant la première exécution réelle sur sig-outils, lancez --dry-run sous le compte signalements et comparez le nombre de fichiers annoncés avec ce qu'attend le registre des traitements. Un écart d'un ordre de grandeur révèle une erreur de date ou de chemin, avant qu'elle ne soit irréversible.
  • Un bilan chiffré, toujours. Le nombre de fichiers supprimés et le volume libéré, chaque nuit, dans le journal. Un zéro plusieurs jours de suite, alors que des photos arrivent tous les jours, est une anomalie à surveiller, au même titre qu'un échec.
  • Idempotence. Relancer la purge juste après une exécution ne fait rien de plus : elle ne supprime que ce qui est périmé. C'est ce qui permet de la relancer sans crainte après une interruption.
  • Locale fixée. L'ordre des motifs, le comportement des classes [a-z] et les messages des outils dépendent de la locale. Une tâche planifiée tourne avec la locale du système, pas la vôtre : fixez LC_ALL=C.UTF-8 dans l'unité si le script dépend d'un ordre ou analyse des messages.

Exercices

1. Prévoir les tours de boucle (niveau 100). Dans un répertoire qui contient a.txt et b c.txt, et aucun fichier .log, avec v='un deux', combien de tours font ces boucles, et avec quelles valeurs ? Vérifiez ensuite avec printf '<%s>\n' "$x" dans le corps. (a) for x in "un deux" trois ; (b) for x in $v ; (c) for x in "$v" ; (d) for x in *.txt ; (e) for x in *.log ; (f) la même que (e) après shopt -s nullglob ; (g) n=3; for x in {1..$n}.

Solution

(a) Deux tours : un deux, puis trois. (b) Deux tours, un puis deux : la variable non protégée est découpée. (c) Un tour : un deux. (d) Deux tours : a.txt, puis b c.txt, intact, car l'expansion des chemins a lieu après le découpage en mots. (e) Un tour, avec la valeur littérale *.log. (f) Aucun tour. (g) Un tour, avec la valeur {1..3}. L'expansion d'accolades a lieu avant celle des variables : au moment où elle s'exécute, elle voit {1..$n}, qui n'est pas un intervalle valide, et laisse le mot tel quel ; $n est ensuite remplacé par 3, ce qui donne le mot {1..3}, qui n'est plus réexpansé. Écrivez for (( x = 1; x <= n; x++ )).

2. Lire /etc/passwd (niveau 100). Écrivez une boucle qui affiche, pour chaque compte de /etc/passwd dont le shell est /bin/bash, son nom et son répertoire personnel. Le fichier a sept champs séparés par : : nom, mot de passe, UID, GID, commentaire, répertoire, shell.

Solution
while IFS=: read -r nom _ uid _ _ repertoire shell; do
    if [[ $shell == /bin/bash ]]; then
        printf '%s\t%s\n' "$nom" "$repertoire"
    fi
done < /etc/passwd

IFS=: ne vaut que pour read. Les _ absorbent les champs inutiles. Le commentaire (cinquième champ) peut contenir des espaces : comme : est le seul séparateur, ce n'est pas un problème. Sur une machine reliée à un annuaire, getent passwd donne aussi les comptes LDAP : done < <(getent passwd).

3. Le compteur à zéro (niveau 200). Ce script affiche toujours 0 export(s) de plus de 1 Mo. Expliquez pourquoi, puis corrigez-le de deux façons différentes, sans changer ce qu'il compte.

#!/usr/bin/env bash
gros=0
find /srv/donnees/exports -name 'signalements-*.csv' -size +1M | while read f; do
    gros=$((gros + 1))
done
echo "$gros export(s) de plus de 1 Mo"
Solution

La boucle est la dernière commande d'un tube : elle s'exécute dans un sous-shell, qui incrémente sa propre copie de gros. Le echo, dans le shell principal, voit la valeur d'origine. Le read sans -r ni IFS= est un défaut de plus, sans effet sur le comptage ici.

Correction 1, substitution de processus et noms séparés par l'octet nul :

gros=0
while IFS= read -r -d '' f; do
    gros=$((gros + 1))
done < <(find /srv/donnees/exports -name 'signalements-*.csv' -size +1M -print0)
echo "$gros export(s) de plus de 1 Mo"

Correction 2, lastpipe, qui fonctionne parce qu'un script n'a pas de contrôle des tâches :

shopt -s lastpipe
gros=0
find /srv/donnees/exports -name 'signalements-*.csv' -size +1M -print0 |
    while IFS= read -r -d '' f; do gros=$((gros + 1)); done
echo "$gros export(s) de plus de 1 Mo"

Une troisième solution évite la boucle : find ... -printf '.' | wc -c compte un caractère par fichier, quel que soit son nom.

4. Le redémarrage qui oublie un serveur (niveau 200). Ce morceau d'un ancien script de Camille ne redémarre l'API que sur sig-app-1, alors que hotes.txt contient les deux hôtes. Diagnostiquez et corrigez, en gardant ssh sans option particulière.

while read -r hote; do
    echo "redémarrage sur $hote"
    ssh "$hote" sudo systemctl restart signalements
done < hotes.txt
Solution

ssh lit son entrée standard pour la transmettre à la commande distante ; cette entrée est hotes.txt, dont il consomme tout le reste. Le read suivant trouve la fin du fichier, la boucle s'arrête après un seul tour. Correction sans toucher aux options de ssh : lire la liste sur un autre descripteur.

while IFS= read -r -u 3 hote; do
    [[ -n $hote && $hote != \#* ]] || continue
    echo "redémarrage sur $hote"
    ssh "$hote" sudo systemctl restart signalements
done 3< hotes.txt

On a ajouté au passage IFS= et le filtrage des lignes vides et des commentaires. ssh -n serait l'autre correction ; la leçon 11 traite le parallélisme et les délais de ce genre de boucle.

5. Rapport et performance (niveau 200). (a) Modifiez rapport-journaux pour qu'il ignore les \r en fin de ligne, sans lancer de commande externe. (b) Générez un journal de 200 000 lignes avec la commande ci-dessous, mesurez avec time la boucle Bash et l'équivalent awk, et donnez le rapport des deux durées. (c) À partir de quelle taille de journal changeriez-vous d'outil, pour une exécution par jour ? Et pour une exécution par minute ?

awk 'BEGIN { srand(42); split("200 200 200 201 304 404 500 503", c, " ");
  for (i = 0; i < 200000; i++) {
    t = sprintf("%02d:%02d:%02d", 9 + int(i / 3600) % 15, int(i / 60) % 60, i % 60)
    printf "2026-10-07T%s.%06d+00:00 sig-app-1 python3[812]: 172.16.8.%d - - [07/Oct/2026 %s] \"GET /signalements HTTP/1.1\" %s -\n",
      t, i % 1000000, i % 250 + 1, t, c[int(rand() * 8) + 1] } }' > gros.log
Solution

(a) Le \r se retrouve à la fin du dernier champ lu, ici le _ final ; le code HTTP, douzième champ, n'est pas touché tant que la ligne a ses treize champs. Pour être robuste quel que soit le nombre de champs, lisez la ligne entière, nettoyez-la, puis découpez-la avec une here-string :

while IFS= read -r ligne; do
    ligne=${ligne%$'\r'}
    read -r _ _ processus _ _ _ _ _ _ _ _ code _ <<< "$ligne"
    ...
done < "$journal"

La here-string ne lance aucun processus, mais elle a un coût : depuis Bash 5.1, son contenu passe par un tube s'il tient dans le tampon du tube, par un fichier temporaire sinon. Mesurez la différence.

(b) Sur notre poste, environ 2,7 s pour Bash et 0,13 s pour awk, soit un rapport de l'ordre de 20. Vos chiffres varieront ; le rapport reste du même ordre.

(c) Il n'y a pas de seuil universel, mais un raisonnement : une fois par jour, une boucle Bash de quelques secondes ne gêne personne, et la lisibilité peut primer jusqu'à quelques centaines de milliers de lignes. Une fois par minute, le temps de calcul se compare à l'intervalle et à la charge de la machine : dès quelques dizaines de milliers de lignes, awk s'impose. Dans tous les cas, aucune commande externe dans le corps de la boucle.

Récapitulatif

  • for parcourt une liste de mots construite d'avance ; while et until répètent selon le code de sortie d'une commande. break n et continue n agissent sur plusieurs niveaux.
  • Pour les fichiers d'un répertoire, un motif ("$rep"/*.csv) avec nullglob ; globstar pour descendre ; find dès qu'il faut un critère autre que le nom.
  • Jamais for x in $(commande) sur des données : découpage en mots et expansion des chemins.
  • Lire des lignes : while IFS= read -r ligne, avec || [[ -n $ligne ]] pour une dernière ligne sans saut de ligne, et ${ligne%$'\r'} contre les fins de ligne Windows.
  • Découper des champs : IFS=, read -r a b c reste, la dernière variable reçoit le reste ; read ignore les guillemets, donc ne lit pas le CSV général ni le JSON.
  • Un tube place la boucle dans un sous-shell : ses variables sont perdues. done < <(commande), ou shopt -s lastpipe dans un script ; wait $! récupère le code du producteur.
  • Seul l'octet nul sépare sûrement des noms de fichiers : find -print0, read -r -d '', xargs -0, mapfile -d ''.
  • Une commande du corps qui lit l'entrée standard vole les données de la boucle : read -u 3 ... done 3< fichier.
  • Sous le capot, read lit par blocs sur un fichier mais octet par octet sur un tube ; une boucle coûte surtout par les processus qu'elle lance. Au-delà de quelques dizaines de milliers de lignes, awk.
  • Souvent, la meilleure boucle est find -exec ... {} + ou find -delete.

Pour aller plus loin

  • Les FAQ n° 1, 20 et 24 de Greg's Wiki, qui couvrent chacune un de ces problèmes avec ses variantes et ses cas limites, et la page BashPitfalls, dont les premiers pièges concernent tous les boucles.
  • Dans le manuel de Bash, les sections Looping Constructs, Process Substitution, la description de read et de mapfile dans Bash Builtin Commands, et les options de The Shopt Builtin.
  • La page de manuel find(1), en particulier les sections sur -exec, -delete, -printf et le calcul des durées.
  • La RFC 4180 pour comprendre pourquoi le CSV n'est pas un format « séparé par des virgules », et la documentation de Miller ou du module csv de Python pour le traiter correctement.
  • La leçon suivante, Fonctions et portée, range la logique de la purge et du rapport dans des fonctions réutilisables.
+20 XP Carte du ciel →Mon cosmonaute →

Sources