Technisch artikel

Delphi vs FPC: 4 verborgen valkuilen in PDFium-code

Dezelfde Object Pascal-broncode kan zich onder Delphi en FPC/Lazarus op vier manieren anders gedragen die telkens weer in code voor PDFium Component bijten: FPC ruimt tijdelijke records met een functieresultaat op voordat een lidmaatschapstest met in ze klaar heeft gelezen, dcc32 wordt geleverd met bereikcontrole uit, zodat array-indexen buiten de grenzen stilletjes rommel lezen, alleen Delphi 13 aanvaardt het toewijzen van een anonieme array of Byte aan TBytes zonder cast, en de AnsiString-samenvoeging van Delphi kan bytes vanaf $80 vernietigen via een verborgen rondgang langs de codetabel. Elk daarvan levert een testsuite op die groen is op de ene compiler en rood, of erger nog, stilzwijgend fout, op de andere

Zet u voor het eerst een project met twee compilers op, dan behandelt de rondleiding langs de viewer voor Lazarus en FPC het gelukkige pad: packages, zoekpaden en een rendervenster op het scherm krijgen. Dit artikel is het tegendeel van een handleiding. Het is de lijst van dingen waar we tegenaan liepen nadat het gelukkige pad werkte, toen CI groen was onder FPC, groen onder Delphi, en een wijziging die aan de ene kant slaagde aan de andere kant ontplofte. Elke valkuil hieronder komt uit een echte storing in de testsuite of de demo van PDFiumPas, met de forensische analyse op commitniveau samengeperst tot een minimale reproductie, de grondoorzaak en de oplossing die wij hebben gestandaardiseerd

Waarom leest een set onder FPC leeg terug en in Delphi niet?

De versie in één zin: FPC mag de tijdelijke variabele met het recordresultaat van een functie finaliseren voordat een expressie die een veld van dat resultaat leest klaar is, zodat X in Func().Issues lidmaatschap kan testen tegen een reeds vrijgegeven set terwijl de gelijkwaardige Delphi-expressie werkt. Onze conformiteitstests voor PDF/E liepen hier in hun eerste versie tegenaan. De validator geeft een record terug waarvan het veld Issues een set schendingsvlaggen is, en de asserties hadden de aanroep inline gezet

Diagram dat toont dat Delphi een tijdelijk record met een PDFium-functieresultaat in leven houdt tot het einde van de instructie, terwijl FPC het vrijgeeft voordat de in-operator de set Issues leest, zodat de assertie alleen onder Delphi slaagt
Delphi houdt het tijdelijke record met het functieresultaat in leven tot het einde van de instructie, terwijl FPC het kan vrijgeven voordat de in-operator de set Issues leest
// Onbetrouwbaar onder FPC: het tijdelijke record met het functieresultaat
// kan worden vrijgegeven voordat de test met 'in' Issues leest
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Betrouwbaar op beide compilers: zet het resultaat eerst in een lokale variabele
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

De inline vorm las de set onder FPC als leeg, dus elke assertie die een vlag verwachtte faalde, terwijl de identieke Delphi-build slaagde. De grondoorzaak is een verschil in de manier waarop de twee compilers de levensduur beheren van tijdelijke functieresultaten binnen grotere expressies: Delphi houdt de tijdelijke variabele in leven tot het einde van de instructie, terwijl het opruimen van het tijdelijke record bij FPC kan racen met de set-lidmaatschapsoperator die er nog uit leest. We hadden hetzelfde gedrag al eerder eens gedocumenteerd, in een opmerking bij de hulpfunctie FlagPresent in de PDF/A-testunit, en herintroduceerden de bug daarna alsnog bij het vanaf nul schrijven van nieuwe tests, wat aangeeft hoe natuurlijk de kapotte vorm eruitziet. De oplossing is mechanisch en het overnemen als algemene regel waard: koppel nooit een veldtoegang of settest rechtstreeks aan een functieaanroep die een record teruggeeft; wijs het resultaat eerst aan een lokale variabele toe en lees daarna het veld. Het kost één regel en verwijdert een hele klasse van compilerafhankelijke wispelturigheid

Waarom aanvaardt Delphi een array-index die FPC weigert te compileren?

