Artykuł techniczny

Atomowy zapis naprawy PDF w Delphi: rename i DACL

Popraw ręcznie plik QDF, przemyci drugi znacznik strumienia dla tego samego obiektu, potem odpal naprawę w miejscu najbardziej naturalnym kodem Delphi, jaki umiesz napisać. Krok naprawy zauważa uszkodzenie i odmawia. Gdy potem spojrzysz na plik, ma 0 bajtów, bo TFileStream.Create(OutputFileName, fmCreate) skrócił właśnie tę ścieżkę jedną linię, zanim walidator w ogóle zobaczył wejście. Naprawa bajtów w pamięci to łatwa połowa roboty. Wstawienie ich z powrotem na dysk, bez zostawiania pliku zerowej długości, półnapisanego albo o złych uprawnieniach, to druga połowa i ma własne tryby awarii

Dlaczego naprawa w miejscu opróżnia plik?

Naprawa w miejscu opróżnia plik, bo kolejność operacji jest zła: fmCreate skraca przy otwarciu, więc każda odmowa po otwarciu przychodzi za późno. Gdy InputFileName i OutputFileName to ta sama ścieżka, odrzucone wejście znaczy zgubiony dokument. Staranny parser nie uratuje cię tu. Może zostawić swój strumień wyjściowy nietknięty, gdy odrzuca niejednoznaczne znaczniki, ale wywołujący zniszczył cel wywołanie wcześniej

// Wersja oczywista: cel jest skracany, zanim cokolwiek zostanie zwalidowane
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // ta sama ścieżka co OutputFileName
  Output := TFileStream.Create(OutputFileName, fmCreate);  // plik ma teraz 0 bajtów
  try
    if not MyRepairQDF(Input, Output) then  // twój własny walidator: za późno na odmowę
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

Zwyczajna następna próba to naprawa do TMemoryStream i otwarcie wyjścia dopiero po udanej naprawie. To zatyka dziurę złego wejścia i nic więcej. Faza zapisu to wciąż najpierw skróć, potem kopiuj, więc pełny dysk, naruszenie współdzielenia w połowie albo wyjątek między skróceniem a ostatnim WriteBuffer wciąż zostawia cel uszkodzony. Parsowanie i publikacja to dwa osobne boundary i każdy potrzebuje własnej ochrony

W PDF Library for Delphi TPDFlib.RepairQDFFile(InputFileName, OutputFileName) ogarnia oba. Czyta wejście, naprawia całkowicie w pamięci i wraca wcześnie, z niczym otwartym po stronie celu, jeśli struktura QDF zostaje odrzucona. Tylko udana naprawa przechodzi do publikacji, a publikacja nigdy nie otwiera celu do zapisu

Trzy sposoby publikacji naprawy QDF w Delphi: otwarcie wyjścia przez fmCreate skraca je, zanim naprawa zdąży odrzucić wejście, najpierw naprawa do TMemoryStream chroni tylko przed złym wejściem, a RepairQDFFile publikuje naprawione bajty przez plik tymczasowy i atomowy rename
bezpieczeństwo parsowania i bezpieczeństwo publikacji to różne boundary: naprawa od pamięci chroni przed złym wejściem, a writer istnieje po to, by pełny dysk albo porażka w połowie zapisu nie zostawiły celu uszkodzonego

Cztery kroki między naprawionymi bajtami a celem

RepairQDFFile gwarantuje, że ścieżka docelowa trzyma albo kompletny stary plik, albo kompletny nowy plik, dla każdej awarii, którą sam proces potrafi zobaczyć. Dochodzi tam w czterech krokach i żaden nie startuje, dopóki poprzedni się nie skończył. Cel jest rozwiązywany do pełnej ścieżki przez GetFullPathNameW, wołane raz, by poznać wymaganą długość, i drugi raz do bufora dokładnie tego rozmiaru, więc długa ścieżka nie jest obcinana na MAX_PATH. Dalej w katalogu docelowym powstaje plik tymczasowy nazwany .pdflib-qdf- plus GUID plus .tmp, tworzony przez CreateFileW z CREATE_NEW na Windows albo open(2) z O_CREAT or O_EXCL i trybem 0600 na POSIX. Obie flagi sprawiają, że utworzenie zawodzi, gdy nazwa już istnieje. Dwa procesy, które jakoś wylosowały ten sam GUID, nie mogą skończyć z jednym współdzielonym uchwytem

