Teknik Makale

Delphi'de Atomik PDF Onarım Çıktısı: Rename, DACL Güvenliği

PDF Library for Delphi, RepairQDFFile çıktısını hedefi hiçbir zaman yazmak için açmayan bir iç yazıcı olan TPDFQDFFileWriter üzerinden yayımlar: onarılmış baytlar aynı dizinde özel olarak oluşturulmuş geçici bir dosyaya gider, dosya boşaltılıp kapatılır ve ancak ondan sonra Windows'ta MoveFileExW ile, POSIX'te rename(2) ile hedefin üzerine taşınır. Yeniden adlandırmadan önce bir şey başarısız olursa hedef sahip olduğu her baytı korur ve çağıran LastErrorCode 305 görür. Bir belgeyi bellekte onarmak, bir onarım özelliğinin kolay yarısıdır. Sonucu, kullanıcıyı hiçbir zaman sıfır uzunluklu ya da yarı yazılmış bir dosyayla bırakmadan diske koymak ise bu yazının konusu olan yarısıdır

Başarısız olan bir onarım hedef dosyayı neden yine de yok edebilir?

Çünkü işlem sırası yanlıştı. v3.539.13'ten önce RepairQDFFile çıktıyı PLCreateFileStream(OutputFileName, fmCreate) ile açıp o akışı ayrıştırıcıya veriyordu. fmCreate açılışta dosyayı keser, dolayısıyla QDF taraması girdinin onarılamaz olduğuna karar verdiğinde hedef zaten boşaltılmıştı. InputFileName ile OutputFileName aynı yol olduğunda yapılan yerinde onarım, reddedilen bir girdiyi kaybolmuş bir dosyaya çeviriyordu. Ayrıştırıcının kendisi usluydu: alt düzey PDFQDFRepair fonksiyonu belirsiz işaretleri reddettiğinde hedef akışa dokunmaz. Bu koruma yalnızca konu dışıydı, çünkü genel API dosyayı bir çağrı önce kesmişti

v3.539.13'teki düzeltme onarımı bir TMemoryStream içine taşıdı ve çıktıyı ancak PDFQDFRepair başarılı olduktan sonra açtı. Bu, ayrıştırma hatası deliğini kapatır ve başka hiçbir şeyi kapatmaz. Yazma aşaması hâlâ fmCreate ardından CopyFrom idi, dolayısıyla disk dolu durumu, yarı yolda çıkan bir paylaşım ihlali ya da kesme ile son WriteBuffer arasında oluşan bir istisna hedefi yine hasarlı bırakıyordu. Bellekte önce onarmak kötü girdiye karşı korur. Diske yayımlamanın kendi sınırı gerekir ve v3.539.14 ile v3.539.15 bunu kurdu

PDF Library for Delphi'de RepairQDFFile fonksiyonunun kendi hedefini yok etmeyi nasıl bıraktığı: v3.539.12 çıktıyı PLCreateFileStream ve fmCreate ile açıyor, bu da PDFQDFRepair girdiyi reddedemeden dosyayı kesiyordu; v3.539.13 önce bir TMemoryStream içine onardı ve v3.539.15 baytları atomik yayım için TPDFQDFFileWriter fonksiyonuna devrediyor
Ayrıştırma hatası düzeltmesi ile yayımlama düzeltmesi farklı sınırlardır: bellekte önce onarmak kötü girdiye karşı korur, yazıcı ise dolu bir diskin ya da yazma ortasındaki bir hatanın hedefi artık hasarlı bırakamaması için vardır
// v3.539.12: hedef, girdi doğrulanmadan önce kesilir
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // hayır demek için çok geç
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: bellekte onar, sonra baytları yayım yazıcısına ver
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // hedef hiç açılmadı
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Atomik yayımlama tam olarak neyi garanti eder?

