Artículo técnico

Endurecer una vinculación PDFium: seguridad ABI y memoria

Una vinculación de Pascal sobre una biblioteca C se lee como Pascal ordinario. Llama a un método, obtiene un registro de vuelta, libera lo que asignó. El problema es que PDFium es una biblioteca C y C++ con su propia convención de llamadas, sus propios anchos de enteros y sus propias reglas sobre quién posee la memoria y quién la libera. Nada de eso cruza el límite del lenguaje por sí solo. Cada uno de esos contratos tiene que reestablecerse a mano en las declaraciones de Pascal, y una sola palabra incorrecta convierte una llamada de apariencia limpia en una corrupción de pila, un desplazamiento truncado o una doble liberación. Cada contrato viene acompañado de la forma clásica de equivocarse y de la estructura que usa en su lugar la vinculación de PDFium Component. Merece la pena recorrerlos porque no son específicos de esta vinculación: son los peligros permanentes de empaquetar cualquier API de C en Delphi o Lazarus

cdecl es parte del tipo de función, no una decoración

PDFium es C compilado. En Win32, sus exportaciones y, sobre todo, las devoluciones de llamada que invoca usan la convención de llamadas cdecl. Bajo cdecl, el llamador limpia la pila cuando la llamada regresa. El valor predeterminado nativo de Delphi es register, y el estándar C de Win32 para callbacks es stdcall en algunas bibliotecas, donde el receptor de la llamada es quien limpia. Cuando una estructura entrega a PDFium un puntero a función y olvida el cdecl en el tipo de ese puntero, las dos partes discrepan sobre quién ajusta el puntero de pila. Lo hacen ambas, o ninguna, y el puntero de pila se desplaza el tamaño de los argumentos en cada invocación

La razón por la que este error es tan difícil de encontrar es que el daño no es local. La llamada corrupta retorna y aparenta estar bien. La desalineación aparece más tarde, en una función sin relación alguna cuyo marco se apoya ahora en un puntero de pila desplazado unos bytes, y se manifiesta como una lectura salvaje, una dirección de retorno corrupta o un crash con un backtrace que no apunta ni de cerca a la callback que realmente escribió mal. El llenado de formularios es el lugar clásico donde esto muerde, porque la interfaz de formularios es un registro lleno de callbacks a las que PDFium vuelve a llamar. Una de ellas, FFI_OpenFile, entrega a PDFium la función que invocará para abrir un archivo externo, declarada como function(pThis: PFPDF_FORMFILLINFO; fileFlag: Integer; wsURL: FPDF_WIDESTRING; mode: PAnsiChar): PFPDF_FILEHANDLER; cdecl. El cdecl final es la clave a copiar. Quítelo y el código sigue compilando, enlazando y ejecutándose justo hasta que PDFium llama a la función. La convención pertenece al tipo de función en sí. No es azúcar opcional, y el compilador no le avisará de su ausencia porque un tipo de función plano es un tipo Pascal perfectamente legal. La única defensa es tratar la convención de llamadas como un campo obligatorio de cada firma importada y de cada callback que pase hacia fuera

Diagrama que contrasta una callback cdecl que mantiene equilibrada la pila del llamador de PDFium con una cdecl ausente que desplaza el puntero de pila hasta un crash lejano
cdecl pertenece al tipo de función, y omitirlo desplaza el puntero de pila hasta que algún marco sin relación se estrella

size_t es el ancho del puntero, y en FPC Win64 eso significa 64 bits

El segundo peligro es un desajuste de ancho entero que solo aparece en un objetivo. El size_t de C se define con el ancho necesario para contener cualquier tamaño de objeto, lo que en una plataforma de 64 bits significa un entero sin signo de 64 bits. Las interfaces de carga progresiva de PDFium hablan en desplazamientos de bytes size_t. El registro FX_FILEAVAIL del proveedor de disponibilidad lleva una callback IsDataAvail que PDFium invoca con un desplazamiento y un tamaño, y la callback AddSegment del registro FX_DOWNLOADHINTS recibe lo mismo. Ambos parámetros son size_t

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

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

