Skip to content

Commit

Permalink
feat: LCOV report (#442)
Browse files Browse the repository at this point in the history
* feat: produce LCOV-style coverage report

* add function metadata to LCOV

* consider at least one line hit as function coverage

* add coverage report config to select between lcov and html

* add default path

* lint

* fix whitespace in default

* rename to coverageFormats

---------

Co-authored-by: Anish Naik <anish.naik@trailofbits.com>
  • Loading branch information
0xalpharush and anishnaik authored Sep 25, 2024
1 parent fc59d39 commit e018eb3
Show file tree
Hide file tree
Showing 11 changed files with 364 additions and 76 deletions.
2 changes: 1 addition & 1 deletion compilation/platforms/crytic_compile.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
2 changes: 1 addition & 1 deletion compilation/platforms/solc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
120 changes: 105 additions & 15 deletions compilation/types/ast.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand All @@ -78,31 +132,37 @@ 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
var contractDefinition ContractDefinition
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])
Expand All @@ -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
}
43 changes: 42 additions & 1 deletion docs/src/coverage_reports.md
Original file line number Diff line number Diff line change
@@ -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`.
7 changes: 7 additions & 0 deletions docs/src/project_configuration/fuzzing_config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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]`)
Expand Down
13 changes: 13 additions & 0 deletions fuzzing/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package config
import (
"encoding/json"
"errors"
"fmt"
"math/big"
"os"

Expand Down Expand Up @@ -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"`

Expand Down Expand Up @@ -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 {
Expand Down
1 change: 1 addition & 0 deletions fuzzing/config/config_defaults.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions fuzzing/config/gen_fuzzing_config.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit e018eb3

Please sign in to comment.