Tehnički članak

Atomični izlaz popravke PDF-a u Delphi-ju: rename i DACL

PDF Library for Delphi objavljuje izlaz funkcije RepairQDFFile kroz interni writer, TPDFQDFFileWriter, koji nikada ne otvara odredište za pisanje: popravljeni bajtovi idu u ekskluzivno kreiran privremeni fajl u istom direktorijumu, fajl se flush-uje i zatvara, i tek onda se preimenuje preko cilja sa MoveFileExW na Windows-u ili rename(2) na POSIX-u. Ako bilo šta padne pre preimenovanja, odredište zadržava svaki bajt koji je imalo, a pozivalac vidi LastErrorCode 305. Popravka dokumenta u memoriji je laka polovina funkcije popravke. Smeštanje rezultata na disk bez toga da korisnik ikada ostane sa fajlom nulte dužine ili upola upisanim je polovina o kojoj je ovaj članak

Zašto popravka koja padne ipak može da uništi ciljni fajl?

Zato što je redosled operacija bio pogrešan. Pre v3.539.13, RepairQDFFile je otvarao izlaz sa PLCreateFileStream(OutputFileName, fmCreate) i zatim taj stream predavao parseru. fmCreate skraćuje fajl pri otvaranju, pa je u trenutku kada je QDF skeniranje odlučilo da ulaz nije popravljiv, odredište već bilo ispražnjeno. Popravka na mestu, gde su InputFileName i OutputFileName ista putanja, pretvarala je odbijen ulaz u izgubljen fajl. Sam parser se ponašao dobro: niskonivojska funkcija PDFQDFRepair ostavlja ciljni stream netaknutim kada odbije dvosmislene markere. Ta zaštita je bila prosto irelevantna, jer je javni API skratio fajl jedan poziv ranije

Popravka u v3.539.13 premestila je popravku u TMemoryStream i otvarala izlaz tek pošto PDFQDFRepair uspe. To zatvara rupu pri neuspehu parsiranja i ništa drugo. Faza upisa je i dalje bila fmCreate praćen CopyFrom, pa bi pun disk, sharing violation na pola puta, ili izuzetak između skraćivanja i poslednjeg WriteBuffer i dalje ostavili oštećeno odredište. Popravka prvo u memoriji štiti od lošeg ulaza. Objavljivanje na disk zahteva sopstvenu granicu, i v3.539.14 i v3.539.15 su je izgradile

Kako je RepairQDFFile u PDF Library for Delphi prestala da uništava sopstveni cilj: v3.539.12 otvarala je izlaz sa PLCreateFileStream i fmCreate, što skraćuje fajl pre nego što PDFQDFRepair može da odbije ulaz, v3.539.13 popravljala je prvo u TMemoryStream, a v3.539.15 predaje bajtove TPDFQDFFileWriter-u na atomsko objavljivanje
Popravka neuspeha pri parsiranju i popravka objavljivanja su različite granice: popravka prvo u memoriji štiti od lošeg ulaza, dok writer postoji da pun disk ili pad na pola upisa više ne mogu da ostave odredište oštećenim
// v3.539.12: odredište se skraćuje pre nego što je ulaz validiran
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // prekasno za "ne"
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: popravka u memoriji, pa bajtovi idu writer-u za objavljivanje
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // odredište nikada nije otvoreno
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Šta atomsko objavljivanje zapravo garantuje?

