Articolo tecnico

Binding PDFium in Delphi: ABI e sicurezza della memoria

Un binding Pascal verso una libreria C si legge come Pascal normale: chiami un metodo, ricevi un record, liberi ciò che hai allocato. Il problema è che PDFium è una libreria C e C++ con la sua convenzione di chiamata, le sue larghezze intere e le sue regole su chi possiede la memoria e chi la rilascia. Tutto questo non attraversa da solo il confine tra i linguaggi. Ogni contratto va riscritto a mano nelle dichiarazioni Pascal, e una sola parola sbagliata trasforma una chiamata dall'aspetto pulito in una corruzione dello stack, un offset troncato o un double free. Ogni contratto qui sotto arriva con il modo classico di sbagliare e con la forma che il binding PDFium Component usa al suo posto. Vale la pena passarli in rassegna perché non sono specifici di questo binding: sono i pericoli permanenti del wrappare qualsiasi API C in Delphi o Lazarus

cdecl fa parte del tipo di funzione, non è una decorazione

PDFium è compilato in C. Su Win32 le sue funzioni esportate e, soprattutto, le callback da esso richiamate utilizzano la convenzione di chiamata cdecl. Con cdecl, il chiamante pulisce lo stack dopo il ritorno della chiamata. L'impostazione nativa predefinita di Delphi è register, e lo standard C Win32 per le callback è stdcall in alcune librerie, in cui è invece il chiamato a pulire lo stack. Quando una struttura passa a PDFium un puntatore a funzione e si omette la convenzione cdecl sul tipo di tale puntatore, le due parti non concordano su chi debba regolare il puntatore dello stack. Entrambi lo modificano, o nessuno dei due lo fa, e il puntatore dello stack devia della dimensione degli argomenti a ogni chiamata

Se questo errore è difficile da trovare, il motivo è che il danno non è locale: la chiamata corrotta ritorna e sembra a posto. Il disallineamento si manifesta più tardi, in qualche funzione senza relazione il cui frame ora poggia su uno stack pointer spostato di pochi byte, e si mostra come una lettura selvaggia, un indirizzo di ritorno sbagliato o un crash con un backtrace che punta lontanissimo dalla callback che hai sbagliato davvero. Il form-fill è il posto classico dove la cosa morde, perché l'interfaccia di form-fill è un record pieno di callback che PDFium richiama a sua volta. Una di queste, FFI_OpenFile, passa a PDFium la funzione da chiamare per aprire un file esterno, dichiarata come function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. Il cdecl in coda è il dettaglio che vale la pena copiare. Toglilo e il codice compila comunque, linka comunque e gira correttamente fino a quando PDFium non chiama la funzione. La convenzione appartiene al tipo funzione in sé. Non è zucchero opzionale, e il compilatore non ti avverte quando manca, perché un tipo funzione semplice è un tipo Pascal perfettamente legale. L'unica difesa è trattare la convenzione di chiamata come un campo obbligatorio di ogni firma importata e di ogni callback che passi verso l'esterno

Diagramma che contrappone una callback cdecl che mantiene bilanciato lo stack di proprietà del chiamante PDFium con una cdecl mancante che fa derivare il puntatore allo stack in un crash distante
cdecl appartiene al tipo della funzione, e rimuoverlo fa derivare il puntatore allo stack finché qualche frame non correlato va in crash

size_t corrisponde alla larghezza del puntatore, e su FPC Win64 equivale a 64 bit

Il secondo pericolo è una discrepanza nella larghezza degli interi che compare su un solo target. Il size_t del C è definito abbastanza largo da contenere qualsiasi dimensione di oggetto, il che su una piattaforma a 64 bit significa un intero senza segno a 64 bit. Le interfacce di caricamento progressivo di PDFium parlano in offset di byte size_t. Il record FX_FILEAVAIL del provider di disponibilità porta una callback IsDataAvail che PDFium invoca con un offset e una dimensione, e la callback AddSegment del record FX_DOWNLOADHINTS riceve gli stessi parametri. Entrambi sono size_t

IsDataAvail = function(
  pThis       : PFX_FILEAVAIL;
  offset, size: size_t): FPDF_BOOL; cdecl;

AddSegment = procedure(
  pThis       : PFX_DOWNLOADHINTS;
  offset, size: size_t); cdecl;

