Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Bytecode level coverage reporting #6563

Merged
merged 7 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ serde_json = { version = "1.0", features = ["arbitrary_precision"] }
toml = "0.8"
tracing = "0.1"
tracing-subscriber = "0.3"
evm-disassembler = "0.3"

axum = "0.6"
hyper = "0.14"
Expand Down
2 changes: 1 addition & 1 deletion crates/cast/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ ethers-core.workspace = true
ethers-providers.workspace = true

chrono.workspace = true
evm-disassembler = "0.3"
evm-disassembler.workspace = true
eyre.workspace = true
futures = "0.3"
hex.workspace = true
Expand Down
53 changes: 52 additions & 1 deletion crates/evm/coverage/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,16 @@
extern crate tracing;

use alloy_primitives::{Bytes, B256};
use foundry_compilers::sourcemap::SourceElement;
use semver::Version;
use std::{
collections::{BTreeMap, HashMap},
fmt::Display,
ops::{AddAssign, Deref, DerefMut},
};

use eyre::{Context, Result};

pub mod analysis;
pub mod anchors;

Expand All @@ -35,6 +38,10 @@ pub struct CoverageReport {
pub items: HashMap<Version, Vec<CoverageItem>>,
/// All item anchors for the codebase, keyed by their contract ID.
pub anchors: HashMap<ContractId, Vec<ItemAnchor>>,
/// All the bytecode hits for the codebase
pub bytecode_hits: HashMap<ContractId, HitMap>,
/// The bytecode -> source mappings
pub source_maps: HashMap<ContractId, (Vec<SourceElement>, Vec<SourceElement>)>,
}

impl CoverageReport {
Expand All @@ -49,6 +56,14 @@ impl CoverageReport {
self.source_paths_to_ids.get(&(version, path))
}

/// Add the source maps
pub fn add_source_maps(
&mut self,
source_maps: HashMap<ContractId, (Vec<SourceElement>, Vec<SourceElement>)>,
) {
self.source_maps.extend(source_maps);
}

/// Add coverage items to this report
pub fn add_items(&mut self, version: Version, items: Vec<CoverageItem>) {
self.items.entry(version).or_default().extend(items);
Expand Down Expand Up @@ -109,7 +124,20 @@ impl CoverageReport {
///
/// This function should only be called *after* all the relevant sources have been processed and
/// added to the map (see [add_source]).
pub fn add_hit_map(&mut self, contract_id: &ContractId, hit_map: &HitMap) {
pub fn add_hit_map(&mut self, contract_id: &ContractId, hit_map: &HitMap) -> Result<()> {
// Add bytecode level hits
let e = self
.bytecode_hits
.entry(contract_id.clone())
.or_insert_with(|| HitMap::new(hit_map.bytecode.clone()));
e.merge(hit_map).context(format!(
"contract_id {:?}, hash {}, hash {}",
contract_id,
e.bytecode.clone(),
hit_map.bytecode.clone(),
))?;

// Add source level hits
if let Some(anchors) = self.anchors.get(contract_id) {
for anchor in anchors {
if let Some(hits) = hit_map.hits.get(&anchor.instruction) {
Expand All @@ -121,6 +149,7 @@ impl CoverageReport {
}
}
}
Ok(())
}
}

Expand Down Expand Up @@ -174,6 +203,28 @@ impl HitMap {
pub fn hit(&mut self, pc: usize) {
*self.hits.entry(pc).or_default() += 1;
}

/// Merge another hitmap into this, assuming the bytecode is consistent
pub fn merge(&mut self, other: &HitMap) -> Result<(), eyre::Report> {
for (pc, hits) in &other.hits {
*self.hits.entry(*pc).or_default() += hits;
}
Ok(())
}

pub fn consistent_bytecode(&self, hm1: &HitMap, hm2: &HitMap) -> bool {
// Consider the bytecodes consistent if they are the same out as far as the
// recorded hits
let len1 = hm1.hits.last_key_value();
let len2 = hm2.hits.last_key_value();
if let (Some(len1), Some(len2)) = (len1, len2) {
let len = std::cmp::max(len1.0, len2.0);
let ok = hm1.bytecode.0[..*len] == hm2.bytecode.0[..*len];
println!("consistent_bytecode: {}, {}, {}, {}", ok, len1.0, len2.0, len);
return ok;
}
true
}
}

/// A unique identifier for a contract
Expand Down
1 change: 1 addition & 0 deletions crates/forge/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ strum = { version = "0.25", features = ["derive"] }
thiserror = "1"
tokio = { version = "1", features = ["time"] }
watchexec = "2.3.2"
evm-disassembler.workspace = true

# doc server
axum = { workspace = true, features = ["ws"] }
Expand Down
18 changes: 14 additions & 4 deletions crates/forge/bin/cmd/coverage.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use clap::{Parser, ValueEnum, ValueHint};
use eyre::{Context, Result};
use forge::{
coverage::{
analysis::SourceAnalyzer, anchors::find_anchors, ContractId, CoverageReport,
CoverageReporter, DebugReporter, ItemAnchor, LcovReporter, SummaryReporter,
analysis::SourceAnalyzer, anchors::find_anchors, BytecodeReporter, ContractId,
CoverageReport, CoverageReporter, DebugReporter, ItemAnchor, LcovReporter, SummaryReporter,
},
inspectors::CheatsConfig,
opts::EvmOpts,
Expand Down Expand Up @@ -161,6 +161,8 @@ impl CoverageArgs {
let mut versioned_asts: HashMap<Version, HashMap<usize, Ast>> = HashMap::new();
let mut versioned_sources: HashMap<Version, HashMap<usize, String>> = HashMap::new();
for (path, mut source_file, version) in sources.into_sources_with_version() {
report.add_source(version.clone(), source_file.id as usize, path.clone());

// Filter out dependencies
if project_paths.has_library_ancestor(std::path::Path::new(&path)) {
continue
Expand All @@ -180,7 +182,6 @@ impl CoverageArgs {
fs::read_to_string(&file)
.wrap_err("Could not read source code for analysis")?,
);
report.add_source(version, source_file.id as usize, path);
}
}

Expand Down Expand Up @@ -279,6 +280,8 @@ impl CoverageArgs {
report.add_anchors(anchors);
}

report.add_source_maps(source_maps);

Ok(report)
}

Expand Down Expand Up @@ -337,7 +340,7 @@ impl CoverageArgs {
contract_name: artifact_id.name.clone(),
},
&hits,
);
)?;
}
}

Expand All @@ -362,6 +365,12 @@ impl CoverageArgs {
.report(&report)
}
}
CoverageReportKind::Bytecode => {
let destdir = root.join("bytecode-coverage");
fs::create_dir_all(&destdir)?;
BytecodeReporter::new(root.clone(), destdir).report(&report)?;
Ok(())
}
CoverageReportKind::Debug => DebugReporter.report(&report),
}?;
}
Expand All @@ -380,6 +389,7 @@ pub enum CoverageReportKind {
Summary,
Lcov,
Debug,
Bytecode,
}

