Odborný článok

BIFF SupBook a klasifikácia externých odkazov XTI v Delphi

Otvorte starý xls, uložte ho znova a add-in vzorec, ktorý volal do zaregistrovanej analytickej knižnice, teraz ukazuje na prázdnu referenciu vnútri samotného zošita. HotXLS vystopuje toto tiché poškodenie k jednému zlému predpokladu: že záznam BIFF SupBook je buď self, alebo externý súbor. [MS-XLS] definuje sedem druhov, nie dva

Prečo uložený zošit stráca svoje add-in odkazy?

Pretože klasifikačný test bol štrukturálny namiesto typovaného. Tradičná skratka číta záznam SupBook ($01AE), kontroluje, či nesie self marker, a ak nie, považuje akýkoľvek reťazec, ktorý nasleduje, za URL dokumentu. Každý záznam, ktorý nie je ani jedna z tých dvoch vecí, spadne do predvolenej vetvy a predvolená vetva je takmer vždy „toto je samotný zošit". Podporujúci odkaz add-inu, odkaz same-sheet, nepoužitý slot a skrátený záznam všetky skončia s rovnakou zlou nálepkou. Nič nespadne, kým sa to deje: záznam sa rozparsuje, vzorec sa znova zkompiluje, súbor sa uloží bez varovania a defekt sa vynorí o tri týždne, keď niekto zaznamená stĺpec nulov tam, kde bývala menová konverzia. [MS-XLS] §2.4.271 popisuje záznam, ktorý môže byť self-referenciou, referenciou same-sheet, kontajnerom add-in funkcií, externým zošitom s virtuálnou cestou a tabuľkou názvov listov, DDE alebo OLE dátovým odkazom alebo nepoužitým zástupným symbolom — a siedmy stav, ktorý nie je v špecifikácii, ale existuje na reálnych diskoch: záznam, ktorý sa nedá rozparsovať. Oprava nie je lepšia heuristika; je to odmietnutie mať vôbec nejakú heuristiku

Sedem druhov, ktoré môže záznam SupBook niesť

HotXLS deklaruje taxonómiu podporujúcich odkazov ako uzavretú enumeráciu v lxExternSheet.pas a každé downstream rozhodnutie sa prepína na nej. Deväť hodnôt enumerácie pokrýva sedem kategórií, pretože prípad DDE a OLE potrebuje prechodný stav, kým sa dá rozlíšiť:

type
  TXLSSupportingLinkKind = (
    slkUnknown,           // nepodarilo sa rozparsovať, alebo ostali zvyškové bajty
    slkSelf,              // tento zošit
    slkSameSheet,         // marker U+0000
    slkAddIn,             // kontajner add-in funkcií
    slkExternalWorkbook,  // virtuálna cesta + tabuľka názvov listov
    slkDde,               // rozlíšené z vlajok ExternName
    slkOle,               // rozlíšené z vlajok ExternName
    slkDdeOrOle,          // jedno z oboch, ešte nie je známe ktoré
    slkUnused);           // zástupný symbol jednej medzery

  TXLSFormulaReferenceClass = (
    frcInternal,
    frcExternalWorkbook,
    frcExternalOther,
    frcUnknownOrMalformed);

  TXLSXtiInfo = record
    XtiIndex    : Integer;   // od nuly, ako je uložené v ExternSheet.rgXTI
    ExternID    : Integer;   // od jednotky, interná konvencia
    SupBookIndex: Integer;
    Sheet1Index : Integer;
    Sheet2Index : Integer;
    LinkKind    : TXLSSupportingLinkKind;
  end;

Dispatch je riadený sentinelmi, nie reťazcami. Hodnota poľa $0401 označuje self záznam. Počet listov jedna spolu s $3A01 označuje kontajner add-inu. Len hodnota v rozsahu 1 až $00FF znamená, že nasleduje kódovaná virtuálna cesta, a až potom HotXLS vôbec dekóduje reťazec. Čokoľvek mimo tých troch tvarov ostáva slkUnknown a záznam, ktorej tabuľka názvov listov nespotrebuje telo záznamu presne, sa degraduje späť na slkUnknown, aj keď hlava vyzerala dôveryhodne

Rebrík riadený sentinelmi, ktorý HotXLS používa na klasifikáciu záznamu BIFF SupBook do siedmich druhov, dekóduje reťazec len pre hodnoty v rozsahu kódovanej cesty a pada späť na neznámy druh namiesto predvolenej vetvy
Každý druh sa dosiahne sentinelom namiesto testu reťazca a záznam, ktorý nezhoduje žiadny tvar, ostáva neznámy namiesto pádu do predvolenej vetvy znamenajúcej tento zošit

Prečo sa marker same-sheet dekóduje ako prázdny reťazec?

Pretože všeobecný BIFF čítač reťazcov zničí bajt, na ktorom klasifikácia závisí. Podporujúci odkaz same-sheet je jednoseznakový reťazec, ktorého jediný znak je U+0000, a TXLSBlob.GetBiffString ho vráti ako prázdny WideString, neodlíšiteľný od skutočne prázdnej cesty — čo je presne ten vstup, na ktorý heuristika self-referencie odpovedá „self". HotXLS preto číta surový prvý kódový bod priamo z tela záznamu namiesto dôvery v dekódovanú hodnotu:

