Artículo técnico

Reparación PDF atómica en Delphi: rename y DACL

Edite a mano un archivo QDF, cuele un segundo marcador de stream para el mismo objeto, y ejecute una reparación in-place con el código Delphi más natural que pueda escribir. El paso de reparación detecta el daño y se niega. Cuando mire el archivo después mide 0 bytes, porque TFileStream.Create(OutputFileName, fmCreate) truncó esa misma ruta una línea antes de que el validador viera la entrada. Reparar bytes en memoria es la mitad fácil del trabajo. Devolverlos al disco sin dejar nunca un archivo de longitud cero, a medio escribir o con permisos equivocados es la otra mitad, y tiene sus propios modos de fallo

¿Por qué una reparación in-place vacía el archivo?

Una reparación in-place vacía el archivo porque el orden de las operaciones está mal: fmCreate trunca al abrir, así que cualquier rechazo que llegue después de la apertura llega tarde. Cuando InputFileName y OutputFileName son la misma ruta, una entrada rechazada se convierte en un documento perdido. Un parser cuidadoso no puede salvarle ahí. Puede dejar su stream de salida intacto cuando rechaza marcadores ambiguos, pero el llamador ya destruyó el objetivo una llamada antes

// La versión obvia: el destino se trunca antes de validar nada
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // la misma ruta que OutputFileName
  Output := TFileStream.Create(OutputFileName, fmCreate);  // el archivo ya tiene 0 bytes
  try
    if not MyRepairQDF(Input, Output) then  // su propio validador: demasiado tarde para decir no
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

El siguiente intento habitual es reparar a un TMemoryStream y abrir la salida solo cuando la reparación tuvo éxito. Eso cierra el agujero de la entrada mala y nada más. La fase de escritura sigue siendo truncar-y-copiar, así que un disco lleno, una violación de compartir a mitad de camino o una excepción entre el truncado y el último WriteBuffer siguen dejando el destino dañado. El parseo y la publicación son dos fronteras separadas y cada una necesita su propia protección

En PDF Library for Delphi, TPDFlib.RepairQDFFile(InputFileName, OutputFileName) se ocupa de ambas. Lee la entrada, la repara por completo en memoria, y retorna temprano sin haber abierto nada en el destino si la estructura QDF se rechaza. Solo una reparación con éxito pasa a la publicación, y la publicación nunca abre el destino para escritura

Tres formas de publicar una reparación QDF en Delphi: abrir la salida con fmCreate la trunca antes de que la reparación pueda rechazar la entrada, reparar primero a un TMemoryStream solo protege contra entradas malas, y RepairQDFFile publica los bytes reparados mediante un archivo temporal y un rename atómico
La seguridad del parseo y la de la publicación son fronteras distintas: la reparación primero en memoria protege contra entradas malas, y el writer existe para que un disco lleno o un fallo a mitad de escritura ya no puedan dejar el destino dañado

Cuatro pasos entre los bytes reparados y el destino

RepairQDFFile garantiza que la ruta destino contiene o bien el archivo viejo completo o bien el archivo nuevo completo, para todo fallo que el propio proceso pueda observar. Llega ahí en cuatro pasos, y ninguno empieza si el anterior no terminó. El destino se resuelve a ruta completa con GetFullPathNameW, llamada una vez para aprender la longitud necesaria y otra vez sobre un buffer de exactamente ese tamaño, así que una ruta larga no se recorta en MAX_PATH. Después se crea un archivo temporal llamado .pdflib-qdf- más un GUID más .tmp en el directorio destino con CreateFileW y CREATE_NEW en Windows, o open(2) con O_CREAT or O_EXCL y modo 0600 en POSIX. Ambos flags hacen que la creación falle si el nombre ya existe. Dos procesos que por casualidad sacaran el mismo GUID no pueden acabar compartiendo un handle

El stream reparado se copia después en trozos de 64 KiB a través de WriteBuffer, que lanza excepción ante una escritura corta en lugar de devolver un contador que nadie comprueba. FlushFileBuffers o fsync(2) empujan los datos hacia abajo, y el handle se cierra. El cierre deliberadamente no lanza excepción: una vez que el flush tuvo éxito, un cierre fallido no es amenaza para los datos, y una excepción ahí escondería el error real de escritura o de flush a quien tenga que diagnosticarlo. El último paso es el rename

