Artikel Teknis

Kode Delphi yang Berjalan Kebetulan: Lima Bug Porting FPC

PDF Library for Delphi menemukan lima cacat decoder saat membawa kode CCITT, TIFF, PNG, Flate, dan stream buffer-nya ke Free Pascal, dan kelimanya sudah bertahun-tahun lolos seluruh test suite Delphi. Tak satu pun bug compiler. Semuanya Pascal yang kebetulan dieksekusi Delphi dengan benar gara-gara satu detail implementasi: hidden result parameter yang jadi alias array milik pemanggil, cabang di luar jangkauan yang tak pernah dibaca siapa pun, buffer panjang nol yang satu-satunya penjaga adalah switch range check, offset basis-1 yang cuma pernah dilewati satu jalur kode dengan nilai 1, dan kontrak TStream.Read yang tidak pernah disentuh stream in-memory. Ganti compiler-nya, atau beri file rusak kepada kode yang sama, dan kebetulan itu berhenti berlaku

Berikut ini bentuk spesifik masing-masing kasus, perbaikannya, dan disiplin yang lahir darinya: source yang sama sekarang harus menghasilkan semantik dokumen yang sama di kedua compiler, dan sebuah include test memastikan hal itu. Artikel saudaranya soal mengeraskan parser PDF Pascal terhadap file berbahaya membahas lebar integer, kedalaman rekursi, dan buffer yang tidak diinisialisasi. Artikel ini soal kelas kegagalan yang lain: kode yang sejak awal salah dan punya compiler yang diam-diam menutupinya

Mengapa fungsi yang mengembalikan dynamic array berjalan tanpa SetLength di Delphi?

Karena Delphi mengirim variabel milik pemanggil sebagai hidden result parameter, jadi fungsi yang tidak pernah mengalokasikan hasilnya tetap bisa menulis ke array yang dialokasikan pemanggil. TPLCCITTDecoder.GetNextChangingElement(a0: Integer; IsWhite: Boolean): TCCITTIntegerArray adalah lookup reference-line di jantung decoding Group 3 dan Group 4 dua dimensi: diberi posisi saat ini a0 dan warna run saat ini, ia mencari changing element pada scanline sebelumnya, yaitu b1 dan b2 dari skema pengkodean dua dimensi ITU-T T.4 dan T.6, lalu mengembalikannya sebagai array dua slot. Fungsi aslinya menulis Result[0] dan Result[1] dan sama sekali tidak pernah memanggil SetLength pada Result

Seharusnya itu fault pada penulisan pertama, dan di Free Pascal memang begitu. Di Delphi tidak pernah, karena kedua call site di decoder terlihat begini: deklarasikan b: TCCITTIntegerArray, jalankan SetLength(b, 2) sekali sebelum loop scanline, lalu di dalam loop isi b := GetNextChangingElement(a0, IsWhite) dan baca b[0] serta b[1]. Panduan bahasa Delphi menyatakan bahwa fungsi yang hasilnya long string, dynamic array, atau tipe managed lain menerima hasil itu sebagai parameter var tambahan, dan dalam praktiknya compiler mengirim alamat target assignment. Jadi Result di dalam fungsi adalah b itu sendiri, sudah berisi dua elemen, dan setiap penulisan mendarat di memori milik pemanggil. Free Pascal menyerahkan array nil yang baru ke fungsi itu lalu menugaskannya ke b setelahnya, dan itulah cara membaca kontrak yang seharusnya jadi dasar kode ini sejak awal

Divergensi decoding CCITT di PDFlibPas: Delphi mengirim array pemanggil b sebagai hidden var Result dari GetNextChangingElement sehingga penulisan mendarat di memori milik pemanggil dan lookup yang meleset menyisakan nilai sebelumnya, sementara Free Pascal menyerahkan array nil baru yang harus disizing oleh guard Length dengan SetLength sebelum penulisan pertama
Delphi mengalias array pemanggil sebagai hidden Result parameter sehingga penulisan tanpa guard tetap mendarat di memori yang dimiliki, sementara Free Pascal datang dengan nil dan guard satu baris mengubah fault jadi perilaku yang dimaksud tanpa menyentuh jalur decode Delphi

