Edite a mano un archivo QDF, cuele un segundo marcador de stream para el mismo objeto, y corra una reparación in situ con el código Delphi más natural que pueda escribir. El paso de reparación nota el daño y se niega. Cuando revisa el archivo después, mide 0 bytes, porque TFileStream.Create(OutputFileName, fmCreate) truncó ese mismo camino 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 jamás 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 situ vacía el archivo?
Una reparación in situ vacía el archivo porque el orden de 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 el mismo camino, una entrada rechazada se vuelve un documento perdido. Un parser cuidadoso no puede salvarlo ahí. Puede dejar su stream de salida intacto cuando rechaza marcadores ambiguos, pero quien llama 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); // el mismo camino que OutputFileName
Output := TFileStream.Create(OutputFileName, fmCreate); // el archivo ahora mide 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 después de que la reparación tuvo éxito. Eso cierra el agujero de entrada mala y nada más. La fase de escritura sigue siendo truncar-y-copiar, así que un disco lleno, una violación de compartición a mitad de camino, o una excepción entre la truncación y el último WriteBuffer dejan el destino dañado igual. Parsear y publicar son dos fronteras separadas y cada una necesita su propia protección
En PDF Library for Delphi, TPDFlib.RepairQDFFile(InputFileName, OutputFileName) se encarga de ambas. Lee la entrada, la repara por completo en memoria, y retorna temprano sin nada abierto en el destino si la estructura QDF se rechaza. Solo una reparación exitosa pasa a la publicación, y la publicación jamás abre el destino para escritura
Cuatro pasos entre los bytes reparados y el destino
RepairQDFFile garantiza que el camino del destino contiene o el archivo viejo completo o el archivo nuevo completo, ante todo fallo que el proceso mismo pueda observar. Llega ahí en cuatro pasos, y ninguno arranca salvo que el anterior terminó. El destino se resuelve a una ruta completa con GetFullPathNameW, llamada una vez para conocer el largo requerido y otra vez hacia un búfer 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 del 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 de alguna manera sacaron el mismo GUID no pueden terminar compartiendo un handle
El stream reparado se copia luego en trozos de 64 KiB vía WriteBuffer, que lanza una excepción ante una escritura corta en lugar de devolver un conteo que nadie revisa. 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 amenaza los datos, y una excepción ahí escondería el error real de escritura o de flush de quien tenga que diagnosticarlo. El último paso es el rename
El rename es donde la mayoría de las rutinas caseras de guardado seguro se rompen. En Windows el archivo temporal reemplaza al objetivo vía MoveFileExW con MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, que en 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 secuencia exacta no atómica que todo el diseño evita, y prefiero ver un rename rechazado que una copia "exitosa" que estaba a mitad cuando la máquina se cayó. Como el archivo temporal vive en el directorio del destino, de todos modos está en el volumen del destino. El archivo viejo tampoco se borra primero; un par borrar-luego-renombrar deja una ventana en la que el camino no existe, y un crash dentro de ella pierde el documento. MOVEFILE_WRITE_THROUGH evita que la llamada retorne antes de que el rename haya llegado a 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 todos los caminos. Tras un rename exitoso el nombre temporal ya no existe, así que el borrado no hace nada; tras un fallo elimina el archivo parcial. El directorio termina con exactamente los archivos que tenía antes de la llamada
¿Por qué un archivo temporal afloja los permisos en Windows?
Un archivo creado con un descriptor de seguridad nil hereda su DACL del directorio padre; el archivo que está por reemplazar no tiene voz. Para un documento totalmente nuevo ese valor por defecto es correcto. Para una reparación in situ está mal. Imagine a un operador que encerró contract.pdf a una sola cuenta con un DACL protegido y no heredado. Un archivo temporal común al lado suyo toma los permisos más amplios del directorio, y tras el rename el contract.pdf publicado carga ese DACL amplio, porque la seguridad NTFS pertenece al objeto de archivo y no al nombre. Los bytes son correctos y el valor de retorno es 1. La restricción de acceso que el operador configuró simplemente desapareció
RepairQDFFile por lo tanto lee el DACL del destino antes de crear el archivo temporal y se lo pasa a CreateFileW vía lpSecurityAttributes, así que el archivo nuevo nace con los permisos del archivo viejo y el rename no cambia nada que un operador notaría. La lectura es GetFileSecurityW con DACL_SECURITY_INFORMATION, con el búfer dimensionado por la respuesta ERROR_INSUFFICIENT_BUFFER de la primera llamada. Cuando el destino aún no existe, que es el caso normal para una ruta de salida separada, no se pasa descriptor y el archivo nuevo hereda de su directorio como cualquier otro archivo nuevo
Tres condiciones detienen la publicación en lugar de dejar que el código adivine, y cada una reporta LastErrorCode 305:
- El DACL no se puede leer, por ejemplo porque la cuenta que llama carece de
READ_CONTROLsobre el destino - El descriptor devuelto no tiene
SE_DACL_PRESENTactivo; entregar semejante descriptor aCreateFileWdejaría que Windows recurra al DACL por defecto del proceso y cambiaría semánticas de acceso que nadie pidió cambiar - El destino trae
FILE_ATTRIBUTE_ENCRYPTED; el archivo temporal estaría en texto plano, y renombrarlo sobre un archivo protegido por 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 cubiertos en el artículo de 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 ocasionalmente dice no en un share EFS es un ticket de soporte. Una reparación que descifra un archivo que el usuario cifró es un incidente
Si usted escribe una prueba de preservación de DACL, un detalle de Windows es fácil de equivocar. Para construir el archivo de prueba restringido, aplique un DACL de solo propietario y fije SE_DACL_PROTECTED en el control del descriptor explícitamente; pasar PROTECTED_DACL_SECURITY_INFORMATION a SetFileSecurityW no convierte un descriptor desprotegido en protegido. Después, compruebe que el archivo publicado sigue reportando el bit protegido y un DACL explícito no nulo, una vez para una ruta de salida separada y una vez para la reparación sobre la propia fuente
¿Qué LastErrorCode le dice qué falló?
RepairQDFFile devuelve 1 en éxito y 0 en fallo, y TPDFlib.LastErrorCode nombra la etapa que se negó. Una entrada que no existe, no se puede abrir o supera los 2 GB reporta 401, y una excepción durante la lectura también se mapea a 401 en lugar de filtrarse hacia 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 jamás se abrió. Todo después de la reparación, desde resolver la ruta hasta flush y rename, reporta PDFLIB_ERROR_QDF_WRITE (305). Los fallos de escritura realistas aterrizan todos en 305: un destino retenido abierto por otro handle sin delete sharing, un destino de solo lectura, un directorio de destino faltante, más los rechazos de DACL y 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 situ: el mismo camino 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;
El camino 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 compartición de lectura y escritura pero sin FILE_SHARE_DELETE, y Windows se niega a renombrar cualquier cosa sobre un archivo retenido así. Mantenga semejante handle abierto durante la llamada y el rename falla en el último paso, después de que los datos se escribieron y se hicieron flush, que es el caso que más quiere 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 propia prueba, no llamadas de la biblioteca
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual: su helper
// No debe 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 de diagnóstico de fallos silenciosos recorre el mismo patrón por el resto de la API
Dónde termina la garantía
La promesa cubre los fallos que el proceso puede ver. Si matan al 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 residuos le toca a usted. El corte de energía es una cuestión del sistema de archivos: los datos se hacen flush y el rename es write-through, pero la entrada de directorio no se sincroniza por separado en POSIX, así que la durabilidad es la que el sistema de archivos provea encima de eso
Un rename exitoso también produce un objeto de archivo nuevo, y algunas propiedades del viejo no viajan con él. El DACL se traslada deliberadamente. La propiedad no, ya que solo se lee DACL_SECURITY_INFORMATION, así que el archivo publicado es de la cuenta que corrió la reparación. El archivo temporal se crea con FILE_ATTRIBUTE_NORMAL, lo que significa que los bits oculto, archivo y similares arrancan frescos, 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 el DACL del destino se leen antes de que el temporal exista, así que coordinar un segundo writer sobre el mismo camino le toca a usted
SaveQDFToFile y ConvertFileToQDF escriben directo
RepairQDFFile es el único punto de entrada QDF que publica vía archivo temporal. SaveQDFToFile y ConvertFileToQDF abren su salida con fmCreate y vierten la conversión directo en ella, igual que la ruta incremental en el artículo de anexo de actualizaciones a un stream escribe en el stream que usted le pase. La división es intencional. Esas dos llamadas convierten un documento ya cargado y validado en un nuevo artefacto de depuración, así que no hay entrada no confiable que pueda rechazarse a mitad de camino, y su ruta de salida normalmente es un archivo fresco. RepairQDFFile recibe un archivo editado a mano y no confiable y rutinariamente se apunta de vuelta al mismo camino; esa combinación es la que justifica la maquinaria extra
Cuando sí quiere reemplazo atómico para un dump QDF, un envoltorio corto suyo basta:
// Su helper propio: escribe el QDF junto al objetivo y luego lo reemplaza en un solo 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 realiza. Para dumps QDF que caen en una carpeta de scratch o depuración, me parece la cantidad correcta de maquinaria; si el dump 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 con el soporte de recuperación de referencias cruzadas, actualización incremental y cifrado que se trata en otras entradas aquí