Technisch artikel

Range check-fouten in Delphi PDF-bibliotheken: oorzaken

Range check-fouten in Delphi PDF-bibliotheken hebben de reputatie moeilijk vast te pinnen te zijn omdat ze geen consistent invoerpatroon volgen. Hetzelfde document veroorzaakt ze op de ene machine en op de andere niet; hetzelfde codepad werpt de exceptie op bij een bestand van 3 pagina's maar loopt schoon bij een bestand van 12. Die inconsistentie is bijna altijd terug te voeren op één hoofdoorzaak: PDF-pagina-objecten worden niet in bestandsvolgorde opgeslagen. Als de bibliotheek haar interne pagina-array opbouwt door objecten sequentieel te scannen in plaats van de door de catalog gedeclareerde paginaboom te doorlopen, construeert zij een index waarvan het geldige bereik niet overeenkomt met wat aanroepers verwachten, en de range check vangt die discrepantie op het slechtst denkbare moment

Hoe range checking in Delphi werkt

Met de compilerdirective {$R+} actief (de standaard in de Debug-configuratie) valideert de Delphi-RTL tijdens runtime elke array-index, string-subscript en enumeratietoewijzing. Een toegang buiten de grenzen werpt ERangeError op in plaats van stilzwijgend aangrenzend geheugen te lezen. Dat gedrag is waardevol: het brengt latente bugs vroeg aan het licht in plaats van ze een datastructuur te laten beschadigen die pas honderd regels verderop faalt. Het frustrerende deel is dat de exceptie afgaat op de plek van de toegang, niet op het punt waar de index verkeerd werd berekend. Wanneer de call stack een diep geneste methode in een PDF-unit toont, ligt de echte fout meestal enkele frames terug

Samengestelde booleaanse condities maken dit erger. Delphi evalueert and-expressies van links naar rechts met short-circuit-semantiek, maar short-circuiting slaat de evaluatie alleen over wanneer de linkerkant False is. Een expressie als:

if FDocStarted and (DestIndex < Length(PageArr)) and
   (PageArr[DestIndex].PageObj <> nil) then

oogt veilig, maar zij beschermt alleen tegen een index buiten bereik als FDocStarted gelijk is aan True en DestIndex niet-negatief is. De controle DestIndex < Length(PageArr) doet niets wanneer DestIndex negatief is, omdat het vergelijken van een negatief geheel getal met een niet-negatieve lengte in ondertekende rekenkunde True oplevert en de daaropvolgende arraytoegang de range-fout alsnog veroorzaakt. De grenscontrole naar de buitenste positie verplaatsen is de juiste oplossing:

if (DestIndex >= 0) and (DestIndex < Length(PageArr)) then
begin
  if FDocStarted and (PageArr[DestIndex].PageObj <> nil) then
    Result := PageArr[DestIndex].PageObj
  else
    Result := nil;
end
else
  raise ERangeError.CreateFmt(
    'Page index %d is out of range (0..%d)',
    [DestIndex, Length(PageArr) - 1]);

Dit is de mechanische oplossing. Zij stopt de crash. Zij verklaart niet waarom DestIndex überhaupt een waarde buiten het geldige bereik heeft gekregen

Stroomdiagram van een Delphi range check-fout in een PDF-bibliotheek waarbij een negatieve DestIndex een eenzijdige grenscontrole omzeilt en ERangeError opwerpt bij de arraytoegang, vergeleken met de gecorrigeerde volgorde van de bewaking die veilig short-circuit
Onder $R+ valideert de RTL elk subscript tijdens runtime, dus een negatieve index overleeft een kale bovengrenstest en gaat af bij de arraytoegang zelf. De ondertekende ondergrens eerst testen laat short-circuit-evaluatie hem afwijzen voordat er ook maar één element wordt aangeraakt

De echte oorzaak: objectvolgorde tegenover paginavolgorde

ISO 32000-1 §7.7.3 definieert de paginaboom als een boom van Pages-knopen waarvan de Kids-arrays pagina-objecten in weergavevolgorde opsommen. Het bestand slaat die objecten op op de offsets die de schrijver toevallig koos; objectnummer 20 kan objectnummer 3 fysiek voorafgaan in de bytestream. Een bibliotheek die haar paginalijst opbouwt door de kruisverwijzingstabel op objectnummer te doorlopen in plaats van de Kids-ketting te volgen, produceert een reeks die afwijkt van wat de gebruiker verwacht. Bij documenten waar de generator de pagina's toevallig op volgorde schreef, werkt alles. Bij documenten waar dat niet zo was, levert de discrepantie tussen de paginanummering van de bibliotheek en die van de aanroeper indices op die buiten PageArr vallen