Aliasing itu juga membawa semantik yang diandalkan decoder. Result[0] hanya diisi saat scan menemukan elemen yang lebih besar dari a0, dan Result[1] hanya saat ada elemen setelahnya, jadi ketika meleset, slot-slot itu tetap berisi apa pun yang ditinggalkan iterasi sebelumnya di b. Perbaikan yang kelihatan paling jelas, alokasikan dua slot lalu nolkan di setiap panggilan, akan menghancurkan carry-over itu dan mengubah output hasil decode di Delphi. Perbaikan yang akhirnya dikirim adalah guard, bukan reset: di Delphi ia jadi dead code dan jalur decode tetap byte per byte seperti semula, sedangkan di Free Pascal ia mengubah fault jadi perilaku yang dimaksud. Asimetri itulah intinya, karena perbaikannya harus no-op di compiler tempat kode sudah menghasilkan output yang terverifikasi

Function TPLCCITTDecoder.GetNextChangingElement(a0: Integer;
  IsWhite: Boolean): TCCITTIntegerArray;
Begin
  // Delphi sampai di sini dengan array dua elemen milik pemanggil sebagai
  // alias Result, jadi baris ini no-op di sana. FPC datang dengan nil.
  If (Length(Result) < 2) Then
    SetLength(Result, 2);
  ...
  // Result[0] / Result[1] tetap hanya ditulis saat hit, jadi yang meleset
  // mempertahankan nilai iterasi sebelumnya persis seperti sebelumnya
End;

Count yang hidup lebih lama dari datanya: entry direktori TIFF

Ketika Anda membatalkan sebuah array, Anda harus membatalkan count-nya di pernyataan yang sama, kalau tidak count itu akan dipercaya oleh kode yang tidak pernah melihat array-nya. Sebuah entry image file directory TIFF (TIFF 6.0 §2, tata letak 12 byte berisi tag, type, count, dan value-atau-offset) membawa count 32-bit langsung dari file, dan PDF Library for Delphi membaca masing-masingnya lewat PopDE: TTIFFEntry, sebuah record berisi Tag, TagType, Length, Offset, plus array IntegerValues dan DoubleValues hasil decode. Kode aslinya memeriksa apakah Offset + TypeSize * Length melewati akhir file, dan kalau ya, ia mengosongkan kedua array itu. Ia membiarkan Result.Length tetap bernilai seperti dari file

Dua hal jadi salah setelah itu. Fungsi berakhir dengan fallback yang bunyinya "kalau Length nol, beri entry itu satu elemen bernilai nol" supaya pemanggil selalu bisa membaca elemen nol. Karena Length tidak pernah dibersihkan di jalur di-luar-jangkauan, fallback itu tidak pernah menyala untuk satu-satunya kasus yang jadi alasan keberadaannya. Dan para pemanggil memang membaca elemen nol, tanpa syarat: Width, Height, BitsPerSample, PhotometricInterpretation, FillOrder, SamplesPerPixel, RowsPerStrip, dan selusin lainnya mengambil E.IntegerValues[0], sementara tabel strip menjalankan Move(E.IntegerValues[0], StripOffsets[0], E.Length * 4), menyalin Length kali empat byte dari array yang tidak punya satu elemen pun. Array yang dikosongkan sementara count-nya masih hidup jauh lebih berbahaya daripada array yang tidak diperiksa, karena yang tidak diperiksa setidaknya menyimpan byte seperti yang diklaimnya

Masalah kedua adalah urutan. Dua panggilan SetLength berjalan sebelum uji jangkauan, disizing dari count milik file, jadi entry yang berniat jahat bisa meminta alokasi multi-gigabyte sebelum satu pun pemeriksaan validitas. Di Delphi exception yang muncul ditangkap handler yang lebih atas di jalur pemuatan gambar dan file itu sekadar gagal dimuat, itulah kenapa tak ada yang menyadarinya; yang sebenarnya terjadi adalah peristiwa out-of-memory yang dipilih oleh file itu sendiri. Perbaikannya memindahkan alokasi ke setelah uji dan membuat count ikut berjalan bersama datanya

Pengerasan entry direktori TIFF di PDFlibPas: entry 12 byte membawa count pasokan file, urutan yang rusak mengalokasikan array dari count itu sebelum uji jangkauan dan meninggalkan Result.Length hidup setelah array dikosongkan, sedangkan urutan yang diperbaiki menguji aritmetika Int64 terhadap panjang file lebih dulu sehingga count dibersihkan bersama array-nya
Mengalokasikan sebelum uji jangkauan membiarkan count jahat meminta alokasi gigabyte dan meninggalkan count hidup pada array yang sudah dikosongkan, jadi perbaikannya menguji offset lebih dulu dan membersihkan Result.Length di pernyataan yang sama dengan array-nya
OutOfRange := Int64(ValueOffset) + Int64(TypeSize) * Result.Length
              > Length(Source);