StringOffset := offset;
FDocUrl := Data.GetBiffString(offset, False, True);
FirstChar := $FFFF;
if val = 1 then
begin
  StringOptions := Data.GetByte(StringOffset + 2);
  if (StringOptions and $01) = 0 then
    FirstChar := Data.GetByte(StringOffset + 3)     // komprimované, jeden bajt
  else
    FirstChar := Data.GetWord(StringOffset + 3);    // wide, dva bajty
end;

if FirstChar = 0 then
  FKind := slkSameSheet
else if (Length(FDocUrl) = 1) and (FDocUrl[1] = WideChar(#32)) then
  FKind := slkUnused
else if Pos(WideChar(#3), FDocUrl) > 0 then
  FKind := slkDdeOrOle
else if FDocUrl <> '' then
  FKind := slkExternalWorkbook;

Všimnite si vetvu komprimované-versus-wide. Bajt volieb sedí na fixnom ofsete od hlavičky reťazca a prvý kódový bod je jeden bajt alebo dva podľa bitu 0, takže jeho čítanie ako bajtu bezpodmienečne funguje na väčšine súborov a zlyháva na tých zapísaných lokalizovanými zostavami — najhoršie možné rozdelenie pre chybu. Nepoužitý zástupný symbol sa chytí rovnakým spôsobom, podľa jeho doslovného payloadu jednej medzery, a prípad DDE alebo OLE podľa separátora U+0003 vloženého do kódovanej cesty

Prečo HotXLS číta surový prvý kódový bod priamo z tela záznamu BIFF SupBook namiesto dekódovaného reťazca, pretože všeobecný čítač reťazcov zmení marker same-sheet U+0000 na prázdnu hodnotu
Marker same-sheet je jednoseznakový reťazec, ktorého znak je U+0000, takže všeobecný čítač reťazcov ho zloží na prázdnu hodnotu a len surový kódový bod na ofsete bajtu volieb ho zachová

Prečo sa DDE a OLE nedajú oddeliť v čase SupBook?

Pretože záznam SupBook nesie odlišujúce bity nie. Hovorí vám, že odkaz je jedno z oboch; vlajky fOle a fOleLink, ktoré rozhodujú ktoré, žijú v zázname ExternName ($0023) prichádzajúcom neskôr v streame. HotXLS zaznamená slkDdeOrOle v čase parsovania a zúži to v ParseExternalName a ak žiadny ExternName nikdy nepríde, druh ostáva prechodný navždy — čo je správne, pretože súbor to skutočne nepovie. Každý downstream konzument považuje tú prechodnú hodnotu za skutočnú hodnotu, nie za chýbajúcu, takže žiadny volajúci si nemusí vymýšľať rozhodovacie pravidlo. Hádzať „pravdepodobne DDE" tu by kúpilo upratanejšiu enumeráciu a triedu zlých odpovedí, ktoré nikto nedokáže vystopovať späť:

if FKind = slkDdeOrOle then
begin
  if Data.DataLength < 2 then
    Exit;
  Flags := Data.GetWord(0);
  if (Flags and $0010) <> 0 then
    FKind := slkOle
  else if (Flags and $0008) <> 0 then
    FKind := slkDde;
end;

XTI indexy sú na disku od nuly a vnútri od jednotky

HotXLS vykoná konverziu off-by-one presne raz, v mieste, kde token vstupuje do interného syntaktického stromu, a nikde inde. PtgNameX.ixti ([MS-XLS] §2.5.198.85) je index od nuly do poľa rgXTI záznamu ExternSheet ($0017, §2.4.106), zatiaľ čo interná konvencia ExternID knižnice je od jednotky s nulou rezervovanou pre „žiadny externý list". Čítacia cesta BIFF8 robí FExternID := wValue + 1, keď dekóduje token tNameX a zápisová cesta emituje StoreExternID - 1, pričom ponecháva surový pohľad tokenu a diskové sémantiky nedotknuté. Pokaziť to je neobyčajne ťažké chytiť: externé definované názvy sa rozlíšia na susednú položku a v súbore s jedinou XTI položkou sa index 0 stane indexom 1, minie a názov sa mlčky degraduje. Regresia, ktorá cvičí iba znovu zkompilovaný text vzorcov, ho nikdy nevidí, pretože rekompilácia sa vôbec nedotýka diskového indexu — tá istá pasca, ktorá robí definované názvy siahajúce cez listy a zošity hodnými testovania proti reálnym bajtovým streamom. Rozlíšenie je ohraničené na oboch koncoch: TlxExternSheetSheet.TryResolveXti vracia False pre negatívny index alebo chýbajúcu položku, TXLSSupBook.TryGetKind vracia False pre SupBook index mimo poľa a ClassifyXti potom mapuje slkSelf a slkSameSheet na frcInternal, slkExternalWorkbook na frcExternalWorkbook a slkAddIn, slkDde, slkOle a slkDdeOrOle na frcExternalOther. Všetko ostatné, každá cesta mimo rozsahu vrátane, pristane na frcUnknownOrMalformed

HotXLS prevádza index XTI od nuly tokenu BIFF PtgNameX na jeho interný ExternID od jednotky v jedinom bode, s ohraničeným rozlíšením na oboch koncoch a mapou klasifikácie, ktorá to konzumuje
Off-by-one medzi diskovým indexom od nuly a interným ExternID od jednotky sa aplikuje raz, keď token vstupuje do syntaktického stromu, a každý nerozlíšiteľný index pristane na triede malformed

Klasifikácia vzorca pred zamrznutím

TXLSCompiledFormula.ClassifyReferences skenuje zachovaný BIFF token stream priamo namiesto dekompilácie vzorca a hľadania hranatých zátvoriek. Lovenie zátvoriek v texte vzorca je textová heuristika v kabáte parsera: zhoduje reťazcové literály, zhoduje štruktúrované referencie a externé definované názvy úplne minie, keďže tie nesú žiadne zátvorky v dekompilovanej forme. Token sken sa pozerá len na PtgNameX, PtgRef3d, PtgArea3d, PtgRefErr3d a PtgAreaErr3d s pádom späť na prechádzku syntaktického stromu, keď žiadny BIFF stream neprežije. Zlučovanie je zámerne pesimistické — pevná priorita je frcUnknownOrMalformed, potom frcExternalWorkbook, potom frcExternalOther, potom frcInternal — takže jediný nečitateľný token otrávi celý vzorec. Pre externý definovaný názov sa validuje aj index názvu: od jednotky, v rozsahu a podložený zachovaným záznamom ExternName

var
  Wb   : TXLSWorkbook;
  Sheet: TXLSWorksheet;
  i    : Integer;
begin
  Wb := TXLSWorkbook.Create;
  try
    Wb.Open('quarterly.xls');
    for i := 1 to Wb.Sheets.Count do        // Sheets je od jednotky
    begin
      Sheet := Wb.Sheets[i];
      // zamrazuje IBA vzorce klasifikované ako frcExternalWorkbook;
      // interné, add-in, DDE/OLE a malformed referencie ostávajú vzorcu
      Sheet.ConvertFormulasToValues(True);
    end;
    Wb.SaveAs('quarterly-detached.xls');
  finally
    Wb.Free;
  end;
end;

Parameter OnlyExternal je miesto, kde sa taxonómia zaplatí sama. Zamrznutie vzorca je nevratné, takže operácia musí dokázať, že referencia je externý zošit, nielen že je podozrivá. Add-in volania prežijú, DDE a OLE odkazy prežijú a čokoľvek, čo parser nemohol úplne pochopiť, prežije, pretože bezpečný výsledok neistoty je nezmeniť nič. Rovnaká disciplína riadi rebinding vzorcov kopírovaných medzi zošitmi, kde nesprávne klasifikovaná referencia rebinding na nesprávny zošit namiesto hlasného zlyhania

Záznamy, ktoré sa nedajú rozparsovať, sa zapisujú späť nedotknuté

HotXLS drží pôvodný payload SupBook a emituje ho znova bajt po bajte, keď záznam nikdy nebol upravený. Zlyhanie parsovania nastaví slkUnknown a vyčistí odvodený stav, ale zachytené telo ostáva v FRawData a cesta ukladania ho uprednostní pred akoukoľvek rekonštrukciou, pokiaľ položka nie je dirty a nie je self záznamom. Alternatíva — normalizácia nerozparsovaného záznamu na self-referenciu, aby mal zápisovač dobre tvarovanú vec na emitovanie — zmení záznam, ktorému ste nerozumeli, na záznam, ktorý je definitívne zlý. Ten princíp je rovnaký kontrakt aplikovaný na VBA projekty a ich externé referencie naprieč cyklom načítanie-a-uloženie a je to rozdiel medzi knižnicou, ktorá round-tripuje reálne súbory sveta, a tou, ktorá round-tripuje súbory, ktoré jej testovacia sada náhodou obsahuje. Zošit, ktorý prešiel pätnástimi rokmi verzií Excelu, generátorom reportov a dvoma migračnými nástrojmi, bude obsahovať záznamy, ktoré nikto súčasne žijúci nenavrhol. Zapíšte ich späť tak, ako ste ich našli

Typovaná klasifikácia záznamov SupBook a XTI bola dodaná v HotXLS 2.361.2 až 2.361.4 spolu s ohraničeným rozlíšením XTI a bezpečnejšou cestou ConvertFormulasToValues popísanou tu. Ak udržiavate Delphi alebo C++Builder kód, ktorý číta legacy xls súbory nesúce add-in volania, DDE alebo OLE odkazy alebo externé definované názvy, HotXLS Delphi spreadsheet component zvláda celú taxonómiu natívne, bez inštalácie Excelu a bez OLE automatizácie na stroji, ktorý robí prácu