Artykuł techniczny

Atomowy zapis naprawy PDF w Delphi: rename i DACL

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

Jak RepairQDFFile w PDF Library for Delphi przestało niszczyć własny cel: v3.539.12 otwierała wyjście przez PLCreateFileStream i fmCreate, co obcina plik, zanim PDFQDFRepair zdąży odrzucić wejście, v3.539.13 naprawiała najpierw do TMemoryStream, a v3.539.15 przekazuje bajty do TPDFQDFFileWriter, żeby publikacja była atomowa
Poprawka błędu parsowania i poprawka publikacji to dwie różne granice: naprawa najpierw w pamięci chroni przed złym wejściem, a writer istnieje po to, żeby pełny dysk albo awaria w połowie zapisu nie mogły już zostawić uszkodzonego miejsca docelowego
// 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ę

Cztery atomowe kroki TPDFQDFFileWriter.Save w PDF Library for Delphi: rozwiązanie ścieżki dwa razy przez GetFullPathNameW, utworzenie pliku tymczasowego .pdflib-qdf z CREATE_NEW albo O_EXCL, żeby ścigające się procesy nie mogły dzielić uchwytu, kopiowanie porcjami po 64 KiB przez WriteBuffer z opróżnieniem, a potem MoveFileExW z REPLACE_EXISTING i WRITE_THROUGH
Każdy krok odmawia działania, dopóki poprzedni się nie zakończy, plik tymczasowy z założenia leży na wolumenie docelowym, okno z najpierw-usuń nigdy nie powstaje, a sprzątanie w bloku finally nie zostawia po sobie śmieci .tmp
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

Dlaczego writer publikacji QDF kopiuje DACL miejsca docelowego przed utworzeniem pliku tymczasowego: pusty deskryptor odziedziczyłby szersze uprawnienia katalogu i zmiana nazwy po cichu poszerzyłaby dostęp, więc GetFileSecurityW czyta DACL, brakujący bit SE_DACL_PRESENT albo atrybut EFS zatrzymuje publikację z kodem 305, a CreateFileW rodzi plik z starymi uprawnieniami
Zabezpieczenia NTFS podróżują z obiektem pliku, a nie z nazwą: przekazanie odczytanego deskryptora jako lpSecurityAttributes sprawia, że zmiana nazwy nie rusza niczego, co skonfigurował operator, a każda brama zawodzi bezpiecznie, zamiast zgadywać
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