Teknik Makale

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

Bir QDF dosyasını elle düzenleyin, aynı nesne için ikinci bir stream işareti araya sokun ve yazabileceğiniz en doğal Delphi koduyla yerinde bir onarım çalıştırın. Onarım adımı hasarı fark eder ve reddeder. Sonrasında dosyaya baktığınızda uzunluğu 0 bayttır; çünkü TFileStream.Create(OutputFileName, fmCreate), doğrulayıcı girdiyi daha görmeden bir satır önce o yolun içini boşaltmıştır. Baytları bellekte onarmak işin kolay yarısıdır. Onları diske, hiçbir zaman sıfır uzunluklu, yarım yazılmış ya da yanlış izinli bir dosya bırakmadan geri koymak ise öteki yarısıdır ve bunun da kendi arıza biçimleri vardır

Yerinde bir onarım dosyayı neden boşaltır?

Yerinde onarım dosyayı, işlem sırası yanlış olduğu için boşaltır: fmCreate açılışta içeriği keser, dolayısıyla açıştan sonra gelen her ret çok geçtir. InputFileName ile OutputFileName aynı yol olduğunda reddedilen bir girdi, kaybolmuş bir belgeye dönüşür. Özenli bir ayrıştırıcı sizi kurtaramaz. Belirsiz işaretleri reddettiğinde kendi çıktı akışına dokunmayabilir, ama çağıran hedefi bir çağrı önce imha etmiştir

// Bariz sürüm: hiçbir şey doğrulanmadan hedef kesilir
Input := TMemoryStream.Create;
try
  Input.LoadFromFile(InputFileName);                       // OutputFileName ile aynı yol
  Output := TFileStream.Create(OutputFileName, fmCreate);  // dosya şu an 0 bayt
  try
    if not MyRepairQDF(Input, Output) then  // kendi doğrulayıcınız: hayır demek için çok geç
      raise Exception.Create('QDF structure rejected');
  finally
    Output.Free;
  end;
finally
  Input.Free;
end;

Sıradaki alışılmış hamle, onarımı bir TMemoryStream içine yapmak ve çıktıyı onarım başarılı olduktan sonra açmaktır. Bu, kötü girdi deliğini kapatır; başka hiçbir şeyi kapatmaz. Yazma aşaması hâlâ önce kes sonra kopyala biçimindedir; dolayısıyla dolu bir disk, yarı yolda çıkan bir paylaşım ihlali ya da kesme ile son WriteBuffer arasındaki bir istisna hedefi yine hasarlı bırakır. Ayrıştırma ile yayımlama iki ayrı sınırdır ve her birinin kendi koruması gerekir

PDF Library for Delphi'de ikisini de TPDFlib.RepairQDFFile(InputFileName, OutputFileName) üstlenir. Girdiyi okur, tamamen bellekte onarır ve QDF yapısı reddedilirse hedefte hiçbir şey açmadan erken döner. Yalnızca başarılı bir onarım yayımlamaya ilerler ve yayımlama hedefi yazmak için hiç açmaz

Delphi'de bir QDF onarımını yayımlamanın üç yolu: çıktıyı fmCreate ile açmak onarım girdiyi reddedemeden dosyayı keser; önce bir TMemoryStream içine onarmak yalnızca kötü girdiye karşı korur; RepairQDFFile ise onarılmış baytları bir geçici dosya ve atomik bir rename üzerinden yayımlar
Ayrıştırma güvenliği ile yayımlama güvenliği farklı sınırlardır: önce bellekte onarmak kötü girdiye karşı korur; yazıcı ise dolu bir diskin ya da yazmanın ortasındaki bir arızanın hedefi hasarlı bırakamaması için vardır

Onarılmış baytlar ile hedef arasındaki dört adım

RepairQDFFile, sürecin kendisinin gözleyebildiği her arıza için hedef yolun ya eksiksiz eski dosyayı ya da eksiksiz yeni dosyayı tutacağını garanti eder. Buna dört adımda ulaşır ve hiçbiri, bir önceki bitmeden başlamaz. Hedef, GetFullPathNameW ile tam yola çözülür; fonksiyon önce gerekli uzunluğu öğrenmek için, sonra da tam o boyutta bir arabelleğe çağrılır, böylece uzun bir yol MAX_PATH'te kırpılmaz. Ardından hedef dizinde .pdflib-qdf- artı bir GUID artı .tmp adında geçici bir dosya oluşturulur: Windows'ta CreateFileW ve CREATE_NEW, POSIX'te O_CREAT or O_EXCL ve 0600 kipiyle open(2) kullanılır. Her iki bayrak da ad zaten varsa oluşturmayı başarısız kılar. Bir şekilde aynı GUID'yi çeken iki süreç, bir tanıtıcıyı paylaşarak çıkamaz