De versie in één zin: dcc32 compileert een index buiten het bereik van een array met vaste grenzen en leest of schrijft, met de standaard uitgeschakelde bereikcontrole, tijdens de uitvoering aangrenzend geheugen zonder enige fout, terwijl FPC diezelfde index tijdens het compileren afwijst. De PDFium Component declareert quad points als een 1-gebaseerde array, TQuadrilateralPoint = array [1..4] of TPdfPoint, in lijn met de gebruikelijke nummering van de QuadPoints-vermeldingen in PDF. Een demo die de array met de reflexmatige 0-gebaseerde lus vulde, werkte maandenlang onder Delphi

Diagram voor PDFium Component van een 1-gebaseerde array met PDF-quad-points waarbij index 0 onder dcc32 stilletjes het aangrenzende recordveld raakt terwijl FPC dezelfde lus stopt met een bereikfout tijdens het compileren
Met de bereikcontrole van dcc32 uit landt index 0 stilletjes op het naburige recordveld, terwijl FPC dezelfde lus tijdens het compileren afwijst
var
  I: Integer;
begin
  for I := 0 to 3 do                       // fout: de array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // standaard dcc32: compileert, index 0
                                           // raakt stilletjes aangrenzend geheugen
                                           // FPC: bereikfout tijdens het compileren
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct op beide compilers
end;

De Delphi-build was een vals positief: met de bereikcontrole uit, wat de standaard van dcc32 is, landde index 0 op welk veld dan ook dat in het record aan de array voorafgaat, en de demo leek te draaien. Diezelfde demo naar Lazarus overzetten leverde meteen een bereikfout tijdens het compileren van FPC op, en het corrigeren van de index legde vervolgens een tweede, diepere bug in het annotatiepad van de bibliotheek bloot die de rommelige leesacties hadden gemaskeerd, de bug die wordt ontleed in het artikel over annotaties met quad points. Uit dat incident kwamen twee lessen. Ten eerste: geef de voorkeur aan Low() en High() boven letterlijke grenzen zodra het arraytype niet vanuit het ontwerp 0-gebaseerd is. Ten tweede: behandel een compilatie met FPC, of minstens één Delphi-build met {$R+} aan, als een verplichte poort bij de eerste run van elke nieuwe demo of test: de standaardinstellingen van dcc32 vertellen u niets over deze klasse bugs, en een programma dat draait is geen bewijs dat het correct is

De toewijzing aan TBytes die alleen Delphi 13 aanvaardt

De versie in één zin: een veld dat als anonieme array of Byte is gedeclareerd toewijzen aan een variabele van het type TBytes compileert op Delphi 13 (compilerversie 37.0) maar faalt op Delphi 12 Athens en elke eerdere uitgave met E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Dit is niet zozeer een splitsing tussen Delphi en FPC als wel tussen Delphi en zijn eigen verleden, maar het bijt dezelfde codebase met meerdere compilers op dezelfde manier: de nieuwste compiler aanvaardt stilletjes een constructie die al het andere afwijst

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anoniem dynamisch arraytype
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // alleen Delphi 13; E2010 op Delphi 12
                                 // Athens en ouder
  OrigBytes := TBytes(FBuffer);  // compileert overal; dezelfde byte-indeling,
                                 // veilige harde cast
end;

Wij hebben precies dit uitgeleverd in een validatieroutine, lokaal ontwikkeld en getest op Delphi 13, waar de impliciete conversie stilzwijgend werd aanvaard. De installer met volledige broncode bedient in grote aantallen gebruikers op Delphi 12 en ouder, en voor hen compileerde de unit eenvoudigweg niet. De structurele oplossing is ofwel de harde cast hierboven, die veilig is omdat een anonieme array of Byte en TBytes een identieke indeling als dynamische array delen, ofwel beter nog: het veld meteen met een benoemd type zoals TBytes declareren zodat er nooit een conversie ontstaat. De procesoplossing telt zwaarder: een constructie die op uw nieuwste gereedschapsketen compileert, bewijst niets over de oudere compilers die uw gebruikers werkelijk draaien, en deze categorie regressie is onzichtbaar tot u tegen elke ondersteunde versie bouwt. Onze uitgavescripts compileren de bibliotheek nu over de volledige compilermatrix, juist omdat een lokale build op 37.0 een soepelheid die alleen 13 kent niet kan opvangen

De AnsiString-byte die op een Chinese Windows-machine verdwijnt

