Article technique

Code Delphi qui ne marche que par accident : cinq pièges FPC

Porter le code CCITT, TIFF, PNG, Flate et buffer de flux sur Free Pascal est un moyen sûr de dénicher du Pascal qui ne marche sous Delphi que par accident. Aucun des cinq schémas ci-dessous n'implique un bug de compilateur. Chacun est du code que Delphi exécute correctement grâce à un détail d'implémentation : un paramètre de Result caché qui alias le tableau de l'appelant, une branche hors plage que personne ne lit jamais au-delà, un buffer de longueur nulle dont la seule protection est un commutateur de range check, un offset en base 1 qu'un seul chemin de code passe à 1, et un contrat de TStream.Read que les flux en mémoire n'exercent jamais. Changez de compilateur, ou donnez au même code un fichier malformé, et l'accident cesse de tenir

Ce qui suit décrit la forme exacte de chaque piège, la façon dont PDF Library for Delphi le traite, et la discipline qui va avec : le même source doit produire les mêmes sémantiques de document sur les deux compilateurs, et le seul moyen de le savoir est de vérifier sur les deux. L'article sœur sur le durcissement d'un analyseur PDF Pascal contre les fichiers malveillants couvre la largeur des entiers, la profondeur de récursion et les buffers non initialisés. Celui-ci parle d'une autre classe d'échec : du code qui a toujours été faux pendant qu'un compilateur le couvre en silence

Pourquoi une fonction qui renvoie un tableau dynamique marche-t-elle sans SetLength sous Delphi ?

Parce que Delphi passe la variable de l'appelant elle-même comme paramètre de Result caché, une fonction qui n'alloue jamais son Result peut quand même écrire dans un tableau alloué par l'appelant. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray est la recherche de ligne de référence au cœur du décodage bidimensionnel Group 3 et Group 4 : étant donnée la position courante a0 et la couleur du run courant, elle cherche les éléments de changement de la ligne de balayage précédente, les b1 et b2 du schéma de codage bidimensionnel ITU-T T.4 et T.6, et les renvoie dans un tableau de deux emplacements. L'implémentation tentante écrit Result[0] et Result[1] sans jamais appeler SetLength sur Result

Cela devrait planter à la première écriture, et sur Free Pascal c'est le cas. Sur Delphi non, tant que les sites d'appel ressemblent à ceci : déclarer b: TCCITTIntegerArray, faire un SetLength(b, 2) une fois avant la boucle de balayage, puis dans la boucle affecter b := GetNextChangingElement(a0, IsWhite) et lire b[0] et b[1]. Le guide du langage Delphi indique qu'une fonction dont le Result est une longue chaîne, un tableau dynamique ou un autre type managé reçoit ce Result comme paramètre var supplémentaire, et en pratique le compilateur passe l'adresse de la cible d'affectation. Donc Result dans la fonction, c'est b lui-même, déjà long de deux éléments, et chaque écriture atterrit en mémoire que l'appelant possède. Free Pascal remet à la fonction un tableau nil tout neuf qu'il affecte ensuite à b, ce qui est la lecture du contrat contre laquelle le code aurait dû être écrit dès le départ

Divergence du décodage CCITT dans PDFlibPas : Delphi passe le tableau b de l'appelant comme paramètre Result var caché, donc les écritures atterrissent dans de la mémoire détenue par l'appelant et une recherche manquée conserve les valeurs précédentes, tandis que Free Pascal remet à la fonction un tableau nil neuf que le garde Length doit dimensionner avec SetLength avant la première écriture
Delphi alias le tableau de l'appelant comme paramètre Result caché, si bien que les écritures non gardées atterrissent quand même dans de la mémoire détenue, tandis que Free Pascal arrive avec nil et que le garde d'une ligne transforme le plantage en comportement voulu sans toucher au chemin de décodage Delphi

L'aliasing porte aussi une sémantique dont le décodeur dépend. Result[0] n'est affecté que si le scan trouve un élément supérieur à a0, et Result[1] seulement s'il existe un élément après, donc en cas d'échec les emplacements gardent ce que l'itération précédente avait laissé dans b. La correction évidente, allouer deux emplacements et les mettre à zéro à chaque appel, détruit ce report et change la sortie décodée sur Delphi. PDF Library for Delphi utilise un garde au lieu d'une remise à zéro : sur Delphi c'est du code mort et le chemin de décodage reste identique octet pour octet, et sur Free Pascal cela transforme une faute en comportement voulu. Cette asymétrie est tout l'enjeu, puisque le garde doit être un no-op sur le compilateur où le code produit déjà une sortie vérifiée

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi arrive ici avec le tableau à deux éléments de l'appelant aliasé
  // comme Result, donc ceci ne fait rien ici. FPC arrive avec nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] restent écrits seulement sur un hit, donc un miss
  // garde exactement les valeurs de l'itération précédente
