Aller au contenu

Fonctions et portée

200 Compagnon ⏱ 1 h 40 bashlinuxdebianubuntushellcheck

À la fin, vous saurez

  • Définir une fonction Bash, lui passer des arguments et prévoir son code de retour
  • Choisir comment une fonction transmet un résultat (statut, sortie capturée, variable par référence) en connaissant le coût de chaque méthode
  • Isoler les variables d'une fonction avec local, et expliquer ce que la portée dynamique de Bash rend visible d'une fonction à l'autre
  • Écrire une bibliothèque de fonctions partagée, la charger de façon fiable depuis plusieurs scripts et la protéger contre le double chargement
  • Structurer un script autour d'une fonction main que l'on peut aussi charger sans l'exécuter
  • Diagnostiquer les pièges des fonctions : exit dans un sous-shell, local qui masque un échec, message capturé par erreur, nom qui masque une commande
  • Expliquer Shellshock et ce que l'exportation de fonctions implique encore pour la sécurité

Prérequis

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

Pourquoi

Ouvrez les cinq scripts que Camille a laissés dans /opt/signalements/bin/ et comptez les manières d'afficher une erreur. publier-export fait echo "ERREUR: $msg" ; purger-pieces-jointes a une fonction log qui préfixe l'heure ; verifier-sante a aussi une fonction log, différente, qui écrit sur la sortie standard ; deployer fait echo "[KO] ..." >&2 ; exit 2 à quatorze endroits ; rapport-journaux ne signale rien du tout. Cinq scripts, cinq conventions, et chaque correction se fait cinq fois.

Ces scripts ont un second défaut, plus grave : ils se lisent de haut en bas, comme une longue recette. publier-export fait 180 lignes sans une seule fonction. Pour savoir ce qu'il fait en cas d'export vide, il faut tout lire. Pour le tester, il faut l'exécuter en entier, dépôt dans le bucket de la mairie compris. Et quand on a voulu ajouter une vérification de l'en-tête du CSV, on l'a insérée à la ligne 97, entre deux blocs qui partagent une variable f dont personne ne sait plus d'où elle vient.

Enfin, verifier-sante contient un bogue qu'aucun test n'a vu, parce qu'il n'y a pas de tests. Il ressemble à ceci :

log() { echo "[$(date +%T)] $*"; }

code_http() {
  log "interrogation de $1"
  curl -s -o /dev/null -w '%{http_code}' "http://$1:8000/sante"
}

code=$(code_http 172.16.8.11)
if [ "$code" != 200 ]; then
  echo "sig-app-1 ne répond pas" | mail -s "ALERTE" astreinte@exemple.invalid
fi

La variable code ne vaut jamais 200 : elle contient deux lignes, le message de log puis le code HTTP, parce que tout ce que la fonction écrit sur sa sortie standard est capturé. L'alerte part donc à chaque exécution. L'équipe a fini par filtrer ces courriels, et le jour où sig-app-1 est réellement tombé, personne ne l'a vu.

Les fonctions répondent à ces trois problèmes : nommer un morceau de logique pour qu'il se lise comme une phrase (verifier_export "$fichier"), le partager entre scripts dans une bibliothèque, et le tester isolément. Mais les fonctions de Bash ne se comportent pas comme celles de Python ou de Go : elles ne renvoient pas de valeur, leurs variables sont globales par défaut, et leur portée est dynamique. Cette leçon explique ces différences par le mécanisme, puis écrit lib/commun.sh, la bibliothèque commune du dépôt signalements-outils, et réorganise publier-export autour d'une fonction main.

Les concepts

Une fonction est une commande définie dans le shell

Le manuel de Bash définit une fonction comme un objet que l'on appelle comme une commande simple et qui exécute une commande composée avec un nouveau jeu de paramètres positionnels. Trois idées dans cette phrase :

  • On l'appelle comme une commande. journaliser "début de l'export" ressemble à n'importe quelle commande, et une fonction peut être utilisée partout où une commande l'est : dans un tube, après &&, dans un if, avec des redirections.
  • Elle exécute une commande composée, le plus souvent un bloc { ...; }, parfois un sous-shell ( ... ), un if ou une boucle.
  • Elle a ses propres paramètres positionnels. Pendant l'appel, $1, $2, $# et "$@" désignent les arguments de la fonction, plus ceux du script. Ils sont restaurés au retour. Seul $0 ne change pas : il reste le nom du script.

Surtout, le manuel insiste : une fonction s'exécute dans le shell courant, sans créer de processus. C'est ce qui la distingue d'un script appelé depuis un autre script. Une fonction peut donc modifier les variables du script, changer son répertoire courant, ouvrir des descripteurs de fichiers : tout ce qu'elle fait persiste après elle, sauf ce que l'on rend explicitement local.

Deux syntaxes, une seule à retenir

# Forme POSIX, comprise par tous les shells (dash, bash, ksh, zsh)
journaliser() {
  printf '%s\n' "$*" >&2
}

# Forme propre à Bash et ksh, avec le mot réservé function
function journaliser {
  printf '%s\n' "$*" >&2
}

La norme POSIX ne connaît que la première forme, nom ( ) commande-composée [redirections]. La seconde n'apporte rien en Bash et casse sous sh. Le guide de style de Google tolère les deux à condition d'être cohérent dans tout un projet ; pour signalements-outils, on retient la forme POSIX, avec l'accolade ouvrante sur la ligne du nom. Les noms sont en minuscules avec des soulignés (verifier_export), ce qui les distingue des variables de configuration en majuscules (REPERTOIRE_EXPORTS).

Deux détails de syntaxe, qui surprennent la première fois :

  • dans { ...; }, l'espace après { et le ; (ou le saut de ligne) avant } sont obligatoires : { et } sont des mots réservés, pas des ponctuations. f() {echo x} est une erreur de syntaxe ;
  • une redirection écrite après la définition s'applique à chaque appel. avertir() { printf '%s\n' "$*"; } >&2 envoie toujours sur la sortie d'erreur, quel que soit le contenu du corps.

Une fonction n'existe qu'à partir du moment où le shell a exécuté sa définition. Bash lit un script au fur et à mesure : appeler verifier_export à la ligne 10 alors qu'elle est définie ligne 40 donne verifier_export: command not found. D'où la règle d'organisation que l'on appliquera plus bas : toutes les définitions d'abord, le programme ensuite.

Ce qu'une fonction renvoie : un statut, pas une valeur

Une fonction Bash ne « renvoie » pas de valeur au sens de Python. Elle a, comme toute commande, un code de sortie entre 0 et 255 :

  • par défaut, celui de la dernière commande exécutée dans son corps ;
  • ou celui que fixe la commande interne return n, qui termine la fonction immédiatement.

D'après le manuel, return ne garde que les 8 bits de poids faible de son argument : return 256 donne 0, return 300 donne 44, return -1 donne 255. Le statut sert à dire réussi ou échoué, éventuellement pourquoi (comme grep, 0 trouvé, 1 rien trouvé, 2 erreur), jamais à transporter un nombre de lignes ou une taille.

Pour transmettre un résultat (un chemin, un nombre, une liste), il y a quatre façons, que la page BashFAQ/084 de Greg's Wiki compare en détail :

MéthodeExempleAvantageInconvénient
Écrire sur la sortie standard, capturée par l'appelantchemin=$(chemin_export "$jour")lisible, familierla fonction tourne dans un sous-shell : coût d'un fork, et ses autres effets (variables, exit) sont perdus
Affecter une variable globale convenuechemin_export "$jour" puis $CHEMINrapidecouplage invisible, collisions de noms
Affecter une variable dont l'appelant donne le nomchemin_export "$jour" cheminrapide, expliciterisque de collision de noms (voir Pièges) ; propre à Bash 4.3 et plus
Écrire dans un fichierchemin_export "$jour" > "$tmp"fonctionne même depuis un sous-shellfichier temporaire à gérer

La première méthode est la bonne par défaut, tant que la fonction n'est pas appelée des milliers de fois et qu'elle n'a pas besoin de modifier l'état du script. La troisième s'impose dans les boucles et pour renvoyer plusieurs valeurs.

Variables : globales par défaut, locales sur demande

