自動發現 Go 項目 Bug 的神器

大家好,我是程序員幽鬼。

Go1.18 新特性中有一個神器:Fuzzing,對於發現 Go 項目中的 Bug 很有幫助。本文通過一個具體的例子來介紹它的基本使用,希望你能掌握並應用。

以下這個函數,你能找到幾個 bug?它的功能看起來很簡單——對於一個字符串,用一個新的用戶定義字符覆蓋它的第一個字符 n 次。例如,如果我們運行OverwriteString("Hello, World!", "A", 5),正確的輸出是:"AAAAA, World!"

// overwrite_string.go

// OverwriteString overwrites the first 'n' characters in a string with
// the rune 'value'
func OverwriteString(str string, value rune, n int) string {
 // If asked to overwrite more than the entire string then no need to loop,
 // just return string length * the rune
 if n > len(str) {
  return strings.Repeat(string(value), len(str))
 }

 result := []rune(str)
 for i := 0; i <= n; i++ {
  result[i] = value
 }
 return string(result)
}

在爲代碼提供一次快速可視化所需的時間內,Go 的新模糊測試工具可以通過該函數運行超過 500 萬個程序生成的輸入,並在這種情況下在_一秒鐘_內找到導致越界數組訪問的輸入。

比如,使用這組參數運行函數:OverwriteString("0000", rune('A'), 4)會導致 panic:

--- FAIL: FuzzBasicOverwriteString (0.05s)
    --- FAIL: FuzzBasicOverwriteString (0.00s)
        testing.go:1349: panic: runtime error: index out of range [4] with length 4
            goroutine 96 [running]:
            runtime/debug.Stack()
             /home/everest/sdk/gotip/src/runtime/debug/stack.go:24 +0x90
            testing.tRunner.func1()
           ...<snip> 
    
    Failing input written to testdata/fuzz/FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f
    To re-run:
    go test -run=FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f

模糊測試(Fuzzing)是一種強大的測試技術,它非常擅長髮現開發人員通常會遺漏的 Bug 和漏洞,並且在發現開源 Go 代碼中的數百個關鍵錯誤方面有着良好的記錄。

將我們的小示例問題擴展到關鍵應用程序中的千行代碼路徑,通過數十億輸入的模糊器只需幾分鐘即可發現細微的錯誤,否則這些錯誤在生產中需要數天才能解決。下面首先介紹如何使用 Go 的最新測試工具並儘快開始發現自己的錯誤。

入門

這是 Go 1.18 的新特性:模糊測試功能,因此在開始之前,請確保您$ go version的版本至少爲 1.18。如果你的版本低於 1.18,請升級。

如果想跟着代碼做,可以在 github.com/fuzzbuzz/go-fuzzing-tutorial 找到這篇文章的代碼。對於本教程的其餘部分,所有命令都是從introduction目錄中運行的。

這是模糊測試的基本寫法:

// overwrite_string_test.go

func FuzzBasicOverwriteString(f *testing.F) {
 f.Fuzz(func(t *testing.T, str string, value rune, n int) {
  OverwriteString(str, value, n)
 })
}

與期望來自固定輸入的特定行爲的單元測試相反,模糊測試通過其測試的功能運行數千個程序生成的輸入,而無需開發人員手動提供輸入。在這種特定情況下,我們希望將測試的函數傳遞給f.Fuzz,因此模糊器將生成一個新stringruneint 來填充每次測試迭代的參數。

默認情況下,模糊測試將檢測崩潰、掛起和極端內存使用情況,因此即使不編寫任何斷言,我們也已經爲我們的函數構建了一個有用的健壯性測試。

要運行此測試,執行如下命令:

go test -fuzz FuzzBasicOverwriteString

在大約一秒鐘內,你應該會看到帶有類似於以下錯誤信息的測試退出:(你的運行結果不會完全一樣)

--- FAIL: FuzzBasicOverwriteString (0.05s)
    --- FAIL: FuzzBasicOverwriteString (0.00s)
        testing.go:1349: panic: runtime error: index out of range [4] with length 4
