Teknisk artikel

PDF-parser-ressourcebudget i Delphi med PDFium Component

PDFium Component eksponerer TPdfParserResourceBudget, én enkelt record der afgrænser rekursionsdybde, token-antal, objekt-antal, afkodede bytes, ekspansionsratio, allokering og en wall-clock-deadline for hver PDF-parser i biblioteket. ConfigurePdfParserResourceBudget sætter den atomisk for processen, og hver parse kopierer et snapshot så dens tærskler ikke kan flytte sig midt i operationen

Seks parsere, seks private lofter

Situationen der tvang dette frem, var hverdagsagtig og sikkert velkendt. Sparse-xref-gennemløb havde sit eget objekt-loft. Stream-inspektøren havde sin egen afkodet-byte-grænse. XMP havde én, XFDF og FDF en anden, content-normalisering en tredje og preflight-defaults en fjerde. Hvert af de tal var forsvarligt i sig selv, og ingen af dem kunne nås udefra, så en caller der ville sige "denne intake-sti håndterer anonyme uploads, intet her må allokere mere end 64 MiB eller køre længere end fire sekunder" havde intet sted at sige det. Det andet problem var værre, fordi det var lydløst: hvor en grænse nu levede i en mutabel global, kunne en parse der startede under én politik slutte under en anden. Én tråd kalder en setter mens en anden er midt i et object stream, og den første halvdel af det gennemløb er afgrænset af det gamle loft, mens anden halvdel er afgrænset af det nye. Intet crasher, intet logges, og bagefter kan du ikke sige hvilke grænser operationen faktisk kørte under — hvilket er præcis den egenskab du havde brug for grænserne til

Hvad afgrænser TPdfParserResourceBudget egentlig?

Syv dimensioner, valgt fordi hver enkelt fanger en klasse af input de andre slipper igennem. MaxRecursionDepth default 1024 og stopper indlejrede dictionaries og arrays fra at forvandle ISO 32000-1 §7.3-objektsyntaks til et stack overflow. MaxTokens default 10.000.000 og afgrænser lexing-arbejdet uafhængigt af filstørrelse. MaxObjects default 4.000.000 og dækker cross-reference-subsection-antal, object-stream-/N-værdier og graf-gennemløb. MaxDecodedBytes default 256 MiB og MaxAllocationBytes 512 MiB, så en fil kan afvises før allokeringen snarere end efter den. MaxExpansionRatio default 1000.0 og er den folk undervurderer: ISO 32000-1 §7.4-stream-filtre er generel komprimering, et par kilobyte Flate-data kan med rette ekspandere til hundredvis af megabyte, og et ratio-loft er standard-forsvaret mod en decompression bomb. Den er ortogonal i forhold til det absolutte byte-loft, fordi en 200 KiB-stream der vil blive til 200 MiB er under 256 MiB-loftet og stadig absurd. DeadlineMilliseconds default 0, hvilket betyder ingen deadline, så den dimension er strengt opt-in. Defaults'ene som sæt er bevidst generøse, dimensioneret til at holde almindelige forretningsdokumenter fra at ramme noget snarere end at være en hærdet profil; kommer dit input fra en offentlig upload-formular, vil du have noget tættere på tallene nedenfor, sammen med reglerne beskrevet i hærdning af en Delphi-PDF-preview-rude

Diagram af de syv dimensioner TPdfParserResourceBudget afgrænser i PDFium Component: MaxRecursionDepth 1024, MaxTokens 10000000, MaxObjects 4000000, MaxDecodedBytes 256 MiB, MaxAllocationBytes 512 MiB, MaxExpansionRatio 1000.0 som forsvar mod decompression bombs, og den opt-in wall-clock-grænse DeadlineMilliseconds, alt sat atomisk for processen af ConfigurePdfParserResourceBudget
Syv dimensioner er valgt fordi hver enkelt fanger en klasse af input de andre slipper igennem, med ekspansionsratioen stående mellem et par kilobyte Flate-data og hundredvis af megabyte
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // Defaults beholdt: MaxRecursionDepth 1024, MaxTokens 10000000,
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // default 256 MiB
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // default 512 MiB
  Budget.MaxExpansionRatio    := 50.0;                     // default 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // default 0 = fra
  ConfigurePdfParserResourceBudget(Budget);
end;

Hvorfor tage et snapshot i stedet for at læse globalen?

