Teknisk artikel

Delphi vs FPC: 4 skjulte PDF-kodefælder i PDFium-opbygninger

Den samme Object Pascal-kildekode kan opføre sig forskelligt under Delphi og FPC/Lazarus på fire måder, der gentagne gange rammer kildekoden til PDFium Component: FPC rydder op i funktioners midlertidige record-resultater (record temporaries), før en in-medlemskabstest er færdig med at læse dem, dcc32 leveres med grænsekontrol slået fra, så arrayindekser uden for grænserne stiltiende læser affaldsdata, kun Delphi 13 accepterer tildeling af et anonymt array of Byte to TBytes uden en typekonvertering, og Delphis AnsiString-sammenkædning kan ødelægge bytes ved eller over $80 via en skjult rundturskonvertering (round-trip) gennem systemets tegnsæt (code-page). Hver enkelt af disse fejl medfører et testforløb, der er grønt på den ene compiler og rødt — eller endnu værre, stiltiende forkert — på den anden

Hvis du opsætter et projekt til to compilere for første gang, dækker gennemgangen af Lazarus- og FPC-fremviseren den nemme vej (the happy path): Pakker, søgestier og at få et gengivelsesvindue på skærmen. Denne artikel er det modsatte af en vejledning. Det er listen over de ting, vi stødte på, efter at den nemme vej fungerede — da vores CI-system var grønt under FPC, grønt under Delphi, og en ændring, der blev godkendt på den ene side, derefter eksploderede på den anden. Hver enkelt af nedenstående fælder stammer fra en reel fejl i PDFiumPas-testforløbet eller dets demoer, hvor efterforskningen på commit-niveau er kondenseret til en minimal reproduktion, den dybere årsag samt den rettelse, vi standardiserede på

Hvorfor læses et set som tomt under FPC, men ikke i Delphi?

Kort sagt: FPC kan frigøre (finalize) den midlertidige variabel, der indeholder en funktions record-resultat, før et udtryk, der læser et felt i dette resultat, er færdigt, således at X in Func().Issues kan teste medlemskab mod et allerede frigivet set, mens det tilsvarende Delphi-udtryk fungerer. Vores PDF/E-konformitetstests stødte på dette i deres første version. Valideringsfunktionen returnerer en record, hvis Issues-felt er et set af overtrædelsesflag, og vores assertions inlinede kaldet

// Unreliable under FPC: the function-result record temporary
// can be released before the 'in' test reads Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Reliable on both compilers: pin the result to a local first
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Den inlinede form læste set-strukturen som tom under FPC, så enhver assertion, der forventede et flag, fejlede, mens den identiske Delphi-opbygning blev godkendt. Den dybere årsag er en forskel på, hvordan de to compilere styrer levetiden for midlertidige funktioners record-resultater i større udtryk: Delphi holder den midlertidige variabel i live til slutningen af sætningen, mens FPCs frigørelse af record-resultatet kan komme i konflikt (race) med set-medlemskabsoperatoren, der stadig er i gang med at læse den. Vi havde allerede dokumenteret den samme adfærd én gang før i en kommentar til hjælpefunktionen FlagPresent i PDF/A-testenheden, men genintroducerede alligevel fejlen, da vi skrev nye tests fra bunden, hvilket fortæller, hvor naturlig den fejlbehæftede form ser ud. Rettelsen er mekanisk og værd at indføre som en fast regel: Kæd aldrig en feltadgang eller set-test direkte på et funktionskald, der returnerer en record; tildel i stedet resultatet til en lokal variabel først, og læs derefter feltet. Det koster en enkelt linje og fjerner en hel klasse af compiler-afhængig ustabilitet

Hvorfor accepterer Delphi et arrayindeks, som FPC nægter at kompilere?