/// Helper function that will link references in unlinked bytecode to the 0 address.
Expand Down
109 changes: 108 additions & 1 deletion crates/forge/src/coverage.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
//! Coverage reports.

use comfy_table::{presets::ASCII_MARKDOWN, Attribute, Cell, Color, Row, Table};
use evm_disassembler::disassemble_bytes;
use foundry_common::fs;
pub use foundry_evm::coverage::*;
use std::io::Write;
use std::{
collections::{hash_map, HashMap},
io::Write,
path::PathBuf,
};

/// A coverage reporter.
pub trait CoverageReporter {
Expand Down Expand Up @@ -170,3 +176,104 @@ impl CoverageReporter for DebugReporter {
Ok(())
}
}

pub struct BytecodeReporter {
root: PathBuf,
destdir: PathBuf,
}

impl BytecodeReporter {
pub fn new(root: PathBuf, destdir: PathBuf) -> BytecodeReporter {
Self { root, destdir }
}
}

impl CoverageReporter for BytecodeReporter {
fn report(self, report: &CoverageReport) -> eyre::Result<()> {
use std::fmt::Write;

let no_source_elements = Vec::new();
let mut line_number_cache = LineNumberCache::new(self.root.clone());

for (contract_id, hits) in &report.bytecode_hits {
let ops = disassemble_bytes(hits.bytecode.to_vec())?;
let mut formatted = String::new();

let source_elements =
report.source_maps.get(contract_id).map(|sm| &sm.1).unwrap_or(&no_source_elements);

for (code, source_element) in std::iter::zip(ops.iter(), source_elements) {
let hits = hits
.hits
.get(&(code.offset as usize))
.map(|h| format!("[{:03}]", h))
.unwrap_or(" ".to_owned());
let source_id = source_element.index;
let source_path = source_id.and_then(|i| {
report.source_paths.get(&(contract_id.version.clone(), i as usize))
});

let code = format!("{:?}", code);
let start = source_element.offset;
let end = source_element.offset + source_element.length;

if let Some(source_path) = source_path {
let (sline, spos) = line_number_cache.get_position(source_path, start)?;
let (eline, epos) = line_number_cache.get_position(source_path, end)?;
writeln!(
formatted,
"{} {:40} // {}: {}:{}-{}:{} ({}-{})",
hits, code, source_path, sline, spos, eline, epos, start, end
)?;
} else if let Some(source_id) = source_id {
writeln!(
formatted,
"{} {:40} // SRCID{}: ({}-{})",
hits, code, source_id, start, end
)?;
} else {
writeln!(formatted, "{} {:40}", hits, code)?;
}
}
fs::write(
&self.destdir.join(contract_id.contract_name.clone()).with_extension("asm"),
formatted,
)?;
}

Ok(())
}
}

/// Cache line number offsets for source files
struct LineNumberCache {
root: PathBuf,
line_offsets: HashMap<String, Vec<usize>>,
}

impl LineNumberCache {
pub fn new(root: PathBuf) -> Self {
LineNumberCache { root, line_offsets: HashMap::new() }
}

pub fn get_position(&mut self, path: &str, offset: usize) -> eyre::Result<(usize, usize)> {
let line_offsets = match self.line_offsets.entry(path.to_owned()) {
hash_map::Entry::Occupied(o) => o.into_mut(),
hash_map::Entry::Vacant(v) => {
let text = fs::read_to_string(self.root.join(path))?;
let mut line_offsets = vec![0];
for line in text.lines() {
let line_offset = line.as_ptr() as usize - text.as_ptr() as usize;
line_offsets.push(line_offset);
}
v.insert(line_offsets)
}
};
let lo = match line_offsets.binary_search(&offset) {
Ok(lo) => lo,
Err(lo) => lo - 1,
};
let pos = offset - line_offsets.get(lo).unwrap() + 1;
Ok((lo, pos))
}
}