Artikel Teknis

Output Repair PDF Atomik di Delphi: Rename dan DACL

PDF Library for Delphi menerbitkan output RepairQDFFile lewat writer internal, TPDFQDFFileWriter, yang tidak pernah membuka tujuan untuk ditulis: byte hasil perbaikan masuk ke file sementara yang dibuat secara eksklusif di direktori yang sama, file itu di-flush dan ditutup, dan baru setelah itu di-rename menimpa target dengan MoveFileExW di Windows atau rename(2) di POSIX. Kalau ada yang gagal sebelum rename, tujuan tetap memegang setiap byte yang dimilikinya, dan pemanggil melihat LastErrorCode 305. Memperbaiki dokumen di memori adalah separuh yang mudah dari fitur repair. Menaruh hasilnya ke disk tanpa pernah meninggalkan pengguna dengan file berukuran nol atau tertulis separuh adalah separuh yang jadi bahasan artikel ini

Kenapa repair yang gagal masih bisa menghancurkan file target?

Karena urutan operasinya salah. Sebelum v3.539.13, RepairQDFFile membuka output dengan PLCreateFileStream(OutputFileName, fmCreate) lalu menyerahkan stream itu ke parser. fmCreate memotong file saat dibuka, jadi pada saat scan QDF memutuskan inputnya tidak bisa diperbaiki, tujuannya sudah dikosongkan. Repair in-place, di mana InputFileName dan OutputFileName adalah path yang sama, mengubah input yang ditolak jadi file yang hilang. Parser-nya sendiri berperilaku baik: fungsi level rendah PDFQDFRepair membiarkan stream target tak tersentuh ketika ia menolak marker yang ambigu. Perlindungan itu sama sekali tidak relevan, karena API publiknya sudah memotong file itu satu panggilan sebelumnya

Perbaikan v3.539.13 memindahkan repair ke TMemoryStream dan baru membuka output setelah PDFQDFRepair berhasil. Itu menutup lubang kegagalan parse dan tidak menutup yang lain. Fase tulisnya masih fmCreate yang diikuti CopyFrom, jadi kondisi disk penuh, sharing violation di tengah jalan, atau exception antara pemotongan dan WriteBuffer terakhir tetap meninggalkan tujuan yang rusak. Repair yang mengutamakan memori melindungi dari input buruk. Publikasi ke disk butuh batasnya sendiri, dan v3.539.14 serta v3.539.15 membangun batas itu

Bagaimana RepairQDFFile di PDF Library for Delphi berhenti menghancurkan targetnya sendiri: v3.539.12 membuka output dengan PLCreateFileStream dan fmCreate, yang memotong file sebelum PDFQDFRepair bisa menolak inputnya, v3.539.13 memperbaiki ke TMemoryStream lebih dulu, dan v3.539.15 menyerahkan byte-nya ke TPDFQDFFileWriter untuk publikasi atomik
Perbaikan kegagalan parse dan perbaikan publikasi adalah dua batas yang berbeda: repair yang mengutamakan memori melindungi dari input buruk, sedangkan writer-nya ada supaya disk penuh atau kegagalan di tengah penulisan tidak lagi bisa meninggalkan tujuan yang rusak
// v3.539.12: tujuan sudah dipotong sebelum inputnya divalidasi
Output := PLCreateFileStream(OutputFileName, fmCreate);
try
  if PDFQDFRepair(Source, Output, QDFError) then   // terlambat untuk berkata tidak
    Result := 1;
finally
  Output.Free;
end;

// v3.539.15: repair di memori, lalu serahkan byte-nya ke writer publikasi
Repaired := TMemoryStream.Create;
try
  if not PDFQDFRepair(Source, Repaired, QDFError) then
    Exit;                                          // tujuan tidak pernah dibuka
  Writer := TPDFQDFFileWriter.Create;
  try
    Writer.Save(Repaired, OutputFileName);
    Result := 1;
  finally
    Writer.Free;
  end;
finally
  Repaired.Free;
end;

Apa yang sebenarnya dijamin oleh publikasi atomik?

