From cc47fe10f3ebd678ea7faa95dd97410b3b27c4c1 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Fri, 23 Jan 2026 16:20:26 +0000 Subject: [PATCH 1/3] fuzz: don't make exponentially-sized types in extract_value_direct In the pruning test I will be writing I would like to iterate over entire types so that I can reduce their size. This is impossible for exponentially sized types. So make them disableable in extract_final_type, and disable them for extract_value_direct (but nowhere else, since they seem to be working elsewhere). It would be nice to test that pruning works properly with massive types; it appears that it does; but that would require more work than I want to put in for this PR. --- fuzz/fuzz_lib/lib.rs | 22 +++++++++++----------- fuzz/fuzz_targets/construct_type.rs | 2 +- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/fuzz/fuzz_lib/lib.rs b/fuzz/fuzz_lib/lib.rs index d3772c85..4403145d 100644 --- a/fuzz/fuzz_lib/lib.rs +++ b/fuzz/fuzz_lib/lib.rs @@ -63,8 +63,8 @@ impl<'f> Extractor<'f> { } /// Attempt to yield a type from the fuzzer. - pub fn extract_final_type(&mut self) -> Option> { - // We can costruct extremely large types by duplicating Arcs; there + pub fn extract_final_type(&mut self, allow_blowup: bool) -> Option> { + // We can construct extremely large types by duplicating Arcs; there // is no need to have an exponential blowup in the number of tasks. const MAX_N_TASKS: usize = 300; @@ -83,7 +83,7 @@ impl<'f> Extractor<'f> { result_stack.push(FinalTy::unit()); } else { let is_sum = self.extract_bit()?; - let dupe = task_stack.len() >= MAX_N_TASKS || self.extract_bit()?; + let dupe = allow_blowup && (task_stack.len() >= MAX_N_TASKS || self.extract_bit()?); task_stack.push(StackElem::Binary { is_sum, dupe }); if !dupe { task_stack.push(StackElem::NeedType) @@ -113,7 +113,7 @@ impl<'f> Extractor<'f> { /// Attempt to yield a value from the fuzzer by constructing a type and then /// reading a bitstring of that type, in the padded value encoding. pub fn extract_value_padded(&mut self) -> Option { - let ty = self.extract_final_type()?; + let ty = self.extract_final_type(true)?; if ty.bit_width() > 64 * 1024 * 1024 { // little fuzzing value in producing massive values return None; @@ -128,7 +128,7 @@ impl<'f> Extractor<'f> { /// Attempt to yield a value from the fuzzer by constructing a type and then /// reading a bitstring of that type, in the compact value encoding. pub fn extract_value_compact(&mut self) -> Option { - let ty = self.extract_final_type()?; + let ty = self.extract_final_type(true)?; if ty.bit_width() > 64 * 1024 * 1024 { // little fuzzing value in producing massive values return None; @@ -184,7 +184,7 @@ impl<'f> Extractor<'f> { } StackElem::Left => { let child = result_stack.pop().unwrap(); - let ty = self.extract_final_type()?; + let ty = self.extract_final_type(true)?; if ty.bit_width() > MAX_TY_WIDTH { return None; } @@ -192,7 +192,7 @@ impl<'f> Extractor<'f> { } StackElem::Right => { let child = result_stack.pop().unwrap(); - let ty = self.extract_final_type()?; + let ty = self.extract_final_type(true)?; if ty.bit_width() > MAX_TY_WIDTH { return None; } @@ -205,7 +205,7 @@ impl<'f> Extractor<'f> { } /// Attempt to yield a type from the fuzzer. - pub fn extract_old_final_type(&mut self) -> Option> { + pub fn extract_old_final_type(&mut self, allow_blowup: bool) -> Option> { // We can costruct extremely large types by duplicating Arcs; there // is no need to have an exponential blowup in the number of tasks. const MAX_N_TASKS: usize = 300; @@ -225,7 +225,7 @@ impl<'f> Extractor<'f> { result_stack.push(OldFinalTy::unit()); } else { let is_sum = self.extract_bit()?; - let dupe = task_stack.len() >= MAX_N_TASKS || self.extract_bit()?; + let dupe = allow_blowup && (task_stack.len() >= MAX_N_TASKS || self.extract_bit()?); task_stack.push(StackElem::Binary { is_sum, dupe }); if !dupe { task_stack.push(StackElem::NeedType) @@ -297,7 +297,7 @@ impl<'f> Extractor<'f> { } StackElem::Left => { let child = result_stack.pop().unwrap(); - let ty = self.extract_old_final_type()?; + let ty = self.extract_old_final_type(true)?; if ty.bit_width() > MAX_TY_WIDTH { return None; } @@ -305,7 +305,7 @@ impl<'f> Extractor<'f> { } StackElem::Right => { let child = result_stack.pop().unwrap(); - let ty = self.extract_old_final_type()?; + let ty = self.extract_old_final_type(true)?; if ty.bit_width() > MAX_TY_WIDTH { return None; } diff --git a/fuzz/fuzz_targets/construct_type.rs b/fuzz/fuzz_targets/construct_type.rs index 65e7e520..391dc6c3 100644 --- a/fuzz/fuzz_targets/construct_type.rs +++ b/fuzz/fuzz_targets/construct_type.rs @@ -5,7 +5,7 @@ #[cfg(any(fuzzing, test))] fn do_test(data: &[u8]) { let mut extractor = simplicity_fuzz::Extractor::new(data); - let _ = extractor.extract_final_type(); + let _ = extractor.extract_final_type(true); } #[cfg(fuzzing)] From c9f742d046f30cb14c94cb6a73dae3cc49bd51fa Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Fri, 23 Jan 2026 15:40:46 +0000 Subject: [PATCH 2/3] fuzz: add 'prune_value' fuzz test which can find regressions for #338 --- fuzz/Cargo.toml | 7 +++ fuzz/fuzz_lib/lib.rs | 2 +- fuzz/fuzz_targets/prune_value.rs | 105 +++++++++++++++++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) create mode 100644 fuzz/fuzz_targets/prune_value.rs diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml index 4135e812..fecec3c1 100644 --- a/fuzz/Cargo.toml +++ b/fuzz/Cargo.toml @@ -67,6 +67,13 @@ test = false doc = false bench = false +[[bin]] +name = "prune_value" +path = "fuzz_targets/prune_value.rs" +test = false +doc = false +bench = false + [[bin]] name = "regression_286" path = "fuzz_targets/regression_286.rs" diff --git a/fuzz/fuzz_lib/lib.rs b/fuzz/fuzz_lib/lib.rs index 4403145d..56f4a19e 100644 --- a/fuzz/fuzz_lib/lib.rs +++ b/fuzz/fuzz_lib/lib.rs @@ -113,7 +113,7 @@ impl<'f> Extractor<'f> { /// Attempt to yield a value from the fuzzer by constructing a type and then /// reading a bitstring of that type, in the padded value encoding. pub fn extract_value_padded(&mut self) -> Option { - let ty = self.extract_final_type(true)?; + let ty = self.extract_final_type(false)?; if ty.bit_width() > 64 * 1024 * 1024 { // little fuzzing value in producing massive values return None; diff --git a/fuzz/fuzz_targets/prune_value.rs b/fuzz/fuzz_targets/prune_value.rs new file mode 100644 index 00000000..94488f90 --- /dev/null +++ b/fuzz/fuzz_targets/prune_value.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: CC0-1.0 + +#![cfg_attr(fuzzing, no_main)] + +#[cfg(any(fuzzing, test))] +fn do_test(data: &[u8]) -> Option<()> { + use simplicity::dag::{DagLike, NoSharing}; + use simplicity::types::{CompleteBound, Final}; + + let mut extractor = simplicity_fuzz::Extractor::new(data); + + let val = extractor.extract_value_padded()?; + let ty = val.ty(); + + // Construct a smaller type + let mut stack = vec![]; + for node in ty.post_order_iter::() { + match node.node.bound() { + CompleteBound::Unit => stack.push(Final::unit()), + CompleteBound::Sum(..) => { + let right = stack.pop().unwrap(); + let left = stack.pop().unwrap(); + stack.push(Final::sum(left, right)); + } + CompleteBound::Product(..) => { + let mut right = stack.pop().unwrap(); + let mut left = stack.pop().unwrap(); + if extractor.extract_bit()? { + left = Final::unit(); + } + if extractor.extract_bit()? { + right = Final::unit(); + } + stack.push(Final::product(left, right)); + } + } + } + let pruned_ty = stack.pop().unwrap(); + assert!(stack.is_empty()); + + + // Prune the value + let pruned_val = match val.prune(&pruned_ty) { + Some(val) => val, + None => panic!("Failed to prune value {val} from {ty} to {pruned_ty}"), + }; + + /* + // If you have a regression you likely want to uncomment these printlns. + println!("Original Value Bits: {:?}", val.iter_padded().collect::>()); + println!("Original Value: {val}"); + println!(" Original Type: {ty}"); + println!(" Pruned Value: {pruned_val}"); + println!(" Pruned Type: {pruned_ty}"); + */ + + // Check that pruning made sense by going through the compact bit iterator + // and checking that the pruned value is obtained from the original by + // just deleting bits. + let mut orig_iter = val.iter_compact(); + let mut prune_iter = pruned_val.iter_compact(); + + loop { + match (orig_iter.next(), prune_iter.next()) { + (Some(true), Some(true)) => {}, + (Some(false), Some(false)) => {}, + (Some(_), Some(prune_bit)) => { + // We get here if the pruned and the original iterator disagree. + // This should happen iff we deleted some bits from the pruned + // value, meaning that we just need to ratchet forward the + // original iterator until we're back on track. + loop { + match orig_iter.next() { + Some(orig_bit) => { + if orig_bit == prune_bit { break } + }, + None => panic!("original iterator ran out before pruned iterator did"), + } + } + } + (None, Some(_)) => panic!("original iterator ran out before pruned iterator did"), + (_, None) => break, // done once the pruned iterator runs out + } + } + Some(()) +} + +#[cfg(fuzzing)] +libfuzzer_sys::fuzz_target!(|data| { let _ = do_test(data); }); + +#[cfg(not(fuzzing))] +fn main() {} + +#[cfg(test)] +mod tests { + use base64::Engine; + + #[test] + fn duplicate_crash() { + let data = base64::prelude::BASE64_STANDARD + .decode("Cg==") + .expect("base64 should be valid"); + let _ = super::do_test(&data); + } +} From 91dbb9adede5e01c4c1cb46084e0446b6cbd1e46 Mon Sep 17 00:00:00 2001 From: Andrew Poelstra Date: Sat, 24 Jan 2026 18:43:47 +0000 Subject: [PATCH 3/3] value: add regression test for #337 --- src/value.rs | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/value.rs b/src/value.rs index 4b9bf285..7f032ede 100644 --- a/src/value.rs +++ b/src/value.rs @@ -1262,6 +1262,42 @@ mod tests { let new_v = Value::from_padded_bits(&mut iter, &v.ty).unwrap(); assert_eq!(v, new_v); } + + #[test] + fn prune_regression_337_1() { + // Two values that differ only in padding bits are nonetheless equal + let ty_2x1_opt = Final::sum(Final::unit(), Final::product(Final::two_two_n(0), Final::unit())); + + // L(ε) as all zeros + let mut iter = BitIter::new(Some(0b0000_0000u8).into_iter()); + let value_1 = Value::from_padded_bits(&mut iter, &ty_2x1_opt).unwrap(); + // L(ε) with a one in its padding bit + let mut iter = BitIter::new(Some(0b0100_0000u8).into_iter()); + let value_2 = Value::from_padded_bits(&mut iter, &ty_2x1_opt).unwrap(); + + assert_eq!(value_1, value_2); + } + + #[test] + fn prune_regression_337_2() { + let ty_2x1_opt = Final::sum(Final::unit(), Final::product(Final::two_two_n(0), Final::unit())); + let ty_1x1_opt = Final::sum(Final::unit(), Final::product(Final::unit(), Final::unit())); + + // Bits [false, true] - first bit is false (left sum), second bit is true (unused padding) + let mut iter = BitIter::new(Some(0b0100_0000u8).into_iter()); + + // Parse as (2 × 1)? then prune to (1 × 1)? + let value = Value::from_padded_bits(&mut iter, &ty_2x1_opt).unwrap(); + let pruned = value.prune(&ty_1x1_opt).unwrap(); + + // Expected: L(ε) - still in the left (unit) branch + let expected = Value::left(Value::unit(), Final::product(Final::unit(), Final::unit())); + + // BUG: This fails because pruning incorrectly returns R((ε,ε)). We first compare string + // serializations since a direct comparison might only test `prune_regression_337_1`. + assert_eq!(pruned.to_string(), expected.to_string()); + assert_eq!(pruned, expected); + } } #[cfg(bench)]