Onarılmış akış ardından WriteBuffer üzerinden 64 KiB'lik parçalar hâlinde kopyalanır; fonksiyon, kimsenin denetlemediği bir sayı döndürmek yerine kısa yazmada istisna fırlatır. FlushFileBuffers ya da fsync(2) veriyi diske indirir ve tanıtıcı kapatılır. Kapatma bilinçli olarak istisna fırlatmaz: flush bir kez başarılı olduktan sonra başarısız bir kapatma veri için tehdit değildir ve oradaki bir istisna, gerçek yazma ya da flush hatasını teşhis edecek kişiden gizlerdi. Son adım yeniden adlandırmadır

PDF Library for Delphi'de RepairQDFFile fonksiyonunun çıktısını yayımlamak için kullandığı dört adım: yolu GetFullPathNameW ile iki kez çözmek, .pdflib-qdf geçici dosyasını yarışan süreçler tanıtıcı paylaşamasın diye CREATE_NEW ya da O_EXCL ile oluşturmak, 64 KiB'lik WriteBuffer parçalarıyla kopyalayıp flush yapmak, sonra REPLACE_EXISTING ve WRITE_THROUGH ile MoveFileExW çağırmak
Her adım, bir önceki bitmeden ilerlemeyi reddeder; geçici dosya yapısı gereği hedef birimde yaşar, önce-sil diye bir pencere hiç oluşmaz ve finally içindeki temizlik geride .tmp kalıntısı bırakmaz

Yeniden adlandırma, ev yapımı güvenli kaydetme yordamlarının çoğunun kırıldığı adımdır. Windows'ta geçici dosya, hedefin yerini MoveFileExW ile MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH bayraklarıyla alır; aynı birimde bu tek bir dosya sistemi işlemidir. MOVEFILE_COPY_ALLOWED bilinçli olarak dışarıda bırakılır. Birimler arası taşıma sessizce kopyala-sonra-sil'e dönüşür; tasarımın bütünüyle kaçındığı atomik olmayan dizi tam olarak budur ve makine giderken yarısı bitmiş "başarılı" bir kopyadan çok, reddedilen bir rename görmeyi yeğlerim. Geçici dosya hedef dizinde durduğu için zaten hedef birimdedir. Eski dosya önce silinmez; sil-sonra-adlandır çifti, yolun hiç var olmadığı bir pencere bırakır ve o pencerenin içindeki bir çökme belgeyi kaybettirir. MOVEFILE_WRITE_THROUGH, rename diske ulaşmadan çağrının dönmesini engeller. POSIX'te rename(2) var olan bir adın yerini zaten atomik olarak alır ve aynı dizine yerleştirme, çağrıyı EXDEV'den uzak tutar

Temizlik her yolda bir finally bloğunda çalışır. Başarılı bir rename'den sonra geçici ad artık var olmadığından silme hiçbir şey yapmaz; başarısızlıkta ise kısmi dosyayı kaldırır. Dizin, çağrıdan önce sahip olduğu dosyalarla birebir aynı hâle gelir

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

Boş bir güvenlik tanımlayıcısıyla oluşturulan dosya DACL'sini üst dizinden devralır; yerini alacağı dosyanın bunda söz hakkı yoktur. Yepyeni bir belge için bu varsayılan doğrudur. Yerinde onarım için yanlıştır. Bir operatörün contract.pdf'i korumalı, devralınmayan bir DACL ile tek bir hesaba kilitleyiverdiğini düşünün. Yanındaki sıradan bir geçici dosya dizinin daha geniş izinlerini devralır; rename'den sonra yayımlanan contract.pdf o geniş DACL'yi taşır, çünkü NTFS güvenliği adda değil dosya nesnesindedir. Baytlar doğrudur, dönüş değeri 1'dir. Operatörün kurduğu erişim kısıtlaması ise sade bir şekilde yok olmuştur

