Article technique

Plage d'octets PDFium pour PDF intégrés en Delphi

Avec la vérification de débordement désactivée, 2000000000 + 2000000000 s'évalue à -294 967 296 quand les deux opérandes sont des Integer Delphi. Ce nombre cesse d'être une curiosité dès qu'un parseur de conteneur lit un décalage et une longueur dans un en-tête d'enregistrement et les additionne pour voir si un PDF incorporé tient dans le tampon. La somme boucle en négatif, la comparaison contre Length(Data) passe, et la ligne suivante prend l'adresse d'un octet à environ deux gigaoctets au-delà de la fin du tableau. Charger un PDF depuis le milieu d'un tampon plus grand demande une vérification de plage qui n'additionne jamais, et une réponse claire à la question de savoir qui possède les octets une fois le document ouvert

Cet article couvre les documents déjà en RAM, assis à un décalage connu à l'intérieur de quelque chose d'autre. Les documents trop volumineux pour tenir en mémoire sont un problème différent, traité en tirant des blocs du disque à la demande comme le décrit le streaming à la demande de gros PDF avec PDFium VCL. La dernière section trace la frontière

Un PDF incorporé à un décalage connu

Un magasin de messagerie garde les corps de message et les pièces jointes dans un seul enregistrement. Un conteneur d'archive concatène un manifeste, quelques images et un PDF. Un protocole filaire personnalisé encadre un document derrière un en-tête préfixé par une longueur. Supposons un enregistrement de 40 Mo, un PDF qui commence à l'octet 1 182 336 et court sur 312 Ko. Le code habituel appelle Copy(Data, Index, Count) pour couper une tranche, puis remet cette tranche à un chargeur qui la copie de nouveau dans son propre tampon. Conteneur compris, les mêmes 312 Ko existent alors trois fois, et un balayage de boîte aux lettres répète cela pour chaque message

PDFium Component adresse la fenêtre directement. TPdf déclare LoadDocument(const Data: TBytes; Index, Count: Integer; Buffered: Boolean = True), où Index est une position d'octet basée sur zéro et Count une longueur en octets. La méthode valide les deux nombres, calcule un unique pointeur, et transmet à la surcharge pointeur LoadDocument(Data: Pointer; Size: NativeUInt; Buffered: Boolean), qui ouvre le document via FPDF_LoadMemDocument64. La forme tableau entier LoadDocument(const Data: TBytes; Buffered: Boolean) n'est qu'un appel d'une ligne à la forme par plage avec 0 et Length(Data), si bien que tout chargement de TBytes passe par la même validation

Diagramme comparatif montrant l'ancien chemin à double copie pour un PDF incorporé dans un conteneur en Delphi face à la surcharge LoadDocument par plage d'octets du PDFium Component qui adresse la fenêtre sur place
Sans la surcharge, la même fenêtre de 312 Ko était copiée deux fois par memcpy ; l'appel par plage d'octets l'adresse sur place, et Buffered décide si quoi que ce soit se copie

Dans le code, la tranche disparaît tout simplement. LocateEmbeddedPdf représente votre propre parseur de conteneur

uses
  System.SysUtils, System.IOUtils, PDFium;

procedure PreviewEmbedded(const RecordFile: string);
var
  Frame: TBytes;           // enregistrement conteneur complet, des dizaines de mégaoctets
  Offset, Size: Integer;
  Pdf: TPdf;
begin
  Frame := TFile.ReadAllBytes(RecordFile);
  LocateEmbeddedPdf(Frame, Offset, Size);   // votre parseur, hors du composant

  Pdf := TPdf.Create(nil);
  try
    // Pas de Copy(Frame, Offset, Size) : la fenêtre est adressée sur place
    Pdf.LoadDocument(Frame, Offset, Size);  // Buffered vaut True par défaut
    Frame := nil;  // correct ici : seule la fenêtre PDF a été copiée
    Writeln('Pages: ', Pdf.PageCount);
  finally
    Pdf.Free;      // referme le document
  end;
end;

Quand le EnableV8Engine global est encore False et que la bibliothèque PDFium n'a pas encore été chargée, les chargeurs en mémoire balayent les octets entrants à la recherche de marqueurs XFA et activent EnableV8Engine s'ils en trouvent, si bien que la DLL capable de V8 se charge. Avec l'appel par plage d'octets, ce balayage ne voit que la fenêtre. Un manifeste ou un second document ailleurs dans le même enregistrement ne peut pas le déclencher

Pourquoi Index plus Count déborde-t-il le contrôle de limites ?

