PDFium Component exponerar TPdfParserResourceBudget, en enda post som begränsar rekursionsdjup, tokenantal, objektantal, avkodade bytes, expansionsratio, allokering och en wall-clock-deadline för varje PDF-parser i biblioteket. ConfigurePdfParserResourceBudget sätter den atomärt för processen, och varje parse kopierar en snapshot så att dess trösklar inte kan ändras mitt i operationen
Sex parsers, sex privata tak
Situationen som tvingade fram det här var vardaglig och förmodligen bekant. Sparse xref-traversering hade sitt eget objekttak. Ströminspectoraren hade sin egen avkodad-byte-gräns. XMP hade en, XFDF och FDF en annan, innehållsnormalisering en tredje, och preflight-defaultvärden en fjärde. Vart och ett av de numren var försvarbart i sig och inget av dem gick att nå utifrån, så en anropare som ville säga "den här intagssökvägen hanterar anonyma uppladdningar, inget här får allokera mer än 64 MiB eller köra längre än fyra sekunder" hade ingenstans att säga det. Det andra problemet var värre för att det var tyst: där en gräns levde i en mutabel global kunde en parse som började under en policy avslutas under en annan. En tråd anropar en setter medan en annan är halvvägs genom en objektström, och den första halvan av den traverseringen begränsas av det gamla taket medan den andra halvan begränsas av det nya. Inget kraschar, inget loggas, och efteråt kan du inte säga vilka gränser operationen faktiskt körde under, vilket är exakt den egenskap du behövde gränserna för
Vad begränsar TPdfParserResourceBudget egentligen?
Sju dimensioner, valda för att var och en fångar en klass av indata som de andra släpper igenom. MaxRecursionDepth har default 1024 och hindrar nästlade dictionaries och arrays från att göra ISO 32000-1 §7.3-objektsyntax till en stack overflow. MaxTokens har default 10 000 000 och begränsar lexingarbetet oberoende av filstorlek. MaxObjects har default 4 000 000 och täcker cross-reference-delavsnittsantal, /N-värden i objektströmmar och graftraverseringar. MaxDecodedBytes har default 256 MiB och MaxAllocationBytes 512 MiB, så en fil kan avvisas före allokeringen i stället för efter den. MaxExpansionRatio har default 1000.0 och är den man underskattar: ISO 32000-1 §7.4-strömfilter är generell komprimering, några kilobyte Flate-data kan legitimt expandera till hundratals megabyte, och ett rationatak är standardförsvaret mot en dekompressionsbomb. Det är ortogonalt mot det absoluta byte-taket, för en 200 KiB-ström som vill bli 200 MiB ligger under 256 MiB-taket och är ändå absurd. DeadlineMilliseconds har default 0, vilket betyder ingen deadline, så den dimensionen är strikt opt-in. Defaultvärdena som uppsättning är medvetet generösa, storlekssatta för att hålla vanliga affärsdokument från att snubbla på något snarare än att vara en härdad profil; kommer din indata från ett publikt uppladdningsformulär vill du ha något närmare siffrorna nedan, tillsammans med reglerna som beskrivs i att härda en Delphi PDF-förhandsvisningsruta
uses
FPdfPdfCommon;
procedure ApplyUntrustedIntakeBudget;
var
Budget: TPdfParserResourceBudget;
begin
Budget := TPdfParserResourceBudget.Default;
// Bevarade defaultvärden: 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 = av
ConfigurePdfParserResourceBudget(Budget);
end;
Varför ta en snapshot i stället för att läsa globalen?
För att en gräns som kan ändras medan den upprätthålls inte är en gräns. Det här är kärnan i hela funktionen, och det är en rad disciplin vid varje ingångspunkt: läs policyn en gång i början av operationen in i en lokal TPdfParserResourceBudget, och låt sedan varje checkpoint jämföra mot den lokala kopian. GetPdfParserResourceBudget tar kritiska sektionen och lämnar tillbaka en värdkopia av posten, och efter det rör parsen aldrig mutabelt globalt tillstånd igen. Resultatet är att ett anrop till ConfigurePdfParserResourceBudget från en annan tråd påverkar nästa operation, aldrig den som är i flykt. Parallella parser kör under olika policies utan att störa varandra, en operation som misslyckades på en resursgräns misslyckades mot en enda sammanhållen uppsättning trösklar, och det finns ingen lock-contention på hot path, eftersom låset tas en gång per parse i stället för en gång per token
type
TCustomScanState = record
Budget: TPdfParserResourceBudget;
StartTick: Cardinal;
TokenCount: Int64;
Depth: Integer;
end;
procedure BeginScan(var S: TCustomScanState);
begin
S.Budget := GetPdfParserResourceBudget; // snapshot tagen en gång, i förväg
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 ovanpå biblioteket, kopiera den formen i stället för att anropa GetPdfParserResourceBudget inuti din token-loop. Att anropa den per token är korrekt men meningslöst: du betalar en kritisk sektion varje iteration och du återinför mitt-i-operationen-driften som snapshotten fanns till för att förhindra
Varför stoppar inte ett byte-tak en patologisk fil?
För att storlek och kostnad bara är löst relaterade i PDF. En 400 KiB-fil med en ISO 32000-1 §7.5.8 cross-reference-ström vars /Index- och /W-arrayer beskriver ett plausibelt men fientligt postantal, en objektströmskedja som återbesöker samma offsetar, eller en innehållsström av operatorer som var och en kostar mer än de ser ut, sitter bekvämt under varje byte- och objekttak du sätter och ockuperar ändå en worker-thread i minuter. Byte-antal begränsar minne; de begränsar inte tid. Det är vad PdfParserStartTick och PdfParserDeadlineReached är till för. PdfParserStartTick fångar ett tick-värde i början av operationen och PdfParserDeadlineReached jämför förfluten tid mot DeadlineMilliseconds, och returnerar False närhelst deadline är 0. Subtraktionen körs i osignerad aritmetik, så ett tick-räknaromslag ger inte en falsk timeout. Cross-reference-parsning, objektströmsavkodning, innehållstokennormalisering och FDF- och XFDF-läsarna kontrollerar alla den vid sina loop-huvuden, så en överbudgeterad parse stannar vid en checkpoint i stället för att dödas utifrån
Två ärliga förbehåll. Det är wall-clock-tid, inte CPU-tid, så en maskin som swappar eller en build-server under last kan fälla en deadline som en tyst maskin skulle klara, vilket talar för att sätta den väl över din observerade worst case i stället för nära din median. Och den är av som standard, eftersom en deadline som utlöses på ett legitimt stort dokument under månadsslutets batchbearbetning är ett värre fel än det den förhindrar. Opta in per intagssökväg i stället för globalt, och para den med de strukturella kontrollerna i att validera cross-reference-strömmar
Budgeten är ett tak, inte en ersättning för formatgränser
Varje parser behåller sina egna formatspecifika konstanter och tillämpar båda. XFDF är det tydligaste exemplet: fältläsaren upprätthåller XfdfMaxTopLevelFields och en MaxFieldNodes-konstant på 100000 tillsammans med snapshotvärdet, och endera kan stoppa parsen
const
XfdfMaxTopLevelFields = 100000; // formatspecifik, gäller alltid
...
// Båda taken är aktiva; det lägre vinner
if (FieldCount >= XfdfMaxTopLevelFields) or
(FieldCount >= Budget.MaxObjects) then
Exit(False);
Det här är medvetet och värt att internalisera innan du letar efter en ratt som inte finns. En global budget på 4 000 000 objekt ger inte en XFDF-fil 4 000 000 fält, eftersom 100000 fält redan är långt utanför vad en riktig anteckningsexport producerar, och att höja ett globalt tak ska inte tyst vidga en formatspecifik sanity-check. Innehållsnormalisering fungerar likadant från andra hållet: TPdfContentNormalizeOptions.Default börjar från sina egna per-sida-gränser och klämmer sedan ner var och en av dem mot snapshotten, så MaxTokensPerPage kan bara någonsin sänkas av budgeten, aldrig höjas. Den globala policyn drar åt; den lossar aldrig
Dålig konfiguration misslyckas när du konfigurerar den
ConfigurePdfParserResourceBudget validerar innan den tilldelar. Ett icke-positivt värde i någon av de sex numeriska dimensionerna höjer omedelbart, och MaxDecodedBytes över High(Integer) avvisas som överskridande plattformsgränser i stället för att tyst trunkeras någonstans djupt i en avkodare. Det här betyder mer än det låter: en budget byggd från en konfigfil med en saknad nyckel ger dig nollor, och ett nollobjekttak accepterat vid start blir ett förbryllande parsefel på det första dokumentet tre timmar senare, då ingen misstänker konfigfilen. Samma omsorg gäller all kod som tillfälligt drar åt budgeten, inklusive tester. Policyn är processomfattande, så att lämna en 4-sekundersdeadline installerad efter att en fixture är klar förgiftar varje senare; spara och återställ runt ändringen
var
Saved: TPdfParserResourceBudget;
begin
Saved := GetPdfParserResourceBudget;
try
ApplyUntrustedIntakeBudget;
ProcessInboundDocument(FileName);
finally
ConfigurePdfParserResourceBudget(Saved);
end;
end;
ResetPdfParserResourceBudget är den trubbigare varianten, som återställer TPdfParserResourceBudget.Default rakt av. Använd den i test-teardown där du vill ha ett känt tillstånd, och använd save-and-restore i produktionskod, där det värdprogrammet konfigurerade vid start är det tillstånd du måste återvända till
Där budgeten slutar hjälpa
Det är en enda processomfattande policy, och det är dess främsta begränsning. Du kan inte ge en worker-thread en strikt profil och en annan en tillåtande, eftersom det finns en post bakom en kritisk sektion; behöver du verkligen per-tenant-gränser är den form som fungerar att serialisera varje policyklass till en egen process, eller att rama in arbete med save-and-restore och acceptera att inramningen bara är säker när inget annat parsar samtidigt. Den begränsar också parsning, inte rendering: rastreringsminne, font-cache-tillväxt och bildavkodning ligger utanför de sju dimensionerna och behöver egna kontroller. Och en budget säger dig att en operation var för dyr, aldrig varför. Taket som slog till är symtomet, och den felformade /Index-arrayen eller skenande filterkedjan bakom det måste fortfarande diagnostiseras för hand, vilket är varför avkodningsvägen som beskrivs i objektströms- och prediktoravkodning är värd att förstå innan du börjar flytta siffror. Behandla budgeten som det som hindrar ett dåligt dokument från att ta processen med sig, och håll din loggning tillräckligt bra för att ta reda på vad dokumentet gjorde
Hela parser-API-ytan, budgetposten och en källkodsbyggnad för Delphi, C++Builder och Lazarus finns på produktsidan: PDFium Component