Articolo tecnico

Output di riparazione PDF atomico in Delphi: rename e DACL

Modifica a mano un file QDF, infila un secondo marker di stream per lo stesso oggetto, poi lancia una riparazione in place con il codice Delphi più naturale che sappi scrivere. Il passaggio di riparazione nota il danno e rifiuta. Quando poi guardi il file è lungo 0 byte, perché TFileStream.Create(OutputFileName, fmCreate) ha troncato proprio quel percorso una riga prima che il validatore vedesse mai l'input. Riparare i byte in memoria è la metà facile del lavoro. Riportarli su disco senza lasciare mai un file a lunghezza zero, scritto a metà o con permessi sbagliati è l'altra metà, e ha i suoi modi di fallire

Perché una riparazione in place svuota il file?

Una riparazione in place svuota il file perché l'ordine delle operazioni è sbagliato: fmCreate tronca all'apertura, quindi qualsiasi rifiuto che arrivi dopo l'apertura arriva tardi. Quando InputFileName e OutputFileName sono lo stesso percorso, un input rifiutato diventa un documento perso. Un parser attento non può salvarti qui. Può lasciare il suo stream di output intatto quando rifiuta marker ambigui, ma il chiamante ha già distrutto il target una chiamata prima

// La versione ovvia: la destinazione viene troncata prima che nulla venga validato
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // stesso percorso di OutputFileName
  Output := TFileStream.Create(OutputFileName, fmCreate);  // il file ora è 0 byte
  try
    if not MyRepairQDF(Input, Output) then  // il tuo validatore: troppo tardi per dire no
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

Il prossimo tentativo consueto è riparare in un TMemoryStream e aprire l'output solo dopo che la riparazione è riuscita. Questo chiude il buco dell'input cattivo e nient'altro. La fase di scrittura è ancora truncate-then-copy, quindi un disco pieno, una sharing violation a metà strada o un'eccezione tra il troncamento e l'ultimo WriteBuffer lasciano comunque la destinazione danneggiata. Analisi e pubblicazione sono due confini separati e ciascuno ha bisogno della sua protezione

In PDF Library for Delphi, TPDFlib.RepairQDFFile(InputFileName, OutputFileName) li gestisce entrambi. Legge l'input, lo ripara interamente in memoria, e ritorna presto senza aver aperto nulla sulla destinazione se la struttura QDF viene rifiutata. Solo una riparazione riuscita prosegue verso la pubblicazione, e la pubblicazione non apre mai la destinazione in scrittura

Tre modi di pubblicare una riparazione QDF in Delphi: aprire l'output con fmCreate lo tronca prima che la riparazione possa rifiutare l'input, riparare prima in un TMemoryStream protegge solo dall'input cattivo, e RepairQDFFile pubblica i byte riparati attraverso un file temporaneo e un rename atomico
La sicurezza del parsing e la sicurezza della pubblicazione sono confini diversi: la riparazione memory-first protegge dall'input cattivo, mentre lo writer esiste perché un disco pieno o un fallimento a metà di una scrittura non possano lasciare la destinazione danneggiata

Quattro passi tra i byte riparati e la destinazione

RepairQDFFile garantisce che il percorso di destinazione contenga o il file vecchio completo o il file nuovo completo, per ogni fallimento che il processo stesso può osservare. Ci arriva in quattro passi, e nessuno di loro parte finché il precedente non è finito. La destinazione viene risolta a percorso completo con GetFullPathNameW, chiamata una volta per conoscere la lunghezza richiesta e una seconda volta in un buffer di esattamente quella dimensione, così un percorso lungo non viene tagliato a MAX_PATH. Poi un file temporaneo nominato .pdflib-qdf- più un GUID più .tmp viene creato nella directory di destinazione con CreateFileW e CREATE_NEW su Windows, oppure open(2) con O_CREAT or O_EXCL e modalità 0600 su POSIX. Entrambi i flag fanno fallire la creazione se il nome esiste già. Due processi che per qualche motivo hanno pescato lo stesso GUID non possono finire a condividere un handle

Lo stream riparato viene poi copiato a chunk da 64 KiB attraverso WriteBuffer, che solleva un'eccezione su una scrittura corta invece di restituire un conteggio che nessuno controlla. FlushFileBuffers o fsync(2) spingono giù i dati, e l'handle viene chiuso. La chiusura deliberatamente non solleva: una volta che il flush è riuscito, una chiusura fallita non minaccia i dati, e un'eccezione lì nasconderebbe il vero errore di scrittura o flush a chi deve fare la diagnosi. L'ultimo passo è il rename

I quattro passi con cui RepairQDFFile pubblica il suo output in PDF Library for Delphi: risolvere il percorso due volte con GetFullPathNameW, creare il file temporaneo .pdflib-qdf con CREATE_NEW o O_EXCL così i processi in gara non possono condividere un handle, copiare a chunk WriteBuffer da 64 KiB e flushare, poi MoveFileExW con REPLACE_EXISTING e WRITE_THROUGH
Ogni passo rifiuta di procedere finché il precedente non è finito, il file temporaneo sta sul volume di destinazione per costruzione, una finestra di delete-first non esiste mai, e la pulizia in un finally non lascia detriti .tmp

