From cc4c4a7281e2248eafe9c984d0cc660030841bd2 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 28 Oct 2026 18:42:01 +0200 Subject: [PATCH 1/2] ci(fuzz): use the workflow's nightly in generate-files.sh --- fuzz/generate-files.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/fuzz/generate-files.sh b/fuzz/generate-files.sh index a350dc6c..0c205e71 100755 --- a/fuzz/generate-files.sh +++ b/fuzz/generate-files.sh @@ -90,7 +90,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 From 698a2b0e7cb90c35a6761c80e5fc9da83e0b6192 Mon Sep 17 00:00:00 2001 From: stringhandler Date: Wed, 28 Oct 2026 18:13:12 +0200 Subject: [PATCH 2/2] fuzz: add a type-directed program generator and a pruning target The existing generator picks a combinator and then type checks it, so few of its programs are well-typed, and those are tiny. Add a generator which picks a type first, and then only combinators which can have that type, so every program is well-typed. It never generates fail, assertions or jets which can fail, so every program also runs. It reuses nodes, so that pruning sees nodes shared between hidden and kept code, runs some case nodes on two inputs, so that they take both branches, and generates disconnect nodes. It uses Elements jets, because libsimplicity decodes Elements jet codes. It does not generate separate nodes with one IHR but different types inside them, which only unit tests cover. Add check_prune, which prunes a generated program and checks that: * the pruned program keeps the CMR and runs, * it decodes to the same IHR and AMR and encodes back to the same bytes, * libsimplicity accepts it and computes the same CMR, AMR and IHR, * libsimplicity rejects the unpruned program as anti-DOS exactly when pruning hid a branch. Programs which pruning fails on with ExecutionError::PruningFailed are counted rather than checked, and the tests require them to be rare. Programs over a cost cap are skipped: their cost comes from running the same nodes many times, which takes time without exercising pruning. The prune_generated target calls check_prune, with long inputs from the start, as only those give large programs. A test of the fuzz library calls it on 1000 inputs, in a new job of the fuzz workflow. Also build ElementsEnv::dummy with the test-utils feature, so that the fuzz crate can run Elements jets. Assisted-by: Claude --- .github/workflows/fuzz.yml | 15 + Cargo-recent.lock | 1 + fuzz/Cargo.toml | 8 + fuzz/fuzz.sh | 8 +- fuzz/fuzz_lib/generator.rs | 504 +++++++++++++++++++++++++++ fuzz/fuzz_lib/lib.rs | 4 + fuzz/fuzz_lib/prune.rs | 143 ++++++++ fuzz/fuzz_targets/prune_generated.rs | 11 + fuzz/generate-files.sh | 15 + src/jet/elements/environment.rs | 2 +- 10 files changed, 709 insertions(+), 2 deletions(-) create mode 100644 fuzz/fuzz_lib/generator.rs create mode 100644 fuzz/fuzz_lib/prune.rs create mode 100644 fuzz/fuzz_targets/prune_generated.rs 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 0c205e71..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 }} 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 {