Article technique

Budget de ressources du parseur PDF avec PDFium Component

PDFium Component expose TPdfParserResourceBudget, un seul record qui borne la profondeur de récursion, le nombre de tokens, le nombre d'objets, les octets décodés, le ratio d'expansion, l'allocation et une deadline temps réel pour chaque parseur PDF de la bibliothèque. ConfigurePdfParserResourceBudget le définit atomiquement pour le processus, et chaque parse copie un snapshot pour que ses seuils ne puissent pas bouger en cours d'opération

Six parseurs, six plafonds privés

La situation qui a forcé ça était banale et sans doute familière. La traversée xref sparse avait son propre plafond d'objets. L'inspecteur de flux avait son propre plafond d'octets décodés. XMP en avait un, XFDF et FDF un autre, la normalisation de contenu un troisième, et les défauts de preflight un quatrième. Chacun de ces nombres était défendable isolément et aucun n'était atteignable de l'extérieur, donc un appelant qui voulait dire « ce chemin d'entrée gère des uploads anonymes, rien ici ne peut allouer plus de 64 MiB ni tourner plus de quatre secondes » n'avait nulle part où le dire. Le second problème était pire parce qu'il était silencieux : là où une limite vivait dans un global mutable, un parse commencé sous une politique pouvait finir sous une autre. Un thread appelle un setter pendant qu'un autre est à mi-chemin d'un object stream, et la première moitié de cette traversée est bornée par l'ancien plafond tandis que la seconde moitié est bornée par le nouveau. Rien ne plante, rien ne journalise, et après coup vous ne pouvez pas dire sous quelles limites l'opération a réellement tourné, ce qui est exactement la propriété pour laquelle vous vouliez ces limites

Que borne réellement TPdfParserResourceBudget ?

Sept dimensions, choisies parce que chacune attrape une classe d'entrées que les autres laissent passer. MaxRecursionDepth vaut 1024 par défaut et empêche les dictionnaires et tableaux imbriqués de transformer la syntaxe d'objet de l'ISO 32000-1 §7.3 en stack overflow. MaxTokens vaut 10 000 000 par défaut et borne le travail de lexing indépendamment de la taille du fichier. MaxObjects vaut 4 000 000 par défaut et couvre les comptes de sous-sections de cross-reference, les valeurs /N d'object stream et les traversées de graphes. MaxDecodedBytes vaut 256 MiB par défaut et MaxAllocationBytes 512 MiB, donc un fichier peut être rejeté avant l'allocation plutôt qu'après. MaxExpansionRatio vaut 1000.0 par défaut et c'est celle qu'on sous-estime : les filtres de flux de l'ISO 32000-1 §7.4 sont de la compression généraliste, quelques kilo-octets de données Flate peuvent légitimement s'expanser en centaines de méga-octets, et un plafond de ratio est la défense standard contre une bombe de décompression. C'est orthogonal au plafond d'octets absolu, car un flux de 200 KiB qui veut devenir 200 MiB est sous le plafond de 256 MiB et reste absurde. DeadlineMilliseconds vaut 0 par défaut, ce qui veut dire pas de deadline, donc cette dimension est strictement opt-in. Les défauts, pris ensemble, sont délibérément généreux, calibrés pour que les documents métier ordinaires ne déclenchent rien plutôt que pour être un profil durci ; si votre entrée arrive d'un formulaire d'upload public, vous voulez quelque chose de plus proche des nombres ci-dessous, avec les règles décrites dans le durcissement d'un volet de prévisualisation PDF en Delphi

Schéma des sept dimensions que TPdfParserResourceBudget borne dans PDFium Component : MaxRecursionDepth 1024, MaxTokens 10000000, MaxObjects 4000000, MaxDecodedBytes 256 MiB, MaxAllocationBytes 512 MiB, MaxExpansionRatio 1000.0 comme défense contre les bombes de décompression, et la limite temps réel opt-in DeadlineMilliseconds, le tout défini atomiquement pour le processus par ConfigurePdfParserResourceBudget
Sept dimensions sont choisies parce que chacune attrape une classe d'entrées que les autres laissent passer, le ratio d'expansion se tenant entre quelques kilo-octets de données Flate et des centaines de méga-octets
uses
  FPdfPdfCommon;

procedure ApplyUntrustedIntakeBudget;
var
  Budget: TPdfParserResourceBudget;
