Teknisk artikel

Delphi vs FPC: 4 dolda PDF-kodfällor i PDFium-builds

Samma källkod i Object Pascal kan bete sig annorlunda under Delphi och FPC/Lazarus på fyra sätt som upprepade gånger drabbar koden för PDFium Component: FPC kastar bort temporära poster från funktionsresultat innan ett in-medlemstest har läst klart dem, dcc32 levereras med intervallkontroll avstängd så att array-index utanför gränserna tyst läser skräp, endast Delphi 13 accepterar att tilldela en anonym array of Byte till TBytes utan typomvandling, och Delphis AnsiString-konkatenering kan förstöra byte vid eller över $80 genom en dold kodsido-rundresa. Vart och ett av dessa fel ger upphov till en testsvit som är grön på den ena kompilatorn och röd, eller ännu värre, tyst felaktig, på den andra

Om du sätter upp ett projekt med dubbla kompilatorer för första gången, täcker genomgången av visaren för Lazarus och FPC den enkla vägen: paket, sökbanor och att få ett renderingsfönster på skärmen. Den här artikeln är motsatsen till en handledning. Det är listan över saker vi stötte på efter att den enkla vägen fungerade, när CI was grön under FPC, grön under Delphi, och sedan en ändring som godkändes på ena sidan havererade på den andra. Varje fälla nedan kommer från ett verkligt fel i testsviten för PDFiumPas eller dess demoprojekt, med kriminalteknisk analys på commit-nivå sammanfattad till en minimal reproduktion, den bakomliggande orsaken och den korrigering vi standardiserade på

Varför läses en mängd (set) som tom under FPC men inte i Delphi?

Meningen i korthet: FPC kan slutgiltigt rensa (finalize) den temporära variabeln som håller en funktions postresultat innan ett uttryck som läser ett fält i det resultatet har slutförts, så att X in Func().Issues kan testa medlemskap mot en redan frigjord mängd medan motsvarande Delphi-uttryck fungerar. Våra PDF/E-konformitetstester stötte på detta i sin första version. Valideraren returnerar en post vars Issues-fält är en mängd av överträdelseflaggor, och påståendena (assertions) inbäddade anropet

// Otillförlitligt under FPC: den temporära posten för funktionsresultatet
// kan frigöras innan 'in'-testet läser Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Tillförlitligt på båda kompilatorerna: fäst resultatet vid en lokal variabel först
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Den inbäddade formen läste mängden som tom under FPC, så varje påstående som förväntade sig en flagga misslyckades, medan det identiska Delphi-bygget godkändes. Den bakomliggande orsaken är en skillnad i hur de två kompilatorerna hanterar livslängden för temporära funktionsresultat i större uttryck: Delphi håller den temporära variabeln vid liv till slutet av satsen, medan FPC:s bortkastande av den temporära posten kan hamna i kapplöpning med mängd-medlemskapsoperatorn som fortfarande läser den. Vi hade redan dokumenterat samma beteende en gång tidigare, i en kommentar till hjälparen FlagPresent i PDF/A-testenheten, och introducerade sedan buggen ändå när vi skrev nya tester från grunden, vilket visar hur naturlig den felaktiga formen ser ut. Korrigeringen är mekanisk och värd att anta som en generell regel: kedja aldrig en fältåtkomst eller ett mängd-test direkt på ett funktionsanrop som returnerar en post; tilldela resultatet till en lokal variabel först, och läs sedan fältet. Det kostar en rad och eliminerar en hel klass av kompilatorberoende instabilitet

Varför accepterar Delphi ett array-index som FPC vägrar att kompilera?

Meningen i korthet: dcc32 kompilerar ett index utanför intervallet till en array med fasta gränser och läser eller skriver tyst intilliggande minne vid körning utan något fel, eftersom dess standardintervallkontroll är avstängd, medan FPC avvisar samma index vid kompilering. PDFium-komponenten deklarerar fyrhörningspunkter som en 1-baserad array, TQuadrilateralPoint = array [1..4] of TPdfPoint, vilket matchar hur PDF-filer QuadPoints-poster vanligtvis numreras. Ett demoprojekt som fyllde den med den reflexiva 0-baserade loopen fungerade i månader under Delphi