Delphi-diagram dat de opslagvolgorde van PDF-objecten zoals ontdekt door een kruisverwijzingsscan afzet tegen de weergavevolgorde die wordt hersteld door de Kids-array van de paginaboom vanuit de catalog te doorlopen
Sequentiële scans nummeren pagina's in de volgorde die de schrijver koos, terwijl viewers de Kids-array van de paginaboom volgen. De pagina-array opbouwen uit de doorloop van de catalog houdt de indices van de aanroeper uitgelijnd en binnen bereik

De juiste aanpak is om bij de catalog te beginnen, de indirecte verwijzing /Pages te resolven en de Kids-array recursief te doorlopen. Voor een plat document zonder tussenliggende Pages-knopen is de doorloop rechttoe rechtaan:

procedure BuildPageIndexFromTree(
  const KidsArray: THPDFArray;
  var PageArr: TPageObjArray);
var
  i, Idx: Integer;
  Child: THPDFObject;
  ChildType: string;
begin
  for i := 0 to KidsArray.Count - 1 do
  begin
    Child := KidsArray.GetIndirectObject(i);
    if Child = nil then
      Continue;
    ChildType := Child.GetNameValue('/Type');
    if ChildType = 'Page' then
    begin
      Idx := Length(PageArr);
      SetLength(PageArr, Idx + 1);
      PageArr[Idx].PageObj := Child;
    end
    else if ChildType = 'Pages' then
    begin
      // tussenliggende knoop: recursie in zijn Kids
      BuildPageIndexFromTree(Child.GetArray('/Kids'), PageArr);
    end;
  end;
end;

Nadat dit is gedraaid, is PageArr[0] de eerste pagina die een viewer zou tonen, ongeacht waar dat object in de bytestream zit. Indices die worden doorgegeven door aanroepers die weergavevolgorde aannemen, mappen nu correct, en de range-fouten houden op

Hard gecodeerde omwegen vergroten het probleem

In codebases waar de hoofdoorzaak nooit is vastgesteld, treft men vaak heuristische patches aan: verwissel de eerste en laatste pagina als het totaal aantal 3 is, roteer de index voor documenten van een specifieke generator, pas een offset toe wanneer het eerste objectnummer een drempel overschrijdt. Elk van die patches past precies bij de set testbestanden die voorhanden was toen hij werd geschreven. Voeg een andere PDF-bron toe en een van de patches gaat op het verkeerde moment af, wat een index oplevert die nu dubbel fout is: fout omdat hij is berekend uit een array in de verkeerde volgorde, en nogmaals fout omdat er een niet-toepasselijke mapping bovenop is gelegd. De range checker vangt hem ergens verderop op en de stack trace wijst nergens nuttigs heen

Het enige productieve pad is elke heuristische mapping te verwijderen en de opbouw van de pagina-array te vervangen door een echte boomdoorloop. Zodra de indices door constructie correct zijn, zijn er geen patches nodig en wordt de range checker een aanwinst in plaats van een obstakel

Als u een bibliotheek onderhoudt die dit patroon vertoont, schakel dan range checking tijdelijk in een Release-build in en draai die tegen een divers corpus PDF's: documenten geproduceerd door Word, door LaTeX, door scannerfirmware, door hulpprogramma's die PDF's naar PDF's splitsen. De bestanden die excepties uitlokken zijn de bestanden waarvan de volgorde van pagina-objecten afwijkt van de doorloopvolgorde die uw code aanneemt. Elk daarvan is een datapunt, geen aparte bug

Voor nieuwe code die een Delphi PDF-bibliotheek aanroept, is het praktische advies om het paginaaantal van de bibliotheek als gezaghebbend te behandelen en nooit een index door te geven die is afgeleid uit rekenkunde op externe data zonder eerst te bevestigen dat die binnen 0..PageCount - 1 valt. Het HotPDF Delphi-component stelt het geresolvede paginaaantal beschikbaar via THotPDF.PageCount na BeginDoc of na het laden van een document; die waarde weerspiegelt altijd de doorloop van de paginaboom en is veilig te gebruiken als bovengrens voor elke indexrekenkunde