sed : éditer des fichiers sans les casser
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.confElle « 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 :
| Écriture | Signification 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 sauvegardeSur 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 -i | Pourquoi |
|---|---|---|
| Numéro d'inode | nouveau | c'est un autre fichier |
| Liens physiques | rompus : les autres noms gardent l'ancien contenu | ils pointent vers l'ancien inode |
| Lien symbolique passé en argument | remplacé par un fichier ordinaire, la cible n'est pas modifiée | le renommage écrase le nom du lien ; --follow-symlinks change ce comportement |
| Propriétaire et groupe | recopiés si possible | fchown sur le temporaire ; seul root peut donner un fichier à un autre compte |
| Droits et ACL | recopiés | copie des droits et des ACL de l'original |
| Contexte SELinux | recopié si SELinux est actif | le temporaire est créé avec le contexte de l'original |
Attributs étendus autres que les ACL, attributs chattr | perdus | non recopiés |
| Date de modification | celle 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 ouvert | continuent de lire l'ancien contenu | leur 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 :
| Commande | Effet |
|---|---|
a texte | append : place texte dans une file d'attente, écrite après la ligne courante, à la fin du cycle |
i texte | insert : écrit texte immédiatement, donc avant la ligne courante |
c texte | change : 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 = 15GNU 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 :
| Commande | Effet | Moyen mnémotechnique |
|---|---|---|
h | copie le travail dans la réserve (la réserve est écrasée) | hold |
H | ajoute un saut de ligne puis le travail à la fin de la réserve | Hold, en ajoutant |
g | copie la réserve dans le travail (le travail est écrasé) | get |
G | ajoute un saut de ligne puis la réserve à la fin du travail | Get, en ajoutant |
x | échange les deux | exchange |
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 :
Najoute 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 sousPOSIXLY_CORRECT). Le motif$!N(« sauf sur la dernière ligne ») évite la question.Paffiche l'espace de travail jusqu'au premier saut de ligne.Dsupprime 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 :
| Fichier | Avec sed | Plutôt |
|---|---|---|
clé = valeur simple, INI à sections | acceptable avec les précautions de cette leçon | regler-conf, puis la gestion de configuration |
| JSON | dangereux : imbrication, échappements, virgules | jq (leçon 8) |
| YAML | dangereux : l'indentation porte le sens | yq, ou réécrire le fichier depuis un modèle |
| Fichier livré par un paquet (conffile) | à éviter : chaque mise à jour du paquet posera une question | un 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ée | fichier de surcharge, et vérification par sshd -T |
| Fichier entièrement produit par vous | inutile | le générer depuis un modèle (envsubst, gabarit) |
/etc/sudoers | interdit sans validation | visudo -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" -
firegler-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" >&2Les 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 deapp.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.confest un lien (vers un fichier d'un dépôt, par exemple),readlink -edonne la cible finale, et c'est elle que l'on sauvegarde et modifie. Sans cette précaution,sed -iremplacerait le lien par un fichier ordinaire, comme le montre la section suivante. GNU sed propose--follow-symlinkspour 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]\rest bien trouvé ; mais la substitutions/...=.*/.../remplace aussi le\rfinal 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 ... =puisa: 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 variablescriptcontient un vrai saut de ligne aprèsa\: c'est la forme POSIX.cmp -savant 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, puissed -isans suffixe.cp -pconserve droits, propriétaire et dates ;sed -isans 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 -pa é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 = valeurdans 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
diffsur 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-confPuis 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
doneQuelques 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$0vaut alorsbash: c'est le prix de cette méthode. L'autre est de déployer l'outil dans/opt/signalements/bin/avecinstall, comme au cours Bash. - La boucle lit les noms d'hôtes dans une liste écrite en dur, et
sshreçoit le script sur son entrée standard : on ne peut pas mettre cesshdans une bouclewhile readalimentée par un fichier, pour la raison vue à la leçon 5 du cours Bash (ssh consommerait la liste). || breakarrête tout au premier échec. Un serveur sur deux mal configuré est un incident à moitié ; deux sur deux, une panne.systemctl is-activene prouve pas que l'API fonctionne. Après l'incident, l'équipe sait que/santene consulte pas la base : il faut aussi une vraie requête,GET /signalements, depuissig-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 :
- 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.
- Le temporaire est créé dans le même répertoire, sous un nom aléatoire (
sed7OCPB1), avecO_CREAT|O_EXCLet les droits0600.O_EXCLfait é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 droits0600évitent qu'un fichier de secrets soit lisible par d'autres pendant son écriture. Le même répertoire est une nécessité :renamene fonctionne qu'à l'intérieur d'un même système de fichiers (il échoue avecEXDEVsinon, dit sa page de manuel), et/tmpest souvent untmpfsdistinct. - 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. Sousroot, le propriétaire d'origine est bien rétabli. - Les droits et les ACL sont recopiés par la fonction
copy_aclde gnulib : ici, sans ACL étendue sur l'original (ENODATA), elle écrit une ACL minimale équivalente aux droits0640, ce qui revient à unchmod. - Le script est appliqué : les lectures et écritures, filtrées ici, se font entre les descripteurs 3 et 4.
- Deux renommages, parce qu'un suffixe a été demandé : l'original devient
essai.conf.bak, puis le temporaire devientessai.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 commandeede 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
dateavant chaque ligne du fichier. À la place dedate, n'importe quelle commande, avec les droits de sed, c'est-à-dire souventrootpour une modification dans/etc. Trois défenses, à cumuler : une liste blanche des caractères permis, commeregler-conf; l'option--sandboxde GNU sed (depuis la version 4.3), qui refuse les commandese,retwavant 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 : 1sudo sedest unrootcomplet. Une règlesudoqui autorisesed -i * /etc/signalements/app.confautorise aussised -i '1w /etc/sudoers' ...ou la commandee. C'est le même problème quesudo vimdans Premiers pas : on ne délègue pas un outil d'édition général. Déléguez un script précis, possédé parroot, non modifiable par l'utilisateur, qui valide ses arguments (regler-confy est prêt), ou mieux, faites passer la modification par un dépôt et un outil de déploiement.--follow-symlinkssousroot. Suivre un lien est sûr quand seulrootpeut é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: unsed -i --follow-symlinkslancé parrootmodifierait alors/etc/shadow. Ne modifiez jamais enrootun 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, paquets4.9-2ubuntu0.24.04.1et4.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.baklaisse l'ancien mot de passe dansfichier.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
0600puis lui donne les droits de l'original : si l'original était trop ouvert (0644pour un fichier qui contient un mot de passe), le nouveau l'est tout autant.regler-confne 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-confrend 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 fichierapp.confdoit être produit par un outil de gestion de configuration à partir d'un dépôt : le moduleini_filed'Ansible fait exactement ce que faitregler-conf(section, option, valeur, sauvegarde facultative), etlineinfilecouvre les fichiers sans sections, avec une optionvalidatequi 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 deregler-conf --dry-runpermet 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_poolde 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 desig-outilset les outils d'administration. Si la limite de la base est inférieure, l'erreurtoo many clients alreadyde 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 - EXITLe 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 -ine 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 -imodifie un fichier en lecture seule, et échoue dans un répertoire en lecture seule (code 4). -iprend un suffixe collé (-i.bak) ;-iEest un suffixeE;-n -ine garde que les lignes affichées ; macOS exige-i ''. Pas de-isans 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,cajoutent, insèrent, remplacent ; la forme POSIX est sur plusieurs lignes (a\puis le texte).retwlisent et écrivent d'autres fichiers.- L'espace de réserve (
h H g G x) est la mémoire de sed ;N,P,Det 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-symlinkset--sandboxdans 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_fileetclosedowndanssed/execute.c: une centaine de lignes qui expliquent tout le comportement de-i. - La page sed(1) de FreeBSD, pour les différences de
-iet-Iavec 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
lineinfileetini_filed'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.
Sources
- GNU sed Manual : Command-Line Options (-i, --follow-symlinks, -s, --sandbox)
- GNU sed Manual : Reporting Bugs (« -i clobbers read-only files », « sed -i » et les liens)
- GNU sed Manual : Multiline techniques et Hold and Pattern Buffers
- GNU sed 4.9, code source : sed/execute.c (open_next_file, closedown)
- POSIX.1-2024, utilitaire sed (pas d'option -i ; forme des commandes a, i, c)
- FreeBSD, page de manuel sed(1) : -i extension et -I
- Debian, journal des modifications de sed 4.9-2+deb13u1 (CVE-2026-5958, --follow-symlinks)
- Linux man-pages, rename(2) : atomicité et EXDEV
- Documentation du noyau Linux, ext4 : option auto_da_alloc
- OpenBSD, page de manuel sshd_config(5) : « the first obtained value will be used »
- Ansible, module ansible.builtin.lineinfile
- Ansible, module community.general.ini_file
- Dale Dougherty et Arnold Robbins, sed & awk, 2e édition, O'Reilly (chapitre 6 : commandes avancées de sed)