Edite um arquivo QDF à mão, esgueire um segundo marcador de stream para o mesmo objeto, e então rode um reparo in-place com o código Delphi mais natural que você consiga escrever. O passo de reparo nota o dano e recusa. Quando você olhar o arquivo depois, ele tem 0 bytes, porque a TFileStream.Create(OutputFileName, fmCreate) truncou justamente aquele caminho uma linha antes de o validador jamais ver a entrada. Reparar bytes na memória é a metade fácil do trabalho. Pô-los de volta no disco sem jamais deixar um arquivo de comprimento zero, pela metade escrito ou com permissões erradas é a outra metade, e ela tem os próprios modos de falha
Por que um reparo in-place esvazia o arquivo?
Um reparo in-place esvazia o arquivo porque a ordem das operações está errada: fmCreate trunca ao abrir, então qualquer recusa que venha depois do open chega tarde demais. Quando InputFileName e OutputFileName são o mesmo caminho, uma entrada rejeitada vira um documento perdido. Um parser cuidadoso não pode salvá-lo aqui. Ele pode deixar o stream de saída intocado quando rejeita marcadores ambíguos, mas quem chamou já destruiu o alvo uma chamada antes
// A versão óvia: o destino é truncado antes que qualquer coisa seja validada
Input := TMemoryStream.Create;
try
Input.LoadFromFile(InputFileName); // mesmo caminho que OutputFileName
Output := TFileStream.Create(OutputFileName, fmCreate); // o arquivo agora tem 0 bytes
try
if not MyRepairQDF(Input, Output) then // seu próprio validador: tarde demais para dizer não
raise Exception.Create('QDF structure rejected');
finally
Output.Free;
end;
finally
Input.Free;
end;
A tentativa seguinte usual é reparar num TMemoryStream e abrir a saída só depois de o reparo ter sucesso. Isso fecha o buraco da entrada ruim e nada mais. A fase de gravação ainda é truncate-then-copy, então um disco cheio, uma violação de compartilhamento no meio do caminho, ou uma exceção entre o truncamento e o último WriteBuffer ainda deixam o destino danificado. Parsear e publicar são duas fronteiras separadas e cada uma precisa da própria proteção
Na PDF Library for Delphi, a TPDFlib.RepairQDFFile(InputFileName, OutputFileName) trata das duas. Ela lê a entrada, repara inteiramente na memória e retorna cedo sem nada aberto no destino se a estrutura QDF for rejeitada. Só um reparo bem-sucedido segue para a publicação, e a publicação nunca abre o destino para gravação
Quatro passos entre os bytes reparados e o destino
A RepairQDFFile garante que o caminho de destino guarda ou o arquivo antigo completo ou o arquivo novo completo, para toda falha que o próprio processo possa observar. Ela chega lá em quatro passos, e nenhum deles começa a menos que o anterior tenha terminado. O destino é resolvido para um caminho completo com GetFullPathNameW, chamada uma vez para aprender o comprimento necessário e outra vez para um buffer de exatamente esse tamanho, então um caminho longo não é cortado no MAX_PATH. Em seguida um arquivo temporário nomeado .pdflib-qdf- mais um GUID mais .tmp é criado no diretório de destino com CreateFileW e CREATE_NEW no Windows, ou open(2) com O_CREAT or O_EXCL e modo 0600 no POSIX. Ambas as flags fazem o create falhar se o nome já existir. Dois processos que por acaso sortearam o mesmo GUID não podem acabar compartilhando um handle
O stream reparado é então copiado em blocos de 64 KiB através de WriteBuffer, que levanta exceção numa gravação curta em vez de devolver uma contagem que ninguém confere. FlushFileBuffers ou fsync(2) empurram os dados para baixo, e o handle é fechado. O close deliberadamente não levanta exceção: uma vez que o flush teve sucesso, um close que falhe não é ameaça aos dados, e uma exceção ali esconderia o erro real de gravação ou de flush de quem tenha de diagnosticar. O último passo é o rename
O rename é onde a maioria das rotinas safe-save caseiras quebra. No Windows o arquivo temporário substitui o alvo através de MoveFileExW com MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, que num mesmo volume é uma única operação de filesystem. MOVEFILE_COPY_ALLOWED fica de fora de propósito. Uma movimentação entre volumes degrada em silêncio para copy-then-delete, a exata sequência não atômica que o design inteiro evita, e eu prefiro de longe ver um rename recusado que uma cópia "bem-sucedida" que estava pela metade quando a máquina caiu. Como o arquivo temporário fica no diretório de destino, ele já está no volume de destino. O arquivo antigo também nunca é apagado primeiro; um par delete-then-rename deixa uma janela em que o caminho não existe, e uma quebra dentro dela perde o documento. O MOVEFILE_WRITE_THROUGH impede a chamada de retornar antes de o rename chegar ao disco. No POSIX, o rename(2) já substitui um nome existente atomicamente, e a colocação no mesmo diretório o mantém longe do EXDEV
A limpeza roda num bloco finally em todo caminho. Depois de um rename bem-sucedido o nome temporário não existe mais, então o delete não faz nada; depois de uma falha ele remove o arquivo parcial. O diretório termina exatamente com os arquivos que tinha antes da chamada
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; o arquivo que ele está prestes a substituir não tem voz. Para um documento novinho esse padrão está certo. Para um reparo in-place está errado. Imagine um operador que trancou o contract.pdf para uma única conta com uma DACL protegida e não herdada. Um arquivo temporário comum ao lado dele pega as permissões mais amplas do diretório, e depois do rename o contract.pdf publicado carrega essa DACL ampla, porque a segurança do NTFS pertence ao objeto de arquivo e não ao nome. Os bytes estão corretos e o valor de retorno é 1. A restrição de acesso que o operador configurou simplesmente sumiu
A RepairQDFFile portanto lê a DACL do destino antes de criar o arquivo temporário e a passa ao CreateFileW através de lpSecurityAttributes, então o arquivo novo começa a vida com as permissões do arquivo antigo e o rename não muda nada que um operador notaria. A leitura é a GetFileSecurityW com DACL_SECURITY_INFORMATION, com o buffer dimensionado pela resposta ERROR_INSUFFICIENT_BUFFER da primeira chamada. Quando o destino ainda não existe, que é o caso normal para um caminho de saída separado, nenhum descriptor é passado e o arquivo novo herda do diretório dele como qualquer arquivo novo
Três condições param a publicação em vez de deixar o código adivinhar, e cada uma reporta LastErrorCode 305:
- A DACL não pode ser lida, por exemplo porque a conta chamante não tem
READ_CONTROLno destino - O descriptor devolvido não tem
SE_DACL_PRESENTdefinido; entregar tal descriptor aoCreateFileWdeixaria o Windows cair para a DACL padrão do processo e mudar semânticas de acesso que ninguém pediu para mudar - O destino carrega
FILE_ATTRIBUTE_ENCRYPTED; o arquivo temporário seria plaintext, e renomeá-lo por cima de um arquivo protegido por EFS publicaria um substituto não criptografado de algo que o usuário escolheu criptografar
EFS é um recurso de filesystem e não tem nada a ver com security handlers de PDF, que são cobertos em o artigo sobre carregamento de documentos criptografados. A forma da falha é o mesmo tipo de rebaixamento silencioso, porém, e recusá-la é o padrão correto. Um reparo que ocasionalmente diz não num share EFS é um ticket de suporte. Um reparo que descriptografa um arquivo que o usuário criptografou é um incidente
Se você escrever um teste de preservação de DACL por conta própria, um detalhe do Windows é fácil de errar. Para construir o arquivo de teste restrito, aplique uma DACL só de dono e defina SE_DACL_PROTECTED no controle do descriptor explicitamente; passar PROTECTED_DACL_SECURITY_INFORMATION à SetFileSecurityW não transforma um descriptor desprotegido num protegido. Depois, confira que o arquivo publicado ainda reporta o bit protegido e uma DACL explícita e não nula, uma vez para um caminho de saída separado e uma vez para o reparo sobre a própria origem
Qual LastErrorCode diz o que falhou?
A RepairQDFFile devolve 1 em sucesso e 0 em falha, e a TPDFlib.LastErrorCode nomeia o estágio que recusou. Uma entrada que não existe, não pode ser aberta ou é maior que 2 GB reporta 401, e uma exceção durante a leitura também é mapeada para 401 em vez de vazar para o código de gravação. Estrutura QDF inválida ou ambígua, como aquele marcador de stream duplicado, reporta PDFLIB_ERROR_QDF_REPAIR (107); o destino jamais foi aberto. Tudo depois do reparo, da resolução do caminho ao flush e ao rename, reporta PDFLIB_ERROR_QDF_WRITE (305). As falhas de gravação realistas todas caem no 305: um destino mantido aberto por outro handle sem delete sharing, um destino somente leitura, um diretório de destino ausente, mais as recusas de DACL e EFS acima. Em todo caso 305 nenhum alvo novo ou parcial fica para trás
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');
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;
O caminho do 305 é fácil de reproduzir do seu próprio projeto de teste sem enganchar API de arquivo alguma. A fmShareDenyNone do Delphi abre um arquivo com compartilhamento de leitura e gravação mas sem FILE_SHARE_DELETE, e o Windows se recusa a renomear qualquer coisa por cima de um arquivo retido assim. Mantenha tal handle aberto durante a chamada e o rename falha no último passo, depois de os dados terem sido gravados e descarregados, que é o caso que você mais quer ver tratado
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;
// Essas checagens são sugestões para o seu próprio teste, não chamadas da biblioteca
Assert(BytesEqual(Before, TFile.ReadAllBytes(Target))); // BytesEqual: seu helper
// Nenhum arquivo temporário pode ficar para trás
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;
Pelo hábito de ler o código em vez de confiar no 0 sozinho, o artigo sobre diagnosticar falhas silenciosas percorre o mesmo padrão pelo resto da API
Onde a garantia termina
A promessa cobre falhas que o processo pode ver. Se o processo é morto entre criar o 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 é o que importa, mas varrer esse detrito fica por sua conta. Queda de energia é uma pergunta de filesystem: os dados são descarregados e o rename é write-through, mas a entrada de diretório não é sincronizada separadamente no POSIX, então a durabilidade é o que o filesystem fornecer por cima disso
Um rename bem-sucedido também produz um objeto de arquivo novo, e algumas propriedades do antigo não viajam com ele. A DACL é carregada para o outro lado deliberadamente. A posse não, já que só DACL_SECURITY_INFORMATION é lido, então o arquivo publicado pertence à conta que rodou o reparo. O arquivo temporário é criado com FILE_ATTRIBUTE_NORMAL, o que significa que os bits de atributo hidden, archive e similares começam do zero, e alternate data streams no arquivo antigo se vão. No POSIX o arquivo publicado mantém o modo 0600 do temporário; chame chmod depois se um grupo precisa de acesso de leitura. Os atributos e a DACL do destino são lidos antes de o arquivo temporário existir, então um segundo writer para o mesmo caminho é problema seu de coordenar
SaveQDFToFile e ConvertFileToQDF gravam direto
A RepairQDFFile é o único ponto de entrada QDF que publica através de um arquivo temporário. A SaveQDFToFile e a ConvertFileToQDF abrem a saída delas com fmCreate e despejam a conversão direto nela, bem como o caminho incremental em o artigo sobre anexar atualizações a um stream grava em qualquer stream que você fornecer. A divisão é intencional. Essas duas chamadas transformam um documento que já foi carregado e validado num novo artefato de depuração, então não há entrada não confiável que pudesse ser rejeitada no meio do caminho, e o caminho de saída delas normalmente é um arquivo novo. A RepairQDFFile recebe um arquivo editado à mão e não confiável e é rotineiramente apontada de volta para o mesmo caminho; essa combinação é o que justifica a maquinaria extra
Quando você quiser substituição atômica para um dump QDF, um wrapper curto seu basta:
// Seu próprio helper: grave o QDF ao lado do alvo, e então substitua-o num único 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); // presente apenas quando algo falhou
end;
end;
Esta versão pula a cópia da DACL e o flush explícito de dados que a RepairQDFFile executa. Para dumps QDF que caem numa pasta de rascunho ou de depuração, eu acho essa a quantidade certa de maquinaria; se o dump substitui um arquivo com permissões trancadas, rode-o pelo caminho do reparo ou copie o security descriptor você mesmo
Saída de reparo atômica, as chamadas de conversão QDF e os códigos de erro acima todos vêm na PDF Library for Delphi, ao lado do suporte a recuperação de cross-reference, atualização incremental e criptografia discutido em outros posts aqui