TPDFQDFFileWriter.Save menjamin bahwa path tujuan berisi entah file lama yang utuh atau file baru yang utuh, tidak pernah campuran, untuk setiap kegagalan yang bisa diamati library sendiri. Writer-nya melakukan ini dalam empat langkah yang masing-masing menolak berjalan kalau langkah sebelumnya belum selesai. Pertama ia meresolusi tujuan dengan GetFullPathNameW, memanggilnya dua kali dan mengalokasikan buffer dari panjang yang dikembalikan alih-alih mengasumsikan MAX_PATH, supaya path panjang tidak terpotong diam-diam. Kedua ia membuat file sementara bernama .pdflib-qdf- plus GUID plus .tmp di direktori tujuan, memakai CreateFileW dengan CREATE_NEW di Windows dan open(2) dengan O_CREAT or O_EXCL serta mode 0600 di POSIX. Kedua flag itu membuat pembuatannya gagal kalau namanya sudah ada, jadi dua proses yang berlomba pada GUID yang sama tidak bisa berbagi handle. Ketiga ia menyalin stream hasil perbaikan dalam potongan 64 KiB lewat WriteBuffer, yang melempar error pada penulisan pendek alih-alih mengembalikan jumlah yang tidak diperiksa siapa pun, lalu memanggil FlushFileBuffers atau fsync(2) dan menutup handle-nya. Keempat ia me-rename

Empat langkah atomik TPDFQDFFileWriter.Save di PDF Library for Delphi: resolusi path dua kali dengan GetFullPathNameW, pembuatan file sementara .pdflib-qdf dengan CREATE_NEW atau O_EXCL supaya proses yang berlomba tidak bisa berbagi handle, penyalinan dalam potongan WriteBuffer 64 KiB lalu flush, dan terakhir MoveFileExW dengan REPLACE_EXISTING serta WRITE_THROUGH
Setiap langkah menolak berjalan kalau langkah sebelumnya belum selesai, file sementaranya berada di volume tujuan secara konstruksi, jendela hapus-dulu tidak pernah ada, dan pembersihan di blok finally tidak meninggalkan serpihan .tmp
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
  // Jangan izinkan penyalinan lintas volume atau hapus dulu tujuannya
  if not MoveFileExW(PWideChar(TempFileName), PWideChar(FileName),
    MOVEFILE_REPLACE_EXISTING or MOVEFILE_WRITE_THROUGH) then
    raise EWriteError.Create('Unable to publish QDF output');
end;

Langkah rename adalah tempat sebagian besar rutin "safe save" buatan sendiri diam-diam rusak. MoveFileExW dengan MOVEFILE_REPLACE_EXISTING mengganti targetnya dalam satu operasi filesystem di volume yang sama. Writer-nya sengaja tidak menyertakan MOVEFILE_COPY_ALLOWED, karena pemindahan lintas volume merosot jadi copy-then-delete, dan itulah persis urutan non-atomik yang ingin dihindari seluruh rancangan ini. Karena file sementaranya berada di direktori tujuan, ia berada di volume tujuan secara konstruksi. Writer-nya juga tidak pernah menghapus file lama lebih dulu; pasangan hapus-lalu-rename punya jendela di mana path-nya sama sekali tidak ada, dan crash di dalam jendela itu menghilangkan dokumennya. MOVEFILE_WRITE_THROUGH meminta panggilan itu tidak kembali sebelum rename-nya sampai ke disk, yang berpasangan dengan flush data yang eksplisit. Di POSIX, rename(2) sudah menjamin bahwa nama barunya menggantikan file yang ada secara atomik, dan penempatan di direktori yang sama menjaganya dari gagal dengan EXDEV. Pembersihannya simetris. Nama sementaranya dihapus di blok finally pada setiap jalur, yang saat sukses jadi no-op karena rename-nya sudah mengonsumsinya, dan saat gagal menghapus file separuh jadi supaya direktorinya tidak menumpuk serpihan .tmp. Regresi di Tests\QDFFileRegression.inc memeriksa persis itu: setelah setiap kegagalan yang disuntikkan, byte tujuan cocok dengan aslinya, byte sumbernya cocok dengan aslinya, dan direktorinya tidak berisi apa pun selain kedua fixture

Kenapa file sementara melonggarkan permission di Windows?