Il rename è dove la maggior parte delle routine safe-save fatte in casa si rompe. Su Windows il file temporaneo rimpiazza il target attraverso MoveFileExW con MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, che su un volume è una singola operazione del filesystem. MOVEFILE_COPY_ALLOWED resta fuori di proposito. Una move tra volumi si degrada silenziosamente in copy-then-delete, la precisa sequenza non atomica che tutto il design evita, e vedo molto più volentieri un rename rifiutato che una copia "riuscita" finita a metà quando la macchina è caduta. Dato che il file temporaneo sta nella directory di destinazione, è comunque sul volume di destinazione. Il file vecchio non viene nemmeno cancellato prima; una coppia delete-then-rename lascia una finestra in cui il percorso non esiste, e un crash dentro quella finestra perde il documento. MOVEFILE_WRITE_THROUGH impedisce alla chiamata di tornare prima che il rename abbia raggiunto il disco. Su POSIX, rename(2) già rimpiazza un nome esistente in modo atomico, e il piazzamento nella stessa directory lo tiene lontano da EXDEV

La pulizia gira in un blocco finally su ogni percorso. Dopo un rename riuscito il nome temporaneo non esiste più, quindi la delete non fa nulla; dopo un fallimento rimuove il file parziale. La directory finisce con esattamente i file che aveva prima della chiamata

Perché un file temporaneo allenta i permessi su Windows?

Un file creato con un descrittore di sicurezza nil eredita la sua DACL dalla directory madre; il file che sta per rimpiazzare non c'entra. Per un documento completamente nuovo quell'impostazione predefinita è giusta. Per una riparazione in place è sbagliata. Immagina un operatore che ha bloccato contract.pdf su un solo account con una DACL protetta e non ereditata. Un file temporaneo semplice accanto becca i permessi più larghi della directory, e dopo il rename il contract.pdf pubblicato porta quella DACL larga, perché la sicurezza NTFS appartiene all'oggetto file e non al nome. I byte sono corretti e il valore di ritorno è 1. La restrizione di accesso che l'operatore aveva impostato semplicemente non c'è più

RepairQDFFile quindi legge la DACL della destinazione prima di creare il file temporaneo e la passa a CreateFileW attraverso lpSecurityAttributes, così il nuovo file nasce con i permessi del file vecchio e il rename non cambia nulla che un operatore noti. La lettura è GetFileSecurityW con DACL_SECURITY_INFORMATION, col buffer dimensionato dalla risposta ERROR_INSUFFICIENT_BUFFER della prima chiamata. Quando la destinazione non esiste ancora, il caso normale per un percorso di output separato, nessun descrittore viene passato e il nuovo file eredita dalla sua directory come qualunque altro file nuovo

Perché lo writer di pubblicazione QDF copia la DACL della destinazione prima di creare il suo file temporaneo: un descrittore nil erediterebbe i permessi più larghi della directory e il rename allargherebbe silenziosamente l'accesso, quindi GetFileSecurityW legge la DACL, un bit SE_DACL_PRESENT assente o un attributo EFS fermano la pubblicazione con 305, e CreateFileW nasce con i vecchi permessi
La sicurezza NTFS viaggia con l'oggetto file, non col nome: passare il descrittore letto come lpSecurityAttributes fa sì che il rename non cambi nulla di ciò che l'operatore ha configurato, e ogni gate fallisce chiuso invece di tirare a indovinare

Tre condizioni fermano la pubblicazione invece di lasciar tirare a indovinare il codice, e ognuna riporta LastErrorCode 305:

  • La DACL non può essere letta, per esempio perché l'account chiamante non ha READ_CONTROL sulla destinazione
  • Il descrittore restituito non ha SE_DACL_PRESENT impostato; consegnare un descrittore del genere a CreateFileW lascerebbe Windows ricadere sulla DACL predefinita del processo e cambiare semantica di accesso che nessuno ha chiesto di cambiare
  • La destinazione porta FILE_ATTRIBUTE_ENCRYPTED; il file temporaneo sarebbe in chiaro, e rinominarlo sopra un file protetto EFS pubblicherrebbe un rimpiazzo non cifrato di qualcosa che l'utente ha scelto di cifrare

EFS è una funzionalità del filesystem e non c'entra nulla con i security handler PDF, che sono trattati nell'articolo sul caricamento di documenti cifrati. La forma del fallimento è però lo stesso tipo di downgrade silenzioso, e rifiutarlo è l'impostazione predefinita corretta. Una riparazione che ogni tanto dice no su una share EFS è un ticket di supporto. Una riparazione che decifra un file che l'utente ha cifrato è un incidente

Se scrivi tu stesso un test di preservazione della DACL, un dettaglio Windows è facile da sbagliare. Per costruire il file di test con restrizioni, applica una DACL owner-only e imposta SE_DACL_PROTECTED nei control del descrittore esplicitamente; passare PROTECTED_DACL_SECURITY_INFORMATION a SetFileSecurityW non trasforma un descrittore non protetto in uno protetto. Dopo, controlla che il file pubblicato riporti ancora il bit protetto e una DACL esplicita e non nulla, una volta per un percorso di output separato e una volta per la riparazione sulla sorgente stessa