Si declara esos desplazamientos con un tipo de 32 bits, la vinculación funciona en Win32 y en Delphi Win64, y luego se rompe en silencio en FPC y Lazarus Win64. La causa es sutil. En FPC Win64, NativeUInt es un tipo genuino de 64 bits con el ancho del puntero, y size_t es un alias de él. La vinculación incluye un comentario en la sección de tipos que advierte precisamente contra ocultar NativeUInt en FPC, porque redefinirlo allí como un alias de 32 bits forzaría size_t a 32 bits y corrompería cada parámetro size_t pasado a la biblioteca o escrito por ella. Un desplazamiento de 64 bits que llega a un parámetro de 32 bits pierde su mitad superior. En un archivo pequeño, cada desplazamiento cabe en 32 bits y todo va bien. En un archivo grande, en cuanto un desplazamiento cruza la frontera de los cuatro gigabytes el valor truncado apunta a otro lugar por completo, PDFium pregunta si está disponible el rango de bytes equivocado, y la carga progresiva se atasca o lee basura. El error es invisible hasta que el archivo es lo bastante grande y el objetivo es justo aquel donde size_t realmente se ensanchó

Diagrama de PDFium Component de un desplazamiento size_t de cinco gigabytes que sobrevive como valor completo de 64 bits pero se convierte en cerca de un gigabyte cuando un alias de 32 bits encoge el parámetro en FPC Win64
Un parámetro size_t forzado a 32 bits trunca los desplazamientos a partir de cuatro gigabytes y manda la carga progresiva al rango de bytes equivocado

Una excepción de Pascal nunca debe propagarse a través de un marco de C

La tercera clase trata del modelo de excepciones, que C no tiene. Cuando PDFium llama a una de sus callbacks, su código Pascal se ejecuta dentro de una pila de marcos de C y C++ que no saben nada de la maquinaria de excepciones de Delphi. Si su callback lanza una excepción y la deja propagarse, esta se desenrolla a través de marcos que nunca se construyeron para desenrollarse. La limpieza interna de PDFium no se ejecuta, sus invariantes internos quedan a medio actualizar, y el proceso queda en un estado que la biblioteca nunca contempló. El contrato de estas callbacks es un código de retorno, no una excepción

Dos callbacks lo hacen concreto. FPDF_FILEWRITE es el sumidero donde PDFium escribe un documento guardado, y FPDF_FILEACCESS es la fuente desde la que lee un documento de entrada. Ambos se implementan aquí sobre un TStream de Delphi, y ambos pueden fallar como falla cualquier stream: el disco se llena, el stream se cierra por debajo, una lectura pasa del final. La callback de escritura envuelve la escritura del stream y convierte cualquier fallo en el código de error de PDFium en lugar de dejarlo escapar

function WriteBlock(
  pThis: PFPDF_FILEWRITE;
  pData: Pointer;
  Size : LongWord): Integer; cdecl;
begin
  // PDFium trata cualquier retorno distinto de 1 como un fallo de escritura. Una
  // excepción de Pascal no debe desenrollarse por este marco cdecl/C++, así que
  // la atrapamos y reportamos el fallo.
  Result := 0;
  try
    PPdfWrite(pThis).Stream.WriteBuffer(pData^, Size);
    Result := 1;
  except
  end;
end;

El lado de lectura hace lo mismo: una lectura fallida reporta cero para respetar el contrato de FPDF_FILEACCESS en lugar de lanzar una excepción a través del límite. Un except desnudo sin re-lanzar le parecerá mal a un programador Pascal educado en no tragarse nunca las excepciones, y en Pascal ordinario lo es. En un límite ABI es la forma correcta, porque el único valor seguro que puede devolverse al llamador C es un código de estado que sepa interpretar. El fallo sigue propagándose, solo que a través del valor de retorno, y el código llamador por encima de la biblioteca lo expone como EPdfError cuando el control vuelve al lado Pascal de la valla

La doble liberación se esconde en la ruta de error

El cuarto peligro es la propiedad. Un handle de documento PDFium lo abre la biblioteca y debe cerrarse exactamente una vez, con FPDF_CloseDocument. El peligro está en una ruta de error que libera un handle del que también es dueña una segunda limpieza. Imagine una rutina que crea un objeto envoltorio, le asigna un handle de documento recién abierto y luego hace más configuración que puede fallar. Si la configuración lanza, un gestor de retorno temprano que llama a FPDF_CloseDocument sobre el handle en crudo lo cerrará, y luego el propio destructor del objeto envoltorio lo cerrará otra vez al liberarse el objeto. El handle se libera dos veces, lo que es comportamiento indefinido y un probable crash

El patrón importa sobre todo en una ruta de importación al estilo imposición que construye un TPdf alrededor de un handle ya abierto. La cura es hacer de la transferencia de propiedad la única fuente de verdad. Una vez que el handle se asigna al campo del envoltorio, el envoltorio es su dueño, y la única limpieza en la ruta de error es liberar el envoltorio. El destructor del envoltorio llama a FPDF_CloseDocument por usted, así que un segundo cierre explícito liberaría dos veces el mismo documento. El gestor de errores de abajo libera el objeto y re-lanza, y hay exactamente un camino hacia el cierre