File yang dibuat dengan security descriptor nil mewarisi DACL-nya dari direktori induknya, bukan dari file yang akan digantikannya. Itu default yang benar untuk dokumen yang benar-benar baru dan default yang salah untuk repair in-place. Misalkan seorang operator sudah mengunci contract.pdf ke satu akun dengan DACL yang terproteksi dan tidak diwariskan. File sementara di sebelahnya mewarisi permission direktori yang lebih luas, dan begitu ia di-rename menimpa contract.pdf, file hasil rename itu membawa DACL yang luas tadi, karena keamanan NTFS berpindah bersama objek file-nya, bukan bersama namanya. Repair-nya berhasil, byte-nya benar, dan kontrol akses yang dikonfigurasi operator hilang diam-diam. Tidak ada apa pun di nilai baliknya yang memberi petunjuk soal itu

PDF Library for Delphi karena itu membaca DACL tujuan sebelum membuat file sementara dan mengirimkannya sebagai argumen lpSecurityAttributes ke CreateFileW, jadi file barunya lahir dengan permission milik file lama dan rename-nya tidak mengubah apa pun yang akan disadari operator. Pembacaannya memakai GetFileSecurityW dengan DACL_SECURITY_INFORMATION, menyizing buffer dari hasil ERROR_INSUFFICIENT_BUFFER panggilan pertama. Tiga kondisi membuat writer-nya gagal-tertutup alih-alih menebak. Kalau DACL-nya tidak bisa dibaca, publikasi berhenti dengan EWriteError, yang dipetakan API publik ke 305. Kalau descriptor-nya kembali tanpa bit SE_DACL_PRESENT terpasang, publikasi juga berhenti, karena mengirim descriptor semacam itu ke CreateFileW akan membiarkan kernel jatuh ke DACL default proses dan mengubah semantik akses tanpa ada yang memintanya. Dan kalau targetnya membawa FILE_ATTRIBUTE_ENCRYPTED, writer-nya menolak mentah-mentah: file sementaranya akan berupa plaintext, dan me-rename file plaintext menimpa file yang dilindungi EFS akan menerbitkan pengganti tak terenkripsi atas sesuatu yang dipilih pengguna untuk dienkripsi di level filesystem. EFS tidak berhubungan dengan security handler standar PDF, yang jadi bahasan artikel tentang pemuatan dokumen terenkripsi, tapi mode kegagalannya sama: penurunan kelas yang senyap

Kenapa writer publikasi QDF menyalin DACL tujuan sebelum membuat file sementaranya: descriptor nil akan mewarisi permission direktori yang lebih luas dan rename-nya akan memperluas akses secara senyap, jadi GetFileSecurityW membaca DACL-nya, bit SE_DACL_PRESENT yang hilang atau atribut EFS menghentikan publikasi dengan 305, dan CreateFileW melahirkan file dengan permission lama
Keamanan NTFS berpindah bersama objek file-nya, bukan bersama namanya: mengirim descriptor hasil bacaan sebagai lpSecurityAttributes membuat rename-nya tidak mengubah apa pun yang dikonfigurasi operator, dan setiap gate gagal-tertutup alih-alih menebak
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');
  // ukur dulu ukuran descriptor-nya, lalu baca hanya bagian DACL-nya
  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;   // diserahkan ke CreateFileW / CREATE_NEW
end;

Satu detail dari regresinya layak diingat kalau Anda menulis test serupa sendiri. Untuk membangun fixture yang dibatasi, test-nya menerapkan DACL hanya-untuk-pemilik dan harus menyetel SE_DACL_PROTECTED di control descriptor-nya secara eksplisit; sekadar mengirim flag protected di argumen SecurityInformation milik SetFileSecurityW tidak mengubah descriptor yang tidak terproteksi jadi descriptor yang terproteksi. Assertion sesudahnya adalah bahwa file yang diterbitkan tetap melaporkan bit protected-nya dan DACL eksplisit yang tidak null, baik untuk path output terpisah maupun untuk repair yang menimpa file sumbernya sendiri

LastErrorCode mana yang memberi tahu apa yang gagal?

