Skip to content

Commit

Permalink
feat: add fuzzer for Noir programs (#5251)
Browse files Browse the repository at this point in the history
# Description

## Problem\*

Step towards #5249 

## Summary\*

This PR adds a very simple fuzzer which can be used to find inputs to
Noir programs which fail to execute. The motivation for this is to
eventually allow `nargo test` to run on noir functions with arguments
and automatically fuzz them in a similar fashion to `forge test`.

It's currently very hit and miss on how quickly it can zero in on
failing cases. For example, the program below is near-unfuzzable
currently.

```rust
fn main(x: u32, y: u32) {
    assert(x != y);
}
```

## Additional Context



## Documentation\*

Check one:
- [ ] No documentation needed.
- [ ] Documentation included in this PR.
- [x] **[For Experimental Features]** Documentation to be submitted in a
separate PR.

# PR Checklist\*

- [x] I have tested the changes locally.
- [x] I have formatted the changes with [Prettier](https://prettier.io/)
and/or `cargo fmt` on default settings.
  • Loading branch information
TomAFrench authored Jun 18, 2024
1 parent 6cbe6a0 commit e100017
Show file tree
Hide file tree
Showing 16 changed files with 524 additions and 45 deletions.
14 changes: 14 additions & 0 deletions Cargo.lock

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

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ members = [
# Crates related to tooling built on top of the Noir compiler
"tooling/lsp",
"tooling/debugger",
"tooling/fuzzer",
"tooling/nargo",
"tooling/nargo_fmt",
"tooling/nargo_cli",
Expand Down Expand Up @@ -69,6 +70,7 @@ noirc_frontend = { path = "compiler/noirc_frontend" }
noirc_printable_type = { path = "compiler/noirc_printable_type" }

# Noir tooling workspace dependencies
noir_fuzzer = { path = "tooling/fuzzer" }
nargo = { path = "tooling/nargo" }
nargo_fmt = { path = "tooling/nargo_fmt" }
nargo_toml = { path = "tooling/nargo_toml" }
Expand Down
9 changes: 0 additions & 9 deletions compiler/noirc_frontend/src/elaborator/lints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,6 @@ pub(super) fn recursive_non_entrypoint_function(
}
}

/// Test functions cannot have arguments in order to be executable.
pub(super) fn test_function_with_args(func: &NoirFunction) -> Option<ResolverError> {
if func.attributes().is_test_function() && !func.parameters().is_empty() {
Some(ResolverError::TestFunctionHasParameters { span: func.name_ident().span() })
} else {
None
}
}

/// Check that we are not passing a mutable reference from a constrained runtime to an unconstrained runtime.
pub(super) fn unconstrained_function_args(
function_args: &[(Type, ExprId, Span)],
Expand Down
1 change: 0 additions & 1 deletion compiler/noirc_frontend/src/elaborator/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -581,7 +581,6 @@ impl<'context> Elaborator<'context> {
self.run_lint(|elaborator| {
lints::low_level_function_outside_stdlib(func, elaborator.crate_id).map(Into::into)
});
self.run_lint(|_| lints::test_function_with_args(func).map(Into::into));
self.run_lint(|_| {
lints::recursive_non_entrypoint_function(func, is_entry_point).map(Into::into)
});
Expand Down
10 changes: 1 addition & 9 deletions compiler/noirc_frontend/src/hir/resolution/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ use crate::hir_def::expr::{
use crate::hir_def::function::FunctionBody;
use crate::hir_def::traits::{Trait, TraitConstraint};
use crate::macros_api::SecondaryAttribute;
use crate::token::{Attributes, FunctionAttribute};
use crate::token::Attributes;
use regex::Regex;
use std::collections::{BTreeMap, BTreeSet, HashSet};
use std::rc::Rc;
Expand Down Expand Up @@ -1043,14 +1043,6 @@ impl<'a> Resolver<'a> {
});
}

if matches!(attributes.function, Some(FunctionAttribute::Test { .. }))
&& !parameters.is_empty()
{
self.push_err(ResolverError::TestFunctionHasParameters {
span: func.name_ident().span(),
});
}

let mut typ = Type::Function(parameter_types, return_type, Box::new(Type::Unit));

if !generics.is_empty() {
Expand Down
18 changes: 13 additions & 5 deletions noir_stdlib/src/uint128.nr
Original file line number Diff line number Diff line change
Expand Up @@ -319,13 +319,12 @@ mod tests {
use crate::uint128::{U128, pow64, pow63};

#[test]
fn test_not() {
let num = U128::from_u64s_le(0, 0);
fn test_not(lo: u64, hi: u64) {
let num = U128::from_u64s_le(lo, hi);
let not_num = num.not();

let max_u64: Field = pow64 - 1;
assert_eq(not_num.hi, max_u64);
assert_eq(not_num.lo, max_u64);
assert_eq(not_num.hi, (hi.not() as Field));
assert_eq(not_num.lo, (lo.not() as Field));

let not_not_num = not_num.not();
assert_eq(num, not_not_num);
Expand Down Expand Up @@ -493,6 +492,15 @@ mod tests {
let end = a.to_integer();
assert_eq(start, end);
}

#[test]
fn integer_conversions_fuzz(lo: u64, hi: u64) {
let start: Field = (lo as Field) + pow64 * (hi as Field);
let a = U128::from_integer(start);
let end = a.to_integer();
assert_eq(start, end);
}

#[test]
fn test_wrapping_mul() {
// 1*0==0
Expand Down
18 changes: 18 additions & 0 deletions tooling/fuzzer/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
[package]
name = "noir_fuzzer"
description = "A fuzzer for Noir programs"
version.workspace = true
authors.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
acvm.workspace = true
nargo.workspace = true
noirc_artifacts.workspace = true
noirc_abi.workspace = true
proptest.workspace = true
rand.workspace = true
93 changes: 93 additions & 0 deletions tooling/fuzzer/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
//! This module has been adapted from Foundry's fuzzing implementation for the EVM.
//! https://github.com/foundry-rs/foundry/blob/6a85dbaa62f1c305f31cab37781232913055ae28/crates/evm/evm/src/executors/fuzz/mod.rs#L40
//!
//! Code is used under the MIT license.
use acvm::{blackbox_solver::StubbedBlackBoxSolver, FieldElement};
use noirc_abi::InputMap;
use proptest::test_runner::{TestCaseError, TestError, TestRunner};

mod strategies;
mod types;

use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome, FuzzTestResult};

use noirc_artifacts::program::ProgramArtifact;

use nargo::ops::{execute_program, DefaultForeignCallExecutor};

/// An executor for Noir programs which which provides fuzzing support using [`proptest`].
///
/// After instantiation, calling `fuzz` will proceed to hammer the program with
/// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the
/// configuration which can be overridden via [environment variables](proptest::test_runner::Config)
pub struct FuzzedExecutor {
/// The program to be fuzzed
program: ProgramArtifact,

/// The fuzzer
runner: TestRunner,
}

impl FuzzedExecutor {
/// Instantiates a fuzzed executor given a testrunner
pub fn new(program: ProgramArtifact, runner: TestRunner) -> Self {
Self { program, runner }
}

/// Fuzzes the provided program.
pub fn fuzz(&self) -> FuzzTestResult {
let strategy = strategies::arb_input_map(&self.program.abi);

let run_result: Result<(), TestError<InputMap>> =
self.runner.clone().run(&strategy, |input_map| {
let fuzz_res = self.single_fuzz(input_map)?;

match fuzz_res {
FuzzOutcome::Case(_) => Ok(()),
FuzzOutcome::CounterExample(CounterExampleOutcome {
exit_reason: status,
..
}) => Err(TestCaseError::fail(status)),
}
});

match run_result {
Ok(()) => FuzzTestResult { success: true, reason: None, counterexample: None },

Err(TestError::Abort(reason)) => FuzzTestResult {
success: false,
reason: Some(reason.to_string()),
counterexample: None,
},
Err(TestError::Fail(reason, counterexample)) => {
let reason = reason.to_string();
let reason = if reason.is_empty() { None } else { Some(reason) };

FuzzTestResult { success: false, reason, counterexample: Some(counterexample) }
}
}
}

/// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
/// or a `CounterExampleOutcome`
pub fn single_fuzz(&self, input_map: InputMap) -> Result<FuzzOutcome, TestCaseError> {
let initial_witness = self.program.abi.encode(&input_map, None).unwrap();
let result = execute_program(
&self.program.bytecode,
initial_witness,
&StubbedBlackBoxSolver,
&mut DefaultForeignCallExecutor::<FieldElement>::new(false, None),
);

// TODO: Add handling for `vm.assume` equivalent

match result {
Ok(_) => Ok(FuzzOutcome::Case(CaseOutcome { case: input_map })),
Err(err) => Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
exit_reason: err.to_string(),
counterexample: input_map,
})),
}
}
}
83 changes: 83 additions & 0 deletions tooling/fuzzer/src/strategies/int.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
use proptest::{
strategy::{NewTree, Strategy},
test_runner::TestRunner,
};
use rand::Rng;

/// Strategy for signed ints (up to i128).
/// The strategy combines 2 different strategies, each assigned a specific weight:
/// 1. Generate purely random value in a range. This will first choose bit size uniformly (up `bits`
/// param). Then generate a value for this bit size.
/// 2. Generate a random value around the edges (+/- 3 around min, 0 and max possible value)
#[derive(Debug)]
pub struct IntStrategy {
/// Bit size of int (e.g. 128)
bits: usize,
/// The weight for edge cases (+/- 3 around 0 and max possible value)
edge_weight: usize,
/// The weight for purely random values
random_weight: usize,
}

impl IntStrategy {
/// Create a new strategy.
/// # Arguments
/// * `bits` - Size of int in bits
pub fn new(bits: usize) -> Self {
Self { bits, edge_weight: 10usize, random_weight: 50usize }
}

fn generate_edge_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
let rng = runner.rng();

let offset = rng.gen_range(0..4);
// Choose if we want values around min, -0, +0, or max
let kind = rng.gen_range(0..4);
let start = match kind {
0 => self.type_min() + offset,
1 => -offset - 1i128,
2 => offset,
3 => self.type_max() - offset,
_ => unreachable!(),
};
Ok(proptest::num::i128::BinarySearch::new(start))
}

fn generate_random_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
let rng = runner.rng();

let start: i128 = rng.gen_range(self.type_min()..=self.type_max());
Ok(proptest::num::i128::BinarySearch::new(start))
}

fn type_max(&self) -> i128 {
if self.bits < 128 {
(1i128 << (self.bits - 1)) - 1
} else {
i128::MAX
}
}

fn type_min(&self) -> i128 {
if self.bits < 128 {
-(1i128 << (self.bits - 1))
} else {
i128::MIN
}
}
}

impl Strategy for IntStrategy {
type Tree = proptest::num::i128::BinarySearch;
type Value = i128;

fn new_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
let total_weight = self.random_weight + self.edge_weight;
let bias = runner.rng().gen_range(0..total_weight);
// randomly select one of 2 strategies
match bias {
x if x < self.edge_weight => self.generate_edge_tree(runner),
_ => self.generate_random_tree(runner),
}
}
}
Loading

0 comments on commit e100017

Please sign in to comment.