TPDFQDFFileWriter.Save, kütüphanenin kendisinin gözleyebildiği her hata için hedef yolun ya eksiksiz eski dosya ya da eksiksiz yeni dosya olacağını, asla bir karışım olmayacağını garanti eder. Yazıcı bunu, her biri bir önceki bitmeden ilerlemeyi reddeden dört adımda yapar. Önce hedefi GetFullPathNameW ile çözer; onu iki kez çağırıp arabelleği MAX_PATH varsaymak yerine döndürülen uzunluğa göre ayırır, böylece uzun yollar sessizce kesilmez. İkinci olarak hedef dizinde .pdflib-qdf- artı bir GUID artı .tmp adlı geçici bir dosyayı, Windows'ta CREATE_NEW ile CreateFileW, POSIX'te O_CREAT or O_EXCL ve 0600 kipiyle open(2) kullanarak oluşturur. Her iki bayrak da ad zaten varsa oluşturmayı başarısız kılar, böylece aynı GUID üzerinde yarışan iki süreç bir tanıtıcıyı paylaşamaz. Üçüncü olarak onarılmış akışı WriteBuffer üzerinden 64 KiB'lik parçalarla kopyalar; bu, kimsenin denetlemediği bir sayı döndürmek yerine kısa yazmada hata verir; ardından FlushFileBuffers ya da fsync(2) çağırıp tanıtıcıyı kapatır. Dördüncü olarak yeniden adlandırır

PDF Library for Delphi'de TPDFQDFFileWriter.Save fonksiyonunun dört atomik adımı: yolu GetFullPathNameW ile iki kez çözer, .pdflib-qdf geçici dosyasını CREATE_NEW ya da O_EXCL ile oluşturur ki yarışan süreçler tanıtıcı paylaşamasın, 64 KiB'lik WriteBuffer parçalarıyla kopyalayıp boşaltır, sonra REPLACE_EXISTING ve WRITE_THROUGH ile MoveFileExW çağırır
Her adım, bir önceki bitmeden ilerlemeyi reddeder; geçici dosya yapısı gereği hedef birimde yaşar, önce silme diye bir pencere hiç oluşmaz ve finally içindeki temizlik geride .tmp kalıntısı bırakmaz
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
  // Birimler arası kopyaya ya da hedefi önce silmeye izin verme
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Yeniden adlandırma adımı, evde yazılmış “güvenli kaydet” yordamlarının çoğunun sessizce kırıldığı yerdir. MOVEFILE_REPLACE_EXISTING ile MoveFileExW, aynı birimde hedefi tek bir dosya sistemi işleminde değiştirir. Yazıcı MOVEFILE_COPY_ALLOWED bayrağını bilinçli olarak dışarıda bırakır, çünkü birimler arası taşıma kopyala-sonra-sil biçimine dönüşür ki bu, tüm tasarımın kaçınmak için var olduğu atomik olmayan dizi tam olarak budur. Geçici dosya hedef dizinde yaşadığı için yapısı gereği hedef birimdedir. Yazıcı eski dosyayı hiçbir zaman önce silmez; sil-sonra-adlandır çiftinde yolun hiç var olmadığı bir pencere bulunur ve o pencerede çöken bir süreç belgeyi kaybeder. MOVEFILE_WRITE_THROUGH, çağrıdan yeniden adlandırma diske ulaşana kadar dönmemesini ister ve bu, verinin açıkça boşaltılmasıyla eşleşir. POSIX'te rename(2) yeni adın var olan dosyanın yerini atomik olarak almasını zaten garanti eder ve aynı dizine yerleştirme, çağrının EXDEV ile başarısız olmasını engeller. Temizlik simetriktir. Geçici ad her yolda bir finally bloğunda kaldırılır; bu, başarı durumunda yeniden adlandırma onu zaten tükettiği için no-op'tur, başarısızlıkta ise kısmi dosyayı silerek dizinin .tmp kalıntısı biriktirmesini önler. Tests\QDFFileRegression.inc içindeki regresyon tam olarak bunu denetler: enjekte edilen her hatadan sonra hedef baytlar özgünle, kaynak baytlar özgünle eşleşir ve dizinde iki fikstürden başka hiçbir şey yoktur

Geçici bir dosya Windows'ta izinleri neden gevşetir?

Boş bir güvenlik tanımlayıcısıyla oluşturulan bir dosya, DACL'sini değiştirmek üzere olduğu dosyadan değil üst dizinden devralır. Bu, yepyeni bir belge için doğru varsayılan, yerinde onarım için ise yanlış olandır. Bir operatörün contract.pdf dosyasını korumalı ve devralınmayan bir DACL ile tek bir hesaba kapattığını düşünün. Yanındaki geçici dosya dizinin daha geniş izinlerini devralır ve contract.pdf üzerine taşındığı anda taşınan dosya geniş DACL'yi taşır, çünkü NTFS güvenliği adla değil dosya nesnesiyle birlikte yolculuk eder. Onarım başarılı olur, baytlar doğrudur ve operatörün yapılandırdığı erişim denetimi sessizce uçmuştur. Dönen değerde bunu ima eden hiçbir şey yoktur