RepairQDFFile mengembalikan 1 saat sukses dan 0 saat gagal apa pun, dan LastErrorCode menyatakan tahap mana yang menolak. Sumber yang tidak bisa dibaca, termasuk yang dipegang proses lain dengan lock eksklusif, melaporkan 401; pembacaannya sekarang dibungkus supaya exception selama input dipetakan ke 401 alih-alih bocor ke error penulisan. Struktur QDF yang invalid atau ambigu, misalnya marker stream ganda untuk objek yang sama, melaporkan PDFLIB_ERROR_QDF_REPAIR, yaitu 107, dan tujuannya belum tersentuh karena writer-nya belum pernah dibangun. Semuanya setelah repair, dari pembuatan file sementara sampai flush dan rename, melaporkan PDFLIB_ERROR_QDF_WRITE, yaitu 305. Regresinya menguji yang realistis: tujuan yang dibuka handle lain tanpa sharing delete, tujuan yang read-only, direktori tujuan yang tidak ada, dan masing-masing dari ketiga tahap writer yang gagal lewat injeksi. Di semuanya nilai baliknya 0, kodenya 305, dan tidak ada target baru atau parsial sesudahnya. Kebiasaan umum membaca kodenya dan bukan hanya nilai baliknya adalah kebiasaan yang sama yang dijelaskan di artikel tentang mendiagnosis kegagalan senyap di library

var
  Pdf: TPDFlib;
begin
  Pdf := TPDFlib.Create;
  try
    // Repair in-place: path yang sama jadi input sekaligus output
    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;

Di mana jaminannya berhenti

Writer-nya menjanjikan konsistensi terhadap kegagalan yang bisa dilihat prosesnya, dan ia jujur soal kegagalan yang tidak bisa dilihatnya. Kalau prosesnya dibunuh antara pembuatan file sementara dan rename-nya, blok finally tidak pernah berjalan dan file .pdflib-qdf-<GUID>.tmp tertinggal di direktorinya; tujuannya masih utuh, dan itulah properti yang penting, tapi serpihannya jadi tanggung jawab Anda untuk disapu. Kehilangan daya juga di luar janjinya: datanya sudah di-flush dan rename-nya write-through, yang merupakan usaha terbaik yang bisa diminta library user-mode, tapi writer-nya tidak fsync entri direktorinya dan tidak membuat klaim durabilitas di atas apa yang disediakan filesystem. Writer kedua yang memodifikasi tujuan secara bersamaan tidak terdeteksi, karena DACL dan atributnya dibaca sebelum file sementara dibuat dan tidak ada yang memeriksanya ulang saat rename. Dan rename yang berhasil menciptakan identitas file yang baru, jadi alternate data stream dan atribut biasa seperti bit archive atau hidden pada file lama tidak selamat; hanya DACL-nya yang dibawa menyeberang dengan sengaja

Batas yang lebih sempit adalah API mana yang bahkan memakai jalur ini. Hanya RepairQDFFile yang lewat TPDFQDFFileWriter. SaveQDFToFile dan ConvertFileToQDF tetap membuka outputnya dengan PLCreateFileStream(FileName, fmCreate) dan mengalirkan konversi QDF langsung ke sana, sama seperti jalur incremental yang dijelaskan di artikel tentang menambahkan update ke stream yang menulis ke stream apa pun yang Anda serahkan. Kedua panggilan itu menghasilkan artefak debugging baru dari dokumen yang sudah dimuat dan divalidasi, jadi lubang kegagalan parse tidak pernah berlaku bagi keduanya, tapi keduanya juga tidak mewarisi publikasi berbasis rename. Jangan membaca artikel ini sebagai "setiap ekspor QDF itu atomik". Ini satu pintu keluar, yang inputnya file tak tepercaya hasil suntingan tangan dan outputnya rutin berupa path yang sama, dan kombinasi itulah yang membuatnya mendapat mesin tambahan ini. Fault injection yang membuktikan semua ini murah karena ketiga tahap writer-nya, WriteData, Flush, dan Publish, bersifat virtual. Subclass test-nya menimpa salah satunya untuk melempar error setelah pekerjaan sebenarnya dimulai, memanggil Save pada stream hasil perbaikan, lalu memastikan exception-nya menjalar, byte sumber dan tujuannya tidak berubah, dan tidak ada file sementara yang tertinggal. Tidak ada API file global yang di-hook, tidak ada file pengguna sungguhan yang tersentuh, dan ketiga tahapnya memetakan satu-ke-satu ke tiga cara publikasi bisa gagal di produksi: disk-nya penuh, flush-nya ditolak, atau rename-nya ditolak karena orang lain memegang targetnya

API RepairQDFFile, writer publikasi atomiknya, dan sisa alur kerja debugging QDF adalah bagian dari PDF Library for Delphi, bersama fitur pemulihan cross-reference, incremental update, dan enkripsi yang dibahas di tempat lain di blog ini