Technical Article

Fortalecendo um binding PDFium VCL: ABI e segurança de memória

Um binding Pascal sobre uma biblioteca C parece com Pascal comum. Você chama um método, recebe um registro de volta, libera o que alocou. O problema é que o PDFium é uma biblioteca C e C++ com sua própria convenção de chamada, suas próprias larguras de inteiros e suas próprias regras sobre quem possui a memória e quem a libera. Nada disso cruza a fronteira da linguagem por si só. Cada um desses contratos precisa ser reafirmado manualmente nas declarações Pascal, e uma única palavra incorreta transforma uma chamada de aparência limpa em uma corrupção de pilha, um deslocamento truncado ou uma liberação dupla (double free). Uma auditoria v1.61.0 de um binding PDFium VCL revelou um defeito de cada tipo. Vale a pena examiná-los porque eles não são específicos deste binding. Eles são os riscos permanentes de envelopar qualquer API C no Delphi ou Lazarus.

cdecl faz parte do tipo de função, não é uma decoração

O PDFium é C compilado. No Win32, suas exportações e, mais importante, os callbacks que ele invoca utilizam a convenção de chamada cdecl. Sob a cdecl, o chamador limpa a pilha após o retorno da chamada. O padrão nativo do Delphi é register, e o padrão C do Win32 para callbacks é stdcall em algumas bibliotecas, onde o receptor (callee) limpa a pilha. Quando uma estrutura entrega ao PDFium um ponteiro de função e você esquece o cdecl no tipo desse ponteiro, os dois lados discordam sobre quem ajusta o ponteiro da pilha. Ambos corrigem, ou nenhum corrige, e o ponteiro da pilha desvia 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 normal. O desalinhamento aparece mais tarde, em alguma função não relacionada cujo frame agora reside em um ponteiro de pilha que está alguns bytes fora do lugar, manifestando-se como uma leitura incorreta, um endereço de retorno corrompido ou uma falha com um backtrace que não aponta para perto do callback que você realmente errou. O preenchimento de formulários (form-fill) é o local clássico onde isso acontece, porque a interface de preenchimento é um registro repleto de callbacks que o PDFium aciona. Um deles, o FFI_OpenFile, entrega ao PDFium uma função que ele chamará para abrir um arquivo externo, declarada como function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. O cdecl no final é o detalhe que vale a pena copiar. Remova-o e o código ainda compilará, criará o link e rodará normalmente até que o PDFium chame a função. A convenção pertence ao próprio tipo da função. Não é um detalhe opcional, e o compilador não avisará quando estiver ausente 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 você passa para fora.

size_t possui a largura do ponteiro e no FPC Win64 isso significa 64 bits

O segundo defeito é uma incompatibilidade de largura de inteiros que aparece em apenas um alvo. O size_t do C é definido para ser amplo o suficiente para armazenar qualquer tamanho de objeto, o que em uma plataforma de 64 bits significa um inteiro não assinado de 64 bits. As interfaces de carregamento progressivo do PDFium se comunicam em deslocamentos de bytes do tipo size_t. O registro FX_FILEAVAIL do provedor de disponibilidade carrega um callback IsDataAvail que o PDFium chama com um deslocamento e um tamanho, e o callback AddSegment do registro 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 você declarar esses deslocamentos como um tipo de 32 bits, o binding funcionará no Win32 e no Delphi Win64, mas quebrará silenciosamente no FPC e Lazarus Win64. A causa é sutil. No FPC Win64, o NativeUInt é um tipo genuíno de 64 bits com a largura de um ponteiro, e o size_t é um alias dele. O binding possui um comentário na seção de tipos alertando exatamente contra a sobreposição do NativeUInt no FPC, porque redefini-lo para um alias de 32 bits forçaria o size_t a 32 bits e corromperia cada parâmetro size_t passado para ou gravado pela biblioteca. Um deslocamento de 64 bits que chega a um parâmetro de 32 bits perde sua metade superior. Para um arquivo pequeno, cada deslocamento cabe em 32 bits e nada dá errado. Para um arquivo grande, no momento em que um deslocamento cruza a linha de quatro gigabytes, o valor truncado aponta para outro lugar inteiramente diferente, o PDFium pergunta se a faixa de bytes errada está disponível, e o carregamento progressivo trava ou lê dados corrompidos. O defeito é invisível até que o arquivo seja grande o suficiente e o alvo seja aquele em que o size_t realmente aumentou de tamanho.

Uma exceção Pascal nunca deve se propagar por um frame C

A terceira classe diz respeito ao modelo de exceções, que o C não possui. Quando o PDFium chama um de seus callbacks, seu código Pascal roda dentro de uma pilha de frames C e C++ que não sabem nada sobre o mecanismo de exceção do Delphi. Se o seu callback gerar e permitir que a exceção se propague, ela se propagará (unwind) por frames que nunca foram construídos para isso. A própria limpeza do PDFium não será executada, seus invariantes internos ficarão parcialmente atualizados e o processo entrará em um estado que a biblioteca nunca previu. O contrato para esses callbacks é um código de retorno, não uma exceção.

