Article technique

Dérive de longueur BIFF dans un générateur XLS Delphi

HotXLS 2.376.0 a corrigé une dérive de longueur d’enregistrement BIFF dans son générateur XLS classique : l’émetteur SXEx des vues PivotTable déclarait un corps de 24 octets dans son en-tête, puis en ajoutait 26. Un lecteur BIFF fait confiance à la longueur déclarée ; les deux octets excédentaires désynchronisaient donc tout ce qui suivait et les classeurs associant une PivotTable à une feuille de graphique perdaient le graphique à la réouverture

La partie intéressante n’est pas le mot décalé d’une unité. C’est la distance entre l’erreur et le symptôme. Rien n’échouait au point du bug. Les enregistrements du pivot étaient sérialisés proprement, le fichier s’écrivait sans erreur, Excel l’ouvrait et les dégâts n’apparaissaient que des centaines d’octets plus loin, dans un sous-flux sans aucun rapport. Cette distance est caractéristique de tout format binaire préfixé par une longueur, et il vaut la peine de la comprendre avant d’en écrire un nouvel émetteur

Pourquoi une seule longueur d’enregistrement erronée détruit-elle tout un flux de feuille de calcul ?

Un flux de classeur BIFF8 n’a aucun encadrement au-delà de sa propre arithmétique. Chaque enregistrement est constitué d’un en-tête de 4 octets, formé de l’identifiant d’enregistrement (2 octets) et de la longueur du corps (2 octets), suivi d’exactement ce nombre d’octets de charge utile ([MS-XLS] 2.1.4). Il n’y a ni séparateur, ni octet magique, ni checksum, ni point de resynchronisation. Le lecteur arrive à l’enregistrement suivant uniquement parce que le précédent lui a dit la vérité sur sa propre taille. La longueur déclarée n’est pas une métadonnée de l’enregistrement ; c’est le pointeur vers le suivant. Suivez donc ce que les deux octets excédentaires ont provoqué. Le lecteur a consommé l’en-tête SXEx, ignoré les 24 octets promis par l’en-tête et s’est retrouvé deux octets trop tôt, sur une paire de zéros restée dans le corps surdimensionné. Il a lu ces zéros comme un identifiant d’enregistrement $0000, puis l’identifiant de fin de feuille de calcul suivant ($000A) comme la longueur de cet enregistrement fantôme et a consciencieusement sauté dix octets dans ce qui venait après. À partir de là, chaque en-tête était lu au mauvais offset. Dans le classeur en échec, cela produisait une feuille de graphique dont _Chart valait nil après la réouverture et un dump de débogage où $18AF était interprété comme un identifiant d’enregistrement. Aucune de ces valeurs n’apparaît à proximité du code du pivot

L’émetteur et l’écriture ne confrontent jamais leurs comptes

La raison structurelle qui rendait la dérive possible est que HotXLS construit un enregistrement BIFF comme un TXLSBlob dont l’en-tête et la charge utile sont deux faits indépendants. EmitSXEx écrit l’identifiant d’enregistrement, puis Blob.AddWord(24) pour la longueur, puis ajoute le corps champ par champ. Ce 24 est une constante comptée à la main, jamais dérivée des octets qui suivent ni contrôlée par rapport à eux. Le chemin d’écriture ne comble pas non plus l’écart : AddRec transmet le blob à TXLSBlobList.Append, qui copie verbatim Data.DataLength octets dans le flux de sortie. DataLength est le vrai nombre d’octets ; l’écrivain émet donc fidèlement 26 octets de corps derrière un en-tête qui en annonce 24. Les deux moitiés font exactement ce qu’on leur a demandé et personne n’est chargé de remarquer leur contradiction. HotXLS évite déjà ce problème lorsqu’il rejoue des charges utiles préservées : TXLSWorkbook.StoreDConnBlobs calcule le mot de longueur de l’en-tête à partir de la longueur réelle du corps plutôt que d’un littéral, raison exacte pour laquelle le rejeu de blobs n’a jamais dérivé

Ce que [MS-XLS] 2.4.282 fixe pour SXEx

