前提条件
- Go 1.18 以降のインストール。 インストール手順については、Goのインストールを参照してください。
- コードを編集するためのツール。 お使いのテキストエディタで問題ありません。
- コマンドターミナル。 GoはLinuxやMacの任意のターミナル、WindowsのPowerShellやcmdでうまく動作します。
- ファジングをサポートする環境。 現在、GoのファジングはAMD64およびARM64アーキテクチャでのみ利用可能です。
コード用のフォルダを作成する
まず、作成するコード用のフォルダを作成します。
- 1. コマンドプロンプトを開き、ホームディレクトリに移動します。
LinuxまたはMacの場合:
Windowsの場合:$ cd
チュートリアルの残りの部分では、プロンプトとして$が表示されます。使用するコマンドはWindowsでも動作します。C:\> cd %HOMEPATH%
- 2. コマンドプロンプトから、fuzzという名前のコード用のディレクトリを作成します。
$ mkdir fuzz
$ cd fuzz
- 3. コードを保持するためのモジュールを作成します。
go mod init
コマンドを実行し、新しいコードのモジュールパスを指定します。
注意: 本番コードの場合、より具体的なニーズに応じたモジュールパスを指定します。詳細については、依存関係の管理を参照してください。$ go mod init example/fuzz
go: creating new go.mod: module example/fuzz
次に、後でファジングするために文字列を反転させる簡単なコードを追加します。
テスト用のコードを追加する
コードを書く
- 1. テキストエディタを使用して、fuzzディレクトリにmain.goという名前のファイルを作成します。
- 2. main.goのファイルの先頭に、次のパッケージ宣言を貼り付けます。
スタンドアロンプログラム(ライブラリとは対照的に)は常にパッケージpackage main
main
にあります。 - 3. パッケージ宣言の下に、次の関数宣言を貼り付けます。
この関数はfunc Reverse(s string) string {
b := []byte(s)
for i, j := 0, len(b)-1; i < len(b)/2; i, j = i+1, j-1 {
b[i], b[j] = b[j], b[i]
}
return string(b)
}
string
を受け取り、byte
ずつループして、最後に反転した文字列を返します。
注意: このコードはgolang.org/x/example内のstringutil.Reverse
関数に基づいています。 - 4. main.goの先頭で、パッケージ宣言の下に、文字列を初期化し、反転させ、出力を印刷し、繰り返すための次の
main
関数を貼り付けます。
この関数は、いくつかのfunc main() {
input := "The quick brown fox jumped over the lazy dog"
rev := Reverse(input)
doubleRev := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q\n", rev)
fmt.Printf("reversed again: %q\n", doubleRev)
}
Reverse
操作を実行し、その後出力をコマンドラインに印刷します。これは、コードの動作を確認したり、デバッグに役立つ場合があります。 5.
main
関数はfmtパッケージを使用するため、インポートする必要があります。
最初のコード行は次のようになります:package main
import "fmt"
コードを実行する
main.goを含むディレクトリのコマンドラインから、コードを実行します。
$ go run .
original: "The quick brown fox jumped over the lazy dog"
reversed: "god yzal eht revo depmuj xof nworb kciuq ehT"
reversed again: "The quick brown fox jumped over the lazy dog"
元の文字列、反転した結果、再度反転した結果(元の文字列と同じ)を確認できます。
ユニットテストを追加する
このステップでは、Reverse
関数の基本的なユニットテストを作成します。
コードを書く
- 1. テキストエディタを使用して、fuzzディレクトリにreverse_test.goという名前のファイルを作成します。
2. reverse_test.goに次のコードを貼り付けます。
package main
import (
"testing"
)
func TestReverse(t *testing.T) {
testcases := []struct {
in, want string
}{
{"Hello, world", "dlrow ,olleH"},
{" ", " "},
{"!12345", "54321!"},
}
for _, tc := range testcases {
rev := Reverse(tc.in)
if rev != tc.want {
t.Errorf("Reverse: %q, want %q", rev, tc.want)
}
}
}
コードを実行する
go test
を使用してユニットテストを実行します。
$ go test
PASS
ok example/fuzz 0.013s
ファジングテストを追加する
ユニットテストには制限があります。すなわち、各入力は開発者によってテストに追加されなければなりません。ファジングの利点の一つは、コードの入力を生成し、テストケースが到達しなかったエッジケースを特定する可能性があることです。
このセクションでは、ユニットテストをファジングテストに変換し、より少ない作業でより多くの入力を生成できるようにします!
ユニットテスト、ベンチマーク、ファジングテストを同じ*_test.goファイルに保持できますが、この例ではユニットテストをファジングテストに変換します。
コードを書く
テキストエディタで、reverse_test.go内のユニットテストを次のファジングテストに置き換えます。
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}
ファジングにもいくつかの制限があります。ユニットテストでは、Reverse
関数の期待される出力を予測し、実際の出力がその期待に合致するかを確認できました。
例えば、テストケースReverse("Hello, world")
では、ユニットテストは返り値を"dlrow ,olleH"
として指定しています。
ファジングでは、入力を制御できないため、期待される出力を予測することはできません。
ただし、Reverse
関数のいくつかの特性をファジングテストで確認できます。このファジングテストで確認される2つの特性は次のとおりです:
- 1. 文字列を2回反転させると、元の値が保持される
- 2. 反転した文字列は、有効なUTF-8としてその状態を保持する。
ユニットテストとファジングテストの構文の違いに注意してください:
- 関数はTestXxxの代わりにFuzzXxxで始まり、
*testing.F
の代わりに*testing.T
を取ります。 t.Run
の実行が期待される場所では、f.Fuzz
が表示され、ファジングターゲット関数のパラメータが*testing.T
であり、ファジングされる型です。ユニットテストからの入力は、f.Add
を使用してシードコーパス入力として提供されます。
新しいパッケージunicode/utf8
がインポートされていることを確認してください。
package main
import (
"testing"
"unicode/utf8"
)
ユニットテストがファジングテストに変換されたので、再度テストを実行する時間です。
コードを実行する
- 1. ファジングせずにファジングテストを実行して、シード入力が通過することを確認します。
他のテストがそのファイルにある場合は、$ go test
PASS
ok example/fuzz 0.013s
go test -run=FuzzReverse
を実行してファジングテストのみを実行することもできます。 2. ファジングを使用して
FuzzReverse
を実行し、ランダムに生成された文字列入力が失敗を引き起こすかどうかを確認します。これは、go test
を使用して新しいフラグ-fuzz
をパラメータFuzz
に設定して実行されます。以下のコマンドをコピーしてください。$ go test -fuzz=Fuzz
もう一つの便利なフラグは
-fuzztime
で、ファジングにかかる時間を制限します。例えば、以下のテストで-fuzztime 10s
を指定すると、以前に失敗が発生しなかった場合、デフォルトで10秒経過後にテストが終了します。詳細なテストフラグについては、cmd/goのドキュメントのthis sectionを参照してください。
今、コピーしたコマンドを実行します。$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/3 completed
fuzz: elapsed: 0s, gathering baseline coverage: 3/3 completed, now fuzzing with 8 workers
fuzz: minimizing 38-byte failing input file...
--- FAIL: FuzzReverse (0.01s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:20: Reverse produced invalid UTF-8 string "\x9c\xdd"
Failing input written to testdata/fuzz/FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
To re-run:
go test -run=FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a
FAIL
exit status 1
FAIL example/fuzz 0.030s
ファジング中に失敗が発生し、その問題を引き起こした入力は、次回
go test
が呼び出されるときに実行されるシードコーパスファイルに書き込まれます。-fuzz
フラグなしでも。失敗を引き起こした入力を表示するには、テキストエディタでtestdata/fuzz/FuzzReverseディレクトリに書き込まれたコーパスファイルを開きます。シードコーパスファイルには異なる文字列が含まれている可能性がありますが、形式は同じです。go test fuzz v1
string("泃")
コーパスファイルの最初の行はエンコーディングバージョンを示します。次の各行は、コーパスエントリを構成する各型の値を表します。ファジングターゲットは1つの入力しか受け取らないため、バージョンの後には1つの値しかありません。
- 3.
go test
を-fuzz
フラグなしで再度実行します。新しい失敗したシードコーパスエントリが使用されます:
テストが失敗したので、デバッグの時間です。$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/af69258a12129d6cbba438df5d5f25ba0ec050461c116f777e77ea7c9a0d217a (0.00s)
reverse_test.go:20: Reverse produced invalid string
FAIL
exit status 1
FAIL example/fuzz 0.016s
無効な文字列エラーを修正する
このセクションでは、失敗をデバッグし、バグを修正します。
進む前に、これについて考えたり、自分で問題を修正しようとしたりする時間を自由に使ってください。
エラーを診断する
このエラーをデバッグする方法はいくつかあります。VS Codeをテキストエディタとして使用している場合は、デバッガを設定することで調査できます。
このチュートリアルでは、便利なデバッグ情報をターミナルにログ出力します。
まず、utf8.ValidString
のドキュメントを考慮してください。
ValidString reports whether s consists entirely of valid UTF-8-encoded runes.
現在のReverse
関数は文字列をバイト単位で反転させており、これが問題です。元の文字列のUTF-8エンコードされたルーンを保持するためには、文字列をルーン単位で反転させる必要があります。
入力(この場合は中国語の文字泃
)が反転されたときにReverse
が無効な文字列を生成する理由を調べるために、反転された文字列のルーン数を確認できます。
コードを書く
テキストエディタで、FuzzReverse
内のファジングターゲットを次のように置き換えます。
f.Fuzz(func(t *testing.T, orig string) {
rev := Reverse(orig)
doubleRev := Reverse(rev)
t.Logf("Number of runes: orig=%d, rev=%d, doubleRev=%d", utf8.RuneCountInString(orig), utf8.RuneCountInString(rev), utf8.RuneCountInString(doubleRev))
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
このt.Logf
行は、エラーが発生した場合や、-v
でテストを実行する場合にコマンドラインに出力します。これにより、この特定の問題をデバッグするのに役立ちます。
コードを実行する
go testを使用してテストを実行します。
$ go test
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=3, doubleRev=1
reverse_test.go:21: Reverse produced invalid UTF-8 string "\x83\xb3\xe6"
FAIL
exit status 1
FAIL example/fuzz 0.598s
シードコーパス全体は、すべての文字が単一バイトである文字列を使用していました。しかし、泃のような文字は複数のバイトを必要とする場合があります。したがって、文字列をバイト単位で反転させると、マルチバイト文字が無効になります。
注意: Goが文字列をどのように扱うかに興味がある場合は、Goにおける文字列、バイト、ルーン、文字のブログ記事を読んで、より深く理解してください。
バグをよりよく理解した上で、Reverse
関数のエラーを修正します。
エラーを修正する
<a name="write-the-code-4"></a>
#### コードを書く
テキストエディタで、既存のReverse()関数を次のように置き換えます。
``````bash
func Reverse(s string) string {
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
`
重要な違いは、Reverse
が文字列内の各rune
を反復処理しているのではなく、各byte
を反復処理していることです。
コードを実行する
- 1.
go test
を使用してテストを実行します。
テストは現在通過します!$ go test
PASS
ok example/fuzz 0.016s
2.
go test -fuzz
で再度ファジングして、新しいバグがないか確認します。$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/37 completed
fuzz: minimizing 506-byte failing input file...
fuzz: elapsed: 0s, gathering baseline coverage: 5/37 completed
--- FAIL: FuzzReverse (0.02s)
--- FAIL: FuzzReverse (0.00s)
reverse_test.go:33: Before: "\x91", after: "�"
Failing input written to testdata/fuzz/FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
To re-run:
go test -run=FuzzReverse/1ffc28f7538e29d79fce69fef20ce5ea72648529a9ca10bea392bcff28cd015c
FAIL
exit status 1
FAIL example/fuzz 0.032s
文字列が2回反転した後、元の文字列とは異なることがわかります。今回は入力自体が無効なunicodeです。文字列でファジングしているのに、これはどういうことでしょうか?
再度デバッグしましょう。
ダブルリバースエラーを修正する
このセクションでは、ダブルリバースの失敗をデバッグし、バグを修正します。
進む前に、これについて考えたり、自分で問題を修正しようとしたりする時間を自由に使ってください。
エラーを診断する
以前と同様に、この失敗をデバッグする方法はいくつかあります。この場合、デバッガを使用するのが良いアプローチです。
このチュートリアルでは、Reverse
関数内に便利なデバッグ情報をログ出力します。
反転された文字列を注意深く見て、エラーを見つけてください。Goでは、文字列は読み取り専用のバイトスライスであり、有効なUTF-8でないバイトを含むことがあります。元の文字列は1バイトのバイトスライス'\x91'
です。入力文字列が[]rune
に設定されると、GoはバイトスライスをUTF-8にエンコードし、バイトをUTF-8文字�に置き換えます。置き換えられたUTF-8文字と入力バイトスライスを比較すると、明らかに等しくありません。
コードを書く
- 1. テキストエディタで、
Reverse
関数を次のように置き換えます。
これにより、文字列をルーンのスライスに変換する際に何が問題なのかを理解するのに役立ちます。func Reverse(s string) string {
fmt.Printf("input: %q\n", s)
r := []rune(s)
fmt.Printf("runes: %q\n", r)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r)
}
コードを実行する
今回は、ログを確認するために失敗したテストのみを実行したいと思います。これを行うために、go test -run
を使用します。
FuzzXxx/testdata内の特定のコーパスエントリを実行するには、{FuzzTestName}/{filename}を-run
に提供できます。これはデバッグ時に役立ちます。この場合、-run
フラグを失敗したテストの正確なハッシュに設定します。ターミナルからユニークなハッシュをコピーして貼り付けてください。以下のものとは異なります。
$ go test -run=FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0
input: "\x91"
runes: ['�']
input: "�"
runes: ['�']
--- FAIL: FuzzReverse (0.00s)
--- FAIL: FuzzReverse/28f36ef487f23e6c7a81ebdaa9feffe2f2b02b4cddaa6252e87f69863046a5e0 (0.00s)
reverse_test.go:16: Number of runes: orig=1, rev=1, doubleRev=1
reverse_test.go:18: Before: "\x91", after: "�"
FAIL
exit status 1
FAIL example/fuzz 0.145s
入力が無効なunicodeであることがわかったので、Reverse
関数のエラーを修正しましょう。
エラーを修正する
この問題を修正するために、Reverse
への入力が有効なUTF-8でない場合はエラーを返すようにします。
コードを書く
- 1. テキストエディタで、既存の
Reverse
関数を次のように置き換えます。
この変更により、入力文字列に有効なUTF-8でない文字が含まれている場合にエラーが返されます。func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
- 2. Reverse関数がエラーを返すようになったので、
main
関数を修正して、余分なエラー値を破棄します。既存のmain
関数を次のように置き換えます。
これらのfunc main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleRevErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}
Reverse
への呼び出しは、入力文字列が有効なUTF-8であるため、nilエラーを返す必要があります。 - 3. errorsおよびunicode/utf8パッケージをインポートする必要があります。main.goのインポート文は次のようになります。
import (
"errors"
"fmt"
"unicode/utf8"
)
- 4. reverse_test.goファイルを修正して、エラーをチェックし、エラーが生成された場合はテストをスキップします。
返す代わりに、func FuzzReverse(f *testing.F) {
testcases := []string {"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := Reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
return
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}
t.Skip()
を呼び出して、そのファジング入力の実行を停止することもできます。
コードを実行する
- 1. go testを使用してテストを実行します。
$ go test
PASS
ok example/fuzz 0.019s
- 2.
go test -fuzz=Fuzz
でファジングし、数秒後にctrl-C
でファジングを停止します。ファジングテストは、失敗する入力に遭遇するまで実行されます。-fuzztime
フラグを渡さない限り、デフォルトでは失敗が発生しない限り永遠に実行されます。このプロセスはctrl-C
で中断できます。
$ go test -fuzz=Fuzz
fuzz: elapsed: 0s, gathering baseline coverage: 0/38 completed
fuzz: elapsed: 0s, gathering baseline coverage: 38/38 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 86342 (28778/sec), new interesting: 2 (total: 35)
fuzz: elapsed: 6s, execs: 193490 (35714/sec), new interesting: 4 (total: 37)
fuzz: elapsed: 9s, execs: 304390 (36961/sec), new interesting: 4 (total: 37)
...
fuzz: elapsed: 3m45s, execs: 7246222 (32357/sec), new interesting: 8 (total: 41)
^Cfuzz: elapsed: 3m48s, execs: 7335316 (31648/sec), new interesting: 8 (total: 41)
PASS
ok example/fuzz 228.000s
- 1.
go test -fuzz=Fuzz -fuzztime 30s
でファジングし、失敗が見つからなかった場合は30秒間ファジングします。
ファジングが成功しました!$ go test -fuzz=Fuzz -fuzztime 30s
fuzz: elapsed: 0s, gathering baseline coverage: 0/5 completed
fuzz: elapsed: 0s, gathering baseline coverage: 5/5 completed, now fuzzing with 4 workers
fuzz: elapsed: 3s, execs: 80290 (26763/sec), new interesting: 12 (total: 12)
fuzz: elapsed: 6s, execs: 210803 (43501/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 9s, execs: 292882 (27360/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 12s, execs: 371872 (26329/sec), new interesting: 14 (total: 14)
fuzz: elapsed: 15s, execs: 517169 (48433/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 18s, execs: 663276 (48699/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 21s, execs: 771698 (36143/sec), new interesting: 15 (total: 15)
fuzz: elapsed: 24s, execs: 924768 (50990/sec), new interesting: 16 (total: 16)
fuzz: elapsed: 27s, execs: 1082025 (52427/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 30s, execs: 1172817 (30281/sec), new interesting: 17 (total: 17)
fuzz: elapsed: 31s, execs: 1172817 (0/sec), new interesting: 17 (total: 17)
PASS
ok example/fuzz 31.025s
-fuzz
フラグに加えて、go test
にいくつかの新しいフラグが追加され、ドキュメントで確認できます。
ファジング出力で使用される用語についての詳細は、Go Fuzzingを参照してください。例えば、「新しい興味深い」は、既存のファジングテストコーパスのコードカバレッジを拡張する入力を指します。「新しい興味深い」入力の数は、ファジングが始まると急激に増加し、新しいコードパスが発見されると数回スパイクし、その後時間とともに減少します。
結論
素晴らしい! Goにおけるファジングを紹介しました。
次のステップは、ファジングしたいコード内の関数を選択し、試してみることです!ファジングがコード内のバグを見つけた場合は、それをtrophy caseに追加することを検討してください。
問題が発生した場合や機能のアイデアがある場合は、issueを提出してください。
機能に関する議論や一般的なフィードバックについては、Gophers Slackの#fuzzing channelにも参加できます。
さらなる読み物については、go.dev/security/fuzzのドキュメントをチェックしてください。
完成したコード
— main.go —
package main
import (
"errors"
"fmt"
"unicode/utf8"
)
func main() {
input := "The quick brown fox jumped over the lazy dog"
rev, revErr := Reverse(input)
doubleRev, doubleRevErr := Reverse(rev)
fmt.Printf("original: %q\n", input)
fmt.Printf("reversed: %q, err: %v\n", rev, revErr)
fmt.Printf("reversed again: %q, err: %v\n", doubleRev, doubleRevErr)
}
func Reverse(s string) (string, error) {
if !utf8.ValidString(s) {
return s, errors.New("input is not valid UTF-8")
}
r := []rune(s)
for i, j := 0, len(r)-1; i < len(r)/2; i, j = i+1, j-1 {
r[i], r[j] = r[j], r[i]
}
return string(r), nil
}
— reverse_test.go —
package main
import (
"testing"
"unicode/utf8"
)
func FuzzReverse(f *testing.F) {
testcases := []string{"Hello, world", " ", "!12345"}
for _, tc := range testcases {
f.Add(tc) // Use f.Add to provide a seed corpus
}
f.Fuzz(func(t *testing.T, orig string) {
rev, err1 := Reverse(orig)
if err1 != nil {
return
}
doubleRev, err2 := Reverse(rev)
if err2 != nil {
return
}
if orig != doubleRev {
t.Errorf("Before: %q, after: %q", orig, doubleRev)
}
if utf8.ValidString(orig) && !utf8.ValidString(rev) {
t.Errorf("Reverse produced invalid UTF-8 string %q", rev)
}
})
}