GitHub Actions のワークフローをチェックする actionlint をつくった
GitHub Actions のワークフローを静的にチェックする actionlint というコマンドラインツールを最近つくっていて,概ね欲しい機能が揃って実装も安定してきたので紹介します.
なぜワークフローファイルの lint をすべきなのか
GitHub Actions が正式リリースされてからだいぶ経ち,GitHub 上での CI は GitHub Actions が第一候補となってきているように感じます.僕も新規にリポジトリを作成して CI をセットアップする場合はほぼ GitHub Actions を使っています.
ですが,GitHub Actions には下記のような問題があり,actionlint でそれらを解決・緩和したいというのが理由です.
- ワークフローを実装する時は,GitHub に push して CI が実行されるのを待って結果を確認するという作業が繰り返し必要になり,時間がかかります.手元で分かる間違いはなるべく手元でチェックし,この繰り返し回数を減らしたいです.
- GitHub Actions サービス側のワークフローのチェックは非常に『緩い』です.例えば,想定していないキーがあっても単に無視されるため,キー名をタイポしていても気付きません.また
matrix.os
のような式内でのプロパティアクセスはプロパティが存在しなくてもエラーにならず,単にnull
に評価されます.また,ブランチ名やタグ名のフィルタに使う glob パターンも簡単な構文チェックしか行っておらず,例えば間違えて正規表現を使ってしまったりしてもエラーになりません. - ワークフローが「一応実行は成功しているが実は意図通り動いていない」(静かに壊れている)ということがよくあります.例えばありがちなのは actions/cache で
key: ${{ matrix.platform }}-${{ hashFiles(...) }}
のようにキャッシュのキーを指定していて,matrix.platform
が存在しない場合です(他所のワークフローからコピペしてきた場合などに matrix の値の名前をミスってることがあります).この場合matrix.platform
はnull
になり,文字列化すると空文字列になるため,キャッシュのキーは意図した値になりません.ですが,これは実行してすぐは普通に動いてしまうので間違いに気付かず,後にジョブ間での難しいキャッシュ周りの問題を引いてしまいます.
コマンドラインでの使い方
ローカルにインストールする場合は以下のいずれかの方法で使ってください.
- (macOS の場合)Homebrew を使う.
ただし,M1 Mac は動作確認が取れていないのでビルド済みバイナリを提供できていないのでこの方法が使えません.v1.4.1 で Apple M1 サポートしました(追記: 2021/7/12)x86_64
用のバイナリをダウンロードして Rosetta2 で動かしていただくか,Go 1.16 以上で手元でソースからビルドしてください - リリースページから実行バイナリをダウンロードして解凍し,PATH が通ったディレクトリに置く
- ソースからビルドする:
go install github.com/rhysd/actionlint/cmd/actionlint@latest
インストールした actionlint
コマンドを lint をかけたいリポジトリ内で引数無しで実行すると,自動で .github/workflows
以下のワークフローファイルを見つけてきてチェックします.基本的にはこれだけです.
$ actionlint
ワークフローを指定したい場合は引数にパスを渡します.
$ actionlint /path/to/workflow.yml
その他オプションについては actionlint -help
を確認してください.
見つけたエラーはエラー箇所のコードスニペットと一緒に標準出力に出力されます.エラーが見つからなかった場合は何も出力しません.
CI での使い方
自動で最新版の actionlint
をカレントディレクトリにダウンロードしてくるダウンロードスクリプトを使うのが一番簡単です.
例えば GitHub Actions なら下記のようなジョブで実行できます.GitHub Actions でのコマンド実行は端末から実行された扱いにならずそのままでは出力に色が着かないので -color
フラグを用います.
jobs: actionlint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - name: Run actionlint shell: bash run: | bash <(curl https://raw.githubusercontent.com/rhysd/actionlint/main/scripts/download-actionlint.bash) ./actionlint -color
また,-oneline
フラグを使うと reviewdog のようなツールにも簡単に integrate できます.
ブラウザでの使い方
actionlint を WebAssembly にコンパイルし,ブラウザでも Playground として利用できるようにしています.
https://rhysd.github.io/actionlint/
ページ内のエディタにワークフローの YAML を貼り付けると,自動で lint 結果が更新されます.エラーメッセージをクリックすると,エディタのカーソルがエラー位置に移動します.全てローカルのブラウザで完結しているので,貼り付けたワークフローがリモートのサーバに送信されることはありません.一応 iOS などのモバイル端末でも動きます.
actionlint で実装した静的チェック
actionlint では方針としてワークフロー内のミスを見つけることにフォーカスしています.1回の実行でできるだけ多くのエラーを見つけ,かつ false positive(誤検知)を最小限に抑えることを目指しています. なので,インデントの崩れなどのコードスタイルについてはチェックしません.もしそういったチェックが欲しい場合は yamllint などのツールを別途使用してください.
actionlint ではざっくり下記のチェックを実装しています.
- ワークフロー構文の構文チェック.必要なキーが無かったり,不要なキーがあったりするとエラーになる
${{ }}
内の式構文の構文チェックおよび意味チェック.式の型チェック,コンテキストオブジェクトのプロパティチェック,関数のシグネチャチェックなど- shellcheck を使った
run:
のシェルスクリプトチェック - pyflakes を使った
run:
の Python スクリプトチェック on:
に書く Webhook イベントのチェックbranches:
などに書く glob パターンの構文チェックおよび ref name のチェックcron:
に書く CRON の構文チェックおよび実行頻度チェックruns-on:
に書く runner ラベルのバリデーションuses:
に書くアクションのフォーマットチェックとローカルアクションの input チェックshell:
に書くシェル名のバリデーション(例えばpowershell
は Windows 系の runner でないと使えないなど)needs:
に書く依存ジョブのチェック.ジョブの存在チェック,循環依存チェックなど- Job ID や Step ID のユニーク性のチェック
- その他,ハードコードされたパスワードの検知,環境変数名のチェックなど...
actionlint による有用なチェックの例
チェック一覧はこちらのドキュメントに例と actionlint の出力,playground へのリンク付きで網羅的に書いてありますが,この記事内でそれを全て書くのは長くなりすぎるのでやめておきます.
代わりに,ここでは actionlint が有用な例をいくつか紹介します.コメントで 'エラー: ' と書かれているところが実際に actionlint で指摘される箇所です.
キー名やラベル名のタイポ
on: push: # エラー: branches: が正しい branch: main
キー名は間違っていても GitHub Actions は特に指摘してくれません.単にワークフローが走らないだけなので,自力でタイポに気付く必要があります.actionlint はどの要素がどういうキーを持っているべきかを知っているので,こういったミスをチェックできます.
strategy: matrix: # エラー: linux-latest は存在しない.ubuntu-latest が正しい os: [linux-latest, windows-latest] runs-on: ${{ matrix.os }}
actionlint は runs-on:
に書かれた runner ラベルが正しいかどうかもチェックします.上記のように,${{ matrix.os }}
のように間接的に指定していても matrix の値を見に行ってチェックします.
Webhook のバリデーション
on: # エラー: pull_request が正しい pullreq: issues: # エラー: opened が正しい types: created schedule: # エラー: CRON シンタックスが間違っている(要素数が1つ足りない) - cron: '0 */3 * *' # エラー: 実行のインターバルが短すぎる - cron: '* */3 * * *' push: tags: # エラー: 正規表現は使えない - '^v\d+$' # エラー: glob パターンの構文エラー(? は * に付けられない) - 'v*.*.*?'
on:
には Webhook イベントを書けます.どういうイベントがあるかはドキュメントに書いてあるので,actionlint は正しいイベントおよびイベントタイプが指定されているかをチェックできます.
cron:
に指定するスケジュールの構文チェックや実行頻度のチェックも行います.実行頻度が1分に1回以上だとエラーになります.
さらに,tags:
や branches:
や paths:
に使う glob パターンについても構文が正しいかや Git の ref name に使えない文字が使われていないかなどをチェックします.ここに正規表現を書けると勘違いしているケースが結構あるみたいなのですが,GitHub Actions 側ではエラーになりません.actionlint ならそういったケースも拾えます.
${{ }}
内の式の型チェック
# エラー: 構文チェック: 文字列リテラルにはシングルクォートしか使えない - run: echo '${{ contains(runner.os, "windows-") }}' # エラー: 意味チェック: github.repository は文字列型なので owner プロパティは存在しない - run: echo '${{ github.repository.owner }}'
GitHub Actions では ${{ }}
で囲んだ中に式を書いて評価させることができます.actionlint ではその式を構文木にパースして型チェックを行います.
github.*
やjob.*
などのグローバルに自動で定義されるコンテキストオブジェクトmatrix.*
やsteps.*
,env.*
,needs.*
などの,ユーザが書いたワークフローに応じて適宜定義されるコンテキストオブジェクトcontains()
,toJSON()
,format()
,hashFiles()
などの組み込み関数
これらを適宜解析しながら型チェックを行います.
strategy: matrix: node: [14, 15] package: - name: 'foo' optional: true - name: 'bar' optional: false # エラー: matrix に定義されていない値(コピペしてきて変え忘れが多い) runs-on: ${{ matrix.os }} steps: # エラー: 存在しないキー.matrix.package の型は {name: string, optional: bool} - run: echo '${{ matrix.package.dev }}' # エラー: startswith() は第1引数に文字列型の値を取るので,object 型の matrix.package は使えない - run: echo '${{ startswith(matrix.package, 'foo') }}'
actionlint は matrix:
に定義された値を解析し,matrix.*
オブジェクトに適宜型を付けます.存在しないプロパティにアクセスしようとした時や,型が合わないときなどにエラーにできます.
# エラー: startswith() は第1引数に文字列型の値を取るので,object 型の runner は使えない - run: echo ${{ startswith(runner, 'linux-') }} # エラー: hashFiles() は少なくとも1つ以上引数が必要 - run: echo ${{ hashFiles() }} # エラー: format() 引数の数とプレースホルダの数が合っていない - run: echo ${{ format('{0}: {1}', 1) }}
${{ }}
内の式で使える関数は組み込み関数のみでドキュメントに定義が載っています.actionlint はこれら全ての関数シグネチャの情報を持っていて,型チェックなどを行います.これらの関数の定義は結構複雑で,オーバーロードがあったり(contains()
は文字列でも配列でも使えるなど),デフォルト引数(join()
のセパレータのデフォルトは ','
)があったり,可変長引数(hashFiles()
は1つ以上の引数を取る)があったりします.actionlint ではそれらを解析できるように実装しています.
また,steps.*
オブジェクトには各ステップの output 値がセットされていきます.これらの値は,そのステップが実行されるまでは存在しないため,アクセスすると null
になってしまいます.これはステップを他のワークフローからコピペしてきた時によく起きる間違いです.
# エラー: ここではまだ get_value ステップが実行されていないため steps.get_value.outputs は存在しない - run: echo '${{ steps.get_value.outputs.name }}' # ここで steps.get_value に値がセットされる - run: echo '::set-output name=foo::value' id: get_value # OK - run: echo '${{ steps.get_value.outputs.name }}'
run:
に書くスクリプトの lint
test: runs-on: ubuntu-latest steps: # エラー: "$FOO" のようにクォートで括るべき - run: echo $FOO test-win: runs-on: windows-latest # PowerShell で実行されるので shellcheck でチェックしない - run: echo $FOO
actionlint は run:
に書かれたシェルスクリプトを shellcheck を使ってチェックします.actionlint は run:
に書かれているスクリプトがどのシェルで実行されるかを解析し,bash
か sh
だった場合のみ shellcheck を実行します.shellcheck
コマンドがシステムに存在しない時は単にチェックをスキップします(-shellcheck
オプションで制御可能).
# エラー: 未定義変数 hello - run: print(hello) shell: python
また,GitHub Actions では shell: python
を指定することで run:
に Python スクリプトを書くこともできます.actionlint ではこれらの Python スクリプトを shellcheck と同様に pyflakes を使ってチェックします.pyflakes
コマンドがシステムに存在しない時は単にチェックをスキップします(-pyflakes
オプションで制御可能).
actionlint の設定ファイル
.github/actionlint.yml
に設定ファイルを置くことができます.ただ,僕は lint の設定ファイルを極力管理したくないので,設定ファイルは基本的に必要無いようにしています.現在は,self-hosted runner を使っている時のカスタムラベルの値だけを設定に書くことができます(runs-on:
のラベルのバリデーションで使われます).
設定ファイルが必要になった際は,actionlint -init-config
でデフォルトの設定ファイルを生成できるので,ゼロから書く必要はありません.
actionlint の実装について
全て Go で実装しています.
ワークフローファイルは go-yaml/yaml を使って YAML の構文木に一旦パースし,YAML 構文木をトラバースしてワークフロー構文独自の構文木に再構成します (parse.go
).
各チェックはルールごとにワークフロー構文木の visitor の pass として分けて実装していて(rule_*.go
),ルールの取捨選択やルールごとのテストがやりやすいように設計しています.ワークフロー構文木 1 pass でチェックを終えられるように,visitor に pass を登録して,visitor は構文木を巡りながら各 pass を適用していきます.
${{ }}
内の式の解析は真面目に字句解析・構文解析・意味解析を実装しました(expr_*.go
).glob パターンの解析も独自にバリデータを実装しています(glob.go
).
実装の方針として,できるだけ多くのエラーを検知できるようにというのがあるので,たとえ1つエラーを見つけても処理は続行します.例えばワークフロー構文木の生成の過程でエラー(必要なキーが無いなど)が見つかっても,気にせずにその後の解析を続けます.壊れたワークフローをチェックしても問題ないように,go-fuzz を使った fuzzing を行ったりもしています.
この辺りの細かい実装の話はもし需要があれば別途どこかに書くかもしれません.
単体テストはまだ十分ではないですが,一応実世界でのテストはしていて,GitHub API を使って GitHub の上位1000リポジトリから 1500 以上の GitHub Actions のワークフローファイルをかき集めてきて,それらに actionlint を適用し,検出したエラーを一通り目でチェックしました.その過程で見つかった誤検知などの問題は一通り修正しています.
まとめ
GitHub Actions ワークフローの静的チェックを行うツール actionlint を実装しました.CI のワークフローの実装やデバッグは時間がかかって大変なので,そういった作業が少しでもこれで軽減できれば良いなと思ってます.
簡単に試せるようになっているので,お手元のワークフローファイルを試してみていただき,フィードバック(誤検知の報告や機能提案など)を issue で教えてもらえるとありがたいです(日本語で大丈夫です).
Go で --version の出力を実装する
Go でコマンドラインツールを実装した時に
$ some-tool -version
のようにバージョンを出力するフラグを実装することが多いと思います.本記事はこれをどう実装するかのメモです.
手動でバージョン情報を管理
素朴にはバージョン情報を定数で持って手動で管理する方法があります.
package main import ( "flag" "fmt" ) const version = "1.2.3" func main() { var v bool flag.BoolVar(&v, "version", false, "Show version") flag.Parse() if v { fmt.Println(version) } }
新しいリリースを行うときは version
定数の値を手で書き換えてコミットしてからリリース用のタグを打つという方法でやっていたのですが,間をおいてリリースする時に修正忘れが怖いのとリリースの手間を極力減らしたいという事情があります.
定数の書き換え処理を CI のリリースジョブで自動でやれば良いように見えますが,それはうまくいきません.リリースジョブはリポジトリにタグを打った時に動きますが,上記バージョン文字列定数はタグを打つ前に書き換える必要があるためです.でないと go install
してビルドしたバイナリのバージョン情報が更新前のものになってしまいます.リリースジョブが走るトリガーを別のものにし,リリースジョブの中でタグを打つようなことはできますが,-version
の実装のためだけにリリースフローを曲げるのは微妙です.
今までは『バージョン文字列定数を更新してコミットしてからタグを打ち git push
する』という一連の流れをスクリプトにしてリリース時にはそれを使うようにし,リリースジョブ側ではそのスクリプトによって生成されたコミットにタグが打たれていることをコミットメッセージから validation するということをやっていました.それで問題なく動いてはいたのですが,やはり普通にタグを打つだけでリリースしたいのと,リリースジョブの validation 部分をメンテするのを避けたいと思うようになりました.
runtime/debug.BuildInfo
からバージョンを取得する
ツイッターで @shibu_jp さんに下記の記事を教えてもらいました.
Go ではビルド時にバイナリにモジュールのバージョン情報が埋め込まれており,runtime/debug.BuildInfo
としてそれを実行時に取得できます.これを利用するとバージョン情報取得は下記のように実装できます.
package main import ( "flag" "fmt" "runtime/debug" ) func getVersion() string { i, ok := debug.ReadBuildInfo() if !ok { return "unknown" } return i.Main.Version } func main() { var v bool flag.BoolVar(&v, "version", false, "Show version") flag.Parse() if v { fmt.Println(getVersion()) } }
これにより,go install
時にビルドされたバージョンを -version
で表示でき,バージョン文字列を手動で管理する必要はなくなりました.モジュールが有効になっていないとバージョンは unknown
になりますが,go install
は @latest
や @v1.0.0
のようなサフィックスをつけると自動でモジュールを有効にしてくれるので問題ないと思います.
ビルド済みバイナリにバージョン情報を埋める
上記の BuildInfo
を使う方法では go build
でビルドした時のバージョンは (devel)
になるので,CI のリリースジョブでビルドしたバイナリを GitHub のリリースページにアップロードするとバージョンが取得できません.
なので,リリースジョブでビルドする時にバージョン情報を埋めて代わりにそちらを使うようにします.
package main import ( "flag" "fmt" "runtime/debug" ) // バージョン情報を埋め込める用に変数を用意しておく // const ではなく var なことに注意 var version = "" func getVersion() string { if version != "" { // バージョン情報が埋め込まれている時 return version } i, ok := debug.ReadBuildInfo() if !ok { return "unknown" } return i.Main.Version } func main() { var v bool flag.BoolVar(&v, "version", false, "Show version") flag.Parse() if v { fmt.Println(getVersion()) } }
これで
go build -ldflags '-s -w -X main.version=1.0.0'
とすると version
変数に "1.0.0"
という文字列をビルド時にリンクします.
CI でのリリース作成には GoReleaser を使っているので,.goreleaser.yaml
に
builds: - main: ./cmd/some-tool ldflags: -s -w -X main.version={{.Version}}
のようにするとリリースタグの値をビルド済み実行バイナリに埋め込むことができます.これで go install
とリリースページで公開するビルド済みバイナリの両方でバージョン情報が表示できるようになりました.
追加情報を出す
バージョンだけでなく『どうやってインストールしたか』も出力しておくと issue の切り分けの場合などに便利なことがあります.これも version
と同じ要領で
package main import ( "flag" "fmt" "runtime/debug" ) var ( version = "" installFrom = "built from source" ) func getVersion() string { if version != "" { return version } i, ok := debug.ReadBuildInfo() if !ok { return "unknown" } return i.Main.Version } func main() { var v bool flag.BoolVar(&v, "version", false, "Show version") flag.Parse() if v { fmt.Printf("%s\n%s\n", getVersion(), installFrom) } }
のようにすると
$ go build ./cmd/some-tool $ ./some-tool -version (devel) built from source
のように2行目にどうインストールされたかが表示されます.-ldflags
を使って,.goreleaser.yaml
に下記のように設定することで,そのバイナリがリリースページからダウンロードされたものか,自前でビルドしたものかを表示できます.
builds: - main: ./cmd/some-tool ldflags: -s -w -X main.version={{.Version}} -X "main.installFrom=downloaded from release page"
細かい注意点として,リンクする文字列に空白を含む場合は -X
の引数全体をダブルクォートで囲む必要があります(シングルクォートではダメで,Bash のように main.installFrom="..."
とするのもダメです).
また,お好みに合わせてさらに追加の情報を含めておくのも良さそうです.go version
や clang --version
を見ると,ターゲットの情報を出しておくと issue 調査時などに便利そうです.
runtime.Version()
: Go のコンパイラバージョン(go1.16.4
など)runtime.GOOS
: OS(darwin
など)runtime.GOARCH
: アーキ(amd64
など)go/build.Default.BuildTags
: ビルドタグ
Go Fuzzing beta を go-yaml/yaml.v3 で試してみた
Go の fuzzing のプロポーザルについてツイートしていたら,Kaoriya さんに
すでにbeta段階まで来ててちょうど昨日ブログ記事がでてました。go test -fuzzらしいです。https://t.co/HhKR1r1lUz
— MURAOKA Taro (@kaoriya) 2021年6月4日
と教えてもらったので,早速 Go 標準で利用可能になる fuzzing 機能(現在は beta 扱い)を使ってみました.
セットアップ
上記のブログ記事に書いてあるやり方で問題なくセットアップできました.
$ git clone https://github.com/go-yaml/yaml.git -b v3 $ cd yaml $ go get golang.org/dl/gotip # gotip 入れる $ gotip download dev.fuzz # dev.fuzz ブランチの Go をビルドしてインストール $ gotip version go version devel go1.17-5542c10fbf Fri Jun 4 03:07:33 2021 +0000 darwin/amd64
使ってみる
gofuzz を使ったことがあるので使い方について困ることは特にありませんでした.
$ gotip doc testing.F
で API 一覧が見られます.
今回 Go に入った fuzzing の機能は go test の一部として実装されていて,Test
で始まる関数が単体テスト,Bench
で始まる関数がベンチマークであるのと同様に Fuzz
で始まる *testing.F
を引数に取る関数を書くと,それが fuzzing のターゲットとして定義されます.
上記のブログ記事に倣って fuzzing のためのファイル fuzz_test.go
をリポジトリ直下に置いて,
// +build gofuzzbeta package yaml_test import ( "strings" "testing" "gopkg.in/yaml.v3" ) func FuzzMarshalUnmarshalYAML(f *testing.F) { f.Add("{}") f.Add("v: hi") f.Add("v: true") f.Add("v: 10") f.Add("v: 0b10") f.Add("v: 0xA") f.Add("v: 4294967296") f.Add("v: 0.1") f.Add("v: .1") f.Add("v: .Inf") f.Add("v: -.Inf") f.Add("v: -10") f.Add("v: -.1") f.Add("123") f.Add("canonical: 6.8523e+5") f.Add("expo: 685.230_15e+03") f.Add("fixed: 685_230.15") f.Add("neginf: -.inf") f.Add("fixed: 685_230.15") f.Add("canonical: true") f.Add("canonical: false") f.Add("bool: True") f.Add("bool: False") f.Add("bool: TRUE") f.Add("bool: FALSE") f.Add("option: on") f.Add("option: y") f.Add("option: Off") f.Add("option: No") f.Add("option: other") f.Add("canonical: 685230") f.Add("decimal: +685_230") f.Add("octal: 02472256") f.Add("octal: -02472256") f.Add("octal: 0o2472256") f.Add("octal: -0o2472256") f.Add("hexa: 0x_0A_74_AE") f.Add("bin: 0b1010_0111_0100_1010_1110") f.Add("bin: -0b101010") f.Add("bin: -0b1000000000000000000000000000000000000000000000000000000000000000") f.Add("decimal: +685_230") f.Add("empty:") f.Add("canonical: ~") f.Add("english: null") f.Add("~: null key") f.Add("empty:") f.Add("seq: [A,B]") f.Add("seq: [A,B,C,]") f.Add("seq: [A,1,C]") f.Add("seq: [A,1,C]") f.Add("seq: [A,1,C]") f.Add("seq:\n - A\n - B") f.Add("seq:\n - A\n - B\n - C") f.Add("seq:\n - A\n - 1\n - C") f.Add("scalar: | # Comment\n\n literal\n\n \ttext\n\n") f.Add("scalar: > # Comment\n\n folded\n line\n \n next\n line\n * one\n * two\n\n last\n line\n\n") f.Add("a: {b: c}") f.Add("a: {b: c, 1: d}") f.Add("hello: world") f.Add("a: {b: c}") f.Add("a: {b: c}") f.Add("a: 'null'") f.Add("a: {b: c}") f.Add("a: {b: c}") f.Add("a:") f.Add("a: 1") f.Add("a: 1.0") f.Add("a: [1, 2]") f.Add("a: YES") f.Add("v: 42") f.Add("v: -42") f.Add("v: 4294967296") f.Add("v: -4294967296") f.Add("int_max: 2147483647") f.Add("int_min: -2147483648") f.Add("int_overflow: 9223372036854775808") f.Add("int64_max: 9223372036854775807") f.Add("int64_max_base2: 0b111111111111111111111111111111111111111111111111111111111111111") f.Add("int64_min: -9223372036854775808") f.Add("int64_neg_base2: -0b111111111111111111111111111111111111111111111111111111111111111") f.Add("int64_overflow: 9223372036854775808") f.Add("uint_min: 0") f.Add("uint_max: 4294967295") f.Add("uint_underflow: -1") f.Add("uint64_min: 0") f.Add("uint64_max: 18446744073709551615") f.Add("uint64_max_base2: 0b1111111111111111111111111111111111111111111111111111111111111111") f.Add("uint64_maxint64: 9223372036854775807") f.Add("uint64_underflow: -1") f.Add("float32_max: 3.40282346638528859811704183484516925440e+38") f.Add("float32_nonzero: 1.401298464324817070923729583289916131280e-45") f.Add("float32_maxuint64: 18446744073709551615") f.Add("float32_maxuint64+1: 18446744073709551616") f.Add("float64_max: 1.797693134862315708145274237317043567981e+308") f.Add("float64_nonzero: 4.940656458412465441765687928682213723651e-324") f.Add("float64_maxuint64: 18446744073709551615") f.Add("float64_maxuint64+1: 18446744073709551616") f.Add("v: 4294967297") f.Add("v: 128") f.Add("'1': '\"2\"'") f.Add("v:\n- A\n- 'B\n\n C'\n") f.Add("v: !!float '1.1'") f.Add("v: !!float 0") f.Add("v: !!float -1") f.Add("v: !!null ''") f.Add("%TAG !y! tag:yaml.org,2002:\n---\nv: !y!int '1'") f.Add("v: ! test") f.Add("a: &x 1\nb: &y 2\nc: *x\nd: *y\n") f.Add("a: &a {c: 1}\nb: *a") f.Add("a: &a [1, 2]\nb: *a") f.Add("foo: ''") f.Add("foo: null") f.Add("foo: null") f.Add("foo: null") f.Add("foo: ~") f.Add("foo: ~") f.Add("foo: ~") f.Add("a: 1\nb: 2\n") f.Add("Line separator\u2028Paragraph separator\u2029") f.Add("a: 1\nb: 2\nc: 3\n") f.Add("a: 1\nb: 2\nc: 3\n") f.Add("a: 1\n") f.Add("a: 1\nc: 3\nd: 4\n") f.Add("a: 1\nb: 2\nc: 3\n") f.Add("a: -b_c") f.Add("a: +b_c") f.Add("a: 50cent_of_dollar") f.Add("a: {b: https://github.com/go-yaml/yaml}") f.Add("a: [https://github.com/go-yaml/yaml]") f.Add("a: 3s") f.Add("a: <foo>") f.Add("a: 1:1\n") f.Add("a: !!binary gIGC\n") f.Add("a: !!binary |\n " + strings.Repeat("kJCQ", 17) + "kJ\n CQ\n") f.Add("a: !!binary |\n " + strings.Repeat("A", 70) + "\n ==\n") f.Add("a:\n b:\n c: d\n") f.Add("a: {b: c}") f.Add("a: 1.2.3.4\n") f.Add("a: 2015-02-24T18:19:39Z\n") f.Add("a: 2015-01-01\n") f.Add("a: 2015-02-24T18:19:39.12Z\n") f.Add("a: 2015-2-3T3:4:5Z") f.Add("a: 2015-02-24t18:19:39Z\n") f.Add("a: 2015-02-24 18:19:39\n") f.Add("a: !!str 2015-01-01") f.Add("a: !!timestamp \"2015-01-01\"") f.Add("a: !!timestamp 2015-01-01") f.Add("a: \"2015-01-01\"") f.Add("a: !!timestamp \"2015-01-01\"") f.Add("a: 2015-01-01") f.Add("a: []") f.Add("\xff\xfe\xf1\x00o\x00\xf1\x00o\x00:\x00 \x00v\x00e\x00r\x00y\x00 \x00y\x00e\x00s\x00\n\x00") f.Add("\xff\xfe\xf1\x00o\x00\xf1\x00o\x00:\x00 \x00v\x00e\x00r\x00y\x00 \x00y\x00e\x00s\x00 \x00=\xd8\xd4\xdf\n\x00") f.Add("\xfe\xff\x00\xf1\x00o\x00\xf1\x00o\x00:\x00 \x00v\x00e\x00r\x00y\x00 \x00y\x00e\x00s\x00\n") f.Add("\xfe\xff\x00\xf1\x00o\x00\xf1\x00o\x00:\x00 \x00v\x00e\x00r\x00y\x00 \x00y\x00e\x00s\x00 \xd8=\xdf\xd4\x00\n") f.Add("a: 123456e1\n") f.Add("a: 123456E1\n") f.Add("First occurrence: &anchor Foo\nSecond occurrence: *anchor\nOverride anchor: &anchor Bar\nReuse anchor: *anchor\n") f.Add("---\nhello\n...\n}not yaml") f.Add("hello") f.Add("true") f.Add("true") f.Add("a: b\r\nc:\r\n- d\r\n- e\r\n") f.Add("a: b") f.Add("---\na: b\n...\n") f.Add("---\n'hello'\n...\n---\ngoodbye\n...\n") f.Add("hello") f.Add("goodbye") f.Add("i: &i [*h,*h,*h,*h,*h,*h,*h,*h,*h]\n") f.Add("yaml: document contains excessive aliasing") f.Add("null") f.Add("s2: null\ns3: null") f.Add("s2: null\ns3: null") f.Add("\"\\0\\\r\n") f.Add(" 0: [\n] 0") f.Add("? ? \"\n\" 0") f.Add(" - {\n000}0") f.Add("0:\n 0: [0\n] 0") f.Add(" - \"\n000\"0") f.Add(" - \"\n000\"\"") f.Add("0:\n - {\n000}0") f.Add("0:\n - \"\n000\"0") f.Add("0:\n - \"\n000\"\"") f.Add(" \ufeff\n") f.Add("? \ufeff\n") f.Add("? \ufeff:\n") f.Add("0: \ufeff\n") f.Add("? \ufeff: \ufeff\n") f.Fuzz(func(t *testing.T, data string) { var n yaml.Node if err := yaml.Unmarshal([]byte(data), &n); err != nil { t.Skip() // Not a valid input } b, err := yaml.Marshal(&n) if err != nil { t.Fatalf("Marshal failed to encode valid YAML tree %#v: %v", &n, err) } if err := yaml.Unmarshal(b, &n); err != nil { t.Fatalf("Marshal failed to decode valid YAML bytes %#v: %v", b, err) } }) }
簡単に中身を解説すると,
// +build gofuzzbeta
今はこのビルドタグを付けておく必要があります.リリース済みのバージョンの Go との互換性のため?みたいなので,正式リリース時には不要になりそうです.
f.Add("{}") // ... f.Fuzz(func(t *testing.T, data string) { // ... })
fuzzing では事前にデータセット(コーパス)を与えることで効率的に入力を生成できます.多分無くても問題は無いですが,事前情報無しに入力を生成していくので効率は悪くなるはずです.Go では f.Add()
で有効な入力を文字列で教えてやります.今回はお試しなので decode_test.go
で使われているテストケースから適当に引っ張ってきました.
f.Fuzz()
にコールバックを与えると,fuzzer が生成した入力に対してコールバックが呼ばれます.コールバックの第2引数は interface{}
なので好きな引数型を指定して良さそうですが,[]byte
にしてみたらランタイムエラーでダメだったので,上記ブログ記事に合わせて string
にしてあります.今回は YAML パーサに対して試すので,入力はバイト列ならとりあえずなんでも良く,まあ string
で OK です(Go の string
は UTF-8 的に不正なシーケンスでも良いので).
var n yaml.Node if err := yaml.Unmarshal([]byte(data), &n); err != nil { t.Skip() // Not a valid input } b, err := yaml.Marshal(&n) if err != nil { t.Fatalf("Marshal failed to encode valid YAML tree %#v: %v", &n, err) } if err := yaml.Unmarshal(b, &n); err != nil { t.Fatalf("Marshal failed to decode valid YAML bytes %#v: %v", b, err) }
f.Fuzz()
のコールバックの中では fuzzer から与えられた入力を使って,普段の単体テストのようにテストを書きます.今回は YAML のパーサとジェネレータを fuzzing したいので,パーサに食わせてみて,YAML として正しくない入力だった場合(yaml.Unmarshal
が error
を返した場合)はそれ以降の実行を諦めて t.Skip()
します.
yaml.Unmarshal
が成功し,YAML として正しい入力だと分かったら,パース結果を yaml.Marshal
で再度文字列化してジェネレータがクラッシュしないかを見ます.さらにジェネレータが正しい出力を吐けたか見るために,再度 yaml.Unmarshal
に食わせてみています.初回のパース結果と2回目は完全に一致することが期待されるので,go-cmp
などを使ってチェックしても良いかもしれません.
問題を検知した場合には普段どおり t.Fatal
を使えば良いみたいです.
実行してみる
$ gotip test -fuzz=FuzzMarshalUnmarshalYAML
-fuzz
にターゲットを指定すると実行が始まります.コアをあるだけ使おうとするので,-parallel
でコア数を制限したほうが良いかもしれません.あとは問題を検知するまで放置するだけです.
今回はこんな感じで割とすぐ panic を引くことができました.
OK: 45 passed fuzzing, elapsed: 3.0s, execs: 504 (168/sec), workers: 20, interesting: 19 fuzzing, elapsed: 6.0s, execs: 975 (162/sec), workers: 20, interesting: 28 fuzzing, elapsed: 9.0s, execs: 1441 (160/sec), workers: 20, interesting: 41 snip... fuzzing, elapsed: 126.0s, execs: 19330 (153/sec), workers: 20, interesting: 126 fuzzing, elapsed: 129.0s, execs: 19794 (153/sec), workers: 20, interesting: 126 fuzzing, elapsed: 132.0s, execs: 20256 (153/sec), workers: 20, interesting: 127 found a crash, minimizing... fuzzing, elapsed: 134.3s, execs: 20482 (153/sec), workers: 20, interesting: 127 --- FAIL: FuzzMarshalUnmarshalYAML (134.26s) panic: runtime error: invalid memory address or nil pointer dereference goroutine 2190 [running]: runtime/debug.Stack() /Users/rhysd/sdk/gotip/src/runtime/debug/stack.go:24 +0x90 testing.tRunner.func1.2({0x12ee440, 0x14eb340}) /Users/rhysd/sdk/gotip/src/testing/testing.go:1271 +0x267 testing.tRunner.func1() /Users/rhysd/sdk/gotip/src/testing/testing.go:1278 +0x218 panic({0x12ee440, 0x14eb340}) /Users/rhysd/sdk/gotip/src/runtime/panic.go:1038 +0x215 snip... testing.(*F).Fuzz.func1.1(0x137d850) /Users/rhysd/sdk/gotip/src/testing/fuzz.go:347 +0x193 testing.tRunner(0xc0005f5a00, 0xc0006188c0) /Users/rhysd/sdk/gotip/src/testing/testing.go:1325 +0x102 created by testing.(*F).Fuzz.func1 /Users/rhysd/sdk/gotip/src/testing/fuzz.go:341 +0x545 --- FAIL: FuzzMarshalUnmarshalYAML (0.00s) Crash written to testdata/corpus/FuzzMarshalUnmarshalYAML/023ce39f07f2e64e497d71b869e21d232cfd6bd939c8e2e0904adb8685cf9caa To re-run: go test gopkg.in/yaml.v3 -run=FuzzMarshalUnmarshalYAML/023ce39f07f2e64e497d71b869e21d232cfd6bd939c8e2e0904adb8685cf9caa FAIL exit status 1 FAIL gopkg.in/yaml.v3 139.073s
問題を引いた入力は testdata/corpus/{ターゲット名}
以下に保存されます.保存されたファイルを見てみると,
go test fuzz v1 string("#\n - - QI\xd7")
となっていて,実際のクラッシュする入力は string
型で "#\n - - QI\xd7"
という値になることが分かります.
go test gopkg.in/yaml.v3 -run=FuzzMarshalUnmarshalYAML/023ce39f07f2e64e497d71b869e21d232cfd6bd939c8e2e0904adb8685cf9caa
のように {ターゲット名}/{コーパス名}
を指定してテスト実行すると,クラッシュを引いた入力を与えて再実行することができます.これでクラッシュに再現性があるかどうかを確認できます.
再現の仕方が分かれば,後はクラッシュを修正するなり報告するなりするだけです.