Kort sagt: dcc32 kompilerer et indeks uden for grænserne i et array med faste grænser og vil — med sin standardgrænsekontrol deaktiveret — stiltiende læse eller skrive i den tilstødende hukommelse under kørslen uden fejl, mens FPC afviser det samme indeks på kompileringstidspunktet. PDFium Component erklærer firkantpunkter (quad points) som et 1-baseret array, TQuadrilateralPoint = array [1..4] of TPdfPoint, hvilket matcher, hvordan PDF's QuadPoints-poster normalt er nummereret. En demo, der udfyldte det med en klassisk 0-baseret løkke, fungerede i månedsvis under Delphi

var
  I: Integer;
begin
  for I := 0 to 3 do                       // wrong: the array is [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // dcc32 default: compiles, index 0
                                           // silently touches adjacent memory
                                           // FPC: compile-time range check error
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // correct on both compilers
end;

Delphi-opbygningen var en falsk positiv: Med grænsekontrol slået fra, hvilket er standard for dcc32, indeks 0 landede på det felt, der tilfældigvis lå før arrayet i recorden, og demoen så ud til at køre. Portering af den samme demo to Lazarus gav straks en grænsekontrolfejl fra FPC under kompileringen. At rette indekset afslørede derefter en anden og dybere fejl i bibliotekets annoteringssti, som de forkerte læsninger havde maskeret, nemlig den fejl, der analyseres i artiklen om firkantmarkeringer (quad-points). To lektioner kom ud af den hændelse: For det første bør man foretrække Low() og High() frem for bogstavelige grænser, når arraytypen ikke er 0-baseret af natur. For det andet bør en FPC-kompilering — eller som minimum én Delphi-opbygning med {$R+} aktiveret — behandles som en obligatorisk indledende kontrol for enhver ny demo eller test: dcc32's standardindstillinger fortæller dig ikke om denne type fejl, og et program, der kører, er ikke bevis på, at det er korrekt

TBytes-tildelingen som kun Delphi 13 accepterer

Kort sagt: Tildeling af et felt erklæret som anonymt array of Byte til en TBytes-variabel kan kompilere i Delphi 13 (compiler-version 37.0), men fejler i Delphi 12 Athens og alle tidligere versioner med E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Dette er ikke så meget en opdeling mellem Delphi og FPC, som det er en opdeling mellem Delphi og dens egen fortid, men det rammer kildekoden til to compilere på samme måde: Den nyeste compiler accepterer stiltiende en konstruktion, som alt andet afviser

type
  TValidator = class
  private
    FBuffer: array of Byte;   // anonymous dynamic array type
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Delphi 13 only; E2010 on Delphi 12
                                 // Athens and earlier
  OrigBytes := TBytes(FBuffer);  // compiles everywhere; same byte layout,
                                 // safe hard cast
end;

Vi leverede præcis dette i en valideringsrutine, udviklet og testet lokalt på Delphi 13, hvor den implicitte konvertering stiltiende blev accepteret. Vores installationsprogram med fuld kildekode betjener et stort antal brugere på Delphi 12 og ældre, og for dem kunne enheden simpelthen ikke kompilere. Den strukturelle rettelse er enten den hårde typekonvertering (hard cast) vist ovenfor, hvilket er sikkert, fordi et anonymt array of Byte og TBytes deler det samme dynamiske array-layout, eller endnu bedre, at erklære feltet som en navngiven type som f.eks. TBytes fra starten, så der aldrig opstår behov for konvertering. Rettelsen af selve processen er dog vigtigere: En konstruktion, der kompilerer på dit nyeste værktøjssæt, beviser intet om de ældre compilere, dine brugere rent faktisk kører, og denne type regression er usynlig, indtil du bygger mod alle understøttede versioner. Vores udgivelsesskripter kompilerer nu biblioteket på tværs af hele compiler-matrixen, netop fordi en lokal 37.0-opbygning ikke kan fange en eftergivenhed, der kun findes i version 13

Den AnsiString-byte som forsvinder på en kinesisk Windows-maskine

