PDF Library for Delphi publikuje wynik RepairQDFFile przez wewnętrzny writer, TPDFQDFFileWriter, który nigdy nie otwiera miejsca docelowego do zapisu: naprawione bajty trafiają do wyłącznie utworzonego pliku tymczasowego w tym samym katalogu, plik jest opróżniany i zamykany, a dopiero potem zmieniana jest jego nazwa na docelową przez MoveFileExW pod Windows albo rename(2) na POSIX. Jeśli cokolwiek padnie przed zmianą nazwy, miejsce docelowe zachowuje każdy bajt, jaki miało, a wywołujący widzi LastErrorCode 305. Naprawa dokumentu w pamięci to łatwiejsza połowa funkcji naprawy. Połową, o której jest ten artykuł, jest dostarczenie wyniku na dysk tak, żeby użytkownik nigdy nie został z plikiem zerowej długości albo napisanym do połowy
Dlaczego nieudana naprawa wciąż może zniszczyć plik docelowy?
Bo kolejność operacji była zła. Przed wersją v3.539.13 RepairQDFFile otwierało wyjście przez PLCreateFileStream(OutputFileName, fmCreate), a potem przekazywało ten strumień parserowi. fmCreate obcina plik przy otwarciu, więc zanim skan QDF uznał, że wejścia nie da się naprawić, miejsce docelowe było już opróżnione. Naprawa w miejscu, w której InputFileName i OutputFileName to ta sama ścieżka, zamieniała odrzucone wejście w utracony plik. Sam parser zachowywał się poprawnie: niskopoziomowa funkcja PDFQDFRepair zostawia strumień docelowy nietknięty, gdy odrzuca niejednoznaczne znaczniki. Ta ochrona była po prostu bez znaczenia, bo publiczne API obcięło plik jedno wywołanie wcześniej
Poprawka w v3.539.13 przeniosła naprawę do TMemoryStream i otwierała wyjście dopiero po udanym PDFQDFRepair. To zamyka dziurę przy błędzie parsowania i nic więcej. Faza zapisu to nadal było fmCreate, po którym następowało CopyFrom, więc zapełniony dysk, naruszenie współdzielenia w połowie drogi albo wyjątek między obcięciem a ostatnim WriteBuffer wciąż zostawiały uszkodzone miejsce docelowe. Naprawa najpierw w pamięci chroni przed złym wejściem. Publikacja na dysk potrzebuje własnej granicy i v3.539.14 oraz v3.539.15 taką granicę zbudowały
// v3.539.12: miejsce docelowe jest obcinane, zanim wejście zostanie zwalidowane
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
if PDFQDFRepair(Source, Output, QDFError) then // za późno, żeby powiedzieć nie
Result := 1;
finally
Output.Free;
end;
// v3.539.15: naprawa w pamięci, potem bajty do writera publikacji
Repaired := TMemoryStream.Create;
try
if not PDFQDFRepair(Source, Repaired, QDFError) then
Exit; // miejsce docelowe nigdy nie otwarte
Writer := TPDFQDFFileWriter.Create;
try
Writer.Save(Repaired, OutputFileName);
Result := 1;
finally
Writer.Free;
end;
finally
Repaired.Free;
end;
Co właściwie gwarantuje atomowa publikacja?
TPDFQDFFileWriter.Save gwarantuje, że ścieżka docelowa zawiera albo kompletny stary plik, albo kompletny nowy, nigdy mieszankę, przy każdej awarii, którą sama biblioteka jest w stanie zauważyć. Writer robi to w czterech krokach, z których każdy odmawia działania, dopóki poprzedni się nie zakończy. Najpierw rozwiązuje ścieżkę docelową przez GetFullPathNameW, wołając ją dwa razy i alokując bufor ze zwróconej długości, zamiast zakładać MAX_PATH, żeby długie ścieżki nie były po cichu ucinane. Drugi krok tworzy plik tymczasowy o nazwie .pdflib-qdf- plus GUID plus .tmp w katalogu docelowym, używając CreateFileW z CREATE_NEW pod Windows i open(2) z O_CREAT or O_EXCL oraz trybem 0600 na POSIX. Obie flagi sprawiają, że utworzenie pliku nie udaje się, jeśli nazwa już istnieje, więc dwa procesy ścigające się na tym samym GUID nie mogą dzielić uchwytu. Trzeci krok kopiuje naprawiony strumień porcjami po 64 KiB przez WriteBuffer, które rzuca wyjątek przy krótkim zapisie, zamiast zwracać liczbę, której nikt nie sprawdza, a potem woła FlushFileBuffers albo fsync(2) i zamyka uchwyt. Czwarty krok zmienia nazwę
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
// Nie pozwól na kopiowanie między wolumenami ani na usunięcie celu najpierw
if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
raise EWriteError.Create('Unable to publish QDF output');
end;
Krok zmiany nazwy to miejsce, w którym większość domorosłych procedur bezpiecznego zapisu po cichu się sypie. MoveFileExW z MOVEFILE_REPLACE_EXISTING zastępuje cel w jednej operacji systemu plików na tym samym wolumenie. Writer celowo pomija MOVEFILE_COPY_ALLOWED, bo przeniesienie między wolumenami degraduje do kopiuj-potem-usuń, czyli dokładnie tej nieatomowej sekwencji, której cały ten projekt ma unikać. Ponieważ plik tymczasowy leży w katalogu docelowym, z założenia trafia na wolumen docelowy. Writer nigdy też nie usuwa starego pliku jako pierwszego; para usuń-potem-zmień-nazwę ma okno, w którym ścieżka nie istnieje w ogóle, a awaria w tym oknie gubi dokument. MOVEFILE_WRITE_THROUGH prosi, żeby wywołanie nie wróciło, dopóki zmiana nazwy nie dotrze na dysk, co łączy się z jawnym opróżnieniem danych. Na POSIX rename(2) już gwarantuje, że nowa nazwa atomowo zastępuje istniejący plik, a to samo umieszczenie w katalogu chroni przed błędem EXDEV. Sprzątanie jest symetryczne. Nazwa tymczasowa jest usuwana w bloku finally na każdej ścieżce, co przy powodzeniu jest no-opem, bo zmiana nazwy już ją zużyła, a przy niepowodzeniu usuwa częściowy plik, żeby katalog nie zarastał resztkami .tmp. Test regresyjny w Tests\QDFFileRegression.inc sprawdza dokładnie to: po każdej wstrzykniętej awarii bajty miejsca docelowego zgadzają się z oryginałem, bajty źródła zgadzają się z oryginałem, a w katalogu nie ma nic poza dwoma fixture'ami
Dlaczego plik tymczasowy rozluźnia uprawnienia pod Windows?
Plik utworzony z pustym deskryptorem zabezpieczeń dziedziczy swój DACL z katalogu nadrzędnego, a nie z pliku, który ma zastąpić. To poprawna wartość domyślna dla zupełnie nowego dokumentu i zła dla naprawy w miejscu. Wyobraź sobie, że operator zamknął contract.pdf do jednego konta chronionym, niedziedziczonym DACL-em. Plik tymczasowy obok niego dziedziczy szersze uprawnienia katalogu, a gdy zmieni się jego nazwę na contract.pdf, przemianowany plik niesie ten szeroki DACL, bo zabezpieczenia NTFS podróżują z obiektem pliku, a nie z nazwą. Naprawa się udaje, bajty są poprawne, a kontrola dostępu, którą skonfigurował operator, po cichu znika. Nic w wartości zwracanej na to nie wskazuje
PDF Library for Delphi czyta więc DACL miejsca docelowego przed utworzeniem pliku tymczasowego i przekazuje go jako argument lpSecurityAttributes do CreateFileW, dzięki czemu nowy plik rodzi się z uprawnieniami starego i zmiana nazwy nie zmienia niczego, co operator by zauważył. Odczyt korzysta z GetFileSecurityW z DACL_SECURITY_INFORMATION, ustalając rozmiar bufora z wyniku ERROR_INSUFFICIENT_BUFFER z pierwszego wywołania. Trzy warunki sprawiają, że writer zawodzi bezpiecznie, zamiast zgadywać. Jeśli DACL-a nie da się odczytać, publikacja zatrzymuje się z EWriteError, które publiczne API mapuje na 305. Jeśli deskryptor wraca bez ustawionego SE_DACL_PRESENT, publikacja też się zatrzymuje, bo przekazanie takiego deskryptora do CreateFileW pozwoliłoby jądru sięgnąć po domyślny DACL procesu i zmienić semantykę dostępu bez pytania nikogo o zgodę. A jeśli cel nosi FILE_ATTRIBUTE_ENCRYPTED, writer odmawia wprost: plik tymczasowy byłby jawnym tekstem, a zmiana nazwy pliku jawnego na plik chroniony przez EFS publikuje niezaszyfrowany zamiennik czegoś, co użytkownik zdecydował się zaszyfrować na poziomie systemu plików. EFS nie ma nic wspólnego ze standardowymi handlerami zabezpieczeń PDF, którym poświęcony jest artykuł o wczytywaniu zaszyfrowanych dokumentów, ale tryb awarii jest tym samym rodzajem cichej degradacji
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');
// ustal rozmiar deskryptora, potem odczytaj tylko jego część 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; // przekazywane do CreateFileW / CREATE_NEW
end;
Jeden szczegół z testu regresyjnego warto zapamiętać, jeśli sam piszesz podobny test. Żeby zbudować ograniczony fixture, test zakłada DACL tylko dla właściciela i musi jawnie ustawić SE_DACL_PROTECTED w kontrolce deskryptora; samo przekazanie flagi chronionej w argumencie SecurityInformation funkcji SetFileSecurityW nie zamienia deskryptora niechronionego w chroniony. Sprawdzenie po fakcie mówi, że opublikowany plik nadal zgłasza bit chroniony i jawny, niepusty DACL, zarówno dla osobnej ścieżki wyjściowej, jak i dla naprawy na samym pliku źródłowym
Który LastErrorCode mówi, co się nie udało?
RepairQDFFile zwraca 1 przy powodzeniu i 0 przy każdej awarii, a LastErrorCode mówi, który etap odmówił. Źródło, którego nie da się odczytać, w tym takie, które inny proces trzyma z wyłączną blokadą, zgłasza 401; odczyt jest teraz opakowany tak, że wyjątek przy wejściu mapuje się na 401, zamiast przeciekać do błędu zapisu. Niepoprawna albo niejednoznaczna struktura QDF, na przykład powtórzony znacznik strumienia dla tego samego obiektu, zgłasza PDFLIB_ERROR_QDF_REPAIR, czyli 107, a miejsce docelowe nie zostało tknięte, bo writer nigdy nie powstał. Wszystko po naprawie, od utworzenia pliku tymczasowego przez opróżnienie po zmianę nazwy, zgłasza PDFLIB_ERROR_QDF_WRITE, czyli 305. Test regresyjny ćwiczy te realistyczne przypadki: miejsce docelowe otwarte innym uchwytem bez współdzielenia usuwania, miejsce docelowe tylko do odczytu, brakujący katalog docelowy oraz każdy z trzech etapów writera zawodzący przez wstrzyknięcie. We wszystkich zwrot to 0, kod to 305, a po fakcie nie istnieje żaden nowy ani częściowy cel. Ogólny nawyk czytania kodu, a nie tylko wartości zwracanej, jest tym samym, który opisuje artykuł o diagnozowaniu cichych awarii w bibliotece
var
Pdf: TPDFlib;
begin
Pdf := TPDFlib.Create;
try
// Naprawa w miejscu: ta sama ścieżka jest wejściem i wyjściem
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;
Gdzie kończy się gwarancja
Writer obiecuje spójność wobec awarii, które proces widzi, i jest uczciwy co do tych, których nie widzi. Jeśli proces zostanie zabity między utworzeniem pliku tymczasowego a zmianą nazwy, blok finally nigdy się nie wykona i w katalogu zostanie plik .pdflib-qdf-<GUID>.tmp; miejsce docelowe jest nadal nietknięte i to jest ta właściwość, która się liczy, ale śmieci są do zamiecenia po twojej stronie. Utrata zasilania też jest poza obietnicą: dane są opróżnione, a zmiana nazwy jest write-through, czyli tym, o co biblioteka w trybie użytkownika może prosić, ale writer nie robi fsync wpisu katalogowego i nie składa żadnej obietnicy trwałości ponad to, co daje system plików. Drugi writer modyfikujący miejsce docelowe równolegle nie jest wykrywany, bo DACL i atrybuty są czytane przed utworzeniem pliku tymczasowego i nic ich nie sprawdza ponownie w chwili zmiany nazwy. A udana zmiana nazwy tworzy nową tożsamość pliku, więc alternatywne strumienie danych i zwykłe atrybuty, takie jak bit archiwum albo ukryty na starym pliku, nie przeżywają; celowo przenoszony jest tylko DACL
Węższa granica dotyczy tego, które API w ogóle korzysta z tej ścieżki. Tylko RepairQDFFile przechodzi przez TPDFQDFFileWriter. SaveQDFToFile i ConvertFileToQDF nadal otwierają wyjście przez PLCreateFileStream(FileName, fmCreate) i strumieniują konwersję QDF prosto do niego, tak samo jak ścieżka przyrostowa opisana w artykule o dopisywaniu aktualizacji do strumienia pisze do tego strumienia, który jej podasz. Te dwa wywołania produkują nowy artefakt do debugowania z dokumentu, który został już wczytany i zwalidowany, więc dziura przy błędzie parsowania nigdy ich nie dotyczyła, ale nie dziedziczą też publikacji opartej na zmianie nazwy. Nie czytaj tego artykułu jako twierdzenia, że każdy eksport QDF jest atomowy. To jedno wyjście, to, którego wejściem jest niezaufany, ręcznie edytowany plik, a wyjściem rutynowo ta sama ścieżka, i ta kombinacja zasłużyła na dodatkową maszynerię. Wstrzykiwanie awarii, które to wszystko dowodzi, jest tanie, bo trzy etapy writera, WriteData, Flush i Publish, są virtual. Podklasa testowa przesłania jeden z nich, żeby rzucić wyjątek po rozpoczęciu prawdziwej pracy, woła Save na naprawionym strumieniu i sprawdza, że wyjątek się propaguje, że bajty źródła i miejsca docelowego są niezmienione i że nie został żaden plik tymczasowy. Żadne globalne API plikowe nie jest podpinane, żaden prawdziwy plik użytkownika nie jest dotykany, a trzy etapy odpowiadają jeden do jednego trzem sposobom, na jakie publikacja może paść w produkcji: dysk się zapełnia, opróżnienie jest odrzucane albo zmiana nazwy jest odmawiana, bo ktoś inny trzyma cel
API RepairQDFFile, jego writer atomowej publikacji i reszta procesu debugowania QDF są częścią PDF Library for Delphi, obok funkcji odtwarzania odsyłaczy, aktualizacji przyrostowych i szyfrowania opisanych w innych miejscach tego bloga