Quale LastErrorCode ti dice cosa è fallito?

RepairQDFFile restituisce 1 al successo e 0 al fallimento, e TPDFlib.LastErrorCode nomina la fase che ha rifiutato. Un input che non esiste, non si può aprire o è più grande di 2 GB riporta 401, e un'eccezione durante la lettura viene mappata anch'essa su 401 invece di filtrare nel codice di scrittura. Una struttura QDF invalida o ambigua, come quel marker di stream duplicato, riporta PDFLIB_ERROR_QDF_REPAIR (107); la destinazione non è mai stata aperta. Tutto ciò che viene dopo la riparazione, dalla risoluzione del percorso fino a flush e rename, riporta PDFLIB_ERROR_QDF_WRITE (305). I fallimenti di scrittura realistici atterrano tutti su 305: una destinazione tenuta aperta da un altro handle senza delete sharing, una destinazione read-only, una directory di destinazione mancante, più i rifiuti DACL ed EFS qui sopra. In ogni caso 305 non resta dietro nessun target nuovo o parziale

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Riparazione in place: lo stesso percorso è input e output
    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;

Il percorso 305 è facile da riprodurre dal tuo progetto di test senza hookare alcuna API di file. fmShareDenyNone di Delphi apre un file con condivisione lettura e scrittura ma senza FILE_SHARE_DELETE, e Windows rifiuta di rinominare qualsiasi cosa sopra un file tenuto così. Tieni un handle del genere aperto attraverso la chiamata e il rename fallisce all'ultimo passo, dopo che i dati sono stati scritti e flushati, che è proprio il caso che più vuoi vedere gestito

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;
  // Questi controlli sono suggerimenti per il tuo test, non chiamate della libreria
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual: helper tuo
  // Nessun file temporaneo può restare dietro
  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;

Per l'abitudine di leggere il codice invece di fidarsi del solo 0, l'articolo sulla diagnosi dei fallimenti silenziosi percorre lo stesso schema nel resto dell'API

Dove la garanzia si ferma

La promessa copre i fallimenti che il processo può vedere. Se il processo viene ucciso tra la creazione del file temporaneo e il rename, il blocco finally non gira mai e un file .pdflib-qdf-<GUID>.tmp resta nella directory. La destinazione è comunque intatta, che è ciò che conta, ma spazzare quei detriti tocca a te. La perdita di corrente è una questione da filesystem: i dati vengono flushati e il rename è write-through, ma la directory entry non viene sincronizzata separatamente su POSIX, quindi la durabilità è quella che il filesystem offre sopra quel livello

Un rename riuscito produce anche un nuovo oggetto file, e alcune proprietà del vecchio non viaggiano con lui. La DACL viene portata oltre deliberatamente. La proprietà no, dato che viene letto solo DACL_SECURITY_INFORMATION, quindi il file pubblicato appartiene all'account che ha eseguito la riparazione. Il file temporaneo viene creato con FILE_ATTRIBUTE_NORMAL, il che significa che i bit hidden, archive e simili ripartono da zero, e gli alternate data stream del file vecchio sono spariti. Su POSIX il file pubblicato conserva la modalità 0600 del temporaneo; chiama chmod dopo se un gruppo deve leggere. Gli attributi e la DACL della destinazione vengono letti prima che il file temporaneo esista, quindi un secondo writer sullo stesso percorso è roba tua da coordinare

SaveQDFToFile e ConvertFileToQDF scrivono direttamente

RepairQDFFile è l'unico punto d'ingresso QDF che pubblica attraverso un file temporaneo. SaveQDFToFile e ConvertFileToQDF aprono il loro output con fmCreate e streamano la conversione direttamente dentro, molto come il percorso incrementale nell'articolo sull'accodamento di update a uno stream scrive in qualunque stream tu fornisca. La divisione è voluta. Quelle due chiamate trasformano un documento già caricato e validato in un nuovo artefatto di debug, quindi non c'è input non fidato che possa essere rifiutato a metà strada, e il loro percorso di output di solito è un file fresco. RepairQDFFile prende un file modificato a mano e non fidato e viene puntato di routine verso lo stesso percorso; quella combinazione è ciò che giustifica il macchinario in più

Quando vuoi davvero la sostituzione atomica per un dump QDF, un breve wrapper tuo basta:

// Helper tuo: scrivi il QDF accanto al target, poi rimpiazzalo in un solo 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 solo quando qualcosa è fallito
  end;
end;

Questa versione salta la copia della DACL e il flush esplicito dei dati che RepairQDFFile esegue. Per dump QDF che atterrano in una cartella di scratch o di debug, direi che è la giusta quantità di macchinario; se il dump rimpiazza un file con permessi bloccati, falla passare dal percorso di riparazione o copia tu il descrittore di sicurezza

L'output atomico di riparazione, le chiamate di conversione QDF e i codici errore qui sopra escono tutti in PDF Library for Delphi, accanto al recupero cross-reference, all'update incrementale e al supporto cifratura di cui parlano altri post qui