A PDF Library for Delphi publica o resultado do RepairQDFFile através de um writer interno, o TPDFQDFFileWriter, que nunca abre o destino para escrita: os bytes reparados vão para um ficheiro temporário criado de forma exclusiva no mesmo diretório, o ficheiro é descarregado e fechado, e só então é renomeado sobre o alvo com MoveFileExW no Windows ou rename(2) no POSIX. Se algo falhar antes do rename, o destino mantém todos os bytes que tinha, e o chamador vê LastErrorCode 305. Reparar um documento em memória é a metade fácil de uma funcionalidade de reparação. Pôr o resultado no disco sem alguma vez deixar o utilizador com um ficheiro de comprimento zero ou a meio é a metade de que trata este artigo
Porque é que uma reparação que falha ainda pode destruir o ficheiro 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 depois entregava esse stream ao parser. O fmCreate trunca ao abrir, pelo que, quando o varrimento QDF decidia que a entrada não era reparável, o destino já tinha sido esvaziado. A reparação no local, em que InputFileName e OutputFileName são o mesmo caminho, transformava uma entrada rejeitada num ficheiro perdido. O parser em si comportava-se bem: a função de baixo nível PDFQDFRepair deixa o stream alvo intocado quando rejeita marcadores ambíguos. Essa proteção era simplesmente irrelevante, porque a API pública tinha truncado o ficheiro uma chamada antes
A correção da v3.539.13 passou a reparação para um TMemoryStream e só abria a saída depois de o PDFQDFRepair ter tido sucesso. Isso fecha o buraco da falha de parse e mais nada. A fase de escrita continuava a ser fmCreate seguido de CopyFrom, pelo que um disco cheio, uma violação de partilha a meio, ou uma exceção entre a truncagem e o último WriteBuffer deixavam na mesma um destino danificado. A reparação em memória protege contra entrada má. A publicação em disco precisa da sua 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: reparar em memória e entregar 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 garante afinal a publicação atómica?
O TPDFQDFFileWriter.Save garante que o caminho de destino é ou o ficheiro antigo completo ou o ficheiro novo completo, nunca uma mistura, para todas as falhas que a própria biblioteca consegue observar. O writer faz isto em quatro passos, cada um dos quais se recusa a avançar a menos que o anterior tenha terminado. Primeiro resolve o destino com GetFullPathNameW, chamando-o duas vezes e alocando o buffer a partir do comprimento devolvido em vez de assumir MAX_PATH, para que os caminhos longos não sejam cortados em silêncio. Segundo cria um ficheiro 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. Ambos os flags fazem a criação falhar se o nome já existir, para que dois processos em corrida pelo mesmo GUID não possam partilhar um handle. Terceiro copia o stream reparado em blocos de 64 KiB através do WriteBuffer, que levanta erro numa escrita curta em vez de devolver uma contagem que ninguém verifica, e depois chama FlushFileBuffers ou fsync(2) e fecha o handle. Quarto, 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 uma 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 do rename é onde a maioria das rotinas caseiras de «gravação segura» se estraga em silêncio. O MoveFileExW com MOVEFILE_REPLACE_EXISTING substitui o alvo numa única operação do sistema de ficheiros no mesmo volume. O writer deixa deliberadamente de fora o MOVEFILE_COPY_ALLOWED, porque uma movimentação entre volumes degenera em copiar-e-apagar, que é precisamente a sequência não atómica que todo o desenho existe para evitar. Como o ficheiro temporário vive no diretório de destino, está por construção no volume de destino. O writer também nunca apaga o ficheiro antigo primeiro; um par apagar-e-renomear tem uma janela em que o caminho não existe de todo, e um crash dentro dessa janela perde o documento. O MOVEFILE_WRITE_THROUGH pede que a chamada não regresse antes de o rename ter chegado ao disco, o que emparelha com o flush explícito dos dados. No POSIX, o rename(2) já garante que o nome novo substitui atomicamente qualquer ficheiro existente, e a mesma colocação no diretório evita que 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 é um no-op porque o rename já o consumiu, e em caso de falha remove o ficheiro parcial para que o diretório não acumule detritos .tmp. A regressão em Tests\QDFFileRegression.inc verifica exatamente isso: depois de cada falha injetada, os bytes do destino coincidem com o original, os bytes da origem coincidem com o original, e o diretório não contém nada além dos dois fixtures
Porque é que um ficheiro temporário alarga as permissões no Windows?
Um ficheiro criado com um descritor de segurança nil herda a sua DACL do diretório pai, não do ficheiro que está prestes a substituir. Isso é o predefinido correto para um documento novo e o errado para uma reparação no local. Suponha que um operador fechou contract.pdf a uma única conta com uma DACL protegida e não herdada. Um ficheiro temporário ao lado herda as permissões mais amplas do diretório, e depois de ser renomeado sobre contract.pdf o ficheiro renomeado transporta a DACL ampla, porque a segurança do NTFS viaja com o objeto de ficheiro, não com o nome. A reparação tem sucesso, os bytes estão certos, e o controlo de acesso que o operador configurou desapareceu em silêncio. Nada no valor de retorno o sugere
A PDF Library for Delphi lê portanto a DACL do destino antes de criar o ficheiro temporário e passa-a como argumento lpSecurityAttributes ao CreateFileW, para que o ficheiro novo nasça com as permissões do antigo e o rename não mude nada que o operador note. 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 de forma fechada 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 em 305. Se o descritor voltar sem o bit SE_DACL_PRESENT definido, a publicação também para, porque passar um descritor desses ao CreateFileW deixaria o kernel recuar para a DACL predefinida do processo e mudar a semântica de acesso sem ninguém o ter pedido. E se o alvo transportar FILE_ATTRIBUTE_ENCRYPTED, o writer recusa de imediato: o ficheiro temporário seria texto simples, e renomear um ficheiro em texto simples sobre um protegido por EFS publica uma substituição não encriptada de algo que o utilizador escolheu encriptar ao nível do sistema de ficheiros. O EFS não tem relação com os security handlers padrão do PDF, que são o tema de o artigo sobre o carregamento de documentos encriptados, mas o modo de falha é o mesmo tipo de degradação 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 o descritor e ler apenas a porção 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 a pena reter se escrever um teste semelhante. Para construir o fixture restrito, o teste aplica uma DACL só para o proprietário e tem de definir SE_DACL_PROTECTED no controlo do descritor de forma explícita; passar apenas o flag protegido no argumento SecurityInformation do SetFileSecurityW não transforma um descritor não protegido num protegido. A asserção seguinte é que o ficheiro publicado continua a reportar o bit protegido e uma DACL explícita e não nula, tanto para um caminho de saída separado como para a reparação sobre o próprio ficheiro de origem
Qual LastErrorCode lhe diz o que falhou?
O RepairQDFFile devolve 1 em caso de sucesso e 0 em qualquer falha, e o LastErrorCode diz qual etapa recusou. Uma origem que não pode ser lida, incluindo uma que outro processo detém com um bloqueio exclusivo, reporta 401; a leitura está agora envolvida de modo a que uma exceção durante a entrada seja mapeada em 401 em vez de escapar 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 chegou a ser construído. Tudo o que vem depois da reparação, da criação do ficheiro 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 partilha de eliminação, um destino só de leitura, um diretório de destino inexistente, e cada uma das três etapas do writer a falhar por injeção. Em todos eles o retorno é 0, o código é 305, e não existe depois nenhum alvo novo nem parcial. O hábito geral de ler o código em vez de apenas o valor de retorno é o mesmo descrito em o artigo sobre diagnosticar falhas silenciosas na biblioteca
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Reparação no local: 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 acaba a garantia
O writer promete consistência contra as falhas que o processo consegue ver, e é honesto quanto às que não consegue. Se o processo for morto entre a criação do ficheiro temporário e o rename, o bloco finally nunca corre e fica um ficheiro .pdflib-qdf-<GUID>.tmp no diretório; o destino continua intacto, que é a propriedade que interessa, mas os detritos são seus para limpar. Uma perda de energia está igualmente fora da promessa: os dados são descarregados e o rename é write-through, que é o melhor que uma biblioteca em modo utilizador pode pedir, mas o writer não faz fsync à entrada de diretório nem faz qualquer afirmação de durabilidade para lá do que o sistema de ficheiros oferece. Um segundo writer que modifique o destino em simultâneo não é detetado, porque a DACL e os atributos são lidos antes de o ficheiro temporário ser criado e nada os volta a verificar no momento do rename. E um rename bem-sucedido cria uma nova identidade de ficheiro, pelo que alternate data streams e atributos vulgares como o bit de arquivo ou de oculto do ficheiro antigo não sobrevivem; só a DACL é transportada de propósito
A fronteira mais estreita é saber que API sequer usa este caminho. Só o RepairQDFFile passa pelo TPDFQDFFileWriter. O SaveQDFToFile e o ConvertFileToQDF continuam a abrir a sua saída com PLCreateFileStream(FileName, fmCreate) e a escrever a conversão QDF diretamente para lá, da mesma forma que o caminho incremental descrito em o artigo sobre acrescentar atualizações a um stream escreve para o stream que lhe entregar. Essas duas chamadas produzem um artefacto de depuração novo a partir de um documento já carregado e validado, por isso o buraco da falha de parse nunca se aplicou a elas, mas também não herdam a publicação assente em rename. Não leia este artigo como «todas as exportações QDF são atómicas». É uma saída, aquela cuja entrada é um ficheiro não fidedigno e editado à mão e cuja saída é rotineiramente o mesmo caminho, e essa combinação foi o que lhe valeu a maquinaria extra. A injeção de falhas que prova tudo isto é barata porque as três etapas do writer, WriteData, Flush e Publish, são virtual. A subclasse de teste faz override de uma delas para levantar erro depois de o trabalho real ter começado, chama Save sobre um stream reparado, e afirma que a exceção se propaga, que os bytes da origem e do destino ficam inalterados, e que não resta nenhum ficheiro temporário. Não se interceta nenhuma API global de ficheiros, não se toca em nenhum ficheiro real do utilizador, e as três etapas correspondem uma a uma às três formas de uma publicação falhar em produção: o disco enche-se, o flush é rejeitado, ou o rename é recusado porque outra pessoa detém o alvo
A API RepairQDFFile, o seu writer de publicação atómica e o resto do fluxo de depuração QDF fazem parte da PDF Library for Delphi, a par da recuperação de cross-reference, das atualizações incrementais e das funcionalidades de encriptação abordadas noutros pontos deste blog