TPDFQDFFileWriter.Save garantuje da je putanja odredišta ili ceo stari fajl ili ceo novi fajl, nikada mešavina, za svaki neuspeh koji sama biblioteka može da primeti. Writer to radi u četiri koraka od kojih svaki odbija da nastavi ako prethodni nije završen. Prvo razrešava odredište sa GetFullPathNameW, pozivajući je dvaput i alocirajući bafer iz vraćene dužine umesto da pretpostavi MAX_PATH, pa se duge putanje ne seku tiho. Drugo kreira privremeni fajl pod imenom .pdflib-qdf- plus GUID plus .tmp u direktorijumu odredišta, koristeći CreateFileW sa CREATE_NEW na Windows-u i open(2) sa O_CREAT or O_EXCL i modom 0600 na POSIX-u. Oba flag-a čine da kreiranje padne ako ime već postoji, pa dva procesa koja se takmiče oko istog GUID-a ne mogu da dele handle. Treće kopira popravljeni stream u blokovima od 64 KiB kroz WriteBuffer, koji podiže grešku pri kratkom upisu umesto da vrati broj koji niko ne proverava, zatim poziva FlushFileBuffers ili fsync(2) i zatvara handle. Četvrto preimenuje

Četiri atomska koraka TPDFQDFFileWriter.Save u PDF Library for Delphi: razreši putanju dvaput sa GetFullPathNameW, kreiraj privremeni fajl .pdflib-qdf sa CREATE_NEW ili O_EXCL da ga procesi u trci ne mogu deliti, kopiraj u blokovima od 64 KiB kroz WriteBuffer i flush-uj, pa MoveFileExW sa REPLACE_EXISTING i WRITE_THROUGH
Svaki korak odbija da nastavi ako prethodni nije završen, privremeni fajl po konstrukciji živi na volumenu odredišta, prozor u kojem se prvo briše nikada ne postoji, a čišćenje u finally ne ostavlja .tmp ostatke za sobom
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
  // Ne dopuštaj kopiranje preko volumena niti prvo brisanje odredišta
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Korak preimenovanja je mesto gde većina kućnih „bezbednih čuvanja“ tiho pukne. MoveFileExW sa MOVEFILE_REPLACE_EXISTING zamenjuje cilj u jednoj fajl-sistemskoj operaciji na istom volumenu. Writer namerno izostavlja MOVEFILE_COPY_ALLOWED, jer se premeštanje preko volumena degradira u kopiranje-pa-brisanje, što je upravo nearitmički niz koji ceo dizajn postoji da izbegne. Pošto privremeni fajl živi u direktorijumu odredišta, on je po konstrukciji na volumenu odredišta. Writer takođe nikada ne briše stari fajl prvi; par brisanje-pa-preimenovanje ima prozor u kojem putanja uopšte ne postoji, i pad unutar tog prozora gubi dokument. MOVEFILE_WRITE_THROUGH traži da poziv ne vrati odgovor dok preimenovanje nije stiglo na disk, što se spaja sa eksplicitnim flush-om podataka. Na POSIX-u rename(2) već garantuje da novo ime atomski zamenjuje svaki postojeći fajl, a isto smeštanje u isti direktorijum čuva ga od pada sa EXDEV. Čišćenje je simetrično. Privremeno ime se uklanja u finally bloku na svakoj putanji, što je pri uspehu no-op jer ga je preimenovanje već potrošilo, a pri neuspehu uklanja delimični fajl pa se u direktorijumu ne skuplja .tmp ostatak. Regresija u Tests\QDFFileRegression.inc proverava upravo to: posle svakog injektiranog neuspeha bajtovi odredišta se poklapaju sa originalom, bajtovi izvora se poklapaju sa originalom, a direktorijum ne sadrži ništa osim dva fikstura

Zašto privremeni fajl oslabi dozvole na Windows-u?

Fajl kreiran sa nil security descriptor-om nasleđuje svoj DACL od roditeljskog direktorijuma, a ne od fajla koji treba da zameni. To je ispravan podrazumevani izbor za potpuno nov dokument i pogrešan za popravku na mestu. Pretpostavite da je administrator zaključao contract.pdf na jedan jedini nalog sa zaštićenim DACL-om koji se ne nasleđuje. Privremeni fajl pored njega nasleđuje šire dozvole direktorijuma, i kada se jednom preimenuje preko contract.pdf, preimenovani fajl nosi širi DACL, jer NTFS bezbednost putuje sa fajl objektom, a ne sa imenom. Popravka uspeva, bajtovi su ispravni, a kontrola pristupa koju je administrator podesio tiho nestaje. Ništa u povratnoj vrednosti na to ne ukazuje

