Scripting Bash professionnel : automatiser l'administration Linux
Quitter

Leçon 1 sur 12 · Le langage en profondeur

Variables, expansions et quoting : découper, remplacer, calculer sans jamais casser un nom de fichier

1 h 00 Rocky Linux 9

Objectif

Traiter les chaînes dans bash lui-même (nom de fichier, extension, nom d'hôte, casse, valeurs par défaut, calculs), puis savoir exactement ce que bash fait d'une ligne avant de lancer la commande, pour choisir le bon type de guillemets à chaque fois. C'est la base de tout le reste du cours, et la première cause de bugs dans les scripts d'exploitation.

L'essentiel

  • #/## retirent un motif au début (le plus court / le plus long), %/%% à la fin ; ${v/a/b} remplace, ${v^^} et ${v,,} changent la casse, ${#v} donne la longueur.
  • ${v:-défaut}, ${v:=défaut}, ${v:?message}, ${v:+autre} : avec les deux-points, une variable vide compte comme absente.
  • $(( … )) calcule en entiers ; printf -v var formate dans une variable sans sous-shell.
  • Bash transforme une ligne dans un ordre fixe : expansions, puis découpage en mots, puis jokers. Les guillemets doubles bloquent les deux dernières étapes.
  • "$@" transmet chaque argument intact ; printf %q protège une chaîne qui sera relue par un autre shell (ssh, journal rejouable).

Cette leçon suppose acquis les variables simples, ${1:-défaut} et les jokers vus dans le cours « Fondamentaux » (leçons 1.11, 1.13 et 1.17). On va beaucoup plus loin.


1. Pourquoi se passer de basename, cut ou sed ?

Dans une boucle qui traite 20 000 fichiers de journaux, chaque $(basename "$f") lance un nouveau processus. L'expansion ${f##*/} fait le même travail à l'intérieur de bash, des centaines de fois plus vite.

f=/var/backups/ml-web-01-2026-10-20.tar.gz

echo "${#f}"            # 40           longueur en caractères
echo "${f:13:9}"        # ml-web-01    9 caractères à partir de la position 13 (on compte depuis 0)
echo "${f: -6}"         # tar.gz       les 6 derniers (l'espace avant - est obligatoire)

2. Retirer un motif au début ou à la fin

Les motifs sont ceux des jokers du shell (*, ?, […]), pas des expressions régulières.

Expansion Retire… Moyen mnémotechnique
${v#motif} le plus court préfixe qui correspond # est à gauche du $ sur le clavier
${v##motif} le plus long préfixe
${v%motif} le plus court suffixe % est à droite
${v%%motif} le plus long suffixe
f=/var/backups/ml-web-01-2026-10-20.tar.gz
echo "${f##*/}"         # ml-web-01-2026-10-20.tar.gz   (= basename)
echo "${f%/*}"          # /var/backups                  (= dirname)
nom=${f##*/}
echo "${nom%.tar.gz}"   # ml-web-01-2026-10-20          extension précise retirée
echo "${nom#*.}"        # tar.gz                        l'extension « complète »
echo "${nom##*.}"       # gz                            la dernière extension seulement

fqdn=ml-db-01.masterlinux.lab
echo "${fqdn%%.*}"      # ml-db-01                      nom court
echo "${fqdn#*.}"       # masterlinux.lab               domaine

for f in /srv/exports/*.CSV; do
    mv -- "$f" "${f%.CSV}.csv"       # renommer en masse, sans rename ni sed
done

3. Remplacer et changer la casse

ligne="agence de fes;agence de rabat;agence de oujda"
echo "${ligne/agence/AGENCE}"     # AGENCE de fes;agence de rabat;...   première seulement
echo "${ligne//;/ | }"            # agence de fes | agence de rabat | ...  toutes
echo "${ligne//agence de /}"      # fes;rabat;oujda                     supprimer = remplacer par rien

chemin=/srv/site/
echo "${chemin/#\/srv/\/data}"    # /data/site/     # = ancré au DÉBUT
echo "${chemin/%\//}"             # /srv/site       % = ancré à la FIN

role=web ; ENV=PROD
echo "${role^} ${role^^} ${ENV,,}"   # Web WEB prod

Les changements de casse sont propres à bash 4 et plus : toutes les distributions actuelles l'ont, mais pas le sh de Debian (dash).

4. Valeurs par défaut, variables obligatoires

Expansion Si v est absente Si v est vide Si v a une valeur
${v-d} d vide la valeur
${v:-d} d d la valeur
${v:=d} d, et v reçoit d d, et v reçoit d la valeur
${v:?msg} erreur, le script s'arrête erreur la valeur
${v:+x} vide vide x
#!/bin/bash
# /usr/local/bin/ml-purge — configuration par variables d'environnement
: "${RETENTION_JOURS:=14}"           # « : » ne fait rien : seule l'affectation compte
CIBLE=${1:?Usage : ml-purge HOTE (exemple : ml-app-01)}
echo "Purge sur $CIBLE au-delà de $RETENTION_JOURS jours${DRY_RUN:+ (simulation)}"

${v:+…} ajoute une option seulement quand elle a un sens : rsync -a ${VERBOSE:+-v} …. Et l'indirection ${!nom} lit la variable dont le nom est dans nom (nom=seuil_db; echo "${!nom}") ; dès que les cas se multiplient, un tableau associatif (leçon 2.2) est plus propre.

5. Calculer : $(( )) et printf -v

echo $(( 45 * 100 / 60 ))                      # 75    division ENTIÈRE (tronquée)
echo $(( 2 * 100 / 3 ))                        # 66    alors que 66,67 s'arrondit à 67
echo $(( (2 * 100 + 3 / 2) / 3 ))              # 67    arrondi : on ajoute la moitié du diviseur
echo $(( 17 % 5 )) $(( 2 ** 10 ))              # 2 1024
(( compteur += 1 ))                            # pas besoin de $ dans (( ))

printf -v jour '%(%F)T' -1            # date du jour, sans lancer la commande date
printf -v hote 'ml-web-%02d' 3        # hote=ml-web-03

Bash ne connaît que les entiers : pour des montants, calculez en centimes (12350 pour 123,50 DH) ou confiez le calcul à awk.

6. Ce que bash fait d'une ligne

Prenons une variable contenant une espace et une étoile, dans un dossier qui contient rapport de juin.txt et simple.txt :

motif="ventes  *"
echo $motif         # ventes rapport de juin.txt simple.txt     ← découpée PUIS l'étoile remplacée
echo "$motif"       # ventes  *                                 ← intacte

Sans guillemets, bash a : 1) remplacé $motif par sa valeur ; 2) découpé sur les espaces (les deux espaces ont disparu) ; 3) remplacé * par la liste des fichiers. Les guillemets doubles empêchent les étapes 2 et 3. Règle d'or : toute expansion entre guillemets doubles, sauf si vous voulez précisément le découpage.

f="rapport de juin.txt"
wc -c $f          # wc: rapport: No such file or directory  (3 fichiers demandés)
wc -c "$f"        # 4 rapport de juin.txt

7. Les trois sortes de guillemets

hote=ml-web-01
echo 'Serveur : $hote'          # Serveur : $hote          rien n'est interprété
echo "Serveur : $hote"          # Serveur : ml-web-01      $ est interprété
echo "Prix : 120 \$"            # Prix : 120 $             \ protège un caractère dans "…"
echo $'Colonne1\tColonne2'      # $'…' comprend \t, \n, \e
echo 'l'\''agence'              # l'agence   (fermer, \' , rouvrir : seule façon d'avoir ' entre '…')
Écriture $v, $( ) \n Usage typique
'…' non non regex, programmes awk, motifs sed
"…" oui non presque tout le reste
$'…' non oui caractères spéciaux (IFS=$'\n', tabulations)

8. "$@", "$*" et IFS

# Le script reçoit trois arguments : "Rabat centre" Fes Agadir
for a in "$@"; do echo "[$a]"; done     # [Rabat centre] [Fes] [Agadir]     3 tours
for a in "$*"; do echo "[$a]"; done     # [Rabat centre Fes Agadir]         1 tour
for a in $@;   do echo "[$a]"; done     # [Rabat] [centre] [Fes] [Agadir]   4 tours : cassé

Un script enveloppe (wrapper) qui ajoute des options puis passe la main écrit exec rsync -a --exclude='*.tmp' "$@" : arguments intacts, et le code de retour est directement celui de rsync.

IFS (espace, tabulation, retour à la ligne par défaut) sert au découpage des mots et à read. Modifiez-le pour une seule commande :

IFS=: read -r nom ip ville <<< "ml-db-01:10.0.0.20:casablanca"
echo "$nom / $ip / $ville"                  # ml-db-01 / 10.0.0.20 / casablanca

9. Les jokers sous contrôle, et les noms qui commencent par -

Par défaut, un joker qui ne trouve rien reste tel quel : for f in /srv/vide/* tourne une fois avec la valeur littérale /srv/vide/*.

Option Effet
shopt -s nullglob un joker sans résultat disparaît : la boucle ne tourne pas
shopt -s failglob un joker sans résultat est une erreur
shopt -s dotglob * inclut les fichiers cachés
shopt -s globstar **/*.log descend dans tous les sous-dossiers
shopt -s extglob motifs étendus : !(*.gz), `@(web

Un fichier nommé -rf est pris pour une option par rm *. Parades : rm -- * (-- = fin des options) ou rm ./*.

10. printf %q : réinjecter une chaîne sans risque

ssh recolle ses arguments et les fait relire par le shell distant :

chemin="/srv/partage/compta 2026;reboot"
printf '%q\n' "$chemin"                             # /srv/partage/compta\ 2026\;reboot
ssh ml-web-01 rm -f "$chemin"                       # DANGER : le shell distant exécute « reboot »
ssh ml-web-01 "rm -f $(printf '%q' "$chemin")"      # sûr

Un nom de fichier fourni par un utilisateur et passé tel quel à ssh permet d'exécuter une commande sur le serveur distant : c'est une vraie faille.

11. Cas réel : le script de purge qui a vidé le mauvais dossier

Sur un serveur de fichiers MasterLinux, un script de nettoyage contenait rm -rf $DOSSIER/*. Lancé un jour sans argument par une tâche planifiée mal écrite, $DOSSIER était vide : la commande est devenue rm -rf /*. Trois protections, chacune suffisante :

DOSSIER=${1:?Usage : purge.sh DOSSIER}     # 1. argument obligatoire
[ -d "$DOSSIER" ] || exit 1                # 2. il doit exister
rm -rf -- "${DOSSIER:?}"/*                 # 3. ${v:?} refuse une valeur vide, guillemets partout

Pièges fréquents

  • Confondre # et %, ou oublier les deux-points : ${PORT-8080} laisse passer PORT="".
  • ${v: -3} sans espace : ${v:-3} est une valeur par défaut, pas « les trois derniers caractères ».
  • Variables sans guillemets : [ $v = oui ] avec v vide devient [ = oui ] ; for f in $(ls) casse les noms. Toujours "$v", et for f in ./*.
  • Des guillemets rangés dans une variable ne servent à rien : opts='-a "mon dossier"'; cmd $opts transmet "mon et dossier". Pour une liste d'arguments, un tableau (leçon 2.2).

Lab

Sur Rocky Linux 9, vous écrivez six petits scripts dans /root/bash :

  1. nom-archive.sh : le nom d'une archive sans dossier ni extension, sans commande externe ;
  2. court.sh : le nom court d'une machine, en majuscules ;
  3. conf.sh : des valeurs par défaut qui résistent aux variables vides ;
  4. pourcent.sh : un pourcentage entier correctement arrondi ;
  5. compte-args.sh : les arguments reçus, intacts grâce à "$@" ;
  6. cmd-distante.sh : une commande ssh sûre avec printf %q.

Quiz

  1. Avec f=/srv/www/index.old.html, que donnent ${f##*/}, ${f%.*} et ${f%%.*} ?
  2. Quelle différence entre ${PORT-80} et ${PORT:-80} ?
  3. Dans quel ordre bash applique-t-il expansion des variables, découpage en mots et jokers ?
  4. Quelle différence entre "$@" et "$*" ?
  5. Pourquoi ssh hote rm -f "$f" est-il dangereux si $f vient d'un utilisateur ?
Réponses
  1. index.old.html ; /srv/www/index.old ; /srv/www/index.
  2. La première ne remplace que si PORT est absente ; la seconde remplace aussi quand PORT est vide.
  3. D'abord les expansions ($v, $( ), $(( ))), puis le découpage en mots selon IFS, puis les jokers. Les guillemets doubles empêchent les deux dernières étapes.
  4. "$@" donne un mot par argument, intact ; "$*" colle tous les arguments en une seule chaîne séparée par le premier caractère d'IFS.
  5. ssh recolle les arguments et les fait réinterpréter par le shell distant : un nom contenant ; ou $( ) y devient une commande. Il faut protéger la valeur avec printf %q.

Exercices pratiques

À réaliser sur Rocky Linux 9

Faites ce lab sur votre machine

Le terminal, c’est le vôtre : une VM Linux (VirtualBox, VMware, Hyper-V, WSL…) ou Docker/Podman. L’outil ml prépare l’exercice et corrige votre travail ; la leçon se valide toute seule ici.

Créez un compte gratuit pour obtenir votre commande personnelle et faire valider vos labs automatiquement.

Créer mon compte

Tâches à réaliser

  1. Créez le script exécutable /root/bash/nom-archive.sh CHEMIN : il affiche le nom de l'archive sans dossier et sans l'extension .tar.gz. Exemple : /var/backups/ml-web-01-2026-10-20.tar.gz donne ml-web-01-2026-10-20. Interdit : basename, dirname, sed, awk, cut.
    Afficher un indiceDeux étapes : f=${1##*/} enlève tout jusqu'au dernier /, puis ${f%.tar.gz} enlève l'extension.
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'f=${1##*/}' 'echo "${f%.tar.gz}"' > /root/bash/nom-archive.sh && chmod +x /root/bash/nom-archive.sh

    Vérification : test "$(/root/bash/nom-archive.sh /var/backups/ml-web-01-2026-10-20.tar.gz 2>/dev/null)" = ml-web-01-2026-10-20 && test "$(/root/bash/nom-archive.sh archive.tar.gz 2>/dev/null)" = archive && ! grep -Eq "basename|dirname|sed|awk|cut" /root/bash/nom-archive.sh

  2. Créez le script exécutable /root/bash/court.sh NOM : il affiche le nom court d'une machine en MAJUSCULES. ml-db-01.masterlinux.lab donne ML-DB-01 ; ml-web-01 (sans domaine) donne ML-WEB-01.
    Afficher un indice${1%%.*} garde ce qui précède le premier point ; ${h^^} passe en majuscules.
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'h=${1%%.*}' 'echo "${h^^}"' > /root/bash/court.sh && chmod +x /root/bash/court.sh

    Vérification : test "$(/root/bash/court.sh ml-db-01.masterlinux.lab 2>/dev/null)" = ML-DB-01 && test "$(/root/bash/court.sh ml-web-01 2>/dev/null)" = ML-WEB-01

  3. Créez le script exécutable /root/bash/conf.sh : il affiche port=P env=E. P vient de la variable d'environnement PORT (8080 si elle est absente OU vide) ; E vient de ENV_ML (dev si absente ou vide). Exemple : PORT=9090 ENV_ML=prod /root/bash/conf.sh affiche port=9090 env=prod.
    Afficher un indiceAvec les deux-points, ${PORT:-8080} traite aussi la variable vide. Pour fixer ENV_ML : : "${ENV_ML:=dev}"
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'port=${PORT:-8080}' ': "${ENV_ML:=dev}"' 'echo "port=$port env=$ENV_ML"' > /root/bash/conf.sh && chmod +x /root/bash/conf.sh

    Vérification : test "$(env -u PORT -u ENV_ML /root/bash/conf.sh 2>/dev/null)" = "port=8080 env=dev" && test "$(PORT=9090 ENV_ML=prod /root/bash/conf.sh 2>/dev/null)" = "port=9090 env=prod" && test "$(PORT= ENV_ML= /root/bash/conf.sh 2>/dev/null)" = "port=8080 env=dev"

  4. Créez le script exécutable /root/bash/pourcent.sh UTILISE TOTAL : il affiche le pourcentage entier ARRONDI (1 3 donne 33, 2 3 donne 67, 45 60 donne 75). Si TOTAL vaut 0, message d'erreur sur la sortie d'erreur et code 1.
    Afficher un indiceLa division entière tronque : ajoutez la moitié du diviseur avant de diviser, $(( (100 * u + t / 2) / t )).
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'u=$1 t=$2' 'if (( t == 0 )); then' '    echo "Erreur : TOTAL ne peut pas valoir 0" >&2' '    exit 1' 'fi' 'echo $(( (100 * u + t / 2) / t ))' > /root/bash/pourcent.sh && chmod +x /root/bash/pourcent.sh

    Vérification : test "$(/root/bash/pourcent.sh 1 3 2>/dev/null)" = 33 && test "$(/root/bash/pourcent.sh 2 3 2>/dev/null)" = 67 && test "$(/root/bash/pourcent.sh 45 60 2>/dev/null)" = 75 && ! /root/bash/pourcent.sh 5 0 >/dev/null 2>&1

  5. Créez le script exécutable /root/bash/compte-args.sh : il affiche le nombre d'arguments reçus, puis chaque argument sur sa propre ligne, entre crochets, SANS le modifier. Exemple : compte-args.sh "*" "x y" "" affiche 3, [*], [x y], [] (l'étoile n'est pas remplacée par des fichiers, les deux espaces restent).
    Afficher un indiceecho "$#" puis for a in "$@"; do echo "[$a]"; done — les guillemets autour de $@ gardent chaque argument entier.
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'echo "$#"' 'for a in "$@"; do' '    echo "[$a]"' 'done' > /root/bash/compte-args.sh && chmod +x /root/bash/compte-args.sh

    Vérification : test "$(cd / && /root/bash/compte-args.sh "*" "x y" "" 2>/dev/null)" = "$(printf "3\n[*]\n[x y]\n[]")" && test "$(/root/bash/compte-args.sh 2>/dev/null)" = 0

  6. Créez le script exécutable /root/bash/cmd-distante.sh CHEMIN : il AFFICHE (sans l'exécuter) la commande ssh ml-web-01 rm -f CHEMIN, où CHEMIN est protégé avec printf %q. Exemple : "/tmp/a b" donne ssh ml-web-01 rm -f /tmp/a\ b.
    Afficher un indiceprintf "ssh ml-web-01 rm -f %q\n" "$1"
    Voir la solution
    mkdir -p /root/bash && printf '%s\n' '#!/bin/bash' 'printf "ssh ml-web-01 rm -f %q\n" "$1"' > /root/bash/cmd-distante.sh && chmod +x /root/bash/cmd-distante.sh

    Vérification : test "$(/root/bash/cmd-distante.sh "/tmp/a b" 2>/dev/null)" = "ssh ml-web-01 rm -f /tmp/a\ b" && test "$(/root/bash/cmd-distante.sh "/tmp/x;reboot" 2>/dev/null)" = "ssh ml-web-01 rm -f /tmp/x\;reboot" && grep -q "%q" /root/bash/cmd-distante.sh