Linux sans magie #34 — Écrire un script Bash propre : arguments, "$@", fonctions et gestion des erreurs

Construire un script Bash lisible avec paramètres positionnels, "$@", fonctions, messages d’usage et gestion explicite des erreurs.

Les articles précédents ont présenté les briques séparément : codes de retour, variables, conditions et boucles.

Il est temps de les assembler dans un vrai script.

L’objectif n’est pas de construire un framework Bash, ni d’empiler des options obscures. Nous allons partir d’une structure minimale et lisible : recevoir des arguments, les valider, répartir le travail dans des fonctions et retourner des erreurs compréhensibles.

À la fin, vous disposerez d’un modèle simple que vous pourrez adapter à des scripts d’administration ou d’automatisation.

Un script Bash commence par un interpréteur explicite

Pour un script destiné à Bash, utilisez un shebang explicite :

#!/usr/bin/env bash

Cette ligne demande au système de lancer le script avec bash trouvé dans le PATH.

Le script peut ensuite être exécuté directement s’il possède le droit d’exécution :

chmod +x mon-script.sh
./mon-script.sh

Ou sans modifier ses permissions :

bash mon-script.sh

Dans les exemples de cet article, nous utiliserons bash script.sh. Le lab ne dépend donc pas du bit exécutable.

Les paramètres positionnels : $1, $2, $3

Lorsqu’un script reçoit des arguments, Bash les expose sous forme de paramètres positionnels.

Exemple :

bash exemple.sh production rapport.txt

Dans le script :

$1
$2

correspondent respectivement à :

production
rapport.txt

Le nombre d’arguments est disponible dans $#.

Un premier script minimal peut donc vérifier qu’un argument est présent :

#!/usr/bin/env bash

