diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36aade25..40c9fbe8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,7 +24,7 @@ jobs: needs: [lint, test] strategy: matrix: - environment: [ubuntu-latest, macos-12, macos-14, windows-latest] + environment: [ubuntu-latest, macos-13, macos-14, windows-latest] permissions: contents: read id-token: write @@ -165,7 +165,7 @@ jobs: test: strategy: matrix: - environment: [ubuntu-latest, macos-12, macos-14, windows-latest] + environment: [ubuntu-latest, macos-13, macos-14, windows-latest] runs-on: ${{ matrix.environment }} timeout-minutes: 20 diff --git a/CODEOWNERS b/CODEOWNERS index 85f87d99..a0108738 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1 +1 @@ -* @Xenomega @anishnaik @0xalpharush +* @Xenomega @anishnaik diff --git a/README.md b/README.md index 18f154b5..162145df 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ `medusa` is a cross-platform [go-ethereum](https://github.com/ethereum/go-ethereum/)-based smart contract fuzzer inspired by [Echidna](https://github.com/crytic/echidna). It provides parallelized fuzz testing of smart contracts through CLI, or its Go API that allows custom user-extended testing methodology. -**Disclaimer**: Please note that `medusa` is an **experimental** smart contract fuzzer. Currently, it should _not_ be adopted into production systems. We intend for `medusa` to reach the same capabilities and maturity that Echidna has. Until then, be careful using `medusa` as your primary smart contract fuzz testing solution. Additionally, please be aware that the Go-level testing API is still **under development** and is subject to breaking changes. +**Disclaimer**: The Go-level testing API is still **under development** and is subject to breaking changes. ## Features @@ -29,6 +29,23 @@ cd docs mdbook serve ``` +## Install + +MacOS users can install the latest release of `medusa` using Homebrew: + +```shell + +brew install medusa +``` + +The master branch can be installed using the following command: + +```shell +brew install --HEAD medusa +``` + +For more information on building from source or obtaining binaries for Windows and Linux, please refer to the [installation guide](./docs/src/getting_started/installation.md). + ## Contributing For information about how to contribute to this project, check out the [CONTRIBUTING](./CONTRIBUTING.md) guidelines. diff --git a/chain/config/config.go b/chain/config/config.go index b50fc2f6..6a3b0c4e 100644 --- a/chain/config/config.go +++ b/chain/config/config.go @@ -14,6 +14,9 @@ type TestChainConfig struct { // CheatCodeConfig indicates the configuration for EVM cheat codes to use. CheatCodeConfig CheatCodeConfig `json:"cheatCodes"` + // SkipAccountChecks skips account pre-checks like nonce validation and disallowing non-EOA tx senders (this is done in eth_call, for instance). + SkipAccountChecks bool `json:"skipAccountChecks"` + // ContractAddressOverrides describes contracts that are going to be deployed at deterministic addresses ContractAddressOverrides map[common.Hash]common.Address `json:"contractAddressOverrides,omitempty"` } diff --git a/chain/config/config_defaults.go b/chain/config/config_defaults.go index 72ed8915..5a611c1c 100644 --- a/chain/config/config_defaults.go +++ b/chain/config/config_defaults.go @@ -10,6 +10,7 @@ func DefaultTestChainConfig() (*TestChainConfig, error) { CheatCodesEnabled: true, EnableFFI: false, }, + SkipAccountChecks: true, } // Return the generated configuration. diff --git a/chain/test_chain_test.go b/chain/test_chain_test.go index ff0ca589..048822a1 100644 --- a/chain/test_chain_test.go +++ b/chain/test_chain_test.go @@ -539,7 +539,7 @@ func TestChainCloning(t *testing.T) { }) } -// TestCallSequenceReplayMatchSimple creates a TestChain, sends some messages to it, then creates another chain which +// TestChainCallSequenceReplayMatchSimple creates a TestChain, sends some messages to it, then creates another chain which // it replays the same sequence on. It ensures that the ending state is the same. // Note: this does not set block timestamps or other data that might be non-deterministic. // This does not test replaying with a previous call sequence with different timestamps, etc. It expects the TestChain diff --git a/compilation/platforms/crytic_compile.go b/compilation/platforms/crytic_compile.go index 6039df26..77d43245 100644 --- a/compilation/platforms/crytic_compile.go +++ b/compilation/platforms/crytic_compile.go @@ -204,7 +204,7 @@ func (c *CryticCompilationConfig) Compile() ([]types.Compilation, string, error) } // Retrieve the source unit ID - sourceUnitId := ast.GetSourceUnitID() + sourceUnitId := types.GetSrcMapSourceUnitID(ast.Src) compilation.SourcePathToArtifact[sourcePath] = types.SourceArtifact{ // TODO: Our types.AST is not the same as the original AST but we could parse it and avoid using "any" Ast: source.AST, diff --git a/compilation/platforms/solc.go b/compilation/platforms/solc.go index 4ceef747..068cbeb7 100644 --- a/compilation/platforms/solc.go +++ b/compilation/platforms/solc.go @@ -145,7 +145,7 @@ func (s *SolcCompilationConfig) Compile() ([]types.Compilation, string, error) { } // Get the source unit ID - sourceUnitId := ast.GetSourceUnitID() + sourceUnitId := types.GetSrcMapSourceUnitID(ast.Src) // Construct our compiled source object compilation.SourcePathToArtifact[sourcePath] = types.SourceArtifact{ // TODO our types.AST is not the same as the original AST but we could parse it and avoid using "any" diff --git a/compilation/types/ast.go b/compilation/types/ast.go index f6b21612..b1dd251c 100644 --- a/compilation/types/ast.go +++ b/compilation/types/ast.go @@ -20,24 +20,84 @@ const ( // Node interface represents a generic AST node type Node interface { + // GetNodeType returns solc's node type e.g. FunctionDefinition, ContractDefinition. GetNodeType() string } +// FunctionDefinition is the function definition node +type FunctionDefinition struct { + // NodeType represents the node type (currently we only evaluate source unit node types) + NodeType string `json:"nodeType"` + // Src is the source file for this AST + Src string `json:"src"` + Name string `json:"name,omitempty"` +} + +func (s FunctionDefinition) GetNodeType() string { + return s.NodeType +} + // ContractDefinition is the contract definition node type ContractDefinition struct { - // NodeType represents the AST node type (note that it will always be a contract definition) + // NodeType represents the node type (currently we only evaluate source unit node types) NodeType string `json:"nodeType"` + // Nodes is a list of Nodes within the AST + Nodes []Node `json:"nodes"` + // Src is the source file for this AST + Src string `json:"src"` // CanonicalName is the name of the contract definition CanonicalName string `json:"canonicalName,omitempty"` // Kind is a ContractKind that represents what type of contract definition this is (contract, interface, or library) Kind ContractKind `json:"contractKind,omitempty"` } -// GetNodeType implements the Node interface and returns the node type for the contract definition func (s ContractDefinition) GetNodeType() string { return s.NodeType } +func (c *ContractDefinition) UnmarshalJSON(data []byte) error { + // Unmarshal the top-level AST into our own representation. Defer the unmarshaling of all the individual nodes until later + type Alias ContractDefinition + aux := &struct { + Nodes []json.RawMessage `json:"nodes"` + + *Alias + }{ + Alias: (*Alias)(c), + } + + if err := json.Unmarshal(data, &aux); err != nil { + return err + } + + // Iterate through all the nodes of the contract definition + for _, nodeData := range aux.Nodes { + // Unmarshal the node data to retrieve the node type + var nodeType struct { + NodeType string `json:"nodeType"` + } + if err := json.Unmarshal(nodeData, &nodeType); err != nil { + return err + } + + // Unmarshal the contents of the node based on the node type + switch nodeType.NodeType { + case "FunctionDefinition": + // If this is a function definition, unmarshal it + var functionDefinition FunctionDefinition + if err := json.Unmarshal(nodeData, &functionDefinition); err != nil { + return err + } + c.Nodes = append(c.Nodes, functionDefinition) + default: + continue + } + } + + return nil + +} + // AST is the abstract syntax tree type AST struct { // NodeType represents the node type (currently we only evaluate source unit node types) @@ -48,7 +108,6 @@ type AST struct { Src string `json:"src"` } -// UnmarshalJSON unmarshals from JSON func (a *AST) UnmarshalJSON(data []byte) error { // Unmarshal the top-level AST into our own representation. Defer the unmarshaling of all the individual nodes until later type Alias AST @@ -62,11 +121,6 @@ func (a *AST) UnmarshalJSON(data []byte) error { return err } - // Check if nodeType is "SourceUnit". Return early otherwise - if aux.NodeType != "SourceUnit" { - return nil - } - // Iterate through all the nodes of the source unit for _, nodeData := range aux.Nodes { // Unmarshal the node data to retrieve the node type @@ -78,7 +132,6 @@ func (a *AST) UnmarshalJSON(data []byte) error { } // Unmarshal the contents of the node based on the node type - var node Node switch nodeType.NodeType { case "ContractDefinition": // If this is a contract definition, unmarshal it @@ -86,23 +139,30 @@ func (a *AST) UnmarshalJSON(data []byte) error { if err := json.Unmarshal(nodeData, &contractDefinition); err != nil { return err } - node = contractDefinition + a.Nodes = append(a.Nodes, contractDefinition) + + case "FunctionDefinition": + // If this is a function definition, unmarshal it + var functionDefinition FunctionDefinition + if err := json.Unmarshal(nodeData, &functionDefinition); err != nil { + return err + } + a.Nodes = append(a.Nodes, functionDefinition) + // TODO: Add cases for other node types as needed default: continue } - // Append the node - a.Nodes = append(a.Nodes, node) } return nil } -// GetSourceUnitID returns the source unit ID based on the source of the AST -func (a *AST) GetSourceUnitID() int { +// GetSrcMapSourceUnitID returns the source unit ID based on the source of the AST +func GetSrcMapSourceUnitID(src string) int { re := regexp.MustCompile(`[0-9]*:[0-9]*:([0-9]*)`) - sourceUnitCandidates := re.FindStringSubmatch(a.Src) + sourceUnitCandidates := re.FindStringSubmatch(src) if len(sourceUnitCandidates) == 2 { // FindStringSubmatch includes the whole match as the first element sourceUnit, err := strconv.Atoi(sourceUnitCandidates[1]) @@ -112,3 +172,33 @@ func (a *AST) GetSourceUnitID() int { } return -1 } + +// GetSrcMapStart returns the byte offset where the function definition starts in the source file +func GetSrcMapStart(src string) int { + // 95:42:0 returns 95 + re := regexp.MustCompile(`([0-9]*):[0-9]*:[0-9]*`) + startCandidates := re.FindStringSubmatch(src) + + if len(startCandidates) == 2 { // FindStringSubmatch includes the whole match as the first element + start, err := strconv.Atoi(startCandidates[1]) + if err == nil { + return start + } + } + return -1 +} + +// GetSrcMapLength returns the length of the function definition in bytes +func GetSrcMapLength(src string) int { + // 95:42:0 returns 42 + re := regexp.MustCompile(`[0-9]*:([0-9]*):[0-9]*`) + endCandidates := re.FindStringSubmatch(src) + + if len(endCandidates) == 2 { // FindStringSubmatch includes the whole match as the first element + end, err := strconv.Atoi(endCandidates[1]) + if err == nil { + return end + } + } + return -1 +} diff --git a/docs/src/coverage_reports.md b/docs/src/coverage_reports.md index cd24b564..10920207 100644 --- a/docs/src/coverage_reports.md +++ b/docs/src/coverage_reports.md @@ -1,3 +1,44 @@ # Coverage Reports -WIP +## Generating HTML Report from LCOV + +Enable coverage reporting by setting the `corpusDirectory` key in the configuration file and setting the `coverageReports` key to `["lcov", "html"]`. + +```json +{ + "corpusDirectory": "corpus", + "coverageReports": ["lcov", "html"] +} +``` + +### Install lcov and genhtml + +Linux: + +```bash +apt-get install lcov +``` + +MacOS: + +```bash +brew install lcov +``` + +### Generate LCOV Report + +```bash + +genhtml corpus/coverage/lcov.info --output-dir corpus --rc derive_function_end_line=0 +``` + +> [!WARNING] +> ** The `derive_function_end_line` flag is required to prevent the `genhtml` tool from crashing when processing the Solidity source code. ** + +Open the `corpus/index.html` file in your browser or follow the steps to use VSCode below. + +### View Coverage Report in VSCode with Coverage Gutters + +Install the [Coverage Gutters](https://marketplace.visualstudio.com/items?itemName=ryanluker.vscode-coverage-gutters) extension. + +Then, right click in a project file and select `Coverage Gutters: Display Coverage`. diff --git a/docs/src/project_configuration/chain_config.md b/docs/src/project_configuration/chain_config.md index b101b56d..13bc0685 100644 --- a/docs/src/project_configuration/chain_config.md +++ b/docs/src/project_configuration/chain_config.md @@ -9,6 +9,12 @@ The chain configuration defines the parameters for setting up `medusa`'s underly - > 🚩 Setting `codeSizeCheckDisabled` to `false` is not recommended since it complicates the fuzz testing process. - **Default**: `true` +### `skipAccountChecks` + +- **Type**: Boolean +- **Description**: If `true`, account-related checks (nonce validation, transaction origin must be an EOA) are disabled in `go-ethereum`. +- **Default**: `true` + ## Cheatcode Configuration ### `cheatCodesEnabled` diff --git a/docs/src/project_configuration/fuzzing_config.md b/docs/src/project_configuration/fuzzing_config.md index b2bf537d..5adcd067 100644 --- a/docs/src/project_configuration/fuzzing_config.md +++ b/docs/src/project_configuration/fuzzing_config.md @@ -55,6 +55,13 @@ The fuzzing configuration defines the parameters for the fuzzing campaign. can then be re-used/mutated by the fuzzer during the next fuzzing campaign. - **Default**: "" +### `coverageFormats` + +- **Type**: [String] (e.g. `["lcov"]`) +- **Description**: The coverage reports to generate after the fuzzing campaign has completed. The coverage reports are saved + in the `coverage` directory within `crytic-export/` or `corpusDirectory` if configured. +- **Default**: `["lcov", "html"]` + ### `targetContracts` - **Type**: [String] (e.g. `[FirstContract, SecondContract, ThirdContract]`) diff --git a/docs/src/static/medusa.json b/docs/src/static/medusa.json index 2e8644b6..8d08a8d0 100644 --- a/docs/src/static/medusa.json +++ b/docs/src/static/medusa.json @@ -4,10 +4,12 @@ "workerResetLimit": 50, "timeout": 0, "testLimit": 0, + "shrinkLimit": 5000, "callSequenceLength": 100, "corpusDirectory": "", "coverageEnabled": true, "targetContracts": [], + "predeployedContracts": {}, "targetContractsBalances": [], "constructorArgs": {}, "deployerAddress": "0x30000", @@ -45,14 +47,17 @@ "optimizationTesting": { "enabled": true, "testPrefixes": ["optimize_"] - } + }, + "targetFunctionSignatures": [], + "excludeFunctionSignatures": [] }, "chainConfig": { "codeSizeCheckDisabled": true, "cheatCodes": { "cheatCodesEnabled": true, "enableFFI": false - } + }, + "skipAccountChecks": true } }, "compilation": { diff --git a/fuzzing/calls/call_sequence_execution.go b/fuzzing/calls/call_sequence_execution.go index ca983f0d..593465f4 100644 --- a/fuzzing/calls/call_sequence_execution.go +++ b/fuzzing/calls/call_sequence_execution.go @@ -169,7 +169,7 @@ func ExecuteCallSequence(chain *chain.TestChain, callSequence CallSequence) (Cal return ExecuteCallSequenceIteratively(chain, fetchElementFunc, nil) } -// ExecuteCallSequenceWithTracer attaches an executiontracer.ExecutionTracer to ExecuteCallSequenceIteratively and attaches execution traces to the call sequence elements. +// ExecuteCallSequenceWithExecutionTracer attaches an executiontracer.ExecutionTracer to ExecuteCallSequenceIteratively and attaches execution traces to the call sequence elements. func ExecuteCallSequenceWithExecutionTracer(testChain *chain.TestChain, contractDefinitions contracts.Contracts, callSequence CallSequence, verboseTracing bool) (CallSequence, error) { // Create a new execution tracer executionTracer := executiontracer.NewExecutionTracer(contractDefinitions, testChain.CheatCodeContracts()) diff --git a/fuzzing/config/config.go b/fuzzing/config/config.go index e488238c..1ccbfb61 100644 --- a/fuzzing/config/config.go +++ b/fuzzing/config/config.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "errors" + "fmt" "math/big" "os" @@ -60,6 +61,9 @@ type FuzzingConfig struct { // CoverageEnabled describes whether to use coverage-guided fuzzing CoverageEnabled bool `json:"coverageEnabled"` + // CoverageFormats indicate which reports to generate: "lcov" and "html" are supported. + CoverageFormats []string `json:"coverageFormats"` + // TargetContracts are the target contracts for fuzz testing TargetContracts []string `json:"targetContracts"` @@ -391,6 +395,15 @@ func (p *ProjectConfig) Validate() error { } } + // The coverage report format must be either "lcov" or "html" + if p.Fuzzing.CoverageFormats != nil { + for _, report := range p.Fuzzing.CoverageFormats { + if report != "lcov" && report != "html" { + return fmt.Errorf("project configuration must specify only valid coverage reports (lcov, html): %s", report) + } + } + } + // Ensure that the log level is a valid one level, err := zerolog.ParseLevel(p.Logging.Level.String()) if err != nil || level == zerolog.FatalLevel { diff --git a/fuzzing/config/config_defaults.go b/fuzzing/config/config_defaults.go index 10f45dc1..38532a03 100644 --- a/fuzzing/config/config_defaults.go +++ b/fuzzing/config/config_defaults.go @@ -46,6 +46,7 @@ func GetDefaultProjectConfig(platform string) (*ProjectConfig, error) { ConstructorArgs: map[string]map[string]any{}, CorpusDirectory: "", CoverageEnabled: true, + CoverageFormats: []string{"html", "lcov"}, SenderAddresses: []string{ "0x10000", "0x20000", diff --git a/fuzzing/config/gen_fuzzing_config.go b/fuzzing/config/gen_fuzzing_config.go index 6a2784b7..47f780c5 100644 --- a/fuzzing/config/gen_fuzzing_config.go +++ b/fuzzing/config/gen_fuzzing_config.go @@ -23,6 +23,7 @@ func (f FuzzingConfig) MarshalJSON() ([]byte, error) { CallSequenceLength int `json:"callSequenceLength"` CorpusDirectory string `json:"corpusDirectory"` CoverageEnabled bool `json:"coverageEnabled"` + CoverageFormats []string `json:"coverageFormats"` TargetContracts []string `json:"targetContracts"` PredeployedContracts map[string]string `json:"predeployedContracts"` TargetContractsBalances []*hexutil.Big `json:"targetContractsBalances"` @@ -45,6 +46,7 @@ func (f FuzzingConfig) MarshalJSON() ([]byte, error) { enc.CallSequenceLength = f.CallSequenceLength enc.CorpusDirectory = f.CorpusDirectory enc.CoverageEnabled = f.CoverageEnabled + enc.CoverageFormats = f.CoverageFormats enc.TargetContracts = f.TargetContracts enc.PredeployedContracts = f.PredeployedContracts if f.TargetContractsBalances != nil { @@ -76,6 +78,7 @@ func (f *FuzzingConfig) UnmarshalJSON(input []byte) error { CallSequenceLength *int `json:"callSequenceLength"` CorpusDirectory *string `json:"corpusDirectory"` CoverageEnabled *bool `json:"coverageEnabled"` + CoverageFormats []string `json:"coverageFormats"` TargetContracts []string `json:"targetContracts"` PredeployedContracts map[string]string `json:"predeployedContracts"` TargetContractsBalances []*hexutil.Big `json:"targetContractsBalances"` @@ -117,6 +120,9 @@ func (f *FuzzingConfig) UnmarshalJSON(input []byte) error { if dec.CoverageEnabled != nil { f.CoverageEnabled = *dec.CoverageEnabled } + if dec.CoverageFormats != nil { + f.CoverageFormats = dec.CoverageFormats + } if dec.TargetContracts != nil { f.TargetContracts = dec.TargetContracts } diff --git a/fuzzing/corpus/corpus.go b/fuzzing/corpus/corpus.go index 8426f156..8a640298 100644 --- a/fuzzing/corpus/corpus.go +++ b/fuzzing/corpus/corpus.go @@ -4,10 +4,13 @@ import ( "bytes" "fmt" "math/big" + "os" "path/filepath" "sync" "time" + "github.com/crytic/medusa/utils" + "github.com/crytic/medusa/chain" "github.com/crytic/medusa/fuzzing/calls" "github.com/crytic/medusa/fuzzing/coverage" @@ -30,13 +33,8 @@ type Corpus struct { // coverageMaps describes the total code coverage known to be achieved across all corpus call sequences. coverageMaps *coverage.CoverageMaps - // mutableSequenceFiles represents a corpus directory with files which describe call sequences that should - // be used for mutations. - mutableSequenceFiles *corpusDirectory[calls.CallSequence] - - // immutableSequenceFiles represents a corpus directory with files which describe call sequences that should not be - // used for mutations. - immutableSequenceFiles *corpusDirectory[calls.CallSequence] + // callSequenceFiles represents a corpus directory with files that should be used for mutations. + callSequenceFiles *corpusDirectory[calls.CallSequence] // testResultSequenceFiles represents a corpus directory with files which describe call sequences that were flagged // to be saved by a test case provider. These are not used in mutations. @@ -66,8 +64,7 @@ func NewCorpus(corpusDirectory string) (*Corpus, error) { corpus := &Corpus{ storageDirectory: corpusDirectory, coverageMaps: coverage.NewCoverageMaps(), - mutableSequenceFiles: newCorpusDirectory[calls.CallSequence](""), - immutableSequenceFiles: newCorpusDirectory[calls.CallSequence](""), + callSequenceFiles: newCorpusDirectory[calls.CallSequence](""), testResultSequenceFiles: newCorpusDirectory[calls.CallSequence](""), unexecutedCallSequences: make([]calls.CallSequence, 0), logger: logging.GlobalLogger.NewSubLogger("module", "corpus"), @@ -75,16 +72,17 @@ func NewCorpus(corpusDirectory string) (*Corpus, error) { // If we have a corpus directory set, parse our call sequences. if corpus.storageDirectory != "" { - // Read mutable call sequences. - corpus.mutableSequenceFiles.path = filepath.Join(corpus.storageDirectory, "call_sequences", "mutable") - err = corpus.mutableSequenceFiles.readFiles("*.json") + // Migrate the legacy corpus structure + // Note that it is important to call this first since we want to move all the call sequence files before reading + // them into the corpus + err = corpus.migrateLegacyCorpus() if err != nil { return nil, err } - // Read immutable call sequences. - corpus.immutableSequenceFiles.path = filepath.Join(corpus.storageDirectory, "call_sequences", "immutable") - err = corpus.immutableSequenceFiles.readFiles("*.json") + // Read call sequences. + corpus.callSequenceFiles.path = filepath.Join(corpus.storageDirectory, "call_sequences") + err = corpus.callSequenceFiles.readFiles("*.json") if err != nil { return nil, err } @@ -100,26 +98,90 @@ func NewCorpus(corpusDirectory string) (*Corpus, error) { return corpus, nil } +// migrateLegacyCorpus is used to read in the legacy corpus standard where call sequences were stored in two separate +// directories (mutable/immutable). +func (c *Corpus) migrateLegacyCorpus() error { + // Check to see if the mutable and/or the immutable directories exist + callSequencePath := filepath.Join(c.storageDirectory, "call_sequences") + mutablePath := filepath.Join(c.storageDirectory, "call_sequences", "mutable") + immutablePath := filepath.Join(c.storageDirectory, "call_sequences", "immutable") + + // Only return an error if the error is something other than "filepath does not exist" + mutableDirInfo, err := os.Stat(mutablePath) + if err != nil && !os.IsNotExist(err) { + return err + } + immutableDirInfo, err := os.Stat(immutablePath) + if err != nil && !os.IsNotExist(err) { + return err + } + + // Return early if these directories do not exist + if mutableDirInfo == nil && immutableDirInfo == nil { + return nil + } + + // Now, we need to notify the user that we have detected a legacy structure + c.logger.Info("Migrating legacy corpus") + + // If the mutable directory exists, read in all the files and add them to the call sequence files + if mutableDirInfo != nil { + // Discover all corpus files in the given directory. + filePaths, err := filepath.Glob(filepath.Join(mutablePath, "*.json")) + if err != nil { + return err + } + + // Move each file from the mutable directory to the parent call_sequences directory + for _, filePath := range filePaths { + err = utils.MoveFile(filePath, filepath.Join(callSequencePath, filepath.Base(filePath))) + if err != nil { + return err + } + } + + // Delete the mutable directory + err = utils.DeleteDirectory(mutablePath) + if err != nil { + return err + } + } + + // If the immutable directory exists, read in all the files and add them to the call sequence files + if immutableDirInfo != nil { + // Discover all corpus files in the given directory. + filePaths, err := filepath.Glob(filepath.Join(immutablePath, "*.json")) + if err != nil { + return err + } + + // Move each file from the immutable directory to the parent call_sequences directory + for _, filePath := range filePaths { + err = utils.MoveFile(filePath, filepath.Join(callSequencePath, filepath.Base(filePath))) + if err != nil { + return err + } + } + + // Delete the immutable directory + err = utils.DeleteDirectory(immutablePath) + if err != nil { + return err + } + } + + return nil +} + // CoverageMaps exposes coverage details for all call sequences known to the corpus. func (c *Corpus) CoverageMaps() *coverage.CoverageMaps { return c.coverageMaps } -// CallSequenceEntryCount returns the total number of call sequences entries in the corpus, based on the provided filter -// flags. Some call sequences may not be valid for use if they fail validation when initializing the corpus. -// Returns the count of the requested call sequence entries. -func (c *Corpus) CallSequenceEntryCount(mutable bool, immutable bool, testResults bool) int { - count := 0 - if mutable { - count += len(c.mutableSequenceFiles.files) - } - if immutable { - count += len(c.immutableSequenceFiles.files) - } - if testResults { - count += len(c.testResultSequenceFiles.files) - } - return count +// CallSequenceEntryCount returns the total number of call sequences that increased coverage and also any test results +// that led to a failure. +func (c *Corpus) CallSequenceEntryCount() (int, int) { + return len(c.callSequenceFiles.files), len(c.testResultSequenceFiles.files) } // ActiveMutableSequenceCount returns the count of call sequences recorded in the corpus which have been validated @@ -302,18 +364,13 @@ func (c *Corpus) Initialize(baseTestChain *chain.TestChain, contractDefinitions return 0, 0, err } - err = c.initializeSequences(c.mutableSequenceFiles, testChain, deployedContracts, true) - if err != nil { - return 0, 0, err - } - - err = c.initializeSequences(c.immutableSequenceFiles, testChain, deployedContracts, false) + err = c.initializeSequences(c.callSequenceFiles, testChain, deployedContracts, true) if err != nil { return 0, 0, err } // Calculate corpus health metrics - corpusSequencesTotal := len(c.mutableSequenceFiles.files) + len(c.immutableSequenceFiles.files) + len(c.testResultSequenceFiles.files) + corpusSequencesTotal := len(c.callSequenceFiles.files) + len(c.testResultSequenceFiles.files) corpusSequencesActive := len(c.unexecutedCallSequences) return corpusSequencesActive, corpusSequencesTotal, nil @@ -411,17 +468,9 @@ func (c *Corpus) CheckSequenceCoverageAndUpdate(callSequence calls.CallSequence, } // If we had an increase in non-reverted or reverted coverage, we save the sequence. - // Note: We only want to save the sequence once. We're most interested if it can be used for mutations first. - if coverageUpdated { - // If we achieved new non-reverting coverage, save this sequence for mutation purposes. - err = c.addCallSequence(c.mutableSequenceFiles, callSequence, true, mutationChooserWeight, flushImmediately) - if err != nil { - return err - } - } else if revertedCoverageUpdated { - // If we did not achieve new successful coverage, but achieved an increase in reverted coverage, save this - // sequence for non-mutation purposes. - err = c.addCallSequence(c.immutableSequenceFiles, callSequence, false, mutationChooserWeight, flushImmediately) + if coverageUpdated || revertedCoverageUpdated { + // If we achieved new coverage, save this sequence for mutation purposes. + err = c.addCallSequence(c.callSequenceFiles, callSequence, true, mutationChooserWeight, flushImmediately) if err != nil { return err } @@ -470,8 +519,8 @@ func (c *Corpus) Flush() error { c.callSequencesLock.Lock() defer c.callSequencesLock.Unlock() - // Write mutation target call sequences. - err := c.mutableSequenceFiles.writeFiles() + // Write all coverage-increasing call sequences. + err := c.callSequenceFiles.writeFiles() if err != nil { return err } @@ -482,11 +531,5 @@ func (c *Corpus) Flush() error { return err } - // Write other call sequences. - err = c.immutableSequenceFiles.writeFiles() - if err != nil { - return err - } - return nil } diff --git a/fuzzing/corpus/corpus_test.go b/fuzzing/corpus/corpus_test.go index c49c904e..5997dbdd 100644 --- a/fuzzing/corpus/corpus_test.go +++ b/fuzzing/corpus/corpus_test.go @@ -23,7 +23,7 @@ func getMockSimpleCorpus(minSequences int, maxSequences, minBlocks int, maxBlock // Add the requested number of entries. numSequences := minSequences + (rand.Int() % (maxSequences - minSequences)) for i := 0; i < numSequences; i++ { - err := corpus.addCallSequence(corpus.mutableSequenceFiles, getMockCallSequence(minBlocks+(rand.Int()%(maxBlocks-minBlocks))), true, nil, false) + err := corpus.addCallSequence(corpus.callSequenceFiles, getMockCallSequence(minBlocks+(rand.Int()%(maxBlocks-minBlocks))), true, nil, false) if err != nil { return nil, err } @@ -31,7 +31,7 @@ func getMockSimpleCorpus(minSequences int, maxSequences, minBlocks int, maxBlock return corpus, nil } -// getMockSimpleCorpusEntry creates a mock CorpusCallSequence with numBlocks blocks for testing +// getMockCallSequence creates a mock CorpusCallSequence with numBlocks blocks for testing func getMockCallSequence(size int) calls.CallSequence { cs := make(calls.CallSequence, size) for i := 0; i < size; i++ { @@ -40,7 +40,7 @@ func getMockCallSequence(size int) calls.CallSequence { return cs } -// getMockSimpleBlockBlock creates a mock CorpusBlock with numTransactions transactions and receipts for testing +// getMockCallSequenceElement creates a mock CorpusBlock with numTransactions transactions and receipts for testing func getMockCallSequenceElement() *calls.CallSequenceElement { return &calls.CallSequenceElement{ Contract: nil, @@ -100,9 +100,9 @@ func TestCorpusReadWrite(t *testing.T) { assert.NoError(t, err) // Ensure that there are the correct number of call sequence files - matches, err := filepath.Glob(filepath.Join(corpus.mutableSequenceFiles.path, "*.json")) + matches, err := filepath.Glob(filepath.Join(corpus.callSequenceFiles.path, "*.json")) assert.NoError(t, err) - assert.EqualValues(t, len(corpus.mutableSequenceFiles.files), len(matches)) + assert.EqualValues(t, len(corpus.callSequenceFiles.files), len(matches)) // Wipe corpus clean so that you can now read it in from disk corpus, err = NewCorpus("corpus") @@ -124,7 +124,7 @@ func TestCorpusCallSequenceMarshaling(t *testing.T) { // Run the test in our temporary test directory to avoid artifact pollution. testutils.ExecuteInDirectory(t, t.TempDir(), func() { // For each entry, marshal it and then unmarshal the byte array - for _, entryFile := range corpus.mutableSequenceFiles.files { + for _, entryFile := range corpus.callSequenceFiles.files { // Marshal the entry b, err := json.Marshal(entryFile.data) assert.NoError(t, err) @@ -139,9 +139,9 @@ func TestCorpusCallSequenceMarshaling(t *testing.T) { } // Remove all items - for i := 0; i < len(corpus.mutableSequenceFiles.files); { - corpus.mutableSequenceFiles.removeFile(corpus.mutableSequenceFiles.files[i].fileName) + for i := 0; i < len(corpus.callSequenceFiles.files); { + corpus.callSequenceFiles.removeFile(corpus.callSequenceFiles.files[i].fileName) } - assert.Empty(t, corpus.mutableSequenceFiles.files) + assert.Empty(t, corpus.callSequenceFiles.files) }) } diff --git a/fuzzing/coverage/coverage_tracer.go b/fuzzing/coverage/coverage_tracer.go index 2f1a47e9..0cbe0785 100644 --- a/fuzzing/coverage/coverage_tracer.go +++ b/fuzzing/coverage/coverage_tracer.go @@ -52,6 +52,13 @@ type CoverageTracer struct { // nativeTracer is the underlying tracer used to capture EVM execution. nativeTracer *chain.TestChainTracer + + // codeHashCache is a cache for values returned by getContractCoverageMapHash, + // so that this expensive calculation doesn't need to be done every opcode. + // The [2] array is to differentiate between contract init (0) vs runtime (1), + // since init vs runtime produces different results from getContractCoverageMapHash. + // The Hash key is a contract's codehash, which uniquely identifies it. + codeHashCache [2]map[common.Hash]common.Hash } // coverageTracerCallFrameState tracks state across call frames in the tracer. @@ -71,6 +78,7 @@ func NewCoverageTracer() *CoverageTracer { tracer := &CoverageTracer{ coverageMaps: NewCoverageMaps(), callFrameStates: make([]*coverageTracerCallFrameState, 0), + codeHashCache: [2]map[common.Hash]common.Hash{make(map[common.Hash]common.Hash), make(map[common.Hash]common.Hash)}, } nativeTracer := &tracers.Tracer{ Hooks: &tracing.Hooks{ @@ -159,11 +167,23 @@ func (t *CoverageTracer) OnOpcode(pc uint64, op byte, gas, cost uint64, scope tr scopeContext := scope.(*vm.ScopeContext) code := scopeContext.Contract.Code codeSize := len(code) + isCreate := callFrameState.create + gethCodeHash := scopeContext.Contract.CodeHash + + cacheArrayKey := 1 + if isCreate { + cacheArrayKey = 0 + } + if codeSize > 0 { // Obtain our contract coverage map lookup hash. if callFrameState.lookupHash == nil { - lookupHash := getContractCoverageMapHash(code, callFrameState.create) + lookupHash, cacheHit := t.codeHashCache[cacheArrayKey][gethCodeHash] + if !cacheHit { + lookupHash = getContractCoverageMapHash(code, isCreate) + t.codeHashCache[cacheArrayKey][gethCodeHash] = lookupHash + } callFrameState.lookupHash = &lookupHash } diff --git a/fuzzing/coverage/report_generation.go b/fuzzing/coverage/report_generation.go index c9bc2da1..b5125ea7 100644 --- a/fuzzing/coverage/report_generation.go +++ b/fuzzing/coverage/report_generation.go @@ -3,14 +3,14 @@ package coverage import ( _ "embed" "fmt" - "github.com/crytic/medusa/compilation/types" - "github.com/crytic/medusa/utils" "html/template" "math" "os" "path/filepath" "strconv" "time" + + "github.com/crytic/medusa/utils" ) var ( @@ -18,26 +18,8 @@ var ( htmlReportTemplate []byte ) -// GenerateReport takes a set of CoverageMaps and compilations, and produces a coverage report using them, detailing -// all source mapped ranges of the source files which were covered or not. -// Returns an error if one occurred. -func GenerateReport(compilations []types.Compilation, coverageMaps *CoverageMaps, htmlReportPath string) error { - // Perform source analysis. - sourceAnalysis, err := AnalyzeSourceCoverage(compilations, coverageMaps) - if err != nil { - return err - } - - // Finally, export the report data we analyzed. - if htmlReportPath != "" { - err = exportCoverageReport(sourceAnalysis, htmlReportPath) - } - return err -} - -// exportCoverageReport takes a previously performed source analysis and generates an HTML coverage report from it. -// Returns an error if one occurs. -func exportCoverageReport(sourceAnalysis *SourceAnalysis, outputPath string) error { +// WriteHTMLReport takes a previously performed source analysis and generates an HTML coverage report from it. +func WriteHTMLReport(sourceAnalysis *SourceAnalysis, reportDir string) (string, error) { // Define mappings onto some useful variables/functions. functionMap := template.FuncMap{ "timeNow": time.Now, @@ -79,21 +61,21 @@ func exportCoverageReport(sourceAnalysis *SourceAnalysis, outputPath string) err // Parse our HTML template tmpl, err := template.New("coverage_report.html").Funcs(functionMap).Parse(string(htmlReportTemplate)) if err != nil { - return fmt.Errorf("could not export report, failed to parse report template: %v", err) + return "", fmt.Errorf("could not export report, failed to parse report template: %v", err) } - // If the parent directory doesn't exist, create it. - parentDirectory := filepath.Dir(outputPath) - err = utils.MakeDirectory(parentDirectory) + // If the directory doesn't exist, create it. + err = utils.MakeDirectory(reportDir) if err != nil { - return err + return "", err } // Create our report file - file, err := os.Create(outputPath) + htmlReportPath := filepath.Join(reportDir, "coverage_report.html") + file, err := os.Create(htmlReportPath) if err != nil { _ = file.Close() - return fmt.Errorf("could not export report, failed to open file for writing: %v", err) + return "", fmt.Errorf("could not export report, failed to open file for writing: %v", err) } // Execute the template and write it back to file. @@ -102,5 +84,26 @@ func exportCoverageReport(sourceAnalysis *SourceAnalysis, outputPath string) err if err == nil { err = fileCloseErr } - return err + return htmlReportPath, err +} + +// WriteLCOVReport takes a previously performed source analysis and generates an LCOV report from it. +func WriteLCOVReport(sourceAnalysis *SourceAnalysis, reportDir string) (string, error) { + // Generate the LCOV report. + lcovReport := sourceAnalysis.GenerateLCOVReport() + + // If the directory doesn't exist, create it. + err := utils.MakeDirectory(reportDir) + if err != nil { + return "", err + } + + // Write the LCOV report to a file. + lcovReportPath := filepath.Join(reportDir, "lcov.info") + err = os.WriteFile(lcovReportPath, []byte(lcovReport), 0644) + if err != nil { + return "", fmt.Errorf("could not export LCOV report: %v", err) + } + + return lcovReportPath, nil } diff --git a/fuzzing/coverage/source_analysis.go b/fuzzing/coverage/source_analysis.go index 7705d2c1..98c4bc75 100644 --- a/fuzzing/coverage/source_analysis.go +++ b/fuzzing/coverage/source_analysis.go @@ -2,6 +2,7 @@ package coverage import ( "bytes" + "encoding/json" "fmt" "sort" @@ -55,13 +56,78 @@ func (s *SourceAnalysis) CoveredLineCount() int { return count } +// GenerateLCOVReport generates an LCOV report from the source analysis. +// The spec of the format is here https://github.com/linux-test-project/lcov/blob/07a1127c2b4390abf4a516e9763fb28a956a9ce4/man/geninfo.1#L989 +func (s *SourceAnalysis) GenerateLCOVReport() string { + var linesHit, linesInstrumented int + var buffer bytes.Buffer + buffer.WriteString("TN:\n") + for _, file := range s.SortedFiles() { + // SF: + buffer.WriteString(fmt.Sprintf("SF:%s\n", file.Path)) + for idx, line := range file.Lines { + if line.IsActive { + // DA:, + if line.IsCovered { + buffer.WriteString(fmt.Sprintf("DA:%d,%d\n", idx+1, line.SuccessHitCount)) + linesHit++ + } else { + buffer.WriteString(fmt.Sprintf("DA:%d,%d\n", idx+1, 0)) + } + linesInstrumented++ + } + } + // FN:, + // FNDA:, + for _, fn := range file.Functions { + byteStart := types.GetSrcMapStart(fn.Src) + length := types.GetSrcMapLength(fn.Src) + + startLine := sort.Search(len(file.CumulativeOffsetByLine), func(i int) bool { + return file.CumulativeOffsetByLine[i] > byteStart + }) + endLine := sort.Search(len(file.CumulativeOffsetByLine), func(i int) bool { + return file.CumulativeOffsetByLine[i] > byteStart+length + }) + + // We are treating any line hit in the definition as a hit for the function. + hit := 0 + for i := startLine; i < endLine; i++ { + // index iz zero based, line numbers are 1 based + if file.Lines[i-1].IsActive && file.Lines[i-1].IsCovered { + hit = 1 + } + + } + + // TODO: handle fallback, receive, and constructor + if fn.Name != "" { + buffer.WriteString(fmt.Sprintf("FN:%d,%s\n", startLine, fn.Name)) + buffer.WriteString(fmt.Sprintf("FNDA:%d,%s\n", hit, fn.Name)) + } + + } + buffer.WriteString("end_of_record\n") + } + + return buffer.String() +} + // SourceFileAnalysis describes coverage information for a given source file. type SourceFileAnalysis struct { // Path describes the file path of the source file. This is kept here for access during report generation. Path string + // CumulativeOffsetByLine describes the cumulative byte offset for each line in the source file. + // For example, for a file with 5 lines, the list might look like: [0, 45, 98, 132, 189], where each number is the byte offset of the line's starting position + // This allows us to quickly determine which line a given byte offset falls within using a binary search. + CumulativeOffsetByLine []int + // Lines describes information about a given source line and its coverage. Lines []*SourceLineAnalysis + + // Functions is a list of functions defined in the source file + Functions []*types.FunctionDefinition } // ActiveLineCount returns the count of lines that are marked executable/active within the source file. @@ -130,13 +196,50 @@ func AnalyzeSourceCoverage(compilations []types.Compilation, coverageMaps *Cover return nil, fmt.Errorf("could not perform source code analysis, code was not cached for '%v'", sourcePath) } + lines, cumulativeOffset := parseSourceLines(compilation.SourceCode[sourcePath]) + funcs := make([]*types.FunctionDefinition, 0) + + var ast types.AST + b, err := json.Marshal(compilation.SourcePathToArtifact[sourcePath].Ast) + if err != nil { + return nil, fmt.Errorf("could not encode AST from sources: %v", err) + } + err = json.Unmarshal(b, &ast) + if err != nil { + return nil, fmt.Errorf("could not parse AST from sources: %v", err) + } + + for _, node := range ast.Nodes { + + if node.GetNodeType() == "FunctionDefinition" { + fn := node.(types.FunctionDefinition) + funcs = append(funcs, &fn) + } + if node.GetNodeType() == "ContractDefinition" { + contract := node.(types.ContractDefinition) + if contract.Kind == types.ContractKindInterface { + continue + } + for _, subNode := range contract.Nodes { + if subNode.GetNodeType() == "FunctionDefinition" { + fn := subNode.(types.FunctionDefinition) + funcs = append(funcs, &fn) + } + } + } + + } + // Obtain the parsed source code lines for this source. if _, ok := sourceAnalysis.Files[sourcePath]; !ok { sourceAnalysis.Files[sourcePath] = &SourceFileAnalysis{ - Path: sourcePath, - Lines: parseSourceLines(compilation.SourceCode[sourcePath]), + Path: sourcePath, + CumulativeOffsetByLine: cumulativeOffset, + Lines: lines, + Functions: funcs, } } + } } @@ -231,25 +334,26 @@ func analyzeContractSourceCoverage(compilation types.Compilation, sourceAnalysis // Obtain the source file this element maps to. if sourceFile, ok := sourceAnalysis.Files[sourcePath]; ok { // Mark all lines which fall within this range. - matchedSourceLine := false - for _, sourceLine := range sourceFile.Lines { - // Check if the line is within range - if sourceMapElement.Offset >= sourceLine.Start && sourceMapElement.Offset < sourceLine.End { - // Mark the line active/executable. - sourceLine.IsActive = true - - // Set its coverage state and increment hit counts - sourceLine.SuccessHitCount += succHitCount - sourceLine.RevertHitCount += revertHitCount - sourceLine.IsCovered = sourceLine.IsCovered || sourceLine.SuccessHitCount > 0 - sourceLine.IsCoveredReverted = sourceLine.IsCoveredReverted || sourceLine.RevertHitCount > 0 - - // Indicate we matched a source line, so when we stop matching sequentially, we know we can exit - // early. - matchedSourceLine = true - } else if matchedSourceLine { - break - } + start := sourceMapElement.Offset + + startLine := sort.Search(len(sourceFile.CumulativeOffsetByLine), func(i int) bool { + return sourceFile.CumulativeOffsetByLine[i] > start + }) + + // index iz zero based, line numbers are 1 based + sourceLine := sourceFile.Lines[startLine-1] + + // Check if the line is within range + if sourceMapElement.Offset < sourceLine.End { + // Mark the line active/executable. + sourceLine.IsActive = true + + // Set its coverage state and increment hit counts + sourceLine.SuccessHitCount += succHitCount + sourceLine.RevertHitCount += revertHitCount + sourceLine.IsCovered = sourceLine.IsCovered || sourceLine.SuccessHitCount > 0 + sourceLine.IsCoveredReverted = sourceLine.IsCoveredReverted || sourceLine.RevertHitCount > 0 + } } else { return fmt.Errorf("could not perform source code analysis, missing source '%v'", sourcePath) @@ -298,10 +402,11 @@ func filterSourceMaps(compilation types.Compilation, sourceMap types.SourceMap) // parseSourceLines splits the provided source code into SourceLineAnalysis objects. // Returns the SourceLineAnalysis objects. -func parseSourceLines(sourceCode []byte) []*SourceLineAnalysis { +func parseSourceLines(sourceCode []byte) ([]*SourceLineAnalysis, []int) { // Create our lines and a variable to track where our current line start offset is. var lines []*SourceLineAnalysis var lineStart int + var cumulativeOffset []int // Split the source code on new line characters sourceCodeLinesBytes := bytes.Split(sourceCode, []byte("\n")) @@ -317,9 +422,10 @@ func parseSourceLines(sourceCode []byte) []*SourceLineAnalysis { IsCovered: false, IsCoveredReverted: false, }) + cumulativeOffset = append(cumulativeOffset, int(lineStart)) lineStart = lineEnd } // Return the resulting lines - return lines + return lines, cumulativeOffset } diff --git a/fuzzing/executiontracer/execution_tracer.go b/fuzzing/executiontracer/execution_tracer.go index 46bfb01a..0b876c0c 100644 --- a/fuzzing/executiontracer/execution_tracer.go +++ b/fuzzing/executiontracer/execution_tracer.go @@ -110,6 +110,12 @@ func (t *ExecutionTracer) GetTrace(txHash common.Hash) *ExecutionTrace { // OnTxEnd is called upon the end of transaction execution, as defined by tracers.Tracer. func (t *ExecutionTracer) OnTxEnd(receipt *coretypes.Receipt, err error) { + // We avoid storing the trace for this transaction. An error should realistically only occur if we hit a block gas + // limit error. In this case, the transaction will be retried in the next block and we can retrieve the trace at + // that time. + if err != nil || receipt == nil { + return + } t.traceMap[receipt.TxHash] = t.trace } diff --git a/fuzzing/fuzzer.go b/fuzzing/fuzzer.go index 4fcc00d5..4d862dc0 100644 --- a/fuzzing/fuzzer.go +++ b/fuzzing/fuzzer.go @@ -409,6 +409,7 @@ func chainSetupFromCompilations(fuzzer *Fuzzer, testChain *chain.TestChain) (*ex fuzzer.config.Fuzzing.TargetContracts = []string{contract.Name()} found = true } else { + // TODO list options for the user to choose from return nil, fmt.Errorf("specify target contract(s)") } } @@ -419,10 +420,14 @@ func chainSetupFromCompilations(fuzzer *Fuzzer, testChain *chain.TestChain) (*ex // Ordering is important here (predeploys _then_ targets) so that you can have the same contract in both lists // while still being able to use the contract address overrides contractsToDeploy := make([]string, 0) + balances := make([]*big.Int, 0) for contractName := range fuzzer.config.Fuzzing.PredeployedContracts { contractsToDeploy = append(contractsToDeploy, contractName) + // Preserve index of target contract balances + balances = append(balances, big.NewInt(0)) } contractsToDeploy = append(contractsToDeploy, fuzzer.config.Fuzzing.TargetContracts...) + balances = append(balances, fuzzer.config.Fuzzing.TargetContractsBalances...) deployedContractAddr := make(map[string]common.Address) // Loop for all contracts to deploy @@ -460,8 +465,8 @@ func chainSetupFromCompilations(fuzzer *Fuzzer, testChain *chain.TestChain) (*ex // If our project config has a non-zero balance for this target contract, retrieve it contractBalance := big.NewInt(0) - if len(fuzzer.config.Fuzzing.TargetContractsBalances) > i { - contractBalance = new(big.Int).Set(fuzzer.config.Fuzzing.TargetContractsBalances[i]) + if len(balances) > i { + contractBalance = new(big.Int).Set(balances[i]) } // Create a message to represent our contract deployment (we let deployments consume the whole block @@ -758,8 +763,8 @@ func (f *Fuzzer) Start() error { // Initialize our coverage maps by measuring the coverage we get from the corpus. var corpusActiveSequences, corpusTotalSequences int - if f.corpus.CallSequenceEntryCount(true, true, true) > 0 { - f.logger.Info("Running call sequences in the corpus...") + if totalCallSequences, testResults := f.corpus.CallSequenceEntryCount(); totalCallSequences > 0 || testResults > 0 { + f.logger.Info("Running call sequences in the corpus") } startTime := time.Now() corpusActiveSequences, corpusTotalSequences, err = f.corpus.Initialize(baseTestChain, f.contractDefinitions) @@ -832,13 +837,33 @@ func (f *Fuzzer) Start() error { f.printExitingResults() // Finally, generate our coverage report if we have set a valid corpus directory. - if err == nil && f.config.Fuzzing.CorpusDirectory != "" { - coverageReportPath := filepath.Join(f.config.Fuzzing.CorpusDirectory, "coverage_report.html") - err = coverage.GenerateReport(f.compilations, f.corpus.CoverageMaps(), coverageReportPath) + if err == nil && len(f.config.Fuzzing.CoverageFormats) > 0 { + // Write to the default directory if we have no corpus directory set. + coverageReportDir := filepath.Join("crytic-export", "coverage") + if f.config.Fuzzing.CorpusDirectory != "" { + coverageReportDir = filepath.Join(f.config.Fuzzing.CorpusDirectory, "coverage") + } + sourceAnalysis, err := coverage.AnalyzeSourceCoverage(f.compilations, f.corpus.CoverageMaps()) + if err != nil { - f.logger.Error("Failed to generate coverage report", err) + f.logger.Error("Failed to analyze source coverage", err) } else { - f.logger.Info("Coverage report saved to file: ", colors.Bold, coverageReportPath, colors.Reset) + var path string + for _, reportType := range f.config.Fuzzing.CoverageFormats { + switch reportType { + case "html": + path, err = coverage.WriteHTMLReport(sourceAnalysis, coverageReportDir) + case "lcov": + path, err = coverage.WriteLCOVReport(sourceAnalysis, coverageReportDir) + default: + err = fmt.Errorf("unsupported coverage report type: %s", reportType) + } + if err != nil { + f.logger.Error(fmt.Sprintf("Failed to generate %s coverage report", reportType), err) + } else { + f.logger.Info(fmt.Sprintf("%s report(s) saved to: %s", reportType, path), colors.Bold, colors.Reset) + } + } } } diff --git a/fuzzing/fuzzer_test.go b/fuzzing/fuzzer_test.go index 5f56ca4c..06f0992b 100644 --- a/fuzzing/fuzzer_test.go +++ b/fuzzing/fuzzer_test.go @@ -403,6 +403,7 @@ func TestDeploymentsWithPredeploy(t *testing.T) { filePath: "testdata/contracts/deployments/predeploy_contract.sol", configUpdates: func(config *config.ProjectConfig) { config.Fuzzing.TargetContracts = []string{"TestContract"} + config.Fuzzing.TargetContractsBalances = []*big.Int{big.NewInt(1)} config.Fuzzing.TestLimit = 1000 // this test should expose a failure immediately config.Fuzzing.Testing.PropertyTesting.Enabled = false config.Fuzzing.Testing.OptimizationTesting.Enabled = false @@ -443,7 +444,7 @@ func TestDeploymentsWithPayableConstructors(t *testing.T) { }) } -// TestDeploymentsInnerDeployments runs a test to ensure dynamically deployed contracts are detected by the Fuzzer and +// TestDeploymentsSelfDestruct runs a test to ensure dynamically deployed contracts are detected by the Fuzzer and // their properties are tested appropriately. func TestDeploymentsSelfDestruct(t *testing.T) { // These contracts provide functions to deploy inner contracts which have properties that will produce a failure. @@ -825,7 +826,8 @@ func TestCorpusReplayability(t *testing.T) { // Cache current coverage maps originalCoverage := f.fuzzer.corpus.CoverageMaps() - originalCorpusSequenceCount := f.fuzzer.corpus.CallSequenceEntryCount(true, true, true) + originalTotalCallSequences, originalTotalTestResults := f.fuzzer.corpus.CallSequenceEntryCount() + originalCorpusSequenceCount := originalTotalCallSequences + originalTotalTestResults // Next, set the fuzzer worker count to one, this allows us to count the call sequences executed before // solving a problem. We will verify the problem is solved with less or equal sequences tested, than diff --git a/fuzzing/fuzzer_test_methods_test.go b/fuzzing/fuzzer_test_methods_test.go index a022551b..80a7f152 100644 --- a/fuzzing/fuzzer_test_methods_test.go +++ b/fuzzing/fuzzer_test_methods_test.go @@ -81,7 +81,7 @@ func assertFailedTestsExpected(f *fuzzerTestContext, expectFailure bool) { // corpus. It asserts that the actual result matches the provided expected result. func assertCorpusCallSequencesCollected(f *fuzzerTestContext, expectCallSequences bool) { // Obtain our count of mutable (often representing just non-reverted coverage increasing) sequences. - callSequenceCount := f.fuzzer.corpus.CallSequenceEntryCount(true, false, false) + callSequenceCount, _ := f.fuzzer.corpus.CallSequenceEntryCount() // Ensure we captured some coverage-increasing call sequences. if expectCallSequences { diff --git a/fuzzing/fuzzer_worker.go b/fuzzing/fuzzer_worker.go index dcac1d0f..8ddeb78c 100644 --- a/fuzzing/fuzzer_worker.go +++ b/fuzzing/fuzzer_worker.go @@ -159,6 +159,8 @@ func (fw *FuzzerWorker) getNewCorpusCallSequenceWeight() *big.Int { func (fw *FuzzerWorker) onChainContractDeploymentAddedEvent(event chain.ContractDeploymentsAddedEvent) error { // Do not track the deployed contract if the contract deployment was a dynamic one and testAllContracts is false if !fw.fuzzer.config.Fuzzing.Testing.TestAllContracts && event.DynamicDeployment { + // Add the contract address to our value set so our generator can use it in calls. + fw.valueSet.AddAddress(event.Contract.Address) return nil } diff --git a/fuzzing/fuzzer_worker_sequence_generator.go b/fuzzing/fuzzer_worker_sequence_generator.go index 666efb23..b0bd3557 100644 --- a/fuzzing/fuzzer_worker_sequence_generator.go +++ b/fuzzing/fuzzer_worker_sequence_generator.go @@ -321,6 +321,10 @@ func (g *CallSequenceGenerator) generateNewElement() (*calls.CallSequenceElement InputValues: args, }) + if g.worker.fuzzer.config.Fuzzing.TestChainConfig.SkipAccountChecks { + msg.SkipAccountChecks = true + } + // Determine our delay values for this element blockNumberDelay := uint64(0) blockTimestampDelay := uint64(0) diff --git a/fuzzing/testdata/contracts/deployments/predeploy_contract.sol b/fuzzing/testdata/contracts/deployments/predeploy_contract.sol index 12ceef8f..bca678e4 100644 --- a/fuzzing/testdata/contracts/deployments/predeploy_contract.sol +++ b/fuzzing/testdata/contracts/deployments/predeploy_contract.sol @@ -7,6 +7,8 @@ contract PredeployContract { contract TestContract { PredeployContract predeploy = PredeployContract(address(0x1234)); + constructor() payable {} + function testPredeploy() public { predeploy.triggerFailure(); } diff --git a/go.mod b/go.mod index f5a85d40..3bae89ac 100644 --- a/go.mod +++ b/go.mod @@ -88,4 +88,4 @@ require ( rsc.io/tmplfunc v0.0.3 // indirect ) -replace github.com/ethereum/go-ethereum => github.com/crytic/medusa-geth v0.0.0-20240708141007-2f7f9258289f +replace github.com/ethereum/go-ethereum => github.com/crytic/medusa-geth v0.0.0-20240919134035-0fd368c28419 diff --git a/go.sum b/go.sum index d01119b9..6c8f950d 100644 --- a/go.sum +++ b/go.sum @@ -46,8 +46,8 @@ github.com/crate-crypto/go-ipa v0.0.0-20240223125850-b1e8a79f509c/go.mod h1:geZJ github.com/crate-crypto/go-kzg-4844 v1.0.0 h1:TsSgHwrkTKecKJ4kadtHi4b3xHW5dCFUDFnUp1TsawI= github.com/crate-crypto/go-kzg-4844 v1.0.0/go.mod h1:1kMhvPgI0Ky3yIa+9lFySEBUBXkYxeOi8ZF1sYioxhc= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/crytic/medusa-geth v0.0.0-20240708141007-2f7f9258289f h1:fxAlt4nFXa2WhoGVXbPezydKLFKx0mDRD4voT/xPcF4= -github.com/crytic/medusa-geth v0.0.0-20240708141007-2f7f9258289f/go.mod h1:hglUZo/5pVIYXNyYjWzsAUDpT/zI+WbWo/Nih7ot+G0= +github.com/crytic/medusa-geth v0.0.0-20240919134035-0fd368c28419 h1:MJXzWPObZtF0EMRqX64JkzJDj+GMLPxg3XK5xb12FFU= +github.com/crytic/medusa-geth v0.0.0-20240919134035-0fd368c28419/go.mod h1:ajGCVsk6ctffGwe9TSDQqj4HIUUQ1WdUit5tWFNl8Tw= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/utils/fs_utils.go b/utils/fs_utils.go index df656bc6..5a68150e 100644 --- a/utils/fs_utils.go +++ b/utils/fs_utils.go @@ -79,6 +79,35 @@ func CopyFile(sourcePath string, targetPath string) error { return os.Chmod(targetPath, sourceInfo.Mode()) } +// MoveFile will move a given file from the source path to the target path. Returns an error if one occured. +func MoveFile(sourcePath string, targetPath string) error { + // Obtain file info for the source file + sourceInfo, err := os.Stat(sourcePath) + if err != nil { + return err + } + + // If the path refers to a directory, return an error + if sourceInfo.IsDir() { + return fmt.Errorf("could not copy file from '%s' to '%s' because the source path refers to a directory", sourcePath, targetPath) + } + + // Ensure the existence of the directory we wish to copy to. + targetDirectory := filepath.Dir(targetPath) + err = os.MkdirAll(targetDirectory, 0777) + if err != nil { + return err + } + + // Move the file from the source path to the target path + err = os.Rename(sourcePath, targetPath) + if err != nil { + return err + } + + return nil +} + // GetFileNameWithoutExtension obtains a filename without the extension. This does not contain any preceding directory // paths. func GetFileNameWithoutExtension(filePath string) string {