A PDF Library for Delphi publica a saída do RepairQDFFile por meio de um writer interno, TPDFQDFFileWriter, que nunca abre o destino para escrita: os bytes reparados vão para um arquivo temporário criado de forma exclusiva no mesmo diretório, o arquivo passa por flush e é fechado, e só então é renomeado sobre o alvo com MoveFileExW no Windows ou rename(2) no POSIX. Se qualquer coisa falhar antes do rename, o destino mantém cada byte que tinha, e o chamador vê LastErrorCode 305. Reparar um documento em memória é a metade fácil de um recurso de reparo. Colocar o resultado no disco sem nunca deixar o usuário com um arquivo de tamanho zero ou pela metade é a metade sobre a qual este artigo trata
Por que um reparo que falha ainda pode destruir o arquivo alvo?
Porque a ordem das operações estava errada. Antes da v3.539.13, o RepairQDFFile abria a saída com PLCreateFileStream(OutputFileName, fmCreate) e então entregava esse stream ao parser. O fmCreate trunca na abertura, então quando a varredura de QDF decidia que a entrada não era reparável, o destino já tinha sido esvaziado. O reparo in-place, em que InputFileName e OutputFileName são o mesmo caminho, transformava uma entrada rejeitada num arquivo perdido. O parser em si era bem comportado: a função de baixo nível PDFQDFRepair mantém o stream de destino intocado quando rejeita marcadores ambíguos. Essa proteção era simplesmente irrelevante, porque a API pública já tinha truncado o arquivo uma chamada antes
A correção da v3.539.13 moveu o reparo para um TMemoryStream e abriu a saída só depois que o PDFQDFRepair tinha tido sucesso. Isso fecha o buraco de falha de parse e mais nada. A fase de escrita continuava sendo fmCreate seguido de CopyFrom, então disco cheio, uma sharing violation no meio do caminho ou uma exceção entre a truncagem e o último WriteBuffer ainda deixavam o destino danificado. Reparo memory-first protege contra entrada ruim. A publicação em disco precisa da própria fronteira, e as v3.539.14 e v3.539.15 construíram uma
// v3.539.12: o destino é truncado antes de a entrada ser validada
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // tarde demais para dizer não
Result := 1;
finally
Output.Free;
end;
// v3.539.15: repara em memória e só então entrega os bytes ao writer de publicação
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // destino nunca aberto
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
O que a publicação atômica garante de fato?
O TPDFQDFFileWriter.Save garante que o caminho de destino seja o arquivo antigo completo ou o arquivo novo completo, nunca uma mistura, para toda falha que a própria biblioteca consiga observar. O writer faz isso em quatro passos, cada um dos quais se recusa a prosseguir a menos que o anterior tenha terminado. Primeiro ele resolve o destino com GetFullPathNameW, chamando duas vezes e alocando o buffer a partir do comprimento retornado em vez de assumir MAX_PATH, para que caminhos longos não sejam cortados em silêncio. Segundo cria um arquivo temporário chamado .pdflib-qdf- mais um GUID mais .tmp no diretório de destino, usando CreateFileW com CREATE_NEW no Windows e open(2) com O_CREAT or O_EXCL e modo 0600 no POSIX. As duas flags fazem a criação falhar se o nome já existir, então dois processos correndo sobre o mesmo GUID não conseguem compartilhar um handle. Terceiro ele copia o stream reparado em blocos de 64 KiB via WriteBuffer, que levanta erro numa escrita curta em vez de devolver uma contagem que ninguém confere, e então chama FlushFileBuffers ou fsync(2) e fecha o handle. Quarto ele renomeia
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
// Não permitir cópia entre volumes nem apagar o destino primeiro
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
O passo de rename é onde a maioria das rotinas caseiras de "safe save" quebra em silêncio. O MoveFileExW com MOVEFILE_REPLACE_EXISTING substitui o alvo numa única operação de filesystem no mesmo volume. O writer deixa MOVEFILE_COPY_ALLOWED de fora de propósito, porque um move entre volumes degenera em copy-then-delete, que é exatamente a sequência não atômica que todo o design existe para evitar. Como o arquivo temporário vive no diretório de destino, ele está no volume de destino por construção. O writer também nunca apaga o arquivo antigo primeiro; um par delete-then-rename tem uma janela em que o caminho não existe de jeito nenhum, e um crash dentro dessa janela perde o documento. O MOVEFILE_WRITE_THROUGH pede que a chamada não retorne até o rename ter chegado ao disco, o que combina com o flush explícito dos dados. No POSIX, rename(2) já garante que o novo nome substitui atomicamente qualquer arquivo existente, e a mesma colocação no diretório evita que ele falhe com EXDEV. A limpeza é simétrica. O nome temporário é removido num bloco finally em todos os caminhos, o que em caso de sucesso é no-op porque o rename já o consumiu, e em caso de falha remove o arquivo parcial para que o diretório não acumule detritos .tmp. A regressão em Tests\QDFFileRegression.inc checa exatamente isso: depois de toda falha injetada, os bytes do destino batem com o original, os bytes da origem batem com o original, e o diretório não contém nada além dos dois fixtures
Por que um arquivo temporário afrouxa as permissões no Windows?
Um arquivo criado com um security descriptor nil herda a DACL do diretório pai, não do arquivo que ele está prestes a substituir. Esse é o padrão correto para um documento novo em folha e o errado para um reparo in-place. Suponha que um operador tenha trancado contract.pdf em uma única conta com uma DACL protegida e não herdada. Um arquivo temporário ao lado dele herda as permissões mais amplas do diretório, e assim que é renomeado sobre contract.pdf o arquivo renomeado carrega a DACL ampla, porque a segurança do NTFS viaja com o objeto de arquivo, não com o nome. O reparo tem sucesso, os bytes estão certos, e o controle de acesso que o operador configurou sumiu em silêncio. Nada no valor de retorno dá qualquer pista disso
A PDF Library for Delphi portanto lê a DACL do destino antes de criar o arquivo temporário e a passa como argumento lpSecurityAttributes para CreateFileW, de modo que o novo arquivo nasce com as permissões do arquivo antigo e o rename não muda nada que o operador perceberia. A leitura usa GetFileSecurityW com DACL_SECURITY_INFORMATION, dimensionando o buffer a partir do resultado ERROR_INSUFFICIENT_BUFFER da primeira chamada. Três condições fazem o writer falhar fechado em vez de adivinhar. Se a DACL não puder ser lida, a publicação para com um EWriteError, que a API pública mapeia para 305. Se o descriptor voltar sem o bit SE_DACL_PRESENT setado, a publicação também para, porque passar um descriptor desses ao CreateFileW deixaria o kernel cair na DACL padrão do processo e mudar a semântica de acesso sem que ninguém tenha pedido. E se o alvo carrega FILE_ATTRIBUTE_ENCRYPTED, o writer recusa de imediato: o arquivo temporário seria texto puro, e renomear um arquivo de texto puro sobre um protegido por EFS publica uma substituição não criptografada de algo que o usuário escolheu criptografar no nível do filesystem. EFS não tem relação com os standard security handlers de PDF, que são o assunto do artigo sobre carregamento de documentos criptografados, mas o modo de falha é o mesmo tipo de downgrade silencioso
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 o descriptor e lê apenas a parte da DACL dele
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; // entregue ao CreateFileW / CREATE_NEW
end;
Um detalhe da regressão vale guardar caso você escreva um teste parecido. Para montar o fixture restrito, o teste aplica uma DACL só para o dono e precisa setar SE_DACL_PROTECTED no control do descriptor explicitamente; passar apenas a flag de proteção no argumento SecurityInformation de SetFileSecurityW não transforma um descriptor desprotegido em protegido. A asserção depois disso é que o arquivo publicado ainda reporta o bit de proteção e uma DACL explícita e não nula, tanto para um caminho de saída separado quanto para o reparo sobre o próprio arquivo de origem
Qual LastErrorCode diz o que falhou?
O RepairQDFFile devolve 1 em caso de sucesso e 0 em qualquer falha, e o LastErrorCode diz qual estágio recusou. Uma origem que não pode ser lida, incluindo uma que outro processo segura com lock exclusivo, reporta 401; a leitura agora está envolvida para que uma exceção durante a entrada mapeie para 401 em vez de vazar para o erro de escrita. Estrutura QDF inválida ou ambígua, como um marcador de stream duplicado para o mesmo objeto, reporta PDFLIB_ERROR_QDF_REPAIR, que é 107, e o destino não foi tocado porque o writer nunca foi construído. Tudo depois do reparo, da criação do arquivo temporário ao flush e ao rename, reporta PDFLIB_ERROR_QDF_WRITE, que é 305. A regressão exercita os casos realistas: um destino aberto por outro handle sem delete sharing, um destino somente leitura, um diretório de destino ausente, e cada um dos três estágios do writer falhando por injeção. Em todos eles o retorno é 0, o código é 305, e nenhum alvo novo ou parcial existe depois. O hábito geral de ler o código em vez de apenas o valor de retorno é o mesmo descrito no artigo sobre diagnosticar falhas silenciosas na biblioteca
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Reparo in-place: o mesmo caminho é entrada e saída
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;
Onde a garantia termina
O writer promete consistência contra falhas que o processo consegue ver, e é honesto quanto às que não consegue. Se o processo for morto entre a criação do arquivo temporário e o rename, o bloco finally nunca roda e um arquivo .pdflib-qdf-<GUID>.tmp fica no diretório; o destino continua intacto, que é a propriedade que importa, mas os detritos são seus para varrer. Queda de energia também está fora da promessa: os dados passam por flush e o rename é write-through, que é o melhor que uma biblioteca em user mode pode pedir, mas o writer não faz fsync da entrada de diretório e não faz nenhuma afirmação de durabilidade além do que o filesystem oferece. Um segundo writer que modifique o destino ao mesmo tempo não é detectado, porque a DACL e os atributos são lidos antes de o arquivo temporário ser criado e nada os reconfere no momento do rename. E um rename bem-sucedido cria uma nova identidade de arquivo, então alternate data streams e atributos comuns como o bit de archive ou hidden no arquivo antigo não sobrevivem; só a DACL é carregada de propósito
A fronteira mais estreita é qual API sequer usa esse caminho. Só o RepairQDFFile passa pelo TPDFQDFFileWriter. O SaveQDFToFile e o ConvertFileToQDF ainda abrem sua saída com PLCreateFileStream(FileName, fmCreate) e escrevem a conversão de QDF direto nela, do mesmo jeito que o caminho incremental descrito no artigo sobre anexar atualizações a um stream escreve no stream que você entregar. Essas duas chamadas produzem um artefato de depuração novo a partir de um documento que já foi carregado e validado, então o buraco de falha de parse nunca valeu para elas, mas elas também não herdam a publicação baseada em rename. Não leia este artigo como "toda exportação de QDF é atômica". É uma saída, aquela cuja entrada é um arquivo não confiável editado à mão e cuja saída é rotineiramente o mesmo caminho, e é essa combinação que lhe rendeu a maquinaria extra. A injeção de falhas que prova tudo isso é barata porque os três estágios do writer, WriteData, Flush e Publish, são virtual. A subclasse de teste sobrescreve um deles para levantar erro depois que o trabalho real começou, chama Save sobre um stream reparado, e verifica que a exceção se propaga, que os bytes de origem e destino ficam inalterados e que nenhum arquivo temporário permanece. Nenhuma API global de arquivo é hookada, nenhum arquivo real do usuário é tocado, e os três estágios mapeiam um para um nas três formas de uma publicação falhar em produção: o disco enche, o flush é rejeitado, ou o rename é recusado porque outra pessoa segura o alvo
A API RepairQDFFile, seu writer de publicação atômica e o resto do fluxo de depuração de QDF fazem parte da PDF Library for Delphi, junto com os recursos de recuperação de cross-reference, incremental update e criptografia cobertos em outros pontos deste blog