If OutOfRange Then
Begin
  Result.Length := 0;              // count ikut pergi bersama nilainya
  SetLength(Result.IntegerValues, 0);
  SetLength(Result.DoubleValues, 0);
End
Else
Begin
  SetLength(Result.IntegerValues, Result.Length);  // baru sekarang
  SetLength(Result.DoubleValues, Result.Length);
End;
// ... nanti, fallback yang sudah ada akhirnya mencapai kasus yang jadi tujuannya:
If (Result.Length = 0) Then
Begin
  SetLength(Result.IntegerValues, 1);
  Result.IntegerValues[0] := 0;
End;

Tidak ada satu pun bagian dari perbaikan ini yang spesifik compiler, dan itulah yang membuatnya layak masuk daftar ini. Cacatnya laten di Delphi karena alasan yang sama dengan latennya di Free Pascal: tidak ada file uji yang punya entry direktori menunjuk melewati akhir file. Porting-nya tidak membongkarnya. Membaca kode dengan pertanyaan "apa yang Delphi kerjakan untuk saya di sini yang tidak saya kerjakan sendiri" yang membongkarnya

Apa yang terjadi kalau IHDR PNG mengklaim color type yang tidak didefinisikan formatnya?

PDF Library for Delphi sekarang menolak gambar itu sebelum row filter berjalan; sebelum v3.539.2 ia menghitung scanline berukuran nol byte dan menyerahkan buffer kosong ke loop unfilter. ISO 15948 §11.2.2 mendefinisikan chunk IHDR dan Tabel 11.1 mencantumkan enam kombinasi legal color type dan bit depth: grayscale pada 1, 2, 4, 8, atau 16 bit, indexed color pada 1, 2, 4, atau 8, serta truecolor, grayscale with alpha, dan truecolor with alpha pada 8 atau 16. TPNGReader memvalidasi field compression method dan filter method pada IHDR lalu meloloskan FColorType beserta bit depth apa adanya

Kode row filter menentukan semua ukuran dari Case FColorType Of yang memetakan tiap color type ke jumlah komponen. Color type di luar keenamnya jatuh ke cabang Else, di mana SourceComponents bernilai 0, jadi ScanlineByteCount 0, jadi SetLength(PreviousScanline, 0) langsung diikuti FillChar(PreviousScanline[0], ScanlineByteCount, 0). Mengindeks elemen nol dari dynamic array kosong sama dengan alamat yang dihitung dari nil. Dengan range checking mati, fill nol byte lewat alamat itu jadi no-op senyap dan decoder terus berjalan melewati baris-baris yang tidak ada; dengan range checking hidup, ia jadi ERangeError pada gambar pertama; dan panggilan Move yang menyusul hanya berjarak satu langkah dari access violation. Yang mana yang Anda dapat bergantung pada compiler dan switch build, bukan pada apa pun yang diputuskan decoder, dan itulah tandanya decoder memang tidak pernah memutuskan apa pun

Perbaikannya adalah tabel dari spesifikasi itu, dipasang di tempat field IHDR lain sudah diperiksa: COLOR_GRAYSCALE menerima FSourceBitDepth in [1, 2, 4, 8, 16], COLOR_PALETTE menerima [1, 2, 4, 8], dan COLOR_RGB, COLOR_GRAYSCALEALPHA, serta COLOR_RGBALPHA menerima [8, 16]; selain itu ValidImage dibersihkan dan gambar ditolak dengan lebar serta tinggi tetap utuh untuk diagnostik. Chunk pHYs yang lebih pendek dari sembilan byte-nya ditutup di pass yang sama, karena pembaca DPI mengindeks S[1] sampai S[8] dari string yang ditinggalkan kosong oleh chunk pendek itu

Offset basis-1 yang diperlakukan sebagai pointer basis-0

InflateStrFromPosition(Const Input: AnsiString; StartPos, MaxOutput: Integer; Out Consumed: Integer): AnsiString menerima StartPos basis-1, karena inputnya AnsiString dan implementasi Delphi mengalamati input zlib sebagai @Input[StartPos]. Implementasi Free Pascal, yang ditulis di atas paszlib supaya kedua target Windows menautkan kompresi secara statis, menyetel next_in ke PAnsiChar(Input) + StartPos dan avail_in ke Length(Input) - StartPos. Itu aritmetika pointer, dan itu basis-0. Kirim 1, yang bagi fungsi ini berarti "mulai dari awal", dan build FPC mulai mengembang dari byte kedua lalu berhenti satu byte sebelum akhir