Bu yüzden PDF Library for Delphi, geçici dosyayı oluşturmadan önce hedefin DACL'sini okur ve CreateFileW fonksiyonuna lpSecurityAttributes bağımsız değişkeni olarak verir; böylece yeni dosya eski dosyanın izinleriyle doğar ve yeniden adlandırma operatörün fark edeceği hiçbir şeyi değiştirmez. Okuma, GetFileSecurityW fonksiyonunu DACL_SECURITY_INFORMATION ile kullanır ve arabelleği ilk çağrının ERROR_INSUFFICIENT_BUFFER sonucuna göre boyutlar. Üç koşul, yazıcının tahmin etmek yerine kapalı yönde başarısız olmasını sağlar. DACL okunamıyorsa yayımlama bir EWriteError ile durur ve genel API bunu 305'e eşler. Tanımlayıcı SE_DACL_PRESENT set edilmeden dönerse yayımlama yine durur, çünkü böyle bir tanımlayıcıyı CreateFileW fonksiyonuna vermek çekirdeğin süreç varsayılan DACL'sine geri düşmesine ve kimse istemeden erişim semantiğini değiştirmesine izin verirdi. Ve hedef FILE_ATTRIBUTE_ENCRYPTED taşıyorsa yazıcı doğrudan reddeder: geçici dosya düz metin olurdu ve düz metin bir dosyayı EFS korumalı bir dosyanın üzerine taşımak, kullanıcının dosya sistemi düzeyinde şifrelemeyi seçtiği bir şeyin şifresiz bir kopyasını yayımlamak olurdu. EFS'in PDF standart güvenlik işleyicileriyle ilgisi yoktur; onlar şifreli belge yükleme yazısının konusudur, ama arıza biçimi aynı türden sessiz bir düşüştür

QDF yayım yazıcısının geçici dosyasını oluşturmadan önce hedef DACL'yi neden kopyaladığı: boş bir tanımlayıcı dizinin daha geniş izinlerini devralır ve yeniden adlandırma erişimi sessizce genişletirdi; bu yüzden GetFileSecurityW DACL'yi okur, eksik bir SE_DACL_PRESENT biti ya da EFS özniteliği yayımlamayı 305 ile durdurur ve CreateFileW eski izinlerle doğar
NTFS güvenliği adla değil dosya nesnesiyle birlikte yolculuk eder: okunan tanımlayıcıyı lpSecurityAttributes olarak vermek, yeniden adlandırmanın operatörün yapılandırdığı hiçbir şeyi değiştirmemesini sağlar ve her kapı tahmin etmek yerine kapalı yönde başarısız olur
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');
  // tanımlayıcıyı boyutla, sonra yalnızca DACL kısmını oku
  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;   // CreateFileW / CREATE_NEW'e verilir
end;

Regresyondan bir ayrıntı, benzer bir test yazarsanız akılda tutmaya değer. Kısıtlı fikstürü kurmak için test sahibine özel bir DACL uygular ve tanımlayıcı denetiminde SE_DACL_PROTECTED bayrağını açıkça set etmek zorundadır; korumalı bayrağı yalnızca SetFileSecurityW fonksiyonunun SecurityInformation bağımsız değişkeninde geçirmek, korumasız bir tanımlayıcıyı korumalı hâle getirmez. Sonraki doğrulama, yayımlanan dosyanın hâlâ korumalı biti ve açık, boş olmayan bir DACL bildirdiğidir; bu hem ayrı bir çıktı yolu hem de kaynak dosyanın kendi üzerinde onarılması için geçerlidir

Hangi LastErrorCode size neyin başarısız olduğunu söyler?

RepairQDFFile başarıda 1, herhangi bir başarısızlıkta 0 döndürür ve hangi aşamanın reddettiğini LastErrorCode söyler. Okunamayan bir kaynak, başka bir sürecin özel kilitle tuttuğu dosya dahil, 401 bildirir; okuma artık, girdi sırasındaki bir istisnanın yazma hatasına sızması yerine 401'e eşlenmesi için sarmalanmıştır. Geçersiz ya da belirsiz QDF yapısı, örneğin aynı nesne için yinelenen bir akış işareti, 107 olan PDFLIB_ERROR_QDF_REPAIR bildirir ve yazıcı hiç oluşturulmadığı için hedefe dokunulmamıştır. Onarımdan sonraki her şey, geçici dosya oluşturmadan boşaltma ve yeniden adlandırmaya kadar, 305 olan PDFLIB_ERROR_QDF_WRITE bildirir. Regresyon gerçekçi olanları çalıştırır: başka bir tanıtıcının silme paylaşımı olmadan açtığı bir hedef, salt okunur bir hedef, olmayan bir hedef dizini ve yazıcının üç aşamasının her birinin enjeksiyonla başarısız kılınması. Hepsinde dönüş 0'dır, kod 305'tir ve sonrasında yeni ya da kısmi bir hedef yoktur. Yalnızca dönüş değerini değil kodu da okuma alışkanlığı, kütüphanedeki sessiz arızaları tanılama yazısında anlatılan alışkanlığın aynısıdır

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Yerinde onarım: aynı yol hem girdi hem çıktı
    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;

