Artikel Teknis

Delphi vs FPC: 4 Jebakan Kode PDF Tersembunyi di Build PDFium

Sumber Object Pascal yang sama dapat berperilaku berbeda di bawah Delphi dan FPC/Lazarus dalam empat cara yang berulang kali memengaruhi kode PDFium Component: FPC membuang rekaman sementara hasil fungsi sebelum pengujian keanggotaan in selesai membacanya, dcc32 dikirimkan dengan pemeriksaan jangkauan dinonaktifkan secara bawaan sehingga indeks larik di luar batas membaca sampah secara senyap, hanya Delphi 13 yang menerima penetapan array of Byte anonim ke TBytes tanpa transmisi (cast), dan penggabungan AnsiString Delphi dapat menghancurkan bita pada atau di atas $80 melalui proses bolak-balik halaman kode (code-page round-trip) tersembunyi. Masing-masing menghasilkan rangkaian pengujian yang hijau di satu kompiler dan merah, atau lebih buruk lagi, salah secara senyap di kompiler lainnya

Jika Anda menyiapkan proyek kompiler ganda untuk pertama kalinya, panduan penampil Lazarus dan FPC mencakup jalur bahagia: paket, jalur pencarian, dan menampilkan jendela rendering di layar. Artikel ini adalah kebalikan dari tutorial. Ini adalah daftar hal-hal yang kami temui setelah jalur bahagia berhasil, ketika CI berwarna hijau di bawah FPC, hijau di bawah Delphi, dan kemudian perubahan yang lolos di satu sisi meledak di sisi lainnya. Setiap jebakan di bawah ini berasal dari kegagalan nyata dalam rangkaian pengujian PDFiumPas atau demonya, dengan forensik tingkat komit yang dipadatkan menjadi reproduksi minimal, akar penyebab, dan perbaikan yang kami standarisasi

Mengapa sebuah set terbaca kosong di bawah FPC tetapi tidak di Delphi?

Versi satu kalimat: FPC dapat memfinalisasi variabel sementara yang menampung hasil rekaman fungsi sebelum ekspresi yang membaca bidang dari hasil tersebut selesai, sehingga X in Func().Issues dapat menguji keanggotaan terhadap set yang sudah dilepaskan sementara ekspresi Delphi yang setara berfungsi. Pengujian kesesuaian PDF/E kami mengalami hal ini pada versi pertama mereka. Validator mengembalikan rekaman yang bidang Issues-nya adalah kumpulan bendera pelanggaran, dan pernyataan tersebut menyertakan panggilan secara inline

// Tidak dapat diandalkan di bawah FPC: rekaman sementara hasil fungsi
// dapat dilepaskan sebelum pengujian 'in' membaca Issues
AssertTrue(pveiLzwUsed in ValidateAnsi(Pdf).Issues);

// Dapat diandalkan di kedua kompiler: sematkan hasilnya ke lokal terlebih dahulu
var
  Vr: TPdfEValidationResult;
begin
  Vr := ValidateAnsi(Pdf);
  AssertTrue(pveiLzwUsed in Vr.Issues);
end;

Bentuk inline membaca set sebagai kosong di bawah FPC, sehingga setiap pernyataan yang mengharapkan bendera gagal, sementara build Delphi yang identik lolos. Akar penyebabnya adalah perbedaan dalam bagaimana kedua kompiler mengelola masa pakai rekaman sementara hasil fungsi di dalam ekspresi yang lebih besar: Delphi menjaga variabel sementara tetap hidup hingga akhir pernyataan, sedangkan pembuangan rekaman sementara oleh FPC dapat berkejaran dengan operator keanggotaan set yang masih membacanya. Kami telah mendokumentasikan perilaku yang sama sekali sebelumnya, dalam komentar pada pembantu FlagPresent di unit pengujian PDF/A, dan kemudian memperkenalkan kembali bug tersebut saat menulis pengujian baru dari awal, yang memberi tahu Anda betapa alaminya bentuk yang rusak itu terlihat. Perbaikannya bersifat mekanis dan layak diadopsi sebagai aturan umum: jangan pernah merantai akses bidang atau pengujian set secara langsung ke panggilan fungsi yang mengembalikan rekaman; tetapkan hasilnya ke variabel lokal terlebih dahulu, lalu baca bidang tersebut. Ini memakan satu baris dan menghapus seluruh kelas ketidakstabilan yang bergantung pada kompiler

Mengapa Delphi menerima indeks larik yang ditolak untuk dikompilasi oleh FPC?

