PDF Library for Delphi publica la salida de RepairQDFFile a través de un writer interno, TPDFQDFFileWriter, que nunca abre el destino para escritura: los bytes reparados van a un archivo temporal creado en exclusiva en el mismo directorio, el archivo se flushea y se cierra, y recién entonces se renombra sobre el objetivo con MoveFileExW en Windows o rename(2) en POSIX. Si algo falla antes del rename, el destino conserva todos los bytes que tenía y quien llama ve LastErrorCode 305. Reparar un documento en memoria es la mitad fácil de una función de reparación. Dejar el resultado en disco sin que el usuario termine nunca con un archivo de cero bytes o a medio escribir es la mitad de la que trata este artículo
¿Por qué una reparación fallida igual puede destruir el archivo destino?
Porque el orden de las operaciones estaba mal. Antes de la v3.539.13, RepairQDFFile abría la salida con PLCreateFileStream(OutputFileName, fmCreate) y después le pasaba ese stream al parser. fmCreate trunca al abrir, así que para cuando el escaneo QDF decidía que la entrada no era reparable, el destino ya estaba vaciado. La reparación in-place, donde InputFileName y OutputFileName son la misma ruta, convertía una entrada rechazada en un archivo perdido. El parser en sí se portaba bien: la función de bajo nivel PDFQDFRepair deja intacto el stream destino cuando rechaza marcadores ambiguos. Esa protección simplemente era irrelevante, porque la API pública ya había truncado el archivo una llamada antes
El fix de la v3.539.13 movió la reparación a un TMemoryStream y abrió la salida solo después de que PDFQDFRepair hubiera tenido éxito. Eso tapa el agujero de la falla de parseo y nada más. La fase de escritura seguía siendo fmCreate y después CopyFrom, así que un disco lleno, una violación de sharing a mitad de camino o una excepción entre la truncación y el último WriteBuffer igual dejaban el destino dañado. La reparación primero en memoria protege contra entrada mala. La publicación en disco necesita su propia frontera, y las v3.539.14 y v3.539.15 construyeron una
// v3.539.12: el destino se trunca antes de validar la entrada
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // ya es tarde para decir no
Result := 1;
finally
Output.Free;
end;
// v3.539.15: repara en memoria y luego le pasa los bytes al writer de publicación
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // el destino nunca se abrió
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
¿Qué garantiza realmente la publicación atómica?
TPDFQDFFileWriter.Save garantiza que la ruta destino sea o el archivo viejo completo o el archivo nuevo completo, nunca una mezcla, ante cualquier falla que la propia librería pueda observar. El writer lo hace en cuatro pasos, y cada uno se niega a seguir si el anterior no terminó. Primero resuelve el destino con GetFullPathNameW, llamándola dos veces y reservando el buffer a partir de la longitud devuelta en lugar de asumir MAX_PATH, para que las rutas largas no se corten en silencio. Segundo, crea un archivo temporal llamado .pdflib-qdf- más un GUID más .tmp en el directorio destino, usando CreateFileW con CREATE_NEW en Windows y open(2) con O_CREAT or O_EXCL y modo 0600 en POSIX. Los dos flags hacen que la creación falle si el nombre ya existe, así que dos procesos que compiten por el mismo GUID no pueden compartir un handle. Tercero, copia el stream reparado en bloques de 64 KiB con WriteBuffer, que lanza excepción ante una escritura corta en lugar de devolver un contador que nadie chequea, y después llama a FlushFileBuffers o fsync(2) y cierra el handle. Cuarto, renombra
procedure TPDFQDFFileWriter.Flush(Target: TStream);
begin
if not FlushFileBuffers(THandleStream(Target).Handle) then
raise EWriteError.Create('Unable to flush QDF output');
end;
procedure TPDFQDFFileWriter.Publish(const TempFileName, FileName: WideString);
begin
// Sin copia entre volúmenes y sin borrar el destino primero
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
El paso del rename es donde la mayoría de las rutinas caseras de "safe save" se rompen sin hacer ruido. MoveFileExW con MOVEFILE_REPLACE_EXISTING reemplaza el objetivo en una sola operación del sistema de archivos dentro del mismo volumen. El writer deja afuera a propósito MOVEFILE_COPY_ALLOWED, porque un movimiento entre volúmenes degenera en copiar y después borrar, que es exactamente la secuencia no atómica que todo el diseño busca evitar. Como el archivo temporal vive en el directorio destino, está en el volumen destino por construcción. El writer tampoco borra el archivo viejo primero; un par borrar y después renombrar tiene una ventana en la que la ruta no existe en absoluto, y una caída dentro de esa ventana pierde el documento. MOVEFILE_WRITE_THROUGH le pide a la llamada que no retorne hasta que el rename haya llegado al disco, lo que se combina con el flush explícito de los datos. En POSIX, rename(2) ya garantiza que el nombre nuevo reemplace atómicamente cualquier archivo existente, y la misma ubicación en el directorio evita que falle con EXDEV. La limpieza es simétrica. El nombre temporal se borra en un bloque finally en todas las rutas, lo que en el caso exitoso no hace nada porque el rename ya se lo comió, y en el caso de falla elimina el archivo parcial para que el directorio no acumule basura .tmp. La regresión en Tests\QDFFileRegression.inc chequea exactamente eso: después de cada falla inyectada, los bytes del destino coinciden con el original, los bytes de la fuente coinciden con el original, y el directorio no contiene nada más que los dos fixtures
¿Por qué un archivo temporal afloja los permisos en Windows?
Un archivo creado con un descriptor de seguridad nulo hereda su DACL del directorio padre, no del archivo que está por reemplazar. Ese es el valor por defecto correcto para un documento nuevo y el equivocado para una reparación in-place. Supongamos que un administrador dejó contract.pdf restringido a una sola cuenta con un DACL protegido y no heredado. Un archivo temporal al lado hereda los permisos más amplios del directorio, y una vez que se renombra sobre contract.pdf el archivo renombrado carga el DACL amplio, porque la seguridad de NTFS viaja con el objeto archivo, no con el nombre. La reparación tiene éxito, los bytes están bien, y el control de acceso que configuró el administrador desapareció sin que nadie se entere. Nada en el valor de retorno lo insinúa
Por eso PDF Library for Delphi lee el DACL del destino antes de crear el archivo temporal y lo pasa como argumento lpSecurityAttributes a CreateFileW, de modo que el archivo nuevo nace con los permisos del archivo viejo y el rename no cambia nada que el administrador note. La lectura usa GetFileSecurityW con DACL_SECURITY_INFORMATION, dimensionando el buffer a partir del resultado ERROR_INSUFFICIENT_BUFFER de la primera llamada. Hay tres condiciones que hacen que el writer falle cerrado en lugar de adivinar. Si el DACL no se puede leer, la publicación se detiene con un EWriteError, que la API pública mapea a 305. Si el descriptor vuelve sin el bit SE_DACL_PRESENT, la publicación también se detiene, porque pasar ese descriptor a CreateFileW dejaría que el kernel cayera al DACL por defecto del proceso y cambiara la semántica de acceso sin que nadie lo haya pedido. Y si el objetivo tiene FILE_ATTRIBUTE_ENCRYPTED, el writer se niega de plano: el archivo temporal sería texto plano, y renombrar un archivo de texto plano sobre uno protegido con EFS publica un reemplazo sin cifrar de algo que el usuario eligió cifrar a nivel del sistema de archivos. EFS no tiene relación con los security handlers estándar de PDF, que son el tema de el artículo sobre carga de documentos cifrados, pero el modo de falla es el mismo tipo de degradación silenciosa
Attributes := GetFileAttributesW(PWideChar(Destination));
if Attributes <> INVALID_FILE_ATTRIBUTES then
begin
if (Attributes and FILE_ATTRIBUTE_ENCRYPTED) <> 0 then
raise EWriteError.Create('QDF replacement of an EFS encrypted file is not supported');
// dimensiona el descriptor y después lee solo su porción DACL
if not GetFileSecurityW(PWideChar(Destination), DACL_SECURITY_INFORMATION,
@Security[0], SecuritySize, SecuritySize) then
raise EWriteError.Create('Unable to read QDF destination permissions');
if not QDFGetSecurityDescriptorControl(@Security[0], Control, Revision) or
((Control and SE_DACL_PRESENT) = 0) then
raise EWriteError.Create('QDF destination has no explicit DACL');
SecurityAttributes.lpSecurityDescriptor := @Security[0];
SecurityPointer := @SecurityAttributes; // se pasa a CreateFileW / CREATE_NEW
end;
Hay un detalle de la regresión que conviene tener presente si usted escribe una prueba parecida. Para armar el fixture restringido, la prueba aplica un DACL solo para el propietario y debe fijar SE_DACL_PROTECTED en el control del descriptor de forma explícita; pasar el flag de protegido en el argumento SecurityInformation de SetFileSecurityW no convierte un descriptor no protegido en uno protegido. La aserción posterior es que el archivo publicado siga reportando el bit de protegido y un DACL explícito no nulo, tanto para una ruta de salida aparte como para la reparación sobre el propio archivo fuente
¿Qué LastErrorCode le dice qué falló?
RepairQDFFile devuelve 1 si sale bien y 0 ante cualquier falla, y LastErrorCode dice qué etapa se negó. Una fuente que no se puede leer, incluida una que otro proceso tiene con lock exclusivo, reporta 401; la lectura ahora está envuelta para que una excepción durante la entrada se mapee a 401 en lugar de filtrarse al error de escritura. Una estructura QDF inválida o ambigua, como un marcador de stream duplicado para el mismo objeto, reporta PDFLIB_ERROR_QDF_REPAIR, que es 107, y el destino no se tocó porque el writer nunca se construyó. Todo lo que viene después de la reparación, desde la creación del archivo temporal hasta el flush y el rename, reporta PDFLIB_ERROR_QDF_WRITE, que es 305. La regresión ejercita los casos realistas: un destino abierto por otro handle sin sharing de borrado, un destino de solo lectura, un directorio destino inexistente y cada una de las tres etapas del writer fallando por inyección. En todos ellos el retorno es 0, el código es 305, y después no existe ningún objetivo nuevo ni parcial. La costumbre general de leer el código y no solo el valor de retorno es la misma que describe el artículo sobre cómo diagnosticar fallas silenciosas en la librería
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');
107: Log('QDF structure rejected; the destination was never opened');
305: Log('write, flush or replace failed; the destination still holds its old bytes');
end;
finally
Pdf.Free;
end;
end;
Dónde termina la garantía
El writer promete consistencia frente a las fallas que el proceso puede ver, y es honesto con las que no. Si matan el proceso entre la creación del archivo temporal y el rename, el bloque finally nunca corre y queda un archivo .pdflib-qdf-<GUID>.tmp en el directorio; el destino sigue intacto, que es la propiedad que importa, pero los restos los tiene que barrer usted. La pérdida de energía también queda fuera de la promesa: los datos están flusheados y el rename es write-through, que es lo máximo que puede pedir una librería en modo usuario, pero el writer no hace fsync de la entrada de directorio y no reclama ninguna durabilidad más allá de lo que da el sistema de archivos. Un segundo writer que modifique el destino en paralelo no se detecta, porque el DACL y los atributos se leen antes de crear el archivo temporal y nadie los vuelve a chequear al momento del rename. Y un rename exitoso crea una identidad de archivo nueva, así que los alternate data streams y los atributos comunes, como el bit de archivo o el de oculto del archivo viejo, no sobreviven; solo el DACL se traslada a propósito
La frontera más estrecha es qué API usa siquiera esta ruta. Solo RepairQDFFile pasa por TPDFQDFFileWriter. SaveQDFToFile y ConvertFileToQDF siguen abriendo su salida con PLCreateFileStream(FileName, fmCreate) y escriben la conversión QDF directamente ahí, igual que la ruta incremental que describe el artículo sobre cómo agregar actualizaciones a un stream escribe en el stream que usted le pase. Esas dos llamadas producen un artefacto de depuración nuevo a partir de un documento que ya se cargó y se validó, así que el agujero de la falla de parseo nunca las afectó, pero tampoco heredan la publicación basada en rename. No lea este artículo como "toda exportación QDF es atómica". Es una sola salida, la que tiene una entrada que es un archivo editado a mano y sin confianza y una salida que de rutina es la misma ruta, y esa combinación es la que se ganó la maquinaria extra. La inyección de fallas que demuestra todo esto es barata porque las tres etapas del writer, WriteData, Flush y Publish, son virtual. La subclase de prueba sobrescribe una de ellas para lanzar excepción después de que el trabajo real ya empezó, llama a Save sobre un stream reparado y verifica que la excepción se propague, que los bytes de la fuente y del destino no cambien y que no quede ningún archivo temporal. No se engancha ninguna API global de archivos, no se toca ningún archivo real del usuario, y las tres etapas se corresponden una a una con las tres formas en que una publicación puede fallar en producción: se llena el disco, se rechaza el flush, o se rechaza el rename porque otro tiene tomado el objetivo
La API RepairQDFFile, su writer de publicación atómica y el resto del flujo de depuración QDF son parte de PDF Library for Delphi, junto con las funciones de recuperación de referencias cruzadas, actualización incremental y cifrado que se cubren en otras notas de este blog