...SNIP
    Failing input written to testdata/fuzz/FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f
    To re-run:
    go test -run=FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f

模糊器在 testdata/fuzz/FuzzBasicOverwriteString目錄內存放導致問題的特定輸入的文件。打開這個文件,你可以看到導致我們的函數 panic 的實際值:(你的值可能不一樣)

go test fuzz v1
string("00")
rune('A')
int(2)

現在我們已經發現了一個錯誤,可以進入我們的代碼修復問題。查看實際導致 panic ( overwrite_string.go:16) 的代碼行,該代碼似乎試圖訪問長度爲 4 的字符串的索引 4,這導致了數組索引越界錯誤。你可以通過更改檢查 if n > len(str) 以測試大於或等於來修復錯誤:

// overwrite_string.go

// OverwriteString overwrites the first 'n' characters in a string with
// the rune 'value'
func OverwriteString(str string, value rune, n int) string {
 // If asked to overwrite more than the entire string then no need to loop,
 // just return string length * the rune
 if n >= len(str) {
  return strings.Repeat(string(value), len(str))
 }

 result := []rune(str)
 for i := 0; i <= n; i++ {
  result[i] = value
 }
 return string(result)
}

這將確保僅當 n 至少小於字符串長度 1 時才輸入循環。我們也可以修復 for 循環的邊界,但這隱藏了一個更有趣的錯誤,所以現在我們忽略它。

通過使用輸出中提供的 fuzzer 命令重新運行崩潰的測試用例,確認修復了 Bug:

$ go test -v -count=1 -run=FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f

=== RUN   FuzzBasicOverwriteString
=== RUN   FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f
--- PASS: FuzzBasicOverwriteString (0.00s)
    --- PASS: FuzzBasicOverwriteString/2bac7bdf139ad0b2de37275db2a606ecb335bd344500173b451e9dfc3658c12f (0.00s)
PASS
ok   github.com/fuzzbuzz/go-fuzzing-tutorial/introduction 0.001s

任何時候執行 go test(這些輸入統稱爲 “種子”),Go 的 fuzzer 將自動運行 testdata 目錄中的每個輸入作爲單元測試。將目錄 testdata 提交 到版本控制會將此輸入保存爲永久迴歸測試,以確保永遠不會重新引入該錯誤。

一次意外之旅

現在,我完全承認,在我寫這篇文章的時候,我希望這個改變能夠滿足基本的模糊測試,但是如果你在這個改變之後重新運行模糊器,你會注意到一個全新的錯誤出現:

$ go test -fuzz FuzzBasicOverwriteString
fuzz: elapsed: 0s, gathering baseline coverage: 0/17 completed
fuzz: elapsed: 0s, gathering baseline coverage: 17/17 completed, now fuzzing with 8 workers
fuzz: minimizing 177-byte failing input file
fuzz: elapsed: 0s, minimizing
--- FAIL: FuzzBasicOverwriteString (0.17s)
    --- FAIL: FuzzBasicOverwriteString (0.00s)
        testing.go:1349: panic: runtime error: index out of range [60] with length 60
            goroutine 2911 [running]:
            runtime/debug.Stack()
             /home/everest/sdk/gotip/src/runtime/debug/stack.go:24 +0x90
            testing.tRunner.func1()
             /home/everest/sdk/gotip/src/testing/testing.go:1349 +0x1f2
            panic({0x5b3700, 0xc00289c798})
             /home/everest/sdk/gotip/src/runtime/panic.go:838 +0x207
            github.com/fuzzbuzz/go-fuzzing-tutorial/introduction.OverwriteString({0xc00288ef00, 0x3d}, 0x83, 0x3c)
             /home/everest/src/fuzzbuzz/go-fuzzing-tutorial/introduction/overwrite_string.go:20 +0x270
            github.com/fuzzbuzz/go-fuzzing-tutorial/introduction.FuzzBasicOverwriteString.func1(0x5?, {0xc00288ef00?, 0x0?}, 0x0?, 0x0?)
             /home/everest/src/fuzzbuzz/go-fuzzing-tutorial/introduction/overwrite_string_test.go:24 +0x38
            reflect.Value.call({0x598d60?, 0x5cfb58?, 0x13?}{0x5c179f, 0x4}{0xc0028c2de0, 0x4, 0x4?})
             /home/everest/sdk/gotip/src/reflect/value.go:556 +0x845
            reflect.Value.Call({0x598d60?, 0x5cfb58?, 0x514?}{0xc0028c2de0, 0x4, 0x4})
             /home/everest/sdk/gotip/src/reflect/value.go:339 +0xbf
            testing.(*F).Fuzz.func1.1(0x0?)
             /home/everest/sdk/gotip/src/testing/fuzz.go:337 +0x231
            testing.tRunner(0xc0028e7380, 0xc0028ec5a0)
             /home/everest/sdk/gotip/src/testing/testing.go:1439 +0x102
            created by testing.(*F).Fuzz.func1
             /home/everest/sdk/gotip/src/testing/fuzz.go:324 +0x5b8
            
    
    Failing input written to testdata/fuzz/FuzzBasicOverwriteString/2ee896e38866e089811eeece13f9919795072e6cc05ee9f782d68d1663d204c7
    To re-run:
    go test -run=FuzzBasicOverwriteString/2ee896e38866e089811eeece13f9919795072e6cc05ee9f782d68d1663d204c7