Versi satu kalimat: dcc32 mengompilasi indeks di luar rentang ke dalam larik dengan batas tetap dan, dengan pemeriksaan jangkauan bawaan dinonaktifkan, membaca atau menulis memori yang berdekatan saat runtime tanpa kesalahan apa pun, sementara FPC menolak indeks yang sama pada waktu kompilasi. PDFium Component mendeklarasikan titik quad sebagai larik berbasis 1, TQuadrilateralPoint = array [1..4] of TPdfPoint, mencocokkan bagaimana entri QuadPoints PDF biasanya diberi nomor. Demo yang mengisinya dengan perulangan berbasis 0 refleksif berfungsi selama berbulan-bulan di bawah Delphi

var
  I: Integer;
begin
  for I := 0 to 3 do                       // salah: larik adalah [1..4]
    Data.AttachmentPoints[I] := Corner[I]; // bawaan dcc32: dapat dikompilasi, indeks 0
                                           // secara senyap menyentuh memori yang berdekatan
                                           // FPC: kesalahan pemeriksaan jangkauan waktu kompilasi
  for I := Low(TQuadrilateralPoint) to High(TQuadrilateralPoint) do
    Data.AttachmentPoints[I] := Corner[I - 1];  // benar di kedua kompiler
end;

Build Delphi adalah positif palsu: dengan pemeriksaan jangkauan dinonaktifkan, yang merupakan bawaan dcc32, indeks 0 mendarat di bidang apa pun yang mendahului larik dalam rekaman, dan demo tampak berjalan. Mem-porting demo yang sama ke Lazarus menghasilkan kesalahan pemeriksaan jangkauan waktu kompilasi langsung dari FPC, dan memperbaiki indeks kemudian mengekspos bug kedua yang lebih dalam di jalur anotasi pustaka yang selama ini ditutupi oleh pembacaan sampah, yang dibahas dalam artikel anotasi quad-points. Dua pelajaran keluar dari insiden itu. Pertama, lebih suka Low() dan High() daripada batas literal kapan pun jenis larik tidak berbasis 0 secara konstruksi. Kedua, perlakukan kompilasi FPC, atau setidaknya satu build Delphi dengan {$R+} diaktifkan, sebagai gerbang pertama yang wajib dijalankan untuk demo atau pengujian baru apa pun: bawaan dcc32 tidak akan memberi tahu Anda tentang kelas bug ini, dan program yang berjalan bukanlah bukti bahwa itu benar

Penetapan TBytes yang hanya diterima oleh Delphi 13

Versi satu kalimat: menetapkan bidang yang dideklarasikan sebagai array of Byte anonim ke variabel TBytes dapat dikompilasi pada Delphi 13 (versi kompiler 37.0) tetapi gagal pada Delphi 12 Athens dan setiap rilis sebelumnya dengan E2010 Incompatible types: 'TArray<Byte>' and 'Dynamic array'. Yang satu ini bukan pemisahan Delphi-versus-FPC melainkan pemisahan Delphi-versus-masa-lalunya-sendiri, tetapi memengaruhi basis kode multi-kompiler yang sama dengan cara yang sama: kompiler terbaru secara diam-diam menerima konstruksi yang ditolak oleh kompiler lain

type
  TValidator = class
  private
    FBuffer: array of Byte;   // jenis larik dinamis anonim
  end;

var
  OrigBytes: TBytes;
begin
  OrigBytes := FBuffer;          // Hanya Delphi 13; E2010 pada Delphi 12
                                 // Athens dan sebelumnya
  OrigBytes := TBytes(FBuffer);  // dapat dikompilasi di mana saja; tata letak bita yang sama,
                                 // cast keras yang aman
end;

Kami mengirimkan persis ini dalam rutinitas validasi, dikembangkan dan diuji secara lokal di Delphi 13, di mana konversi implisit diterima secara diam-diam. Penginstal sumber lengkap melayani pengguna di Delphi 12 dan yang lebih tua dalam jumlah besar, dan bagi mereka unit tersebut tidak dapat dikompilasi. Perbaikan struktural adalah transmisi keras (hard cast) yang ditunjukkan di atas, yang aman karena array of Byte anonim dan TBytes berbagi tata letak larik dinamis yang identik, atau lebih baik, mendeklarasikan bidang tersebut sebagai tipe bernama seperti TBytes sejak awal sehingga tidak ada konversi yang timbul. Perbaikan proses lebih penting: konstruksi yang dapat dikompilasi pada toolchain terbaru Anda tidak membuktikan apa pun tentang kompiler lama yang sebenarnya dijalankan pengguna Anda, dan kategori regresi ini tidak terlihat sampai Anda membangun terhadap setiap versi yang didukung. Skrip rilis kami sekarang mengompilasi pustaka di seluruh matriks kompiler lengkap tepatnya karena build 37.0 lokal tidak dapat menangkap kelonggaran khusus versi 13

Bita AnsiString yang lenyap di mesin Windows berbahasa Mandarin

