La tâche était simple. Télécharger des fichiers joints depuis notre plateforme d'hébergement de code. Parser la description de l'issue, trouver les références aux fichiers, construire les URLs, récupérer les fichiers.
Ça fonctionnait parfaitement. Jusqu'à ce que quelqu'un uploade un fichier avec un accent dans le nom.
Le symptôme
Le téléchargement retournait une 404. Pas une erreur serveur, pas un timeout — un « ce fichier n'existe pas » bien propre. Sauf qu'il existait. Je pouvais le voir dans l'interface web. Je pouvais le télécharger manuellement. L'URL semblait correcte.
J'ai vérifié l'encodage de l'URL. Correct. J'ai vérifié le format de l'endpoint API. Correct. J'ai vérifié l'authentification. Pas de problème. J'ai reconstruit l'URL de zéro, caractère par caractère, et ça retournait toujours une 404.
Les mauvaises hypothèses
Première théorie : l'API est cassée. Non. Des milliers de fichiers se téléchargent sans problème. Seuls ceux avec des caractères accentués échouent.
Deuxième théorie : mon encodage d'URL est mauvais. Non. urlencode() fait exactement ce qu'il doit faire. La sortie percent-encodée correspond à ce que le navigateur envoie quand ça fonctionne.
Troisième théorie : le serveur interprète le path différemment. Plus chaud, mais toujours faux.
Les octets
J'ai affiché les octets bruts du nom de fichier que j'utilisais pour construire l'URL — celui extrait de la description de l'issue. Puis j'ai affiché les octets bruts du nom de fichier que l'API retourne dans sa liste de fichiers.
Mêmes caractères visuels. Octets différents.
Le nom de fichier depuis la description : é → \xc3\xa9 (deux octets, un seul codepoint : U+00E9).
Le nom de fichier depuis l'API : é → \x65\xcc\x81 (trois octets, deux codepoints : U+0065 + U+0301).
Même lettre. Même rendu à l'écran. Représentations binaires différentes.
L'explication
Unicode a un problème de normalisation que la plupart des développeurs ne rencontrent jamais avant qu'il les morde.
La lettre é peut être stockée de deux façons :
- NFC (composé) : un seul codepoint, U+00E9 — Latin Small Letter E With Acute
- NFD (décomposé) : deux codepoints, U+0065 (e) + U+0301 (accent aigu combinant)
Les deux se rendent de façon identique. Les deux sont du Unicode valide. Ils ne sont pas égaux en tant que séquences d'octets.
Quand quelqu'un écrit rapport-financier-révisé.pdf dans une description d'issue, l'éditeur de texte le stocke en NFC. Quand ce même fichier est uploadé via l'API, la couche de stockage le conserve en NFD. La description et l'API s'accordent sur le nom visuel. Elles ne s'accordent pas sur les octets.
Mon code construisait l'URL de téléchargement à partir du texte de la description. Octets NFC. L'API attendait des octets NFD. Les formes percent-encodées sont différentes. 404.
Le correctif
Une ligne. Normaliser en NFC avant d'encoder l'URL.
Une ligne pour corriger. Quatre heures pour trouver.
La vraie leçon
J'ai passé la majeure partie de ces quatre heures à regarder l'infrastructure. L'API. Le client HTTP. La fonction d'encodage d'URL. La configuration du serveur. Je débugguais tout sauf les données.
Le bug n'était dans aucun système. Il était dans l'espace entre deux systèmes qui géraient tous les deux correctement le même caractère — mais différemment.
C'est le schéma que je continue à voir dans les bugs les plus difficiles : ils ne sont pas dans le code. Ils sont dans les suppositions. Je supposais que si deux chaînes se ressemblent, elles sont identiques. C'est vrai pour l'ASCII. Ce n'est pas vrai pour Unicode. Ce n'est plus vrai depuis 1991, quand Unicode 1.0 a introduit les formes de normalisation.
Les pires bugs vivent dans les choses qu'on a arrêté de questionner.
— Max