var
  I: Integer;
begin
  for I := 0 to 3 do                       // fel: arrayen är [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 standard: kompilerar, index 0
                                           // rör tyst vid intilliggande minne
                                           // FPC: intervallkontrollsfel vid kompilering
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // korrekt på båda kompilatorerna
end;

Delphi-bygget var ett falskt positivt resultat: med intervallkontrollen avstängd, vilket är dcc32-standard, landade index 0 på det fält som råkar ligga före arrayen i posten, och demot verkade fungera. Att portera samma demo till Lazarus gav ett omedelbart kompileringsfel för intervallkontroll från FPC, och att korrigera indexet avslöjade då en andra, djupare bugg i bibliotekets anteckningsväg som skräpläsningarna hade maskerat, den som dissekeras i artikeln om fyrhörningsanteckningar. Två lärdomar kom ut ur den incidenten. För det första, föredra Low() och High() framför bokstavliga gränser närhelst arraytypen inte är 0-baserad av konstruktion. För det andra, behandla en FPC-kompilering, eller som minst ett Delphi-bygge med {$R+} aktiverat, som en obligatorisk grind för första körningen av alla nya demon eller tester: dcc32:s standardinställningar kommer inte att berätta för dig om denna klass av buggar, och ett program som körs är inte ett bevis på att det är korrekt

TBytes-tilldelningen som endast Delphi 13 accepterar

Meningen i korthet: att tilldela ett fält deklarerat som anonym array of Byte till en TBytes-variabel kompilerar på Delphi 13 (kompilatorversion 37.0) med misslyckas på Delphi 12 Athens och alla tidigare versioner med E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Detta är inte så mycket en Delphi-mot-FPC-split som en Delphi-mot-sitt-eget-förflutna-split, och det drabbar samma kodbas med flera kompilatorer på samma sätt: den nyaste kompilatorn accepterar tyst en konstruktion som allt annat avvisar

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonym dynamisk arraytyp
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Endast Delphi 13; E2010 på Delphi 12
                                 // Athens och tidigare
  OrigBytes := TBytes(FBuffer);  // kompilerar överallt; samma byte-layout,
                                 // säker hård typomvandling
end;

Vi levererade exakt detta i en valideringsrutin, utvecklad och testad lokalt på Delphi 13, där den implicita konverteringen tyst accepterades. Installatören med full källkod betjänar användare på Delphi 12 och äldre i stort antal, och för dem kompilerade enheten helt enkelt inte. Den strukturella lösningen är antingen den hårda typomvandlingen som visas ovan, vilket är säkert eftersom en anonym array of Byte och TBytes delar en identisk dynamisk array-layout, eller ännu hellre att deklarera fältet som en namngiven typ som TBytes från första början så att ingen konvertering någonsin uppstår. Processrättelsen är viktigare: en konstruktion som kompilerar på din nyaste verktygskedja bevisar ingenting om de äldre kompilatorer som dina användare faktiskt kör, och denna kategori av regression är osynlig tills du bygger mot varje understödd version. Våra releaseskript kompilerar nu biblioteket över hela kompilatormatrisen just eftersom ett lokalt 37.0-bygge inte kan fånga upp en eftergift som bara finns i 13

AnsiString-byten som försvinner på en kinesisk Windows-dator

Meningen i korthet: att sammanfoga en rå byte vid eller över $80 till en AnsiString med + kan tyst ersätta den byten med ? ($3F) under Delphi, eftersom uttrycket tar en implicit rundresa från AnsiString till UnicodeString till AnsiString via systemets kodsida. Vi hittade detta genom ett PDF/A-test som konstruerar ett namn som innehåller en isolerad $FE-byte, vilket aldrig är en giltig UTF-8-ledande byte, för att verifiera att valideraren flaggar namn som inte är giltiga UTF-8 enligt ISO 19005-2 klausul 6.1.8

var
  BadName: AnsiString;