begin
  Budget := TPdfParserResourceBudget.Default;
  // Défauts conservés : MaxRecursionDepth 1024, MaxTokens 10000000,
  // MaxObjects 4000000
  Budget.MaxDecodedBytes      := Int64(32) * 1024 * 1024;  // défaut 256 MiB
  Budget.MaxAllocationBytes   := Int64(64) * 1024 * 1024;  // défaut 512 MiB
  Budget.MaxExpansionRatio    := 50.0;                     // défaut 1000.0
  Budget.DeadlineMilliseconds := 4000;                     // défaut 0 = désactivé
  ConfigurePdfParserResourceBudget(Budget);
end;

Pourquoi prendre un snapshot plutôt que de lire le global ?

Parce qu'une limite qui peut changer pendant qu'elle est appliquée n'est pas une limite. C'est le point de conception central de toute la fonctionnalité, et c'est une ligne de discipline à chaque point d'entrée : lire la politique une fois au début de l'opération dans un TPdfParserResourceBudget local, puis faire comparer chaque checkpoint contre cette copie locale. GetPdfParserResourceBudget prend la section critique et rend une copie par valeur du record, et après ça le parse ne touche plus jamais l'état global mutable. Le résultat, c'est qu'un appel à ConfigurePdfParserResourceBudget depuis un autre thread affecte la prochaine opération, jamais celle en vol. Des parses concurrents tournent sous des politiques différentes sans interférer, une opération qui a échoué sur une limite de ressources a échoué contre un ensemble cohérent unique de seuils, et il n'y a pas de contention de lock sur le chemin chaud car le lock est pris une fois par parse plutôt qu'une fois par token

Schéma de la politique de snapshot dans PDFium Component : le record TPdfParserResourceBudget global au processus se tient derrière une seule section critique, chaque parse appelle GetPdfParserResourceBudget une fois pour une copie par valeur, chaque checkpoint compare contre ce snapshot local, et un appel concurrent à ConfigurePdfParserResourceBudget depuis un autre thread n'affecte que la prochaine opération, jamais celle en vol
Lire la politique une fois dans un record local veut dire qu'un changement de configuration depuis un autre thread n'affecte que la prochaine opération, et qu'il n'y a pas de contention de lock sur le chemin chaud
type
  TCustomScanState = record
    Budget: TPdfParserResourceBudget;
    StartTick: Cardinal;
    TokenCount: Int64;
    Depth: Integer;
  end;

procedure BeginScan(var S: TCustomScanState);
begin
  S.Budget := GetPdfParserResourceBudget;  // snapshot pris une fois, d'entrée de jeu
  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;

Si vous écrivez votre propre parseur par-dessus la bibliothèque, copiez cette forme plutôt que d'appeler GetPdfParserResourceBudget dans votre boucle de tokens. L'appeler par token est correct mais inutile : vous payez une section critique à chaque itération et vous réintroduisez la dérive en cours d'opération que le snapshot était là pour empêcher

Pourquoi un plafond d'octets n'arrête-t-il pas un fichier pathologique ?

Parce que la taille et le coût ne sont que vaguement liés en PDF. Un fichier de 400 KiB avec un flux de cross-reference de l'ISO 32000-1 §7.5.8 dont les tableaux /Index et /W décrivent un nombre d'entrées plausible mais hostile, une chaîne d'object streams qui revisite les mêmes offsets, ou un flux de contenu d'opérateurs qui coûtent chacun plus qu'ils n'en ont l'air, se tiendra confortablement sous tous les plafonds d'octets et d'objets que vous fixez et occupera quand même un thread de travail pendant des minutes. Les compteurs d'octets bornent la mémoire ; ils ne bornent pas le temps. C'est à ça que servent PdfParserStartTick et PdfParserDeadlineReached. PdfParserStartTick capture une valeur de tick au début de l'opération et PdfParserDeadlineReached compare le temps écoulé à DeadlineMilliseconds, retournant False chaque fois que la deadline est 0. La soustraction se fait en arithmétique non signée, donc un retour à zéro du compteur de ticks ne produit pas de timeout fantôme. L'analyse de cross-reference, le décodage d'object streams, la normalisation de tokens de contenu et les lecteurs FDF et XFDF la vérifient tous en tête de boucle, donc un parse au-dessus du budget s'arrête à un checkpoint au lieu d'être tué de l'extérieur

Deux mises en garde honnêtes. C'est du temps réel, pas du temps CPU, donc une machine qui swappe ou un serveur de build sous charge peut déclencher une deadline qu'une machine calme passerait, ce qui plaide pour la fixer bien au-dessus de votre pire cas observé plutôt que près de votre médiane. Et elle est désactivée par défaut, parce qu'une deadline qui se déclenche sur un document légitimement gros pendant un traitement batch de fin de mois est un échec pire que celui qu'elle empêche. Activez-la par chemin d'entrée plutôt que globalement, et associez-la aux vérifications structurelles de la validation des flux de cross-reference