Fordi en grænse der kan ændre sig mens den håndhæves, ikke er en grænse. Det er hele funktionens kerne-designpunkt, og det er én linje disciplin ved hvert entry point: læs politikken én gang ved operationens start ind i en lokal TPdfParserResourceBudget, og lad så hvert checkpoint sammenligne mod den lokale kopi. GetPdfParserResourceBudget tager critical section'en og giver en værdi-kopi af record'en tilbage, og derefter rører parsen aldrig mutabel global tilstand igen. Resultatet er at et kald til ConfigurePdfParserResourceBudget fra en anden tråd påvirker den næste operation, aldrig den der er i luften. Parallelle parses kører under forskellige politikker uden at forstyrre hinanden, en operation der fejlede på en ressource-grænse fejlede mod ét enkelt sammenhængende sæt tærskler, og der er ingen lock-contention på hot path'en, fordi låsen tages én gang per parse snarere end én gang per token

Diagram af snapshot-politikken i PDFium Component: den proces-dækkende TPdfParserResourceBudget-record sidder bag én critical section, hver parse kalder GetPdfParserResourceBudget én gang for en værdi-kopi, hvert checkpoint sammenligner mod det lokale snapshot, og et samtidigt ConfigurePdfParserResourceBudget-kald fra en anden tråd påvirker kun den næste operation, aldrig den der er i luften
At læse politikken én gang ind i en lokal record betyder at en konfigurationsændring fra en anden tråd kun påvirker den næste operation, og der er ingen lock-contention på hot path'en
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // snapshot taget én gang, på forhånd
  S.StartTick := PdfParserStartTick;
  S.TokenCount := 0;
  S.Depth := 0;
end;

function ScanCheckpoint(var S: TCustomScanState): Boolean;
begin
  Result := False;
  if PdfParserDeadlineReached(S.StartTick, S.Budget) then
    Exit;
  Inc(S.TokenCount);
  if S.TokenCount > S.Budget.MaxTokens then
    Exit;
  if S.Depth > S.Budget.MaxRecursionDepth then
    Exit;
  Result := True;
end;

Skriver du din egen parser oven på biblioteket, så kopier den form frem for at kalde GetPdfParserResourceBudget inde i din token-løkke. At kalde den per token er korrekt men meningsløst: du betaler en critical section ved hver iteration og genintroducerer den midt-operation-drift snapshot'et skulle forhindre

Hvorfor stopper et byte-loft ikke en patologisk fil?

Fordi størrelse og omkostning kun er løst forbundet i PDF. En 400 KiB-fil med en ISO 32000-1 §7.5.8-cross-reference-stream hvis /Index- og /W-arrays beskriver et plausibelt men fjendtligt indgangs-antal, en object-stream-kæde der genser de samme offsets, eller en content-stream af operatorer der hver koster mere end de ser ud til, sidder behageligt under ethvert byte- og objekt-loft du sætter og besætter stadig en worker-tråd i minutter. Byte-antal afgrænser hukommelse; de afgrænser ikke tid. Det er hvad PdfParserStartTick og PdfParserDeadlineReached er til. PdfParserStartTick fanger en tick-værdi ved operationens start, og PdfParserDeadlineReached sammenligner forløbet tid mod DeadlineMilliseconds og returnerer False når deadlinen er 0. Subtraktionen kører i unsigned-aritmetik, så et tick-counter-rollover producerer ikke en spurious timeout. Cross-reference-parsing, object-stream-afkodning, content-token-normalisering og FDF- og XFDF-læserne tjekker den alle ved deres løkke-hoveder, så en over-budget-parse stopper ved et checkpoint i stedet for at blive dræbt udefra

To ærlige forbehold. Det er wall-clock-tid, ikke CPU-tid, så en maskine der swapper eller en build-server under belastning kan udløse en deadline en stille maskine ville klare, hvilket taler for at sætte den godt over din observerede worst case frem for tæt på din median. Og den er fra som default, fordi en deadline der affyres på et legitimt stort dokument under månedsafslutnings-batchbehandling er en værre fejl end den den forhindrer. Opt in per intake-sti snarere end globalt, og par den med de strukturelle checks i validering af cross-reference streams

Budget'et er et loft, ikke en erstatning for format-grænser

Hver parser beholder sine egne format-specifikke konstanter og anvender begge. XFDF er det klareste eksempel: felt-læseren håndhæver XfdfMaxTopLevelFields og en MaxFieldNodes-konstant på 100000 ved siden af snapshot-værdien, og enhver af dem kan stoppe parsen