Dans une fonction, une affectation fichier=... modifie la variable globale fichier, celle du script. C'est l'inverse de la plupart des langages, et c'est la source de bogues la plus fréquente dans les scripts structurés en fonctions. La commande interne local déclare une variable dont la visibilité est restreinte à la fonction :

verifier_export() {
  local fichier=$1 entete
  ...
}

Le manuel ajoute que, dans une fonction, declare (et son synonyme typeset) crée aussi des variables locales, sauf avec l'option -g qui force la portée globale. local accepte toutes les options de declare : local -r pour une constante locale, local -i pour un entier, local -a pour un tableau (leçon 7).

local n'existe pas dans la norme POSIX, qui le cite seulement parmi les noms dont le comportement n'est pas spécifié. Dash, le sh de Debian et d'Ubuntu, le fournit quand même : c'est l'une des rares extensions qu'on peut utiliser presque partout.

La portée dynamique

Ce qu'une variable locale « restreint à la fonction » mérite une précision, que le manuel donne en toutes lettres : la portée est restreinte à la fonction et à ses enfants, c'est-à-dire aux fonctions qu'elle appelle. Bash utilise une portée dynamique (dynamic scoping) : quand une fonction lit une variable, Bash la cherche d'abord parmi ses variables locales, puis parmi celles de la fonction qui l'a appelée, puis de l'appelant de celle-ci, et ainsi de suite jusqu'aux variables globales. Ce qui compte, c'est l'ordre des appels au moment de l'exécution, pas l'endroit où le code est écrit.

La plupart des langages actuels (Python, Go, JavaScript) utilisent au contraire une portée lexicale : une fonction voit les variables de l'endroit où elle est écrite. En Bash, la même fonction peut voir deux valeurs différentes selon qui l'appelle :

afficher() { echo "fichier vu par afficher : ${fichier:-<vide>}"; }
traiter()  { local fichier="rapport.csv"; afficher; }

fichier="global.csv"
traiter     # fichier vu par afficher : rapport.csv
afficher    # fichier vu par afficher : global.csv

Appelée par traiter, afficher voit la variable locale de traiter, qui masque la globale ; appelée directement, elle voit la globale. Le manuel parle de variables qui en masquent (shadow) d'autres de même nom. La variable globale n'est jamais modifiée : quand traiter se termine, sa variable locale disparaît et la globale redevient visible.

C'est rarement ce que l'on cherche, et c'est souvent un piège : une fonction utilitaire qui lit fichier sans le recevoir en argument dépend de tous ses appelants possibles. Règle pratique : une fonction ne lit que ses arguments, ses propres variables locales et des constantes globales documentées (en majuscules, souvent readonly). Tout le reste passe par les arguments.

Les références de nom

Pour qu'une fonction écrive dans une variable choisie par l'appelant (troisième méthode du tableau), Bash 4.3 a introduit les références de nom (namerefs). declare -n ou local -n crée une variable qui ne contient pas une valeur mais le nom d'une autre variable ; toute lecture ou affectation passe à travers :

# chemin_export JOUR VARIABLE : range dans VARIABLE le chemin du CSV du JOUR
chemin_export() {
  local -n _chemin=$2
  printf -v _chemin '%s/signalements-%s.csv' "$REPERTOIRE_EXPORTS" "$1"
}

chemin_export 2026-10-07 fichier
echo "$fichier"     # /srv/donnees/exports/signalements-2026-10-07.csv

printf -v variable écrit le résultat de printf dans la variable au lieu de l'afficher : combiné à la référence, il évite à la fois le sous-shell et l'affichage. D'après le manuel, une référence de nom peut désigner un tableau ou un élément de tableau, mais un tableau ne peut pas lui-même devenir une référence. Le cas d'usage le plus courant, passer un tableau à une fonction, est traité en leçon 7.

Le préfixe _ du nom de la référence n'est pas décoratif : il réduit le risque de collision expliqué dans les Pièges courants.

Où Bash cherche une commande

La leçon 2 de Premiers pas a donné l'ordre de recherche d'un nom de commande. Le manuel de Bash le précise pour un script : si le nom ne contient pas de /, Bash cherche d'abord une fonction de ce nom, puis une commande interne (builtin), puis un exécutable dans les répertoires de PATH (en consultant sa table de hachage). Les alias passent avant tout, mais ils ne sont pas développés dans un shell non interactif, donc pas dans un script (sauf shopt -s expand_aliases, à éviter).

Conséquence directe : une fonction masque une commande interne ou un programme du même nom. Une fonction test, ls, cd ou log (macOS, par exemple, fournit un programme log) prend le pas sur l'original pour tout le reste du script, et pour les bibliothèques qu'il charge. Deux commandes internes permettent de contourner une fonction :

  • command nom ... exécute nom en ignorant les fonctions : seules les commandes internes et les programmes de PATH sont considérés. command -v nom affiche ce qui serait exécuté et renvoie 1 si rien n'est trouvé : c'est le test d'existence d'une commande le plus portable ;
  • builtin nom ... exécute la commande interne nom, et rien d'autre. C'est ainsi qu'une fonction cd qui enrichit le cd d'origine appelle ce dernier : cd() { ...; builtin cd "$@"; }.

type -t nom répond par un seul mot, alias, keyword, function, builtin ou file, ce qui en fait un bon outil de diagnostic.

En pratique

Les essais ont été faits avec Bash 5.2.21, en LC_ALL=C pour que les messages soient ceux que vous rencontrerez dans les journaux. Créez un répertoire d'essai et un clone de signalements-outils, ou travaillez dans un répertoire vide qui en reproduit la structure (bin/, lib/).

Écrire et appeler une première fonction

On commence par la plus utile, celle qui écrit un message. Les messages d'un script vont sur la sortie d'erreur : la sortie standard est réservée aux données, ce que la leçon 8 formalisera. C'est exactement ce qui a manqué à verifier-sante.

journaliser() {
  printf '%(%Y-%m-%dT%H:%M:%S%z)T %s : %s\n' -1 "${0##*/}" "$*" >&2
}

journaliser "début de l'export"
2026-10-08T14:45:36+0200 publier-export : début de l'export

