Artigo Técnico

Saída atômica de reparo de PDF no Delphi: rename e DACL

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

Três jeitos de publicar um reparo de QDF em Delphi: abrir a saída com fmCreate trunca-a antes de o reparo poder rejeitar a entrada, reparar primeiro num TMemoryStream protege só contra entrada ruim, e RepairQDFFile publica os bytes reparados através de um arquivo temporário e um rename atômico
Segurança de parse e segurança de publicação são fronteiras diferentes: o reparo em memória primeiro protege contra entrada ruim, enquanto o writer existe para que um disco cheio ou uma falha no meio de uma gravação não deixem o destino danificado

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

Os quatro passos que o RepairQDFFile usa para publicar a saída dele na PDF Library for Delphi: resolver o caminho duas vezes com GetFullPathNameW, criar o arquivo temporário .pdflib-qdf com CREATE_NEW ou O_EXCL para que processos em corrida não compartilhem um handle, copiar em blocos de 64 KiB via WriteBuffer e fazer flush, e então MoveFileExW com REPLACE_EXISTING e WRITE_THROUGH
Cada passo se recusa a prosseguir a menos que o anterior tenha terminado, o arquivo temporário vive no volume de destino por construção, uma janela de delete-first nunca existe, e a limpeza num finally não deixa detritos .tmp para trás

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

Por que o writer de publicação de QDF copia a DACL de destino antes de criar seu arquivo temporário: um descriptor nil herdaria as permissões mais amplas do diretório e o rename ampliaria o acesso em silêncio, então GetFileSecurityW lê a DACL, um bit SE_DACL_PRESENT ausente ou um atributo EFS para a publicação com 305, e o CreateFileW nasce com as permissões antigas
A segurança do NTFS viaja com o objeto de arquivo, não com o nome: passar o descriptor lido como lpSecurityAttributes faz o rename não mudar nada que o operador tenha configurado, e todo portão falha fechado em vez de adivinhar

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_CONTROL no destino
  • O descriptor devolvido não tem SE_DACL_PRESENT definido; entregar tal descriptor ao CreateFileW deixaria 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