End;

Un compteur qui a survécu à ses données : l'entrée de répertoire TIFF

Quand vous invalidez un tableau, il faut invalider son compteur dans la même instruction, sinon le compteur sera cru par du code qui ne voit jamais le tableau. Une entrée de répertoire d'image TIFF (TIFF 6.0 §2, la disposition de 12 octets de tag, type, compteur et valeur-ou-offset) transporte un compteur 32 bits directement sorti du fichier, et PDF Library for Delphi lit chacune via PopDE: TTIFFEntry, un enregistrement avec Tag, TagType, Length, Offset et les tableaux décodés IntegerValues et DoubleValues. Le test de plage naïf vérifie si Offset + TypeSize * Length dépasse la fin du fichier et, le cas échéant, met les deux tableaux à longueur zéro tout en laissant Result.Length à la valeur du fichier

Deux choses tournent mal ensuite. La fonction se termine par un repli qui dit « si Length vaut zéro, donne à l'entrée un élément à zéro » pour que les appelants puissent toujours lire l'élément zéro. Length n'étant jamais effacé sur le chemin hors plage, ce repli ne se déclenche jamais dans le seul cas pour lequel il existe. Et les appelants lisent bel et bien l'élément zéro, inconditionnellement : Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip et une douzaine d'autres prennent E.IntegerValues[0], et les tables de strips font Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), copiant Length fois quatre octets depuis un tableau qui n'en contient aucun. Un tableau effacé avec un compteur vivant est strictement plus dangereux qu'un tableau non testé, car le non testé tient au moins les octets qu'il prétend

Le second piège est l'ordre. Exécutez les deux SetLength avant le test de plage, dimensionnés sur le compteur du fichier, et une entrée hostile peut demander une allocation de plusieurs gigaoctets avant le moindre test de validité. Sur Delphi, l'exception qui en résulte est typiquement attrapée par un gestionnaire plus haut dans le chemin de chargement d'image et le fichier échoue simplement à se charger, voilà pourquoi le problème passe facilement inaperçu ; ce qui se passe réellement, c'est un événement de mémoire épuisée choisi par le fichier. Le bon ordre alloue après le test et fait voyager le compteur avec les données

Durcissement de l'entrée de répertoire TIFF dans PDFlibPas : l'entrée de 12 octets transporte un compteur venu du fichier, l'ordre défectueux allouait les tableaux d'après ce compteur avant le test de bornes et laissait Result.Length vivant après les avoir vidés, et l'ordre corrigé teste d'abord l'arithmétique Int64 contre la longueur du fichier pour que le compteur soit remis à zéro en même temps que les tableaux
Allouer avant le test de bornes laissait un compteur hostile réclamer des gigaoctets et laissait un compteur vivant sur un tableau vidé, donc le correctif teste d'abord le décalage et remet Result.Length à zéro dans la même instruction que les tableaux
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // le compteur part avec les valeurs
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // seulement maintenant
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... plus loin, le repli existant atteint enfin le cas pour lequel il était prévu :
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Rien ici n'est spécifique au compilateur, et c'est ce qui justifie sa place dans cette liste. Le piège reste latent sur Delphi pour la même raison que sur Free Pascal : il faut une entrée de répertoire pointant au-delà de la fin du fichier, et les fichiers de test ordinaires n'en ont pas. Un portage ne l'expose pas. Lire le code avec la question « que fait Delphi pour moi ici que je ne fais pas moi-même », oui

Que se passe-t-il quand un IHDR PNG annonce un type de couleur que le format ne définit pas ?

PDF Library for Delphi rejette l'image avant que les filtres de lignes ne tournent. L'ISO 15948 §11.2.2 définit le chunk IHDR et sa table 11.1 liste les six combinaisons légales de type de couleur et de profondeur : niveaux de gris à 1, 2, 4, 8 ou 16 bits, couleur indexée à 1, 2, 4 ou 8, et truecolor, niveaux de gris avec alpha et truecolor avec alpha à 8 ou 16. Le piège, c'est un lecteur qui valide les champs méthode de compression et méthode de filtre de l'IHDR et laisse passer FColorType et la profondeur tels quels : un type de couleur illégal produit alors une scanline de zéro octet, et les boucles de dé-filtrage reçoivent un buffer vide

Le code de filtres de lignes dimensionne typiquement tout via un Case FColorType Of qui associe chaque type de couleur à un nombre de composantes. Un type hors des six tombe dans la branche Else, où SourceComponents vaut 0, donc ScanlineByteCount vaut 0, donc SetLength(PreviousScanline, 0) est immédiatement suivi de FillChar(PreviousScanline[0], ScanlineByteCount, 0). Indexer l'élément zéro d'un tableau dynamique vide, c'est une adresse calculée depuis nil. Avec le range check désactivé, un remplissage de zéro octet via cette adresse est un no-op silencieux et le décodeur avance à travers des lignes qui n'existent pas ; avec le range check activé, c'est une ERangeError dès la première image ; et les appels Move qui suivent frôlent l'erreur d'accès. Ce que vous obtenez dépend du compilateur et des commutateurs de build plutôt que de quoi que ce soit que le décodeur ait décidé, et c'est le signe que le décodeur n'a jamais rien décidé