Diagrama de una ruta de error que cierra dos veces un handle de documento PDFium junto a un patrón de dueño único donde liberar el envoltorio cierra el handle exactamente una vez
Transferir el handle al envoltorio convierte su destructor en el único camino hacia FPDF_CloseDocument
Result := TPdf.Create(nil);
try
  Result.FDocument := NewDoc;   // Result pasa a ser el dueño del handle
  Result.InitializeFormFill;
  Result.ReloadPage;
except
  // Result.Free cierra el handle. Un segundo FPDF_CloseDocument(NewDoc)
  // aquí liberaría dos veces el mismo documento PDFium.
  Result.Free;
  raise;
end;

Tanto los registros gestionados como una biblioteca llena de exportaciones necesitan un desmontaje explícito

La última clase trata de la memoria que el compilador gestiona en su nombre, y que un hábito de C corrompe en silencio. Muchas funciones auxiliares de esta vinculación devuelven un registro que contiene un WideString o un array dinámico. Son campos con conteo de referencias, y el compilador emite contabilidad oculta para mantener sus conteos. El instinto heredado de C es limpiar un registro recién creado con FillChar(Result, SizeOf(Result), 0). Eso estampa ceros sobre la referencia gestionada dentro del registro sin decrementarla antes. El compilador reutiliza un único temporal oculto para el resultado de una función entre iteraciones del bucle, así que en la segunda iteración FillChar sobrescribe un puntero a cadena vivo que nunca se liberó, y la cadena a la que apuntaba se filtra. Llame a la función en un bucle sobre mil anotaciones y filtrará mil cadenas

La solución es dejar que el lenguaje limpie el registro como sabe hacerlo, con Default(T), que libera cualquier campo gestionado antes de ponerlo a cero

// Default() en lugar de FillChar: el compilador reutiliza un único temporal oculto para
// el resultado de la función entre iteraciones del bucle, así que FillChar pondría a cero
// punteros WideString vivos sin liberarlos.
Result := Default(TPdfAnnotation);

Un problema de propiedad relacionado vive en el límite de carga de la biblioteca. Esta vinculación resuelve varios cientos de punteros a función de la DLL de PDFium con GetProcAddress tras un LoadLibrary. Si falta una exportación requerida, el estado de vinculación parcial es peligroso: docenas de punteros son válidos, el resto son nil u obsoletos, y cualquier llamada posterior a través de uno de ellos salta a un módulo que puede estar ya descargado. La vinculación lo resuelve descargando la biblioteca y ejecutando un ClearAllBindings completo que devuelve a nil cada puntero importado siempre que una exportación requerida no se resuelva. Después de eso, ningún puntero a función queda colgando de un módulo descargado, y una llamada posterior falla limpiamente con una comprobación de puntero nulo en lugar de saltar a código liberado

El contenedor es donde se reestablecen a mano cuatro contratos

Ninguno de estos cinco peligros es exótico. Son los modos de fallo predecibles de una fina capa de Pascal sobre una API de C, y se agrupan porque esa capa es justo donde hay que volver a declarar cuatro contratos separados. La convención de llamadas debe escribirse cdecl en cada callback. El ancho del entero debe coincidir con size_t en el único objetivo donde realmente se ensancha. El modelo de excepciones debe convertirse en códigos de retorno en cada callback que sale de Pascal. La propiedad de cada handle y de cada campo gestionado debe enunciarse una vez y respetarse en cada ruta, incluidas las rutas de error que nadie ejercita hasta producción. Omitir cualquiera de ellos produce un fallo cuyo síntoma aparece lejos de su causa, y eso es lo que hace cara esta categoría. El valor está menos en una regla concreta que en tratar cada uno de estos puntos como una disciplina propia que revisar en toda la vinculación

Si quiere ver la vinculación haciendo trabajo real en lugar de custodiar sus bordes, las técnicas de caché de renderizado y zoom en nuestra nota sobre caché de renderizado y zoom muestran la ruta de renderizado, y el recorrido por compiladores cruzados en cómo construir un visor Lazarus y FPC es el lugar donde el comportamiento de size_t en Win64 descrito aquí importa de verdad. Ambos se apoyan en el mismo trabajo de seguridad de memoria y ABI que incluye el PDFium Component para Delphi, Lazarus y C++Builder, junto con las API de renderizado, extracción de texto y formularios que cubre el resto de este blog