Fuzzing

Target fuzz bawaan dan pengujian bergaya properti.

Daftar isi

Fuzzing adalah pengujian yang masukannya dihasilkan secara acak oleh mesin, bukan ditulis satu per satu oleh manusia. Sejak Go 1.18, kerangka ini menjadi bagian dari paket testing, sehingga fuzzer, yaitu program yang menghasilkan masukan acak dan mengawasi perilaku kode terhadapnya, dapat dijalankan dengan perintah go test yang sama. Topik ini membahas bentuk target fuzz, peran korpus benih sebagai titik awal, perbedaan penjalanan biasa dengan opsi -fuzz, dan cara pandang pengujian berbasis properti yang melandasinya. Contoh yang dipakai adalah fungsi pembalik teks yang harus tetap benar untuk huruf yang tersusun atas lebih dari satu byte.

Target fuzz dan korpus benih#

Target fuzz adalah fungsi bernama FuzzXxx pada berkas _test.go yang menerima satu parameter bertipe *testing.F dan memanggil f.Fuzz dengan fungsi tanpa nama yang menjalankan properti yang diuji. Sebelum itu, f.Add mendaftarkan korpus benih, yaitu masukan awal pilihan penulis yang dijalankan setiap kali go test dieksekusi. Contoh berikut memakai fungsi pembalik teks yang memecah string menjadi rune terlebih dahulu:

go balik.go
package main

import "fmt"

// Balik membalikkan urutan rune pada teks. Memecah teks menjadi rune
// terlebih dahulu membuat huruf yang tersusun atas lebih dari satu
// byte tetap utuh.
func Balik(s string) string {
	r := []rune(s)
	for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 {
		r[i], r[j] = r[j], r[i]
	}
	return string(r)
}

func main() {
	fmt.Println(Balik("kata"))
	fmt.Println(Balik("naïve"))
	fmt.Println(Balik(Balik("naïve")))
}
text Keluaran
atak
evïan
naïve

Fungsi main memperagakan dua kasus yang menjadi inti topik ini, yaitu teks ASCII yang terbalik menjadi atak, dan kata berhuruf multibyte naïve yang terbalik menjadi evïan dengan hurufnya tetap utuh. Pemanggilan terakhir membalik dua kali dan mengembalikan teks asli, yang merupakan properti yang dipakai oleh target fuzz di bawah ini.

Targetnya menguji dua properti sekaligus, yaitu pembalikan dua kali mengembalikan teks asli dan hasil pembalikan selalu merupakan UTF-8 yang sah. Baris t.Skip menjaga properti itu tetap jujur, karena masukan acak sering kali berupa byte yang bukan UTF-8 sah, dan properti kesamaan memang hanya dinyatakan untuk masukan yang sah:

go balik_test.go
// tidak-dijalankan: dijalankan melalui go test

package main

import (
	"testing"
	"unicode/utf8"
)

func TestBalik(t *testing.T) {
	uji := []struct {
		masuk string
		ingin string
	}{
		{"", ""},
		{"kata", "atak"},
		{"naïve", "evïan"},
	}

	for _, u := range uji {
		if hasil := Balik(u.masuk); hasil != u.ingin {
			t.Errorf("Balik(%q) = %q, seharusnya %q", u.masuk, hasil, u.ingin)
		}
	}
}

func FuzzBalik(f *testing.F) {
	f.Add("halo")
	f.Add("a")
	f.Add("naïve")

	f.Fuzz(func(t *testing.T, s string) {
		if !utf8.ValidString(s) {
			t.Skip()
		}
		if hasil := Balik(Balik(s)); hasil != s {
			t.Errorf("Balik(Balik(%q)) = %q, seharusnya %q", s, hasil, s)
		}
		if !utf8.ValidString(Balik(s)) {
			t.Errorf("Balik(%q) menghasilkan teks yang bukan UTF-8 sah", s)
		}
	})
}

Baris komentar pertama pada berkas itu menandai pengujian yang dijalankan melalui go test dan tidak dijalankan sendirian. Tanda tangan fungsi yang diteruskan ke f.Fuzz menentukan tipe masukan yang dihasilkan fuzzer, yaitu satu *testing.T diikuti parameter masukan. Tipe masukannya mencakup string, slice byte, tipe bilangan bulat, bilangan pecahan, serta bool. Setiap pemanggilan f.Add wajib memuat nilai dengan jumlah dan tipe yang sama dengan parameter itu, dan ketidakcocokan semacam ini terdeteksi saat kompilasi, bukan saat pengujian berjalan.

Benih yang tercantum di atas sengaja memuat naïve, yaitu kata dengan huruf yang tersusun atas dua byte, karena kasus tepi semacam itulah yang paling sering menggagalkan implementasi pembalikan.

Menjalankan korpus benih#

Tanpa opsi -fuzz, go test menjalankan fungsi pengujian biasa beserta seluruh korpus benih secara deterministik, sehingga keluarannya dapat dikutip apa adanya setelah angka waktunya dirapikan:

bash
# salin berkas contoh ke folder uji
rm -rf /tmp/gotest-fuzz && mkdir -p /tmp/gotest-fuzz && cp *.go /tmp/gotest-fuzz/ && cd /tmp/gotest-fuzz
# buat modul sementara tanpa mencatat pesannya
go mod init contoh >/dev/null 2>&1
# tanpa opsi -fuzz, go test hanya menjalankan korpus benih
go test -v 2>&1 | sed -E 's/^(ok|FAIL)[[:space:]]+contoh[[:space:]].*/\1 contoh/; s/\([0-9]+\.[0-9]+s\)/(WAKTU)/'
text Keluaran
=== RUN   TestBalik
--- PASS: TestBalik (WAKTU)
=== RUN   FuzzBalik
=== RUN   FuzzBalik/seed#0
=== RUN   FuzzBalik/seed#1
=== RUN   FuzzBalik/seed#2
--- PASS: FuzzBalik (WAKTU)
    --- PASS: FuzzBalik/seed#0 (WAKTU)
    --- PASS: FuzzBalik/seed#1 (WAKTU)
    --- PASS: FuzzBalik/seed#2 (WAKTU)