Parce que Index et Count sont tous deux des Integer signés sur 32 bits, et que l'addition de deux grandes valeurs positives ne produit pas de façon fiable un grand résultat positif. Quand le décalage et la longueur viennent d'un en-tête de fichier, un attaquant ou un enregistrement corrompu contrôle les deux. Le même piège vous attend dans votre propre parseur de conteneur, qui calcule ces nombres en premier

// Une aide pour votre propre parseur de conteneur (suggérée, hors du composant)
function IsValidWindow(const Data: TBytes; Index, Count: Integer): Boolean;
begin
  // FAUX : Index + Count est évalué en Integer et peut boucler en négatif
  //   Result := (Index >= 0) and (Index + Count <= Length(Data));

  // JUSTE : rejetez les signes, bornez Index seul, puis comparez Count
  // au reste ; la seule arithmétique est une soustraction qui ne peut pas boucler
  Result := (Index >= 0) and (Count >= 0) and
            (Index <= Length(Data)) and
            (Count <= Length(Data) - Index);
end;

Déroulez le cas qui échoue. Avec Index = 2000000000 et Count = 2000000000, la vraie somme est quatre milliards, mais l'arithmétique signée 32 bits la stocke en -294 967 296. Cette valeur est confortablement en dessous de tout Length(Data), donc la mauvaise vérification passe et @Data[Index] pointe loin hors du tableau, avec une longueur de deux gigaoctets attachée. Un crash est le bon dénouement. Le mauvais, c'est PDFium qui parse la mémoire de processus qui se trouve là par hasard

Le bon ordre n'additionne jamais. Les valeurs négatives sont rejetées avant que quoi que ce soit ne soit indexé. Index est ensuite borné contre Length(Data) seul, ce qui garantit que Length(Data) - Index est un Integer non négatif, et ce n'est qu'alors que Count est comparé à ce reste. Delphi évalue and avec une logique à court-circuit par défaut ({$B-}), si bien que chaque terme suivant ne s'exécute qu'une fois les précédents établis. Ne comptez pas sur {$Q+} comme filet de sécurité : les builds de release partent couramment avec la vérification de débordement désactivée, et avec elle activée vous n'avez échangé qu'un trou de sécurité mémoire contre un EIntOverflow qui s'échappe du milieu d'une routine de validation

Diagramme de flux contrastant la vérification de bornes qui déborde avec Index plus Count et l'ordre de validation par soustraction seule qui garde le chargement PDFium par plage d'octets sûr en mémoire en Delphi
Additionner d'abord déborde au-delà de MaxInt et défait la garde ; rejeter les signes et borner les termes avant une seule soustraction sans débordement garde chaque intermédiaire représentable

Le LoadDocument par plage d'octets exécute les mêmes quatre tests dans le même ordre et lève EPdfError avec un message nommant la règle en défaut : « PDF byte range index cannot be negative », « PDF byte range count cannot be negative », « PDF byte range index exceeds data length » et « PDF byte range exceeds data length ». Vous pouvez reproduire chacun avec un tableau de quatre octets. Index = 5, Count = 0 déclenche le troisième message, Index = 2, Count = 3 le quatrième, et après chaque rejet Pdf.Active reste False. Le même traitement des longueurs non fiables à travers la frontière native est couvert plus largement dans le durcissement de l'ABI PDFium VCL et de la sécurité mémoire en Delphi

Une fenêtre de longueur zéro reste un appel légal

Une fenêtre vide fait passer un pointeur nil, parce que @Data[Index] n'est pas une expression légale pour tous les Index que la validation accepte. Index = Length(Data) avec Count = 0 est une fenêtre vide bien formée à la queue du tampon, et un TBytes vide donne Index = 0 sur un tableau qui n'a pas d'élément zéro. Prendre l'adresse dans l'un ou l'autre cas lit au-delà de la fin ou déréférence un tableau dynamique nil, et avec la vérification de plage activée, Delphi lève ERangeError avant même que le chargeur ne tourne

La surcharge par plage d'octets se branche donc : Count = 0 donne nil, tout autre compte donne @Data[Index]. La surcharge pointeur n'accepte nil qu'accompagné d'une taille de zéro, PDFium décline alors d'ouvrir un tampon vide, et l'appel se termine par un EPdfError ordinaire portant le dernier texte d'erreur de PDFium, ou « Cannot load PDF document » quand PDFium n'en rapporte aucun. Un en-tête qui prétend une pièce jointe de zéro octet reçoit la même exception interceptable que toute autre mauvaise entrée. La rejeter dans votre parseur, avec le numéro d'enregistrement dans le message, reste plus lisible dans un journal de support