PDF Library for Delphi zato čita DACL odredišta pre kreiranja privremenog fajla i prosleđuje ga kao argument lpSecurityAttributes funkciji CreateFileW, pa se novi fajl rađa sa dozvolama starog fajla i preimenovanje ne menja ništa što bi administrator primetio. Čitanje koristi GetFileSecurityW sa DACL_SECURITY_INFORMATION, dimenzionišući bafer iz rezultata ERROR_INSUFFICIENT_BUFFER prvog poziva. Tri uslova čine da writer padne zatvoreno umesto da pogađa. Ako se DACL ne može pročitati, objavljivanje se zaustavlja uz EWriteError, koji javni API mapira u 305. Ako descriptor dođe bez postavljenog SE_DACL_PRESENT, objavljivanje se takođe zaustavlja, jer bi prosleđivanje takvog descriptor-a funkciji CreateFileW pustilo kernel da se vrati na podrazumevani DACL procesa i promeni semantiku pristupa bez da je iko to tražio. I ako cilj nosi FILE_ATTRIBUTE_ENCRYPTED, writer odbija odmah: privremeni fajl bio bi čist tekst, a preimenovanje fajla čistog teksta preko EFS-zaštićenog objavljuje nešifrovanu zamenu za nešto što je korisnik odabrao da šifruje na nivou fajl sistema. EFS nema veze sa standardnim PDF security handler-ima, koji su tema članka o učitavanju šifrovanih dokumenata, ali je način neuspeha ista vrsta tihog snižavanja

Zašto QDF writer za objavljivanje kopira DACL odredišta pre kreiranja privremenog fajla: nil descriptor bi nasledio šire dozvole direktorijuma i preimenovanje bi tiho proširilo pristup, pa GetFileSecurityW čita DACL, nedostatak bita SE_DACL_PRESENT ili EFS atribut zaustavlja objavljivanje uz 305, a CreateFileW se rađa sa starim dozvolama
NTFS bezbednost putuje sa fajl objektom, a ne sa imenom: prosleđivanje pročitanog descriptor-a kao lpSecurityAttributes čini da preimenovanje ne promeni ništa što je administrator podesio, a svaka kapija pada zatvoreno umesto da pogađa
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');
  // dimenzioniši descriptor, pa pročitaj samo njegov DACL deo
  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;   // predaje se CreateFileW / CREATE_NEW
end;

Jedan detalj iz regresije vredi imati na umu ako sami pišete sličan test. Da bi napravio restriktivni fikstur, test primenjuje DACL samo za vlasnika i mora eksplicitno da postavi SE_DACL_PROTECTED u kontroli descriptor-a; samo prosleđivanje zaštićenog flag-a u argumentu SecurityInformation funkcije SetFileSecurityW ne pretvara nezaštićeni descriptor u zaštićeni. Tvrdnja posle toga je da objavljeni fajl i dalje prijavljuje zaštićeni bit i eksplicitan, ne-nil DACL, i za odvojenu izlaznu putanju i za popravku preko samog izvornog fajla

Koji LastErrorCode govori šta je palo?