PASS
ok contoh

Setiap benih tampil sebagai subtest bernama seed#0 dan seterusnya pada urutan pendaftarannya, sehingga kegagalan satu benih dapat dilacak langsung. Bentuk penjalanan inilah yang dipakai pada integrasi berkelanjutan, karena hasilnya tetap dan cepat, sedangkan fuzzing penuh di bawah ini dipakai secara berkala ketika sedang mencari masukan yang belum terpikirkan.

Fuzzing penuh dengan -fuzz#

Opsi -fuzz mengubah perilaku go test secara mendasar, yaitu menghasilkan masukan baru secara acak dan terus-menerus, menjalankan targetnya untuk setiap masukan, dan menyimpan masukan yang menambah cakupan kode ke dalam cache fuzzing sebagai benih tambahan. Cache itu berada di dalam folder cache build milik Go, bukan di dalam topik, sehingga benih-benih tambahannya tidak ikut tersalin bersama kode. Blok berikut menunjukkan perintahnya, tetapi ditandai tidak-dijalankan karena jumlah eksekusi, urutan baris keluaran, dan durasinya berbeda pada setiap eksekusi:

bash
# tidak-dijalankan
# fuzzing menghasilkan masukan baru terus-menerus sehingga jumlah eksekusi,
# urutan baris keluaran, dan durasinya berbeda pada setiap eksekusi
go test -fuzz=FuzzBalik -fuzztime=30s

Opsi -fuzztime membatasi lamanya fuzzing, yang pada contoh ini tiga puluh detik, sedangkan tanpa opsi itu fuzzing berjalan sampai dihentikan secara manual. Apabila salah satu masukan melanggar properti, fuzzer menghentikan penjalanan dan menuliskan masukan pemicunya sebagai berkas baru pada folder testdata/fuzz/FuzzBalik di dalam topik, sehingga struktur berkasnya menjadi seperti berikut:

text Struktur berkas topik setelah fuzzing menemukan kegagalan
balik.go
balik_test.go
testdata/
└── fuzz/
    └── FuzzBalik/
        └── 98fce631eb9c5dd5

Nama berkasnya dihitung dari isi masukannya, sehingga berbeda-beda pada setiap penemuan. Isi berkasnya berformat teks yang dimulai dengan penanda versi, dan contoh aslinya yang ditemukan fuzzer memuat satu byte yang bukan kelanjutan UTF-8 yang sah:

text Contoh isi berkas masukan pemicu
go test fuzz v1
string("\x80")

Berkas pada testdata otomatis dijalankan ulang oleh go test biasa sebagai korpus regresi, sehingga kegagalan yang pernah ditemukan tidak pernah lolos begitu saja di kemudian hari. Perilaku inilah alasan folder testdata patut disertakan bersama kode ketika fuzzer pernah menemukan sesuatu. Tanpa pemeriksaan utf8.ValidString pada masukan, properti kesamaan pun terlanggar oleh byte semacam itu, karena konversi []rune menggantikan byte yang tidak sah dengan karakter pengganti U+FFFD, sehingga pembalikan dua kali tidak lagi mengembalikan teks aslinya.

Menguji implementasi yang salah#

Kekuatan pola properti baru terlihat penuh ketika implementasinya salah. Implementasi berikut membalik string dengan membalik urutan byte-nya, kesalahan yang lazim ditemukan pada kode pembalik teks:

go Pembalik byte yang merusak huruf multibyte
// tidak-dijalankan
func BalikByte(s string) string {
        b := []byte(s)
        for i, j := 0, len(b)-1; i < j; i, j = i+1, j-1 {
                b[i], b[j] = b[j], b[i]
        }
        return string(b)
}

Terhadap benih ASCII semata seperti halo dan a, target fuzz dengan BalikByte lolos pada go test biasa, karena byte ASCII terbalik tetap merupakan UTF-8 yang sah. Benih naïve menggagalkan BalikByte seketika tanpa fuzzing sama sekali, karena hasil pembalikannya bukan UTF-8 sah. Dengan benih ASCII saja, fuzzer -fuzz menemukan masukan multibyte pemicunya dalam hitungan detik. Pendekatan seperti ini disebut property-based testing, yaitu pengujian berbasis properti yang memeriksa sifat yang harus selalu benar untuk semua masukan, alih-alih membandingkan pasangan masukan dan keluaran tetap satu per satu.

Ringkasan#

  • Target fuzz bernama FuzzXxx menerima *testing.F, mendaftarkan benih dengan f.Add yang jumlah dan tipenya harus cocok dengan parameter f.Fuzz, lalu menjalankan properti yang diuji melalui fungsi tanpa nama.
  • go test tanpa -fuzz hanya menjalankan benih dan korpus regresi secara deterministik, sehingga cocok untuk penjalanan rutin.
  • Opsi -fuzz menghasilkan masukan acak terus-menerus, dan bila propertinya dilanggar, masukan pemicunya ditulis ke testdata/fuzz untuk dijalankan ulang sebagai regresi.
  • Properti yang dinyatakan harus jujur, misalnya dengan t.Skip untuk masukan yang bukan UTF-8 sah, agar kegagalannya selalu bermakna.

Referensi#