Emprunté ou copié : ce que décide Buffered

Buffered est le seul paramètre ici dont les conséquences survivent à l'appel. Avec Buffered = True, le composant copie la fenêtre sélectionnée, et seulement la fenêtre, dans son propre tampon avant le chargement. Dans l'exemple des 40 Mo, 312 Ko sont copiés et le conteneur reste tranquille. Une fois que LoadDocument revient, vous pouvez libérer, réutiliser ou écraser le conteneur immédiatement

Avec Buffered = False, @Data[Index] va tout droit vers FPDF_LoadMemDocument64, et PDFium continue de lire à travers ce pointeur tant que le document est ouvert. Tout le TBytes sous-jacent devient emprunté. Il doit rester vivant et non modifié jusqu'à ce que vous passiez Active := False ou libériez le TPdf. C'est le tableau entier qui compte, pas seulement la fenêtre : un tableau dynamique Delphi est à comptage de références comme un seul bloc, donc lâcher la dernière référence n'importe où dans votre code libère la mémoire que PDFium est encore en train de lire. SetLength est tout aussi fatal. Une réallocation peut déplacer le bloc

Deux détails Delphi rendent cela plus facile à rater qu'il n'y paraît. Les tableaux dynamiques n'ont pas de copie sur écriture, contrairement aux chaînes, si bien que Other := FFrame; Other[0] := 0; écrit dans les octets mêmes que PDFium est en train de parser. Et un tampon de réception que votre code réseau réutilise pour la trame suivante se fait écraser sous le document ouvert. Passer un résultat de fonction directement, comme dans Pdf.LoadDocument(ReadRecord(Id), Offset, Size, False), laisse le tableau détenu seulement par un temporaire caché qui meurt avec la routine appelante, pendant que le document, lui, continue de vivre

Diagramme chronologique des deux modes Buffered au chargement d'un PDF incorporé depuis une plage d'octets en Delphi : ne copier que la fenêtre PDF et libérer le conteneur immédiatement, ou emprunter tout le TBytes sous-jacent jusqu'à l'exécution d'UnloadDocument
Buffered = True ne copie que la fenêtre si bien que le conteneur est jetable, tandis que Buffered = False laisse tout le tableau sous-jacent emprunté jusqu'au déchargement

Gardez Buffered = True pour tout ce qui traverse un thread, se cache derrière un tampon réutilisable, ou survit à la routine qui l'a chargé. Le coût supplémentaire est une copie de la taille propre du PDF, et il achète la liberté de lâcher un enregistrement de 40 Mo dès que l'appel revient. L'emprunt ne gagne sa place que quand le conteneur est déjà un champ de longue durée de vie que vous détenez pour toute la session de toute façon, tel un visualiseur qui garde un enregistrement d'archive ouvert pendant que l'utilisateur feuillette la pièce jointe. Dans ce cas, rendez la propriété explicite dans une seule classe

type
  TFrameSession = class
  private
    FFrame: TBytes;   // possède le stockage sous-jacent tant que FPdf a un document ouvert
    FPdf: TPdf;
  public
    constructor Create(const AFrame: TBytes);
    destructor Destroy; override;
    procedure OpenEmbedded(Offset, Size: Integer);
  end;

constructor TFrameSession.Create(const AFrame: TBytes);
begin
  inherited Create;
  FFrame := AFrame;   // partage le tableau ; personne d'autre ne peut y écrire désormais
  FPdf := TPdf.Create(nil);
end;

procedure TFrameSession.OpenEmbedded(Offset, Size: Integer);
begin
  FPdf.Active := False;                            // refermer d'abord toute fenêtre précédente
  FPdf.LoadDocument(FFrame, Offset, Size, False);  // FFrame doit survivre à ce document
end;

destructor TFrameSession.Destroy;
begin
  FPdf.Active := False;  // 1. libérer l'emprunt
  FPdf.Free;
  FFrame := nil;         // 2. seulement maintenant le stockage peut partir
  inherited;
end;

Le constructeur partage le tableau de l'appelant au lieu de le copier, si bien que l'appelant doit cesser d'y écrire. Si vous ne pouvez pas le promettre, passez Copy(AFrame) au constructeur et vous voilà revenu à une copie, faite une fois par session au lieu d'une fois par chargement

Vérifier que la fenêtre commence à %PDF-