Ce que fait chaque morceau :

  • %(...)T est un format de la commande interne printf de Bash : il met en forme une date selon les codes de strftime ; l'argument -1 signifie « maintenant ». Pas de date externe, donc pas de processus supplémentaire, ce qui compte pour une fonction appelée des centaines de fois. L'horodatage ISO 8601 avec le décalage horaire se trie et se compare sans ambiguïté ;
  • ${0##*/} retire de $0 tout ce qui précède le dernier / (leçon 3) : le nom du script, pas son chemin ;
  • "$*" colle tous les arguments en une seule chaîne séparée par des espaces, ce que l'on veut pour un message ; "$@" les garderait séparés, ce qui donnerait ici un argument par mot à printf ;
  • >&2 redirige la sortie de printf vers le descripteur 2.

Les arguments d'une fonction

Dans le corps, $1, $2... sont les arguments de l'appel, $# leur nombre, "$@" leur liste exacte (leçon 2). Les arguments du script ne sont plus visibles, ce qui piège tous ceux qui écrivent :

afficher_date() {
  echo "date demandée : ${1:-aujourd'hui}"
}

afficher_date            # date demandée : aujourd'hui, même si le script a reçu une date
afficher_date "$@"       # transmet les arguments du script à la fonction

ShellCheck signale ce cas par l'avertissement SC2120, foo references arguments, but none are ever passed : la fonction lit $1, mais aucun appel ne lui passe d'argument. Le remède est presque toujours d'appeler afficher_date "$@", ou de passer l'argument voulu.

Pour une fonction dont un argument est obligatoire, deux styles. Le plus court échoue avec un message de Bash :

verifier_export() {
  local fichier=${1:?"verifier_export : fichier manquant"}
  ...
}

Le plus clair passe par mourir, que l'on écrit plus bas. Dans les deux cas, ne laissez pas une fonction travailler sur un argument vide : rm -rf "$1/" avec $1 vide devient rm -rf /.

Renvoyer un statut, et seulement un statut

verifier_export répond à une question par oui ou non : c'est un usage naturel du statut. Chaque échec explique sa raison sur la sortie d'erreur et renvoie 1 :

verifier_export() {
  local fichier=$1 entete lignes
  if [[ ! -f $fichier ]]; then
    avertir "export introuvable : $fichier"
    return 1
  fi
  if [[ ! -s $fichier ]]; then
    avertir "export vide : $fichier"
    return 1
  fi
  IFS= read -r entete < "$fichier"
  if [[ ${entete%$'\r'} != "$ENTETE_ATTENDUE" ]]; then
    avertir "en-tête inattendu : $entete"
    return 1
  fi
  lignes=$(wc -l < "$fichier")
  if (( lignes < 2 )); then
    avertir "aucune ligne de données dans $fichier"
    return 1
  fi
}

La dernière commande exécutée est le test (( lignes < 2 )), faux quand tout va bien : son statut 1 n'est pas celui de la fonction, parce que le if qui l'entoure renvoie 0 quand aucune branche n'est exécutée. Si vous terminez une fonction par un test nu, comme [[ -s $fichier ]], son statut devient celui de la fonction : c'est voulu pour une fonction-prédicat, piégeux ailleurs. En cas de doute, terminez par return 0.

L'appelant utilise le statut comme n'importe quelle commande :

if ! verifier_export "$fichier"; then
  mourir -c 3 "export invalide, rien n'est publié"
fi
# ou, plus court :
verifier_export "$fichier" || mourir -c 3 "export invalide, rien n'est publié"

Renvoyer une valeur : mesurer avant de choisir

Comparons les deux façons de calculer le nom d'un fichier d'export : par la sortie standard capturée, puis par référence de nom. On répète l'appel 5 000 fois, comme dans une boucle sur des fichiers :

nom_export()   { printf 'signalements-%s.csv' "$1"; }
nom_export_v() { local -n _sortie=$1; printf -v _sortie 'signalements-%s.csv' "$2"; }

time for ((i = 0; i < 5000; i++)); do x=$(nom_export 2026-10-08); done
time for ((i = 0; i < 5000; i++)); do nom_export_v x 2026-10-08; done

Sur notre machine de test, la première boucle a pris un peu plus de 3 secondes, la seconde 35 millisecondes : environ cent fois moins. L'écart vient entièrement de $( ) : pour capturer la sortie, Bash crée un sous-shell, c'est-à-dire un processus enfant par fork, branche sa sortie sur un tube, lit le tube, puis attend la fin de l'enfant. La fonction elle-même ne coûte presque rien. Les chiffres absolus dépendent de la machine ; l'ordre de grandeur, lui, est stable.

Il y a une seconde différence, plus importante que la vitesse : ce que la fonction fait dans un sous-shell est perdu.

compteur=0
incrementer() { compteur=$((compteur + 1)); echo "$compteur"; }

valeur=$(incrementer)
echo "valeur=$valeur compteur=$compteur"
valeur=1 compteur=0

La fonction a bien incrémenté compteur, mais dans le sous-shell, qui a disparu avec sa copie des variables. Le script, lui, voit toujours 0.

Règle de choix pour signalements-outils :

  • $(fonction) pour une valeur simple, calculée une fois ou quelques fois, par une fonction sans effet de bord ;
  • une référence de nom (local -n) avec printf -v dans les boucles, ou quand il faut renvoyer plusieurs valeurs ;
  • jamais de variable globale implicite pour transmettre un résultat.

local et l'échec masqué

Écrivons naïvement une fonction qui lit la première ligne d'un fichier distant :

lire_version() {
  local version=$(ssh sig-app-1 cat /opt/signalements/VERSION)
  echo "statut de la ligne précédente : $?"
}

Si ssh échoue, $? vaut quand même 0. La ligne contient deux commandes : la substitution $(ssh ...), puis la commande interne local, qui crée la variable et réussit. Le statut final est celui de local. Vérifions avec false, qui échoue toujours :

m() {
  local x=$(false); echo "local x=\$(false) -> $?"
  local y; y=$(false); echo "y=\$(false) -> $?"
}
m
local x=$(false) -> 0
y=$(false) -> 1

Avec la déclaration séparée de l'affectation, l'affectation simple y=$(false) prend le statut de la substitution. ShellCheck le signale par SC2155, Declare and assign separately to avoid masking return values, et le guide de style de Google en fait une règle : déclaration et affectation dans deux instructions quand la valeur vient d'une substitution de commande. Le même masquage existe avec export, readonly et declare. La leçon 9 montrera que ce détail neutralise aussi set -e.

Une bibliothèque commune : lib/commun.sh

Il est temps de remplacer les cinq conventions de Camille par une seule. Toutes les fonctions qui ne sont propres à aucun script vont dans lib/commun.sh, que chaque script charge par la commande interne source (ou son synonyme POSIX .). Le manuel la décrit ainsi : elle lit et exécute les commandes du fichier dans l'environnement du shell courant. Les fonctions que le fichier définit deviennent donc celles du script, exactement comme si on les avait copiées.

Voici le fichier complet, que toutes les leçons suivantes réutilisent :

# shellcheck shell=bash
# lib/commun.sh : fonctions partagées par les outils de Signalements.
# Ce fichier se charge par « source » ; il ne s'exécute pas seul.

# Ne charger qu'une fois, même si plusieurs fichiers le demandent.
[[ -n ${_COMMUN_SH_CHARGE:-} ]] && return 0
readonly _COMMUN_SH_CHARGE=1

# Nom affiché dans les messages : celui du script principal, sauf s'il est déjà fixé.
NOM_OUTIL=${NOM_OUTIL:-${0##*/}}

# journaliser MESSAGE...
#   Écrit une ligne sur la sortie d'erreur, horodatée sauf si HORODATER vaut 0
#   (sous systemd, le journal horodate déjà chaque ligne).
journaliser() {
  if [[ ${HORODATER:-1} == 0 ]]; then
    printf '%s : %s\n' "$NOM_OUTIL" "$*" >&2
  else
    printf '%(%Y-%m-%dT%H:%M:%S%z)T %s : %s\n' -1 "$NOM_OUTIL" "$*" >&2
  fi
}

# deboguer MESSAGE...
#   Comme journaliser, seulement si VERBEUX vaut 1. Renvoie toujours 0.
deboguer() {
  [[ ${VERBEUX:-0} == 1 ]] || return 0
  journaliser "débogage : $*"
}

# avertir MESSAGE...
#   Signale un problème qui n'arrête pas le script.
avertir() {
  journaliser "attention : $*"
}

# mourir [-c CODE] MESSAGE...
#   Journalise une erreur et termine le script avec CODE (1 par défaut).
#   Attention : appelée dans $(...), elle ne termine que le sous-shell.
mourir() {
  local code=1
  if [[ ${1:-} == -c ]]; then
    code=$2
    shift 2
  fi
  journaliser "erreur : $*"
  exit "$code"
}

# exiger COMMANDE...
#   Vérifie que chaque commande est disponible ; sinon, les signale toutes et meurt.
exiger() {
  local commande manque=0
  for commande in "$@"; do
    if ! command -v -- "$commande" > /dev/null 2>&1; then
      avertir "commande introuvable : $commande"
      manque=1
    fi
  done
  (( manque == 0 )) || mourir "prérequis manquants, installez les commandes ci-dessus"
}

Quelques choix méritent une explication.

  • Pas de shebang, pas de droit d'exécution. Le fichier n'est jamais exécuté seul ; la directive # shellcheck shell=bash dit à ShellCheck quel langage analyser, puisqu'il n'y a pas de #! pour le lui indiquer. Installez-le en 0644.
  • Un en-tête par fonction, au format recommandé par le guide de style de Google : ce que fait la fonction, ses arguments, ce qu'elle écrit, ce qu'elle renvoie quand ce n'est pas évident. C'est la documentation que lira la personne qui reprendra le dépôt après vous.
  • deboguer renvoie toujours 0. Sans le return 0 explicite, son statut serait celui du test [[ ${VERBEUX:-0} == 1 ]], donc 1 en mode normal : chaque deboguer serait un « échec », ce qui ferait tomber le script dès la leçon 9 et set -e.
  • mourir accepte un code. Les codes de publier-export (3 pour un export invalide, 4 pour un échec de dépôt) seront fixés par la leçon 8 ; la bibliothèque n'a pas à les connaître.
  • exiger vérifie tout avant de mourir. Un script qui signale une commande manquante, puis une autre au lancement suivant, puis une troisième, fait perdre trois allers-retours. On les liste toutes d'un coup.
  • Aucune option du shell. La bibliothèque ne fait ni set -e, ni shopt, ni cd : elle modifierait l'environnement de tous les scripts qui la chargent. C'est au script principal de décider de son mode d'exécution.

Essayons-la depuis un script de test, avec deux commandes qui n'existent pas :

#!/usr/bin/env bash
REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh"

journaliser "début"
deboguer "invisible"
VERBEUX=1 deboguer "visible"
avertir "un avertissement"
exiger gzip sha256sum aws-introuvable psql-absent
2026-10-08T14:45:36+0200 essai : début
2026-10-08T14:45:36+0200 essai : débogage : visible
2026-10-08T14:45:36+0200 essai : attention : un avertissement
2026-10-08T14:45:36+0200 essai : attention : commande introuvable : aws-introuvable
2026-10-08T14:45:36+0200 essai : attention : commande introuvable : psql-absent
2026-10-08T14:45:36+0200 essai : erreur : prérequis manquants, installez les commandes ci-dessus

Le script se termine avec le code 1. Notez VERBEUX=1 deboguer "visible" : une affectation placée devant un appel de fonction ne vaut que pour cet appel.

Charger la bibliothèque depuis n'importe où

La ligne délicate est celle qui trouve lib/commun.sh. Les scripts seront appelés de partout : depuis le dépôt pendant le développement, depuis /opt/signalements/bin/ en production, par un lien symbolique dans /usr/local/bin/, par un minuteur systemd dont le répertoire courant est /. Un chemin relatif au répertoire courant (source lib/commun.sh) ne marche que dans le premier cas. Il faut un chemin relatif au script.

Bash fournit le tableau BASH_SOURCE : son premier élément est le fichier qui contient le code en cours d'exécution. Pour le script principal, c'est le chemin par lequel on l'a lancé. Mais ce chemin peut être un lien symbolique. Faisons l'essai avec un lien lien/outil vers depot/bin/outil :

REP_BIN=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")
echo "naïf : $(dirname -- "${BASH_SOURCE[0]}")   résolu : $REP_BIN"
naïf : ./lien   résolu : /home/vous/essais/depot/bin

Sans résolution, on chercherait lib/ à côté du lien, où il n'existe pas. readlink -f (GNU coreutils) suit tous les liens symboliques et renvoie un chemin absolu ; dirname en garde le répertoire ; les -- protègent contre un chemin qui commencerait par un tiret. D'où la ligne retenue pour tous les scripts du dépôt :

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh" || {
  printf '%s : impossible de charger lib/commun.sh\n' "${0##*/}" >&2
  exit 1
}

Pourquoi BASH_SOURCE[0] plutôt que $0 ? Pour le script principal, les deux coïncident. Mais $0 est le nom que le processus parent a choisi de donner, et la page BashFAQ/028 de Greg's Wiki rappelle qu'il peut valoir n'importe quoi ; surtout, dans un fichier chargé par source, $0 reste le nom du script appelant, alors que BASH_SOURCE[0] désigne bien le fichier chargé. Cette même page déconseille de dépendre de l'emplacement du script quand on peut l'éviter ; ici, on ne peut pas : la bibliothèque est livrée avec les scripts, dans le même dépôt. Elle ne fonctionne pas pour un script reçu sur l'entrée standard (curl ... | bash), cas que l'on n'a pas à prendre en charge.

La directive # shellcheck source=../lib/commun.sh sert à l'analyse : sans elle, ShellCheck ne sait pas quel fichier se cache derrière "$REP_OUTILS/lib/commun.sh" et le signale (SC1090, Can't follow non-constant source), puis ne vérifie pas les fonctions utilisées. D'après le code source de ShellCheck, un chemin relatif donné par cette directive est cherché d'abord depuis le répertoire courant, puis dans chaque répertoire déclaré par source-path ; la valeur spéciale SCRIPTDIR y désigne le répertoire du script analysé. La leçon 12 place donc source-path=SCRIPTDIR dans le .shellcheckrc du dépôt, avec external-sources=true, sans quoi ShellCheck ne suit pas du tout les fichiers chargés (SC1091).

Le || { ...; exit 1; } traite le seul cas où l'on ne peut pas utiliser mourir : quand c'est précisément la bibliothèque qui manque.

Le double chargement

Les deux premières lignes utiles de commun.sh sont une garde :

[[ -n ${_COMMUN_SH_CHARGE:-} ]] && return 0
readonly _COMMUN_SH_CHARGE=1

Si un script charge commun.sh, puis une autre bibliothèque (lib/s3.sh, par exemple) qui le charge aussi, le second source trouve la variable et s'arrête aussitôt. D'après le manuel, return exécuté dans un fichier chargé par source arrête la lecture de ce fichier, sans terminer le script. Sans garde, le second chargement redéfinirait les fonctions (sans dommage ici) mais échouerait sur toute variable readonly déjà définie, avec un readonly variable sur la sortie d'erreur.

mourir et les sous-shells

Le commentaire de mourir contient un avertissement qui mérite une démonstration. Une fonction qui calcule une valeur et l'écrit sur sa sortie standard, appelée par $( ), s'exécute dans un sous-shell. Si elle appelle mourir, l'exit termine le sous-shell, pas le script :

lire_date() {
  [[ -n ${1:-} ]] || mourir "date manquante"
  printf '%s' "$1"
}

d=$(lire_date "")
echo "le script continue, code=$? d=[$d]"
2026-10-08T14:46:02+0200 essai : erreur : date manquante
le script continue, code=1 d=[]

Le message s'affiche, le statut de l'affectation vaut 1, mais le script continue avec une date vide. Deux parades : vérifier le statut à l'appel (d=$(lire_date "$1") || exit), ou ne jamais appeler mourir dans une fonction destinée à $( ), et lui faire renvoyer un statut d'échec que l'appelant traite. La leçon 9 montre comment set -e se comporte (et ne se comporte pas) dans ce cas.

Le motif main "$@"

Reste à organiser le script lui-même. On adopte la structure recommandée par le guide de style de Google : constantes en tête, puis toutes les fonctions, puis une fonction main qui contient le programme, appelée sur la dernière ligne. Cela a trois avantages, dont le dernier n'est pas évident.

Lisibilité. main se lit comme la table des matières du script : vérifier, compresser, déposer. Les détails sont dans les fonctions.

Testabilité. En entourant l'appel d'une condition, on peut charger le script sans l'exécuter :

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Exécuté, le script a BASH_SOURCE[0] égal à $0, et main est appelée. Chargé par source depuis un autre script (ou un test), $0 est le nom de l'appelant, la condition est fausse, et seules les définitions sont lues : on peut alors appeler verifier_export seule, sur un fichier de test. C'est ce qu'exploitera Bats en leçon 12.

Robustesse face à une modification en cours d'exécution. Bash lit un script au fur et à mesure qu'il l'exécute, pas en entier au démarrage. Si le fichier est réécrit sur place pendant qu'il tourne, Bash continue de lire à la position où il en était, dans le nouveau contenu. Essayez avec un script qui dort une seconde, réécrit pendant ce temps avec une ligne de commentaire de plus en tête :

etape 1
modif.sh: line 4: ong: command not found

Bash a repris sa lecture au milieu d'une ligne du nouveau fichier et a tenté d'exécuter un fragment de mot. Sur un serveur, c'est ce qui arrive quand on déploie une nouvelle version d'un script par cp (qui réécrit le fichier existant) pendant qu'une exécution est en cours, typiquement un export de nuit un peu long. Avec le motif main, le corps de main est entièrement lu et analysé avant d'être exécuté, et le exit sur la même construction garantit que Bash ne lira plus rien du fichier après le retour de main. Cela ne dispense pas de déployer proprement (écrire un nouveau fichier puis le renommer, comme le fera la leçon 10), mais cela ferme la fenêtre de risque.

publier-export après cette leçon

Voici le script réorganisé. Il ne lit pas encore d'options (leçon 8) et ne fait pas encore set -euo pipefail (leçon 9), mais chaque étape est une fonction nommée, chaque erreur a un message et un code, et l'ancien envoi d'un fichier vide est bloqué par verifier_export.

#!/usr/bin/env bash
# publier-export : dépose l'export quotidien de Signalements pour la mairie.
# État après la leçon 6 : structuré en fonctions, sans options ni set -e.

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh" || {
  printf 'publier-export : impossible de charger lib/commun.sh\n' >&2
  exit 1
}

REPERTOIRE_EXPORTS=${REPERTOIRE_EXPORTS:-/srv/donnees/exports}
DESTINATION=${DESTINATION:-s3://sig-exports-mairie}
readonly POINT_ACCES=https://s3.fr-par.scw.cloud
readonly ENTETE_ATTENDUE='id,type,commune,date'

# chemin_export JOUR VARIABLE
#   Range dans VARIABLE le chemin du CSV du JOUR (AAAA-MM-JJ).
chemin_export() {
  local -n _chemin=$2
  printf -v _chemin '%s/signalements-%s.csv' "$REPERTOIRE_EXPORTS" "$1"
}

# verifier_export FICHIER
#   Renvoie 0 si l'export est publiable ; sinon explique pourquoi et renvoie 1.
verifier_export() {
  local fichier=$1 entete lignes
  if [[ ! -f $fichier ]]; then
    avertir "export introuvable : $fichier"
    return 1
  fi
  if [[ ! -s $fichier ]]; then
    avertir "export vide : $fichier"
    return 1
  fi
  IFS= read -r entete < "$fichier"
  if [[ ${entete%$'\r'} != "$ENTETE_ATTENDUE" ]]; then
    avertir "en-tête inattendu : $entete"
    return 1
  fi
  lignes=$(wc -l < "$fichier")
  if (( lignes < 2 )); then
    avertir "aucune ligne de données dans $fichier"
    return 1
  fi
  deboguer "$fichier : lignes de données : $((lignes - 1))"
}

# compresser FICHIER
#   Écrit FICHIER.gz et son empreinte FICHIER.gz.sha256 dans le même répertoire.
compresser() {
  local fichier=$1
  gzip --keep --force -- "$fichier" || return 1
  (cd -- "${fichier%/*}" && sha256sum -- "${fichier##*/}.gz" > "${fichier##*/}.gz.sha256")
}

# deposer ARCHIVE JOUR
#   Copie ARCHIVE et son empreinte dans DESTINATION/AAAA/MM/.
deposer() {
  local archive=$1 jour=$2 cible
  cible="$DESTINATION/${jour:0:4}/${jour:5:2}/"
  aws s3 cp --endpoint-url "$POINT_ACCES" "$archive" "$cible" &&
    aws s3 cp --endpoint-url "$POINT_ACCES" "$archive.sha256" "$cible"
}

main() {
  local jour fichier
  exiger gzip sha256sum aws
  jour=${1:-$(date +%F)}
  chemin_export "$jour" fichier
  verifier_export "$fichier" || mourir -c 3 "export invalide, rien n'est publié"
  compresser "$fichier" || mourir "compression impossible : $fichier"
  deposer "$fichier.gz" "$jour" || mourir -c 4 "échec du dépôt vers $DESTINATION"
  journaliser "export du $jour publié dans $DESTINATION"
}

if [[ ${BASH_SOURCE[0]} == "$0" ]]; then
  main "$@"
  exit
fi

Points à remarquer :

  • REPERTOIRE_EXPORTS et DESTINATION prennent une valeur par défaut seulement si elles ne sont pas déjà définies (${VAR:-défaut}, leçon 3) : on peut lancer le script contre un répertoire d'essai sans le modifier. Les vraies constantes sont readonly ;
  • compresser lance sha256sum dans un sous-shell ( cd ... && ... ) : le cd n'affecte que ce sous-shell, le script garde son répertoire courant. L'empreinte contient ainsi le seul nom du fichier, ce qui permet à la mairie de la vérifier avec sha256sum -c après téléchargement ;
  • main est la seule fonction qui décide d'arrêter le script. Les autres renvoient un statut : on peut les réutiliser et les tester sans qu'elles tuent leur appelant ;
  • jour=${1:-$(date +%F)} : le premier argument de main, donc du script grâce à main "$@". Aucune validation du format pour l'instant : c'est le travail de la leçon 8.

Pour l'essayer sans toucher au bucket de la mairie, placez en tête de PATH un faux aws qui se contente d'afficher ses arguments (la leçon 12 généralisera ce procédé), et pointez REPERTOIRE_EXPORTS vers un répertoire d'essai :

$ mkdir -p faux exports
$ printf '#!/usr/bin/env bash\nprintf "aws"; printf " <%%s>" "$@"; echo\n' > faux/aws && chmod +x faux/aws
$ printf 'id,type,commune,date\n1,nid-de-poule,Exempleville,2026-10-07\n' > exports/signalements-2026-10-07.csv
$ PATH="$PWD/faux:$PATH" REPERTOIRE_EXPORTS=$PWD/exports bin/publier-export 2026-10-07; echo "code=$?"
aws <s3> <cp> <--endpoint-url> <https://s3.fr-par.scw.cloud> </home/vous/essais/exports/signalements-2026-10-07.csv.gz> <s3://sig-exports-mairie/2026/10/>
aws <s3> <cp> <--endpoint-url> <https://s3.fr-par.scw.cloud> </home/vous/essais/exports/signalements-2026-10-07.csv.gz.sha256> <s3://sig-exports-mairie/2026/10/>
2026-10-08T14:47:46+0200 publier-export : export du 2026-10-07 publié dans s3://sig-exports-mairie
code=0

Avec un export vide ou absent, le script s'arrête avant tout dépôt, avec le code 3 :

2026-10-08T14:47:46+0200 publier-export : attention : export vide : /home/vous/essais/exports/signalements-2026-10-06.csv
2026-10-08T14:47:46+0200 publier-export : erreur : export invalide, rien n'est publié

Et grâce à la garde finale, on peut appeler une fonction isolément :

$ bash -c 'source bin/publier-export; REPERTOIRE_EXPORTS=/x; chemin_export 2026-10-08 f; echo "$f"'
/x/signalements-2026-10-08.csv

Note

Pour alléger les sorties, la suite du cours montre les messages de lib/commun.sh sans horodatage, comme si vous aviez lancé export HORODATER=0 dans votre shell (la variable est expliquée dans « En production ») : publier-export : erreur : export vide plutôt que 2026-10-08T05:00:01+0200 publier-export : erreur : export vide. Faites de même pour comparer vos essais aux sorties des leçons.

Sous le capot

Ce que Bash garde d'une fonction

Quand Bash lit une définition de fonction, il ne garde pas le texte : il l'analyse et conserve l'arbre de commandes obtenu. declare -f nom réimprime cet arbre, ce qui le montre bien :

$ f() { # commentaire
>   echo   "a"   ;   }
$ declare -f f
f () 
{ 
    echo "a"
}

Le commentaire a disparu, les espaces sont normalisés. Deux conséquences : une erreur de syntaxe dans le corps est détectée à la définition, pas à l'appel ; et l'expansion des variables du corps n'a lieu qu'à l'exécution, à chaque appel. declare -F liste seulement les noms des fonctions définies ; avec shopt -s extdebug, il ajoute le fichier et la ligne de définition, ce qui aide à savoir quelle bibliothèque a défini quoi.

La pile d'appels : FUNCNAME, BASH_SOURCE, BASH_LINENO

Pendant l'exécution d'une fonction, Bash tient trois tableaux parallèles qui décrivent la pile d'appels :

  • FUNCNAME[0] est la fonction en cours, FUNCNAME[1] celle qui l'a appelée, et ainsi de suite ; le dernier élément vaut main, qui désigne ici le niveau du script et non notre fonction main. Le manuel précise que la variable n'existe que pendant l'exécution d'une fonction ;
  • BASH_SOURCE[i] est le fichier où FUNCNAME[i] est définie ;
  • BASH_LINENO[i] est la ligne, dans le fichier BASH_SOURCE[i+1], où FUNCNAME[i] a été appelée.

La commande interne caller lit ces informations à notre place. Sans argument, elle affiche la ligne et le fichier de l'appel courant ; avec un entier, la ligne, la fonction et le fichier du cadre correspondant. Dans un fichier t1.sh où appelant, ligne 19, appelle pile, ligne 18 :

pile() {
  echo "FUNCNAME=${FUNCNAME[*]} BASH_LINENO=${BASH_LINENO[*]} BASH_SOURCE=${BASH_SOURCE[*]}"
  caller 0
  caller 1
}
appelant() { pile; }
appelant
FUNCNAME=pile appelant main BASH_LINENO=18 19 0 BASH_SOURCE=t1.sh t1.sh t1.sh
18 appelant t1.sh
19 main t1.sh

C'est avec ces variables que la leçon 9 affichera une pile d'appels complète quand un script échoue, et que la garde [[ ${BASH_SOURCE[0]} == "$0" ]] sait si elle est exécutée ou chargée.

Paramètres positionnels et variables locales

À l'appel d'une fonction, Bash sauvegarde les paramètres positionnels du niveau courant, installe ceux de l'appel, et les restaure au retour, avec $#. Il crée aussi un nouveau contexte de variables, empilé au-dessus de celui de l'appelant. local crée la variable dans ce contexte ; une lecture cherche le nom en partant du contexte le plus récent et en descendant la pile. C'est exactement la portée dynamique décrite plus haut : il n'y a pas de notion de « fonction englobante » dans le code source, seulement une pile de contextes à l'exécution. Au retour, le contexte est dépilé et ses variables disparaissent.

unset suit la même règle : appliqué à une variable locale du contexte courant, il la rend non définie jusqu'au retour de la fonction ; appliqué à une variable d'un appelant, il la supprime dans ce contexte-là, et la page de manuel décrit l'option localvar_unset qui modifie ce cas limite. Retenez surtout qu'un unset dans une fonction peut toucher une variable de l'appelant.

local - (un tiret seul) est une forme moins connue : elle rend locales les options du shell positionnées par set dans la fonction. Elles sont restaurées au retour :

opts() { local -; set -f; echo "dans opts : $-"; }
echo "avant : $-"; opts; echo "après : $-"
avant : hB
dans opts : fhB
après : hB

$- liste les options actives ; f (noglob, désactivation de l'expansion des chemins) n'est actif que pendant la fonction. Une fonction de bibliothèque qui a besoin de changer une option pour elle seule doit utiliser local -. Attention, cela ne concerne que les options de set, pas celles de shopt.

Enfin, la profondeur d'appel n'est pas bornée par défaut : une récursion infinie consomme de la mémoire jusqu'à l'échec. La variable FUNCNEST, si on lui donne une valeur positive, fixe une profondeur maximale au-delà de laquelle Bash interrompt la commande.

Une référence de nom, résolue à chaque accès

local -n _chemin=$2 crée une variable locale _chemin portant l'attribut nameref et dont la valeur est le texte fichier. Chaque accès à _chemin est réécrit en accès à la variable nommée fichier, et cette variable est cherchée selon la portée dynamique, depuis le contexte de la fonction. C'est ce détail qui crée le piège de collision : si la fonction possède elle-même une variable locale fichier, c'est elle que la référence trouve en premier, et non celle de l'appelant.

export -f : une fonction dans l'environnement

Une fonction n'est connue que du shell qui l'a définie. Un script lancé comme un nouveau processus (bash autre-script, ou xargs bash -c '...') ne la voit pas, sauf si on l'exporte avec export -f. Comme l'environnement d'un processus ne contient que des chaînes NOM=valeur, Bash y code la fonction sous la forme d'une variable au nom spécial :

$ saluer() { echo "bonjour $1"; }
$ export -f saluer
$ env | grep -A1 BASH_FUNC
BASH_FUNC_saluer%%=() {  echo "bonjour $1"
}
$ bash -c 'saluer Camille'
bonjour Camille

Au démarrage, tout Bash parcourt son environnement, reconnaît les variables nommées BASH_FUNC_<nom>%% dont la valeur commence par () {, et les analyse comme du code pour définir les fonctions. Ce mécanisme a une histoire, racontée dans la partie Sécurité.

Pièges courants

Appeler une fonction avant sa définition. publier-export: line 10: verifier_export: command not found alors que la fonction est bien dans le fichier : elle est définie plus bas. Toutes les définitions en tête, main à la fin.

local x=$(commande) qui masque l'échec. Le statut est celui de local (SC2155). Déclarez d'abord, affectez ensuite : local x; x=$(commande).

Un message capturé avec le résultat. Le bogue de verifier-sante : tout ce qu'une fonction écrit sur la sortie standard part dans $( ), messages compris. Les messages vont sur la sortie d'erreur (>&2), toujours, et les fonctions de commun.sh le font pour vous.

exit dans un sous-shell. mourir appelé dans $( ), dans un tube (... | while read, leçon 5) ou dans un bloc ( ) ne termine que le sous-shell. Vérifiez le statut à la sortie du sous-shell.

La variable de boucle globale. Le plus sournois. Un script parcourt ses hôtes avec for ((i = 0; i < 5; i++)) et appelle à chaque tour une fonction qui fait, elle aussi, une boucle sur i sans local :

traiter_hote() { for ((i = 0; i < 2; i++)); do :; done; }
for ((i = 0; i < 5; i++)); do echo "tour $i"; traiter_hote; done
tour 0
tour 3
tour 3
tour 3
...

La fonction remet i à 2 à chaque appel, la boucle externe l'incrémente à 3, la condition i < 5 reste vraie, et le script tourne à l'infini. Avec local i dans traiter_hote, tout rentre dans l'ordre. Prenez l'habitude de déclarer locales toutes les variables d'une fonction, y compris les compteurs de boucle et les variables de read.

La collision d'une référence de nom. La fonction suivante veut écrire dans la variable de l'appelant, mais possède une variable locale du même nom :

compter() { local -n _cible=$1; local n=3; _cible=$n; }
n=0; compter n; echo "n=$n"     # n=0

La référence _cible désigne « n », et le n le plus proche est la variable locale de compter : c'est elle qui reçoit 3, la variable de l'appelant n'est pas modifiée, et aucun message ne le signale. Si le nom passé est celui de la référence elle-même (compter _cible), Bash 5.2 affiche warning: _cible: circular name reference. Parades : préfixer les références et les variables locales des fonctions qui en utilisent (_cible, _n), et documenter que la fonction écrit dans la variable passée.

Une fonction qui porte le nom d'une commande. Une fonction test, log, ls ou cd remplace la commande pour tout le script et pour les bibliothèques chargées. type -t nom pour diagnostiquer ; choisissez des noms explicites, en français dans notre dépôt, qui ne risquent pas de collision avec une commande du système.

Compter sur un alias. Les alias de votre ~/.bashrc ne sont pas lus par un script, et un alias défini dans un script n'est pas développé (ll: command not found, code 127). Écrivez une fonction.

return pour transmettre un nombre. return $nombre_de_lignes donne 0 pour 256 lignes, 44 pour 300. Le statut est un statut ; un nombre passe par la sortie ou par une référence.

return hors d'une fonction ou d'un fichier chargé. return au niveau principal d'un script exécuté échoue (*can only return' from a function or sourced script*) et le script continue. Pour terminer un script, c'est exit`.

Modifier un script pendant qu'il tourne. Démontré plus haut : déployez en écrivant un nouveau fichier puis en le renommant, et structurez le script autour de main avec un exit final.

Sécurité

Shellshock

Le 24 septembre 2014, Debian publie l'avis DSA-3032-1 pour une vulnérabilité de Bash découverte par Stéphane Chazelas, CVE-2014-6271, vite surnommée Shellshock. L'avis la décrit comme liée au traitement des variables d'environnement et précise que, dans beaucoup de configurations courantes, elle est exploitable par le réseau.

Le mécanisme est celui de l'exportation des fonctions, présent depuis 1989. À l'époque, Bash reconnaissait une fonction exportée dans n'importe quelle variable d'environnement dont la valeur commençait par () {. Et son analyseur ne s'arrêtait pas à la fin de la définition : il exécutait aussi ce qui suivait. Le test diffusé à l'époque tenait en une ligne :

env x='() { :;}; echo vulnérable' bash -c 'echo test'

Sur un Bash vulnérable, vulnérable s'affichait avant test : la simple création d'un processus Bash avec cette variable dans son environnement exécutait la commande. Or de nombreux programmes recopient des données venues du réseau dans des variables d'environnement avant de lancer un shell :

  • un serveur web en CGI place les en-têtes HTTP de la requête (User-Agent, par exemple) dans des variables HTTP_* ;
  • un client DHCP passe les options reçues du serveur à des scripts de configuration ;
  • OpenSSH place la commande demandée dans SSH_ORIGINAL_COMMAND quand une commande est imposée, par ForceCommand dans sshd_config ou par l'option command= d'une clé.

Des correctifs successifs ont suivi dans les jours suivants (CVE-2014-7169, puis 7186, 7187, 6277 et 6278, toutes liées à l'analyse de ces définitions). La correction de fond, adoptée par Bash, a été de ne plus chercher de fonctions que dans des variables au nom réservé : c'est l'origine du BASH_FUNC_saluer%% vu plus haut. Une variable ordinaire comme HTTP_USER_AGENT ne peut plus définir de fonction. Avec Bash 5.2, le test de 2014 n'affiche que test.

Ce qui reste vrai aujourd'hui

Le correctif a fermé la porte des variables ordinaires, pas celle des variables au nom réservé. Quiconque peut choisir le nom et la valeur d'une variable d'environnement d'un processus Bash peut encore y définir une fonction, et donc remplacer une commande :

$ env 'BASH_FUNC_saluer%%=() { echo piégé; }' bash -c 'saluer x'
piégé

Une fonction ainsi injectée sous le nom gzip ou aws prendrait le pas sur le programme, puisque les fonctions passent avant PATH. Les règles qui en découlent :

  • ne laissez pas un tiers contrôler l'environnement d'un script privilégié. C'est l'une des raisons pour lesquelles sudo réinitialise l'environnement (env_reset, leçon 7 de Premiers pas), et pour lesquelles une unité systemd ne transmet que les variables qu'on lui donne (Environment=, EnvironmentFile=) ;
  • n'utilisez export -f que lorsque c'est nécessaire, typiquement pour une fonction passée à xargs ou parallel (leçon 11), et jamais vers un processus qui change de niveau de privilège ;
  • tenez Bash à jour. Shellshock a montré qu'un interpréteur présent sur toutes les machines est une surface d'attaque, même quand personne ne « l'utilise » directement.

Une bibliothèque est du code exécuté

source fichier exécute le fichier avec les droits du script, sans aucune vérification. Celui qui peut écrire dans lib/commun.sh, ou dans le répertoire lib/, peut faire exécuter n'importe quoi à tous les outils, y compris ceux lancés par root ou par le compte signalements qui détient les clés du bucket de la mairie. En production :

  • lib/ et bin/ appartiennent à root, en 0755 pour les répertoires et les scripts, 0644 pour la bibliothèque ; aucun compte de service n'y écrit ;
  • le chemin de la bibliothèque est calculé à partir de l'emplacement résolu du script, jamais à partir du répertoire courant ou d'une variable d'environnement que l'appelant pourrait fixer (source "$LIB_DIR/commun.sh" avec un LIB_DIR hérité serait une porte ouverte) ;
  • ne chargez jamais par source un fichier de configuration modifiable par d'autres : c'est exécuter leur contenu. La leçon 8 lit la configuration sans source.

Des fonctions qu'on ne peut pas redéfinir

Une fonction critique peut être protégée par readonly -f nom : toute redéfinition ultérieure échoue avec nom: readonly function. Cela protège contre une redéfinition accidentelle par une bibliothèque chargée plus tard, pas contre un attaquant qui contrôle déjà le code exécuté. De même, appeler command gzip plutôt que gzip ignore une éventuelle fonction gzip ; c'est utile dans une bibliothèque qui ne maîtrise pas son environnement, inutile partout ailleurs.

En production

Installer les outils comme un tout. Les scripts de bin/ et la bibliothèque de lib/ forment une seule unité de déploiement : on installe ensemble, depuis une version étiquetée du dépôt (pas une branche), bin/ dans /opt/signalements/bin/ et lib/ dans /opt/signalements/lib/, et l'on crée des liens symboliques vers les scripts dans /usr/local/bin/ si l'on veut les avoir dans PATH. Grâce à readlink -f, le chargement de la bibliothèque fonctionne dans les deux cas. On ne copie jamais commun.sh à la main dans un autre dépôt : deux copies divergent toujours.

Horodater ou non. Sous systemd, chaque ligne écrite sur la sortie d'erreur d'un service arrive dans le journal, déjà horodatée : l'heure ajoutée par journaliser ferait doublon. D'où la variable HORODATER, que l'unité positionne explicitement :

[Service]
Type=oneshot
User=signalements
Environment=HORODATER=0
ExecStart=/opt/signalements/bin/publier-export

On aurait pu détecter le journal automatiquement : systemd définit la variable JOURNAL_STREAM quand la sortie d'un service est reliée au journal. Mais la page systemd.exec(5) prévient qu'il ne suffit pas de tester sa présence, parce qu'un service peut lancer des processus dont il a redirigé la sortie sans effacer la variable ; il faudrait comparer le périphérique et l'inode de la sortie d'erreur avec sa valeur. Une variable explicite est plus simple et ne surprend personne.

Documenter l'interface des fonctions. Une fonction de bibliothèque est une interface : d'autres scripts en dépendent. L'en-tête (arguments, sortie, statut, variables lues) fait partie du contrat, et un changement de signature (mourir qui accepterait un code en premier argument au lieu de -c) doit être fait dans tous les scripts à la fois, dans le même commit. C'est l'argument principal pour garder bibliothèque et scripts dans le même dépôt.

Savoir s'arrêter. Le guide de style de Google recommande de réécrire dans un langage plus structuré tout script qui dépasse une centaine de lignes ou dont la logique de contrôle devient complexe. C'est un ordre de grandeur, pas une règle absolue ; mais si commun.sh se met à contenir de l'analyse de JSON, des nouvelles tentatives avec attente exponentielle et un cache, c'est le signe que l'outil mérite Python. La leçon 12 donne des critères plus précis.

Préparer les tests. Une fonction qui ne lit que ses arguments, n'écrit que sur ses sorties et ne termine pas le script se teste en trois lignes. C'est la vraie raison de la garde finale et de la règle « seule main appelle mourir » : la leçon 12 chargera publier-export dans Bats et appellera verifier_export sur des fichiers fabriqués pour l'occasion, sans jamais déposer quoi que ce soit.

Exercices

1. Prévoir la portée (niveau 100). Sans l'exécuter, dites ce qu'affiche ce script, puis vérifiez.

x=global
lire()   { echo "lire voit : $x"; }
local1() { local x=local1; lire; }
modif()  { x=modifie; }
local1
lire
modif
lire
Solution
lire voit : local1
lire voit : global
lire voit : modifie

Appelée par local1, lire voit la variable locale de son appelant : c'est la portée dynamique. Appelée directement, elle voit la globale. modif n'a pas déclaré x locale : elle modifie la variable globale, durablement.

2. Réparer verifier-sante (niveau 100). Reprenez le script de Camille du début de la leçon. Expliquez pourquoi l'alerte part à chaque exécution, puis corrigez-le en utilisant journaliser de lib/commun.sh.

Solution

La fonction log écrit sur la sortie standard ; appelée dans code_http, elle-même capturée par $( ), son message s'ajoute à la variable : code contient [14:02:11] interrogation de 172.16.8.11, un saut de ligne, puis 200. La comparaison avec 200 échoue toujours. Correction :

REP_OUTILS=$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")/..
# shellcheck source=../lib/commun.sh
source "$REP_OUTILS/lib/commun.sh" || exit 1

code_http() {
  journaliser "interrogation de $1"
  curl -s -o /dev/null -w '%{http_code}' "http://$1:8000/sante"
}

code=$(code_http 172.16.8.11)
if [[ $code != 200 ]]; then
  avertir "sig-app-1 répond $code sur /sante"
fi

journaliser écrit sur la sortie d'erreur, qui n'est pas capturée. La leçon 4 améliore encore la vérification (statut de curl, délai), et la leçon 11 interroge les deux machines en parallèle.

3. Une fonction rapide pour une boucle (niveau 200). Écrivez une fonction nom_archive JOUR VARIABLE qui range dans VARIABLE le nom signalements-AAAA-MM-JJ.csv.gz, sans sous-shell. Puis montrez, par un appel qui la piège, pourquoi le nom de sa référence commence par _.

Solution
nom_archive() {
  local -n _nom=$2
  printf -v _nom 'signalements-%s.csv.gz' "$1"
}

nom_archive 2026-10-07 archive
echo "$archive"       # signalements-2026-10-07.csv.gz

Si la référence s'appelait nom, un appelant qui ferait nom_archive 2026-10-07 nom (un nom de variable tout à fait naturel) créerait une référence circulaire. Bash 5.2 affiche alors plusieurs fois warning: nom: circular name reference sur la sortie d'erreur, à chaque appel, puis se rabat sur la variable globale nom : le résultat est juste, mais par un cas limite que le manuel ne documente pas, et le journal se remplit d'avertissements. Si la fonction avait aussi une variable locale jour et que l'appelant passait le nom jour, la référence désignerait la locale, en silence. Le préfixe _ rend ces collisions improbables ; il ne les rend pas impossibles, d'où la documentation de l'interface.

4. La bibliothèque introuvable (niveau 200). Un collègue installe publier-export en copiant le seul fichier bin/publier-export dans /usr/local/bin/. Que se passe-t-il au lancement, quel message obtient-il, et quelle installation proposez-vous à la place ? Et si l'on avait écrit source "$(dirname "$0")/../lib/commun.sh" en installant un lien symbolique dans /usr/local/bin/ ?

Solution

readlink -f résout /usr/local/bin/publier-export (un fichier ordinaire, pas un lien), REP_OUTILS vaut /usr/local/bin/.., et source cherche /usr/local/lib/commun.sh, qui n'existe pas. source affiche /usr/local/bin/../lib/commun.sh: No such file or directory et renvoie un échec, et la garde affiche publier-export : impossible de charger lib/commun.sh puis termine avec le code 1. L'installation correcte copie ensemble bin/ et lib/ d'une version étiquetée dans /opt/signalements/bin/ et /opt/signalements/lib/, et crée un lien : ln -s /opt/signalements/bin/publier-export /usr/local/bin/. Avec $(dirname "$0") sans résolution et un lien symbolique, on chercherait /usr/local/bin/../lib/commun.sh : même échec, alors que la bibliothèque est bien installée. C'est le cas que corrige readlink -f.

5. Le minuteur toujours en échec (niveau 200). Le script rapport-journaux de Camille, lancé chaque nuit par un minuteur systemd, est marqué en échec toutes les nuits, avec le statut 244, alors que le rapport produit est juste. En voici l'essentiel. Trouvez la cause, corrigez-la, puis dites quel réflexe l'aurait évitée.

code=0

compter_codes() {
  fichier=$1
  for code in 200 404 500; do
    echo "$fichier $code $(grep -c " $code " "$fichier")"
  done
}

for fichier in /srv/donnees/journaux/*/syslog.log; do
  compter_codes "$fichier" || code=1
done
exit "$code"
Solution

compter_codes n'a aucune variable locale. Sa boucle for code in 200 404 500 utilise la variable globale code, celle qui devait porter le statut final du script : à la fin de la boucle, elle vaut 500. Le script termine par exit 500, et comme un statut ne garde que 8 bits, 500 devient 500 - 256 = 244. Le minuteur voit un échec chaque nuit ; et le jour où un journal manquera vraiment, rien ne le distinguera des autres nuits. Correction : local fichier=$1 code en tête de la fonction. Le réflexe : toute variable affectée dans une fonction est déclarée local, sans exception, variables de boucle comprises. On notera aussi que compter_codes renvoie le statut de son dernier echo, donc toujours 0 : le || code=1 ne sert à rien. La leçon 9 revient sur ces statuts trompeurs.

6. Une pile d'appels (niveau 200). Ajoutez à lib/commun.sh une fonction pile_appels qui écrit sur la sortie d'erreur la chaîne des appels qui ont mené jusqu'à elle, une ligne par cadre, au format fonction (fichier:ligne), sans s'afficher elle-même. Appelez-la depuis mourir quand VERBEUX vaut 1.

Solution
# pile_appels
#   Écrit sur la sortie d'erreur la pile des appels qui ont mené à la fonction appelante.
pile_appels() {
  local i
  for ((i = 1; i < ${#FUNCNAME[@]} - 1; i++)); do
    printf '  %s (%s:%s)\n' "${FUNCNAME[i]}" "${BASH_SOURCE[i+1]}" "${BASH_LINENO[i]}" >&2
  done
}

L'indice 0 est pile_appels elle-même, d'où le départ à 1 ; le dernier élément de FUNCNAME est le niveau du script (main au sens de Bash), qui n'a pas d'appelant, d'où l'arrêt avant lui. FUNCNAME[i] a été appelée depuis le fichier BASH_SOURCE[i+1], à la ligne BASH_LINENO[i]. Dans mourir, avant l'exit : [[ ${VERBEUX:-0} == 1 ]] && pile_appels. Sur un petit script où main appelle verifier, qui appelle mourir (avec HORODATER=0) :

p.sh : erreur : export vide
  mourir (p.sh:8)
  verifier (p.sh:9)
  main (p.sh:11)

Attention à ce que ce test ne soit pas la dernière commande d'une fonction (voir deboguer). La leçon 9 branche une fonction de ce genre sur trap ERR.

Récapitulatif

  • Une fonction est une commande définie dans le shell : elle s'exécute dans le shell courant, sans processus, avec ses propres $1, $#, "$@" ; $0 reste le nom du script. Forme POSIX nom() { ...; }, définie avant d'être appelée.
  • Elle renvoie un statut (0 à 255, celui de la dernière commande ou de return), jamais une valeur. Un résultat passe par la sortie standard capturée (simple, mais un sous-shell : coûteux, effets perdus) ou par une référence de nom (local -n, printf -v).
  • Les variables sont globales par défaut ; local les restreint à la fonction et à celles qu'elle appelle : Bash a une portée dynamique. Déclarez locales toutes les variables d'une fonction, compteurs compris, et séparez local x de x=$(...) (SC2155).
  • Les messages vont sur la sortie d'erreur, sinon ils sont capturés avec le résultat ; mourir dans $( ) ne termine que le sous-shell.
  • Une fonction masque une commande interne ou un programme de même nom ; command et builtin contournent la fonction, type -t diagnostique.
  • lib/commun.sh réunit journaliser, deboguer, avertir, mourir et exiger ; on la charge par un chemin calculé avec readlink -f sur BASH_SOURCE[0], avec une garde contre le double chargement, et elle ne change aucune option du shell.
  • Structure d'un script : constantes, fonctions, main, puis if [[ ${BASH_SOURCE[0]} == "$0" ]]; then main "$@"; exit; fi : lisible, testable, et protégé contre une réécriture en cours d'exécution.
  • FUNCNAME, BASH_SOURCE, BASH_LINENO et caller décrivent la pile d'appels.
  • Shellshock (CVE-2014-6271) venait de l'exportation des fonctions ; depuis, seules les variables BASH_FUNC_<nom>%% définissent des fonctions, ce qui reste une raison de ne jamais laisser un tiers fixer l'environnement d'un script privilégié.

Pour aller plus loin

  • Les sections Shell Functions et Bash Builtin Commands du manuel de Bash, ou la page bash(1) de votre machine (sections FUNCTIONS et SHELL BUILTIN COMMANDS), pour local, declare, caller et les options localvar_inherit et localvar_unset.
  • La page BashFAQ/084 de Greg's Wiki sur les façons de renvoyer une valeur, et BashFAQ/028 sur la difficulté de trouver l'emplacement de son propre script.
  • Le Google Shell Style Guide, sections Functions, Function Comments et Local Variables : une convention complète et argumentée pour les projets à plusieurs.
  • L'avis DSA-3032-1 et la chronologie de Shellshock, pour comprendre comment une fonctionnalité de 1989 est devenue une faille exploitable par le réseau.
  • La leçon suivante, Tableaux indexés et associatifs, qui passe des tableaux aux fonctions par référence de nom et construit les lignes de commande de deployer.
+20 XP Carte du ciel →Mon cosmonaute →

Sources