PDF Library for Delphi pubblica l'output di RepairQDFFile tramite un writer interno, TPDFQDFFileWriter, che non apre mai la destinazione in scrittura: i byte riparati finiscono in un file temporaneo creato in modo esclusivo nella stessa directory, il file viene flushato e chiuso, e solo allora viene rinominato sopra il target con MoveFileExW su Windows o rename(2) su POSIX. Se qualcosa fallisce prima del rename, la destinazione conserva ogni byte che aveva, e il chiamante vede LastErrorCode 305. Riparare un documento in memoria è la metà facile di una funzione di riparazione. Mettere il risultato su disco senza mai lasciare all'utente un file a lunghezza zero o scritto a metà è la metà di cui parla questo articolo
Perché una riparazione fallita può comunque distruggere il file di destinazione?
Perché l'ordine delle operazioni era sbagliato. Prima della v3.539.13, RepairQDFFile apriva l'output con PLCreateFileStream(OutputFileName, fmCreate) e poi passava quello stream al parser. fmCreate tronca all'apertura, quindi nel momento in cui la scansione QDF decideva che l'input non era riparabile, la destinazione era già stata svuotata. La riparazione in place, dove InputFileName e OutputFileName sono lo stesso percorso, trasformava un input rifiutato in un file perso. Il parser in sé si comportava bene: la funzione di basso livello PDFQDFRepair lascia intatto lo stream di destinazione quando rifiuta marker ambigui. Quella protezione era semplicemente irrilevante, perché l'API pubblica aveva troncato il file una chiamata prima
La correzione della v3.539.13 ha spostato la riparazione in un TMemoryStream e ha aperto l'output solo dopo che PDFQDFRepair era andato a buon fine. Questo chiude il buco del parse fallito e nient'altro. La fase di scrittura era ancora fmCreate seguito da CopyFrom, quindi un disco pieno, una sharing violation a metà, o un'eccezione tra il troncamento e l'ultima WriteBuffer lasciavano comunque una destinazione danneggiata. La riparazione prima in memoria protegge da input cattivi. La pubblicazione su disco ha bisogno di un confine proprio, e le v3.539.14 e v3.539.15 ne hanno costruito uno
// v3.539.12: la destinazione viene troncata prima che l'input sia validato
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // troppo tardi per dire di no
Result := 1;
finally
Output.Free;
end;
// v3.539.15: riparazione in memoria, poi i byte al writer di pubblicazione
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // la destinazione non viene mai aperta
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Cosa garantisce davvero la pubblicazione atomica?
TPDFQDFFileWriter.Save garantisce che il percorso di destinazione sia o il vecchio file completo o il nuovo file completo, mai un misto, per ogni guasto che la libreria stessa riesca a osservare. Il writer lo fa in quattro passi, ognuno dei quali rifiuta di procedere se il precedente non è finito. Primo, risolve la destinazione con GetFullPathNameW, chiamandola due volte e allocando il buffer dalla lunghezza restituita invece di dare per scontato MAX_PATH, così i percorsi lunghi non vengono tagliati in silenzio. Secondo, crea un file temporaneo chiamato .pdflib-qdf- più un GUID più .tmp nella directory di destinazione, usando CreateFileW con CREATE_NEW su Windows e open(2) con O_CREAT or O_EXCL e mode 0600 su POSIX. Entrambi i flag fanno fallire la creazione se il nome esiste già, quindi due processi in gara sullo stesso GUID non possono condividere un handle. Terzo, copia lo stream riparato in blocchi da 64 KiB tramite WriteBuffer, che solleva un'eccezione su una scrittura corta invece di restituire un conteggio che nessuno controlla, poi chiama FlushFileBuffers o fsync(2) e chiude l'handle. Quarto, rinomina
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
// Non permettere una copia cross-volume né cancellare prima la destinazione
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Il passo di rename è dove la maggior parte delle routine "safe save" fatte in casa si rompe in silenzio. MoveFileExW con MOVEFILE_REPLACE_EXISTING sostituisce il target in una sola operazione del file system sullo stesso volume. Il writer omette di proposito MOVEFILE_COPY_ALLOWED, perché uno spostamento cross-volume degenera in copia-e-cancella, che è esattamente la sequenza non atomica che tutto il progetto esiste per evitare. Dato che il file temporaneo vive nella directory di destinazione, è per costruzione sul volume di destinazione. Il writer non cancella mai prima il vecchio file; una coppia cancella-poi-rinomina ha una finestra in cui il percorso non esiste affatto, e un crash dentro quella finestra perde il documento. MOVEFILE_WRITE_THROUGH chiede alla chiamata di non tornare finché il rename non ha raggiunto il disco, il che si accoppia con il flush esplicito dei dati. Su POSIX, rename(2) garantisce già che il nuovo nome sostituisca atomicamente qualunque file esistente, e la stessa collocazione nella directory evita che fallisca con EXDEV. La pulizia è simmetrica. Il nome temporaneo viene rimosso in un blocco finally su ogni percorso, il che in caso di successo è un no-op perché il rename lo ha già consumato, e in caso di fallimento rimuove il file parziale così la directory non accumula detriti .tmp. La regressione in Tests\QDFFileRegression.inc controlla esattamente questo: dopo ogni guasto iniettato, i byte della destinazione corrispondono all'originale, i byte della sorgente corrispondono all'originale, e la directory non contiene altro che le due fixture
Perché un file temporaneo allenta i permessi su Windows?
Un file creato con un security descriptor nil eredita la propria DACL dalla directory padre, non dal file che sta per sostituire. È il default corretto per un documento nuovo di zecca e quello sbagliato per una riparazione in place. Supponi che un operatore abbia blindato contract.pdf su un solo account con una DACL protetta e non ereditata. Un file temporaneo accanto a esso eredita i permessi più larghi della directory, e una volta rinominato sopra contract.pdf il file rinominato porta la DACL larga, perché la sicurezza NTFS viaggia con l'oggetto file, non con il nome. La riparazione riesce, i byte sono corretti, e il controllo degli accessi configurato dall'operatore è sparito in silenzio. Niente nel valore di ritorno lo lascia intuire
PDF Library for Delphi legge quindi la DACL della destinazione prima di creare il file temporaneo e la passa come argomento lpSecurityAttributes a CreateFileW, così il nuovo file nasce con i permessi del vecchio file e il rename non cambia niente che l'operatore noterebbe. La lettura usa GetFileSecurityW con DACL_SECURITY_INFORMATION, dimensionando il buffer dal risultato ERROR_INSUFFICIENT_BUFFER della prima chiamata. Tre condizioni fanno fallire il writer in modo chiuso invece di tirare a indovinare. Se la DACL non è leggibile, la pubblicazione si ferma con un EWriteError, che l'API pubblica mappa su 305. Se il descrittore torna senza SE_DACL_PRESENT impostato, anche la pubblicazione si ferma, perché passare un descrittore così a CreateFileW lascerebbe che il kernel ricada sulla DACL di default del processo e cambi la semantica di accesso senza che nessuno l'abbia chiesto. E se il target porta FILE_ATTRIBUTE_ENCRYPTED, il writer rifiuta senza mezzi termini: il file temporaneo sarebbe in chiaro, e rinominare un file in chiaro sopra uno protetto da EFS pubblica una sostituzione non cifrata di qualcosa che l'utente ha scelto di cifrare a livello di file system. EFS non ha niente a che vedere con gli standard security handler PDF, che sono l'argomento di l'articolo sul caricamento di documenti cifrati, ma la modalità di guasto è lo stesso tipo di declassamento silenzioso
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 il descrittore, poi ne legge solo la porzione DACL
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; // passato a CreateFileW / CREATE_NEW
end;
Un dettaglio della regressione vale la pena tenerlo a mente se scrivi un test simile per conto tuo. Per costruire la fixture ristretta, il test applica una DACL solo-proprietario e deve impostare SE_DACL_PROTECTED nel control del descrittore esplicitamente; passare il flag protected nel solo argomento SecurityInformation di SetFileSecurityW non trasforma un descrittore non protetto in uno protetto. L'asserzione successiva è che il file pubblicato riporti ancora il bit protected e una DACL esplicita e non nulla, sia per un percorso di output separato sia per la riparazione sopra il file sorgente stesso
Quale LastErrorCode ti dice cosa è fallito?
RepairQDFFile restituisce 1 in caso di successo e 0 in caso di qualunque fallimento, e LastErrorCode dice quale fase ha rifiutato. Una sorgente che non si riesce a leggere, inclusa una che un altro processo tiene con un lock esclusivo, riporta 401; la lettura ora è avvolta in modo che un'eccezione durante l'input si mappi su 401 invece di finire nell'errore di scrittura. Una struttura QDF non valida o ambigua, come un marker di stream duplicato per lo stesso oggetto, riporta PDFLIB_ERROR_QDF_REPAIR, cioè 107, e la destinazione non è stata toccata perché il writer non è mai stato costruito. Tutto ciò che viene dopo la riparazione, dalla creazione del file temporaneo fino al flush e al rename, riporta PDFLIB_ERROR_QDF_WRITE, cioè 305. La regressione esercita quelli realistici: una destinazione aperta da un altro handle senza delete sharing, una destinazione in sola lettura, una directory di destinazione mancante, e ognuna delle tre fasi del writer che fallisce per iniezione. In tutti i casi il ritorno è 0, il codice è 305, e dopo non esiste nessun target nuovo o parziale. L'abitudine generale di leggere il codice invece del solo valore di ritorno è la stessa descritta in l'articolo sulla diagnosi dei fallimenti silenziosi nella libreria
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');
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;
Dove si ferma la garanzia
Il writer promette coerenza contro i guasti che il processo riesce a vedere, ed è onesto su quelli che non riesce a vedere. Se il processo viene ucciso tra la creazione del file temporaneo e il rename, il blocco finally non gira mai e nella directory resta un file .pdflib-qdf-<GUID>.tmp; la destinazione è comunque intatta, che è la proprietà che conta, ma i detriti tocca a te spazzarli. Anche la perdita di corrente è fuori dalla promessa: i dati vengono flushati e il rename è write-through, che è il massimo che una libreria in user mode possa chiedere, ma il writer non fa fsync della directory entry e non rivendica nessuna durabilità oltre a quella che il file system fornisce. Un secondo writer che modifica la destinazione in parallelo non viene rilevato, perché DACL e attributi vengono letti prima che il file temporaneo sia creato e niente li ricontrolla al momento del rename. E un rename riuscito crea una nuova identità di file, quindi alternate data stream e attributi ordinari come il bit archive o hidden del vecchio file non sopravvivono; solo la DACL viene trasferita di proposito
Il confine più stretto è quale API usi mai questo percorso. Solo RepairQDFFile passa per TPDFQDFFileWriter. SaveQDFToFile e ConvertFileToQDF aprono ancora il loro output con PLCreateFileStream(FileName, fmCreate) e scrivono la conversione QDF direttamente lì dentro, allo stesso modo in cui il percorso incrementale descritto in l'articolo sull'aggiunta di aggiornamenti a uno stream scrive nello stream che gli passi. Quelle due chiamate producono un nuovo artefatto di debug da un documento già caricato e validato, quindi il buco del parse fallito non le ha mai riguardate, ma non ereditano nemmeno la pubblicazione basata su rename. Non leggere questo articolo come "ogni export QDF è atomico". È una sola uscita, quella il cui input è un file non fidato e modificato a mano e il cui output è abitualmente lo stesso percorso, ed è quella combinazione che le ha meritato la macchina in più. La fault injection che prova tutto questo costa poco perché le tre fasi del writer, WriteData, Flush e Publish, sono virtual. La sottoclasse di test ne sovrascrive una per sollevare un'eccezione dopo che il lavoro vero è iniziato, chiama Save su uno stream riparato, e verifica che l'eccezione si propaghi, che i byte di sorgente e destinazione siano invariati e che non resti nessun file temporaneo. Nessuna API di file globale viene agganciata, nessun file utente reale viene toccato, e le tre fasi si mappano una a una sui tre modi in cui una pubblicazione può fallire in produzione: il disco si riempie, il flush viene rifiutato, o il rename viene negato perché qualcun altro tiene il target
L'API RepairQDFFile, il suo writer di pubblicazione atomica e il resto del flusso di debug QDF fanno parte di PDF Library for Delphi, insieme alle funzioni di recupero del cross-reference, aggiornamento incrementale e cifratura coperte altrove su questo blog