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
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
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
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_CONTROLna celu - Zwrócony deskryptor nie ma ustawionego
SE_DACL_PRESENT; podanie takiego deskryptora doCreateFileWpozwolił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