Dois callbacks tornam isso concreto. O FPDF_FILEWRITE é o destino onde o PDFium grava um documento salvo, e o FPDF_FILEACCESS é a origem da qual ele lê um documento de entrada. Ambos são implementados aqui sobre um TStream do Delphi, e ambos podem falhar da forma que qualquer stream falha: o disco enche, o fluxo é fechado por baixo de você, uma leitura ultrapassa o final. O callback de gravação envelopa sua gravação no stream e transforma qualquer falha no código de falha do PDFium, em vez de deixá-la 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 com falha retorna zero para corresponder ao contrato do FPDF_FILEACCESS em vez de gerar um erro através da fronteira. Um bloco except vazio sem re-raise parece errado para um programador Pascal treinado para nunca omitir exceções, e no Pascal comum de fato está errado. Em uma fronteira ABI, no entanto, é o formato correto, porque o único valor seguro para retornar ao chamador C é um código de status que ele saiba interpretar. A falha ainda se propaga, mas através do valor de retorno, e o código chamador acima da biblioteca a exibe como EPdfError assim que o controle retorna ao lado Pascal.

A liberação dupla se esconde no caminho do erro

O quarto defeito é a propriedade (ownership). Um handle de documento PDFium é aberto pela biblioteca e deve ser fechado exatamente uma vez, por FPDF_CloseDocument. O perigo é um caminho de erro que libera um handle que uma segunda limpeza também possui. Imagine uma rotina que cria um objeto envelopador, atribui um handle de documento recém-aberto a ele e, em seguida, faz mais configurações que podem falhar. Se a configuração gerar um erro, um manipulador de retorno antecipado que chama FPDF_CloseDocument no handle bruto o fechará, e o próprio destrutor do objeto envelopador o fechará novamente quando o objeto for liberado. O handle é liberado duas vezes (double free), o que é um comportamento indefinido e uma provável falha catastrófica.

A auditoria encontrou isso em um caminho de importação no estilo imposição que cria um TPdf em torno de um handle já aberto. A correção é fazer com que a transferência de propriedade seja a única fonte de verdade. Uma vez que o handle é atribuído ao campo do envelopador, o envelopador passa a possuí-lo, e a única limpeza no caminho do erro é liberar o envelopador. O destrutor do envelopador chama FPDF_CloseDocument para você, de modo que um segundo fechamento explícito causaria a liberação dupla do mesmo documento. O manipulador de erro corrigido libera o objeto e faz o re-raise, existindo exatamente um caminho para o fechamento.

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;

Registros gerenciados e uma biblioteca repleta de exportações precisam de encerramento explícito

A última classe trata da memória que o compilador gerencia para você, a qual um hábito de C corromperá silenciosamente. Muitas das funções auxiliares deste binding retornam um registro que contém uma WideString ou um array dinâmico. Esses são campos com contagem de referências, e o compilador emite um controle oculto para manter essas contagens. O instinto herdado do C é limpar um registro novo com FillChar(Result, SizeOf(Result), 0). Isso grava zeros sobre a referência gerenciada dentro do registro sem decrementá-la primeiro. O compilador reutiliza uma variável temporária oculta para o resultado de uma função ao longo das iterações de um loop, de modo que, na segunda iteração, o FillChar sobrescreve um ponteiro de string ativo que nunca foi liberado, e a string para a qual ele apontava vaza. Chame a função em um loop com mil anotações e você vazará mil strings.

A correção é permitir que a linguagem limpe o registro da maneira que ela sabe fazer, com Default(T), o que libera qualquer campo gerenciado antes de zerá-lo.

// 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 de carregamento da biblioteca. Este binding resolve centenas de ponteiros de função da DLL do PDFium com GetProcAddress após um LoadLibrary. Se uma exportação obrigatória estiver ausente, o estado parcialmente vinculado é perigoso: dezenas de ponteiros são válidos, o restante é nil ou desatualizado, e qualquer chamada posterior por meio de um deles salta para um módulo que já pode estar descarregado. O binding trata isso descarregando a biblioteca e executando um ClearAllBindings completo que redefine cada ponteiro importado de volta para nil sempre que uma exportação obrigatória falha em ser resolvida. Depois disso, nenhum ponteiro de função fica pendente em um módulo descarregado, e uma chamada posterior falha de forma limpa com uma verificação de ponteiro nulo (nil-pointer), em vez de desviar para um código já liberado.

O envelopador é onde quatro contratos são reafirmados manualmente

Nenhum desses cinco defeitos é exótico. Eles são os modos de falha previsíveis de uma fina camada Pascal sobre uma API C, e eles se acumulam porque essa camada é exatamente onde quatro contratos distintos precisam ser declarados novamente. A convenção de chamada precisa ser grafada como cdecl em cada callback. A largura do inteiro precisa corresponder a size_t no único alvo em que realmente aumenta de tamanho. O modelo de exceção precisa ser convertido em códigos de retorno em cada callback que cruza a fronteira do Pascal. A propriedade de cada handle e de cada campo gerenciado deve ser estabelecida uma vez e respeitada em todos os caminhos, incluindo os caminhos de erro que ninguém exercita até a produção. Esqueça qualquer um deles e você terá um defeito cujo sintoma surge longe de sua causa, o que torna essa categoria dispendiosa. O valor da auditoria foi menor em qualquer correção pessoal do que em tratar cada uma delas como sua própria disciplina de verificação em todo o binding.

Se você deseja ver o binding fazendo um trabalho real em vez de proteger suas bordas, as técnicas de cache de renderização e zoom em nossa nota sobre o desempenho de cache de renderização e zoom mostram o caminho de renderização, e o tutorial de compilação cruzada em construindo um visualizador no Lazarus e FPC é o local onde o comportamento do size_t do Win64 descrito aqui realmente importa. Ambos baseiam-se no mesmo trabalho de segurança de memória e ABI fornecido no Componente PDFium para Delphi, Lazarus e C++Builder, junto com as APIs de renderização, extração de texto e formulários cobertas em outras seções deste blog.