Naprawiony strumień jest potem kopiowany w porcjach po 64 KiB przez WriteBuffer, który rzuca wyjątkiem przy krótkim zapisie, zamiast zwracać liczbę, której nikt nie sprawdza. FlushFileBuffers albo fsync(2) spycha dane w dół i uchwyt jest zamykany. Zamknięcie celowo nie rzuca: gdy flush się udał, nieudane zamknięcie nie grozi danym, a wyjątek w tym miejscu ukryłby prawdziwy błąd zapisu albo flush przed tym, kto musi stawiać diagnozę. Ostatnim krokiem jest rename

Cztery kroki, którymi RepairQDFFile publikuje swoje wyjście w PDF Library for Delphi: rozwiązanie ścieżki dwa razy przez GetFullPathNameW, utworzenie pliku tymczasowego .pdflib-qdf z CREATE_NEW albo O_EXCL, by ścigające się procesy nie mogły dzielić uchwytu, kopiowanie porcjami po 64 KiB przez WriteBuffer i zrzucenie, 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

Na rename większość domowej roboty procedur safe-save się wywala. Na Windows plik tymczasowy zastępuje cel przez MoveFileExW z MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH, co na jednym wolumenie jest pojedynczą operacją systemu plików. MOVEFILE_COPY_ALLOWED jest celowo pominięte. Przenoszenie między wolumenami cicho degraduje do skopiuj-potem-usuń, dokładnie tej nieatomowej sekwencji, której cała konstrukcja unika, i wolałbym zobaczyć odmówiony rename niż „udaną” kopię, która była w połowie, gdy maszyna padła. Bo plik tymczasowy siedzi w katalogu docelowym, i tak leży na wolumenie docelowym. Stary plik też nigdy nie jest usuwany najpierw; para usuń-potem-zmień-nazwę zostawia okno, w którym ścieżka nie istnieje, i awaria w jego środku gubi dokument. MOVEFILE_WRITE_THROUGH pilnuje, by wywołanie nie wróciło, zanim rename dotrze na dysk. Na POSIX rename(2) już atomowo zastępuje istniejącą nazwę, a umieszczenie w tym samym katalogu trzyma to z dala od EXDEV

Sprzątanie biegnie w bloku finally na każdej ścieżce. Po udanym rename nazwa tymczasowa już nie istnieje, więc usuwanie nic nie robi; po porażce zabiera częściowy plik. Katalog kończy z dokładnie tymi plikami, które miał przed wywołaniem

Dlaczego plik tymczasowy rozluźnia uprawnienia pod Windows?

Plik utworzony z pustym deskryptorem bezpieczeństwa dziedziczy DACL po katalogu rodzica; plik, który zaraz zastąpi, nie ma tu nic do gadania. Dla zupełnie nowego dokumentu ten domyślny wybór jest właściwy. Dla naprawy w miejscu jest zły. Wyobraź operatora, który zamknął contract.pdf do jednego konta chronionym, niedziedziczonym DACL. Zwykły plik tymczasowy obok podczepia szersze uprawnienia katalogu, a po rename opublikowany contract.pdf niesie ten szeroki DACL, bo zabezpieczenia NTFS należą do obiektu pliku, a nie do nazwy. Bajty są poprawne, wartość zwrotna to 1. Ograniczenie dostępu ustawione przez operatora po prostu znika

