diff --git a/CHANGELOG.md b/CHANGELOG.md index 94e69c6b..3d36a031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ # Unreleased * Fix `RedeemNode::prune` producing programs that consensus rejects, and add `ExecutionError::PruningFailed` [#391](https://github.com/BlockstreamResearch/rust-simplicity/issues/391). +* Enforce libsimplicity's limits on cells and program length: `BitMachine::for_program` rejects programs needing more than `CELLS_MAX` cells, and decoding fails with the new `decode::Error::TooManyNodes` beyond `DAG_LEN_MAX` nodes. Export both constants from `simplicity-sys`. # 0.9.0 - 2026-09-16 diff --git a/fuzz/fuzz_targets/c_rust_merkle.rs b/fuzz/fuzz_targets/c_rust_merkle.rs index 6283e910..56cc0376 100644 --- a/fuzz/fuzz_targets/c_rust_merkle.rs +++ b/fuzz/fuzz_targets/c_rust_merkle.rs @@ -65,6 +65,19 @@ fn do_test(data: &[u8]) { &Midstate::from(c_data.ihr).to_parts().0, rust_data.ihr().as_ref(), ); + + // Our Bit Machine lacks tail call optimization, so it needs at least as + // many cells and frames as libsimplicity's. Both count two frames for + // the input and output. + let arrow = rust_data.arrow(); + let bounds = rust_data.bounds(); + let cells = arrow + .source + .bit_width() + .saturating_add(arrow.target.bit_width()) + .saturating_add(bounds.extra_cells); + assert!(cells >= c_data.cell_bound as usize); + assert!(bounds.extra_frames + 2 >= c_data.frame_bound as usize); } } } diff --git a/simplicity-sys/depend/wrapper.c b/simplicity-sys/depend/wrapper.c index 72122ef5..04875632 100644 --- a/simplicity-sys/depend/wrapper.c +++ b/simplicity-sys/depend/wrapper.c @@ -7,11 +7,16 @@ #include "simplicity/type.h" #include "simplicity/bounded.h" #include "simplicity/frame.h" +#include "simplicity/limitations.h" #include const uint64_t c_overhead = overhead; +const ubounded c_budget_max = BUDGET_MAX; +const ubounded c_cells_max = CELLS_MAX; +const size_t c_dag_len_max = DAG_LEN_MAX; + const size_t c_sizeof_uchar = sizeof(unsigned char); const size_t c_sizeof_int = sizeof(int); const size_t c_sizeof_uint = sizeof(unsigned int); diff --git a/simplicity-sys/src/ffi.rs b/simplicity-sys/src/ffi.rs index 06216c46..a272bdee 100644 --- a/simplicity-sys/src/ffi.rs +++ b/simplicity-sys/src/ffi.rs @@ -79,6 +79,11 @@ extern "C" { pub type ubounded = c_uint_least32_t; /// Used with `evalTCOProgram` to enforce consensus limits. pub const BUDGET_MAX: ubounded = 4000050; +/// The maximum number of cells (bits) that consensus allows the Bit Machine +/// to need when evaluating a program, as bounded by `analyseBounds`. +pub const CELLS_MAX: ubounded = 0x500000; +/// The maximum number of nodes in a program accepted by consensus. +pub const DAG_LEN_MAX: c_size_t = 8000000; /// The max value of UBOUNDED_MAX pub const UBOUNDED_MAX: ubounded = ubounded::MAX; @@ -87,6 +92,12 @@ extern "C" { pub static c_alignof_ubounded: c_size_t; } +extern "C" { + pub static c_budget_max: ubounded; + pub static c_cells_max: ubounded; + pub static c_dag_len_max: c_size_t; +} + pub mod bounded { use super::ubounded; extern "C" { @@ -167,6 +178,15 @@ mod tests { } } + #[test] + fn test_limits() { + unsafe { + assert_eq!(BUDGET_MAX, c_budget_max); + assert_eq!(CELLS_MAX, c_cells_max); + assert_eq!(DAG_LEN_MAX, c_dag_len_max); + } + } + #[test] #[rustfmt::skip] fn test_aligns() { diff --git a/simplicity-sys/src/tests/mod.rs b/simplicity-sys/src/tests/mod.rs index e5526218..241554d9 100644 --- a/simplicity-sys/src/tests/mod.rs +++ b/simplicity-sys/src/tests/mod.rs @@ -31,6 +31,10 @@ pub struct TestOutput { pub ihr: CSha256Midstate, /// The cost bound of the program pub cost_bound: ubounded, + /// The bound on the cells needed to evaluate the program + pub cell_bound: ubounded, + /// The bound on the frames needed to evaluate the program + pub frame_bound: ubounded, /// Whether or not evaluation succeded pub eval_result: SimplicityErr, } @@ -127,6 +131,8 @@ pub fn run_program( cmr: CSha256Midstate::default(), ihr: CSha256Midstate::default(), cost_bound: 0, + cell_bound: 0, + frame_bound: 0, eval_result: SimplicityErr::NoError, }; @@ -211,6 +217,8 @@ pub fn run_program( ) .into_result()?; result.cost_bound = cost_bound; + result.cell_bound = cell_bound; + result.frame_bound = frame_bound; if test_up_to <= TestUpTo::ComputeCostUnbounded { return Ok(result); } diff --git a/src/analysis.rs b/src/analysis.rs index fe5fed84..92ed614b 100644 --- a/src/analysis.rs +++ b/src/analysis.rs @@ -306,7 +306,9 @@ impl NodeBounds { /// Node bounds for a `comp` node pub fn comp(left: Self, right: Self, mid_ty_bit_width: usize) -> NodeBounds { NodeBounds { - extra_cells: mid_ty_bit_width + cmp::max(left.extra_cells, right.extra_cells), + // Type widths saturate at usize::MAX, so the sums here must too. + extra_cells: mid_ty_bit_width + .saturating_add(cmp::max(left.extra_cells, right.extra_cells)), extra_frames: 1 + cmp::max(left.extra_frames, right.extra_frames), cost: Cost::OVERHEAD + Cost::of_type(mid_ty_bit_width) + left.cost + right.cost, } @@ -351,8 +353,8 @@ impl NodeBounds { ) -> NodeBounds { NodeBounds { extra_cells: left_source_bit_width - + left_target_bit_width - + cmp::max(left.extra_cells, right.extra_cells), + .saturating_add(left_target_bit_width) + .saturating_add(cmp::max(left.extra_cells, right.extra_cells)), extra_frames: 2 + cmp::max(left.extra_frames, right.extra_frames), cost: Cost::OVERHEAD + Cost::of_type(left_source_bit_width) diff --git a/src/bit_encoding/decode.rs b/src/bit_encoding/decode.rs index 2d57c085..716d4f53 100644 --- a/src/bit_encoding/decode.rs +++ b/src/bit_encoding/decode.rs @@ -14,6 +14,7 @@ use crate::node::{ use crate::types; use crate::value::Word; use crate::{BitIter, FailEntropy}; +use simplicity_sys::ffi::DAG_LEN_MAX; use std::collections::HashSet; use std::sync::Arc; use std::{cmp, error, fmt}; @@ -42,6 +43,13 @@ pub enum Error { NotInCanonicalOrder, /// Program does not have maximal sharing SharingNotMaximal, + /// Program has more nodes than consensus allows + TooManyNodes { + /// The number of nodes in the program. + got: usize, + /// The maximum allowed number of nodes. + max: usize, + }, /// Type-checking error Type(crate::types::Error), } @@ -81,6 +89,13 @@ impl fmt::Display for Error { Error::Natural(ref e) => e.fmt(f), Error::NotInCanonicalOrder => f.write_str("program not in canonical order"), Error::SharingNotMaximal => f.write_str("Decoded programs must have maximal sharing"), + Error::TooManyNodes { got, max } => { + write!( + f, + "program has {} nodes, more than the maximum {}", + got, max + ) + } Error::Type(ref e) => fmt::Display::fmt(e, f), } } @@ -97,6 +112,7 @@ impl error::Error for Error { Error::Natural(ref e) => Some(e), Error::NotInCanonicalOrder => None, Error::SharingNotMaximal => None, + Error::TooManyNodes { .. } => None, Error::Type(ref e) => Some(e), } } @@ -172,6 +188,12 @@ pub fn decode_expression<'brand, I: Iterator, J: Jet>( let len = bits.read_natural::(None)?; assert_ne!(len, 0, "impossible to encode 0 in Simplicity"); + if len > DAG_LEN_MAX { + return Err(Error::TooManyNodes { + got: len, + max: DAG_LEN_MAX, + }); + } let mut nodes = Vec::with_capacity(cmp::min(len, 10_000)); for _ in 0..len { @@ -326,6 +348,31 @@ mod tests { RedeemNode::decode::<_, _, Core>(iter, BitIter::from(&[][..])).unwrap_err(); } + #[test] + fn too_many_nodes() { + for (len, too_many) in [(DAG_LEN_MAX, false), (DAG_LEN_MAX + 1, true)] { + let mut prog = Vec::::new(); + let mut w = BitWriter::new(&mut prog); + encode::encode_natural(len, &mut w).unwrap(); + w.flush_all().unwrap(); + + types::Context::with_context(|ctx| { + let mut iter = BitIter::from(&prog[..]); + let err = decode_expression::<_, Core>(&ctx, &mut iter) + .expect_err("there are no nodes after the length"); + match err { + Error::TooManyNodes { got, max } => { + assert!(too_many, "{} nodes should be allowed", len); + assert_eq!(got, len); + assert_eq!(max, DAG_LEN_MAX); + } + // An allowed length fails later, when decoding the nodes. + _ => assert!(!too_many, "{} nodes should be too many: {}", len, err), + } + }); + } + } + #[test] fn decode_fixed_natural() { let tries: Vec<(usize, usize, &[u8])> = vec![ diff --git a/src/bit_machine/limits.rs b/src/bit_machine/limits.rs index ed23324d..4adb6c4c 100644 --- a/src/bit_machine/limits.rs +++ b/src/bit_machine/limits.rs @@ -12,24 +12,27 @@ use std::error; /// The maximum number of cells needed by the bit machine. /// /// This roughly corresponds to the maximum size of any type used by a -/// program, in bits. In a blockchain context this should be limited by -/// the transaction's weight budget. +/// program, in bits. /// -/// The limit here is an absolute limit enforced by the library to avoid -/// unbounded allocations. +/// This is libsimplicity's consensus limit. libsimplicity bounds the cells +/// needed by a Bit Machine with tail call optimization, which this one lacks, +/// so this bound on a program is never smaller than libsimplicity's and is +/// sometimes larger. Every program which consensus rejects for needing too +/// many cells is rejected here, and so are some which consensus accepts. // This value must be less than usize::MAX / 2 to avoid panics in this module. -const MAX_CELLS: usize = 2 * 1024 * 1024 * 1024 - 1; +const MAX_CELLS: usize = simplicity_sys::ffi::CELLS_MAX as usize; /// The maximum number of frames needed by the bit machine. /// /// This roughly corresponds to the maximum depth of nested `comp` and -/// `disconnect` combinators. In a blockchain context this should be -/// limited by the transaction's weight budget. +/// `disconnect` combinators. /// -/// The limit here is an absolute limit enforced by the library to avoid -/// unbounded allocations. +/// libsimplicity has no limit on frames of its own. Programs need at most +/// two frames per node, here and in libsimplicity, and consensus limits +/// programs to `DAG_LEN_MAX` nodes, so this only rejects programs which are +/// too long for consensus anyway. // This value must be less than usize::MAX / 2 to avoid panics in this module. -const MAX_FRAMES: usize = 1024 * 1024; +const MAX_FRAMES: usize = 2 * simplicity_sys::ffi::DAG_LEN_MAX; #[non_exhaustive] #[derive(Clone, Debug)] @@ -84,7 +87,7 @@ impl LimitError { if got > MAX_FRAMES { Err(Self::MaxFramesExceeded { got, - max: MAX_CELLS, + max: MAX_FRAMES, bound, }) } else { @@ -118,3 +121,19 @@ impl LimitError { Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn frame_error_reports_frame_limit() { + match LimitError::check_max_frames(MAX_FRAMES + 1, "test") { + Err(LimitError::MaxFramesExceeded { got, max, .. }) => { + assert_eq!(got, MAX_FRAMES + 1); + assert_eq!(max, MAX_FRAMES); + } + res => panic!("expected MaxFramesExceeded, got {:?}", res), + } + } +} diff --git a/src/node/mod.rs b/src/node/mod.rs index 99afe1e1..a0251433 100644 --- a/src/node/mod.rs +++ b/src/node/mod.rs @@ -837,7 +837,7 @@ impl Drop for Node { mod tests { use ffi::tests::TestData; - use crate::analysis::Cost; + use crate::analysis::{Cost, IO_EXTRA_FRAMES}; use crate::ffi; use crate::jet::Elements; use crate::BitIter; @@ -846,7 +846,7 @@ mod tests { fn check_merkle_roots(test: &TestData) { let prog = BitIter::from(test.prog.as_slice()); let witness = BitIter::from(test.witness.as_slice()); - ffi::tests::run_program( + let c_result = ffi::tests::run_program( &test.prog, &test.witness, ffi::tests::TestUpTo::CheckOneOne, @@ -858,7 +858,15 @@ mod tests { assert_eq!(prog.cmr().to_byte_array(), test.cmr); assert_eq!(prog.amr().to_byte_array(), test.amr); assert_eq!(prog.ihr().to_byte_array(), test.ihr); - assert_eq!(prog.bounds().cost, Cost::from_milliweight(test.cost)) + assert_eq!(prog.bounds().cost, Cost::from_milliweight(test.cost)); + + // Our Bit Machine lacks tail call optimization, so it needs at least as + // many cells and frames as libsimplicity's. + let io_cells = prog.arrow().source.bit_width() + prog.arrow().target.bit_width(); + let cells = io_cells + prog.bounds().extra_cells; + let frames = prog.bounds().extra_frames + IO_EXTRA_FRAMES; + assert!(cells >= c_result.cell_bound as usize); + assert!(frames >= c_result.frame_bound as usize); } #[test]