Une fenêtre décalée de quelques octets est l'échec subtil, parce que le document peut quand même s'ouvrir. ISO 32000-1 §7.5.4 définit chaque entrée de table de référence croisée comme un décalage d'octet depuis le début du fichier, et §7.5.2 place l'en-tête %PDF- au début. Quand votre parseur se trompe dans le compte d'un préfixe et que la fenêtre commence huit octets trop tôt, ces décalages sont faux de huit octets. PDFium cherche l'en-tête dans les 1024 premiers octets et compense le bruit de tête ; une fenêtre qui commence trop tard ou finit trop tôt peut le pousser à reconstruire la table de référence croisée depuis les marqueurs d'objets qu'il trouve. Dans les deux cas le nombre de pages a l'air bon. Le décalage dans votre parseur reste faux, et le prochain lecteur de cet enregistrement sera peut-être moins indulgent

La surcharge LoadDocument(const Data: TBytes; Index, Count: Integer; const Options: TPdfLoadOptions; out Report: TPdfLoadReport; Buffered: Boolean = True) rend cela visible. Report.HeaderOffset est l'endroit où %PDF- a été trouvé dans les 1024 premiers octets de la fenêtre, ou -1 quand il n'a pas été trouvé. Report.UsedRecovery vaut True quand PDFium n'a pas accepté la table de référence croisée comme valide. Tous les décalages du rapport se comptent depuis le premier octet de la fenêtre, pas depuis le conteneur. Un Count trop court coupe le %%EOF final, et TPdfLoadOptions.Default(plmStrict) active RequireFinalEndOfFileMarker, si bien que le mode strict rejette cette fenêtre sans appel

var
  Options: TPdfLoadOptions;
  Report: TPdfLoadReport;
begin
  Options := TPdfLoadOptions.Default;   // plmCompatible
  Pdf.LoadDocument(Frame, Offset, Size, Options, Report, True);

  // LogWarning est votre propre routine de journalisation
  if Report.HeaderOffset > 0 then
    LogWarning(Format('Window starts %d bytes before %%PDF-, check Offset',
      [Report.HeaderOffset]))
  else if Report.HeaderOffset < 0 then
    LogWarning('No %PDF- header in the first 1024 bytes of the window');

  if Report.UsedRecovery then
    LogWarning('Cross-reference table was rebuilt, check Offset and Size');
end;

L'audit derrière ces champs a un coût qui compte pour les chargements empruntés. Quand la fenêtre tient dans Options.AuditByteLimit (256 Mo par défaut), l'audit lit une copie temporaire de la fenêtre, si bien que Buffered = False combiné à un rapport n'est plus sans copie pendant le chargement. Mettre AuditByteLimit à 0 saute l'audit et la copie, et laisse HeaderOffset à -1. Faites tourner la forme auditée pendant que vous développez le parseur de conteneur et face à de nouvelles sources d'enregistrements ; passez à la surcharge simple une fois les décalages de confiance. Le reste du rapport est couvert dans la détection des échecs de chargement PDF avec un rapport de chargement PDFium

Quand la fenêtre par plage d'octets est le mauvais outil

La surcharge par plage d'octets suppose que tout le conteneur est déjà en mémoire, et comme Count est un Integer, une fenêtre ne peut pas dépasser MaxInt octets. Une archive de 6 Go sur disque, ou un socket que vous ne pouvez pas rembobiner, ne rentre pas dans cette forme, et tout lire dans un TBytes juste pour adresser une fenêtre à l'intérieur manque l'objectif. Pour ces cas, LoadCustomDocument(AStream: TStream; AOwnsStream: Boolean) laisse PDFium tirer des blocs d'un flux positionnable à la demande. Un petit descendant de TStream de votre cru qui ajoute le décalage de départ du PDF à chaque Seek présente le document incorporé comme s'il commençait à la position zéro, comme le montre l'article sur le streaming à la demande

L'autre inadéquation est le contenu qui n'est pas encore un PDF simple. Si les octets incorporés sont compressés, chiffrés par le conteneur, ou enveloppés dans un autre encodage, une copie transformée est inévitable, et charger ce nouveau tableau avec le Buffered = True par défaut est la réponse directe. La plage d'octets rapporte dans une seule forme : des octets PDF contigus, non modifiés, déjà résidents, à un décalage que votre parseur a validé sans additionner

L'appel par plage d'octets se tient à côté des chargements par fichier, flux, pointeur brut et à la demande dans le même composant TPdf. La page produit PDFium Component liste les versions Delphi et C++Builder prises en charge, les options de licence et le reste de l'API de chargement