De versie in één zin: een ruwe byte vanaf $80 met + aan een AnsiString plakken kan die byte onder Delphi stilzwijgend vervangen door ? ($3F), omdat de expressie een impliciete rondgang van AnsiString naar UnicodeString naar AnsiString via de codetabel van het systeem maakt. Wij vonden dit via een PDF/A-test die een naam construeert met een losstaande $FE-byte, die nooit een geldige UTF-8-leidbyte is, om te verifiëren dat de validator namen markeert die geen geldige UTF-8 zijn volgens ISO 19005-2 clausule 6.1.8

Diagram van de rondgang van AnsiString naar UnicodeString die een ruwe $FE-byte op een Chinese Windows-machine met CP936 vervangt door een vraagteken, naast het veilige ter plekke aanpassen van de byte in Delphi
De impliciete rondgang via UnicodeString vervangt een niet-afbeeldbare $FE-byte onder CP936 door $3F, dus het veilige pad past de byte ter plekke aan
var
  BadName: AnsiString;
begin
  // Op Delphi met een multibyte-codetabel voor het systeem (waargenomen op CP936)
  // maakt de samenvoeging een rondgang via UnicodeString en komt $FE, dat geen
  // geldige CP936-reeks is, terug als '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Veilig: bouw met een ASCII-plaatshouder en pas de byte daarna ter plekke aan;
  // een geindexeerde toewijzing in een afgeronde AnsiString maakt geen rondgang
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Op een Chinees Windows-systeem met codetabel 936 bevatte de samengevoegde tekenreeks helemaal geen $FE, dus meldde de bibliotheek terecht niets en werd de test rood terwijl het op een bug in de bibliotheek leek. De bibliotheek had het nooit mis: een FPC-harnas dat een PDF aanleverde die de $FE-byte werkelijk bevatte, kreeg de verwachte vlag. De corruptie vond plaats binnen de Delphi-testexecutable terwijl de tekenreeksexpressie werd geëvalueerd, want het Unicode-eerst tekenreeksmodel van Delphi converteert gemengde AnsiString-expressies via UnicodeString, en $FE is in CP936 geen geldige leidbyte, dus de rondgang vervangt hem. Wees eerlijk over de grens hier: op een enkelbyte westerse codetabel zoals CP1252 overleeft dezelfde expressie meestal, en juist daarom verbergt deze bug zich op de meeste ontwikkelmachines en duikt hij alleen op bij Oost-Aziatische systemen of gelokaliseerde CI-runners. De regel die wij hebben aangenomen: bouw nooit binaire testvectoren met bytes vanaf $80 via AnsiString-samenvoeging; pas de bytes ter plekke aan nadat de tekenreeks is afgerond, zoals hierboven, of construeer de vector van meet af aan in TBytes

Wat een werkwijze met twee compilers standaard moet controleren

Vier valkuilen, één patroon: elke compiler vertelt u over een andere deelverzameling van uw bugs. De bereikanalyse tijdens het compileren van FPC ving een index buiten het bereik op die dcc32 maandenlang stil uitvoerde, en het Unicode-tekenreeksmodel van dcc32 legde een afhankelijkheid van de codetabel bloot die een puur byte-georiënteerde FPC-build nooit uitlokt. Het praktische gevolg is dat geen van beide groene pijplijnen alleen volstaat. Cross-compileren is niet louter een vinkje voor portabiliteit, het is een tweede statische analyse en een tweede uitvoeringsmodel toegepast op dezelfde broncode, in dezelfde geest als de defensieve grenscontroles in het artikel over het harden van de ABI en de geheugenveiligheid

De vaste regels die uit deze incidenten voortkwamen, zijn kort genoeg om uit het hoofd te leren. Zet recordresultaten van functies vast in een lokale variabele voordat u velden leest. Doorloop arrays met vaste grenzen met Low() en High(), en draai minstens één build met bereikcontrole of met FPC voordat u een nieuwe demo vertrouwt. Cast velden van een anoniem dynamisch arraytype expliciet, of declareer ze met benoemde types, en bouw vóór de uitgave de volledige compilermatrix. Houd ruwe hoge bytes volledig buiten AnsiString-samenvoegingen. Geen van deze regels kost meetbare moeite zodra ze gewoontes zijn, en elke regel sluit een faalwijze die een werkwijze met één compiler structureel niet kan zien

Alle vier de kwesties zijn gevonden en verholpen tijdens het onderhoud van de PDFium Component, die dezelfde Object Pascal-broncode levert voor Delphi, C++Builder en FPC/Lazarus en zijn conformiteits- en regressietests op elk van die gereedschapsketens draait, zodat de valkuilen in dit artikel door tests worden bewaakt en niet door het geheugen