PDF Library for Delphi applique la table de la spécification là où les autres champs de l'IHDR sont contrôlés : COLOR_GRAYSCALE accepte FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE accepte [1, 2, 4, 8], et COLOR_RGB, COLOR_GRAYSCALEALPHA et COLOR_RGBALPHA acceptent [8, 16] ; tout le reste efface ValidImage et l'image est refusée avec sa largeur et sa hauteur intactes pour le diagnostic. Un chunk pHYs plus court que ses neuf octets est contrôlé au même endroit, donc le lecteur de DPI n'indexe jamais S[1] à S[8] d'une chaîne laissée vide par le chunk court

Un décalage 1-based traité comme un pointeur 0-based

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString prend un StartPos en base 1, car son entrée est un AnsiString et l'implémentation Delphi adresse l'entrée zlib via @Input[StartPos]. L'implémentation Free Pascal, écrite contre paszlib pour que les deux cibles Windows lient la compression statiquement, doit adresser la même entrée par arithmétique de pointeurs, et l'arithmétique de pointeurs est en base 0. Mettez next_in à PAnsiChar(Input) + StartPos et avail_in à Length(Input) - StartPos, passez 1, qui est ce que « commencer au début » signifie pour cette fonction, et le build FPC commence à gonfler au second octet et s'arrête un octet avant la fin

Celui-ci se cache facilement parce que l'appelant qu'atteignent la plupart des tests est InflateStr, qui passe 0. Zéro se trouve être le bon offset en base 0, donc les deux builds sont d'accord sur chaque appel InflateStr simple et sur chaque test qui y passe. TPDFDocument.DecodeAllStreams, la routine qu'utilisent SaveQDFToFile et ConvertFileToQDF pour gonfler les flux FlateDecode simples en forme lisible, passe 1. Avec le décalage de un en place, l'en-tête zlib sauté fait échouer le gonflage, mais le flux zlib rapporte quand même un Consumed non nul pour les octets examinés, donc un appelant qui fait confiance à Consumed prend la charge utile vide pour un décodage réussi et remplace chaque flux de contenu par une chaîne vide. Le QDF qui en résulte a le bon nombre de pages, une structure valide et aucun contenu de page, un fichier qui s'ouvre sans erreur dans toutes les visionneuses et n'affiche rien

// Branche FPC de InflateStrFromPosition, après la v3.539.16.
// StartPos est 1-based comme dans la branche Delphi ; bornez-le, puis convertissez-le
// en décalage de pointeur 0-based une seule fois, à la frontière.
If (StartPos < 1) Then
  StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
  Exit;
...
strm.next_in  := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;

Le test qui l'attrape est le plus petit possible : gonflez une charge utile, dégonflez-la depuis la position 0 et depuis la position 1, et confirmez que les deux renvoient la même charge utile et rapportent tous deux un Consumed égal à la longueur complète du flux. Un flux RFC 1950 a un en-tête de deux octets et une fin Adler-32 de quatre octets, donc un décalage de un à l'une des extrémités n'est pas une corruption subtile, c'est un flux qui soit ne démarre pas, soit ne finit pas. La leçon porte sur la frontière, pas sur zlib : quand le paramètre d'une fonction est défini dans une base d'index et que l'implémentation en dessous utilise l'autre, la conversion tient dans exactement une ligne, et un test doit l'appeler avec la valeur qui distingue les deux bases

Pourquoi une lecture TStream.Read courte n'est-elle pas la fin du flux ?

Parce que TStream.Read a le droit de renvoyer moins d'octets que demandé, pour la raison qu'il veut, et seule une valeur de retour de 0 signifie qu'il n'y a plus rien. TMemoryStream et TFileStream sur un disque local remplissent presque toujours la demande, et c'est pourquoi du code qui traite « il a renvoyé moins que demandé » comme une fin de fichier passe tous les tests qui les utilisent. Les flux adossés au réseau, les flux de décompression et n'importe quel descendant de TStream écrit par un client peuvent renvoyer deux octets quand on en demande soixante-quatre mille et avoir encore des gigaoctets derrière

