Um binding Pascal para uma biblioteca C lê-se como Pascal vulgar. Chama-se um método, recebe-se um registo (record) de volta, liberta-se o que foi alocado. O problema é que o PDFium é uma biblioteca C e C++ com a sua própria convenção de chamada, as suas próprias larguras de inteiros e as suas próprias regras sobre a quem pertence a memória e quem a liberta. Nada disso atravessa a fronteira da linguagem por si só. Cada um destes contratos tem de ser reafirmado manualmente nas declarações Pascal, e uma única palavra errada transforma uma chamada de aspeto limpo numa corrupção de stack, num deslocamento (offset) truncado ou numa libertação dupla (double free). Uma auditoria v1.61.0 a um binding VCL do PDFium revelou um defeito de cada tipo. Vale a pena analisá-los porque não são específicos deste binding. São os riscos permanentes de envolver qualquer API C em Delphi ou Lazarus.
cdecl faz parte do tipo de função, não é uma decoração
O PDFium é C compilado. Em Win32, os seus exports e, mais importante, los callbacks que invoca utilizam a convenção de chamada cdecl. Em cdecl, quem chama limpa a stack após o retorno da chamada. O padrão nativo do Delphi é register, e o padrão C em Win32 para callbacks é stdcall nalgumas bibliotecas, onde é o chamado a efetuar a limpeza. Quando uma estrutura entrega um ponteiro de função ao PDFium e se esquece do cdecl no tipo desse ponteiro, os dois lados discordam sobre quem ajusta o ponteiro da stack. Ou ambos o corrigem, ou nenhum o faz, e o ponteiro da stack desvia-se pelo tamanho dos argumentos a cada invocação.
A razão pela qual este defeito é difícil de encontrar é que o dano não é local. A chamada corrompida retorna e parece correta. O desalinhamento surge mais tarde, numa função não relacionada cujo frame assenta agora num ponteiro de stack desviado por alguns bytes, manifestando-se como uma leitura incorreta, um endereço de retorno corrompido ou um crash com um backtrace que não aponta para lado nenhum perto do callback que realmente errou. O preenchimento de formulários (form-fill) é o local clássico onde isto afeta, porque a interface de preenchimento é um registo cheio de callbacks para os quais o PDFium liga de volta. Um deles, FFI_OpenFile, entrega ao PDFium uma função que será chamada para abrir um ficheiro externo, declarada como function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. O cdecl final é o detalhe que vale a pena copiar. Remova-o e o código continua a compilar, a ligar e a correr até ao momento em que o PDFium chama a função. A convenção pertence ao próprio tipo de função. Não é um adereço opcional, e o compilador não o alertará para a sua ausência porque um tipo de função simples é um tipo Pascal perfeitamente legal. A única defesa é tratar a convenção de chamada como um campo obrigatório de cada assinatura importada e de cada callback que passa para o exterior.
size_t tem a largura de um ponteiro, e em FPC Win64 isso significa 64 bits
O segundo defeito é uma incompatibilidade de largura de inteiros que apenas surge num alvo. O size_t do C está definido para ser suficientemente largo para conter qualquer tamanho de objeto, o que numa plataforma de 64 bits significa um inteiro de 64 bits sem sinal. As interfaces de carregamento progressivo do PDFium comunicam em deslocamentos (offsets) de bytes do tipo size_t. O registo FX_FILEAVAIL do fornecedor de disponibilidade contém um callback IsDataAvail que o PDFium chama com um deslocamento e um tamanho, e o callback AddSegment do registo FX_DOWNLOADHINTS recebe o mesmo. Ambos os parâmetros são 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 declarar estes deslocamentos como um tipo de 32 bits, o binding funciona em Win32 e em Delphi Win64, mas falha silenciosamente em FPC e Lazarus Win64. A causa é subtil. Em FPC Win64, NativeUInt é um tipo genuíno de 64 bits com a largura de um ponteiro, e size_t tem um alias para ele. O binding contém um comentário na secção de tipos alertando precisamente contra a ocultação de NativeUInt em FPC, porque redefini-lo para um alias de 32 bits aí forçaria o size_t para 32 bits e corromperia todos os parâmetros size_t passados para ou escritos pela biblioteca. Um deslocamento de 64 bits que chegue a um parâmetro de 32 bits perde a sua metade superior. Para um ficheiro pequeno, cada deslocamento cabe em 32 bits e nada está errado. Para um ficheiro grande, no momento em que um deslocamento cruza a linha dos quatro gigabytes, o valor truncado aponta para um local completamente diferente, o PDFium questiona se a gama de bytes errada está disponível, e o carregamento progressivo bloqueia ou lê dados corrompidos. O defeito é invisível até que o ficheiro seja suficientemente grande e o alvo seja aquele em que o size_t realmente alargou.
Uma exceção Pascal nunca deve desenrolar (unwind) através de um frame C
A terceira classe diz respeito ao modelo de exceções, que a linguagem C não possui. Quando o PDFium chama um dos seus callbacks, o seu código Pascal corre dentro de uma stack de frames C e C++ que não sabem nada sobre o mecanismo de exceções do Delphi. Se o seu callback gerar e deixar propagar a exceção, esta desenrola-se através de frames que nunca foram construídos para serem desenrolados. A própria limpeza do PDFium não é executada, as suas invariantes internas ficam parcialmente atualizadas e o processo entra num estado que a biblioteca nunca previu. O contrato para estes callbacks é um código de retorno, não uma exceção.
Dois callbacks tornam isto concreto. FPDF_FILEWRITE é o destino onde o PDFium escreve um documento guardado, e FPDF_FILEACCESS é a origem a partir da qual lê um documento de entrada. Ambos estão implementados aqui sobre um TStream do Delphi, e ambos podem falhar da mesma forma que qualquer fluxo falha: o disco fica cheio, o fluxo é fechado por baixo de si, uma leitura ultrapassa o fim. O callback de escrita envolve a escrita do fluxo e converte qualquer falha no código de falha do PDFium, em vez de a deixar escapar.
function WriteBlock(
pThis: PFPDF_FILEWRITE;
pData: Pointer;
Size : LongWord): Integer; cdecl;
begin
// PDFium treats any non-1 return as a write failure. A Pascal exception
// must not unwind through this cdecl/C++ frame, so trap it and report
// failure instead.
Result := 0;
try
PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
Result := 1;
except
end;
end;
O lado da leitura faz o mesmo: uma leitura falhada reporta zero para corresponder ao contrato de FPDF_FILEACCESS, em vez de gerar um erro através da fronteira. Um except vazio sem relançamento parece incorreto para um programador Pascal treinado para nunca silenciar exceções, e em Pascal comum é incorreto. Numa fronteira ABI tem o formato correto, porque o único valor seguro a devolver ao chamador C é um código de estado que ele saiba interpretar. A falha continua a propagar-se, mas através do valor de retorno, e o código chamador acima da biblioteca expõe-na como EPdfError assim que o controlo regressa ao lado Pascal.
A libertação dupla esconde-se no caminho de erro
O quarto defeito é a propriedade (ownership)
Um handle de documento PDFium é aberto pela biblioteca e tem de ser fechado exatamente uma vez, através de FPDF_CloseDocument. O perigo é um caminho de erro que liberte um handle que uma segunda rotina de limpeza também possui. Imagine uma rotina que cria um objeto wrapper, atribui-lhe um handle de documento acabado de abrir e depois efetua mais configurações que podem falhar. Se a configuração falhar lançando uma exceção, um manipulador de retorno antecipado que chame FPDF_CloseDocument no handle simples fechá-lo-á, e depois o próprio destrutor do objeto wrapper fechá-lo-á novamente quando o objeto for libertado. O handle é libertado duas vezes, o que constitui comportamento indefinido e provoca provavelmente um crash.
A auditoria encontrou esta situação num caminho de importação do tipo imposição que cria um TPdf em torno de um handle já aberto. A correção consiste em tornar a transferência de propriedade na única fonte de verdade. Assim que o handle é atribuído ao campo do wrapper, o wrapper passa a ser o proprietário, e a única limpeza no caminho de erro é libertar o wrapper. O destrutor do wrapper chama FPDF_CloseDocument por si, pelo que um segundo fecho explícito libertaria em duplicado o mesmo documento. O manipulador de erro corrigido liberta o objeto e relança a exceção, existindo exatamente um caminho para o fecho.
Result := TPdf.Create(nil);
try
Result.FDocument := NewDoc; // Result now owns the handle
Result.InitializeFormFill;
Result.ReloadPage;
except
// Result.Free closes the handle. A second FPDF_CloseDocument(NewDoc)
// here would double-free the same PDFium document.
Result.Free;
raise;
end;
Os registos geridos e uma biblioteca cheia de exports necessitam ambos de desmontagem explícita
A última classe diz respeito a memória que o compilador gere em seu nome, que um hábito de C corrompe silenciosamente. Muitas das funções auxiliares deste binding retornam um registo (record) que contém uma WideString ou um array dinâmico. Estes são campos com contagem de referências, e o compilador emite rotinas ocultas para manter as suas contagens. O instinto herdado de C é limpar um registo novo com FillChar(Result, SizeOf(Result), 0). Isso preenche com zeros a referência gerada dentro do registo sem a decrementar primeiro. O compilador reutiliza uma variável temporária oculta para o resultado de uma função ao longo das iterações do ciclo, de modo que na segunda iteração o FillChar sobrescreve um ponteiro de string ativo que nunca foi libertado, originando uma fuga da string para a qual ele apontava. Chame a função num ciclo sobre mil anotações e libertará mil strings.
A correção consiste em deixar a linguagem limpar o registo da forma que conhece, com Default(T), que liberta qualquer campo gerido antes de o preencher com zeros.
// Default() instead of FillChar: the compiler reuses one hidden temp for
// the function result across loop iterations, so FillChar would zero live
// WideString pointers without releasing them.
Result := Default(TPdfAnnotation);
Um problema de propriedade relacionado reside no limite do carregamento da biblioteca. Este binding resolve várias centenas de ponteiros de funções da DLL do PDFium com GetProcAddress após um LoadLibrary. Se faltar um export obrigatório, o estado parcialmente vinculado é perigoso: dezenas de ponteiros são válidos, o resto são nil ou desatualizados, e qualquer chamada posterior através de um deles salta para um módulo que já pode estar descarregado. O binding trata isto descarregando a biblioteca e executando um ClearAllBindings completo que redefine cada ponteiro importado para nil sempre que a resolução de um export obrigatório falha. Depois disso, nenhum ponteiro de função fica pendente para um módulo descarregado, e uma chamada posterior falha de forma limpa com uma verificação de ponteiro nulo (nil-pointer), em vez de se desviar para código libertado.
O wrapper é onde quatro contratos são reafirmados manualmente
Nenhum destes cinco defeitos é exótico. São os modos de falha previsíveis de uma fina camada Pascal sobre uma API C, e agrupam-se porque essa camada é exatamente onde quatro contratos separados têm de ser declarados novamente. A convenção de chamada tem de ser explicitada como cdecl em cada callback. A largura do inteiro tem de corresponder a size_t no único alvo onde realmente alarga. O modelo de exceções tem de ser convertido em códigos de retorno em cada callback que saia do Pascal. A propriedade de cada handle e de cada campo gerido tem de ser definida uma vez e respeitada em todos os caminhos, incluindo os caminhos de erro que ninguém exercita até à produção. Falhe em qualquer um deles e obterá um defeito cujo sintoma surge longe da sua causa, o que torna esta categoria dispendiosa. O valor da auditoria residiu menos em qualquer correção individual do que em tratar cada uma delas como a sua própria disciplina a verificar em todo o binding.
If you want to see the binding doing real work rather than guarding its edges, the render-cache and zoom techniques in our note on render-cache and zoom performance show the rendering path, and the cross-compiler walkthrough in building a Lazarus and FPC viewer is the place the Win64 size_t behavior described here actually matters. Both build on the same memory-safety and ABI work that ships in the PDFium Component for Delphi, Lazarus, and C++Builder alongside the rendering, text-extraction, and form APIs covered elsewhere on this blog.