Le patch qui a réécrit des octets qu'on ne lui avait jamais demandé de toucher

La demande est volontairement minuscule : renommer une variable dans un fichier de parser encodé en ISO-8859-1 depuis avant votre arrivée dans l’équipe. L’agent annonce un succès, le diff ressemble à un seul bloc, et vous ouvrez le fichier. Votre éditeur affiche é là où il y avait é, sur des lignes que votre demande n’a jamais mentionnées. git diff --stat annonce bien plus de lignes modifiées que demandé. Rien n’a produit d’erreur, rien n’a averti, et la vérification de l’outil lui-même disait que le patch avait été appliqué. Voici l’histoire de deux chemins d’écriture qui pouvaient perdre des octets qu’ils ne touchaient même pas — et pourquoi le filet de sécurité leur donnait raison.
Un chemin d’écriture n’est exact que dans la mesure de sa lecture
Voici la partie facile à manquer quand on réfléchit aux outils de fichiers : pour patcher un fichier, l’outil doit d’abord le lire, et tout ce que cette lecture fait aux octets, c’est ce qui est réécrit. Deux lectures sur ces chemins étaient avec perte.
Le mode patch par défaut passait par le transport texte. mode="replace" — le mode par défaut — et le mode d’édition V4A, pour tout octet au-delà du premier échantillon binaire de 1 000 octets, décodaient le contenu avec errors="replace". C’est le décodeur poli qu’on veut pour l’affichage : tout octet que l’UTF-8 ne peut pas décoder devient U+FFFD, le caractère de remplacement. Pour un humain qui lit un log, c’est une faveur. Pour une lecture qui alimente une écriture, c’est une mutation silencieuse — chaque octet indécodable sur des lignes que votre édition n’a jamais touchées ressortait en EF BF BD. Un patch d’une ligne sur ce parser Latin-1 réécrivait tous les 0xE9 du fichier.
La lecture V4A exécutait un nettoyage devenu inutile. Le chemin de lecture brut faisait passer tout le fichier par un nettoyage de fuite de fences de terminal, qui supprime les séquences d’échappement OSC et les octets BEL, et élimine toute ligne contenant un littéral __HERMES_FENCE_. Ce wrapper était un contournement pour une couche de backend depuis remplacée ; plus rien ne l’émet, ce qui signifie que le nettoyage ne pouvait plus retirer que des octets appartenant à votre fichier — des caractères de contrôle dans une fixture, une capture, un fichier de protocole legacy.
Pourquoi rien ne l’a attrapé
L’outil dispose d’une étape de vérification. Le diff qu’on vous a montré aussi. Les deux relisaient le fichier par le même transport avec perte qui venait justement de le corrompre. Les octets corrompus se décodent en les mêmes caractères de remplacement au retour, la comparaison colle, et l’outil rapporte un patch propre et bien appliqué.
Cette combinaison — muter à la lecture, puis contrôler avec la même lecture — est le schéma à retenir au-delà de ce projet. Un contrôle qui partage la transformation à l’origine du dégât ne peut pas le détecter ; il ne peut que confirmer que le dégât est stable. Le symptôme rapporté n’était donc jamais un message d’erreur. C’était une autopsie : un relecteur qui remarque du mojibake dans git diff, ou une CI qui échoue sur un fichier dont l’encodage avait discrètement changé sous ses pieds.
Ce que fait le correctif
PR #120515 fait que les deux modes de patch lisent exactement les octets qu’ils réécrivent. Les lectures sont natives sur un hôte POSIX local pour les fichiers réguliers (un FIFO passe toujours par le chemin shell, avec son timeout), et en base64 sur le fil pour les backends distants. Le contenu traverse ensuite la couche texte avec surrogateescape, le codec qui fait un aller-retour sans perte pour tout octet indécodable — c’est l’inverse exact de l’encodage que write_file utilisait déjà à cette fin, verrouillé par un test d’aller-retour.
Un détail explique pourquoi ils n’ont pas simplement fait de « pas un UTF-8 valide » une erreur de lecture : les opérations V4A Add File, Delete File et les deux extrémités de Move File utilisent un échec de lecture comme test d’existence. Transformer un fichier à l’encodage exotique en erreur de lecture aurait fait dire à l’outil qu’un fichier qui existe manifestement n’existe pas. surrogateescape garde ces fichiers lisibles et exacts au niveau des octets.
Le bug frère : des écritures qui vidaient le fichier
Le second correctif, PR #122012, est pire de la façon la plus directe. Sur les backends heredoc-stdin — Modal, Daytona et Vercel — write_file et patch ne corrompaient pas la cible. Ils la remplaçaient par un fichier vide.
Le mécanisme est un détail de shell : le heredoc n’était lié qu’à la dernière commande du script wrapper (trap - EXIT), donc le cat > "$tmp" censé recevoir le contenu ne lisait rien du tout, et le fichier temporaire vide était mis en place. Le correctif redirige le heredoc vers toute la commande composée, et ajoute la moitié manquante pour que le corps arrive exact au niveau des octets — sans le saut de ligne final que le heredoc ajoute, afin que les écritures passent le contrôle.
Le commit de suivi est une petite leçon de portabilité shell : la charge utile est réémise en n’utilisant que des builtins du shell, en évitant délibérément base64/head, parce qu’un binaire manquant redonnerait silencieusement une entrée standard vide à la commande ; et || : est nécessaire parce qu’avec set -e hérité, le statut de fin de fichier de read tuait le lecteur et produisait une entrée standard vide, mesuré sur bash 3.2 et 5.2. Un bug qui vidait des fichiers à distance a été corrigé sans ajouter une seule dépendance externe sur le chemin critique.
Que faire face à ça
- Connaissez l’encodage avant de patcher.
file -I path/to/fileprend une seconde et vous dit si vous êtes en UTF-8 ou dans un encodage simple octet hérité. Tout ce qui n’est pas de l’UTF-8 mérite les contrôles ci-dessous. - Contrôlez avec des octets, pas avec l’outil.
git diff --stat(des comptes de lignes, pas « ça a l’air bon »),cmpcontre une copie saine, ou un hash de la zone que vous n’avez pas touchée. Pour les fichiers proches du binaire — fixtures, captures, blobs fournisseurs — un hash est le seul contrôle honnête. - Considérez le contrôle post-édition comme suspect quand il vient du même outil. Si l’outil qui a écrit le fichier vous dit aussi que le fichier est bon, c’est une affirmation sur son propre chemin de lecture, pas sur vos octets.
- Recontrôlez tout ce qui a été écrit vers un backend sandbox ces dernières semaines. Un fichier de zéro octet se présente comme un fichier ; si une configuration, un lockfile ou une fixture a été mystérieusement vide, c’est une explication candidate. Vide est plus facile à repérer que corrompu, mais seulement si vous regardez.
- Les deux correctifs sont sur
mainuniquement. Ils ont été fusionnés le 2026-09-25, un jour après le tag v0.21.5, donc ils n’y sont pas (voir la page de release v0.21.5 pour ce qu’elle contient). Ils arrivent avec la prochaine release ou unhermes updatequi suitmain. Si vous êtes aujourd’hui sur les chemins hébergés ou sandbox, prenez l’habitude de contrôler la taille des fichiers après une écriture.
L’encodage est un thème récurrent dans les correctifs récents de ce projet — le billet sur le correctif de locale Windows couvre l’autre moitié, où le crash venait du décodage plutôt que de l’écriture. Et la question générale « mes données sont-elles intactes ? » est traitée plus longuement dans les notes sur la corruption de la base d’état : même règle, autre couche de stockage.
Ce qu’il faut retenir
L’échec intéressant ici n’est pas qu’un fichier ait été corrompu. C’est que le contrôle partageait le défaut. Un contrôle qui passe par la même transformation, le même codec ou le même transport que l’opération qu’il surveille ne peut jamais confirmer que la cohérence — jamais l’exactitude. Quand vous construisez un pipeline qui lit, transforme et écrit des données que vous n’avez pas écrites vous-même, gardez au moins un contrôle indépendant qui ne touche jamais à la transformation : un nombre d’octets, un hash, une taille, une copie faite avant l’écriture. Ici, ce contrôle indépendant est exactement ce que le correctif a rendu possible à nouveau, et c’est la raison pour laquelle la prochaine corruption silencieuse de ce genre se manifestera comme un diff plutôt que comme une surprise.