diff --git a/.github/workflows/fuzzing.yaml b/.github/workflows/fuzzing.yaml new file mode 100644 index 00000000..32926af7 --- /dev/null +++ b/.github/workflows/fuzzing.yaml @@ -0,0 +1,23 @@ +name: Fuzzing +on: pull_request +permissions: read-all +jobs: + fuzzing: + runs-on: ubuntu-latest + strategy: + fail-fast: false + env: + TARGET_PATH: "." + steps: + - uses: actions/checkout@8e5e7e5ab8b370d6c329ec480221332ada57f0ab # v3.5.2 + - uses: actions/setup-go@4d34df0c2316fe8122ab82dc22947d607c0c91f9 # v4.0.0 + with: + go-version: "1.19.9" + - run: | + set -euo pipefail + + GOARCH=amd64 CPU=4 make fuzz + - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + if: failure() + with: + path: "${{env.TARGET_PATH}}/testdata/fuzz/**/*" diff --git a/Makefile b/Makefile index 3ae9d6a3..06ad414d 100644 --- a/Makefile +++ b/Makefile @@ -26,3 +26,7 @@ verify-genproto: .PHONY: test test: PASSES="unit" ./scripts/test.sh $(GO_TEST_FLAGS) + +.PHONY: fuzz +test: + ./scripts/fuzzing.sh diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 00000000..f7c02e13 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,161 @@ +// Copyright 2022 The etcd Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package raft + +import ( + "runtime" + "strings" + "testing" + + fuzz "github.com/AdaLogics/go-fuzz-headers" + + pb "go.etcd.io/raft/v3/raftpb" +) + +func getMsgType(i int) pb.MessageType { + allTypes := map[int]pb.MessageType{0: pb.MsgHup, + 1: pb.MsgBeat, + 2: pb.MsgProp, + 3: pb.MsgApp, + 4: pb.MsgAppResp, + 5: pb.MsgVote, + 6: pb.MsgVoteResp, + 7: pb.MsgSnap, + 8: pb.MsgHeartbeat, + 9: pb.MsgHeartbeatResp, + 10: pb.MsgUnreachable, + 11: pb.MsgSnapStatus, + 12: pb.MsgCheckQuorum, + 13: pb.MsgTransferLeader, + 14: pb.MsgTimeoutNow, + 15: pb.MsgReadIndex, + 16: pb.MsgReadIndexResp, + 17: pb.MsgPreVote, + 18: pb.MsgPreVoteResp} + return allTypes[i%len(allTypes)] +} + +// All cases in shouldReport represent known errors in etcd +// as these are reported via manually added panics. +func shouldReport(err string) bool { + if strings.Contains(err, "stepped empty MsgProp") { + return false + } + if strings.Contains(err, "Was the raft log corrupted, truncated, or lost?") { + return false + } + if strings.Contains(err, "ConfStates not equivalent after sorting:") { + return false + } + if strings.Contains(err, "term should be set when sending ") { + return false + } + if (strings.Contains(err, "unable to restore config")) && (strings.Contains(err, "removed all voters")) { + return false + } + if strings.Contains(err, "ENCOUNTERED A PANIC OR FATAL") { + return false + } + if strings.Contains(err, "need non-empty snapshot") { + return false + } + if strings.Contains(err, "index, ") && strings.Contains(err, ", is out of range [") { + return false + } + + return true +} + +func catchPanics() { + if r := recover(); r != nil { + var errMsg string + switch r.(type) { + case string: + errMsg = r.(string) + case runtime.Error: + errMsg = r.(runtime.Error).Error() + } + if shouldReport(errMsg) { + // Getting to this point means that the fuzzer + // did not stop because of a manually added panic. + panic(errMsg) + } + } +} + +func FuzzStep(f *testing.F) { + f.Fuzz(func(t *testing.T, data []byte) { + defer SetLogger(getLogger()) + SetLogger(discardLogger) + + defer catchPanics() + f := fuzz.NewConsumer(data) + msg := pb.Message{} + err := f.GenerateStruct(&msg) + if err != nil { + return + } + + msgTypeIndex, err := f.GetInt() + if err != nil { + return + } + msg.Type = getMsgType(msgTypeIndex) + + cfg := newTestConfig(1, 5, 1, newTestMemoryStorage(withPeers(1, 2))) + cfg.Logger = &ZapRaftLogger{} + r := newRaft(cfg) + r.becomeCandidate() + r.becomeLeader() + r.prs.Progress[2].BecomeReplicate() + _ = r.Step(msg) + _ = r.readMessages() + }) +} + +type ZapRaftLogger struct { +} + +func (zl *ZapRaftLogger) Debug(_ ...interface{}) {} + +func (zl *ZapRaftLogger) Debugf(_ string, _ ...interface{}) {} + +func (zl *ZapRaftLogger) Error(_ ...interface{}) {} + +func (zl *ZapRaftLogger) Errorf(_ string, _ ...interface{}) {} + +func (zl *ZapRaftLogger) Info(_ ...interface{}) {} + +func (zl *ZapRaftLogger) Infof(_ string, _ ...interface{}) {} + +func (zl *ZapRaftLogger) Warning(_ ...interface{}) {} + +func (zl *ZapRaftLogger) Warningf(_ string, _ ...interface{}) {} + +func (zl *ZapRaftLogger) Fatal(_ ...interface{}) { + panic("ENCOUNTERED A PANIC OR FATAL") +} + +func (zl *ZapRaftLogger) Fatalf(_ string, _ ...interface{}) { + panic("ENCOUNTERED A PANIC OR FATAL") +} + +func (zl *ZapRaftLogger) Panic(_ ...interface{}) { + panic("ENCOUNTERED A PANIC OR FATAL") +} + +func (zl *ZapRaftLogger) Panicf(_ string, _ ...interface{}) { + panic("ENCOUNTERED A PANIC OR FATAL") +} diff --git a/go.mod b/go.mod index 353ff51d..457dab1f 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module go.etcd.io/raft/v3 go 1.19 require ( + github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 github.com/cockroachdb/datadriven v1.0.2 github.com/gogo/protobuf v1.3.2 github.com/golang/protobuf v1.5.2 diff --git a/go.sum b/go.sum index a226f130..dfaa7537 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= github.com/cockroachdb/datadriven v1.0.2 h1:H9MtNqVoVhvd9nCBwOyDjUEdZCREqbIdCJD93PBm/jA= github.com/cockroachdb/datadriven v1.0.2/go.mod h1:a9RdTaap04u637JoCzcUoIcDmvwSUtcUFtT/C3kJlTU= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/scripts/fuzzing.sh b/scripts/fuzzing.sh new file mode 100755 index 00000000..aa6339ac --- /dev/null +++ b/scripts/fuzzing.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash + +set -euo pipefail + +source ./scripts/test_lib.sh + +GO_CMD="go" +fuzz_time=${FUZZ_TIME:-"300s"} +target_path=${TARGET_PATH:-"."} +TARGETS="FuzzStep" + + +for target in ${TARGETS}; do + log_callout -e "\\nExecuting fuzzing with target ${target} in $target_path with a timeout of $fuzz_time\\n" + run pushd "${target_path}" + $GO_CMD test -fuzz "${target}" -fuzztime "${fuzz_time}" + run popd + log_success -e "\\COMPLETED: fuzzing with target $target in $target_path \\n" +done