Aller au contenu
sed : éditer des fichiers sans les casser

sed : éditer des fichiers sans les casser

200 Compagnon ⏱ 1 h 30 sedbashlinuxdebianubuntu

À la fin, vous saurez

  • Prévoir l'effet de sed -i sur l'inode, les liens physiques et symboliques, le propriétaire, les droits et les processus qui ont le fichier ouvert
  • Écrire une modification de configuration idempotente, limitée à une section, et le démontrer en l'exécutant deux fois
  • Choisir entre a, i, c, r, w et la substitution pour ajouter, insérer ou remplacer des lignes, en connaissant la forme portable de chaque commande
  • Utiliser l'espace de réserve et les commandes N, P et D pour traiter un contexte de plusieurs lignes
  • Protéger un script sed construit à partir de valeurs externes contre l'injection de commandes, par une liste blanche et l'option --sandbox
  • Reconnaître les fichiers que sed ne doit pas modifier (JSON, YAML, fichiers de paquets, configurations à valeur prioritaire) et choisir l'outil adapté

Prérequis

Testé avec bash 5.2.21 (Ubuntu 24.04), 5.2.37 (Debian 13) coreutils 9.4 (Ubuntu 24.04), 9.7 (Debian 13) sed 4.9 (Ubuntu 24.04 et Debian 13) , vérifié le 9 octobre 2026

Pourquoi

Le compte rendu de l'incident de mercredi tient en quelques lignes. Le 7 octobre, de 14 h 02 à 14 h 19, sig-app-2 a renvoyé des erreurs 503 : son pool de connexions à la base était saturé (« pool de connexions saturé », 37 requêtes en attente pour 10 connexions), et les applications mobiles, en réessayant, ont multiplié le trafic par quatre environ sur l'heure. Parmi les actions décidées le jeudi, deux concernent la configuration : l'offre de base managée de sig-db a été montée d'un cran, ce qui relève sa limite de connexions, et le paramètre taille_pool de /etc/signalements/app.conf doit passer de 10 à 20 sur les deux serveurs de l'API.

Sur le papier, c'est une ligne de sed. Voici celle que Camille aurait écrite, et qui traîne encore dans un vieux runbook :

sudo sed -i 's/taille_pool = .*/taille_pool = 20/' /etc/signalements/app.conf

Elle « marche ». Elle modifie aussi la ligne commentée # taille_pool = 5 qui documentait l'ancienne valeur, sans prévenir. Si un jour une section [cache] reçoit sa propre taille_pool, elle sera écrasée elle aussi. Si la clé n'existe pas encore dans le fichier, la commande ne fait rien, en silence, avec un code de sortie 0. Si /etc/signalements/app.conf est un lien symbolique vers un fichier versionné, le lien est remplacé par un fichier ordinaire, et le dépôt ne voit plus jamais les modifications. Et la variante qui ajoute une ligne (sed -i '/^\[api\]/a delai_lecture = 15'), relancée par erreur une seconde fois, ajoute la ligne une seconde fois.

Une modification de configuration n'est pas un traitement de texte comme les autres. Elle touche un fichier unique, souvent lu par un service en cours d'exécution, parfois géré par un paquet ou un outil de déploiement, et une erreur ne se voit qu'au redémarrage suivant, quand le service refuse de démarrer. Cette leçon apprend ce que sed -i fait vraiment au fichier, comment écrire une modification que l'on peut relancer sans crainte, et quand renoncer à sed. Elle se termine par regler-conf, un petit outil qui fixe une clé dans une section d'un fichier INI et que l'équipe utilisera sur les deux serveurs.

Les concepts

Éditer « sur place » : ce que le mot cache

sed est un éditeur de flux : la leçon 3 l'a montré, il lit son entrée, applique son script à chaque ligne et écrit le résultat sur la sortie standard. Il n'a aucune notion de fichier à modifier. La norme POSIX ne connaît d'ailleurs que les options -E et -n : l'option -i (in-place, édition sur place) est une extension, apparue dans GNU sed et reprise sous une autre forme par les sed des BSD et de macOS.

Le manuel de GNU sed décrit ce qu'elle fait en une phrase : sed crée un fichier temporaire, y envoie la sortie au lieu de la sortie standard, et, une fois la fin du fichier atteinte, renomme le fichier temporaire sous le nom d'origine. Autrement dit, sed -i script fichier est un raccourci pour :

sed script fichier > temporaire && mv temporaire fichier

à quelques précautions près, que la partie Sous le capot détaille. Ce n'est pas une modification du fichier existant : c'est son remplacement par un nouveau fichier qui porte le même nom. Toutes les surprises de sed -i découlent de cette différence.

L'option -i et son argument facultatif

-i accepte un suffixe de sauvegarde, collé à l'option : -i.bak renomme l'original en fichier.bak avant de mettre le nouveau à sa place. Si le suffixe contient une étoile, chaque * est remplacée par le nom du fichier, ce qui permet un préfixe (-i'ancien-*') ou un répertoire de sauvegarde existant (-i'sauvegardes/*'). Sans suffixe, l'original est perdu.

Parce que l'argument est facultatif et collé, l'ordre des options compte, et le manuel de GNU sed prend la peine de le signaler :

ÉcritureSignification pour GNU sed
sed -Ei 's/.../.../' f-E puis -i sans sauvegarde
sed -iE 's/.../.../' f-i avec le suffixe E : une sauvegarde fE est créée, et -E n'est pas activé
sed -i -E ...sans ambiguïté
sed -n -i 's/a/b/p' f-n supprime l'affichage automatique : le fichier ne contiendra que les lignes affichées par p

Les sed des BSD, dont celui de macOS, exigent au contraire un argument séparé : -i extension, avec une chaîne vide pour ne pas sauvegarder. D'où la phrase que tout le monde finit par rencontrer :

sed -i '' 's/a/b/' fichier      # macOS, FreeBSD : pas de sauvegarde
sed -i 's/a/b/' fichier         # GNU : pas de sauvegarde

Sur macOS, la seconde ligne prend le script pour un suffixe et fichier pour le script ; sur GNU, la première prend '' pour le script (vide, donc aucune modification) et s/a/b/ pour un nom de fichier, qui n'existe pas. Il n'existe aucune écriture de -i sans sauvegarde qui marche sur les deux. Les écritures portables sont -i.bak collé (accepté par les deux, avec une sauvegarde) ou, mieux, pas de -i du tout : sed ... fichier > temporaire && mv temporaire fichier.

Enfin, -i implique -s (separate) : chaque fichier est traité comme un flux indépendant. Sans -s, sed considère tous ses fichiers d'entrée comme un seul flux continu : $ désigne la dernière ligne du dernier fichier, les numéros de ligne continuent d'un fichier à l'autre, et un intervalle d'adresses peut commencer dans un fichier et finir dans le suivant.

Ce que -i conserve, et ce qu'il casse

Puisque le fichier est remplacé, tout ce qui est attaché au fichier plutôt qu'au nom change. Le tableau suivant résume le comportement de GNU sed 4.9, que la partie En pratique vérifie point par point :

PropriétéAprès sed -iPourquoi
Numéro d'inodenouveauc'est un autre fichier
Liens physiquesrompus : les autres noms gardent l'ancien contenuils pointent vers l'ancien inode
Lien symbolique passé en argumentremplacé par un fichier ordinaire, la cible n'est pas modifiéele renommage écrase le nom du lien ; --follow-symlinks change ce comportement
Propriétaire et grouperecopiés si possiblefchown sur le temporaire ; seul root peut donner un fichier à un autre compte
Droits et ACLrecopiéscopie des droits et des ACL de l'original
Contexte SELinuxrecopié si SELinux est actifle temporaire est créé avec le contexte de l'original
Attributs étendus autres que les ACL, attributs chattrperdusnon recopiés
Date de modificationcelle de l'édition, même si rien n'a changéle fichier est réécrit dans tous les cas
Sauvegarde (-i.bak)créée même si rien n'a changéle manuel le précise en note de bas de page
Processus qui avaient le fichier ouvertcontinuent de lire l'ancien contenuleur descripteur désigne l'ancien inode

Deux conséquences paraissent contre-intuitives, et le manuel de GNU sed les range dans sa liste des « non-bogues ». D'abord, sed -i peut modifier un fichier en lecture seule : il ne l'ouvre jamais en écriture, il crée un nouveau fichier à côté et le renomme ; or créer et renommer dépendent des droits du répertoire, pas de ceux du fichier. Ensuite, et symétriquement, sed -i échoue sur un fichier accessible en écriture si son répertoire ne l'est pas.

L'idempotence

Une opération est idempotente quand l'appliquer deux fois donne le même résultat que l'appliquer une fois. C'est la propriété qui compte pour toute modification de configuration :

  • un script interrompu au milieu (coupure SSH, serveur redémarré) doit pouvoir être relancé sans réfléchir ;
  • un script appliqué à dix serveurs, dont trois étaient déjà à jour, doit laisser ces trois-là intacts ;
  • un outil de déploiement ou de gestion de configuration relance ses étapes à chaque exécution : une étape non idempotente dérive un peu plus à chaque passage.

Une substitution qui remplace une valeur par une valeur fixe est idempotente : s/^taille_pool = .*/taille_pool = 20/, appliquée deux fois, donne la même ligne. Un ajout ne l'est pas : a delai_lecture = 15 ajoute une ligne à chaque passage. L'idempotence d'un ajout se construit donc en deux temps : vérifier si la ligne existe, puis remplacer ou ajouter. sed seul le fait mal, parce qu'il ne revient jamais en arrière dans son flux : quand il découvre, à la fin de la section, que la clé était absente, les lignes de la section sont déjà écrites. On verra que la solution la plus lisible combine deux appels.

