Skip to content

Commit

Permalink
feat: build simple dictionary from inspecting ACIR program (#5264)
Browse files Browse the repository at this point in the history
# Description

## Problem\*

Resolves #5262
## Summary\*

This PR pulls out a bunch of relevant constants from the ACIR/brillig
bytecode so we can feed these into the fuzzer's strategy to ensure
proper coverage of these values.

## Additional Context



## Documentation\*

Check one:
- [x] No documentation needed.
- [ ] Documentation included in this PR.
- [ ] **[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 20, 2024
1 parent 318314d commit 508e677
Show file tree
Hide file tree
Showing 6 changed files with 193 additions and 23 deletions.
5 changes: 5 additions & 0 deletions test_programs/noir_test_success/fuzzer_checks/Nargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[package]
name = "fuzzer_checks"
type = "bin"
authors = [""]
[dependencies]
6 changes: 6 additions & 0 deletions test_programs/noir_test_success/fuzzer_checks/src/main.nr
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@

#[test(should_fail_with = "42 is not allowed")]
fn finds_magic_value(x: u32) {
let x = x as u64;
assert(2 * x != 42, "42 is not allowed");
}
124 changes: 124 additions & 0 deletions tooling/fuzzer/src/dictionary/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
//! This module defines how to build a dictionary of values which are likely to be correspond
//! to significant inputs during fuzzing by inspecting the [Program] being fuzzed.
//!
//! This dictionary can be fed into the fuzzer's [strategy][proptest::strategy::Strategy] in order to bias it towards
//! generating these values to ensure they get proper coverage.
use std::collections::HashSet;

use acvm::{
acir::{
circuit::{
brillig::{BrilligBytecode, BrilligInputs},
directives::Directive,
opcodes::{BlackBoxFuncCall, FunctionInput},
Circuit, Opcode, Program,
},
native_types::Expression,
},
brillig_vm::brillig::Opcode as BrilligOpcode,
AcirField,
};

/// Constructs a [HashSet<F>] of values pulled from a [Program<F>] which are likely to be correspond
/// to significant inputs during fuzzing.
pub(super) fn build_dictionary_from_program<F: AcirField>(program: &Program<F>) -> HashSet<F> {
let constrained_dictionaries = program.functions.iter().map(build_dictionary_from_circuit);
let unconstrained_dictionaries =
program.unconstrained_functions.iter().map(build_dictionary_from_unconstrained_function);
let dictionaries = constrained_dictionaries.chain(unconstrained_dictionaries);

let mut constants: HashSet<F> = HashSet::new();
for dictionary in dictionaries {
constants.extend(dictionary);
}
constants
}

fn build_dictionary_from_circuit<F: AcirField>(circuit: &Circuit<F>) -> HashSet<F> {
let mut constants: HashSet<F> = HashSet::new();

fn insert_expr<F: AcirField>(dictionary: &mut HashSet<F>, expr: &Expression<F>) {
let quad_coefficients = expr.mul_terms.iter().map(|(k, _, _)| *k);
let linear_coefficients = expr.linear_combinations.iter().map(|(k, _)| *k);
let coefficients = linear_coefficients.chain(quad_coefficients);

dictionary.extend(coefficients.clone());
dictionary.insert(expr.q_c);

// We divide the constant term by any coefficients in the expression to aid solving constraints such as `2 * x - 4 == 0`.
let scaled_constants = coefficients.map(|coefficient| expr.q_c / coefficient);
dictionary.extend(scaled_constants);
}

fn insert_array_len<F: AcirField, T>(dictionary: &mut HashSet<F>, array: &[T]) {
let array_length = array.len() as u128;
dictionary.insert(F::from(array_length));
dictionary.insert(F::from(array_length - 1));
}

for opcode in &circuit.opcodes {
match opcode {
Opcode::AssertZero(expr)
| Opcode::Call { predicate: Some(expr), .. }
| Opcode::MemoryOp { predicate: Some(expr), .. }
| Opcode::Directive(Directive::ToLeRadix { a: expr, .. }) => {
insert_expr(&mut constants, expr)
}

Opcode::MemoryInit { init, .. } => insert_array_len(&mut constants, init),

Opcode::BrilligCall { inputs, predicate, .. } => {
for input in inputs {
match input {
BrilligInputs::Single(expr) => insert_expr(&mut constants, expr),
BrilligInputs::Array(exprs) => {
exprs.iter().for_each(|expr| insert_expr(&mut constants, expr));
insert_array_len(&mut constants, exprs);
}
BrilligInputs::MemoryArray(_) => (),
}
}
if let Some(predicate) = predicate {
insert_expr(&mut constants, predicate)
}
}

Opcode::BlackBoxFuncCall(BlackBoxFuncCall::RANGE {
input: FunctionInput { num_bits, .. },
}) => {
let field = 1u128.wrapping_shl(*num_bits);
constants.insert(F::from(field));
constants.insert(F::from(field - 1));
}
_ => (),
}
}

constants
}

fn build_dictionary_from_unconstrained_function<F: AcirField>(
function: &BrilligBytecode<F>,
) -> HashSet<F> {
let mut constants: HashSet<F> = HashSet::new();

for opcode in &function.bytecode {
match opcode {
BrilligOpcode::Cast { bit_size, .. } => {
let field = 1u128.wrapping_shl(*bit_size);
constants.insert(F::from(field));
constants.insert(F::from(field - 1));
}
BrilligOpcode::Const { bit_size, value, .. } => {
constants.insert(*value);

let field = 1u128.wrapping_shl(*bit_size);
constants.insert(F::from(field));
constants.insert(F::from(field - 1));
}
_ => (),
}
}

constants
}
5 changes: 4 additions & 1 deletion tooling/fuzzer/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,11 @@
//! Code is used under the MIT license.

use acvm::{blackbox_solver::StubbedBlackBoxSolver, FieldElement};
use dictionary::build_dictionary_from_program;
use noirc_abi::InputMap;
use proptest::test_runner::{TestCaseError, TestError, TestRunner};

mod dictionary;
mod strategies;
mod types;

Expand Down Expand Up @@ -37,7 +39,8 @@ impl FuzzedExecutor {

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

let run_result: Result<(), TestError<InputMap>> =
self.runner.clone().run(&strategy, |input_map| {
Expand Down
36 changes: 19 additions & 17 deletions tooling/fuzzer/src/strategies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,28 +5,22 @@ use proptest::prelude::*;
use acvm::{AcirField, FieldElement};

use noirc_abi::{input_parser::InputValue, Abi, AbiType, InputMap, Sign};
use std::collections::BTreeMap;
use std::collections::{BTreeMap, HashSet};
use uint::UintStrategy;

mod int;
mod uint;

proptest::prop_compose! {
pub(super) fn arb_field_from_integer(bit_size: u32)(value: u128)-> FieldElement {
let width = (bit_size % 128).clamp(1, 127);
let max_value = 2u128.pow(width) - 1;
let value = value % max_value;
FieldElement::from(value)
}
}

pub(super) fn arb_value_from_abi_type(abi_type: &AbiType) -> SBoxedStrategy<InputValue> {
pub(super) fn arb_value_from_abi_type(
abi_type: &AbiType,
dictionary: HashSet<FieldElement>,
) -> SBoxedStrategy<InputValue> {
match abi_type {
AbiType::Field => vec(any::<u8>(), 32)
.prop_map(|bytes| InputValue::Field(FieldElement::from_be_bytes_reduce(&bytes)))
.sboxed(),
AbiType::Integer { width, sign } if sign == &Sign::Unsigned => {
UintStrategy::new(*width as usize)
UintStrategy::new(*width as usize, dictionary)
.prop_map(|uint| InputValue::Field(uint.into()))
.sboxed()
}
Expand Down Expand Up @@ -55,15 +49,17 @@ pub(super) fn arb_value_from_abi_type(abi_type: &AbiType) -> SBoxedStrategy<Inpu
}
AbiType::Array { length, typ } => {
let length = *length as usize;
let elements = vec(arb_value_from_abi_type(typ), length..=length);
let elements = vec(arb_value_from_abi_type(typ, dictionary), length..=length);

elements.prop_map(InputValue::Vec).sboxed()
}

AbiType::Struct { fields, .. } => {
let fields: Vec<SBoxedStrategy<(String, InputValue)>> = fields
.iter()
.map(|(name, typ)| (Just(name.clone()), arb_value_from_abi_type(typ)).sboxed())
.map(|(name, typ)| {
(Just(name.clone()), arb_value_from_abi_type(typ, dictionary.clone())).sboxed()
})
.collect();

fields
Expand All @@ -75,17 +71,23 @@ pub(super) fn arb_value_from_abi_type(abi_type: &AbiType) -> SBoxedStrategy<Inpu
}

AbiType::Tuple { fields } => {
let fields: Vec<_> = fields.iter().map(arb_value_from_abi_type).collect();
let fields: Vec<_> =
fields.iter().map(|typ| arb_value_from_abi_type(typ, dictionary.clone())).collect();
fields.prop_map(InputValue::Vec).sboxed()
}
}
}

pub(super) fn arb_input_map(abi: &Abi) -> BoxedStrategy<InputMap> {
pub(super) fn arb_input_map(
abi: &Abi,
dictionary: HashSet<FieldElement>,
) -> BoxedStrategy<InputMap> {
let values: Vec<_> = abi
.parameters
.iter()
.map(|param| (Just(param.name.clone()), arb_value_from_abi_type(&param.typ)))
.map(|param| {
(Just(param.name.clone()), arb_value_from_abi_type(&param.typ, dictionary.clone()))
})
.collect();

values
Expand Down
40 changes: 35 additions & 5 deletions tooling/fuzzer/src/strategies/uint.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
use std::collections::HashSet;

use acvm::{AcirField, FieldElement};
use proptest::{
strategy::{NewTree, Strategy},
test_runner::TestRunner,
Expand All @@ -13,9 +16,12 @@ use rand::Rng;
pub struct UintStrategy {
/// Bit size of uint (e.g. 128)
bits: usize,

/// A set of fixtures to be generated
fixtures: Vec<FieldElement>,
/// The weight for edge cases (+/- 3 around 0 and max possible value)
edge_weight: usize,
/// The weight for fixtures
fixtures_weight: usize,
/// The weight for purely random values
random_weight: usize,
}
Expand All @@ -24,8 +30,15 @@ impl UintStrategy {
/// Create a new strategy.
/// # Arguments
/// * `bits` - Size of uint in bits
pub fn new(bits: usize) -> Self {
Self { bits, edge_weight: 10usize, random_weight: 50usize }
/// * `fixtures` - Set of `FieldElements` representing values which the fuzzer weight towards testing.
pub fn new(bits: usize, fixtures: HashSet<FieldElement>) -> Self {
Self {
bits,
fixtures: fixtures.into_iter().collect(),
edge_weight: 10usize,
fixtures_weight: 40usize,
random_weight: 50usize,
}
}

fn generate_edge_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
Expand All @@ -37,6 +50,22 @@ impl UintStrategy {
Ok(proptest::num::u128::BinarySearch::new(start))
}

fn generate_fixtures_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
// generate random cases if there's no fixtures
if self.fixtures.is_empty() {
return self.generate_random_tree(runner);
}

// Generate value tree from fixture.
let fixture = &self.fixtures[runner.rng().gen_range(0..self.fixtures.len())];
if fixture.num_bits() <= self.bits as u32 {
return Ok(proptest::num::u128::BinarySearch::new(fixture.to_u128()));
}

// If fixture is not a valid type, generate random value.
self.generate_random_tree(runner)
}

fn generate_random_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
let rng = runner.rng();
let start = rng.gen_range(0..=self.type_max());
Expand All @@ -57,11 +86,12 @@ impl Strategy for UintStrategy {
type Tree = proptest::num::u128::BinarySearch;
type Value = u128;
fn new_tree(&self, runner: &mut TestRunner) -> NewTree<Self> {
let total_weight = self.random_weight + self.edge_weight;
let total_weight = self.random_weight + self.fixtures_weight + self.edge_weight;
let bias = runner.rng().gen_range(0..total_weight);
// randomly select one of 2 strategies
// randomly select one of 3 strategies
match bias {
x if x < self.edge_weight => self.generate_edge_tree(runner),
x if x < self.edge_weight + self.fixtures_weight => self.generate_fixtures_tree(runner),
_ => self.generate_random_tree(runner),
}
}
Expand Down

0 comments on commit 508e677

Please sign in to comment.