Versi satu kalimat: menggabungkan bita mentah pada atau di atas $80 ke dalam AnsiString dengan + dapat secara senyap menggantikan bita tersebut dengan ? ($3F) di bawah Delphi, karena ekspresi tersebut mengambil jalur implisit AnsiString ke UnicodeString ke AnsiString melalui halaman kode sistem. Kami menemukan ini melalui pengujian PDF/A yang mengonstruksi nama yang mengandung bita $FE terisolasi, yang tidak pernah menjadi bita utama UTF-8 yang sah, untuk memverifikasi validator menandai nama yang bukan UTF-8 yang valid menurut klausul ISO 19005-2 6.1.8

var
  BadName: AnsiString;
begin
  // Pada Delphi dengan halaman kode sistem multi-bita (teramati pada CP936),
  // penggabungan bolak-balik melalui UnicodeString dan $FE, yang
  // bukan urutan CP936 yang valid, kembali sebagai '?' ($3F)
  BadName := '/Bad' + AnsiChar($FE) + 'Name';

  // Aman: bangun dengan placeholder ASCII, lalu tempel bita di tempatnya;
  // penetapan indeks ke dalam AnsiString yang sudah ditetapkan tidak melakukan bolak-balik
  BadName := '/Bad' + #1 + 'Name';
  BadName[5] := AnsiChar($FE);
end;

Pada sistem Windows berbahasa Mandarin yang menjalankan halaman kode 936, string yang digabungkan tidak pernah berisi $FE sama sekali, sehingga pustaka dengan benar melaporkan tidak ada apa-apa dan pengujian menjadi merah sementara terlihat seperti bug pustaka. Pustaka tidak pernah salah: rangkaian FPC yang dimasukkan PDF yang benar-benar berisi bita $FE mendapatkan bendera yang diharapkan. Kerusakan terjadi di dalam executable pengujian Delphi saat ekspresi string sedang dievaluasi, karena model string Unicode-first Delphi mengonversi ekspresi AnsiString campuran melalui UnicodeString, dan $FE bukan bita utama yang valid di CP936 sehingga proses bolak-balik menggantikannya. Jujurlah tentang batasan di sini: pada halaman kode Barat bita tunggal seperti CP1252, ekspresi yang sama biasanya bertahan, itulah sebabnya bug ini tersembunyi di sebagian besar mesin pengembangan dan muncul hanya pada sistem Asia Timur atau pelari CI yang dilokalkan. Aturan yang kami adopsi: jangan pernah membangun vektor uji biner yang berisi bita pada atau di atas $80 dengan penggabungan AnsiString; baik tempel bita di tempatnya setelah string diselesaikan, seperti di atas, atau konstruksi vektor dalam TBytes sejak awal

Apa yang harus diperiksa secara bawaan oleh alur kerja kompiler ganda

Empat jebakan, satu pola: setiap kompiler memberi tahu Anda tentang subset bug Anda yang berbeda. Analisis jangkauan waktu kompilasi FPC menangkap indeks di luar batas yang dijalankan dcc32 secara senyap selama berbulan-bulan, dan model string Unicode dcc32 mengekspos ketergantungan halaman kode yang tidak pernah dipicu oleh build FPC berorientasi bita murni. Konsekuensi praktisnya adalah tidak ada pipa hijau yang cukup jika berdiri sendiri. Kompilasi silang bukan hanya kotak centang portabilitas, ini adalah penganalisis statis kedua dan model runtime kedua yang diterapkan pada sumber yang sama, dalam semangat yang sama dengan pemeriksaan batas defensif dalam artikel pengerasan ABI dan keamanan memori

Aturan tetap yang keluar dari insiden ini cukup pendek untuk dihafal. Sematkan rekaman hasil fungsi ke variabel lokal sebelum membaca bidang. Ulangi larik batas tetap dengan Low() dan High(), dan jalankan setidaknya satu build dengan pemeriksaan jangkauan atau FPC sebelum memercayai demo baru apa pun. Transmisikan bidang larik dinamis anonim secara eksplisit, atau deklarasikan dengan tipe bernama, dan bangun matriks kompiler lengkap sebelum rilis. Jauhkan bita tinggi mentah dari penggabungan AnsiString sepenuhnya. Tidak ada satu pun dari ini yang memakan upaya terukur setelah menjadi kebiasaan, dan masing-masing menutup mode kegagalan yang secara struktural tidak dapat dilihat oleh alur kerja kompiler tunggal

Keempat masalah ditemukan dan diperbaiki selama pemeliharaan PDFium Component, yang mengirimkan sumber Object Pascal yang sama untuk Delphi, C++Builder, and FPC/Lazarus dan menjalankan rangkaian kesesuaian dan regresinya pada setiap toolchain tersebut, sehingga jebakan dalam artikel ini dijaga oleh pengujian alih-alih oleh memori