Los cuatro pasos con que RepairQDFFile publica su salida en PDF Library for Delphi: resolver la ruta dos veces con GetFullPathNameW, crear el archivo temporal .pdflib-qdf con CREATE_NEW u O_EXCL para que los procesos en carrera no puedan compartir handle, copiar en trozos de 64 KiB con WriteBuffer y hacer flush, y luego MoveFileExW con REPLACE_EXISTING y WRITE_THROUGH
Cada paso se niega a continuar si el anterior no terminó, el archivo temporal vive en el volumen destino por construcción, no existe nunca una ventana de borrar primero, y la limpieza en un finally no deja restos .tmp

El rename es donde más rutinas caseras de «guardado seguro» se rompen. En Windows el archivo temporal reemplaza al objetivo mediante MoveFileExW con MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, que dentro de un mismo volumen es una sola operación del sistema de archivos. MOVEFILE_COPY_ALLOWED se deja fuera a propósito. Un movimiento entre volúmenes degrada en silencio a copiar-y-borrar, la exacta secuencia no atómica que todo el diseño evita, y prefiero con mucho ver un rename rechazado que una copia «con éxito» que estaba a medias cuando la máquina se apagó. Como el archivo temporal vive en el directorio destino, está en el volumen destino de todos modos. El archivo viejo tampoco se borra nunca primero; un par borrar-y-renombrar deja una ventana en la que la ruta no existe, y un crash dentro de ella pierde el documento. MOVEFILE_WRITE_THROUGH impide que la llamada retorne antes de que el rename haya llegado al disco. En POSIX, rename(2) ya reemplaza un nombre existente de forma atómica, y la colocación en el mismo directorio lo mantiene lejos de EXDEV

La limpieza corre en un bloque finally en todas las rutas. Tras un rename con éxito el nombre temporal ya no existe, así que el borrado no hace nada; tras un fallo elimina el archivo parcial. El directorio acaba con exactamente los archivos que tenía antes de la llamada

¿Por qué un archivo temporal relaja los permisos en Windows?

Un archivo creado con un descriptor de seguridad nil hereda su DACL del directorio padre; el archivo que está a punto de reemplazar no tiene nada que decir. Para un documento nuevo ese valor por defecto es el correcto. Para una reparación in-place es el equivocado. Imagine un operador que cerró contract.pdf a una sola cuenta con una DACL protegida y no heredada. Un archivo temporal llano a su lado recoge los permisos más amplios del directorio, y tras el rename el contract.pdf publicado lleva esa DACL amplia, porque la seguridad de NTFS pertenece al objeto de archivo y no al nombre. Los bytes están bien y el valor de retorno es 1. El control de acceso que configuró el operador simplemente desapareció

RepairQDFFile lee por tanto la DACL del destino antes de crear el archivo temporal y la pasa a CreateFileW a través de lpSecurityAttributes, de modo que el archivo nuevo nace con los permisos del viejo y el rename no cambia nada que un operador pudiera notar. La lectura usa GetFileSecurityW con DACL_SECURITY_INFORMATION, con el buffer dimensionado a partir de la respuesta ERROR_INSUFFICIENT_BUFFER de la primera llamada. Cuando el destino aún no existe, que es el caso normal de una ruta de salida separada, no se pasa descriptor y el archivo nuevo hereda de su directorio como cualquier archivo nuevo

Por qué el writer de publicación QDF copia la DACL del destino antes de crear su archivo temporal: un descriptor nil heredaría los permisos más amplios del directorio y el rename ampliaría el acceso en silencio, así que GetFileSecurityW lee la DACL, y un bit SE_DACL_PRESENT ausente o un atributo EFS detienen la publicación con 305, mientras CreateFileW nace con los permisos viejos
La seguridad de NTFS viaja con el objeto de archivo, no con el nombre: pasar el descriptor leído como lpSecurityAttributes hace que el rename no cambie nada de lo que configuró el operador, y cada puerta falla cerrada en lugar de adivinar