Il existe une idempotence plus exigeante, qui compte en production : relancée sur un fichier déjà conforme, l'opération ne doit pas toucher le fichier du tout. Pas de nouvelle date de modification, pas de nouvelle sauvegarde, pas de service redémarré pour rien. sed -i ne l'offre pas, puisqu'il réécrit toujours ; il faut comparer avant d'écrire.

Ajouter, insérer, remplacer : a, i et c

Trois commandes produisent du texte au lieu de transformer l'espace de travail :

CommandeEffet
a texteappend : place texte dans une file d'attente, écrite après la ligne courante, à la fin du cycle
i texteinsert : écrit texte immédiatement, donc avant la ligne courante
c textechange : supprime la ligne (ou l'intervalle entier) et écrit texte à sa place

POSIX impose une écriture sur plusieurs lignes : la commande, une barre oblique inverse, un saut de ligne, puis le texte, chaque ligne de texte sauf la dernière se terminant elle aussi par une barre oblique inverse.

/^\[api\]/a\
delai_lecture = 15

GNU sed accepte en plus, comme extension, la forme sur une ligne : /^\[api\]/a delai_lecture = 15. Elle est pratique en ligne de commande, mais elle n'est pas portable (le sed de macOS la refuse), et les espaces en tête du texte y sont supprimées. Dans un script destiné à durer, préférez la forme POSIX.

Deux détails de comportement. Le texte de a est écrit même si la ligne est ensuite supprimée par d : la file d'attente est vidée à la fin du cycle quoi qu'il arrive. Et c sur un intervalle (/debut/,/fin/c\) écrit son texte une seule fois, à la fin de l'intervalle, en remplaçant toutes ses lignes ; précédé de ! ou utilisé dans un bloc {}, il l'écrit à chaque ligne, ce qui surprend.

Lire et écrire d'autres fichiers : r et w

r fichier met le contenu d'un fichier dans la même file d'attente que a : il sera écrit après la ligne courante. Si le fichier n'existe pas, sed l'ignore sans erreur. w fichier écrit l'espace de travail dans un fichier ; le drapeau w de la commande s (s/a/b/w changements.txt) n'y écrit que les lignes effectivement substituées. Tous les fichiers cités par w sont créés, ou vidés, avant la lecture de la première ligne, même si la commande ne s'exécute jamais. GNU sed ajoute R (une ligne d'un fichier à chaque exécution), W (la première ligne de l'espace de travail) et e (exécuter une commande du shell), qui reviennent dans la partie Sécurité.

Deux espaces : le travail et la réserve

La leçon 3 a présenté les deux tampons de sed et ne s'est servie que du premier, l'espace de travail (pattern space), qui contient la ligne courante. Le second, l'espace de réserve (hold space), vide au départ, survit d'un cycle à l'autre. C'est la seule mémoire de sed : sans elle, chaque ligne est traitée sans rien savoir des précédentes. Cinq commandes la manipulent :

CommandeEffetMoyen mnémotechnique
hcopie le travail dans la réserve (la réserve est écrasée)hold
Hajoute un saut de ligne puis le travail à la fin de la réserveHold, en ajoutant
gcopie la réserve dans le travail (le travail est écrasé)get
Gajoute un saut de ligne puis la réserve à la fin du travailGet, en ajoutant
xéchange les deuxexchange

Avec elles, on peut se souvenir de la ligne précédente, accumuler un paragraphe, ou inverser l'ordre des lignes. On peut ; mais un script sed qui joue beaucoup avec la réserve devient vite illisible. Les exemples du manuel de GNU sed qui s'en servent (inverser des lignes, numéroter, compter des caractères) tiennent de l'exercice de style, et la leçon 5 montrera qu'awk, avec ses variables, fait la même chose en clair.

Plusieurs lignes : N, P, D et les étiquettes

Trois commandes font tenir plusieurs lignes dans l'espace de travail :

  • N ajoute un saut de ligne puis la ligne suivante de l'entrée à l'espace de travail, sans commencer de nouveau cycle. S'il n'y a plus de ligne suivante, GNU sed affiche l'espace de travail et s'arrête (POSIX dit qu'il s'arrête sans l'afficher ; GNU suit ce comportement sous POSIXLY_CORRECT). Le motif $!N (« sauf sur la dernière ligne ») évite la question.
  • P affiche l'espace de travail jusqu'au premier saut de ligne.
  • D supprime l'espace de travail jusqu'au premier saut de ligne, puis recommence un cycle sans lire de nouvelle ligne s'il reste quelque chose.

Le trio N, P, D fait glisser une fenêtre de deux lignes sur le fichier : c'est la façon de comparer chaque ligne à la suivante. Pour répéter une opération tant qu'elle s'applique, sed offre des sauts : :etiquette définit une étiquette, b etiquette y saute sans condition, t etiquette y saute si une substitution a réussi depuis la dernière lecture ou le dernier t. Une boucle :a ... ta est le seul moyen, en sed, de traiter un nombre de lignes inconnu d'avance.

Quand sed n'est pas le bon outil

sed transforme des lignes. Un fichier de configuration n'est fait de lignes qu'en apparence ; selon son format, cette approximation est acceptable ou dangereuse :

FichierAvec sedPlutôt
clé = valeur simple, INI à sectionsacceptable avec les précautions de cette leçonregler-conf, puis la gestion de configuration
JSONdangereux : imbrication, échappements, virgulesjq (leçon 8)
YAMLdangereux : l'indentation porte le sensyq, ou réécrire le fichier depuis un modèle
Fichier livré par un paquet (conffile)à éviter : chaque mise à jour du paquet posera une questionun fichier de surcharge dans le répertoire .d/ prévu
Fichier où la première valeur l'emporte (sshd_config)piège : une ligne ajoutée à la fin est ignoréefichier de surcharge, et vérification par sshd -T
Fichier entièrement produit par vousinutilele générer depuis un modèle (envsubst, gabarit)
/etc/sudoersinterdit sans validationvisudo -c -f, fichiers dans /etc/sudoers.d/

Le cas de sshd_config mérite d'être retenu : sa page de manuel précise que, pour chaque mot-clé, « the first obtained value will be used », la première valeur obtenue est retenue. Un sed -i '$a PasswordAuthentication no' ajoute une ligne qui n'aura aucun effet si une ligne précédente, ou un fichier inclus en tête par Include /etc/ssh/sshd_config.d/*.conf, fixe déjà la valeur. Le service redémarre sans erreur, et le réglage de sécurité n'est pas appliqué.

En pratique

Les sorties ont été produites avec GNU sed 4.9 et Bash 5.2.21, sous LC_ALL=C.UTF-8 et TZ=UTC, sur des copies du bac à sable de la leçon 1. On ne touche jamais config/app.conf lui-même : on en fait une copie par expérience.

$ cd ~/essais-texte
$ mkdir -p travail-4 && cd travail-4
$ cp ../config/app.conf .
$ cat -n app.conf
     1	# /etc/signalements/app.conf : configuration de l'API Signalements
     2	# Géré à la main sur chaque serveur. Voir docs/serveurs/sig-app-1.md.
     3	
     4	[base]
     5	hote = sig-db.pn-signalements.internal
     6	port = 5432
     7	nom = signalements
     8	utilisateur = signalements
     9	# taille_pool = 5
    10	taille_pool = 10
    11	
    12	[api]
    13	ecoute = 0.0.0.0:8000
    14	travailleurs = 4
    15	delai = 30
    16	journal_json = oui
    17	
    18	[stockage]
    19	seau = sig-photos
    20	region = fr-par

Un fichier INI : des sections entre crochets, des lignes clé = valeur, des commentaires qui commencent par #. La ligne 9 garde la trace de l'ancienne valeur ; elle doit survivre.

Le réflexe naïf, et ce qu'il casse

Sans -i, pour voir le résultat sans rien modifier :

$ sed 's/taille_pool = .*/taille_pool = 20/' app.conf | grep -n taille_pool
9:# taille_pool = 20
10:taille_pool = 20

La ligne commentée a été réécrite : le motif n'était pas ancré, il a trouvé taille_pool = au milieu de la ligne 9. L'ancre ^ corrige ce premier défaut :

$ sed 's/^taille_pool = .*/taille_pool = 20/' app.conf | grep -n taille_pool
9:# taille_pool = 5
10:taille_pool = 20

Reste que le motif s'applique à tout le fichier, quelle que soit la section. Le jour où la section [stockage] recevra son propre taille_pool (pour les connexions à l'Object Storage, par exemple), la commande modifiera les deux. Et l'écriture taille_pool = suppose une espace de chaque côté du signe égal, alors que le format accepte taille_pool=10 ou une indentation.

Le cas de l'ajout est pire. Ajoutons deux fois, comme le ferait un script relancé après une coupure :

$ cp app.conf t.conf
$ sed -i '/^\[api\]/a delai_lecture = 15' t.conf
$ sed -i '/^\[api\]/a delai_lecture = 15' t.conf
$ sed -n '/^\[api\]/,/^\[/p' t.conf
[api]
delai_lecture = 15
delai_lecture = 15
ecoute = 0.0.0.0:8000
travailleurs = 4
delai = 30
journal_json = oui

[stockage]

Deux lignes identiques. Selon la bibliothèque qui lit le fichier, la seconde l'emporte, la première l'emporte, ou le fichier est refusé : le module configparser de Python, par exemple, lève une erreur DuplicateOptionError en mode strict, qui est son mode par défaut. Un service qui refuse sa configuration au redémarrage est un service arrêté.

Restreindre à une section

La leçon 3 a présenté les intervalles d'adresses. Une section INI est exactement un intervalle : de son en-tête jusqu'à l'en-tête suivant, ou jusqu'à la fin du fichier pour la dernière.

$ sed -n '/^\[base\]/,/^\[/p' app.conf
[base]
hote = sig-db.pn-signalements.internal
port = 5432
nom = signalements
utilisateur = signalements
# taille_pool = 5
taille_pool = 10

[api]

L'intervalle inclut sa ligne de fin, l'en-tête [api]. Ce n'est pas gênant tant que les commandes appliquées à l'intervalle ne visent que des lignes clé = valeur. La deuxième adresse n'est testée qu'à partir de la ligne suivant le début de l'intervalle, sinon l'en-tête [base], qui commence lui-même par [, fermerait l'intervalle aussitôt ouvert.

La substitution limitée à la section, avec un motif qui tolère les blancs :

$ sed '/^\[base\]/,/^\[/{ s/^[[:space:]]*taille_pool[[:space:]]*=.*/taille_pool = 20/; }' app.conf | sed -n 4,11p
[base]
hote = sig-db.pn-signalements.internal
port = 5432
nom = signalements
utilisateur = signalements
# taille_pool = 5
taille_pool = 20

Le bloc { ...; } applique la substitution aux seules lignes de l'intervalle. Le point-virgule avant l'accolade fermante est facultatif pour GNU sed, exigé par POSIX : prenez l'habitude de l'écrire.

Remplacer ou ajouter : en deux temps

Pour qu'un ajout soit idempotent, on compte d'abord, on agit ensuite. Compter les lignes de la clé dans la section, non commentées :

$ sed -n '/^\[base\]/,/^\[/{ /^[[:space:]]*taille_pool[[:space:]]*=/p; }' app.conf | wc -l
1
$ sed -n '/^\[api\]/,/^\[/{ /^[[:space:]]*delai_lecture[[:space:]]*=/p; }' app.conf | wc -l
0

Trois cas : 1, on remplace ; 0, on ajoute ; plus de 1, le fichier est déjà ambigu et l'on refuse de choisir à la place d'un humain.

Où ajouter ? Juste après l'en-tête serait simple, mais le fichier deviendrait peu lisible, avec les nouvelles clés en tête de section. À la fin de la section, c'est-à-dire après sa dernière ligne non vide, pour ne pas glisser la clé après la ligne vide qui sépare deux sections. sed sait afficher un numéro de ligne avec = :

$ sed -n '/^\[api\]/,/^\[/{ /^\[/!{ /^[[:space:]]*$/!=; }; }' app.conf | tail -n 1
16

Lisons de l'extérieur vers l'intérieur : dans la section [api], pour les lignes qui ne sont pas un en-tête (/^\[/!), et qui ne sont pas vides (/^[[:space:]]*$/!), afficher le numéro (=). tail -n 1 garde le dernier : la ligne 16, journal_json = oui. Il ne reste qu'à ajouter après elle, avec la forme POSIX de a :

$ sed '16a\
> delai_lecture = 15' app.conf | sed -n 12,19p
[api]
ecoute = 0.0.0.0:8000
travailleurs = 4
delai = 30
journal_json = oui
delai_lecture = 15

[stockage]

Comparer avant d'écrire

Le dernier ingrédient est la comparaison. cmp -s compare deux fichiers octet par octet, sans rien afficher, et renvoie 0 s'ils sont identiques ; - désigne l'entrée standard. Si la version produite par sed est identique au fichier, il n'y a rien à faire. Sinon, diff -u montre la modification avant de l'appliquer, comme le recommandait la procédure de Premiers pas pour toute modification de configuration :

if sed "$script" "$fichier" | cmp -s - "$fichier"; then
    echo "déjà conforme"
else
    sed "$script" "$fichier" | diff -u "$fichier" -
fi

regler-conf

Tout assemblé, voici bin/regler-conf, rangé dans le dépôt signalements-outils du cours Bash pour l'automatisation. Il reprend ses conventions : set -Eeuo pipefail, une fonction d'erreur, des codes de sortie documentés en tête (leçon 9 du cours Bash).

#!/usr/bin/env bash
# regler-conf : fixe « CLE = VALEUR » dans une section d'un fichier INI, sans rien toucher d'autre.
#
# Usage : regler-conf [--dry-run] FICHIER SECTION CLE VALEUR
#
# La clé est remplacée si elle est présente (non commentée) dans la section,
# ajoutée après la dernière ligne non vide de la section sinon. Une seconde
# exécution avec les mêmes arguments ne change rien.
#
# Codes de sortie :
#   0  le fichier est conforme (modifié, ou déjà conforme)
#   1  erreur d'exécution (fichier illisible, écriture impossible, vérification finale)
#   2  mauvaise utilisation (arguments, caractères interdits)
#   3  section absente du fichier
#   4  ambiguïté : section ou clé présente plusieurs fois
#   5  --dry-run : une modification serait faite

set -Eeuo pipefail
export LC_ALL=C.UTF-8

readonly PROG=${0##*/}

erreur() {
    local code=$1
    shift
    printf '%s : %s\n' "$PROG" "$*" >&2
    exit "$code"
}

simulation=non
if [[ ${1:-} == --dry-run ]]; then
    simulation=oui
    shift
fi
(( $# == 4 )) || erreur 2 "usage : $PROG [--dry-run] FICHIER SECTION CLE VALEUR"
fichier=$1 section=$2 cle=$3 valeur=$4

# Liste blanche : rien qui soit spécial pour une expression régulière, pour sed
# (& \ / saut de ligne) ou pour le format INI (= ; # [ ]).
[[ $section =~ ^[A-Za-z0-9_-]+$ ]] || erreur 2 "nom de section invalide : $section"
[[ $cle =~ ^[A-Za-z0-9_-]+$ ]] || erreur 2 "nom de clé invalide : $cle"
[[ $valeur =~ ^[A-Za-z0-9_.:@+,-]*$ ]] || erreur 2 "valeur refusée (caractères permis : A-Z a-z 0-9 _ . : @ + , -) : $valeur"
# Un lien symbolique est suivi : on modifie (et on sauvegarde) le fichier réel,
# au lieu de remplacer le lien par un fichier ordinaire comme le ferait sed -i.
if [[ -L $fichier ]]; then
    fichier=$(readlink -e -- "$fichier") || erreur 1 "lien symbolique cassé : $1"
fi
[[ -f $fichier && -r $fichier ]] || erreur 1 "fichier illisible : $fichier"
# Un retour chariot rendrait les fins de ligne mixtes après modification.
if grep -q $'\r' -- "$fichier"; then
    erreur 1 "fins de ligne CRLF dans $fichier : convertissez-le d'abord"
fi

# Les motifs, construits une fois. Les noms ont été validés : aucun échappement nécessaire.
entete="^\\[$section\\][[:space:]]*\$"
ligne_cle="^[[:space:]]*$cle[[:space:]]*="

# 1. La section doit exister, une seule fois.
nb_sections=$(sed -n "/$entete/p" "$fichier" | wc -l)
(( nb_sections > 0 )) || erreur 3 "section [$section] absente de $fichier"
(( nb_sections == 1 )) || erreur 4 "section [$section] présente $nb_sections fois dans $fichier"

# 2. Combien de fois la clé apparaît-elle, non commentée, dans la section ?
#    L'intervalle va de l'en-tête à l'en-tête suivant (ou à la fin du fichier).
nb_cles=$(sed -n "/$entete/,/^\\[/{ /$ligne_cle/p; }" "$fichier" | wc -l)
(( nb_cles <= 1 )) || erreur 4 "clé $cle présente $nb_cles fois dans [$section] : corrigez à la main"

# 3. Le script sed qui produit la version voulue.
if (( nb_cles == 1 )); then
    script="/$entete/,/^\\[/{ s/$ligne_cle.*/$cle = $valeur/; }"
else
    # Dernière ligne non vide de la section (hors en-tête suivant), ou l'en-tête lui-même.
    derniere=$(sed -n "/$entete/,/^\\[/{ /^\\[/!{ /^[[:space:]]*\$/!=; }; }" "$fichier" | tail -n 1)
    if [[ -z $derniere ]]; then
        derniere=$(sed -n "/$entete/=" "$fichier")
    fi
    script="${derniere}a\\
$cle = $valeur"
fi

# 4. Comparer avant d'écrire : rien à faire si le résultat est identique.
if sed "$script" "$fichier" | cmp -s - "$fichier"; then
    printf '%s : %s déjà conforme ([%s] %s = %s)\n' "$PROG" "$fichier" "$section" "$cle" "$valeur" >&2
    exit 0
fi
sed "$script" "$fichier" | diff -u --label "$fichier" --label "$fichier (nouveau)" "$fichier" - || true
if [[ $simulation == oui ]]; then
    exit 5
fi

# 5. Sauvegarde datée (droits et propriétaire conservés), puis édition sur place.
#    Deux exécutions dans la même seconde ne doivent pas écraser la première sauvegarde.
base="$fichier.$(date -u +%Y%m%dT%H%M%SZ)"
sauvegarde=$base
n=1
while [[ -e $sauvegarde ]]; do
    sauvegarde="$base.$n"
    n=$((n + 1))
done
cp -p -- "$fichier" "$sauvegarde" || erreur 1 "sauvegarde impossible : $sauvegarde"
sed -i -- "$script" "$fichier" || erreur 1 "écriture impossible : $fichier (sauvegarde : $sauvegarde)"

# 6. Vérifier le résultat plutôt que de le supposer.
attendu=$(sed -n "/$entete/,/^\\[/{ /$ligne_cle/p; }" "$fichier")
[[ $attendu == "$cle = $valeur" ]] || erreur 1 "vérification échouée dans $fichier (sauvegarde : $sauvegarde)"
printf '%s : %s modifié, sauvegarde %s\n' "$PROG" "$fichier" "$sauvegarde" >&2

Les choix qui méritent une explication :

  • La liste blanche. Les noms de section et de clé, et la valeur, sont collés dans un script sed. Un caractère comme /, & ou \ y changerait le sens de la substitution (leçon 3), un . deviendrait un joker dans le motif, et un saut de ligne pourrait ajouter une commande au script, ce que la partie Sécurité démontre. Plutôt que d'échapper chaque caractère dangereux, l'outil refuse tout ce qui n'est pas explicitement permis. Le jeu de caractères suffit aux valeurs de app.conf (sig-db.pn-signalements.internal, 0.0.0.0:8000, des nombres) ; une valeur qui contiendrait une espace ou une barre oblique demande d'élargir la liste et d'échapper, consciemment.
  • Deux en-têtes de section, deux clés : on s'arrête. Le code 4 signale un fichier ambigu. Un outil automatique qui choisirait la première occurrence produirait un fichier « conforme » pour lui et faux pour le service.
  • Le lien symbolique suivi. Si app.conf est un lien (vers un fichier d'un dépôt, par exemple), readlink -e donne la cible finale, et c'est elle que l'on sauvegarde et modifie. Sans cette précaution, sed -i remplacerait le lien par un fichier ordinaire, comme le montre la section suivante. GNU sed propose --follow-symlinks pour le même résultat ; le faire dans le script garde le nom réel dans les messages et dans le nom de la sauvegarde.
  • Le refus des fins de ligne CRLF. [[:space:]] reconnaît le retour chariot, donc l'en-tête [base]\r est bien trouvé ; mais la substitution s/...=.*/.../ remplace aussi le \r final de la ligne modifiée, et le fichier se retrouve avec des fins de ligne mixtes. Plutôt que de deviner, l'outil s'arrête (la partie Pièges y revient).
  • sed -n ... = puis a : le script d'ajout est construit en deux appels, l'un pour trouver le numéro de ligne, l'autre pour ajouter. sed seul ne sait pas « revenir » à la fin d'une section qu'il a déjà écrite. La variable script contient un vrai saut de ligne après a\ : c'est la forme POSIX.
  • cmp -s avant d'écrire : un fichier déjà conforme n'est ni réécrit, ni sauvegardé. C'est l'idempotence au sens fort.
  • La sauvegarde par cp -p, puis sed -i sans suffixe. cp -p conserve droits, propriétaire et dates ; sed -i sans suffixe ne fait qu'un renommage, alors qu'avec un suffixe il en fait deux, avec un instant où le fichier n'existe plus (partie Sous le capot).
  • La boucle sur le nom de sauvegarde. Elle n'était pas dans la première version de l'outil. En l'essayant pour cette leçon, trois modifications lancées dans la même seconde ont produit trois fois le même nom de sauvegarde : cp -p a écrasé la première, et l'état d'origine du fichier a été perdu. Un horodatage à la seconde n'est pas un identifiant unique.
  • La vérification finale relit le fichier et contrôle qu'il contient exactement une ligne cle = valeur dans la section. Elle ne coûte rien et détecte un script sed faux, une modification concurrente ou un disque plein.
  • Les messages sur la sortie d'erreur, le diff sur la sortie standard : on peut garder la trace des modifications (regler-conf ... > changements.diff) sans y mêler les messages.

L'essayer

$ regler-conf app.conf base taille_pool 20; echo "code : $?"
--- app.conf
+++ app.conf (nouveau)
@@ -7,7 +7,7 @@
 nom = signalements
 utilisateur = signalements
 # taille_pool = 5
-taille_pool = 10
+taille_pool = 20
 
 [api]
 ecoute = 0.0.0.0:8000
regler-conf : app.conf modifié, sauvegarde app.conf.20261009T152149Z
code : 0
$ regler-conf app.conf base taille_pool 20; echo "code : $?"
regler-conf : app.conf déjà conforme ([base] taille_pool = 20)
code : 0
$ ls app.conf*
app.conf
app.conf.20261009T152149Z

La seconde exécution n'a rien touché : pas de diff, pas de nouvelle sauvegarde. Le commentaire de la ligne 9 est intact. Vérifions aussi ce que voit le système de fichiers, en comparant l'inode et la date de modification avec ceux qu'aurait produits un sed -i nu :

$ stat -c '%i %y' app.conf
29244061 2026-10-09 15:22:03.797422210 +0000
$ sed -i 's/^taille_pool = .*/taille_pool = 20/' app.conf; stat -c '%i %y' app.conf
29244062 2026-10-09 15:22:03.807063543 +0000
$ sed -i 's/^taille_pool = .*/taille_pool = 20/' app.conf; stat -c '%i %y' app.conf
29244061 2026-10-09 15:22:03.811262281 +0000

Chaque sed -i donne une nouvelle date de modification, même quand le contenu ne change plus. L'inode change aussi ; qu'il retrouve au troisième tour son numéro d'origine n'a rien de mystérieux : l'inode libéré au tour précédent a été réattribué par le système de fichiers au nouveau fichier. Un numéro d'inode identique ne prouve donc pas que le fichier n'a pas été remplacé. Sur un fichier remis dans son état initial, regler-conf au contraire :

$ cp ../config/app.conf app.conf
$ regler-conf app.conf base taille_pool 20 > /dev/null; stat -c '%i %y' app.conf
regler-conf : app.conf modifié, sauvegarde app.conf.20261009T152203Z
29244063 2026-10-09 15:22:03.844583095 +0000
$ regler-conf app.conf base taille_pool 20; stat -c '%i %y' app.conf
regler-conf : app.conf déjà conforme ([base] taille_pool = 20)
29244063 2026-10-09 15:22:03.844583095 +0000

Le second appel ne touche ni l'inode ni la date. Un outil de supervision qui surveille les modifications de /etc (l'empreinte du fichier, comme auditd ou AIDE du cours d'administration) ne verra ainsi que les vraies modifications.

L'ajout d'une clé absente, en simulation d'abord :

$ regler-conf --dry-run app.conf api delai_lecture 15; echo "code : $?"
--- app.conf
+++ app.conf (nouveau)
@@ -14,6 +14,7 @@
 travailleurs = 4
 delai = 30
 journal_json = oui
+delai_lecture = 15
 
 [stockage]
 seau = sig-photos
code : 5

Le code 5 permet à un script appelant de savoir qu'une modification serait faite, sans la faire : c'est ce qu'un contrôle de dérive de configuration utilisera plus loin.

Les cas qui doivent échouer

Un outil se juge autant à ses refus :

$ regler-conf app.conf base delai_connexion "5; rm -rf /"; echo "code : $?"
regler-conf : valeur refusée (caractères permis : A-Z a-z 0-9 _ . : @ + , -) : 5; rm -rf /
code : 2
$ regler-conf app.conf journaux niveau info; echo "code : $?"
regler-conf : section [journaux] absente de app.conf
code : 3
$ printf '\n[base]\nport = 6432\n' >> app.conf
$ regler-conf app.conf base port 5433; echo "code : $?"
regler-conf : section [base] présente 2 fois dans app.conf
code : 4

La section absente n'est pas créée : une faute de frappe dans le nom de section ([bsae]) produirait sinon une section fantôme, que le service ignorerait sans rien dire. Le fichier avec deux sections [base] est refusé : quelqu'un doit décider laquelle est la bonne.

Ce que sed -i fait aux liens

Revenons au sed -i nu pour vérifier le tableau de la partie Concepts, sur une copie neuve de app.conf (la précédente a reçu une seconde section [base]). Un fichier, un second nom par lien physique, un lien symbolique :

$ cp ../config/app.conf app.conf
$ cp app.conf essai.conf; ln essai.conf lien-dur.conf; ln -s essai.conf lien-sym.conf
$ ls -li essai.conf lien-dur.conf lien-sym.conf
29243853 -rw-rw-r-- 2 admin admin 402 Oct  9 15:43 essai.conf
29243853 -rw-rw-r-- 2 admin admin 402 Oct  9 15:43 lien-dur.conf
29243855 lrwxrwxrwx 1 admin admin  10 Oct  9 15:43 lien-sym.conf -> essai.conf
$ sed -i 's/^taille_pool = 10$/taille_pool = 20/' essai.conf
$ ls -li essai.conf lien-dur.conf lien-sym.conf
29243856 -rw-rw-r-- 1 admin admin 402 Oct  9 15:43 essai.conf
29243853 -rw-rw-r-- 1 admin admin 402 Oct  9 15:43 lien-dur.conf
29243855 lrwxrwxrwx 1 admin admin  10 Oct  9 15:43 lien-sym.conf -> essai.conf
$ grep -H '^taille_pool' essai.conf lien-dur.conf
essai.conf:taille_pool = 20
lien-dur.conf:taille_pool = 10

(Les numéros d'inode, les dates et le nom du compte, ici admin, varient d'une machine à l'autre.) essai.conf a un nouvel inode et un seul lien ; lien-dur.conf, qui partageait l'inode d'origine, garde l'ancienne valeur. Les deux noms, qui désignaient le même fichier, désignent maintenant deux fichiers différents. Modifions à présent à travers le lien symbolique :

$ sed -i 's/^port = 5432$/port = 5433/' lien-sym.conf
$ ls -li essai.conf lien-sym.conf
29243856 -rw-rw-r-- 1 admin admin 402 Oct  9 15:43 essai.conf
29243857 -rw-rw-r-- 1 admin admin 402 Oct  9 15:43 lien-sym.conf

lien-sym.conf n'est plus un lien : c'est un fichier ordinaire, qui contient la modification, et essai.conf, la cible, n'a pas changé. Avec --follow-symlinks, le lien est conservé et la cible modifiée :

$ rm lien-sym.conf; ln -s essai.conf lien-sym.conf
$ sed -i --follow-symlinks 's/^port = 5432$/port = 5433/' lien-sym.conf
$ ls -l lien-sym.conf; grep '^port' essai.conf
lrwxrwxrwx 1 admin admin 10 Oct  9 15:43 lien-sym.conf -> essai.conf
port = 5433

Ce qui se passe quand -n rencontre -i, ou E rencontre i

Les deux pièges de syntaxe du tableau de la partie Concepts, vérifiés :

$ cp app.conf t.conf; sed -n -i 's/^port = 5432$/port = 5433/p' t.conf; cat t.conf; wc -l t.conf
port = 5433
1 t.conf
$ cp app.conf t.conf; sed -iE 's/5432/5433/' t.conf; ls t.conf*
t.conf
t.confE

Avec -n, le fichier de vingt lignes n'en contient plus qu'une : seules les lignes affichées par p ont été écrites dans le temporaire. Avec -iE, sed a créé une sauvegarde nommée t.confE et n'a pas activé les expressions étendues ; si le script avait utilisé ( et \1, sed aurait refusé de s'exécuter (invalid reference \1 on `s' command's RHS), ce qui est le cas favorable.

Et la sauvegarde créée pour rien :

$ rm -f t.conf*; cp app.conf t.conf; sed -i.bak 's/inexistant/x/' t.conf; ls t.conf*; cmp t.conf t.conf.bak && echo identiques
t.conf
t.conf.bak
identiques

Appliquer sur sig-app-1 et sig-app-2

Le dépôt signalements-outils est sur sig-outils, pas sur les serveurs de l'API. Plutôt que d'y copier l'outil, on peut le faire lire par un Bash distant sur son entrée standard : bash -s exécute le script reçu sur l'entrée standard, et les mots après -- deviennent ses paramètres positionnels. Pour un serveur, en simulation d'abord :

cd ~/signalements-outils
ssh sig-app-1 'sudo bash -s -- --dry-run /etc/signalements/app.conf base taille_pool 20' < bin/regler-conf

Puis pour de bon, un serveur à la fois, en vérifiant le service avant de passer au suivant :

for hote in sig-app-1 sig-app-2; do
    ssh "$hote" 'sudo bash -s -- /etc/signalements/app.conf base taille_pool 20' < bin/regler-conf || break
    ssh "$hote" 'sudo systemctl restart signalements && systemctl is-active signalements' || break
    sleep 30
done

Quelques remarques sur ces lignes, que l'on ne montre pas avec une sortie inventée :

  • Le nom du programme affiché dans les messages sera bash, puisque $0 vaut alors bash : c'est le prix de cette méthode. L'autre est de déployer l'outil dans /opt/signalements/bin/ avec install, comme au cours Bash.
  • La boucle lit les noms d'hôtes dans une liste écrite en dur, et ssh reçoit le script sur son entrée standard : on ne peut pas mettre ce ssh dans une boucle while read alimentée par un fichier, pour la raison vue à la leçon 5 du cours Bash (ssh consommerait la liste).
  • || break arrête tout au premier échec. Un serveur sur deux mal configuré est un incident à moitié ; deux sur deux, une panne.
  • systemctl is-active ne prouve pas que l'API fonctionne. Après l'incident, l'équipe sait que /sante ne consulte pas la base : il faut aussi une vraie requête, GET /signalements, depuis sig-outils, et un œil sur les 503 dans les journaux (les commandes de la leçon 2).

La réserve en action : la ligne d'avant

L'espace de réserve sert à se souvenir de la ligne précédente. Pour afficher chaque ligne taille_pool avec la ligne qui la précède, souvent son commentaire :

$ sed -n '/^taille_pool/{x;p;x;p;}; h' app.conf
# taille_pool = 5
taille_pool = 10

À chaque ligne, h copie la ligne courante dans la réserve, qui contient donc toujours la ligne précédente au moment où la ligne suivante est lue. Quand la ligne commence par taille_pool, x échange travail et réserve (le travail contient maintenant la ligne précédente), p l'affiche, x échange à nouveau, p affiche la ligne courante. Le h final, hors du bloc, s'exécute pour toutes les lignes. GNU grep ferait la même chose avec grep -B1 '^taille_pool' : la réserve n'a d'intérêt que lorsqu'il faut transformer en fonction du contexte, pas seulement l'afficher.

Recoller les lignes continuées

Certains fichiers continuent une ligne logique sur plusieurs lignes physiques, avec une barre oblique inverse finale : les commandes dans un script, les variables d'un Makefile, certaines configurations. Pour les analyser avec grep, il faut les recoller :

$ printf '%s\n' 'commande --a \' '    --b \' '    --c' 'autre' > cont.txt
$ cat cont.txt
commande --a \
    --b \
    --c
autre
$ sed -e :a -e '/\\$/N; s/\\\n *//; ta' cont.txt
commande --a --b --c
autre

Le script se lit ainsi : étiquette a ; si la ligne se termine par une barre oblique inverse (/\\$/, la barre doublée pour la rendre littérale), N ajoute la ligne suivante ; la substitution supprime la barre, le saut de ligne et l'indentation qui suit ; si elle a réussi, ta revient à l'étiquette, pour traiter une troisième ligne éventuelle. Les étiquettes s'écrivent dans un -e séparé (ou suivies d'un saut de ligne) : POSIX ne permet pas de les terminer par un point-virgule.

Inverser un fichier

Le dernier exemple n'a aucune utilité pratique (tac le fait), mais il oblige à comprendre la réserve :

$ printf 'un\ndeux\ntrois\n' | sed -n '1!G;h;$p'
trois
deux
un

Ligne 1 : 1!G ne s'applique pas ; h met un en réserve. Ligne 2 : G ajoute la réserve au travail, qui devient deux\nun ; h le met en réserve. Ligne 3 : le travail devient trois\ndeux\nun, et $p l'affiche, puisque c'est la dernière ligne. Tout le fichier passe en mémoire : sur un journal de plusieurs gigaoctets, c'est une très mauvaise idée.

Sous le capot

sed -i, appel système par appel système

strace montre ce que fait GNU sed 4.9 lors d'un sed -i.bak sur une copie de app.conf aux droits 0640. Les appels liés au chargement des bibliothèques et de la locale sont retirés :

$ cp app.conf essai.conf; chmod 640 essai.conf
$ strace -e trace=openat,fchown,fchmod,fgetxattr,fsetxattr,rename,fstat,close sed -i.bak 's/^port = 5432$/port = 5433/' essai.conf
...
openat(AT_FDCWD, "essai.conf", O_RDONLY) = 3
fstat(3, {st_mode=S_IFREG|0640, st_size=402, ...}) = 0
openat(AT_FDCWD, "./sed7OCPB1", O_RDWR|O_CREAT|O_EXCL, 0600) = 4
fstat(3, {st_mode=S_IFREG|0640, st_size=402, ...}) = 0
fstat(4, {st_mode=S_IFREG|0600, st_size=0, ...}) = 0
fchown(4, 1000, 1000)                   = 0
fgetxattr(3, "system.posix_acl_access", 0x7ffff84adc70, 132) = -1 ENODATA (No data available)
fstat(3, {st_mode=S_IFREG|0640, st_size=402, ...}) = 0
fsetxattr(4, "system.posix_acl_access", "\2\0\0\0\1\0\6\0\377\377\377\377\4\0\4\0\377\377\377\377 \0\0\0\377\377\377\377", 28, 0) = 0
close(3)                                = 0
close(4)                                = 0
rename("essai.conf", "essai.conf.bak")  = 0
rename("./sed7OCPB1", "essai.conf")     = 0
close(1)                                = 0
+++ exited with 0 +++

On y retrouve, dans l'ordre, le code des fonctions open_next_file et closedown de sed/execute.c :

  1. L'original est ouvert en lecture seule. Jamais en écriture : c'est pourquoi les droits du fichier ne comptent pas, seulement ceux du répertoire.
  2. Le temporaire est créé dans le même répertoire, sous un nom aléatoire (sed7OCPB1), avec O_CREAT|O_EXCL et les droits 0600. O_EXCL fait échouer la création si le nom existe déjà, y compris sous la forme d'un lien symbolique : un tiers ne peut pas rediriger l'écriture en préparant un lien à ce nom. Les droits 0600 évitent qu'un fichier de secrets soit lisible par d'autres pendant son écriture. Le même répertoire est une nécessité : rename ne fonctionne qu'à l'intérieur d'un même système de fichiers (il échoue avec EXDEV sinon, dit sa page de manuel), et /tmp est souvent un tmpfs distinct.
  3. Le propriétaire est recopié par fchown. En cas d'échec, le code se contente de recopier le groupe, et ignore un second échec : un compte ordinaire ne peut pas donner un fichier à un autre, et l'on obtient alors un fichier qui appartient à celui qui a lancé sed. Sous root, le propriétaire d'origine est bien rétabli.
  4. Les droits et les ACL sont recopiés par la fonction copy_acl de gnulib : ici, sans ACL étendue sur l'original (ENODATA), elle écrit une ACL minimale équivalente aux droits 0640, ce qui revient à un chmod.
  5. Le script est appliqué : les lectures et écritures, filtrées ici, se font entre les descripteurs 3 et 4.
  6. Deux renommages, parce qu'un suffixe a été demandé : l'original devient essai.conf.bak, puis le temporaire devient essai.conf. Sans suffixe, il n'y a que le second.

L'instant où le fichier n'existe pas

Entre les deux rename du point 6, aucun fichier ne porte le nom essai.conf. L'intervalle est très court, mais un service qui relit sa configuration à ce moment précis, ou un systemctl reload lancé en parallèle, reçoit ENOENT. Sans suffixe, le remplacement se fait en un seul rename, que POSIX et la page de manuel de Linux décrivent comme atomique : à tout instant, le nom désigne soit l'ancien fichier, soit le nouveau, jamais rien. C'est le principe de l'écriture atomique, et c'est pourquoi regler-conf sauvegarde avec cp -p puis appelle sed -i sans suffixe.

Et après une coupure de courant ?

On remarque aussi ce qui manque dans la trace : aucun fsync. Le code de sed 4.9 n'en contient pas. Le nouveau contenu peut donc rester dans le cache du noyau quelques secondes après le rename. Sur ext4, l'option de montage auto_da_alloc, active par défaut d'après la documentation du noyau, détecte ce motif de remplacement par renommage et, dans le mode de journalisation par défaut (data=ordered), force l'écriture des données du nouveau fichier sur le disque avant que le renommage ne soit validé dans le journal : c'est ce qui évite le fichier « de longueur nulle » après une coupure. D'autres systèmes de fichiers n'offrent pas cette garantie. Pour un fichier critique, un sync après la modification ne coûte rien.

Le processus qui garde l'ancien fichier

Un processus qui a ouvert le fichier avant la modification garde un descripteur sur l'ancien inode. L'inode n'est plus accessible par aucun nom, mais il n'est pas libéré tant qu'un descripteur l'utilise. Démonstration, en ouvrant le descripteur 3 du shell sur le fichier :

$ cp app.conf t.conf; exec 3< t.conf
$ sed -i 's/^port = 5432$/port = 5433/' t.conf
$ grep '^port' <&3; grep '^port' t.conf
port = 5432
port = 5433
$ readlink /proc/$$/fd/3 | sed 's|.*/||'
t.conf (deleted)
$ exec 3<&-

Par le descripteur, on lit l'ancien contenu ; par le nom, le nouveau. /proc signale le fichier comme supprimé. Pour un service qui lit sa configuration au démarrage, comme l'API, cela ne change rien : il faut de toute façon un redémarrage. Mais pour un programme qui écrit dans un fichier qu'un autre modifie par sed -i (un journal, un fichier d'état), les écritures continuent dans l'ancien inode et sont perdues à sa fermeture. Ne modifiez jamais par sed -i un fichier qu'un processus est en train d'écrire.

Pourquoi le répertoire, et pas le fichier

Le manuel de GNU sed l'écrit dans sa liste de non-bogues : les droits d'un fichier disent ce que l'on peut faire de ses données, les droits d'un répertoire ce que l'on peut faire de la liste des noms qu'il contient. Démonstration sur un fichier en lecture seule, puis dans un répertoire en lecture seule :

$ mkdir ro && cp app.conf ro/app.conf && chmod 444 ro/app.conf
$ echo x >> ro/app.conf
bash: ro/app.conf: Permission denied
$ sed -i 's/^port = 5432$/port = 5433/' ro/app.conf; echo "code $?"
code 0
$ ls -l ro/app.conf; grep '^port' ro/app.conf
-r--r--r-- 1 admin admin 402 Oct  9 15:43 ro/app.conf
port = 5433
$ chmod 555 ro
$ sed -i 's/^port = 5433$/port = 5434/' ro/app.conf; echo "code $?"
sed: couldn't open temporary file ro/sedB28z3k: Permission denied
code 4

Le shell ne peut pas ajouter une ligne au fichier en lecture seule ; sed -i le remplace sans difficulté, et le nouveau fichier garde les droits 0444. Dans un répertoire en lecture seule, sed ne peut pas créer son temporaire et s'arrête avec le code 4, que son manuel réserve aux erreurs d'entrée-sortie (1 : script invalide, 2 : fichier d'entrée introuvable, le traitement continuant avec les autres). Pour protéger un fichier contre sed -i, c'est son répertoire qu'il faut protéger.

sed -s et le flux unique

Sans -i ni -s, plusieurs fichiers forment un seul flux. $= affiche le numéro de la dernière ligne :

$ cp app.conf a.conf; cp app.conf b.conf
$ sed -n '$=' a.conf b.conf
40
$ sed -s -n '$=' a.conf b.conf
20
20

Le premier appel voit une seule « dernière ligne », la quarantième du flux. C'est ce qui rend sed '1d' *.csv faux pour retirer l'en-tête de chaque export : seul le premier perd le sien. sed -s '1d' *.csv ou sed -i '1d' *.csv traitent chaque fichier à part.

Pièges courants

Le motif non ancré. s/taille_pool = .*/.../ modifie aussi # taille_pool = 5 et ancienne_taille_pool = 3. Ancrez au début de ligne et tolérez les blancs : ^[[:space:]]*taille_pool[[:space:]]*=.

La modification qui ignore les sections. Une clé peut exister dans plusieurs sections. Restreignez par un intervalle /^\[section\]/,/^\[/{ ...; }.

L'ajout qui n'est pas idempotent. a, i et $a ajoutent à chaque passage. Comptez d'abord, ajoutez ensuite ; ou utilisez un outil qui le fait (regler-conf, lineinfile d'Ansible).

sed -n -i ... p. Le fichier ne garde que les lignes affichées. -n et -i ensemble sont presque toujours une erreur, sauf pour extraire volontairement une partie d'un fichier.

-iE, -in, -ie. L'option -i mange les lettres qui la suivent comme suffixe. Écrivez -E -i ou -Ei, jamais une lettre collée après i.

Le script copié d'un Mac. sed -i '' ... sur GNU, sed -i ... sur macOS : l'un ou l'autre échoue, ou pire, prend le script pour un nom de fichier. Même sed -i -- script fichier, correct pour GNU sed, ferait de -- un suffixe de sauvegarde avec le sed de FreeBSD ou de macOS, dont l'option -i exige un argument. Dans un script portable, sortez sur un temporaire et renommez.

Le lien symbolique transformé en fichier. sed -i sur un lien remplace le lien. La configuration cesse de suivre le fichier versionné, et personne ne s'en aperçoit avant la prochaine mise à jour du dépôt. --follow-symlinks, ou résolvez le chemin avant (readlink -e).

Les liens physiques rompus. Après sed -i, les autres noms du fichier gardent l'ancien contenu. Rare dans /etc, fréquent dans les arborescences de sauvegarde par liens physiques (rsync --link-dest, du cours d'administration) : ne modifiez jamais un fichier d'une telle sauvegarde avec sed -i.

Les fins de ligne CRLF. Un fichier venu de Windows (voir CRLF) a des \r en fin de ligne. Un motif ancré par $ (/^port = 5432$/) ne trouve rien, parce que le \r précède la fin de ligne ; un motif en =.* remplace le \r de la seule ligne modifiée :

$ printf '[base]\r\ntaille_pool = 10\r\nport = 5432\r\n' > crlf.conf
$ sed '/^\[base\]/,/^\[/{ s/^[[:space:]]*taille_pool[[:space:]]*=.*/taille_pool = 20/; }' crlf.conf | cat -A
[base]^M$
taille_pool = 20$
port = 5432^M$

Le fichier a maintenant des fins de ligne mixtes. Convertissez d'abord (sed -i 's/\r$//', extension GNU pour \r), ou refusez le fichier comme regler-conf.

sed '1d' *.csv sur plusieurs fichiers. Sans -s (ou -i), les fichiers forment un seul flux : seul le premier perd sa première ligne.

La sauvegarde au nom horodaté qui s'écrase. Deux modifications dans la même seconde, même nom, première sauvegarde perdue. Ajoutez un compteur, ou des nanosecondes (date +%N, extension GNU).

La sauvegarde chargée par le service. Un répertoire de surcharge est lu selon un motif. sed -i.bak sur /etc/ssh/sshd_config.d/10-signalements.conf crée 10-signalements.conf.bak, que le motif *.conf ignore. Mais la forme à préfixe, sed -i'ancien-*', crée ancien-10-signalements.conf, qui correspond au motif : sshd charge l'ancienne et la nouvelle version, et pour chaque mot-clé la première lue l'emporte. Chaque logiciel a ses règles (le cron de Debian ignore dans /etc/cron.d/ tout nom qui contient un point, apt avertit et ignore les extensions inconnues dans sources.list.d/) : ne les apprenez pas par cœur, rangez les sauvegardes hors des répertoires lus par les services.

L'ajout à la fin d'un fichier où la première valeur gagne. sshd_config retient la première valeur de chaque mot-clé : une ligne ajoutée à la fin peut n'avoir aucun effet. Vérifiez toujours la configuration effective (sudo sshd -T | grep -i passwordauthentication), pas le fichier.

Le conffile modifié. Un fichier livré par un paquet Debian (conffile) modifié par sed fera poser une question à dpkg à la prochaine mise à jour du paquet, qui bloquera une mise à jour automatique. Préférez le fichier de surcharge (drop-in) dans le répertoire .d/ prévu.

Sécurité

  • L'injection de commandes sed. Un script sed construit à partir d'une valeur externe peut recevoir des commandes. La commande a\ prend une ligne de texte ; si la valeur contient un saut de ligne, ce qui suit devient une nouvelle commande. Avec la commande e de GNU sed, qui exécute son argument dans un shell, c'est une exécution de code :

    $ valeur=$(printf '15\ne date -u +%%F')
    $ script="16a\\
    > delai_lecture = $valeur"
    $ sed "$script" app.conf | head -n 4
    2026-10-09
    # /etc/signalements/app.conf : configuration de l'API Signalements
    2026-10-09
    # Géré à la main sur chaque serveur. Voir docs/serveurs/sig-app-1.md.
    

    La « valeur » a fait exécuter date avant chaque ligne du fichier. À la place de date, n'importe quelle commande, avec les droits de sed, c'est-à-dire souvent root pour une modification dans /etc. Trois défenses, à cumuler : une liste blanche des caractères permis, comme regler-conf ; l'option --sandbox de GNU sed (depuis la version 4.3), qui refuse les commandes e, r et w avant toute exécution ; et le refus de toute valeur qui contient un saut de ligne.

    $ sed --sandbox "$script" app.conf; echo "code : $?"
    sed: -e expression #1, char 25: e/r/w commands disabled in sandbox mode
    code : 1
    
  • sudo sed est un root complet. Une règle sudo qui autorise sed -i * /etc/signalements/app.conf autorise aussi sed -i '1w /etc/sudoers' ... ou la commande e. C'est le même problème que sudo vim dans Premiers pas : on ne délègue pas un outil d'édition général. Déléguez un script précis, possédé par root, non modifiable par l'utilisateur, qui valide ses arguments (regler-conf y est prêt), ou mieux, faites passer la modification par un dépôt et un outil de déploiement.

  • --follow-symlinks sous root. Suivre un lien est sûr quand seul root peut écrire dans le répertoire du lien, comme /etc/signalements/. Dans un répertoire où un utilisateur peut écrire (son répertoire personnel, un répertoire de données partagé), il peut remplacer le fichier par un lien vers /etc/shadow : un sed -i --follow-symlinks lancé par root modifierait alors /etc/shadow. Ne modifiez jamais en root un fichier situé dans un répertoire qu'un autre compte contrôle. GNU sed 4.10 (avril 2026) a d'ailleurs corrigé une situation de concurrence dans --follow-symlinks -i : un attaquant pouvait échanger le lien entre sa résolution et l'ouverture du fichier. Ubuntu 24.04 et Debian 13 l'ont reporté dans leur 4.9 par une mise à jour de sécurité (CVE-2026-5958, paquets 4.9-2ubuntu0.24.04.1 et 4.9-2+deb13u1) : vérifiez que vos serveurs l'ont reçue.

  • Les secrets dans les sauvegardes. Changer un mot de passe dans un fichier de configuration par sed -i.bak laisse l'ancien mot de passe dans fichier.bak, avec les mêmes droits, ce qui est le mieux que l'on puisse espérer ; mais aussi dans les sauvegardes de la machine, dans les copies datées que personne ne supprime, parfois dans un dépôt Git si la sauvegarde est créée dans un répertoire versionné. Pour un fichier qui contient un secret (le mot de passe de la base dans /etc/signalements/env, par exemple), ne gardez pas de sauvegarde en clair, et faites passer le secret par un gestionnaire de secrets plutôt que par sed.

  • Le temporaire est protégé, pas le résultat. sed crée son temporaire en 0600 puis lui donne les droits de l'original : si l'original était trop ouvert (0644 pour un fichier qui contient un mot de passe), le nouveau l'est tout autant. regler-conf ne corrige pas les droits ; vérifiez-les (stat -c '%a %U:%G') lors de la prise en charge d'une machine.

En production

  • Une modification à la main est une dérive. regler-conf rend la modification sûre et répétable ; il ne la rend pas traçable. Sur deux serveurs, avec une fiche de serveur à jour (cours d'administration, leçon 1) et le diff gardé dans le ticket de l'incident, c'est acceptable comme mesure d'urgence. À terme, le fichier app.conf doit être produit par un outil de gestion de configuration à partir d'un dépôt : le module ini_file d'Ansible fait exactement ce que fait regler-conf (section, option, valeur, sauvegarde facultative), et lineinfile couvre les fichiers sans sections, avec une option validate qui teste le fichier avant de le mettre en place. Le cours Ansible : les fondamentaux y viendra.

  • Contrôler la dérive avec --dry-run. Le code 5 de regler-conf --dry-run permet un contrôle quotidien : pour chaque serveur et chaque réglage attendu, une simulation ; tout code 5 signale une machine qui a dérivé. C'est un détecteur rudimentaire, mais il coûte dix lignes.

  • Valider avant de redémarrer. Pour les logiciels qui savent vérifier leur configuration (nginx -t, sshd -t, visudo -c, named-checkconf), lancez la vérification entre la modification et le redémarrage, et revenez à la sauvegarde si elle échoue. L'API Signalements n'a pas de telle commande : à défaut, on redémarre un serveur, on vérifie de vraies requêtes, puis on passe au second, comme dans la boucle de la partie En pratique.

  • Calculer avant de régler. Passer taille_pool de 10 à 20 n'est pas anodin. Si le pool est propre à chaque processus de travail, deux serveurs à quatre travailleurs peuvent ouvrir jusqu'à 2 × 4 × 20 = 160 connexions, auxquelles s'ajoutent l'export nocturne de sig-outils et les outils d'administration. Si la limite de la base est inférieure, l'erreur too many clients already de mercredi reviendra au prochain pic, plus vite. Un réglage de configuration se justifie par un calcul, écrit dans le compte rendu de l'incident à côté du diff.

  • Préférer les fichiers de surcharge. Quand un logiciel lit un répertoire .d/ (/etc/ssh/sshd_config.d/, /etc/sysctl.d/, /etc/rsyslog.d/, les surcharges systemd), écrire un fichier entier à soi y est plus sûr que d'éditer le fichier principal : l'opération est idempotente par nature (on écrase son propre fichier), elle ne touche pas le fichier du paquet, et elle se lit d'un coup d'œil. La modification par sed est le dernier recours, pas le premier.

  • Garder les sauvegardes sous contrôle. Les copies datées s'accumulent. Rangez-les ailleurs si le répertoire est lu par un service, et purgez-les avec la même rigueur que les pièces jointes du cours Bash : la dernière suffit souvent, l'historique est dans le dépôt.

Exercices

1. Prévoir l'effet de -i (niveau 100). Pour chaque commande, dites ce que contient ensuite f.conf (fichier de trois lignes a = 1, b = 2, c = 3), et quels fichiers existent dans le répertoire. (a) sed -i 's/2/20/' f.conf ; (b) sed -i.bak 's/9/90/' f.conf ; (c) sed -n -i '/b/p' f.conf ; (d) sed -iE 's/1/10/' f.conf ; (e) sed -i '$a d = 4' f.conf, lancée deux fois.

Solution

(a) b = 20 à la place de b = 2, aucun autre fichier. (b) Contenu inchangé, mais f.conf.bak est créé quand même, identique : GNU sed crée la sauvegarde même sans modification. (c) Le fichier ne contient plus que b = 2 : -n supprime l'affichage automatique, et seul ce qui est affiché est écrit. (d) a = 10, et une sauvegarde f.confE : E est pris pour le suffixe de -i, et -E n'est pas activé (sans conséquence ici, le script n'utilisant pas d'expression étendue). (e) Deux lignes d = 4 à la fin : a n'est pas idempotent.

2. Restreindre et vérifier (niveau 200). Sans utiliser regler-conf, écrivez la commande sed qui fixe delai = 45 dans la section [api] de app.conf, sans toucher une éventuelle clé delai d'une autre section. Ajoutez une section [stockage] qui contient delai = 60 à votre copie pour vérifier. Montrez ensuite, par une commande, que relancer votre commande ne modifie plus rien.

Solution
printf 'delai = 60\n' >> app.conf      # la section [stockage] est la dernière du fichier
script='/^\[api\]/,/^\[/{ s/^[[:space:]]*delai[[:space:]]*=.*/delai = 45/; }'
sed -i "$script" app.conf
sed -n '/delai/p' app.conf             # delai = 45, puis delai = 60
sed "$script" app.conf | cmp -s - app.conf && echo "idempotent"

L'intervalle limite la substitution à [api]. Le motif ancré avec = juste après le nom ne confond pas delai avec delai_lecture, ce que ne ferait pas ^delai seul : vérifiez en ajoutant delai_lecture = 15 à la section. La comparaison par cmp -s entre la sortie d'un nouveau passage et le fichier prouve l'idempotence sans rien écrire.

3. La configuration qui ne s'applique pas (niveau 200). Après l'incident, un collègue a lancé sur sig-app-1 : sudo sed -i '$a PasswordAuthentication no' /etc/ssh/sshd_config && sudo systemctl reload ssh. Aucune erreur. Les journaux montrent pourtant que le serveur propose toujours l'authentification par mot de passe. Donnez deux causes possibles, la commande qui donne la valeur réellement appliquée, et la bonne façon de faire.

Solution

sshd_config(5) : pour chaque mot-clé, la première valeur obtenue l'emporte. Première cause : un fichier inclus par la ligne Include /etc/ssh/sshd_config.d/*.conf, placée en tête du fichier sur Ubuntu 24.04 et Debian 13, fixe déjà PasswordAuthentication yes (les images cloud déposent souvent un 50-cloud-init.conf). Seconde cause : une ligne PasswordAuthentication yes plus haut dans sshd_config lui-même. Dans les deux cas la ligne ajoutée à la fin est ignorée. Une troisième, plus sournoise : la ligne ajoutée tombe à l'intérieur d'un bloc Match placé en fin de fichier, et ne s'applique qu'aux connexions de ce bloc.

sudo sshd -T | grep -i '^passwordauthentication' affiche la configuration effective. La bonne façon : un fichier de surcharge qui passe avant les autres dans l'ordre alphabétique, par exemple /etc/ssh/sshd_config.d/10-signalements.conf contenant PasswordAuthentication no, puis sudo sshd -t (validation) avant systemctl reload ssh, puis sshd -T pour vérifier. Et, puisque les tentatives viennent d'internet, fermer le port 22 dans le groupe de sécurité : la configuration de sshd n'est que la seconde ligne de défense.

4. La ligne d'avant, la ligne d'après (niveau 200). Avec l'espace de réserve, écrivez une commande sed qui affiche, pour chaque ligne de app.conf contenant 5432, la ligne précédente et la ligne elle-même, séparées par | sur une seule ligne. Puis, avec N, P et D, écrivez une commande qui supprime toute ligne vide suivie d'une autre ligne vide (pour ne garder qu'une ligne vide entre deux blocs).

Solution
$ sed -n '/5432/{x;G;s/\n/ | /p;x;}; h' app.conf
hote = sig-db.pn-signalements.internal | port = 5432

Quand la ligne contient 5432, x met la ligne précédente dans le travail et la ligne courante en réserve, G rajoute la réserve après un saut de ligne, et la substitution remplace ce saut de ligne par | puis affiche. Le second x est indispensable : sans lui, le h final mettrait en réserve la ligne assemblée (hote = ... | port = 5432) au lieu de la ligne courante, et deux lignes consécutives contenant 5432 donneraient un résultat faux. Essayez sur printf 'a 5432\nb 5432\n' avec et sans lui.

sed '$!N; /^\n$/!P; D' fichier

$!N ajoute la ligne suivante (sauf à la fin) ; si l'espace de travail est exactement « vide, saut de ligne, vide » (/^\n$/), on n'affiche pas la première ligne ; D supprime la première ligne et recommence avec la seconde, sans en lire de nouvelle. Ainsi chaque ligne est comparée à sa suivante. cat -s fait la même chose ; l'exercice sert à comprendre le mécanisme.

5. Durcir regler-conf (niveau 200). (a) Lancez regler-conf en remplaçant sa liste blanche par l'acceptation de toute valeur, puis appelez-le avec une valeur contenant un saut de ligne suivi de e id. Que se passe-t-il, et dans quel cas exactement (remplacement ou ajout) ? (b) Ajoutez --sandbox aux appels de sed qui exécutent le script : le problème est-il réglé ? (c) Pourquoi garder malgré tout la liste blanche ?

Solution

(a) Dans le cas de l'ajout, la valeur suit a\ et un saut de ligne ; la ligne e id qui suit devient une commande, exécutée à chaque ligne du fichier, avec les droits de l'appelant (et lors du sed -i, la sortie de id est écrite dans le fichier). Dans le cas du remplacement, le saut de ligne arrive au milieu de la commande s : sed refuse le script (unterminated `s' command), et l'outil s'arrête avec le code 1 sans rien modifier.

(b) --sandbox fait refuser le script entier, avant toute exécution, dès qu'il contient e, r ou w : l'exécution de commande est évitée. Mais une valeur avec un saut de ligne suivi de d ou de q resterait une injection : elle supprimerait des lignes ou tronquerait le fichier, sans aucune commande « dangereuse » au sens de --sandbox.

(c) Parce que --sandbox ne protège que contre trois commandes, alors que la liste blanche protège contre toute modification du sens du script : commandes injectées, caractères spéciaux de la substitution (&, \, /), jokers dans les motifs, et valeurs que le format INI interpréterait (;, #). Les deux se cumulent : la liste blanche est la défense principale, --sandbox la ceinture de sécurité si quelqu'un l'élargit un jour.

6. Le remplacement sans sed -i (niveau 200). Réécrivez l'étape 5 de regler-conf sans sed -i, avec un fichier temporaire créé par mktemp dans le bon répertoire et un mv, en conservant propriétaire, droits et ACL. Quels avantages et inconvénients par rapport à sed -i ?

Solution
repertoire=$(dirname -- "$fichier")
temporaire=$(mktemp -- "$repertoire/.regler-conf.XXXXXX")
trap 'rm -f -- "$temporaire"' EXIT
sed "$script" "$fichier" > "$temporaire"
chown --reference="$fichier" -- "$temporaire" 2>/dev/null || true
chmod --reference="$fichier" -- "$temporaire"
getfacl -- "$fichier" | setfacl --set-file=- -- "$temporaire"   # si des ACL sont utilisées
sync -- "$temporaire"
mv -f -- "$temporaire" "$fichier"
trap - EXIT

Le temporaire est dans le même répertoire pour que mv soit un rename atomique (et non une copie entre systèmes de fichiers). mktemp le crée en 0600 avec O_EXCL, comme sed. Avantages : sync avant le renommage garantit la durabilité sur tout système de fichiers, le script maîtrise chaque étape, et la méthode est portable vers macOS (chown --reference mis à part). Inconvénients : plus de lignes, donc plus d'occasions d'oublier quelque chose (le contexte SELinux, par exemple, que sed recopie et que ce code ne recopie pas : il faudrait chcon --reference). Le trap du cours Bash supprime le temporaire si le script est interrompu.

Récapitulatif

  • sed -i ne modifie pas un fichier : il en écrit un nouveau dans le même répertoire, puis le renomme par-dessus l'ancien. Nouvel inode, liens physiques rompus, lien symbolique remplacé (sauf --follow-symlinks), propriétaire, droits, ACL et contexte SELinux recopiés, processus ouverts qui gardent l'ancien contenu.
  • Ce sont les droits du répertoire qui comptent : sed -i modifie un fichier en lecture seule, et échoue dans un répertoire en lecture seule (code 4).
  • -i prend un suffixe collé (-i.bak) ; -iE est un suffixe E ; -n -i ne garde que les lignes affichées ; macOS exige -i ''. Pas de -i sans sauvegarde qui soit portable.
  • Avec un suffixe, sed fait deux renommages et le fichier n'existe pas un court instant ; sans suffixe, un seul renommage atomique. Aucun fsync.
  • Une modification de configuration doit être idempotente : ancrer les motifs, se limiter à la section (/^\[s\]/,/^\[/{ ...; }), compter avant d'ajouter, comparer (cmp -s) avant d'écrire, refuser les cas ambigus.
  • a, i, c ajoutent, insèrent, remplacent ; la forme POSIX est sur plusieurs lignes (a\ puis le texte). r et w lisent et écrivent d'autres fichiers.
  • L'espace de réserve (h H g G x) est la mémoire de sed ; N, P, D et les étiquettes (:a, ta) traitent plusieurs lignes. Au-delà de quelques commandes, awk est plus lisible.
  • Un script sed construit à partir de valeurs externes est injectable : liste blanche, refus des sauts de ligne, --sandbox.
  • sed n'édite ni JSON, ni YAML, ni les fichiers où la première valeur l'emporte sans vérification de la configuration effective. Préférez les fichiers de surcharge, les outils dédiés, puis la gestion de configuration.

Pour aller plus loin

  • Dans le manuel de GNU sed, la description de -i, --follow-symlinks et --sandbox dans Command-Line Options, les chapitres Multiline techniques et Branching and flow control, et la liste des non-bogues de Reporting Bugs.
  • Le code de open_next_file et closedown dans sed/execute.c : une centaine de lignes qui expliquent tout le comportement de -i.
  • La page sed(1) de FreeBSD, pour les différences de -i et -I avec GNU, et la spécification POSIX de sed, pour la forme portable de chaque commande.
  • Les chapitres sur les commandes avancées de sed & awk (Dougherty et Robbins), qui détaillent l'espace de réserve et les scripts multilignes.
  • La documentation des modules lineinfile et ini_file d'Ansible, pour voir comment un outil de gestion de configuration résout les mêmes problèmes d'idempotence.
  • La leçon suivante, awk : un langage pour les lignes et les champs, quitte la ligne comme unité pour le champ, et remplace les acrobaties de l'espace de réserve par de vraies variables.
+20 XP Carte du ciel →Mon cosmonaute →

Sources