La spécification est sans ambiguïté sur la taille, ce qui a rendu le correctif mécanique. [MS-XLS] 2.4.282 définit le corps SXEx comme un grbit de 4 octets suivi de dix champs de 2 octets : csxformat, cchErrorString, cchNullString, cchTag, csxselect, crwPage, ccolPage, cchPageFieldStyle, cchTableStyle et cchVacateStyle. Quatre plus vingt font vingt-quatre. L’ancien émetteur écrivait onze mots nuls là où la spécification en définit dix, et les appels anonymes à AddWord(0) ne portaient aucun nom de champ ; les compter à l’œil pendant une revue était donc aussi fiable qu’on peut l’imaginer. La préallocation était l’indice que la disposition avait été comprise mais pas la boucle : TXLSBlob.Create(28) demande exactement quatre octets d’en-tête plus un corps de 24 octets, alors que le blob dépassait cette indication à chaque appel, silencieusement, parce que AdjustBufferSize réalloue à la demande. Une indication de capacité que le code dépasse immédiatement mérite un second regard dans tout sérialiseur

function EmitSXEx(Table: TXLSPivotTable; DataList: TXLSBlobList): Integer;
var
  Blob: TXLSBlob;
begin
  Blob := TXLSBlob.Create(28);   // en-tête de 4 octets + corps de 24 octets
  Blob.AddWord($00C6);
  Blob.AddWord(24);
  Blob.AddByte($02);
  Blob.AddByte($00);             // grbit1 = fPrintTitles
  Blob.AddByte($00);
  Blob.AddByte($00);             // grbit2
  // Dix mots nuls complètent le corps de 24 octets selon [MS-XLS] 2.4.282
  // La longueur déclarée DOIT correspondre aux octets écrits, sinon chaque
  // enregistrement suivant est mal analysé
  Blob.AddWord(0);               // csxformat
  Blob.AddWord(0);               // cchErrorString
  Blob.AddWord(0);               // cchNullString
  Blob.AddWord(0);               // cchTag
  Blob.AddWord(0);               // csxselect
  Blob.AddWord(0);               // crwPage
  Blob.AddWord(0);               // ccolPage
  Blob.AddWord(0);               // cchPageFieldStyle
  Blob.AddWord(0);               // cchTableStyle
  Blob.AddWord(0);               // cchVacateStyle
  AddRec(DataList, Blob);
  Result := 1;
end;

Pourquoi cela a-t-il survécu à toute une suite de tests PivotTable ?

Parce que les tests de pivot existants ne faisaient jamais de round-trip par fichier. Ils construisaient un classeur, vérifiaient le modèle en mémoire et s’arrêtaient là ; les assertions en mémoire ne peuvent pas voir une différence de longueur qui n’existe que dans le flux d’octets sérialisé. L’ensemble d’enregistrements couvert par l’écriture d’enregistrements PivotTable BIFF8 depuis Delphi était bien testé selon cette norme, tout en livrant un émetteur qui corrompait le flux. Le défaut avait aussi besoin d’une deuxième fonctionnalité pour devenir visible : une feuille avec pivot suivie de peu de choses se rouvrait encore, car la corruption sortait de la fin d’un sous-flux que personne n’inspectait. Seule la combinaison d’une PivotTable et d’une feuille de graphique, où les feuilles de graphique et les dessins occupent un sous-flux situé après la feuille de calcul, transformait un mauvais alignement silencieux en objet visiblement manquant

// PivotChartRoundTripThroughLinkRecords, version condensée
Wb.Sheets.Add.Name := 'Report';
Wb.Sheets[2].AddPivotTable('Data!A1:B3', 2, 2, 'SalesPivot');
Wb.Sheets.AddChartSheet('PivotView', TXLSChartType(2), '', '', '',
  Series, No3D, PivotInfo);
Assert.AreEqual(1, Wb.SaveAs(TempPath));
Wb.Free;
Wb := TXLSWorkbook.Create;
Wb.Open(TempPath);                       // l’analyse erronée se produit ici
Model := Wb.Sheets[3]._Chart.GetChartModel;
Assert.IsTrue(Model.IsPivotChart);

Avant le correctif, Wb.Sheets[3]._Chart valait nil sur cette ligne, car le lecteur avait perdu la limite du sous-flux bien avant d’atteindre le BOF du graphique. L’assertion qui a finalement détecté un bug de sérialisation du pivot était une assertion sur un graphique

Relire un flux BIFF mal aligné jusqu’au premier enregistrement erroné

Parcourez la chaîne des en-têtes et affichez-la, car un flux BIFF désynchronisé s’annonce structurellement bien avant que les données aient l’air incorrectes. Commencez au BOF du sous-flux ($0809), lisez l’identifiant et la longueur, avancez de quatre octets plus la longueur et recommencez. Tant que le flux est aligné, vous arrivez sur des identifiants plausibles et la chaîne se termine exactement sur EOF ($000A). Lorsqu’elle dérive, vous obtenez des identifiants inexistants, des longueurs qui dépassent le tampon ou une chaîne qui passe tout droit au-delà de l’endroit où EOF aurait dû se trouver