Alasan ia bertahan adalah karena satu-satunya pemanggil yang dijangkau hampir semua tes adalah InflateStr, yang mengirim 0. Nol kebetulan adalah offset basis-0 yang benar, jadi kedua build sepakat pada setiap panggilan InflateStr biasa dan setiap tes yang melewatinya. TPDFDocument.DecodeAllStreams, rutin yang dipakai SaveQDFToFile dan ConvertFileToQDF untuk mengembangkan stream FlateDecode tunggal jadi bentuk yang bisa dibaca, mengirim 1. Di build FPC header zlib yang terlewat membuat inflate-nya gagal, tapi stream zlib tetap melaporkan Consumed bukan nol untuk byte yang sempat diperiksanya, jadi DecodeAllStreams menerima payload kosong sebagai decode yang berhasil dan mengganti setiap content stream dengan string kosong. QDF yang dihasilkan punya jumlah halaman yang benar, struktur yang valid, dan tanpa konten halaman, yaitu file yang terbuka tanpa error di semua viewer dan tidak menampilkan apa pun

// Cabang FPC dari InflateStrFromPosition, setelah v3.539.16.
// StartPos basis-1 seperti cabang Delphi; clamp dulu, lalu konversi
// ke offset pointer basis-0 tepat sekali, di batasnya.
If (StartPos < 1) Then
  StartPos := 1;
If (Length(Input) = 0) Or (StartPos > Length(Input)) Then
  Exit;
...
strm.next_in  := Pointer(PAnsiChar(Input) + StartPos - 1);
strm.avail_in := Length(Input) - StartPos + 1;

Regresi yang menjaganya adalah yang paling kecil yang mungkin: deflate sebuah payload, inflate dari posisi 0 dan dari posisi 1, lalu pastikan keduanya mengembalikan payload yang sama dan keduanya melaporkan Consumed sebesar panjang stream penuh. Stream RFC 1950 punya header dua byte dan trailer Adler-32 empat byte, jadi off-by-one di salah satu ujung bukan korupsi halus, melainkan stream yang entah gagal mulai atau gagal selesai. Pelajarannya soal batas itu, bukan soal zlib: ketika parameter sebuah fungsi didefinisikan dalam satu basis indeks sementara implementasi di bawahnya memakai basis yang lain, konversinya harus berada tepat di satu baris, dan tes harus memanggilnya dengan nilai yang membedakan kedua basis itu

Kenapa TStream.Read yang pendek bukan tanda akhir stream?

Karena TStream.Read boleh mengembalikan byte lebih sedikit dari yang diminta dengan alasan apa pun yang disukainya, dan hanya nilai balik 0 yang berarti tidak ada lagi. TMemoryStream dan TFileStream di disk lokal hampir selalu mengisi penuh permintaan, itulah kenapa kode yang memperlakukan "hasil balik lebih sedikit dari yang saya minta" sebagai end-of-file lolos semua tes yang memakainya. Stream berbasis jaringan, stream dekompresi, dan TStream turunan apa pun buatan pelanggan bisa mengembalikan dua byte saat diminta enam puluh empat ribu dan masih menyimpan gigabyte di belakangnya

TPLBuffer adalah pembaca yang dilalui setiap parser di PDF Library for Delphi, dan ia bisa membungkus AnsiString, pointer, byte array, atau TStream. Empat query scanning-nya, DistanceToByte, DistanceToOtherByte, DistanceToAnyByte, dan DistanceToOtherBytes, semuanya mengembalikan Int64, membaca sumber dalam blok 64 KB untuk mencari delimiter dan melaporkan seberapa jauh jaraknya tanpa menggeser posisi logis. Setiap loop berakhir dengan Until ReadCount < BlockSize. Untuk tiga sumber in-memory itu benar, karena ReadIntoBuffer selalu menyerahkan blok penuh sampai blok terakhir. Untuk sumber stream, artinya scan menyerah pada short read pertama, melaporkan delimiter sebagai tidak ada, dan tokenizer di atasnya memutuskan objek berakhir di tempat yang bukan akhirnya