Se dichiari quegli offset come tipo a 32 bit, il binding funziona su Win32 e su Delphi Win64, poi si rompe in silenzio su FPC e Lazarus Win64. La causa è sottile. Su FPC Win64 NativeUInt è un vero tipo a 64 bit, largo quanto un puntatore, e size_t è aliasato a esso. Il binding ha un commento nella sezione dei tipi che mette in guardia proprio dal fare shadowing di NativeUInt su FPC, perché ridefinirlo lì come alias a 32 bit forzerebbe size_t a 32 bit e corromperebbe ogni parametro size_t passato alla libreria o scritto da essa. Un offset a 64 bit che arriva su un parametro a 32 bit perde la metà alta. Con un file piccolo ogni offset entra in 32 bit e nulla si rompe. Con un file grande, appena un offset supera la linea dei quattro gigabyte il valore troncato punta da tut'altra parte, PDFium chiede se è disponibile l'intervallo di byte sbagliato, e il caricamento progressivo si blocca o legge spazzatura. L'errore resta invisibile finché il file non è abbastanza grande e il target non è proprio quello in cui size_t si è davvero allargato

Diagramma PDFium Component di un offset size_t di cinque gigabyte che sopravvive come valore a 64 bit completo ma va in wrap a circa un gigabyte quando un alias a 32 bit restringe il parametro su FPC Win64
Un parametro size_t forzato a 32 bit tronca gli offset oltre i quattro gigabyte e manda il caricamento progressivo verso l'intervallo di byte sbagliato

Un'eccezione Pascal non deve mai propagarsi attraverso un frame C

La terza classe riguarda il modello delle eccezioni, assente in C. Quando PDFium chiama una delle tue callback, il codice Pascal viene eseguito in uno stack di frame C e C++ che non dispongono del sistema di gestione delle eccezioni di Delphi. Se la callback solleva un'eccezione e la lascia propagare, questa risale attraverso frame che non sono predisposti a questa operazione. Le operazioni di pulizia interne di PDFium non vengono eseguite, le sue variabili interne rimangono parzialmente aggiornate e il processo si trova in uno stato non previsto dalla libreria. Il contratto per queste callback prevede un codice di ritorno, non un'eccezione

Due callback rendono concreta questa situazione. FPDF_FILEWRITE è il canale in cui PDFium scrive un documento salvato, e FPDF_FILEACCESS è la sorgente da cui legge un documento in ingresso. Entrambi sono implementati qui su un oggetto TStream di Delphi, ed entrambi possono fallire per le ragioni tipiche degli stream: il disco è pieno, lo stream viene chiuso inaspettatamente o una lettura supera il limite finale. La callback di scrittura racchiude la scrittura dello stream e converte qualsiasi errore nel codice di errore di PDFium anziché lasciarlo propagare

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium tratta qualsiasi valore di ritorno diverso da 1 come un errore di scrittura. Un'eccezione Pascal
  // non deve propagarsi attraverso questo frame cdecl/C++, quindi intercettala e segnala
  // l'errore invece.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

La parte di lettura si comporta allo stesso modo: una lettura non riuscita restituisce zero per rispettare il contratto di FPDF_FILEACCESS anziché sollevare un'eccezione oltre il confine. Un blocco except vuoto senza un rilancio dell'eccezione appare errato a un programmatore Pascal abituato a non ignorare mai gli errori, e nel normale codice Pascal lo è. Presso un confine ABI rappresenta la struttura corretta, poiché l'unico valore sicuro da restituire al chiamante C è un codice di stato che questo sia in grado di interpretare. L'errore viene comunque propagato tramite il valore di ritorno, e il codice chiamante a monte della libreria lo presenterà come EPdfError non appena il controllo tornerà al codice Pascal

La doppia liberazione (double free) si nasconde nel percorso di errore

Il quarto pericolo è la proprietà (ownership). Un handle di documento PDFium viene aperto dalla libreria e deve essere chiuso esattamente una volta, con FPDF_CloseDocument. Il pericolo è un percorso di errore che libera un handle che anche un'altra routine di pulizia possiede. Immagina una routine che crea un oggetto wrapper, gli assegna un handle di documento appena aperto e poi esegue altro setup che può fallire. Se il setup lancia, un gestore di uscita anticipata che chiama FPDF_CloseDocument sull'handle grezzo lo chiude, e poi il distruttore dello stesso wrapper lo richiuderà quando l'oggetto verrà liberato. L'handle viene liberato due volte, il che è comportamento indefinito e un probabile crash

Il pattern conta di più sul percorso di import in stile imposizione che costruisce un TPdf attorno a un handle già aperto. La cura consiste nel fare del trasferimento di proprietà l'unica fonte di verità. Una volta che l'handle è assegnato al campo del wrapper, lo possiede il wrapper, e l'unica pulizia sul percorso di errore è liberare il wrapper. Il distruttore del wrapper chiama FPDF_CloseDocument per te, quindi una seconda chiusura esplicita farebbe double free dello stesso documento. Il gestore di errori qui sotto libera l'oggetto e rilancia, e verso la chiusura porta esattamente un solo percorso

