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 vacía a disco y se cierra, y solo entonces se renombra sobre el destino 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. Poner el resultado en disco sin dejar nunca al usuario con un archivo de longitud cero o a medio escribir es la mitad de la que va este artículo
¿Por qué una reparación fallida puede destruir igualmente 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 luego 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 era simplemente irrelevante, porque la API pública había truncado el archivo una llamada antes
El fix de la v3.539.13 movió la reparación a un TMemoryStream y abría la salida solo después de que PDFQDFRepair hubiera tenido éxito. Eso cierra el agujero del fallo de parseo y nada más. La fase de escritura seguía siendo fmCreate seguido de CopyFrom, 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 seguían dejando un destino dañado. La reparación primero en memoria protege contra entradas malas. 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 negarse
Result := 1;
finally
Output.Free;
end;
// v3.539.15: reparar en memoria y luego entregar 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 de verdad una publicación atómica?
TPDFQDFFileWriter.Save garantiza que la ruta destino contiene o bien el archivo viejo completo o bien el archivo nuevo completo, nunca una mezcla, para todo fallo que la propia biblioteca pueda observar. El writer hace esto en cuatro pasos que se niegan a continuar si el anterior no terminó. Primero resuelve el destino con GetFullPathNameW, llamándola dos veces y asignando el buffer a partir de la longitud devuelta en lugar de asumir MAX_PATH, así que las rutas largas no se cortan 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 compitan por el mismo GUID no pueden compartir un handle. Tercero, copia el stream reparado 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, y luego 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
// No permitir una copia entre volúmenes ni 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 más rutinas caseras de «guardado seguro» se rompen sin hacer ruido. MoveFileExW con MOVEFILE_REPLACE_EXISTING reemplaza el destino en una sola operación del sistema de archivos dentro del mismo volumen. El writer deja fuera a propósito MOVEFILE_COPY_ALLOWED, porque un movimiento entre volúmenes degenera en copiar y borrar, que es precisamente la secuencia no atómica que todo el diseño existe para evitar. Como el archivo temporal vive en el directorio destino, está en el volumen destino por construcción. El writer tampoco borra nunca el archivo viejo primero; un par borrar-y-renombrar tiene una ventana en la que la ruta no existe en absoluto, y un crash dentro de esa ventana pierde el documento. MOVEFILE_WRITE_THROUGH pide a la llamada que no retorne hasta que el rename haya llegado al disco, lo que encaja con el flush explícito de los datos. En POSIX, rename(2) ya garantiza que el nombre nuevo reemplaza 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 caso de éxito es un no-op porque el rename ya se lo ha comido, y en caso de fallo elimina el archivo parcial para que el directorio no acumule restos .tmp. La regresión en Tests\QDFFileRegression.inc comprueba exactamente eso: tras cada fallo inyectado, los bytes del destino coinciden con el original, los bytes del origen coinciden con el original, y el directorio no contiene nada más que los dos fixtures
¿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, no del archivo que está a punto de reemplazar. Ese es el valor por defecto correcto para un documento nuevo y el equivocado para una reparación in-place. Supón que un operador ha cerrado contract.pdf a una sola cuenta con una DACL protegida y no heredada. Un archivo temporal a su lado hereda los permisos más amplios del directorio, y una vez renombrado sobre contract.pdf el archivo renombrado lleva la DACL amplia, porque la seguridad de NTFS viaja con el objeto de archivo, no con el nombre. La reparación tiene éxito, los bytes están bien, y el control de acceso que configuró el operador desaparece en silencio. Nada en el valor de retorno lo insinúa
PDF Library for Delphi lee por tanto la DACL del destino antes de crear el archivo temporal y la pasa como argumento lpSecurityAttributes a CreateFileW, de modo que el archivo nuevo nace con los permisos del viejo y el rename no cambia nada que el operador pudiera notar. 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 la 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 puesto, la publicación también se detiene, porque pasar ese descriptor a CreateFileW dejaría que el kernel recurriera a la DACL por defecto del proceso y cambiara la semántica de acceso sin que nadie lo pidiera. Y si el destino lleva FILE_ATTRIBUTE_ENCRYPTED, el writer se niega en redondo: el archivo temporal sería texto plano, y renombrar un archivo en texto plano sobre uno protegido con EFS publica un reemplazo sin cifrar de algo que el usuario decidió cifrar a nivel de 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 fallo 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');
// dimensionar el descriptor y leer solo su parte 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 recordar si escribes un test parecido. Para construir el fixture restringido, el test aplica una DACL solo para el propietario y tiene que poner SE_DACL_PROTECTED en el control del descriptor de forma explícita; con solo pasar el flag de protegido en el argumento SecurityInformation de SetFileSecurityW no se convierte un descriptor sin proteger en uno protegido. La aserción posterior es que el archivo publicado sigue reportando el bit de protegido y una DACL explícita y no nula, tanto para una ruta de salida aparte como para una reparación sobre el propio archivo de origen
¿Qué LastErrorCode te dice qué falló?
RepairQDFFile devuelve 1 si tiene éxito y 0 ante cualquier fallo, y LastErrorCode dice qué etapa se negó. Un origen que no se puede leer, incluido uno que otro proceso retiene con un lock exclusivo, reporta 401; la lectura está ahora 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 ha tocado porque el writer nunca se construyó. Todo lo posterior a 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 compartir 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 destino nuevo ni parcial. La costumbre general de leer el código y no solo el valor de retorno es la misma que se describe en el artículo sobre diagnóstico de fallos silenciosos en la biblioteca
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 se acaba la garantía
El writer promete consistencia frente a los fallos que el proceso puede ver, y es honesto sobre los que no. Si matan el proceso entre la creación del archivo temporal y el rename, el bloque finally nunca corre y queda en el directorio un archivo .pdflib-qdf-<GUID>.tmp; el destino sigue intacto, que es la propiedad que importa, pero los restos los barres tú. Un corte de corriente también queda fuera de la promesa: los datos se han vaciado a disco y el rename es write-through, que es lo máximo que puede pedir una biblioteca 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 dé el sistema de archivos. Un segundo writer que modifique el destino en paralelo no se detecta, porque la DACL y los atributos se leen antes de crear el archivo temporal y nada los vuelve a comprobar en el momento del rename. Y un rename con éxito crea una identidad de archivo nueva, así que los alternate data streams y los atributos corrientes, como el bit de archivo o el de oculto del archivo viejo, no sobreviven; solo la DACL se traslada a propósito
La frontera más estrecha es qué API usa siquiera este camino. Solo RepairQDFFile pasa por TPDFQDFFileWriter. SaveQDFToFile y ConvertFileToQDF siguen abriendo su salida con PLCreateFileStream(FileName, fmCreate) y vuelcan la conversión QDF directamente ahí, igual que el camino incremental descrito en el artículo sobre añadir actualizaciones a un stream escribe en el stream que le pases. Esas dos llamadas producen un artefacto de depuración nuevo a partir de un documento que ya se ha cargado y validado, así que el agujero del fallo de parseo nunca les aplicó, pero tampoco heredan la publicación basada en rename. No leas este artículo como «todo export QDF es atómico». Es una salida, la que tiene una entrada de archivo no confiable y editado a mano y una salida que de forma habitual es la misma ruta, y esa combinación es la que se ganó la maquinaria extra. La inyección de fallos que demuestra todo esto es barata porque las tres etapas del writer, WriteData, Flush y Publish, son virtual. La subclase de test sobrescribe una de ellas para lanzar excepción después de que el trabajo real haya empezado, llama a Save sobre un stream reparado, y afirma que la excepción se propaga, que los bytes de origen y destino no cambian, y que no queda 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 el destino
La API RepairQDFFile, su writer de publicación atómica y el resto del flujo de depuración QDF forman parte de PDF Library for Delphi, junto con las funciones de recuperación de cross-reference, actualización incremental y cifrado que se cubren en otras entradas de este blog