Penanganan short read di stream buffer PDFlibPas: DistanceToByte memindai blok 64 KB, loop lama memperlakukan Until ReadCount < BlockSize sebagai akhir data dan menyerah pada short read pertama, sedangkan loop yang diperbaiki berjalan sampai ReadCount sama dengan nol, menemukan delimiter, dan memulihkan posisi di blok finally
Stream boleh mengembalikan dua byte saat diminta enam puluh empat ribu, jadi nol adalah satu-satunya sinyal akhir data yang boleh dipercaya scan, dan klausa finally memulihkan posisi logis ketika delimiter ditemukan dan loop berhenti lebih awal
// TPLBuffer.DistanceToByte, loop setelah v3.539.6.
// Nol adalah satu-satunya sinyal akhir data yang didefinisikan TStream.Read.
TempPosition := FPosition;
Try
  Repeat
    ReadCount := ReadIntoBuffer(@TempBuffer[0], BlockSize);
    For TestPos := 0 To ReadCount - 1 Do
      If TempBuffer[TestPos] = Value Then
      Begin
        Result := TotalSkipped + TestPos;
        Exit;
      End;
    Inc(TotalSkipped, ReadCount);
  Until ReadCount = 0;
Finally
  FPosition := TempPosition;   // peek tidak boleh menggeser pembaca
End;

Tes yang memakukannya adalah turunan TMemoryStream yang override Read-nya membatasi setiap permintaan maksimal dua byte. Bungkus string aaaaaX di dalamnya, set posisi buffer ke 1, dan keempat query harus melaporkan jarak 4 ke X, meninggalkan posisi tetap di 1 sesudahnya, dan melaporkan -1 untuk byte yang tidak ada. Sebelum perbaikan, query pertama hanya melihat dua byte, menyimpulkan stream sudah habis, lalu mengembalikan -1. finally sama pentingnya dengan kondisi loop: Exit dari dalam scan adalah jalur sukses yang normal, dan posisi logis harus dipulihkan juga di jalur itu, bukan hanya saat loop berjalan sampai tuntas

Satu source, dua compiler, satu set assertion

Disiplin yang lahir dari kelima kasus ini adalah bahwa "build Delphi lolos" adalah bukti tentang Delphi, bukan tentang source-nya. Sejak v3.539.16, suite DUnitX Delphi dan suite konsol Free Pascal sama-sama menyertakan Tests\CrossCompilerSemantics.inc yang sama, satu rutin bernama RunCrossCompilerFileSemantics, yang membangun dokumen dua halaman dengan konten terkompresi lewat TPDFlib, menyimpannya, menyimpannya lagi sebagai QDF lewat SaveQDFToFile, memperbaiki QDF itu dengan RepairQDFFile, mengenkripsi file biasa dengan AES-128 lewat EncryptFile dan mask permission dari EncodePermissions, lalu memuat ulang setiap artefak dan meng-assert hal yang sama di kedua compiler: jumlah halaman 2, title tetap bertahan, teks halaman dua terekstrak utuh dari file biasa, hasil perbaikan, dan file terenkripsi, password yang salah ditolak dengan LastErrorCode bukan nol, EncryptionStrength bernilai 128, EncryptionAlgorithm bernilai 2, dan bit permission individual dari GetUserPermissions kembali persis seperti yang dienkode

Perbandingannya sengaja dinormalisasi, bukan byte per byte. Enkripsi menarik salt acak dan writer menetapkan document identifier, jadi kedua build memang tidak diharapkan mengeluarkan file yang identik; yang diharapkan adalah file yang bermakna sama, dan assertion-nya dirumuskan pada level itu. Kaki QDF ada di sana justru karena bug offset: QDF dengan dua halaman tanpa konten lolos pemeriksaan jumlah halaman dan gagal di pemeriksaan ekstraksi teks, dan matriks itu meng-assert pemeriksaan kedua. Perbaikan apa pun di masa depan yang no-op di satu compiler dan mengubah perilaku di compiler lain, yang menggambarkan empat dari lima kasus di atas, sekarang harus melewati assertion yang sama dua kali sebelum dikirim

Separuh lain dari porting yang sama, soal link-time, yaitu membuat object OMF milik Delphi dan ekspektasi COFF milik Free Pascal saling cocok, punya ceritanya sendiri di penautan object FPC Win32 dari OMF ke COFF, dan pengerasan struktural pembaca TIFF yang sama terhadap BigTIFF serta file bertile ada di catatan decoder TIFF bawaan. Decoder di artikel ini, dan tes lintas compiler yang kini duduk di bawahnya, dikirim dalam PDF Library for Delphi untuk Delphi, C++Builder, dan Free Pascal, di mana source yang sama diharapkan memperoleh hasil yang sama di setiap compiler yang disasar, bukan dikaruniai hasil itu oleh salah satunya