Le budget est un plafond, pas un remplaçant des limites de format

Chaque parseur garde ses propres constantes spécifiques au format et applique les deux. XFDF est l'exemple le plus clair : le lecteur de champs applique XfdfMaxTopLevelFields et une constante MaxFieldNodes de 100000 aux côtés de la valeur du snapshot, et l'un comme l'autre peut arrêter le parse

const
  XfdfMaxTopLevelFields = 100000;   // local au format, s'applique toujours
...
  // Les deux plafonds sont actifs ; le plus bas gagne
  if (FieldCount >= XfdfMaxTopLevelFields) or
     (FieldCount >= Budget.MaxObjects) then
    Exit(False);

C'est intentionnel et ça vaut le coup de l'intégrer avant de chercher un bouton qui n'existe pas. Un budget global de 4 000 000 d'objets n'accorde pas à un fichier XFDF 4 000 000 de champs, car 100000 champs est déjà très loin de tout ce qu'un vrai export d'annotations produit, et relever un plafond global ne devrait pas élargir en silence une vérification de cohérence spécifique au format. La normalisation de contenu fonctionne pareil dans l'autre sens : TPdfContentNormalizeOptions.Default part de ses propres limites par page puis les clampe chacune au snapshot, donc MaxTokensPerPage ne peut qu'être abaissé par le budget, jamais relevé. La politique globale resserre ; elle ne relâche jamais

Schéma du budget de parseur comme plafond dans PDFium Component : le lecteur de champs XFDF applique XfdfMaxTopLevelFields à 100000 aux côtés de la valeur de snapshot de MaxObjects, donc le plus bas arrête le parse, tandis que TPdfContentNormalizeOptions clampe MaxTokensPerPage au budget, car la politique globale resserre les limites et ne les relâche jamais
Un budget global de 4 000 000 d'objets n'accorde pas à un fichier XFDF 4 000 000 de champs, car chaque parseur garde ses constantes spécifiques au format et applique les deux plafonds, le plus bas gagnant

Une mauvaise configuration échoue au moment où vous la configurez

ConfigurePdfParserResourceBudget valide avant d'assigner. Une valeur non positive dans l'une des six dimensions numériques lève immédiatement une exception, et un MaxDecodedBytes au-dessus de High(Integer) est rejeté comme dépassant les limites de plateforme plutôt que d'être tronqué en silence quelque part au fond d'un décodeur. Ça compte plus que ça n'en a l'air : un budget construit depuis un fichier de config avec une clé manquante vous donne des zéros, et un plafond d'objets à zéro accepté au démarrage se transforme en échec de parse mystifiant sur le premier document trois heures plus tard, au moment où personne ne suspecte la config. La même attention s'applique à tout code qui resserre le budget temporairement, y compris les tests. La politique est globale au processus, donc laisser une deadline de 4 secondes installée après la fin d'un fixture empoisonne tous les suivants ; sauvegardez et restaurez autour du changement

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

ResetPdfParserResourceBudget est la version plus brutale, restaurant carrément TPdfParserResourceBudget.Default. Utilisez-la dans le teardown de test où vous voulez un état connu, et utilisez sauvegarde-et-restauration dans le code de production, où l'état auquel vous devez revenir est ce que l'application hôte a configuré au démarrage

Là où le budget cesse d'aider

C'est une politique unique globale au processus, et c'est sa principale limitation. Vous ne pouvez pas donner à un thread de travail un profil strict et à un autre un profil permissif, car il n'y a qu'un seul record derrière une seule section critique ; si vous avez vraiment besoin de limites par tenant, la forme qui marche est de sérialiser chaque classe de politique sur son propre processus, ou de borner le travail par sauvegarde-et-restauration en acceptant que la parenthèse ne soit sûre que quand rien d'autre ne parse en parallèle. Ça borne aussi le parsing, pas le rendu : la mémoire de rastérisation, la croissance du cache de polices et le décodage d'images se tiennent hors de ces sept dimensions et ont besoin de leurs propres contrôles. Et un budget vous dit qu'une opération était trop chère, jamais pourquoi. Le plafond qui a sauté est le symptôme, et le tableau /Index mal formé ou la chaîne de filtres incontrôlée derrière doit encore être diagnostiqué à la main, ce qui explique pourquoi le chemin de décodage décrit dans le décodage des object streams et des prédicteurs vaut le coup d'être compris avant de bouger des nombres. Traitez le budget comme la chose qui empêche un mauvais document d'emporter le processus avec lui, et gardez un logging assez bon pour découvrir ce que le document faisait

Toute la surface d'API du parseur, le record de budget et un build en code source pour Delphi, C++Builder et Lazarus sont sur la page produit : PDFium Component