RepairQDFFile czyta więc DACL celu przed utworzeniem pliku tymczasowego i podaje go do CreateFileW przez lpSecurityAttributes, więc nowy plik rodzi się z uprawnieniami starego, a rename nie zmienia niczego, co operator by zauważył. Odczyt to GetFileSecurityW z DACL_SECURITY_INFORMATION, z buforem wymierzonym z odpowiedzi ERROR_INSUFFICIENT_BUFFER pierwszego wywołania. Gdy cel jeszcze nie istnieje — normalny przypadek osobnej ścieżki wyjściowej — deskryptor nie jest podawany i nowy plik dziedziczy po swoim katalogu jak każdy inny nowy plik

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ć

Trzy warunki zatrzymują publikację, zamiast pozwolić kodowi zgadywać, i każdy raportuje LastErrorCode 305:

  • DACL nie da się odczytać, na przykład bo konto wywołujące nie ma READ_CONTROL na celu
  • Zwrócony deskryptor nie ma ustawionego SE_DACL_PRESENT; podanie takiego deskryptora do CreateFileW pozwoliłoby Windows zejść do domyślnego DACL procesu i zmienić semantykę dostępu, o której zmianę nikt nie prosił
  • Cel niesie FILE_ATTRIBUTE_ENCRYPTED; plik tymczasowy byłby jawnym tekstem, a przemianowanie go nad plikiem chronionym EFS opublikowałoby niezaszyfrowane zastępstwo czegoś, co użytkownik wybrał do zaszyfrowania

EFS to funkcja systemu plików i nie ma nic wspólnego z handlerami bezpieczeństwa PDF, którymi zajmuje się artykuł o ładowaniu zaszyfrowanych dokumentów. Kształt porażki to jednak ten sam rodzaj cichego downgradu, a odmowa jest właściwym domyślnym zachowaniem. Naprawa, która od czasu do czasu mówi nie na udziale EFS, to zgłoszenie supportowe. Naprawa, która odszyfrowuje plik zaszyfrowany przez użytkownika, to incydent

Jeśli sam piszesz test zachowania DACL, jeden szczegół Windows łatwo pominąć. Budując ograniczony plik testowy, aplikuj DACL tylko dla właściciela i ustaw jawnie SE_DACL_PROTECTED w kontrolach deskryptora; podanie PROTECTED_DACL_SECURITY_INFORMATION do SetFileSecurityW nie zamienia niechronionego deskryptora w chroniony. Potem sprawdź, że opublikowany plik nadal raportuje bit chroniony i jawny, niepusty DACL — raz dla osobnej ścieżki wyjściowej i raz dla naprawy na samym źródle

Który LastErrorCode mówi ci, co zawiodło?

RepairQDFFile zwraca 1 przy sukcesie i 0 przy porażce, a TPDFlib.LastErrorCode nazywa etap, który odmówił. Wejście, które nie istnieje, nie daje się otworzyć albo jest większe niż 2 GB, raportuje 401, a wyjątek podczas czytania też jest mapowany na 401, zamiast przeciekać do kodu zapisu. Niepoprawna albo niejednoznaczna struktura QDF, jak ten zdublowany znacznik strumienia, raportuje PDFLIB_ERROR_QDF_REPAIR (107); cel nie został nigdy otwarty. Wszystko po naprawie, od rozwiązania ścieżki przez flush po rename, raportuje PDFLIB_ERROR_QDF_WRITE (305). Realistyczne awarie zapisu lądują wszystkie na 305: cel trzymany otwarty przez inny uchwyt bez delete sharing, cel tylko do odczytu, brakujący katalog docelowy, plus odmowy DACL i EFS wyżej. W każdym przypadku 305 nie zostaje po sobie nowy ani częściowy cel

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');
        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;

Ścieżkę 305 łatwo odtworzyć z własnego projektu testowego bez podczepiania się pod żadne API plikowe. fmShareDenyNone Delphi otwiera plik ze współdzieleniem odczytu i zapisu, ale bez FILE_SHARE_DELETE, a Windows odmawia przemianowania czegokolwiek nad plikiem trzymanym tak. Trzymaj taki uchwyt otwarty przez wywołanie, a rename zawiedzie na samym końcu, po zapisaniu i zrzuceniu danych — czyli w tym przypadku, który najbardziej chcesz zobaczyć obsłużony

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;
  // Te kontrole to sugestie dla twojego własnego testu, nie wywołania biblioteki
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual: twój pomocnik
  // Żaden plik tymczasowy nie może zostać
  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;