RepairQDFFile bu yüzden geçici dosyayı oluşturmadan önce hedefin DACL'sini okur ve CreateFileW'ye lpSecurityAttributes üzerinden verir; böylece yeni dosya eski dosyanın izinleriyle doğar ve rename, operatörün fark edeceği hiçbir şeyi değiştirmez. Okuma, DACL_SECURITY_INFORMATION ile GetFileSecurityW'dir; arabellek ilk çağrının ERROR_INSUFFICIENT_BUFFER yanıtına göre boyutlanır. Hedef henüz yokken — ayrı bir çıktı yolu için normal durum budur — tanımlayıcı verilmez ve yeni dosya, her öbür yeni dosya gibi kendi dizininden devralır

QDF yayım yazıcısının geçici dosyasını oluşturmadan önce hedef DACL'sini neden kopyaladığı: boş bir tanımlayıcı dizinin daha geniş izinlerini devralır ve rename 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 yolculuk eder: okunan tanımlayıcıyı lpSecurityAttributes olarak vermek, rename'in operatörün yapılandırdığı hiçbir şeyi değiştirmemesini sağlar ve her kapı tahmin yürütmek yerine kapalı yönde başarısız olur

Üç koşul, kodu tahmin yürütmek yerine yayımlamayı durdurur ve her biri LastErrorCode 305 bildirir:

  • DACL okunamaz; örneğin çağıran hesabın hedef üzerinde READ_CONTROL hakkı yoktur
  • Dönen tanımlayıcıda SE_DACL_PRESENT set değildir; böyle bir tanımlayıcıyı CreateFileW'ye vermek, Windows'un süreç varsayılan DACL'sine geri düşmesine ve kimsenin değişmesini istemediği erişim semantiğini değiştirmesine yol açardı
  • Hedef FILE_ATTRIBUTE_ENCRYPTED taşır; geçici dosya düz metin olurdu ve onu EFS korumalı bir dosyanın üzerine adlandırmak, kullanıcının şifrelemeyi seçtiği bir şeyin şifresiz bir ikamini yayımlamak olurdu

EFS bir dosya sistemi özelliğidir ve PDF güvenlik işleyicileriyle ilgisi yoktur; onlar şifreli belge yükleme yazısında ele alınır. Yine de arıza biçimi aynı tür sessiz düşüştür ve onu reddetmek doğru varsaylandır. EFS payında ara sıra hayır diyen bir onarım bir destek kaydıdır. Kullanıcının şifrelediği bir dosyanın şifresini çözen onarım ise bir olaydır

Kendiniz bir DACL koruma testi yazacaksanız bir Windows ayrıntısı, kolayca yanlış kurulan cinstendir. Kısıtlı test dosyasını kurmak için yalnızca sahibine uygulanan bir DACL verin ve tanımlayıcı denetiminde SE_DACL_PROTECTED'i açıkça set edin; SetFileSecurityW'ye PROTECTED_DACL_SECURITY_INFORMATION geçirmek, korumasız bir tanımlayıcıyı korumalıya çevirmez. Ardından yayımlanan dosyanın hâlâ korumalı biti ve açık, boş olmayan bir DACL bildirdiğini doğrulayın; bir kez ayrı çıktı yolu için, bir kez de kaynağın kendi üzerinden onarım için

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

RepairQDFFile başarıda 1, başarısızlıkta 0 döndürür ve reddeden aşamayı TPDFlib.LastErrorCode adlandırır. Var olmayan, açılamayan ya da 2 GB'den büyük bir girdi 401 bildirir; okuma sırasındaki bir istisna da yazma koduna sızmak yerine 401'e eşlenir. O yinelenen stream işareti gibi geçersiz ya da belirsiz bir QDF yapısı PDFLIB_ERROR_QDF_REPAIR (107) bildirir; hedef hiç açılmamıştır. Yol çözümünden flush ve rename'e kadar onarımdan sonraki her şey PDFLIB_ERROR_QDF_WRITE (305) bildirir. Gerçekçi yazma arızalarının hepsi 305'e düşer: silme paylaşımı olmadan başka bir tanıtıcıyla açık tutulan bir hedef, salt okunur bir hedef, olmayan bir hedef dizini, bir de yukarıdaki DACL ve EFS retleri. 305 olan her durumda geride yeni ya da kısmi bir hedef kalmaz

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