const
  XfdfMaxTopLevelFields = 100000;   // format-lokal, gælder altid
...
  // Begge lofter er aktive; det laveste vinder
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

Det er bevidst og værd at internalisere før du går på jagt efter en knap der ikke findes. Et globalt budget på 4.000.000 objekter giver ikke en XFDF-fil 4.000.000 felter, fordi 100000 felter allerede er langt uden for hvad en ægte annotation-eksport producerer, og at hæve et globalt loft bør ikke lydløst udvide en format-specifik sanity-check. Content-normalisering virker på samme måde fra den anden retning: TPdfContentNormalizeOptions.Default starter fra sine egne per-page-grænser og klemmer så hver af dem ned mod snapshot'et, så MaxTokensPerPage kun nogensinde kan sænkes af budget'et, aldrig hæves. Den globale politik strammer; den løsner aldrig

Diagram af hvordan parser-budget'et fungerer som et loft i PDFium Component: XFDF-felt-læseren håndhæver XfdfMaxTopLevelFields på 100000 ved siden af snapshot-værdien af MaxObjects, så den laveste stopper parsen, mens TPdfContentNormalizeOptions klemmer MaxTokensPerPage ned mod budget'et, fordi den globale politik strammer grænser og aldrig løsner dem
Et globalt budget på 4.000.000 objekter giver ikke en XFDF-fil 4.000.000 felter, fordi hver parser beholder sine format-specifikke konstanter og anvender begge lofter med det laveste som vinder

Dårlig konfiguration fejler når du konfigurerer den

ConfigurePdfParserResourceBudget validerer før den tildeler. En ikke-positiv værdi i nogen af de seks numeriske dimensioner rejser med det samme, og MaxDecodedBytes over High(Integer) afvises som overskridelse af platform-grænserne snarere end lydløst at blive afkortet et eller andet sted dybt i en decoder. Det betyder mere end det lyder: et budget bygget fra en config-fil med en manglende nøgle giver dig nuller, og et nul-objekt-loft accepteret ved opstart bliver til en gådefuld parse-fejl på det første dokument tre timer senere, på hvilket tidspunkt ingen har mistanke til config'en. Den samme omhu gælder enhver kode der midlertidigt strammer budget'et, inklusive tests. Politikken er proces-dækkende, så at efterlade en 4-sekunders-deadline installeret efter en fixture er færdig forgifter enhver senere; gem og gendan omkring ændringen

var
  Saved: TPdfParserResourceBudget;
begin
  Saved := GetPdfParserResourceBudget;
  try
    ApplyUntrustedIntakeBudget;
    ProcessInboundDocument(FileName);
  finally
    ConfigurePdfParserResourceBudget(Saved);
  end;
end;

ResetPdfParserResourceBudget er den mere brute version, der gendanner TPdfParserResourceBudget.Default direkte. Brug den i test-teardown hvor du vil have en kendt tilstand, og brug save-and-restore i produktionskode, hvor uanset hvad host-applikationen konfigurerede ved opstart, er den tilstand du skal vende tilbage til

Hvor budget'et holder op med at hjælpe

Det er en enkelt proces-dækkende politik, og det er dens vigtigste begrænsning. Du kan ikke give én worker-tråd en streng profil og en anden en tilladende, for der er én record bag én critical section; har du reelt brug for per-tenant-grænser, er den form der virker at serialisere hver politik-klasse på sin egen proces, eller at indramme arbejde med save-and-restore og acceptere at indramningen kun er sikker når intet andet parser samtidig. Den afgrænser også parsing, ikke rendering: rasteriseringshukommelse, font-cache-vækst og billedafkodning ligger uden for de syv dimensioner og har brug for deres egne kontroller. Og et budget fortæller dig at en operation var for dyr, aldrig hvorfor. Loftet der affyrede er symptomet, og det fejlformede /Index-array eller den løbske filter-kæde bag det skal stadig diagnosticeres i hånden, hvilket er hvorfor afkodnings-stien beskrevet i object stream- og predictor-afkodning er værd at forstå før du begynder at flytte tal. Behandl budget'et som det der forhindrer et dårligt dokument i at tage processen med sig ned, og hold din logging god nok til at finde ud af hvad dokumentet lavede

Hele parser-API-overfladen, budget-record'en og en kildekode-bygning til Delphi, C++Builder og Lazarus er på produktsiden: PDFium Component