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
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
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
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_CONTROLsobre el destino - El descriptor devuelto no lleva
SE_DACL_PRESENTpuesto; entregar semejante descriptor aCreateFileWdejarí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