begin
  // På Delphi med en multibyte-systemkodsida (observerat på CP936)
  // går sammanfogningen en rundresa via UnicodeString och $FE, vilket
  // inte är en giltig CP936-sekvens, och kommer tillbaka som '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Säkert: bygg med en ASCII-platshållare, korrigera sedan byten på plats;
  // indexerad tilldelning i en färdig AnsiString gör ingen rundresa
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

På ett kinesiskt Windows-system som kör kodsida 936 innehöll den sammanfogade strängen aldrig $FE alls, så biblioteket rapporterade korrekt ingenting och testet blev rött samtidigt som det såg ut som en biblioteksbugg. Biblioteket hade aldrig fel: en FPC-miljö som matade in en PDF som verkligen innehöll $FE-byten fick den förväntade flaggan. Korruptionen skedde inuti Delphi-testexekverbara filen medan stränguttrycket utvärderades, eftersom Delphis Unicode-först-strängmodell konverterar blandade AnsiString-uttryck via UnicodeString, och $FE är inte en giltig ledande byte i CP936 så rundresan ersätter den. Var ärlig med gränsen här: på en Western-kodsida med en enskild byte, som CP1252, överlever vanligtvis samma uttryck, vilket är exakt varför den här buggen gömmer sig på de flesta utvecklingsdatorer och dyker upp först på östasiatiska system eller lokaliserade CI-plattformar. Regeln vi antog: bygg aldrig binära testvektorer som innehåller byte vid eller över $80 genom AnsiString-sammanfogning; antingen korrigera byte på plats efter att strängen har satts, som ovan, eller konstruera vektorn i TBytes från början

Vad ett arbetsflöde med dubbla kompilatorer bör kontrollera som standard

Fyra fällor, ett mönster: varje kompilator berättar för dig om en annan delmängd av dina buggar. FPC:s intervallanalys vid kompilering fångade ett index utanför gränserna som dcc32 körde tyst i månader, och dcc32:s Unicode-strängmodell avslöjade ett kodsido-beroende som ett rent byte-orienterat FPC-bygge aldrig utlöser. Den praktiska konsekvensen är att ingen av de gröna pipelinerna är tillräcklig ensam. Korskompilering är inte bara en portabilitetsruta att bocka av, det är en andra statisk analysator och en andra runtime-modell som tillämpas på samma källkod, i samma anda som de defensiva gränskontrollerna i artikeln om ABI- och minnessäkerhetshärdning

De stående reglerna som kom ut ur dessa incidenter är korta nog att memorera. Fäst temporära post-funktionsresultat till en lokal variabel innan du läser fält. Iterera över arrayer med fasta gränser med Low() och High(), och run at least one range-checked or FPC build before trusting any new demo. Cast anonymous dynamic-array fields explicitly, or declare them with named types, and build the full compiler matrix before release. Keep raw high bytes out of AnsiString concatenation entirely. None of these cost measurable effort once they are habits, and each one closes a failure mode that a single-compiler workflow structurally cannot see

De stående reglerna som kom ut ur dessa incidenter är korta nog att memorera. Fäst temporära post-funktionsresultat till en lokal variabel innan du läser fält. Iterera över arrayer med fasta gränser med Low() och High(), och kör minst ett bygge med intervallkontroll eller FPC innan du litar på ett nytt demo. Typomvandla anonyma dynamiska arrayfält explicit, eller deklarera dem med namngivna typer, och bygg hela kompilatormatrisen före release. Håll råa höga byte helt borta från AnsiString-sammanfogning. Inget av detta kostar mätbar ansträngning när det väl blivit en vana, och var och en stänger ett felläge som ett arbetsflöde med en enda kompilator strukturellt sett inte kan upptäcka

Alla fyra problemen upptäcktes och åtgärdades under underhållet av PDFium Component, som levererar samma källkod i Object Pascal för Delphi, C++Builder och FPC/Lazarus och kör sina konformitets- och regressionssviter på var och en av dessa verktygskedjor, så fällorna i den här artikeln bevakas av tester snarare än av minne