Garanti nerede bitiyor

Yazıcı, sürecin görebildiği arızalara karşı tutarlılık vaat eder ve göremedikleri konusunda dürüsttür. Süreç, geçici dosya oluşturulması ile yeniden adlandırma arasında öldürülürse finally bloğu hiç çalışmaz ve dizinde bir .pdflib-qdf-<GUID>.tmp dosyası kalır; hedef hâlâ sağlamdır ki önemli olan özellik budur, ama kalıntıyı süpürmek size düşer. Elektrik kesintisi de vaadin dışındadır: veri boşaltılır ve yeniden adlandırma write-through'dur, bu bir kullanıcı modu kütüphanesinin isteyebileceği en iyisidir, ama yazıcı dizin girdisini fsync etmez ve dosya sisteminin sağladığının üzerine bir dayanıklılık iddiası koymaz. Hedefi eşzamanlı olarak değiştiren ikinci bir yazıcı algılanmaz, çünkü DACL ve öznitelikler geçici dosya oluşturulmadan önce okunur ve yeniden adlandırma anında hiçbir şey onları yeniden denetlemez. Ve başarılı bir yeniden adlandırma yeni bir dosya kimliği yaratır, dolayısıyla alternatif veri akışları ile eski dosyadaki arşiv ya da gizli biti gibi sıradan öznitelikler ayakta kalmaz; bilinçli olarak yalnızca DACL taşınır

Daha dar sınır, bu yolu hangi API'nin kullandığıdır. TPDFQDFFileWriter üzerinden yalnızca RepairQDFFile geçer. SaveQDFToFile ile ConvertFileToQDF çıktılarını hâlâ PLCreateFileStream(FileName, fmCreate) ile açıp QDF dönüşümünü doğrudan içine akıtır; bir akışa güncelleme ekleme yazısında anlatılan artımlı yolun, kendisine verdiğiniz akışa yazması gibi. Bu iki çağrı, çoktan yüklenmiş ve doğrulanmış bir belgeden yeni bir hata ayıklama çıktısı üretir, dolayısıyla ayrıştırma hatası deliği onlar için hiç geçerli olmadı; ama yeniden adlandırmaya dayanan yayımlamayı da devralmazlar. Bu yazıyı “her QDF dışa aktarımı atomiktir” diye okumayın. Bu tek bir çıkıştır; girdisi güvenilmeyen, elle düzenlenmiş bir dosya olan ve çıktısı rutin olarak aynı yol olan çıkış; ona ek makineyi kazandıran da bu bileşimdir. Bütün bunları kanıtlayan hata enjeksiyonu ucuzdur, çünkü yazıcının üç aşaması WriteData, Flush ve Publish virtual'dir. Test alt sınıfı bunlardan birini, gerçek iş başladıktan sonra hata verecek biçimde geçersiz kılar, onarılmış bir akış üzerinde Save çağırır ve istisnanın yayıldığını, kaynak ile hedef baytların değişmediğini ve hiçbir geçici dosyanın kalmadığını doğrular. Hiçbir genel dosya API'si kancalanmaz, hiçbir gerçek kullanıcı dosyasına dokunulmaz ve üç aşama, üretimde bir yayımlamanın başarısız olabileceği üç yola bire bir karşılık gelir: disk dolar, boşaltma reddedilir ya da hedefi başkası tuttuğu için yeniden adlandırma reddedilir

RepairQDFFile API'si, atomik yayım yazıcısı ve QDF hata ayıklama iş akışının geri kalanı PDF Library for Delphi ürününün bir parçasıdır; bu blogda başka yerlerde ele alınan çapraz başvuru kurtarma, artımlı güncelleme ve şifreleme özelliklerinin yanında