PDF Library for Delphi publiceert de output van RepairQDFFile via een interne writer, TPDFQDFFileWriter, die de bestemming nooit voor schrijven opent: de gerepareerde bytes gaan naar een exclusief aangemaakt tijdelijk bestand in dezelfde map, het bestand wordt geflusht en gesloten, en pas daarna wordt het over het doel heen gehernoemd met MoveFileExW op Windows of rename(2) op POSIX. Als er iets faalt vóór de rename, behoudt de bestemming elke byte die hij had, en ziet de aanroeper LastErrorCode 305. Een document in geheugen repareren is de makkelijke helft van een reparatiefunctie. Het resultaat op schijf krijgen zonder de gebruiker ooit met een leeg of half geschreven bestand achter te laten is de helft waar dit artikel over gaat
Waarom kan een reparatie die faalt het doelbestand alsnog verwoesten?
Omdat de volgorde van de bewerkingen fout was. Vóór v3.539.13 opende RepairQDFFile de output met PLCreateFileStream(OutputFileName, fmCreate) en gaf die stream daarna aan de parser. fmCreate kapt af bij het openen, dus tegen de tijd dat de QDF-scan besloot dat de input niet te repareren was, was de bestemming al geleegd. In-place reparatie, waarbij InputFileName en OutputFileName hetzelfde pad zijn, veranderde een geweigerde input in een verloren bestand. De parser zelf gedroeg zich netjes: de low-level functie PDFQDFRepair laat de doelstream onaangeroerd wanneer hij dubbelzinnige markers weigert. Die bescherming was alleen zinloos, want de publieke API had het bestand één aanroep eerder al afgekapt
De fix in v3.539.13 verplaatste de reparatie naar een TMemoryStream en opende de output pas nadat PDFQDFRepair was geslaagd. Dat dicht het gat van de parsefout en verder niets. De schrijffase was nog steeds fmCreate gevolgd door CopyFrom, dus een volle schijf, een sharing violation halverwege, of een exception tussen het afkappen en de laatste WriteBuffer liet nog steeds een beschadigde bestemming achter. Memory-first repareren beschermt tegen slechte input. Publicatie op schijf heeft zijn eigen grens nodig, en v3.539.14 en v3.539.15 hebben die gebouwd
// v3.539.12: de bestemming wordt afgekapt voordat de input is gevalideerd
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // te laat om nee te zeggen
Result := 1;
finally
Output.Free;
end;
// v3.539.15: repareer in geheugen en geef de bytes aan de publicatiewriter
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // bestemming nooit geopend
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Wat garandeert atomaire publicatie nu eigenlijk?
TPDFQDFFileWriter.Save garandeert dat het bestemmingspad ofwel het volledige oude bestand ofwel het volledige nieuwe bestand is, nooit een mengeling, voor elke fout die de library zelf kan waarnemen. De writer doet dat in vier stappen die elk weigeren door te gaan tenzij de vorige is afgerond. Eerst lost hij de bestemming op met GetFullPathNameW, twee keer aangeroepen, met de buffer gealloceerd op de teruggegeven lengte in plaats van uit te gaan van MAX_PATH, zodat lange paden niet stilzwijgend worden afgekapt. Ten tweede maakt hij een tijdelijk bestand aan met de naam .pdflib-qdf- plus een GUID plus .tmp in de bestemmingsmap, met CreateFileW en CREATE_NEW op Windows en open(2) met O_CREAT or O_EXCL en mode 0600 op POSIX. Beide flags laten het aanmaken mislukken als de naam al bestaat, dus twee processen die op dezelfde GUID racen kunnen geen handle delen. Ten derde kopieert hij de gerepareerde stream in blokken van 64 KiB via WriteBuffer, dat bij een korte write een fout gooit in plaats van een aantal terug te geven dat niemand controleert, en roept daarna FlushFileBuffers of fsync(2) aan en sluit de handle. Ten vierde hernoemt hij
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
// Sta geen kopie over volumes toe en verwijder de bestemming niet eerst
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
De renamestap is waar de meeste zelfgebouwde "veilig opslaan"-routines stilletjes stukgaan. MoveFileExW met MOVEFILE_REPLACE_EXISTING vervangt het doel in één bestandssysteemoperatie op hetzelfde volume. De writer laat MOVEFILE_COPY_ALLOWED bewust weg, want een verplaatsing over volumes ontaardt in kopiëren-dan-verwijderen, precies de niet-atomaire reeks die het hele ontwerp wil vermijden. Omdat het tijdelijke bestand in de bestemmingsmap staat, zit het per constructie op het bestemmingsvolume. De writer verwijdert het oude bestand ook nooit eerst; een paar van verwijderen-dan-hernoemen heeft een venster waarin het pad helemaal niet bestaat, en een crash binnen dat venster kost het document. MOVEFILE_WRITE_THROUGH vraagt de aanroep niet terug te keren voordat de rename de schijf heeft bereikt, wat samengaat met de expliciete flush van de data. Op POSIX garandeert rename(2) al dat de nieuwe naam elk bestaand bestand atomair vervangt, en dezelfde plaatsing in de map voorkomt dat het met EXDEV faalt. De opruiming is symmetrisch. De tijdelijke naam wordt op elk pad in een finally-blok verwijderd, wat bij succes een no-op is omdat de rename hem al heeft opgeslokt, en bij een fout het gedeeltelijke bestand weghaalt zodat de map geen .tmp-rommel verzamelt. De regressietest in Tests\QDFFileRegression.inc controleert precies dat: na elke geïnjecteerde fout komen de bytes van de bestemming overeen met het origineel, komen de bytes van de source overeen met het origineel, en bevat de map niets anders dan de twee fixtures
Waarom verruimt een tijdelijk bestand de rechten op Windows?
Een bestand dat met een nil security descriptor wordt aangemaakt erft zijn DACL van de bovenliggende map, niet van het bestand dat het op het punt staat te vervangen. Dat is de juiste standaard voor een gloednieuw document en de verkeerde voor een in-place reparatie. Stel dat een beheerder contract.pdf heeft dichtgezet op één account met een beschermde, niet-overgeërfde DACL. Een tijdelijk bestand ernaast erft de ruimere rechten van de map, en zodra het over contract.pdf is gehernoemd draagt het gehernoemde bestand de ruime DACL, want NTFS-beveiliging reist mee met het bestandsobject, niet met de naam. De reparatie slaagt, de bytes zijn goed, en de toegangscontrole die de beheerder had ingesteld is stilzwijgend verdwenen. Niets in de returnwaarde wijst erop
PDF Library for Delphi leest daarom de DACL van de bestemming voordat hij het tijdelijke bestand aanmaakt en geeft die mee als het argument lpSecurityAttributes van CreateFileW, zodat het nieuwe bestand geboren wordt met de rechten van het oude bestand en de rename niets verandert wat de beheerder zou merken. Het uitlezen gebruikt GetFileSecurityW met DACL_SECURITY_INFORMATION, waarbij de buffer wordt gedimensioneerd op het ERROR_INSUFFICIENT_BUFFER-resultaat van de eerste aanroep. Drie voorwaarden laten de writer fail closed gaan in plaats van te gokken. Als de DACL niet kan worden gelezen, stopt de publicatie met een EWriteError, die de publieke API op 305 mapt. Als de descriptor terugkomt zonder dat SE_DACL_PRESENT gezet is, stopt de publicatie ook, want zo'n descriptor aan CreateFileW geven zou de kernel laten terugvallen op de standaard-DACL van het proces en de toegangssemantiek veranderen zonder dat iemand daarom vroeg. En als het doel FILE_ATTRIBUTE_ENCRYPTED draagt, weigert de writer botweg: het tijdelijke bestand zou plaintext zijn, en een plaintext bestand over een EFS-beschermd bestand heen hernoemen publiceert een onversleutelde vervanging van iets wat de gebruiker op bestandssysteemniveau wilde versleutelen. EFS staat los van de standaard security handlers van PDF, het onderwerp van het artikel over het laden van versleutelde documenten, maar de faalvorm is dezelfde soort stille degradatie
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');
// dimensioneer de descriptor en lees daarna alleen het DACL-deel
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; // gaat naar CreateFileW / CREATE_NEW
end;
Eén detail uit de regressietest is het onthouden waard als u zelf een vergelijkbare test schrijft. Om de beperkte fixture te bouwen past de test een DACL toe die alleen de owner toestaat, en moet hij SE_DACL_PROTECTED expliciet in de descriptor control zetten; alleen de protected-flag meegeven in het argument SecurityInformation van SetFileSecurityW maakt van een onbeschermde descriptor geen beschermde. De assertie daarna is dat het gepubliceerde bestand nog steeds de protected-bit en een expliciete, niet-null DACL meldt, zowel voor een apart uitvoerpad als voor reparatie over het bronbestand zelf
Welke LastErrorCode vertelt je wat er misging?
RepairQDFFile geeft 1 terug bij succes en 0 bij elke fout, en LastErrorCode zegt welke fase weigerde. Een source die niet te lezen is, inclusief een die een ander proces met een exclusieve lock vasthoudt, meldt 401; het lezen is nu omwikkeld zodat een exception tijdens de input op 401 mapt in plaats van in de write-fout te lekken. Ongeldige of dubbelzinnige QDF-structuur, zoals een dubbele streammarker voor hetzelfde object, meldt PDFLIB_ERROR_QDF_REPAIR, dat is 107, en de bestemming is onaangeroerd omdat de writer nooit is aangemaakt. Alles na de reparatie, van het aanmaken van het tijdelijke bestand tot flush en rename, meldt PDFLIB_ERROR_QDF_WRITE, dat is 305. De regressietest oefent de realistische gevallen: een bestemming die door een andere handle zonder delete sharing is geopend, een alleen-lezen bestemming, een ontbrekende bestemmingsmap, en elk van de drie writerfasen die via injectie faalt. In al die gevallen is de return 0, is de code 305, en bestaat er daarna geen nieuw of gedeeltelijk doel. De algemene gewoonte om de code te lezen in plaats van alleen de returnwaarde is dezelfde als beschreven in het artikel over het diagnosticeren van stille fouten in de library
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// In-place reparatie: hetzelfde pad is input en 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;
Waar de garantie ophoudt
De writer belooft consistentie tegen fouten die het proces kan zien, en is eerlijk over de fouten die het niet kan zien. Wordt het proces gedood tussen het aanmaken van het tijdelijke bestand en de rename, dan loopt het finally-blok nooit en blijft er een .pdflib-qdf-<GUID>.tmp-bestand in de map achter; de bestemming is nog intact, en dat is de eigenschap die telt, maar de rommel mag u zelf opruimen. Stroomuitval valt ook buiten de belofte: de data is geflusht en de rename is write-through, wat het beste is wat een user-mode library kan vragen, maar de writer fsynct de mapentry niet en doet geen duurzaamheidsclaim bovenop wat het bestandssysteem biedt. Een tweede writer die de bestemming gelijktijdig wijzigt wordt niet gedetecteerd, want de DACL en attributen worden gelezen voordat het tijdelijke bestand wordt aangemaakt en niets controleert ze opnieuw op het moment van de rename. En een geslaagde rename maakt een nieuwe bestandsidentiteit, dus alternate data streams en gewone attributen zoals de archief- of verborgen-bit op het oude bestand overleven het niet; alleen de DACL wordt bewust meegenomen
De nauwere grens is welke API dit pad überhaupt gebruikt. Alleen RepairQDFFile gaat via TPDFQDFFileWriter. SaveQDFToFile en ConvertFileToQDF openen hun output nog steeds met PLCreateFileStream(FileName, fmCreate) en streamen de QDF-conversie er direct in, net zoals het incrementele pad uit het artikel over het toevoegen van updates aan een stream schrijft naar welke stream u hem ook geeft. Die twee aanroepen produceren een nieuw debugging-artefact uit een document dat al is geladen en gevalideerd, dus het gat van de parsefout gold nooit voor hen, maar ze erven ook de rename-gebaseerde publicatie niet. Lees dit artikel niet als "elke QDF-export is atomair". Het gaat om één uitgang, de uitgang waarvan de input een niet-vertrouwd, met de hand bewerkt bestand is en waarvan de output steevast hetzelfde pad is, en die combinatie heeft hem de extra machinerie opgeleverd. De foutinjectie die dit alles bewijst is goedkoop omdat de drie fasen van de writer, WriteData, Flush en Publish, virtual zijn. De testsubklasse override't er één zodat hij een fout gooit nadat het echte werk is begonnen, roept Save aan op een gerepareerde stream, en stelt vast dat de exception doorwerkt, dat de bytes van source en bestemming ongewijzigd zijn, en dat er geen tijdelijk bestand achterblijft. Geen enkele globale bestands-API wordt gehookt, geen echt gebruikersbestand wordt aangeraakt, en de drie fasen mappen één op één op de drie manieren waarop een publicatie in productie kan falen: de schijf loopt vol, de flush wordt geweigerd, of de rename wordt geweigerd omdat iemand anders het doel vasthoudt
De RepairQDFFile-API, de bijbehorende atomaire publicatiewriter en de rest van de QDF-debuggingworkflow maken deel uit van de PDF Library for Delphi, naast de functies voor cross-reference recovery, incrementele updates en versleuteling die elders op dit blog aan bod komen