if [[ $# -lt 1 ]]
then
  printf 'Usage : %s <nom>\n' "$0" >&2
  exit 2
fi

nom=$1
printf 'Bonjour %s\n' "$nom"

Ici, le script retourne 2 si l’appel est incomplet.

La valeur exacte d’un code d’erreur applicatif dépend de votre convention. L’essentiel est de distinguer le succès 0 d’un échec non nul et de documenter la signification des valeurs choisies.

Pourquoi citer les paramètres

Un argument peut contenir des espaces.

Exemple :

bash exemple.sh 'rapport final.txt'

Cette affectation est sûre :

fichier=$1

Et cette utilisation conserve la valeur comme un argument unique :

printf '%s\n' "$fichier"

Le même principe s’applique à "$1", "$2" et aux autres expansions.

Le quoting reste particulièrement important lorsque vous transmettez les arguments à une autre commande ou à une fonction.

"$@" : retransmettre tous les arguments sans les fusionner

Bash fournit plusieurs paramètres spéciaux. Pour transmettre tous les arguments à une autre commande ou fonction, la forme la plus utile est :

"$@"

Lorsqu’elle est entre guillemets doubles, chaque paramètre positionnel reste un argument distinct.

Créons un exemple :

afficher_arguments()
{
  printf 'Nombre : %s\n' "$#"

  for argument in "$@"
  do
    printf '<%s>\n' "$argument"
  done
}

afficher_arguments "un" "deux mots" "trois"

Résultat attendu :

Nombre : 3
<un>
<deux mots>
<trois>

La chaîne deux mots reste un seul argument.

C’est précisément ce que l’on veut lorsque l’on retransmet les arguments du script :

traiter "$@"

"$*" et "$@" ne signifient pas la même chose

Avec des guillemets doubles, "$*" rassemble les paramètres positionnels en une seule chaîne, séparée selon le premier caractère de IFS.

À l’inverse, "$@" conserve un argument par paramètre.

Exemple conceptuel avec trois arguments :

un
deux mots
trois

"$@" les transmet comme trois arguments.

"$*" les représente comme un seul argument contenant l’ensemble.

Pour relayer fidèlement les arguments reçus, utilisez donc généralement :

"$@"

et non :

"$*"

Les fonctions structurent le script

Une fonction Bash permet de regrouper une tâche identifiable.

Exemple :

usage()
{
  printf 'Usage : %s <source> <destination>\n' "$0"
}

Une autre fonction peut effectuer une validation :

verifier_fichier()
{
  local fichier=$1

  if [[ ! -f $fichier ]]
  then
    printf 'Erreur : fichier absent : %s\n' "$fichier" >&2
    return 1
  fi

  return 0
}

Le mot-clé local crée ici une variable locale à la fonction.

Cela évite qu’une variable de travail comme fichier écrase involontairement une variable du même nom ailleurs dans le script.

return quitte une fonction, exit termine le script

La distinction est essentielle.

Dans une fonction :

return 1

termine la fonction et lui donne le statut 1.

Le script appelant peut ensuite décider quoi faire :

if ! verifier_fichier "$source"
then
  exit 1
fi

À l’inverse :

exit 1

termine le script entier.

Une fonction utilitaire qui rencontre une erreur récupérable a donc souvent intérêt à utiliser return. Le niveau principal du script décide ensuite s’il faut abandonner avec exit, afficher un autre message ou tenter une autre action.

Valider les arguments avant de travailler

Une structure simple consiste à effectuer les validations dès le début.

Exemple :

if [[ $# -ne 2 ]]
then
  usage >&2
  exit 2
fi

source=$1
destination=$2

Le script n’essaie pas de continuer avec des paramètres incomplets.

Cette approche évite de découvrir tardivement qu’une variable obligatoire est vide ou absente.

Construire un lab local non destructif

Créons un répertoire temporaire :

LAB_DIR="$(mktemp -d)"
printf 'Lab : %s\n' "$LAB_DIR"
cd "$LAB_DIR"

Créons deux fichiers source :

printf '%s\n' 'alpha' > 'un.txt'
printf '%s\n' 'beta' > 'deux mots.txt'

Nous allons écrire un script qui affiche le contenu des fichiers passés en arguments, sans les modifier.

Créez inspecter.sh :

cat > inspecter.sh <<'EOF'
#!/usr/bin/env bash

usage()
{
  printf 'Usage : %s <fichier> [fichier ...]\n' "$0"
}

verifier_fichier()
{
  local fichier=$1

  if [[ ! -f $fichier ]]
  then
    printf 'Erreur : fichier absent ou non régulier : %s\n' "$fichier" >&2
    return 1
  fi

  return 0
}

afficher_fichier()
{
  local fichier=$1

  printf '%s\n' "--- $fichier ---"
  cat -- "$fichier"
}

main()
{
  if [[ $# -lt 1 ]]
  then
    usage >&2
    return 2
  fi

  local fichier

  for fichier in "$@"
  do
    if ! verifier_fichier "$fichier"
    then
      return 1
    fi

    afficher_fichier "$fichier" || return 1
  done

  return 0
}

main "$@"
exit $?
EOF

Le script contient quatre niveaux clairs :

  • usage : explique comment appeler le script ;
  • verifier_fichier : valide un fichier ;
  • afficher_fichier : effectue l’action ;
  • main : orchestre les étapes.

Le niveau principal appelle :

main "$@"

Les arguments du script sont ainsi retransmis à main sans perdre leur séparation.

Vérifier le chemin de succès

Exécutez :

bash inspecter.sh 'un.txt' 'deux mots.txt'

Résultat attendu :

--- un.txt ---
alpha
--- deux mots.txt ---
beta

Puis vérifiez le statut :

printf 'status=%s\n' "$?"

Attention : cette commande doit être exécutée immédiatement après le script si vous voulez observer son statut, avant toute autre commande.

Sur le chemin de succès, le statut attendu est :

status=0

Vérifier une erreur d’usage

Exécutez le script sans argument :

bash inspecter.sh
status=$?
printf 'status=%s\n' "$status"

Le message d’usage doit être affiché sur la sortie d’erreur et le statut attendu est :

status=2

Le script distingue ainsi une erreur d’appel d’une erreur rencontrée pendant le traitement.

Vérifier une erreur de fichier

Exécutez :

bash inspecter.sh 'absent.txt'
status=$?
printf 'status=%s\n' "$status"

Le script doit signaler que le fichier n’est pas disponible et retourner :

status=1

La fonction verifier_fichier utilise return 1, puis main propage l’échec. Le niveau principal termine ensuite le script avec le même statut.

Propager explicitement les erreurs

Cette ligne mérite d’être comprise :

afficher_fichier "$fichier" || return 1

Si afficher_fichier retourne un statut non nul, main s’arrête avec 1.

Le comportement d’erreur est donc visible dans le code.

Pour un script pédagogique ou opérationnel simple, cette gestion explicite est souvent plus facile à relire qu’un comportement reposant entièrement sur des options globales comme set -e, dont les règles d’application dépendent du contexte syntaxique.

Cela ne signifie pas que set -e est inutilisable. Il est simplement hors du périmètre de ce #34 : ici, nous voulons voir clairement où une erreur est vérifiée et comment elle est propagée.

Ne pas masquer le statut que vous voulez conserver

Considérez :

commande
printf 'Commande terminée.\n'
exit $?

Le $? observé par exit correspond ici au statut de printf, pas à celui de commande.

Si vous devez conserver un statut, stockez-le immédiatement :

commande
status=$?
printf 'Commande terminée avec le statut %s.\n' "$status"
exit "$status"

Le même principe vaut après un appel de fonction.

Bonnes pratiques

Commencez par une structure simple et explicite :

validation des arguments
fonctions courtes
main
main "$@"
exit

Citez les paramètres et variables lorsqu’ils deviennent des arguments :

commande "$fichier"
fonction "$@"

Utilisez "$@" lorsque vous devez retransmettre fidèlement les arguments reçus.

Donnez un rôle précis aux fonctions. Une fonction qui valide un fichier ne devrait pas en même temps modifier plusieurs ressources sans raison claire.

Utilisez return pour signaler le succès ou l’échec d’une fonction, puis laissez le niveau supérieur décider s’il doit appeler exit.

Envoyez les messages d’erreur sur la sortie standard d’erreur :

printf 'Erreur : ...\n' >&2

Documentez la signification de vos codes de retour lorsque vous distinguez plusieurs types d’échec.

Sécurité

Un script Bash reçoit souvent des données qu’il ne contrôle pas : chemins, noms, options ou paramètres issus d’un utilisateur ou d’un autre outil.

Le quoting protège la séparation des arguments, mais ne transforme pas une donnée non fiable en donnée sûre pour tous les usages.

Évitez notamment de reconstruire une commande sous forme de chaîne pour l’exécuter ensuite avec eval.

Préférez transmettre directement les arguments :

commande "$argument"

plutôt que de concaténer du texte représentant une commande.

Pour les commandes qui acceptent des chemins pouvant commencer par -, utilisez -- lorsque la commande le supporte, comme dans :

cat -- "$fichier"

Les exemples de cet article ne requièrent aucun privilège élevé et ne modifient que les fichiers du lab.

Coûts

Le lab utilise uniquement Bash et quelques fichiers locaux. Il n’utilise aucun service cloud ni ressource facturable.

Limites et points de vigilance

Cet article reste volontairement centré sur la structure d’un petit script Bash.

Il ne couvre pas encore :

  • getopts pour analyser des options comme -f ou -v ;
  • les tableaux Bash ;
  • les traps et le nettoyage automatique avec trap ;
  • set -e, set -u et pipefail ;
  • les fichiers de configuration externes ;
  • les tests automatisés de scripts shell ;
  • les outils d’analyse statique comme ShellCheck.

Ces sujets deviennent utiles dès que le script grandit, mais ils ne remplacent pas les bases présentées ici : arguments bien cités, fonctions courtes et gestion visible des erreurs.

Rollback ou nettoyage

Le lab a créé un répertoire temporaire et trois fichiers.

Vérifiez le chemin avant suppression :

printf '%s\n' "$LAB_DIR"

Quittez le répertoire :

cd /

Puis supprimez uniquement le lab :

rm -rf -- "$LAB_DIR"

Cette commande est destructive pour le contenu du répertoire indiqué. Vérifiez donc la valeur de LAB_DIR avant de l’exécuter.

Références officielles

Conclusion

Un script Bash propre n’a pas besoin d’être sophistiqué.

Commencez par valider les arguments, transmettez-les avec "$@", découpez le travail en fonctions et rendez les erreurs visibles avec des statuts explicites.

Cette structure suffit déjà à transformer une suite de commandes en un script lisible, prévisible et plus facile à maintenir.