Tres condiciones detienen la publicación en lugar de dejar que el código adivine, y cada una reporta LastErrorCode 305:

  • La DACL no se puede leer, por ejemplo porque la cuenta llamadora carece de READ_CONTROL sobre el destino
  • El descriptor devuelto no lleva SE_DACL_PRESENT puesto; entregar semejante descriptor a CreateFileW dejaría que Windows recurriera a la DACL por defecto del proceso y cambiara una semántica de acceso que nadie pidió cambiar
  • El destino lleva FILE_ATTRIBUTE_ENCRYPTED; el archivo temporal sería texto plano, y renombrarlo sobre un archivo protegido con EFS publicaría un reemplazo sin cifrar de algo que el usuario decidió cifrar

EFS es una característica del sistema de archivos y no tiene nada que ver con los security handlers de PDF, que están tratados en el artículo sobre carga de documentos cifrados. La forma del fallo es el mismo tipo de degradación silenciosa, y negarse es el valor por defecto correcto. Una reparación que a veces dice que no en un recurso compartido EFS es un ticket de soporte. Una reparación que descifra un archivo que el usuario cifró es un incidente

Si escribe usted una prueba de conservación de DACL, un detalle de Windows es fácil de torcer. Para construir el archivo de prueba restringido, aplique una DACL solo para el propietario y ponga SE_DACL_PROTECTED en el control del descriptor de forma explícita; pasar PROTECTED_DACL_SECURITY_INFORMATION a SetFileSecurityW no convierte un descriptor sin proteger en uno protegido. Después, compruebe que el archivo publicado sigue reportando el bit de protegido y una DACL explícita y no nula, una vez para una ruta de salida aparte y otra para una reparación sobre el propio origen

¿Qué LastErrorCode le dice qué falló?

RepairQDFFile devuelve 1 en caso de éxito y 0 en caso de fallo, y TPDFlib.LastErrorCode nombra la etapa que se negó. Una entrada que no existe, que no se puede abrir o que pesa más de 2 GB reporta 401, y una excepción durante la lectura también se mapea a 401 en lugar de colarse en el código de escritura. Una estructura QDF inválida o ambigua, como ese marcador de stream duplicado, reporta PDFLIB_ERROR_QDF_REPAIR (107); el destino nunca se abrió. Todo lo posterior a la reparación, desde resolver la ruta hasta el flush y el rename, reporta PDFLIB_ERROR_QDF_WRITE (305). Los fallos de escritura realistas aterrizan todos en 305: un destino retenido por otro handle sin compartir borrado, un destino de solo lectura, un directorio destino ausente, más los rechazos de DACL y de EFS de arriba. En cada caso 305 no queda ningún objetivo nuevo ni parcial

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Reparación in-place: la misma ruta es entrada y salida
    if Pdf.RepairQDFFile('edited.qdf.pdf', 'edited.qdf.pdf') = 1 then
      Log('published; the previous bytes were replaced in one rename')
    else
      case Pdf.LastErrorCode of
        401: Log('could not read the input; it was not modified');
        PDFLIB_ERROR_QDF_REPAIR:
          Log('QDF structure rejected; the destination was never opened');
        PDFLIB_ERROR_QDF_WRITE:
          Log('write, flush or replace refused; the destination keeps its old bytes');
      end;
  finally
    Pdf.Free;
  end;
end;

La ruta del 305 es fácil de reproducir desde su propio proyecto de pruebas sin enganchar ninguna API de archivos. El fmShareDenyNone de Delphi abre un archivo con compartir lectura y escritura pero sin FILE_SHARE_DELETE, y Windows se niega a renombrar algo sobre un archivo retenido así. Mantenga ese handle abierto durante la llamada y el rename falla en el último paso, después de que los datos se hayan escrito y vaciado, que es justo el caso que más le interesa ver manejado

var
  Before: TBytes;
  Holder: TFileStream;
  Search: TSearchRec;
