Artigo Técnico

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

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

Como o RepairQDFFile da PDF Library for Delphi parou de destruir o próprio alvo: a v3.539.12 abria a saída com PLCreateFileStream e fmCreate, que trunca antes de o PDFQDFRepair conseguir rejeitar a entrada, a v3.539.13 reparava primeiro num TMemoryStream, e a v3.539.15 entrega os bytes ao TPDFQDFFileWriter para publicação atômica
A correção de falha de parse e a correção de publicação são fronteiras diferentes: o reparo memory-first protege contra entrada ruim, enquanto o writer existe para que disco cheio ou uma falha no meio da escrita não deixe mais o destino danificado
// 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

Os quatro passos atômicos do TPDFQDFFileWriter.Save 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
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

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 param 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
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