// Parcourir un flux d’enregistrements BIFF et s’arrêter au premier en-tête impossible
procedure ScanRecords(Buf: PByte; Size: LongWord);
var
  Pos: LongWord;
  Id, Len: Word;
begin
  Pos := 0;
  while Pos + 4 <= Size do
  begin
    Id  := PWord(Buf + Pos)^;
    Len := PWord(Buf + Pos + 2)^;
    // Un identifiant nul n’est jamais un enregistrement légal et un corps qui
    // dépasse le tampon prouve que la chaîne a déjà dérivé en amont
    if (Id = 0) or (Pos + 4 + LongWord(Len) > Size) then
    begin
      WriteLn(Format('desync at %d: id=$%.4x len=%d', [Pos, Id, Len]));
      Break;
    end;
    WriteLn(Format('%6d  id=$%.4x  len=%d', [Pos, Id, Len]));
    if Id = $000A then
      WriteLn('-- EOF, substream ends cleanly --');
    Inc(Pos, 4 + LongWord(Len));
  end;
end;

Lisez ensuite la sortie à rebours et retenez une règle : le premier enregistrement qui ne peut pas être analysé n’est presque jamais le coupable. C’est la victime. Le coupable est l’enregistrement immédiatement précédent, le dernier qui a été analysé sans plainte, car un enregistrement qui ment sur sa propre longueur se laisse toujours analyser correctement. Ici, le parcours s’est arrêté sur un enregistrement fantôme $0000 et l’enregistrement précédent était SXEx. Comparez la longueur déclarée de cet enregistrement à la liste des champs de la spécification, octet par octet : l’arithmétique tombe juste ou non. Si le parcours n’atteint même jamais un premier enregistrement sain, le problème est à un niveau inférieur, dans le fichier composé OLE2 qui contient le flux Workbook, et aucun nombre de dumps au niveau des enregistrements ne vous aidera

Un émetteur qui ne peut pas mentir sur sa propre longueur

Le correctif durable n’est pas une constante correcte, mais la suppression de la possibilité d’en écrire une incorrecte. Réservez le mot de longueur, émettez le corps puis corrigez l’en-tête avec le nombre d’octets réellement produit. HotXLS expose précisément ce qu’il faut : TXLSBlob.DataLength fournit l’offset courant et SetWord réécrit à une position déjà émise

function BeginRecord(Blob: TXLSBlob; RecId: Word): LongWord;
begin
  Blob.AddWord(RecId);
  Result := Blob.DataLength;   // mémoriser l’emplacement du mot de longueur
  Blob.AddWord(0);             // valeur provisoire, corrigée par EndRecord
end;

procedure EndRecord(Blob: TXLSBlob; LenPos: LongWord);
var
  Body: LongWord;
begin
  Body := Blob.DataLength - LenPos - SizeOf(Word);
  if Body > 8224 then
    raise Exception.Create('BIFF body exceeds 8224 bytes, split with Continue');
  Blob.SetWord(Word(Body), LenPos);
end;

Soyez honnête sur le point où cette garantie s’arrête. Une assertion générale selon laquelle les octets émis valent 2 + 2 + la valeur déclarée ne tient que pour les enregistrements qui respectent la limite BIFF8 de 8224 octets de charge utile. Les corps trop grands déclarent légitimement 8224 dans l’en-tête et continuent dans des enregistrements Continue $003C, exactement comme le font les écrivains de cache de pivot et de connexions HotXLS pour les grandes charges ; l’invariant est donc conditionnel : sous la limite, la longueur du blob émis doit être égale à la longueur déclarée plus quatre, au-dessus, le séparateur possède l’arithmétique. Encodez cette distinction dans l’helper plutôt que dans un commentaire. Le même raisonnement se transfère à tout format tag-longueur-valeur, pas seulement à BIFF. Un émetteur qui déclare une taille avant de savoir ce qu’il a écrit formule une affirmation que le code ne peut pas contrôler et que le reviewer ne peut pas compter ; cela fonctionne jusqu’à ce qu’une deuxième fonctionnalité arrive en aval de la première

Le writer BIFF8, les émetteurs d’enregistrements de pivot et le sous-flux de graphique décrit ici sont livrés dans le composant tableur HotXLS pour Delphi pour Delphi et C++Builder, qui lit et écrit XLS, XLSX et ODS sans Excel installé