PDFium Component stellt TPdfParserResourceBudget bereit, einen einzigen Record, der für jeden PDF-Parser in der Bibliothek Rekursionstiefe, Token-Anzahl, Objekt-Anzahl, dekodierte Bytes, Expansions-Ratio, Allokation und eine Wall-Clock-Deadline begrenzt. ConfigurePdfParserResourceBudget setzt ihn atomar für den Prozess, und jeder Parse kopiert sich einen Snapshot, sodass sich die Schwellen mitten in der Operation nicht verschieben können
Sechs Parser, sechs private Obergrenzen
Die Lage, die das erzwungen hat, war unspektakulär und wahrscheinlich bekannt. Der Sparse-Xref-Traversal hatte seine eigene Objekt-Obergrenze. Der Stream-Inspektor sein eigenes Dekodier-Byte-Limit. XMP hatte eins, XFDF und FDF ein anderes, Content-Normalisierung ein drittes, und die Preflight-Defaults ein viertes. Jede dieser Zahlen war für sich genommen vertretbar, und keine davon war von außen erreichbar — ein Aufrufer, der sagen wollte „dieser Intake-Pfad verarbeitet anonyme Uploads, hier darf nichts mehr als 64 MiB allokieren oder länger als vier Sekunden laufen", hatte keinen Ort, wo er das sagen konnte. Das zweite Problem war schlimmer, weil es lautlos war: Wo ein Limit in einem mutablen Global lebte, konnte ein Parse, der unter einer Policy startete, unter einer anderen enden. Ein Thread ruft einen Setter auf, während ein anderer mitten in einem Objekt-Stream steckt, und die erste Hälfte dieses Traversals ist durch die alte Obergrenze begrenzt, die zweite durch die neue. Nichts crasht, nichts loggt, und hinterher können Sie nicht sagen, unter welchen Limits die Operation tatsächlich lief — genau die Eigenschaft, für die Sie die Limits eigentlich wollten
Was begrenzt TPdfParserResourceBudget eigentlich?
Sieben Dimensionen, gewählt, weil jede eine Klasse von Input fängt, die die anderen durchlassen. MaxRecursionDepth steht standardmäßig auf 1024 und verhindert, dass verschachtelte Dictionaries und Arrays aus der ISO-32000-1-§7.3-Objektsyntax einen Stack-Overflow erzeugen. MaxTokens steht standardmäßig auf 10.000.000 und begrenzt die Lexer-Arbeit unabhängig von der Dateigröße. MaxObjects steht standardmäßig auf 4.000.000 und deckt Cross-Reference-Subsection-Zahlen, /N-Werte von Objekt-Streams und Graphtraversale ab. MaxDecodedBytes steht standardmäßig auf 256 MiB und MaxAllocationBytes auf 512 MiB, sodass eine Datei vor der Allokation abgelehnt werden kann statt danach. MaxExpansionRatio steht standardmäßig auf 1000.0 und wird am meisten unterschätzt: ISO 32000-1 §7.4 Stream-Filter sind General-Purpose-Kompression, ein paar Kilobyte Flate-Daten können sich legitim in hunderte Megabyte aufblähen, und eine Ratio-Obergrenze ist die Standardabwehr gegen eine Dekompressionsbombe. Sie ist orthogonal zur absoluten Byte-Obergrenze, denn ein 200-KiB-Stream, der 200 MiB werden will, liegt unter der 256-MiB-Decke und ist trotzdem absurd. DeadlineMilliseconds steht standardmäßig auf 0, also ohne Deadline, diese Dimension ist strikt opt-in. Die Defaults als Menge sind bewusst großzügig, dimensioniert, um normale Geschäftsdokumente nichts anstoßen zu lassen, statt ein gehärtetes Profil zu sein; wenn Ihr Input aus einem öffentlichen Upload-Formular kommt, wollen Sie Werte näher an den Zahlen unten, zusammen mit den Regeln aus dem Artikel zum Härten eines Delphi-PDF-Vorschaufensters
uses
FPdfPdfCommon;
procedure ApplyUntrustedIntakeBudget;
var
Budget: TPdfParserResourceBudget;
begin
Budget := TPdfParserResourceBudget.Default;
// Defaults beibehalten: 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 = aus
ConfigurePdfParserResourceBudget(Budget);
end;
Warum ein Snapshot statt das Global zu lesen?
Weil ein Limit, das sich ändern kann, während es durchgesetzt wird, kein Limit ist. Das ist der Kerndesignpunkt des gesamten Features, und er ist eine Zeile Disziplin an jedem Einstiegspunkt: Die Policy einmal zu Beginn der Operation in eine lokale TPdfParserResourceBudget lesen und dann jeden Checkpoint gegen diese lokale Kopie vergleichen lassen. GetPdfParserResourceBudget nimmt die Critical Section und gibt eine Wertkopie des Records zurück, danach fasst der Parse niemals wieder den mutablen Global-State an. Das Ergebnis: Ein ConfigurePdfParserResourceBudget-Aufruf aus einem anderen Thread betrifft die nächste Operation, nie die in Flug befindliche. Parallele Parses laufen unter verschiedenen Policies, ohne sich zu stören, eine Operation, die an einem Ressourcenlimit gescheitert ist, ist an einem einzigen kohärenten Schwellensatz gescheitert, und auf dem Hot Path gibt es keinen Lock-Contention, weil der Lock einmal pro Parse genommen wird statt einmal pro Token
type
TCustomScanState = record
Budget: TPdfParserResourceBudget;
StartTick: Cardinal;
TokenCount: Int64;
Depth: Integer;
end;
procedure BeginScan(var S: TCustomScanState);
begin
S.Budget := GetPdfParserResourceBudget; // Snapshot einmal am Anfang genommen
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;
Wenn Sie Ihren eigenen Parser auf der Bibliothek aufsetzen, kopieren Sie diese Form, statt GetPdfParserResourceBudget innerhalb Ihrer Token-Schleife aufzurufen. Der Aufruf pro Token ist korrekt, aber sinnlos: Sie zahlen bei jeder Iteration eine Critical Section und führen die Mid-Operation-Drift wieder ein, die der Snapshot verhindern sollte
Warum stoppt eine Byte-Obergrenze eine pathologische Datei nicht?
Weil Größe und Kosten in PDF nur lose zusammenhängen. Eine 400-KiB-Datei mit einem Cross-Reference-Stream nach ISO 32000-1 §7.5.8, dessen /Index- und /W-Arrays eine plausible, aber feindselige Eintragszahl beschreiben, eine Objekt-Stream-Kette, die dieselben Offsets erneut besucht, oder ein Content-Stream aus Operatoren, die jeweils mehr kosten, als sie aussehen — all das liegt bequem unter jeder Byte- und Objekt-Obergrenze, die Sie setzen, und belegt trotzdem minutenlang einen Worker-Thread. Byte-Zahlen begrenzen Speicher; sie begrenzen keine Zeit. Genau dafür gibt es PdfParserStartTick und PdfParserDeadlineReached. PdfParserStartTick erfasst einen Tick-Wert zu Beginn der Operation, und PdfParserDeadlineReached vergleicht die verstrichene Zeit gegen DeadlineMilliseconds und liefert immer dann False, wenn die Deadline 0 ist. Die Subtraktion läuft in unsigned Arithmetik, ein Tick-Counter-Rollover erzeugt also keinen falschen Timeout. Cross-Reference-Parsing, Objekt-Stream-Dekodierung, Content-Token-Normalisierung und die FDF- und XFDF-Reader prüfen das alle an ihren Schleifenköpfen, ein über-Budget-Parse stoppt also an einem Checkpoint, statt von außen gekillt zu werden
Zwei ehrliche Einschränkungen. Es ist Wall-Clock-Zeit, keine CPU-Zeit, eine Maschine, die swapped, oder ein Build-Server unter Last kann also eine Deadline auslösen, die eine ruhige Maschine schaffen würde — ein Argument dafür, sie deutlich über Ihrem beobachteten Worst Case anzusetzen statt nahe am Median. Und sie ist per Default aus, denn eine Deadline, die bei einem legitimen großen Dokument während der Monatsabschluss-Batchverarbeitung feuert, ist ein schlimmerer Fehler als der, den sie verhindert. Nehmen Sie sie pro Intake-Pfad opt-in statt global, und kombinieren Sie sie mit den strukturellen Checks aus dem Artikel zum Validieren von Cross-Reference-Streams
Das Budget ist eine Obergrenze, kein Ersatz für Format-Limits
Jeder Parser behält seine eigenen format-spezifischen Konstanten und wendet beide an. XFDF ist das klarste Beispiel: Der Field-Reader setzt XfdfMaxTopLevelFields und eine MaxFieldNodes-Konstante von 100000 neben dem Snapshot-Wert durch, und jeder der beiden kann den Parse stoppen
const
XfdfMaxTopLevelFields = 100000; // format-lokal, gilt immer
...
// Beide Obergrenzen sind aktiv; die niedrigere gewinnt
if (FieldCount >= XfdfMaxTopLevelFields) or
(FieldCount >= Budget.MaxObjects) then
Exit(False);
Das ist Absicht und wert, verinnerlicht zu werden, bevor Sie nach einem Knopf suchen, den es nicht gibt. Ein globales Budget von 4.000.000 Objekten schenkt einer XFDF-Datei keine 4.000.000 Felder, denn 100000 Felder liegen schon weit außerhalb dessen, was ein echtes Annotations-Export erzeugt, und eine globale Obergrenze anzuheben sollte einen format-spezifischen Plausibilitätscheck nicht stillschweigend aufweiten. Content-Normalisierung arbeitet aus der anderen Richtung genauso: TPdfContentNormalizeOptions.Default startet mit seinen eigenen Per-Page-Limits und klemmt jedes davon auf den Snapshot herunter, MaxTokensPerPage kann durch das Budget also nur gesenkt, nie angehoben werden. Die globale Policy verschärft; sie lockert nie
Schlechte Konfiguration scheitert beim Konfigurieren
ConfigurePdfParserResourceBudget validiert, bevor es zuweist. Ein nicht-positiver Wert in einer der sechs numerischen Dimensionen wirft sofort, und MaxDecodedBytes über High(Integer) wird abgelehnt, weil es die Plattformlimits überschreitet, statt irgendwo tief in einem Decoder stillschweigend abgeschnitten zu werden. Das ist wichtiger, als es klingt: Ein Budget, das aus einer Config-Datei mit fehlendem Key gebaut wird, liefert Ihnen Nullen, und eine bei Startup akzeptierte Null-Objekt-Obergrenze wird drei Stunden später beim ersten Dokument zu einem rätselhaften Parse-Fehler — an dem Punkt verdächtigt niemand die Config. Dieselbe Sorgfalt gilt für jeden Code, der das Budget temporär verschärft, Tests eingeschlossen. Die Policy ist prozessweit, ein nach einem Fixture installiert gelassenes 4-Sekunden-Deadline vergiftet also jedes spätere; speichern und wiederherstellen rund um die Änderung
var
Saved: TPdfParserResourceBudget;
begin
Saved := GetPdfParserResourceBudget;
try
ApplyUntrustedIntakeBudget;
ProcessInboundDocument(FileName);
finally
ConfigurePdfParserResourceBudget(Saved);
end;
end;
ResetPdfParserResourceBudget ist die gröbere Variante und stellt schlicht TPdfParserResourceBudget.Default wieder her. Nutzen Sie sie im Test-Teardown, wo Sie einen bekannten Zustand wollen, und Save-and-Restore im Produktionscode, wo der Zustand, den die Host-Anwendung beim Startup konfiguriert hat, der ist, zu dem Sie zurückkehren müssen
Wo das Budget nicht mehr hilft
Es ist eine einzelne prozessweite Policy, und das ist seine Hauptgrenze. Sie können nicht einem Worker-Thread ein striktes Profil geben und einem anderen ein permissives, denn hinter einer Critical Section liegt genau ein Record; wenn Sie wirklich Per-Tenant-Limits brauchen, funktioniert die Form, jede Policy-Klasse auf einen eigenen Prozess zu serialisieren oder Arbeit mit Save-and-Restore einzuklammern — mit der Einschränkung, dass die Klammer nur sicher ist, wenn nichts anderes parallel parst. Außerdem begrenzt es Parsing, nicht Rendering: Rasterisierungs-Speicher, Font-Cache-Wachstum und Bild-Dekodierung liegen außerhalb dieser sieben Dimensionen und brauchen eigene Kontrollen. Und ein Budget sagt Ihnen, dass eine Operation zu teuer war, nie warum. Die gefeuerte Obergrenze ist das Symptom, und das kaputte /Index-Array oder die durchgegangene Filter-Kette dahinter müssen immer noch von Hand diagnostiziert werden — genau deshalb lohnt es sich, den im Artikel über Objekt-Stream- und Predictor-Dekodierung beschriebenen Dekodierungspfad zu verstehen, bevor Sie anfangen, Zahlen zu verschieben. Behandeln Sie das Budget als das, was ein schlechtes Dokument davon abhält, den Prozess mit sich zu reißen, und halten Sie Ihr Logging gut genug, um herauszufinden, was das Dokument getan hat
Die komplette Parser-API-Oberfläche, der Budget-Record und ein Source-Code-Build für Delphi, C++Builder und Lazarus finden sich auf der Produktseite: PDFium Component