TPLBuffer est le lecteur traversé par chaque analyseur de PDF Library for Delphi, et il peut envelopper un AnsiString, un pointeur, un tableau d'octets ou un TStream. Ses quatre requêtes de balayage, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte et DistanceToOtherBytes, renvoyant toutes un Int64, lisent la source en blocs de 64 Ko à la recherche d'un délimiteur et indiquent à quelle distance il se trouve sans déplacer la position logique. La façon naturelle de terminer une telle boucle est Until ReadCount < BlockSize. Pour les trois sources en mémoire c'est correct, puisque ReadIntoBuffer livre toujours le bloc complet sauf le dernier. Pour une source flux, cela signifie que le scan abandonne au premier read court, rapporte le délimiteur comme absent, et l'analyseur syntaxique au-dessus décide que l'objet se termine là où il ne se termine pas, voilà pourquoi TPLBuffer boucle jusqu'à ce qu'un read renvoie 0

Gestion des lectures courtes dans le tampon de flux PDFlibPas : DistanceToByte balaye des blocs de 64 Ko, l'ancienne boucle traitait Until ReadCount < BlockSize comme une fin de données et abandonnait dès la première lecture courte, tandis que la boucle corrigée tourne jusqu'à ce que ReadCount soit égal à zéro, trouve le délimiteur et restaure la position dans un bloc finally
Un flux peut renvoyer deux octets quand on en demande soixante-quatre mille, donc zéro est le seul signal de fin de données auquel le balayage peut se fier, et la clause finally restaure la position logique quand le délimiteur est trouvé et que la boucle sort plus tôt
// TPLBuffer.DistanceToByte, la boucle après la v3.539.6.
// Zéro est le seul signal de fin de données que définit TStream.Read.
TempPosition := FPosition;
Try
  Repeat
    ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
    For TestPos := 0 To ReadCount - 1 Do
      If TempBuffer[TestPos] = Value Then
      Begin
        Result := TotalSkipped + TestPos;
        Exit;
      End;
    Inc(TotalSkipped, ReadCount);
  Until ReadCount = 0;
Finally
  FPosition := TempPosition;   // un peek ne doit pas déplacer le lecteur
End;

La façon d'épingler cela est un descendant de TMemoryStream dont la surcharge de Read plafonne chaque requête à deux octets. Enveloppez-y la chaîne aaaaaX, mettez la position du buffer à 1, et les quatre requêtes doivent rapporter une distance de 4 jusqu'au X, laisser la position à 1 ensuite, et rapporter -1 pour un octet absent. Un scan qui s'arrête au premier read court voit deux octets, conclut que le flux est épuisé et renvoie -1. Le finally compte autant que la condition de boucle : un Exit au milieu du scan est le chemin normal de succès, et la position logique doit être restaurée sur ce chemin aussi, pas seulement quand la boucle va jusqu'au bout

Une source, deux compilateurs, un seul jeu d'assertions

La discipline derrière ces cinq cas, c'est que « le build Delphi passe » est une preuve sur Delphi, pas sur le source. Un test croisé compilateur qui vaut la peine fait tourner une routine sous les deux compilateurs : construisez un document de deux pages avec contenu compressé via TPDFlib, enregistrez-le, réenregistrez-le en QDF via SaveQDFToFile, réparez le QDF avec RepairQDFFile, chiffrez le fichier en clair avec AES-128 via EncryptFile et un masque de permissions de EncodePermissions, puis rechargez chaque artefact et vérifiez les mêmes choses sur les deux compilateurs : le nombre de pages vaut 2, le titre survit, le texte de la page deux s'extrait intact des fichiers en clair, réparé et chiffré, le mauvais mot de passe est refusé avec un LastErrorCode non nul, EncryptionStrength vaut 128, EncryptionAlgorithm vaut 2, et les bits de permissions individuels de GetUserPermissions reviennent exactement comme encodés

Comparez des sémantiques normalisées plutôt que des octets. Le chiffrement tire des sels aléatoires et le writer affecte les identifiants de document, donc les deux builds ne sont pas censés émettre des fichiers identiques ; ils sont censés émettre des fichiers qui veulent dire la même chose, et les tests doivent être formulés à ce niveau. La branche QDF mérite sa place à cause du piège d'offset : un QDF de deux pages sans contenu passe un test de nombre de pages et échoue à un test d'extraction de texte, alors testez le second. Toute modification qui est un no-op sur un compilateur et un changement de comportement sur l'autre, ce qui décrit quatre des cinq cas ci-dessus, doit franchir les mêmes tests sur les deux

La moitié lien de ce même portage, faire se comprendre les objets OMF de Delphi et les attentes COFF de Free Pascal, a son propre article dans l'édition de liens d'objets FPC Win32 OMF vers COFF, et le durcissement structurel du même lecteur TIFF contre BigTIFF et les fichiers tuilés se trouve dans les notes du décodeur TIFF intégré. Les décodeurs de cet article livrent dans la PDF Library for Delphi pour Delphi, C++Builder et Free Pascal, où l'on attend du même source qu'il gagne le même résultat sur chaque compilateur visé plutôt qu'on le lui accorde