FAIL
exit status 1
FAIL github.com/fuzzbuzz/go-fuzzing-tutorial/introduction 0.174s

你看到的情況下,模糊器生成的實際輸入可能看起來不同,以下是我看到的測試用例:

go test fuzz v1
string("000000000000000000000000000000Ö00000000000000000000000000000")
rune('\u0083')
int(60)

乍一看,這個錯誤看起來幾乎與你剛剛修復的錯誤一模一樣。嘗試訪問 60 個字符長字符串的索引 60 應該是不可能的,因爲該函數將在初始 if 語句處返回。但是這就是模糊測試的力量——它揭示了開發人員沒有考慮過的邊緣情況,這實際上是一個完全獨立的錯誤。

如果你檢查 panic 的輸入,你可能會像我一樣注意到其中一個字符是 Unicode 字符。也就是說,它由一個以上的字節表示。在我的情況下,它是 Ö。當然,這個輸入字符串有 60 個字符長,但它有 61 個字節長。在 Go 中,通過 len 是獲取字符串中的字節數,而不是字符數(或 rune)。

這很容易自己檢查。如果你運行以下 Go 代碼片段:

str := "Ö"
runeArray := []rune(str)
fmt.Println("Str len:", len(str)"Rune array len:", len(runeArray))

將看到以下輸出:

Str len: 2 Rune array len: 1

有了關於 Go 的字符串實現的重要信息,再次重寫 if 語句,從 if n >= len(str) 改爲 if n >= utf8.RuneCountInString(str)。因此我們想要比較的是字符數而不是字節數:

// overwrite_string.go

// OverwriteString overwrites the first 'n' characters in a string with
// the rune 'value'
func OverwriteString(str string, value rune, n int) string {
 // If asked to overwrite more than the entire string then no need to loop,
 // just return string length * the rune
 if n >= utf8.RuneCountInString(str) {
  return strings.Repeat(string(value), len(str))
 }

 result := []rune(str)
 for i := 0; i <= n; i++ {
  result[i] = value
 }
 return string(result)
}

再次運行 fuzz 測試,觀察它的變化,試圖找到另一個輸入來使我們的函數 panic:

go test -fuzz FuzzBasicOverwriteString

你_應該_讓它運行一段時間以確保沒有任何其他錯誤潛伏的錯誤,至少可以確信該函數不會在最基本的輸入上崩潰。你可以按ctrl/cmmand-C停止模糊器。

功能性 Bug

到目前爲止,我們已經發現了導致崩潰的錯誤。拒絕服務是一件大事,但我們所知道的是,這個函數不會因意外輸入而崩潰。但測試函數的_正確性_也很重要。有很多方法可以解決這個問題,但是通過模糊測試,最好嘗試考慮一個始終適用於你的代碼_的不變量(或屬性) 。_