Diagramma di un percorso di errore difettoso che chiude due volte un handle documento PDFium accanto a una correzione a proprietario unico dove liberare il wrapper chiude l'handle esattamente una volta
Trasferire l'handle nel wrapper ne fa del distruttore l'unico percorso verso FPDF_CloseDocument
Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result ora possiede l'handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free chiude l'handle. Una seconda FPDF_CloseDocument(NewDoc)
  // qui libererebbe due volte lo stesso documento PDFium.
  Result.Free;
  raise;
end;

Sia i record gestiti sia una libreria ricca di funzioni esportate richiedono una disattivazione esplicita

L'ultima classe riguarda la memoria gestita dal compilatore al posto tuo, che l'abitudine al linguaggio C potrebbe compromettere in silenzio. Molte funzioni di supporto di questo binding restituiscono un record che contiene un tipo WideString o un array dinamico. Si tratta di campi con conteggio dei riferimenti, per i quali il compilatore genera operazioni nascoste per mantenerne aggiornati i valori. L'istinto derivato dal C spinge a pulire un record vuoto con FillChar(Result, SizeOf(Result), 0). Questa istruzione inserisce zeri sul riferimento gestito all'interno del record senza prima decrementarlo. Il compilatore riutilizza una variabile temporanea nascosta per il risultato della funzione tra le varie iterazioni del ciclo, quindi alla seconda iterazione FillChar sovrascrive un puntatore di stringa attivo che non è mai stato rilasciato, causando una perdita (leak) della stringa. Chiamando la funzione in un ciclo su mille annotazioni si causerà la perdita di mille stringhe

La soluzione consiste nel lasciare che il linguaggio pulisca il record nel modo corretto tramite Default(T), che rilascia ogni campo gestito prima di azzerarlo

// Default() invece di FillChar: il compilatore riutilizza una temp nascosta per
// il risultato della funzione tra le iterazioni del ciclo, quindi FillChar azzererebbe
// puntatori WideString attivi senza rilasciarli.
Result := Default(TPdfAnnotation);

Un problema correlato di proprietà risiede nel confine di caricamento della libreria. Questo binding risolve diverse centinaia di puntatori a funzione dalla DLL PDFium tramite GetProcAddress dopo una chiamata a LoadLibrary. Se manca una funzione esportata richiesta, lo stato parzialmente collegato è rischioso: decine di puntatori sono validi, i restanti sono nil o obsoleti, e qualsiasi chiamata successiva tramite uno di essi potrebbe indirizzare a un modulo già scaricato. Il binding gestisce questa situazione scaricando la libreria ed eseguendo una pulizia completa tramite ClearAllBindings, che reimposta a nil ogni puntatore importato qualora una funzione esportata richiesta non venga risolta. In questo modo, nessun puntatore a funzione farà riferimento a un modulo scaricato, e le chiamate successive falliranno in modo controllato con una verifica del puntatore nullo anziché indirizzare a porzioni di codice già liberate

Il wrapper è il punto in cui quattro contratti vengono ridefiniti manualmente

Nessuno di questi cinque pericoli è esotico. Sono le modalità di guasto prevedibili di un sottile strato Pascal sopra una API C, e si concentrano proprio lì perché quello strato è il punto in cui quattro contratti separati vanno ridichiarati. La convenzione di chiamata va scritta cdecl su ogni callback. La larghezza degli interi deve corrispondere a size_t sull'unico target in cui si allarga davvero. Il modello delle eccezioni va convertito in codici di ritorno a ogni callback che esce dal Pascal. La proprietà di ogni handle e di ogni campo gestito va dichiarata una volta e rispettata su ogni percorso, compresi i percorsi di errore che nessuno esercita finché non si è in produzione. Perdine uno e ottieni un guasto il cui sintomo compare lontano dalla causa, ed è questo che rende costosa questa categoria. Il valore sta meno nelle singole regole che nel trattare ciascuna di queste come una disciplina a sé, da verificare su tutto il binding

Se desideri vedere il binding in azione piuttosto che l'analisi delle sue protezioni, le tecniche di cache di rendering e zoom descritte nella nostra nota sulle prestazioni di cache di rendering e zoom mostrano il percorso di rendering, e la guida alla compilazione multipiattaforma per la creazione di un visualizzatore con Lazarus e FPC è la sezione in cui il comportamento di size_t su Win64 qui analizzato ha un impatto reale. Entrambi si basano sulle stesse soluzioni di sicurezza della memoria e ABI incluse nel componente PDFium Component per Delphi, Lazarus e C++Builder, insieme alle API di rendering, estrazione del testo e gestione dei moduli descritte in altre sezioni di questo blog