Przyzwyczajeniu czytania kodu zamiast ufać samemu 0 patronuje artykuł o diagnozowaniu cichych porażek, który przeprowadza ten sam wzorzec przez resztę API

Który LastErrorCode mówi, co się nie udało?

Obietnica obejmuje awarie, które proces potrafi zobaczyć. Jeśli proces zostanie zabity między utworzeniem pliku tymczasowego a rename, blok finally nigdy nie ruszy i plik .pdflib-qdf-<GUID>.tmp zostaje w katalogu. Cel jest wciąż nietknięty, co jest tym, co się liczy, ale zamiatanie tych śmieci należy do ciebie. Zanik zasilania to pytanie systemu plików: dane są zrzucone, a rename idzie write-through, ale wpis katalogowy nie jest osobno synchronizowany na POSIX, więc trwałość to to, co system plików dostarcza na wierzchu

Udany rename produkuje też nowy obiekt pliku, i kilka właściwości starego nie podróżuje z nim. DACL jest przenoszony celowo. Własność nie, bo czytane jest tylko DACL_SECURITY_INFORMATION, więc opublikowany plik należy do konta, które odpaliło naprawę. Plik tymczasowy jest tworzony z FILE_ATTRIBUTE_NORMAL, co znaczy, że bity ukrycia, archiwum i podobne startują od zera, a alternatywne strumienie danych starego pliku znikają. Na POSIX opublikowany plik zachowuje tryb 0600 pliku tymczasowego; wywołaj po wszystkim chmod, jeśli grupa potrzebuje odczytu. Atrybuty i DACL celu są czytane, zanim plik tymczasowy istnieje, więc drugi writer na tę samą ścieżkę to twój problem koordynacyjny

SaveQDFToFile i ConvertFileToQDF piszą bezpośrednio

RepairQDFFile jest jedynym punktem wejścia QDF publikującym przez plik tymczasowy. SaveQDFToFile i ConvertFileToQDF otwierają wyjście przez fmCreate i strumieniują konwersję prosto do niego, podobnie jak ścieżka przyrostowa z artykułu o doklejaniu aktualizacji do strumienia pisze do dowolnego podanego strumienia. Podział jest zamierzony. Te dwa wywołania zamieniają dokument już wczytany i zwalidowany w nowy artefakt debugowania, więc nie ma niezaufanego wejścia, które mogłoby zostać odrzucone w połowie, a ich ścieżka wyjściowa to normalnie świeży plik. RepairQDFFile dostaje plik zredagowany ręką, niezaufany, i rutynowo bywa celowany z powrotem w tę samą ścieżkę; ta kombinacja usprawiedliwia dodatkową maszynerię

Gdy jednak chcesz atomowego zastąpienia dla zrzutu QDF, krótki wrapper twojego autorstwa wystarcza:

// Twój własny pomocnik: zapisz QDF obok celu, potem zastąp go jednym 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);   // obecny tylko wtedy, gdy coś się nie udało
  end;
end;

Ta wersja pomija kopiowanie DACL i jawny flush danych, które robi RepairQDFFile. Dla zrzutów QDF lądujących w folderze scratch albo debugowania uważam, że to właściwa ilość maszynerii; jeśli zrzut zastępuje plik o zamkniętych uprawnieniach, przepuść go przez ścieżkę naprawy albo skopiuj deskryptor bezpieczeństwa sam

Atomowe wyjście naprawy, wywołania konwersji QDF i powyższe kody błędów wszystkie lecą w PDF Library for Delphi, obok odtwarzania odwołań krzyżowych, aktualizacji przyrostowych i wsparcia szyfrowania omawianych w innych wpisach tutaj