OverwriteString 不變量的一個例子是,該函數永遠不應填充比它被要求的數字_更多的字符_。更具體地說,如果被要求覆蓋 “Hello, world!” 使用 5 個 “A” 字符,應該可以檢查字符串中剩餘的字符是否仍然是 “, world!”。(通常成爲語料或種子)

這可以通過以下測試進行一般化:

// overwrite_string_test.go

func FuzzOverwriteStringSuffix(f *testing.F) {
 f.Add("Hello, world!"'A', 15)

 f.Fuzz(func(t *testing.T, str string, value rune, n int) {
  result := OverwriteString(str, value, n)
  if n > 0 && n < utf8.RuneCountInString(str) {
   // If we modified characters [0:n]then characters [n:] should stay the same
   resultSuffix := string([]rune(result)[n:])
   strSuffix := string([]rune(str)[:])
   if resultSuffix != strSuffix {
    t.Fatalf("OverwriteString modified too many characters! Expected %s, got %s.", strSuffix, resultSuffix)
   }
  }
 })
}

運行測試會發現另一個 Bug:

$ go test -fuzz FuzzOverwriteStringSuffix

fuzz: elapsed: 0s, gathering baseline coverage: 0/54 completed
fuzz: minimizing 66-byte failing input file
fuzz: elapsed: 0s, gathering baseline coverage: 8/54 completed
--- FAIL: FuzzOverwriteStringSuffix (0.03s)
    --- FAIL: FuzzOverwriteStringSuffix (0.00s)
        overwrite_string_test.go:38: OverwriteString modified too many characters! Expected 0, got A.
    
    Failing input written to testdata/fuzz/FuzzOverwriteStringSuffix/148139e8febb077401421c031a9bd3c3315179c5a66c90349102d223b451ec02
    To re-run:
    go test -run=FuzzOverwriteStringSuffix/148139e8febb077401421c031a9bd3c3315179c5a66c90349102d223b451ec02
FAIL
exit status 1
FAIL github.com/fuzzbuzz/go-fuzzing-tutorial/introduction 0.031s

請注意,這一次不是 panic,而是一條看起來非常類似於單元測試失敗的消息。實際上,這段代碼一直存在一個功能性錯誤。

在第 20 行它檢查循環索引用的是 <=,所以它一直填充多餘的字符。將循環條件從 i <= n 更改爲 i < n 解決此問題。

最終OverwriteString函數應該如下所示:

// overwrite_string.go

// OverwriteString overwrites the first 'n' characters in a string with
// the rune 'value'
func OverwriteString(str string, value rune, n int) string {
 // If asked for more no need to loop, just return
 // string length * the rune
 if n >= utf8.RuneCountInString(str) {
  return strings.Repeat(string(value), len(str))
 }

 result := []rune(str)
 for i := 0; i < n; i++ {
  result[i] = value
 }
 return string(result)
}

如果再次運行 fuzzer,你應該會看到 fuzzer 每秒可靠地運行數千個輸入,同時沒有發現其他錯誤。

理想情況下,應該讓這個模糊測試運行至少幾分鐘,以增加對該代碼正確性的信心(特別是如果它正在測試超過 10 行的函數)。

這篇文章中的錯誤是在幾秒鐘內發現的,但有些錯誤可能需要數小時或數天的時間來進行模糊測試,因爲模糊器需要時間來探索被測軟件的整個狀態空間。後續文章將介紹大規模連續模糊測試的藝術。

結語

這只是對 Go 模糊測試的簡要介紹。今天討論的示例是你開始向自己的項目添加模糊測試所需的全部內容。

後續文章將深入研究你可以找到的錯誤類型,調查一些真實世界的模糊測試錯誤,並討論如何自動化你的模糊測試,以便 CI 在你睡着時發現錯誤。

作者:Everest Munro-Zeisberger,原文鏈接:https://blog.fuzzbuzz.io/go-fuzzing-basics


歡迎關注「幽鬼」,像她一樣做團隊的核心。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/VcX6MGx39rz3q0DcwH783A