RepairQDFFile vraća 1 pri uspehu i 0 pri svakom neuspehu, a LastErrorCode kaže koja je faza odbila. Izvor koji se ne može pročitati, uključujući onaj koji neki drugi proces drži pod ekskluzivnim lock-om, prijavljuje 401; čitanje je sada obavijeno tako da izuzetak pri ulazu ide u 401 umesto da iscuri u grešku upisa. Nevalidna ili dvosmislena QDF struktura, kao što je duplirani stream marker za isti objekat, prijavljuje PDFLIB_ERROR_QDF_REPAIR, što je 107, a odredište nije dirano jer writer nikada nije konstruisan. Sve posle popravke, od kreiranja privremenog fajla preko flush-a do preimenovanja, prijavljuje PDFLIB_ERROR_QDF_WRITE, što je 305. Regresija vežba one realistične: odredište koje je otvorio drugi handle bez delete sharing-a, odredište samo za čitanje, nepostojeći direktorijum odredišta, i svaku od tri writer faze koja pada kroz injekciju. U svima njima povratna vrednost je 0, kod je 305, i posle toga ne postoji ni nov ni delimičan cilj. Opšta navika čitanja koda umesto samo povratne vrednosti ista je ona opisana u članku o dijagnostikovanju tihih neuspeha u biblioteci

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Popravka na mestu: ista putanja je i ulaz i izlaz
    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;

Gde se garancija zaustavlja

Writer obećava konzistentnost prema neuspesima koje proces može da vidi, i iskren je prema onima koje ne može. Ako je proces ubijen između kreiranja privremenog fajla i preimenovanja, finally blok se nikada ne izvrši i u direktorijumu ostane .pdflib-qdf-<GUID>.tmp fajl; odredište je i dalje netaknuto, što je svojstvo koje je važno, ali ostatke morate sami da pometete. Gubitak napajanja je takođe van obećanja: podaci su flush-ovani a preimenovanje je write-through, što je najbolje što biblioteka u korisničkom režimu može da traži, ali writer ne radi fsync direktorijumskog unosa i ne daje nikakvu tvrdnju o trajnosti povrh onoga što fajl sistem pruža. Drugi writer koji istovremeno menja odredište nije detektovan, jer se DACL i atributi čitaju pre kreiranja privremenog fajla i ništa ih ne proverava ponovo u trenutku preimenovanja. A uspešno preimenovanje stvara novi identitet fajla, pa alternativni data stream-ovi i obični atributi kao što je archive ili hidden bit na starom fajlu ne preživljavaju; samo se DACL namerno prenosi

Uža granica je koji API uopšte koristi tu putanju. Samo RepairQDFFile prolazi kroz TPDFQDFFileWriter. SaveQDFToFile i ConvertFileToQDF i dalje otvaraju svoj izlaz sa PLCreateFileStream(FileName, fmCreate) i strimuju QDF konverziju pravo u njega, isto kao što inkrementalna putanja opisana u članku o dodavanju izmena u stream piše u koji god stream joj date. Ta dva poziva proizvode nov artefakt za debug iz dokumenta koji je već učitan i validiran, pa se rupa pri neuspehu parsiranja nikada nije ni odnosila na njih, ali ni ona ne nasleđuju objavljivanje zasnovano na preimenovanju. Ne čitajte ovaj članak kao „svaki QDF izvoz je atomski“. To su jedna izlazna tačka, ona čiji je ulaz nepoverljiv, ručno uređivan fajl i čiji je izlaz rutinski ista putanja, i upravo je ta kombinacija zaslužila dodatnu mašineriju. Injektiranje grešaka koje dokazuje sve ovo je jeftino jer su tri faze writer-a, WriteData, Flush i Publish, virtual. Test podklasa prepisuje jednu od njih da podigne grešku pošto je pravi posao već počeo, poziva Save nad popravljenim stream-om, i tvrdi da se izuzetak propagira, da su bajtovi izvora i odredišta nepromenjeni, i da nijedan privremeni fajl nije ostao. Nijedan globalni fajl API nije kačen, nijedan pravi korisnički fajl nije diran, a tri faze se preslikavaju jedan-na-jedan na tri načina na koja objavljivanje može da padne u produkciji: disk se napuni, flush je odbijen, ili je preimenovanje odbijeno jer neko drugi drži cilj

API RepairQDFFile, njegov writer za atomsko objavljivanje i ostatak QDF debug radnog toka deo su PDF Library for Delphi, uz funkcije oporavka cross-reference tabela, inkrementalnih ažuriranja i šifrovanja obrađene na drugim mestima na ovom blogu