diff --git a/.github/workflows/fuzz.yml b/.github/workflows/fuzz.yml index 7b0a8316..85be41a2 100644 --- a/.github/workflows/fuzz.yml +++ b/.github/workflows/fuzz.yml @@ -9,6 +9,20 @@ on: pull_request: jobs: + test: + name: Test Fuzz Library + if: ${{ !github.event.act }} + runs-on: ubuntu-latest + steps: + - name: Checkout Crate + uses: actions/checkout@v4 + + - name: Install Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run Tests + run: cargo test -p simplicity-fuzz --lib + fuzz: name: Run Fuzz Target if: ${{ !github.event.act }} @@ -23,6 +37,7 @@ c_rust_merkle, decode_natural, decode_program, parse_human, +prune_generated, regression_286, regression_value, ] diff --git a/Cargo-recent.lock b/Cargo-recent.lock index 9a936051..13466a14 100644 --- a/Cargo-recent.lock +++ b/Cargo-recent.lock @@ -589,6 +589,7 @@ dependencies = [ name = "simplicity-fuzz" version = "0.0.1" dependencies = [ + "arbitrary", "base64 0.22.1", "libfuzzer-sys", "simplicity-lang 0.3.1", diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 7adbe83f..cc3a9620 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -13,6 +13,7 @@ cargo-fuzz = true path = "fuzz_lib/lib.rs" [dependencies] +arbitrary = "1.4.2" libfuzzer-sys = "0.4" # We shouldn't need an explicit version on the next line, but Andrew's tools # choke on it otherwise. See https://github.com/nix-community/crate2nix/issues/373 @@ -67,6 +68,13 @@ test = false doc = false bench = false +[[bin]] +name = "prune_generated" +path = "fuzz_targets/prune_generated.rs" +test = false +doc = false +bench = false + [[bin]] name = "regression_286" path = "fuzz_targets/regression_286.rs" diff --git a/fuzz/fuzz.sh b/fuzz/fuzz.sh index 69bb1102..82048524 100755 --- a/fuzz/fuzz.sh +++ b/fuzz/fuzz.sh @@ -22,5 +22,11 @@ rustc --version # Run fuzz target for targetFile in $targetFiles; do targetName=$(targetFileToName "$targetFile") - cargo-fuzz run "$targetName" -- -max_total_time=30 + # prune_generated builds a program from its input, and only long inputs give + # large programs, which libFuzzer otherwise tries only after a long time. + extraArgs=() + if [ "$targetName" == "prune_generated" ]; then + extraArgs=(-len_control=0 -max_len=4096) + fi + cargo-fuzz run "$targetName" -- -max_total_time=30 "${extraArgs[@]}" done diff --git a/fuzz/fuzz_lib/generator.rs b/fuzz/fuzz_lib/generator.rs new file mode 100644 index 00000000..3b1fe29e --- /dev/null +++ b/fuzz/fuzz_lib/generator.rs @@ -0,0 +1,504 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Type-directed program generator +//! +//! [`generate_program`] chooses a type first and then only combinators which can +//! have that type, so every program it produces is well-typed. It never generates +//! `fail`, assertions or jets which can fail, so every program also runs to +//! completion. Case nodes branch on witness data, constants and jet outputs, and +//! nodes are sometimes reused, so that pruning has branches to hide and nodes +//! shared between hidden and kept code. +//! +//! It reads its choices with [`Unstructured`] rather than [`crate::Extractor`]: +//! once the data runs out, `Unstructured` returns defaults rather than failing, so +//! every input gives a program. +//! +//! It does not generate: +//! +//! * separate nodes with one IHR but different types inside them. Pruning merges +//! nodes with equal IHRs before running a program, and only unit tests cover +//! programs which need that merge; +//! * `fail`, assertions or jets which can fail. Pruning turns hidden branches into +//! CMRs, so what they contain does not matter to it; +//! * jets which read the transaction environment, as libsimplicity runs the pruned +//! programs without one. + +use std::sync::Arc; + +use arbitrary::{Result, Unstructured}; +use simplicity::dag::{InternalSharing, PostOrderIterItem}; +use simplicity::jet::{Elements, Jet as _}; +use simplicity::node::{ + Construct, Converter, CoreConstructible as _, DisconnectConstructible as _, Inner, Redeem, + RedeemData, WitnessConstructible as _, +}; +use simplicity::types::{self, Final}; +use simplicity::{ConstructNode, RedeemNode, Value}; + +/// Maximum number of combinators that the generator chooses, which bounds the +/// size of the program to a small multiple of this. +const MAX_BUDGET: usize = 200; + +/// Maximum number of case nodes which run on two inputs. Each one doubles the +/// cost of the program, if it is nested in another. +const MAX_TWICE: usize = 2; + +/// Maximum bit width of a type for which a constant is scribed into the program. +const MAX_SCRIBE_WIDTH: usize = 16; + +/// Jets which cannot fail and do not read the transaction environment. +/// +/// These are Elements jets, rather than Core jets, because libsimplicity decodes +/// programs with the Elements jet codes. +const JETS: &[Elements] = &[ + Elements::Add8, + Elements::Add16, + Elements::Add32, + Elements::All8, + Elements::And8, + Elements::Ch8, + Elements::Complement8, + Elements::Complement32, + Elements::Eq1, + Elements::Eq8, + Elements::Eq16, + Elements::Eq32, + Elements::FullAdd8, + Elements::High8, + Elements::Increment8, + Elements::IsOne8, + Elements::IsZero8, + Elements::Le8, + Elements::LeftShift8, + Elements::Low8, + Elements::Lt8, + Elements::Lt32, + Elements::Maj8, + Elements::Max8, + Elements::Min8, + Elements::Multiply8, + Elements::Negate8, + Elements::One8, + Elements::Or8, + Elements::RightShift8, + Elements::Some8, + Elements::Subtract8, + Elements::Subtract32, + Elements::Xor8, + Elements::Xor32, +]; + +type Node<'brand> = Arc>; + +/// Generates a program of type 1 → 1, together with its witness data. +/// +/// Once `u` runs out of data the generator only makes cheap choices, so this +/// returns a program for any input. +pub fn generate_program(u: &mut Unstructured) -> Result> { + types::Context::with_context(|ctx| { + let mut gen = Generator { + budget: u.int_in_range(1..=MAX_BUDGET)?, + u, + ctx: &ctx, + pool: vec![], + twice: 0, + }; + let program = gen.root()?; + program + .set_arrow_to_program() + .expect("generated programs have type 1 → 1"); + program.convert::(&mut Finalizer { u: gen.u }) + }) +} + +/// Finalizes the types of a generated program, and generates a value of its +/// finalized type for each witness node, which only then is known. +struct Finalizer<'a, 'u> { + u: &'a mut Unstructured<'u>, +} + +impl<'brand> Converter, Redeem> for Finalizer<'_, '_> { + type Error = arbitrary::Error; + + fn convert_witness( + &mut self, + data: &PostOrderIterItem<&ConstructNode<'brand>>, + _: &Option, + ) -> Result { + let ty = data + .node + .arrow() + .target + .finalize() + .expect("generated programs are well-typed"); + value(self.u, &ty) + } + + fn convert_disconnect( + &mut self, + _: &PostOrderIterItem<&ConstructNode<'brand>>, + right: Option<&Arc>, + _: &Option>, + ) -> Result> { + Ok(Arc::clone( + right.expect("generated disconnect nodes have a right child"), + )) + } + + fn convert_data( + &mut self, + data: &PostOrderIterItem<&ConstructNode<'brand>>, + inner: Inner<&Arc, &Arc, &Value>, + ) -> Result> { + let arrow = data + .node + .arrow() + .finalize() + .expect("generated programs are well-typed"); + let inner = inner + .map(|node| node.cached_data()) + .map_disconnect(|node| node.cached_data()) + .map_witness(Value::shallow_clone); + Ok(Arc::new(RedeemData::new(arrow, inner))) + } +} + +struct Generator<'a, 'u, 'brand> { + u: &'a mut Unstructured<'u>, + ctx: &'a types::Context<'brand>, + /// Number of combinators left to choose before only leaves are generated. + budget: usize, + /// Every node generated so far, with the type it was generated for. + pool: Vec<(Arc, Arc, Node<'brand>)>, + /// Number of case nodes generated which run on two inputs. + twice: usize, +} + +impl<'brand> Generator<'_, '_, 'brand> { + /// Generates the program, of type 1 → 1, as a combinator with children, so + /// that the program is never a single leaf. + fn root(&mut self) -> Result> { + let unit = Final::unit(); + match self.u.choose_index(3)? { + 0 => self.branch(&unit, &unit), + 1 => self.branch_twice(&unit, &unit), + _ => self.jet(&unit, &unit), + } + } + + /// Generates a node of type `a` → `b`. + /// + /// Type inference may finalize the node to a smaller type, where nothing in + /// the program constrains part of `a` or `b`. + fn node(&mut self, a: &Arc, b: &Arc) -> Result> { + if self.budget == 0 { + return self.leaf(a, b); + } + self.budget -= 1; + + // `choose_index` returns 0 once the data runs out, so 0 must terminate. + let node = match self.u.choose_index(8)? { + 0 => self.leaf(a, b)?, + 1 => self.reuse(a, b)?, + 2 => self.shape(a, b)?, + 3 => { + let mid = self.ty(2)?; + let left = self.node(a, &mid)?; + let right = self.node(&mid, b)?; + typed(Node::comp(&left, &right)) + } + 4 => self.branch(a, b)?, + 5 => self.jet(a, b)?, + 6 => self.branch_twice(a, b)?, + _ => self.disconnect(a, b)?, + }; + self.pool + .push((Arc::clone(a), Arc::clone(b), Arc::clone(&node))); + Ok(node) + } + + /// Generates a node of type `a` → `b` which has no children to generate. + fn leaf(&mut self, a: &Arc, b: &Arc) -> Result> { + if b.is_unit() { + Ok(Node::unit(self.ctx)) + } else if a == b && self.u.arbitrary()? { + Ok(Node::iden(self.ctx)) + } else if b.bit_width() <= MAX_SCRIBE_WIDTH && self.u.arbitrary()? { + // Constant words have source type 1, so discard the input first. + let constant = Node::scribe(self.ctx, &value(self.u, b)?); + Ok(typed(Node::comp(&Node::unit(self.ctx), &constant))) + } else { + Ok(Node::witness(self.ctx, None)) + } + } + + /// Returns a node of type `a` → `b` which was generated before, so that it is + /// shared, or a leaf if there is none. + fn reuse(&mut self, a: &Arc, b: &Arc) -> Result> { + let matches: Vec<_> = self + .pool + .iter() + .filter(|(pa, pb, _)| pa == a && pb == b) + .map(|(_, _, node)| Arc::clone(node)) + .collect(); + if matches.is_empty() { + self.leaf(a, b) + } else { + Ok(Arc::clone(&matches[self.u.choose_index(matches.len())?])) + } + } + + /// Generates a node whose combinator follows the shape of `a` or `b`. + fn shape(&mut self, a: &Arc, b: &Arc) -> Result> { + enum Shape { + InjL, + InjR, + Pair, + Take, + Drop, + Case, + } + + let mut shapes = vec![]; + if b.as_sum().is_some() { + shapes.extend([Shape::InjL, Shape::InjR]); + } + if b.as_product().is_some() { + shapes.push(Shape::Pair); + } + if let Some((a0, _)) = a.as_product() { + shapes.extend([Shape::Take, Shape::Drop]); + if a0.as_sum().is_some() { + shapes.push(Shape::Case); + } + } + if shapes.is_empty() { + return self.leaf(a, b); + } + + let sum = |ty: &Arc| ty.as_sum().map(|(l, r)| (Arc::clone(l), Arc::clone(r))); + let product = + |ty: &Arc| ty.as_product().map(|(l, r)| (Arc::clone(l), Arc::clone(r))); + Ok(match shapes[self.u.choose_index(shapes.len())?] { + Shape::InjL => Node::injl(&self.node(a, &sum(b).unwrap().0)?), + Shape::InjR => Node::injr(&self.node(a, &sum(b).unwrap().1)?), + Shape::Pair => { + let (b0, b1) = product(b).unwrap(); + typed(Node::pair(&self.node(a, &b0)?, &self.node(a, &b1)?)) + } + Shape::Take => Node::take(&self.node(&product(a).unwrap().0, b)?), + Shape::Drop => Node::drop_(&self.node(&product(a).unwrap().1, b)?), + Shape::Case => { + let (a0, a1) = product(a).unwrap(); + let (l, r) = sum(&a0).unwrap(); + let left = self.node(&Final::product(l, Arc::clone(&a1)), b)?; + let right = self.node(&Final::product(r, a1), b)?; + typed(Node::case(&left, &right)) + } + }) + } + + /// Generates `comp (pair s iden) (case l r)` of type `a` → `b`: compute a sum + /// from the input, then branch on it with the input available to both branches. + fn branch(&mut self, a: &Arc, b: &Arc) -> Result> { + let (l, r) = (self.ty(1)?, self.ty(1)?); + let scrutinee = self.node(a, &Final::sum(Arc::clone(&l), Arc::clone(&r)))?; + let left = self.node(&Final::product(l, Arc::clone(a)), b)?; + let right = self.node(&Final::product(r, Arc::clone(a)), b)?; + let input = typed(Node::pair(&scrutinee, &Node::iden(self.ctx))); + Ok(typed(Node::comp(&input, &typed(Node::case(&left, &right))))) + } + + /// Generates `comp (pair (comp (pair s1 iden) c) (comp (pair s2 iden) c)) (take iden)` + /// of type `a` → `b`: a single case node `c` which runs on two inputs, so that it + /// can take both of its branches. + fn branch_twice(&mut self, a: &Arc, b: &Arc) -> Result> { + if self.twice == MAX_TWICE { + return self.branch(a, b); + } + self.twice += 1; + let (l, r) = (self.ty(1)?, self.ty(1)?); + let sum = Final::sum(Arc::clone(&l), Arc::clone(&r)); + let left = self.node(&Final::product(l, Arc::clone(a)), b)?; + let right = self.node(&Final::product(r, Arc::clone(a)), b)?; + let case = typed(Node::case(&left, &right)); + let first = self.run_case(a, &sum, &case)?; + let second = self.run_case(a, &sum, &case)?; + let both = typed(Node::pair(&first, &second)); + Ok(typed(Node::comp(&both, &Node::take(&Node::iden(self.ctx))))) + } + + /// Generates `comp (pair s iden) case` of type `a` → `b`, for a case node of + /// type (`sum` × `a`) → `b`. + fn run_case( + &mut self, + a: &Arc, + sum: &Arc, + case: &Node<'brand>, + ) -> Result> { + let scrutinee = self.node(a, sum)?; + let input = typed(Node::pair(&scrutinee, &Node::iden(self.ctx))); + Ok(typed(Node::comp(&input, case))) + } + + /// Generates `comp (disconnect s t) rest` of type `a` → `b`, where `s` has type + /// 2^256 × `a` → `b0` × `c`, the disconnected expression `t` has type `c` → `d`, + /// and `rest` has type `b0` × `d` → `b`. + fn disconnect(&mut self, a: &Arc, b: &Arc) -> Result> { + let (b0, c, d) = (self.ty(1)?, self.ty(1)?, self.ty(1)?); + let source = Final::product(Final::two_two_n_fixed::<8>(), Arc::clone(a)); + let left = self.node(&source, &Final::product(Arc::clone(&b0), Arc::clone(&c)))?; + let right = self.node(&c, &d)?; + let disconnect = typed(Node::disconnect(&left, &Some(right))); + let rest = self.node(&Final::product(b0, d), b)?; + Ok(typed(Node::comp(&disconnect, &rest))) + } + + /// Generates `comp s (comp jet t)` of type `a` → `b`, where `s` produces the + /// input of the jet and `t` consumes its output. + fn jet(&mut self, a: &Arc, b: &Arc) -> Result> { + let jet = JETS[self.u.choose_index(JETS.len())?]; + let source = self.node(a, &jet.source_ty().to_final())?; + let target = self.node(&jet.target_ty().to_final(), b)?; + let jet = Node::jet(self.ctx, &jet); + Ok(typed(Node::comp( + &source, + &typed(Node::comp(&jet, &target)), + ))) + } + + /// Generates a type of at most the given depth. + fn ty(&mut self, depth: usize) -> Result> { + let n_choices = if depth == 0 { 4 } else { 6 }; + Ok(match self.u.choose_index(n_choices)? { + 0 => Final::unit(), + 1 => Final::two_two_n_fixed::<0>(), + 2 => Final::two_two_n_fixed::<3>(), + // A type that some jet consumes or produces. + 3 => { + let jet = JETS[self.u.choose_index(JETS.len())?]; + if self.u.arbitrary()? { + jet.source_ty().to_final() + } else { + jet.target_ty().to_final() + } + } + 4 => Final::sum(self.ty(depth - 1)?, self.ty(depth - 1)?), + _ => Final::product(self.ty(depth - 1)?, self.ty(depth - 1)?), + }) + } +} + +/// Returns `n` deterministic pseudorandom inputs, for unit tests which run a +/// fuzz target on many generated programs without an RNG dependency. +pub fn pseudorandom_inputs(n: usize) -> impl Iterator> { + let mut state = 0x2545_f491_4f6c_dd1d_u64; + let mut next = move || { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + state + }; + (0..n).map(move |_| { + let len = 16 + (next() % 4096) as usize; + (0..len).map(|_| next() as u8).collect() + }) +} + +/// Generates a value of the given type. +fn value(u: &mut Unstructured, ty: &Final) -> Result { + if let Some((l, r)) = ty.as_sum() { + if u.arbitrary()? { + Ok(Value::right(Arc::clone(l), value(u, r)?)) + } else { + Ok(Value::left(value(u, l)?, Arc::clone(r))) + } + } else if let Some((l, r)) = ty.as_product() { + Ok(Value::product(value(u, l)?, value(u, r)?)) + } else { + Ok(Value::unit()) + } +} + +/// Unwraps the result of constructing a node, which only fails on a type error. +fn typed(result: std::result::Result) -> T { + result.expect("the generator only combines nodes of matching types") +} + +#[cfg(test)] +mod tests { + use super::*; + use simplicity::bit_machine::ExecutionError; + use simplicity::dag::DagLike as _; + use simplicity::jet::elements::ElementsEnv; + use simplicity::BitMachine; + + #[test] + fn generator_yield() { + const N: usize = 1000; + let env = ElementsEnv::dummy(); + let (mut ran, mut over_50, mut max_nodes, mut min_nodes) = (0, 0, 0, usize::MAX); + let (mut has_case, mut has_jet, mut has_witness, mut has_disconnect) = (0, 0, 0, 0); + let (mut has_hidden, mut kept_case, mut failed) = (0, 0, 0); + for data in pseudorandom_inputs(N) { + let program = generate_program(&mut Unstructured::new(&data)) + .expect("the generator accepts any input"); + let n_nodes = program + .as_ref() + .post_order_iter::() + .count(); + max_nodes = max_nodes.max(n_nodes); + min_nodes = min_nodes.min(n_nodes); + if n_nodes > 50 { + over_50 += 1; + } + let has = |program: &RedeemNode, f: fn(&Inner<_, _, _>) -> bool| { + program + .post_order_iter::() + .any(|data| f(data.node.inner())) + }; + has_case += usize::from(has(&program, |i| matches!(i, Inner::Case(..)))); + has_jet += usize::from(has(&program, |i| matches!(i, Inner::Jet(..)))); + has_witness += usize::from(has(&program, |i| matches!(i, Inner::Witness(..)))); + has_disconnect += usize::from(has(&program, |i| matches!(i, Inner::Disconnect(..)))); + + let mut mac = match BitMachine::for_program(&program) { + Ok(mac) => mac, + Err(_) => continue, + }; + mac.exec(&program, &env) + .expect("generated programs contain nothing that fails"); + ran += 1; + let pruned = match program.prune(&env) { + Ok(pruned) => pruned, + Err(ExecutionError::PruningFailed) => { + failed += 1; + continue; + } + Err(e) => panic!("pruning should succeed when execution does: {e}"), + }; + has_hidden += usize::from(has(&pruned, |i| { + matches!(i, Inner::AssertL(..) | Inner::AssertR(..)) + })); + kept_case += usize::from(has(&pruned, |i| matches!(i, Inner::Case(..)))); + } + let stats = format!( + "of {N}: ran {ran}, >50 nodes {over_50} (min {min_nodes}, max {max_nodes}), \ + case {has_case}, jet {has_jet}, witness {has_witness}, disconnect {has_disconnect}, \ + pruning hid a branch \ + {has_hidden}, kept a case node {kept_case}, failed {failed}" + ); + // The inputs are fixed, so these only change when the generator or pruning + // does. + assert_eq!(ran, N, "{stats}"); + assert!(min_nodes >= 3, "{stats}"); + assert!(over_50 >= N * 3 / 4, "{stats}"); + assert!(has_case >= N * 9 / 10, "{stats}"); + assert!(has_jet >= N * 9 / 10, "{stats}"); + assert!(has_witness >= N * 9 / 10, "{stats}"); + assert!(has_disconnect >= N / 2, "{stats}"); + assert!(has_hidden >= N * 8 / 10, "{stats}"); + assert!(kept_case >= N / 4, "{stats}"); + assert!(failed <= N / 100, "{stats}"); + } +} diff --git a/fuzz/fuzz_lib/lib.rs b/fuzz/fuzz_lib/lib.rs index d3772c85..7ab432b5 100644 --- a/fuzz/fuzz_lib/lib.rs +++ b/fuzz/fuzz_lib/lib.rs @@ -7,9 +7,13 @@ use simplicity::types::Final as FinalTy; use simplicity::{BitIter, Value}; use std::sync::Arc; +mod generator; mod program; +mod prune; +pub use generator::{generate_program, pseudorandom_inputs}; pub use program::ProgramControl; +pub use prune::{check_prune, PruneOutcome}; /// A wrapper around a buffer which has utilities for extracting various /// Simplicity types. diff --git a/fuzz/fuzz_lib/prune.rs b/fuzz/fuzz_lib/prune.rs new file mode 100644 index 00000000..807a5461 --- /dev/null +++ b/fuzz/fuzz_lib/prune.rs @@ -0,0 +1,143 @@ +// SPDX-License-Identifier: CC0-1.0 + +//! Checks of pruning on generated programs + +use arbitrary::Unstructured; +use simplicity::bit_machine::ExecutionError; +use simplicity::ffi::tests::{ffi::SimplicityErr, run_program, TestUpTo}; +use simplicity::hashes::sha256::Midstate; +use simplicity::jet::{elements::ElementsEnv, Elements}; +use simplicity::{BitIter, BitMachine, Cost, RedeemNode}; + +use crate::generate_program; + +/// Programs which cost more than this are skipped. Their cost comes from running +/// the same nodes many times, which takes time without exercising pruning more. +const MAX_COST: Cost = Cost::from_milliweight(1_000_000_000); + +/// What [`check_prune`] did with a generated program. +#[derive(Copy, Clone, PartialEq, Eq, Debug)] +pub enum PruneOutcome { + /// The program was too expensive or too large to run, and was skipped. + Skipped, + /// Pruning failed with [`ExecutionError::PruningFailed`]: it could not produce a + /// program which consensus accepts. + PruningFailed, + /// The program was pruned and passed every check. + Checked, +} + +/// Generates a program from `data`, prunes it, and checks the pruned program +/// against the Rust decoder and Bit Machine, and against libsimplicity. +pub fn check_prune(data: &[u8]) -> PruneOutcome { + let program = match generate_program(&mut Unstructured::new(data)) { + Ok(program) => program, + Err(_) => return PruneOutcome::Skipped, + }; + if program.bounds().cost > MAX_COST { + return PruneOutcome::Skipped; + } + let env = ElementsEnv::dummy(); + let mut mac = match BitMachine::for_program(&program) { + Ok(mac) => mac, + Err(_) => return PruneOutcome::Skipped, + }; + mac.exec(&program, &env) + .expect("generated programs contain nothing that fails"); + + let pruned = match program.prune(&env) { + Ok(pruned) => pruned, + Err(ExecutionError::PruningFailed) => return PruneOutcome::PruningFailed, + Err(e) => panic!("pruning should succeed when execution does: {e}"), + }; + assert_eq!(pruned.cmr(), program.cmr(), "pruning should keep the CMR"); + BitMachine::for_program(&pruned) + .expect("the pruned program is no larger") + .exec(&pruned, &env) + .expect("the pruned program should run"); + + // A decoder infers the types of the pruned program from its encoding, so it + // must infer the types that pruning did to read the witness data back. The + // IHR only covers the outer types of the program, the AMR all of them. + let (prog, wit) = pruned.to_vec_with_witness(); + let decoded = + RedeemNode::decode::<_, _, Elements>(BitIter::from(&prog[..]), BitIter::from(&wit[..])) + .expect("the pruned program should decode"); + assert_eq!( + decoded.ihr(), + pruned.ihr(), + "the decoder should infer the types that pruning did" + ); + assert_eq!( + decoded.amr(), + pruned.amr(), + "the decoder should infer the types that pruning did" + ); + assert_eq!(decoded.to_vec_with_witness(), (prog.clone(), wit.clone())); + + // libsimplicity, the consensus implementation, accepts the pruned program + // and computes the same roots. + let c_pruned = run_program(&prog, &wit, TestUpTo::Everything, None, None) + .expect("libsimplicity should decode the pruned program"); + assert_eq!( + c_pruned.eval_result, + SimplicityErr::NoError, + "libsimplicity should accept the pruned program" + ); + assert_eq!( + &Midstate::from(c_pruned.cmr).to_parts().0, + pruned.cmr().as_ref() + ); + assert_eq!( + &Midstate::from(c_pruned.amr).to_parts().0, + pruned.amr().as_ref() + ); + assert_eq!( + &Midstate::from(c_pruned.ihr).to_parts().0, + pruned.ihr().as_ref() + ); + + // libsimplicity rejects the unpruned program for its unexecuted branches + // exactly when pruning hid a branch, so both agree on what was executed. + // This relies on generated programs having the types that inference gives + // them, so that the encoding of the unpruned program decodes to the same + // program. + let unpruned = program.to_vec_with_witness(); + let c_unpruned = run_program(&unpruned.0, &unpruned.1, TestUpTo::Everything, None, None) + .expect("libsimplicity should decode the unpruned program"); + let expected = if unpruned == (prog, wit) { + SimplicityErr::NoError + } else { + SimplicityErr::AntiDoS + }; + assert_eq!( + c_unpruned.eval_result, expected, + "libsimplicity should reject the unpruned program exactly when pruning changed it" + ); + PruneOutcome::Checked +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pseudorandom_inputs; + + #[test] + fn check_prune_on_pseudorandom_inputs() { + const N: usize = 1000; + let (mut skipped, mut failed, mut checked) = (0, 0, 0); + for data in pseudorandom_inputs(N) { + match check_prune(&data) { + PruneOutcome::Skipped => skipped += 1, + PruneOutcome::PruningFailed => failed += 1, + PruneOutcome::Checked => checked += 1, + } + } + let stats = + format!("of {N}: skipped {skipped}, pruning failed {failed}, checked {checked}"); + // Pruning cannot yet resolve every set of case nodes which become one node + // when pruned, but generated programs should rarely contain one. + assert!(failed <= N / 100, "{stats}"); + assert!(checked >= N * 95 / 100, "{stats}"); + } +} diff --git a/fuzz/fuzz_targets/prune_generated.rs b/fuzz/fuzz_targets/prune_generated.rs new file mode 100644 index 00000000..da10fd27 --- /dev/null +++ b/fuzz/fuzz_targets/prune_generated.rs @@ -0,0 +1,11 @@ +// SPDX-License-Identifier: CC0-1.0 + +#![cfg_attr(fuzzing, no_main)] + +#[cfg(fuzzing)] +libfuzzer_sys::fuzz_target!(|data| { + simplicity_fuzz::check_prune(data); +}); + +#[cfg(not(fuzzing))] +fn main() {} diff --git a/fuzz/generate-files.sh b/fuzz/generate-files.sh index a350dc6c..4f585fbf 100755 --- a/fuzz/generate-files.sh +++ b/fuzz/generate-files.sh @@ -24,6 +24,7 @@ cargo-fuzz = true path = "fuzz_lib/lib.rs" [dependencies] +arbitrary = "1.4.2" libfuzzer-sys = "0.4" # We shouldn't need an explicit version on the next line, but Andrew's tools # choke on it otherwise. See https://github.com/nix-community/crate2nix/issues/373 @@ -63,6 +64,20 @@ on: pull_request: jobs: + test: + name: Test Fuzz Library + if: \${{ !github.event.act }} + runs-on: ubuntu-latest + steps: + - name: Checkout Crate + uses: actions/checkout@v4 + + - name: Install Toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Run Tests + run: cargo test -p simplicity-fuzz --lib + fuzz: name: Run Fuzz Target if: \${{ !github.event.act }} @@ -90,7 +105,7 @@ $(for name in $(listTargetNames); do echo "$name,"; done) - name: Install Toolchain uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2024-07-01 + toolchain: nightly-2026-02-24 components: "llvm-tools-preview" - name: Install Dependencies diff --git a/src/jet/elements/environment.rs b/src/jet/elements/environment.rs index e022753c..d03bd982 100644 --- a/src/jet/elements/environment.rs +++ b/src/jet/elements/environment.rs @@ -109,7 +109,7 @@ where } } -#[cfg(test)] +#[cfg(any(test, feature = "test-utils"))] impl ElementsEnv { /// Return a dummy Elements environment pub fn dummy() -> Self {