Kort sagt: Sammenkædning af en rå byte ved eller over $80 i en AnsiString med + kan i Delphi stiltiende erstatte denne byte med ? ($3F), fordi udtrykket foretager en implicit rundturskonvertering fra AnsiString til UnicodeString to AnsiString via systemets tegnsæt (code page). Vi opdagede dette via en PDF/A-test, der konstruerer et navn indeholdende en isoleret $FE-byte — som aldrig er en gyldig UTF-8-startbyte — for at verificere, at valideringsfunktionen markerer navne, der ikke er gyldig UTF-8 i henhold til ISO 19005-2, punkt 6.1.8

var
  BadName: AnsiString;
begin
  // On Delphi with a multi-byte system code page (observed on CP936),
  // the concatenation round-trips through UnicodeString and $FE, which
  // is not a valid CP936 sequence, comes back as '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Safe: build with an ASCII placeholder, then patch the byte in place;
  // indexed assignment into a settled AnsiString does not round-trip
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

På et kinesisk Windows-system, der kører code page 936, indeholdt den sammenkædede streng overhovedet aldrig $FE, så biblioteket rapporterede korrekt intet, og testen blev rød, hvilket lignede en fejl i biblioteket. Biblioteket tog dog aldrig fejl: Et FPC-testmiljø, der indlæste en PDF, som reelt indeholdt $FE-byten, modtog det forventede flag. Korruptionen skete inde i Delphis test-eksekverbare fil, mens strengudtrykket blev evalueret, fordi Delphis Unicode-først-strengmodel konverterer blandede AnsiString-udtryk via UnicodeString, og $FE er ikke en gyldig startbyte i CP936, hvorfor rundturskonverteringen erstatter den. Lad os være ærlige om denne grænse: På et enkelt-byte vestligt tegnsæt som f.eks. CP1252 overlever det samme udtryk normalt, hvilket er præcis grunden til, at denne fejl gemmer sig på de fleste udviklingsmaskiner og kun kommer til syne på østasiatiske systemer eller lokaliserede CI-kørsler. Den regel, vi indførte: Opbyg aldrig binære testvektorer, der indeholder bytes ved eller over $80, via AnsiString-sammenkædning; rettelsen er enten at rette bytes på stedet, efter at strengen er færdigbygget som vist ovenfor, eller at opbygge vektoren i TBytes fra starten

Hvad en arbejdsgang med to compilere bør kontrollere som standard

Begge fejltyper sker stiltiende ved skrivetidspunktet, så den konstatering, der betyder noget, gøres ved at genåbne outputtet frem for at stole på den kode, der producerede det. Tre kontroller dækker næsten alt: Genåbn filen og bekræft, at HasVbaProject stadig returnerer true, når der forventes makroer, hvilket fanger en tabt nyttelast og en forkert filtype i en enkelt test. Læs ExternalLinks.Count og sammenlign det med antallet før omskrivningen. Åbn derefter filen én gang i Excel med makroer deaktiveret, da Excels indholdstypevalidering er strengere end noget biblioteks, og Excel er det program, dine kunder vil bedømme filen ud fra

De faste regler, der kom ud af disse hændelser, er korte nok til at kunne huskes udenad: Bind funktioners record-resultater til en lokal variabel før feltlæsning. Gennemløb arrays med faste grænser ved hjælp af Low() og High(), og afvikl mindst én grænsekontrolleret eller FPC-opbygning, før du stoler på en ny demo. Konverter anonyme dynamiske array-felter eksplicit, eller erklær dem med navngivne typer, og byg hele compiler-matrixen før frigivelse. Hold rå høje bytes helt ude af AnsiString-sammenkædning. Ingen af disse koster nævneværdig indsats, når de først er blevet vaner, og hver enkelt af dem lukker en fejltilstand, som en arbejdsgang med kun én compiler strukturelt ikke kan se

Alle fire problemer blev fundet og rettet under vedligeholdelsen af PDFium Component, som leverer den samme Object Pascal-kildekode til Delphi, C++Builder og FPC/Lazarus, og afvikler sine konformitets- og regressions-testforløb på hver enkelt af disse værktøjskæder, således at fælderne i denne artikel er beskyttet af tests frem for af hukommelse