305 yolu, hiçbir dosya API'sini kancalamadan kendi test projenizden kolayca yeniden üretilir. Delphi'nin fmShareDenyNone'u bir dosyayı okuma ve yazma paylaşımıyla ama FILE_SHARE_DELETE olmadan açar ve Windows, bu şekilde tutulan bir dosyanın üzerine hiçbir şeyin adlandırılmasını reddeder. Böyle bir tanıtıcıyı çağrı boyunca açık tutun; rename, veri yazılıp flush edildikten sonraki en son adımda başarısız olur — en çok işlendiğini görmek istediğiniz durum da tam olarak budur

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;
  // Bu denetimler kütüphane çağrısı değil, kendi testiniz için önerilerdir
  Assert(BytesEqual(Before, TFile.ReadAllBytes(Target)));  // BytesEqual: sizin yardımcınız
  // Geride geçici dosya kalmamalı
  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;

0 dönüş koduna tek başına güvenmek yerine kodu okuma alışkanlığı için, kütüphanedeki sessiz arızaları tanılama yazısı aynı örüntüyü API'nin geri kalanında da ele alır

Garantinin nerede bittiği

Vaat, sürecin görebildiği arızaları kapsar. Süreç, geçici dosyanın oluşturulması ile rename 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; önemli olan da budur, ama o kalıntıyı süpürmek size düşer. Elektrik kesintisi bir dosya sistemi sorusudur: veri flush edilir, rename write-through'dur; yine de POSIX'te dizin girdisi ayrıca sync edilmez, dolayısıyla dayanıklılık dosya sisteminin bunun üzerine ne sunduysa odur

Başarılı bir rename aynı zamanda yeni bir dosya nesnesi üretir ve eskinin birkaç özelliği onunla birlikte gelmez. DACL bilinçli olarak taşınır. Sahiplik taşınmaz; yalnızca DACL_SECURITY_INFORMATION okunduğu için yayımlanan dosyayı onarımı çalıştıran hesap sahibilenir. Geçici dosya FILE_ATTRIBUTE_NORMAL ile oluşturulur; gizli, arşiv ve benzeri öznitelik bitleri sıfırdan başlar ve eski dosyadaki alternate data stream'ler kaybolur. POSIX'te yayımlanan dosya geçici dosyanın 0600 kipini taşır; bir grubun okuma erişimi gerekiyorsa sonradan chmod çağırın. Hedefin öznitelikleri ve DACL'si, geçici dosya daha var olmadan önce okunur; aynı yola ikinci bir yazıcıyı eşgüdümlemek size düşer

SaveQDFToFile ve ConvertFileToQDF doğrudan yazar

Geçici bir dosya üzerinden yayımlayan tek QDF giriş noktası RepairQDFFile'dır. SaveQDFToFile ile ConvertFileToQDF çıktılarını fmCreate ile açar ve dönüşümü doğrudan içine akıtır; bir akışa güncelleme ekleme yazısındaki artımlı yolun, kendisine verdiğiniz akışa yazması gibi. Ayrım bilinçlidir. O iki çağrı, çoktan yüklenip doğrulanmış bir belgeyi yeni bir hata ayıklama çıktısına çevirir; yarı yolda reddedilebilecek güvenilmeyen bir girdi yoktur ve çıktı yolları çoğunlukla tazedir. RepairQDFFile ise elle düzenlenmiş, güvenilmeyen bir dosya alır ve rutin olarak aynı yola geri işaretlenir; ek makineyi hak eden bileşim budur

Bir QDF dökümü için atomik değiştirmeyi gerçekten istediğinizde, kendi yazacağınız kısa bir sarmalayıcı yeter:

// Kendi yardımcınız: QDF'yi hedefin yanına yaz, sonra tek bir rename ile değiştir
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);   // yalnızca bir şey başarısız olduğunda bulunur
  end;
end;

Bu sürüm, RepairQDFFile'ın yaptığı DACL kopyasını ve açık veri flush'ini atlar. Çalışma ya da hata ayıklama klasörüne düşen QDF dökümleri için makinenin tam doğru dozda olduğunu düşünüyorum; döküm, kilitli izinli bir dosyanın yerine geçecekse işi onarım yolundan geçirin ya da güvenlik tanımlayıcısını kendiniz kopyalayın

Atomik onarım çıktısı, QDF dönüşüm çağrıları ve yukarıdaki hata kodlarının tümü PDF Library for Delphi ile gelir; bu blogda başka yazılarda ele alınan çapraz başvuru kurtarma, artımlı güncelleme ve şifreleme desteğinin yanında