begin
  Before := TFile.ReadAllBytes(Target);   // System.IOUtils
  Holder := TFileStream.Create(Target, fmOpenRead or fmShareDenyNone);
  try
    Assert(Pdf.RepairQDFFile(Source, Target) = 0);
    Assert(Pdf.LastErrorCode = PDFLIB_ERROR_QDF_WRITE);
  finally
    Holder.Free;
  end;
  // Estas comprobaciones son sugerencias para su propio test, no llamadas de la librería
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual: helper suyo
  // No puede quedar ningún archivo temporal
  if FindFirst(ExtractFilePath(Target) + '.pdflib-qdf-*.tmp',
    faAnyFile, Search) = 0 then
  try
    Assert(False, 'temporary file left behind: ' + Search.Name);
  finally
    FindClose(Search);
  end;
end;

Para el hábito de leer el código en lugar de fiarse del 0 a secas, el artículo sobre diagnóstico de fallos silenciosos recorre el mismo patrón por el resto de la API

Dónde se acaba la garantía

La promesa cubre los fallos que el proceso puede ver. Si matan el proceso entre la creación del archivo temporal y el rename, el bloque finally nunca corre y un archivo .pdflib-qdf-<GUID>.tmp se queda en el directorio. El destino sigue intacto, que es lo que importa, pero barrer esos restos es cosa suya. El corte de corriente es una pregunta del sistema de archivos: los datos están vaciados y el rename es write-through, pero la entrada de directorio no se sincroniza aparte en POSIX, así que la durabilidad es la que el sistema de archivos dé por encima de eso

Un rename con éxito también produce un objeto de archivo nuevo, y algunas propiedades del viejo no viajan con él. La DACL se traslada deliberadamente. La propiedad no, ya que solo se lee DACL_SECURITY_INFORMATION, así que el archivo publicado es propiedad de la cuenta que ejecutó la reparación. El archivo temporal se crea con FILE_ATTRIBUTE_NORMAL, lo que significa que los bits de atributo oculto, archivo y similares empiezan de cero, y los alternate data streams del archivo viejo desaparecen. En POSIX el archivo publicado conserva el modo 0600 del temporal; llame a chmod después si un grupo necesita acceso de lectura. Los atributos y la DACL del destino se leen antes de que exista el archivo temporal, así que coordinar un segundo escritor sobre la misma ruta es cosa suya

SaveQDFToFile y ConvertFileToQDF escriben directo

RepairQDFFile es el único punto de entrada QDF que publica a través de un archivo temporal. SaveQDFToFile y ConvertFileToQDF abren su salida con fmCreate y vuelcan la conversión directo ahí, igual que el camino incremental de el artículo sobre añadir actualizaciones a un stream escribe en el stream que usted suministre. La división es intencionada. Esas dos llamadas convierten un documento que ya se cargó y validó en un artefacto de depuración nuevo, así que no hay entrada no confiable que pudiera rechazarse a mitad de camino, y su ruta de salida suele ser un archivo nuevo. RepairQDFFile toma un archivo editado a mano y no confiable, y se apunta rutinariamente a la misma ruta; esa combinación es la que justifica la maquinaria extra

Cuando de verdad quiera reemplazo atómico para un volcado QDF, basta un wrapper corto suyo:

// Helper propio: escribe el QDF junto al objetivo y luego lo reemplaza en un rename
function SaveQDFReplacing(Pdf: TPDFlib; const FileName: string): Boolean;
var
  ID: TGUID;
  TempName: string;
begin
  CreateGUID(ID);
  TempName := ExtractFilePath(ExpandFileName(FileName)) +
    '.myapp-qdf-' + GUIDToString(ID) + '.tmp';
  try
    Result := (Pdf.SaveQDFToFile(TempName) = 1) and
      MoveFileExW(PWideChar(WideString(TempName)), PWideChar(WideString(FileName)),
        MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH);
  finally
    if FileExists(TempName) then
      DeleteFile(TempName);   // solo presente cuando algo falló
  end;
end;

Esta versión se salta la copia de DACL y el flush explícito de datos que RepairQDFFile hace. Para volcados QDF que caen en una carpeta de scratch o de depuración, esa me parece la cantidad correcta de maquinaria; si el volcado reemplaza un archivo con permisos cerrados, páselo por la ruta de reparación o copie el descriptor de seguridad usted mismo

La salida atómica de reparaciones, las llamadas de conversión QDF y los códigos de error de arriba vienen todos en PDF Library for Delphi, junto al soporte de recuperación de cross-reference, actualización incremental y cifrado del que se habla en otras entradas de este blog