diff --git a/Cargo.lock b/Cargo.lock index 028956dba97..f1994c1a681 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -11925,6 +11925,7 @@ dependencies = [ "num 0.4.1", "once_cell", "petgraph 0.6.5", + "serde", "serde_json", "walkdir", ] diff --git a/aptos-move/framework/aptos-experimental/sources/confidential_asset/confidential_balance.move b/aptos-move/framework/aptos-experimental/sources/confidential_asset/confidential_balance.move index 32e847ce642..941eff93f00 100644 --- a/aptos-move/framework/aptos-experimental/sources/confidential_asset/confidential_balance.move +++ b/aptos-move/framework/aptos-experimental/sources/confidential_asset/confidential_balance.move @@ -383,4 +383,43 @@ module aptos_experimental::confidential_balance { ok } + + // + // Fuzz tests + // + + // `split_into_chunks_u64`/`_u128` decompose an integer into 16-bit limbs and + // lift each limb to a Ristretto `Scalar`. The shift/mask logic + // (`amount >> (i * CHUNK_SIZE_BITS) & 0xffff`) is exactly where an off-by-one + // in the chunk count, the bit width, or the limb ordering slips past + // hand-picked examples. We fuzz the full integer range and check each emitted + // scalar against an independently computed little-endian limb decomposition; + // the assertion only holds if every chunk is the correct 16 bits in the + // correct position, and it covers the high limbs that fixed examples rarely + // reach. + #[test] + fun fuzz_split_into_chunks_u64(amount: u64) { + let chunks = split_into_chunks_u64(amount); + assert!(vector::length(&chunks) == PENDING_BALANCE_CHUNKS, 0); + let i = 0; + while (i < PENDING_BALANCE_CHUNKS) { + let expected = (amount >> ((i * CHUNK_SIZE_BITS) as u8)) & 0xffff; + let expected_scalar = ristretto255::new_scalar_from_u64(expected); + assert!(ristretto255::scalar_equals(vector::borrow(&chunks, i), &expected_scalar), 1); + i = i + 1; + }; + } + + #[test] + fun fuzz_split_into_chunks_u128(amount: u128) { + let chunks = split_into_chunks_u128(amount); + assert!(vector::length(&chunks) == ACTUAL_BALANCE_CHUNKS, 0); + let i = 0; + while (i < ACTUAL_BALANCE_CHUNKS) { + let expected = (amount >> ((i * CHUNK_SIZE_BITS) as u8)) & 0xffff; + let expected_scalar = ristretto255::new_scalar_from_u128(expected); + assert!(ristretto255::scalar_equals(vector::borrow(&chunks, i), &expected_scalar), 1); + i = i + 1; + }; + } } diff --git a/aptos-move/framework/aptos-framework/sources/account/rate_limiter.move b/aptos-move/framework/aptos-framework/sources/account/rate_limiter.move index 68c2ccc9850..9806a1c6ed5 100644 --- a/aptos-move/framework/aptos-framework/sources/account/rate_limiter.move +++ b/aptos-move/framework/aptos-framework/sources/account/rate_limiter.move @@ -169,4 +169,67 @@ module aptos_framework::rate_limiter { refill(&mut bucket); assert!(bucket.current_amount == 10, 603); // Should be full again } + + // + // Fuzz tests + // + + // A token bucket must never hold more than its capacity, no matter how + // requests and time-based refills interleave. `refill`'s arithmetic is where + // a fuzzer earns its keep: a zero `refill_interval` divides by zero, and an + // unbounded `capacity`/elapsed pair overflows `time_passed * capacity` or the + // running `current_amount + new_tokens`. We bound the drawn values into + // realistic ranges so the test exercises the logic rather than tripping the + // u64 guardrails, and assert the capacity invariant plus exact accounting on + // the no-elapsed-time path. + #[test(aptos_framework = @0x1)] + fun fuzz_request_respects_capacity( + aptos_framework: &signer, + cap_raw: u64, + interval_raw: u64, + req_raw: u64, + ) { + timestamp::set_time_has_started_for_testing(aptos_framework); + let capacity = 1 + cap_raw % 1000000; // [1, 1_000_000] + let interval = 1 + interval_raw % 86400; // [1, 86400]; never 0 + let bucket = initialize(capacity, interval); + + // No time has elapsed since initialize(), so the refill inside request() + // adds nothing: a full bucket grants exactly `req` when req <= capacity. + let req = req_raw % (capacity + 1); // [0, capacity] + let granted = request(&mut bucket, req); + assert!(granted, 0); + assert!(bucket.current_amount == capacity - req, 1); + assert!(bucket.current_amount <= bucket.capacity, 2); + } + + #[test(aptos_framework = @0x1)] + fun fuzz_refill_never_exceeds_capacity( + aptos_framework: &signer, + cap_raw: u64, + interval_raw: u64, + elapsed_raw: u64, + ) { + timestamp::set_time_has_started_for_testing(aptos_framework); + let capacity = 1 + cap_raw % 1000000; // [1, 1_000_000] + let interval = 1 + interval_raw % 86400; // [1, 86400] + let bucket = initialize(capacity, interval); + + // Drain fully, advance the clock by a bounded, strictly positive amount + // (update_global_time_for_test requires time to move forward), then + // refill. With capacity <= 1e6 and elapsed <= 1e5, `time_passed * + // capacity` <= 1e11 — well inside u64 — so we test the refill logic, not + // overflow. + assert!(request(&mut bucket, capacity), 0); + assert!(bucket.current_amount == 0, 1); + + let elapsed = 1 + elapsed_raw % 100000; // [1, 100_000] seconds + timestamp::update_global_time_for_test_secs(timestamp::now_seconds() + elapsed); + refill(&mut bucket); + + // A refill tops up to, but never beyond, capacity... + assert!(bucket.current_amount <= bucket.capacity, 2); + // ...and any carried-over fractional accumulation is a proper remainder. + assert!(bucket.fractional_accumulated < interval, 3); + } } diff --git a/aptos-move/framework/aptos-framework/sources/aggregator/optional_aggregator.move b/aptos-move/framework/aptos-framework/sources/aggregator/optional_aggregator.move index a5d9794862c..f9a982a29d6 100644 --- a/aptos-move/framework/aptos-framework/sources/aggregator/optional_aggregator.move +++ b/aptos-move/framework/aptos-framework/sources/aggregator/optional_aggregator.move @@ -283,4 +283,54 @@ module aptos_framework::optional_aggregator { destroy(aggregator); } + + // + // Fuzz tests + // + + // `add_integer` aborts unless `value <= limit - current`, and `sub_integer` + // aborts unless `value <= current`. The bug classes worth hunting are an + // off-by-one at the limit boundary and an underflow in the `limit - value` + // headroom computation. We fuzz the limit and both operands independently + // and fold each operand into the always-legal range inside the body, then + // assert the stored value tracks the adds and subs exactly and never exceeds + // the limit. The fold keeps every drawn case on the success path, so the + // assertions — not aborts — are what catch a regression. + #[test] + fun fuzz_integer_add_sub_tracks_value(limit: u128, add_raw: u128, sub_raw: u128) { + let integer = new_integer(limit); + assert!(read_integer(&integer) == 0, 0); + + // `v` in [0, limit]. `limit + 1` would overflow only at limit == MAX_U128, + // where every u128 already satisfies v <= limit. + let v = if (limit == MAX_U128) { add_raw } else { add_raw % (limit + 1) }; + add_integer(&mut integer, v); + assert!(read_integer(&integer) == v, 1); + assert!(read_integer(&integer) <= limit, 2); + + // `s` in [0, v]; same overflow guard against v == MAX_U128. + let s = if (v == MAX_U128) { sub_raw } else { sub_raw % (v + 1) }; + sub_integer(&mut integer, s); + assert!(read_integer(&integer) == v - s, 3); + + destroy_integer(integer); + } + + // Same accounting property exercised end-to-end through the public + // `add`/`sub`/`read` surface on the integer-backed aggregator, whose limit is + // MAX_U128 (so any single u128 operand fits). This covers the option dispatch + // in `add`/`sub`/`read` that the internal test above bypasses. + #[test(account = @aptos_framework)] + fun fuzz_optional_aggregator_roundtrip(account: signer, a: u128, b: u128) { + aggregator_factory::initialize_aggregator_factory(&account); + let aggregator = new(false); + add(&mut aggregator, a); + assert!(read(&aggregator) == a, 0); + + let s = if (a == MAX_U128) { b } else { b % (a + 1) }; + sub(&mut aggregator, s); + assert!(read(&aggregator) == a - s, 1); + + destroy(aggregator); + } } diff --git a/aptos-move/framework/aptos-framework/sources/storage_gas.move b/aptos-move/framework/aptos-framework/sources/storage_gas.move index 991b6192569..4826d5d0fc9 100644 --- a/aptos-move/framework/aptos-framework/sources/storage_gas.move +++ b/aptos-move/framework/aptos-framework/sources/storage_gas.move @@ -619,4 +619,44 @@ module aptos_framework::storage_gas { assert!(gas_parameter.per_byte_read == 1000, 0); }; } + + // + // Fuzz tests + // + + // `interpolate` is integer linear interpolation: + // y0 + (x - x0) * (y1 - y0) / (x1 - x0) + // It is only well-defined when x0 < x1 (else division by zero), x0 <= x <= x1 + // and y0 <= y1 (else u64 underflow in the subtractions), and when the + // intermediate product stays within u64. Fuzz constraints are applied per + // parameter and cannot express these cross-parameter relationships, so we + // draw five unconstrained u64s and fold them into a valid configuration in + // the body. The property under test is the defining one for linear + // interpolation: the output never leaves the [y0, y1] band, and the + // endpoints map exactly. + #[test] + fun fuzz_interpolate_stays_within_bounds( + x0_raw: u64, + span_raw: u64, + frac_raw: u64, + y0_raw: u64, + dy_raw: u64, + ) { + // Keep the x-coordinates in basis-point territory (the only domain + // `interpolate` is ever called with) and the y-values well below the + // overflow threshold: (x - x0) <= 10_000 and (y1 - y0) < 1_000_000_000, so + // the product is at most ~1e13, comfortably inside u64. + let x0 = x0_raw % 10000; + let x1 = x0 + 1 + (span_raw % 10000); // x1 in [x0 + 1, x0 + 10000] + let x = x0 + (frac_raw % (x1 - x0 + 1)); // x in [x0, x1] + let y0 = y0_raw % 1000000000; + let y1 = y0 + (dy_raw % 1000000000); // y1 in [y0, y0 + 1e9 - 1] + + let y = interpolate(x0, x1, y0, y1, x); + assert!(y >= y0, 0); + assert!(y <= y1, 1); + // Endpoints are exact: at x0 the offset is 0, at x1 it is the full (y1 - y0). + assert!(interpolate(x0, x1, y0, y1, x0) == y0, 2); + assert!(interpolate(x0, x1, y0, y1, x1) == y1, 3); + } } diff --git a/third_party/move/move-compiler-v2/Cargo.toml b/third_party/move/move-compiler-v2/Cargo.toml index b8c3a787afd..caf8de0645b 100644 --- a/third_party/move/move-compiler-v2/Cargo.toml +++ b/third_party/move/move-compiler-v2/Cargo.toml @@ -36,6 +36,7 @@ move-symbol-pool = { workspace = true } num = { workspace = true } once_cell = { workspace = true } petgraph = { workspace = true } +serde = { workspace = true } serde_json = { workspace = true } [dev-dependencies] diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/ast.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/ast.rs index 34e84c50ce1..68fbad94f3e 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/ast.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/ast.rs @@ -45,14 +45,28 @@ pub enum AttributeValue_ { Value(Value), Module(ModuleIdent), ModuleAccess(ModuleAccess), + List(Vec), + Range { + lo: Box, + hi: Box, + inclusive_hi: bool, + }, + Union(Vec), } pub type AttributeValue = Spanned; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConstraintOp { + Ne, + In, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Attribute_ { Name(Name), Assigned(Name, Box), Parameterized(Name, Attributes), + Constrained(Name, ConstraintOp, Box), } pub type Attribute = Spanned; @@ -61,7 +75,8 @@ impl Attribute_ { match self { Attribute_::Name(nm) | Attribute_::Assigned(nm, _) - | Attribute_::Parameterized(nm, _) => nm, + | Attribute_::Parameterized(nm, _) + | Attribute_::Constrained(nm, _, _) => nm, } } } @@ -70,6 +85,11 @@ impl Attribute_ { pub enum AttributeName_ { Unknown(Symbol), Known(KnownAttribute), + /// Unique-by-construction key used by `Attribute_::Constrained` entries. + /// `slot` makes duplicate constraints on the same parameter (e.g. + /// `a in X, a != Y`) distinct in the [`UniqueMap`] that stores attributes, + /// while [`Display`](std::fmt::Display) still shows the underlying name. + Disambiguated(Symbol, u32), } pub type AttributeName = Spanned; @@ -880,6 +900,7 @@ impl fmt::Display for AttributeName_ { match self { AttributeName_::Unknown(sym) => write!(f, "{}", sym), AttributeName_::Known(known) => write!(f, "{}", known.name()), + AttributeName_::Disambiguated(sym, _) => write!(f, "{}", sym), } } } @@ -990,6 +1011,29 @@ impl AstDebug for AttributeValue_ { AttributeValue_::Value(v) => v.ast_debug(w), AttributeValue_::Module(m) => w.write(&format!("{}", m)), AttributeValue_::ModuleAccess(n) => n.ast_debug(w), + AttributeValue_::List(items) => { + w.write("["); + w.list(items, ", ", |w, item| { + item.value.ast_debug(w); + false + }); + w.write("]"); + }, + AttributeValue_::Range { + lo, + hi, + inclusive_hi, + } => { + lo.value.ast_debug(w); + w.write(if *inclusive_hi { "..=" } else { ".." }); + hi.value.ast_debug(w); + }, + AttributeValue_::Union(items) => { + w.list(items, " | ", |w, item| { + item.value.ast_debug(w); + false + }); + }, } } } @@ -1012,6 +1056,14 @@ impl AstDebug for Attribute_ { }); w.write(")"); }, + Attribute_::Constrained(n, op, v) => { + w.write(&format!("{}", n)); + w.write(match op { + ConstraintOp::Ne => " != ", + ConstraintOp::In => " in ", + }); + v.ast_debug(w); + }, } } } diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/translate.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/translate.rs index 0f785292bd3..9d4c53088d0 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/translate.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/expansion/translate.rs @@ -698,7 +698,8 @@ fn deprecated_attribute_location(attributes: &[P::Attributes]) -> Option { let sp!(nloc, sym) = match &attr.value { P::Attribute_::Name(n) | P::Attribute_::Assigned(n, _) - | P::Attribute_::Parameterized(n, _) => *n, + | P::Attribute_::Parameterized(n, _) + | P::Attribute_::Constrained(n, _, _) => *n, }; match KnownAttribute::resolve(sym) { Some(KnownAttribute::Deprecation(_dep)) => Some(nloc), @@ -728,11 +729,14 @@ fn unique_attributes( attributes: impl IntoIterator, ) -> E::Attributes { let mut attr_map = UniqueMap::new(); + let mut constrained_slot: u32 = 0; for sp!(loc, attr_) in attributes { + let is_constrained = matches!(&attr_, E::Attribute_::Constrained(..)); let sp!(nloc, sym) = match &attr_ { E::Attribute_::Name(n) | E::Attribute_::Assigned(n, _) - | E::Attribute_::Parameterized(n, _) => *n, + | E::Attribute_::Parameterized(n, _) + | E::Attribute_::Constrained(n, _, _) => *n, }; let name_ = match KnownAttribute::resolve(sym) { None => { @@ -797,7 +801,17 @@ fn unique_attributes( E::AttributeName_::Known(known) }, }; - if let Err((_, old_loc)) = attr_map.add(sp(nloc, name_), sp(loc, attr_)) { + // `Constrained` entries are deliberately allowed to repeat on the same parameter + // (e.g. `a in [..], a != 5`) — give each a unique slot so the `UniqueMap` accepts + // them. The `Disambiguated` key still `Display`s as the underlying name. + let key = if is_constrained { + let slot = constrained_slot; + constrained_slot += 1; + sp(nloc, E::AttributeName_::Disambiguated(sym, slot)) + } else { + sp(nloc, name_) + }; + if let Err((_, old_loc)) = attr_map.add(key, sp(loc, attr_)) { let msg = format!("Duplicate attribute '{}' attached to the same item", name_); context.env.add_diag(diag!( Declarations::DuplicateItem, @@ -826,6 +840,13 @@ fn attribute( .collect::>>()?; EA::Parameterized(n, unique_attributes(context, attr_position, true, attrs)) }, + PA::Constrained(n, op, v) => { + let op = match op { + P::ConstraintOp::Ne => E::ConstraintOp::Ne, + P::ConstraintOp::In => E::ConstraintOp::In, + }; + EA::Constrained(n, op, Box::new(attribute_value(context, *v)?)) + }, })) } @@ -919,6 +940,29 @@ fn attribute_value( )?), } }, + PV::List(items) => { + let items = items + .into_iter() + .map(|v| attribute_value(context, v)) + .collect::>>()?; + EV::List(items) + }, + PV::Range { + lo, + hi, + inclusive_hi, + } => EV::Range { + lo: Box::new(attribute_value(context, *lo)?), + hi: Box::new(attribute_value(context, *hi)?), + inclusive_hi, + }, + PV::Union(items) => { + let items = items + .into_iter() + .map(|v| attribute_value(context, v)) + .collect::>>()?; + EV::Union(items) + }, })) } diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/ast.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/ast.rs index 3013b72d5a0..3bfd3040b84 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/ast.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/ast.rs @@ -115,14 +115,28 @@ pub struct UseDecl { pub enum AttributeValue_ { Value(Value), ModuleAccess(NameAccessChain), + List(Vec), + Range { + lo: Box, + hi: Box, + inclusive_hi: bool, + }, + Union(Vec), } pub type AttributeValue = Spanned; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConstraintOp { + Ne, + In, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum Attribute_ { Name(Name), Assigned(Name, Box), Parameterized(Name, Attributes), + Constrained(Name, ConstraintOp, Box), } pub type Attribute = Spanned; @@ -133,7 +147,8 @@ impl Attribute_ { match self { Attribute_::Name(nm) | Attribute_::Assigned(nm, _) - | Attribute_::Parameterized(nm, _) => nm, + | Attribute_::Parameterized(nm, _) + | Attribute_::Constrained(nm, _, _) => nm, } } } @@ -1195,6 +1210,29 @@ impl AstDebug for AttributeValue_ { match self { AttributeValue_::Value(v) => v.ast_debug(w), AttributeValue_::ModuleAccess(n) => n.ast_debug(w), + AttributeValue_::List(items) => { + w.write("["); + w.list(items, ", ", |w, item| { + item.value.ast_debug(w); + false + }); + w.write("]"); + }, + AttributeValue_::Range { + lo, + hi, + inclusive_hi, + } => { + lo.value.ast_debug(w); + w.write(if *inclusive_hi { "..=" } else { ".." }); + hi.value.ast_debug(w); + }, + AttributeValue_::Union(items) => { + w.list(items, " | ", |w, item| { + item.value.ast_debug(w); + false + }); + }, } } } @@ -1217,6 +1255,14 @@ impl AstDebug for Attribute_ { }); w.write(")"); }, + Attribute_::Constrained(n, op, v) => { + w.write(&format!("{}", n)); + w.write(match op { + ConstraintOp::Ne => " != ", + ConstraintOp::In => " in ", + }); + v.ast_debug(w); + }, } } } diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/lexer.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/lexer.rs index 4bbbc164e26..d6464663c89 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/lexer.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/lexer.rs @@ -33,6 +33,7 @@ pub enum Tok { Minus, Period, PeriodPeriod, + PeriodPeriodEqual, Slash, Colon, ColonColon, @@ -130,6 +131,7 @@ impl fmt::Display for Tok { Minus => "-", Period => ".", PeriodPeriod => "..", + PeriodPeriodEqual => "..=", Slash => "/", Colon => ":", ColonColon => "::", @@ -649,7 +651,9 @@ fn find_token( } }, '.' => { - if text.starts_with("..") { + if text.starts_with("..=") { + (Tok::PeriodPeriodEqual, 3) + } else if text.starts_with("..") { (Tok::PeriodPeriod, 2) } else { (Tok::Period, 1) diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/syntax.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/syntax.rs index 5cffb4308f9..5a7386d730f 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/syntax.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/parser/syntax.rs @@ -714,11 +714,75 @@ fn parse_visibility(context: &mut Context) -> Result }) } -// Parse an attribute value. Either a value literal or a module access -// AttributeValue = -// -// | +// Parse an attribute value. +// AttributeValue = +// UnionValue = ( "|" )* +// RangeValue = [ ( ".." | "..=" ) ] +// PrimaryValue = +// | "[" Comma "]" +// | fn parse_attribute_value(context: &mut Context) -> Result> { + let start_loc = context.tokens.start_loc(); + let first = parse_attribute_range_value(context)?; + if context.tokens.peek() != Tok::Pipe { + return Ok(first); + } + let mut elements = vec![first]; + while match_token(context.tokens, Tok::Pipe)? { + elements.push(parse_attribute_range_value(context)?); + } + let end_loc = context.tokens.previous_end_loc(); + Ok(spanned( + context.tokens.file_hash(), + start_loc, + end_loc, + AttributeValue_::Union(elements), + )) +} + +fn parse_attribute_range_value(context: &mut Context) -> Result> { + let start_loc = context.tokens.start_loc(); + let lo = parse_attribute_primary_value(context)?; + let inclusive_hi = match context.tokens.peek() { + Tok::PeriodPeriod => false, + Tok::PeriodPeriodEqual => true, + _ => return Ok(lo), + }; + context.tokens.advance()?; + let hi = parse_attribute_primary_value(context)?; + let end_loc = context.tokens.previous_end_loc(); + Ok(spanned( + context.tokens.file_hash(), + start_loc, + end_loc, + AttributeValue_::Range { + lo: Box::new(lo), + hi: Box::new(hi), + inclusive_hi, + }, + )) +} + +fn parse_attribute_primary_value( + context: &mut Context, +) -> Result> { + let start_loc = context.tokens.start_loc(); + if context.tokens.peek() == Tok::LBracket { + let items = parse_comma_list( + context, + Tok::LBracket, + Tok::RBracket, + parse_attribute_value, + "attribute value", + )?; + let end_loc = context.tokens.previous_end_loc(); + return Ok(spanned( + context.tokens.file_hash(), + start_loc, + end_loc, + AttributeValue_::List(items), + )); + } if let Some(v) = maybe_parse_value(context)? { return Ok(sp(v.loc, AttributeValue_::Value(v))); } @@ -731,6 +795,8 @@ fn parse_attribute_value(context: &mut Context) -> Result // | "=" +// | "!=" +// | "in" // | "(" Comma ")" // AttributeName = ( "::" Identifier )* // merged into one identifier fn parse_attribute(context: &mut Context) -> Result> { @@ -747,6 +813,22 @@ fn parse_attribute(context: &mut Context) -> Result> context.tokens.advance()?; Attribute_::Assigned(n, Box::new(parse_attribute_value(context)?)) }, + Tok::ExclaimEqual => { + context.tokens.advance()?; + Attribute_::Constrained( + n, + ConstraintOp::Ne, + Box::new(parse_attribute_value(context)?), + ) + }, + Tok::Identifier if context.tokens.content() == "in" => { + context.tokens.advance()?; + Attribute_::Constrained( + n, + ConstraintOp::In, + Box::new(parse_attribute_value(context)?), + ) + }, Tok::LParen => { let args_ = parse_comma_list( context, diff --git a/third_party/move/move-compiler-v2/legacy-move-compiler/src/unit_test/mod.rs b/third_party/move/move-compiler-v2/legacy-move-compiler/src/unit_test/mod.rs index e082ca973cd..bcb99dfa6c5 100644 --- a/third_party/move/move-compiler-v2/legacy-move-compiler/src/unit_test/mod.rs +++ b/third_party/move/move-compiler-v2/legacy-move-compiler/src/unit_test/mod.rs @@ -33,6 +33,10 @@ pub struct TestPlan { // `NamedCompiledModule` for compiled modules with source, // `CompiledModule` for modules with bytecode only pub module_info: BTreeMap, + /// Opaque metadata that downstream runners may consult — e.g. the + /// `move-unit-test` runner stores `FuzzPlanMetadata` + fuzz source here + /// to enable shrinking/mutation. Legacy code does not introspect it. + pub runner_metadata: Option>, } #[derive(Debug, Clone)] @@ -43,7 +47,14 @@ pub struct ModuleTestPlan { #[derive(Debug, Clone)] pub struct TestCase { + /// Display/identity name of this case. For fuzz/matrix expansion this is + /// decorated and made unique (e.g. `foo#3[a=42]`), so it is NOT a valid + /// Move identifier and must not be used to look up the function. pub test_name: TestName, + /// The real Move function symbol to invoke (e.g. `foo`). Always a valid + /// identifier. Runners must use this — not `test_name` — when loading the + /// function from the VM. + pub function_name: TestName, pub arguments: Vec, pub expected_failure: Option, } @@ -125,6 +136,7 @@ impl TestPlan { files, module_tests, module_info, + runner_metadata: None, } } } diff --git a/third_party/move/move-compiler-v2/src/file_format_generator/module_generator.rs b/third_party/move/move-compiler-v2/src/file_format_generator/module_generator.rs index ab2b8fb6733..dd87510d5b0 100644 --- a/third_party/move/move-compiler-v2/src/file_format_generator/module_generator.rs +++ b/third_party/move/move-compiler-v2/src/file_format_generator/module_generator.rs @@ -1188,6 +1188,18 @@ impl ModuleContext<'_> { ) } }, + Attribute::Constrained(_, name, _, _) => { + let name = fun_env.symbol_pool().string(*name); + if matches!( + name.as_str(), + well_known::PERSISTENT_ATTRIBUTE | well_known::MODULE_LOCK_ATTRIBUTE + ) { + self.error( + fun_env.get_id_loc(), + format!("attribute `{}` cannot have a constraint", name), + ) + } + }, } } if !has_persistent && fun_env.visibility() == Visibility::Public { diff --git a/third_party/move/move-compiler-v2/src/fuzz.rs b/third_party/move/move-compiler-v2/src/fuzz.rs new file mode 100644 index 00000000000..4221c57d0fd --- /dev/null +++ b/third_party/move/move-compiler-v2/src/fuzz.rs @@ -0,0 +1,1511 @@ +// Copyright (c) Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! Fuzz value generation for the `#[test]` attribute. +//! +//! ## Design +//! +//! Modeled on Foundry's fuzz architecture (`crates/evm/fuzz/`): the compiler +//! does not pick fuzz values itself. It collects per-parameter constraints +//! (`a in `, `a != `, or absence-of-spec) into a [`ParamSpec`] +//! and asks a [`FuzzValueSource`] to materialize concrete values for each +//! parameter at plan-build time. +//! +//! Three sources of values are mixed when sampling a primitive parameter, in +//! the same spirit as Foundry's `UintStrategy` (`crates/evm/fuzz/src/strategies/uint.rs`): +//! +//! - **Random** — pseudo-random values drawn from the parameter type's full +//! width (default 50%). +//! - **Edge cases** — boundary values like `0`, `1`, `MAX`, `MAX-1`, `MAX/2` +//! to catch off-by-one bugs (default 10%). +//! - **Dictionary** — values mined from the surrounding Move program: named +//! address aliases and module-level constants of compatible primitive +//! types (default 40%). +//! +//! Domain (`a in ...`) and exclude (`a != ...`) sets filter the candidate +//! after generation; on a reject we redraw, capped at [`FUZZ_RETRIES`] times +//! the requested count to avoid pathological loops. +//! +//! The implementation is deterministic given the seed and intentionally +//! avoids `proptest` to keep the dependency surface small — the Foundry +//! analogue is `proptest` driven, but for a Move test runner the value +//! pipeline can be a plain seeded RNG since we do not (yet) need shrinking. + +use move_core_types::{ + account_address::AccountAddress, language_storage::ModuleId, u256, value::MoveValue, +}; +use move_model::{ + ast::{Address, AttributeValue, Value}, + model::GlobalEnv, + ty::{PrimitiveType, Type}, +}; +use num::{bigint::Sign, BigInt, ToPrimitive}; +use std::collections::BTreeMap; + +// --------------------------------------------------------------------------- +// Public surface — unchanged from Phase 2 +// --------------------------------------------------------------------------- + +/// A single inclusive/half-open range. Both endpoints are model-AST literals +/// because the compiler does not commit to a concrete representation until the +/// source materializes a sample for the parameter type. +#[derive(Debug, Clone)] +pub struct RangeSpec { + pub lo: AttributeValue, + pub hi: AttributeValue, + pub inclusive_hi: bool, +} + +/// A union of discrete literals and ranges. An empty domain is treated as +/// "unrestricted" when used as a fuzz `domain`, and as "no exclusions" when +/// used as an `exclude`. +#[derive(Debug, Clone, Default)] +pub struct Domain { + pub literals: Vec, + pub ranges: Vec, +} + +impl Domain { + pub fn is_empty(&self) -> bool { + self.literals.is_empty() && self.ranges.is_empty() + } +} + +/// What the compiler resolved for one function parameter after reading the +/// `#[test(...)]` attribute and any constraints attached to it. +#[derive(Debug, Clone)] +pub enum ParamSpec { + /// `a = ` — a single explicit value. + Concrete(MoveValue), + /// `a = [, , ...]` — a matrix that expands into N cases. + Matrix(Vec), + /// `a` not mentioned, or `a in ...` / `a != ...`. The fuzz source samples + /// `n` values from `domain` (unrestricted when empty), subject to + /// `exclude`. + Fuzz { domain: Domain, exclude: Domain }, +} + +// --------------------------------------------------------------------------- +// Plan metadata sidecar — Topic 3 / Topic 2 +// --------------------------------------------------------------------------- + +/// Per-argument origin for an expanded test case. The runner consults this to +/// decide whether shrinking and mutation are applicable to a failing case. +#[derive(Debug, Clone)] +pub enum ArgOrigin { + /// The argument came from a `Concrete` or `Matrix` spec; not shrinkable. + Fixed, + /// The argument was drawn by the fuzz source; eligible for shrink/mutate. + Fuzz { + param_name: String, + ty: Type, + domain: Domain, + exclude: Domain, + }, +} + +/// Map from `(module_id, expanded_test_name)` to per-argument origin. The +/// runner can look up an entry for a failing test to know which arguments to +/// shrink and which to keep fixed. +#[derive(Debug, Default, Clone)] +pub struct FuzzPlanMetadata { + pub entries: BTreeMap<(ModuleId, String), Vec>, +} + +impl FuzzPlanMetadata { + pub fn insert(&mut self, module_id: ModuleId, test_name: String, origins: Vec) { + self.entries.insert((module_id, test_name), origins); + } + + pub fn get(&self, module_id: &ModuleId, test_name: &str) -> Option<&Vec> { + // `entries` is keyed by the same `(ModuleId, String)` tuple used at + // insert time, and both components are `Ord`, so a direct keyed lookup + // is correct and O(log n) — no need to linear-scan. (A `&str` borrow of + // the tuple key isn't possible, so we rebuild an owned key.) + self.entries + .get(&(module_id.clone(), test_name.to_string())) + } +} + +/// Plugged in by the unit-test entrypoint. The compiler never instantiates +/// fuzz values itself; it only collects constraints. +/// +/// Three methods, two with default impls — implementers only need [`sample`]. +/// [`shrink`] gives counterexample minimization (Topic 3); [`mutate`] backs +/// the corpus-driven path (Topic 2). +pub trait FuzzValueSource: Send + Sync { + /// Materialize `n` values of type `ty` for parameter `param_name`, drawn + /// from `domain` (unrestricted when empty) and avoiding any value in + /// `exclude`. `seed` is provided for reproducibility. + /// + /// Passing `n == 0` requests the source's own configured run count (for + /// [`DefaultFuzzSource`] that is [`FuzzConfig::runs`]). Callers that are + /// generic over the source — like the plan builder — pass `0` so the + /// `--fuzz-runs` setting is honored instead of a hardcoded count. + /// + /// `param_name` enables Foundry-style fixtures — sources can route + /// per-parameter using user-declared `FIXTURE_` constants. The + /// caller passes the parameter's display string so the trait doesn't + /// depend on a `SymbolPool`. + fn sample( + &self, + ty: &Type, + param_name: &str, + domain: &Domain, + exclude: &Domain, + n: usize, + seed: u64, + ) -> Result, String>; + + /// Produce a "smaller" candidate close to `current` for shrinking a failing + /// counterexample. Returns `None` when no further shrinking is possible — + /// the runner stops shrinking when this returns `None` or when no shrink + /// candidate still reproduces the failure. + /// + /// Default: no shrinking. Override for type-aware minimization. + fn shrink( + &self, + _ty: &Type, + _param_name: &str, + _current: &MoveValue, + _domain: &Domain, + _exclude: &Domain, + ) -> Option { + None + } + + /// Mutate `current` to a nearby value for corpus-driven exploration. + /// Returns `None` when no useful mutation is available. The mutated value + /// must still satisfy the original `domain` / `exclude`. + /// + /// Default: no mutation. Override when wiring corpus replay. + fn mutate( + &self, + _ty: &Type, + _param_name: &str, + _current: &MoveValue, + _domain: &Domain, + _exclude: &Domain, + _seed: u64, + ) -> Option { + None + } + + /// The base RNG seed this source draws from, when it is seed-driven. + /// Surfaced so the plan builder can label expanded fuzz cases with the seed + /// needed to reproduce them (e.g. `#3[seed=0,amount=28]`). Returns `None` + /// for sources without a reproducible seed. + fn base_seed(&self) -> Option { + None + } +} + +/// Default source that produces no samples and reports a clear error. Plug a +/// real implementation in to light up the fuzz path. +pub struct NoFuzzSource; + +impl FuzzValueSource for NoFuzzSource { + fn sample( + &self, + _ty: &Type, + _param_name: &str, + _domain: &Domain, + _exclude: &Domain, + _n: usize, + _seed: u64, + ) -> Result, String> { + Err( + "no fuzz value source is registered; install a `FuzzValueSource` to enable \ + implicit-fuzz #[test] expansion" + .to_string(), + ) + } +} + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +/// Default number of samples drawn per implicit-fuzz `#[test]` parameter. +/// +/// This is the *single source of truth* for the default run count: the +/// [`FuzzConfig::default`] field, the `--fuzz-runs` CLI flag, and the +/// `UnitTestingConfig` default all reference it, so there is exactly one place +/// to change. +/// +/// Set below Foundry's 256 on purpose. Each case is a full in-process MoveVM +/// execution, so the default governs inner-loop `move test` latency; and the +/// plan builder multiplies `runs` against the pairwise expansion of any +/// explicit `#[test]` matrices under the [`MAX_FUZZ_CASES`] cap, so a large +/// default eats the matrix headroom. 64 keeps the inner loop fast while leaving +/// room for matrix+fuzz combinations; raise `--fuzz-runs` (e.g. 256+ in a +/// nightly CI profile) for deeper search. +/// +/// [`MAX_FUZZ_CASES`]: crate::plan_builder +pub const DEFAULT_FUZZ_RUNS: usize = 64; + +/// Fallback base RNG seed for [`FuzzConfig::default`]. +/// +/// This is only the *library-level* default used when a `FuzzConfig` is built +/// directly. The unit-test CLI does NOT default `--fuzz-seed` to this: when the +/// flag is omitted, a fresh random seed is drawn per run (see [`random_seed`]) +/// so each run explores a different slice of the input space; pass +/// `--fuzz-seed N` to pin it and reproduce a specific run. +pub const DEFAULT_FUZZ_SEED: u64 = 0; + +/// Draw a nondeterministic `u64` seed for a fuzz run. Used when the caller did +/// not pin `--fuzz-seed`, so every run searches differently while still being +/// reproducible once the drawn value is logged. +/// +/// Sourced from `RandomState` (OS-seeded, per-instance random) to avoid pulling +/// in the `rand` crate — consistent with this module's no-`rand` policy. Only +/// used to pick a starting seed, never for the value stream itself, which stays +/// the deterministic SplitMix64 in [`Rng`]. +pub fn random_seed() -> u64 { + use std::hash::{BuildHasher, Hasher}; + std::collections::hash_map::RandomState::new() + .build_hasher() + .finish() +} + +/// Default relative weight (0..=100) of dictionary draws against the +/// random+edge strategies. Matches Foundry's `dictionary_weight` of 40. +/// +/// Single source of truth shared by [`FuzzConfig::default`] and the +/// `--fuzz-dictionary-weight` CLI flag. Participates in the +/// `random + edge + dictionary == 100` weighting (with [`EDGE_WEIGHT`]), so the +/// CLI and library defaults must not drift apart. +pub const DEFAULT_FUZZ_DICTIONARY_WEIGHT: u8 = 40; + +/// Tunables for [`DefaultFuzzSource`]. The knob *names* mirror Foundry's +/// `[fuzz]` section so users coming from EVM tooling find familiar dials, but +/// the defaults are not identical: `runs` defaults to [`DEFAULT_FUZZ_RUNS`] +/// rather than Foundry's 256 (see that constant for why). +/// `dictionary_weight` does match Foundry's default of +/// [`DEFAULT_FUZZ_DICTIONARY_WEIGHT`]. +#[derive(Clone, Debug)] +pub struct FuzzConfig { + /// Number of samples drawn per implicit-fuzz parameter. + pub runs: usize, + /// Base RNG seed. Independent of the seed argument passed to + /// [`FuzzValueSource::sample`] — the two are mixed together so that the + /// same source can produce stable but parameter-distinct streams. + pub seed: u64, + /// Relative weight (0..=100) of dictionary draws against the random+edge + /// strategies. Mirrors Foundry's `dictionary_weight` (default 40). + pub dictionary_weight: u8, + /// Maximum number of retries (relative to `runs`) when domain/exclude + /// constraints reject candidate draws. + pub max_retry_multiplier: usize, +} + +impl Default for FuzzConfig { + fn default() -> Self { + Self { + runs: DEFAULT_FUZZ_RUNS, + seed: DEFAULT_FUZZ_SEED, + dictionary_weight: DEFAULT_FUZZ_DICTIONARY_WEIGHT, + max_retry_multiplier: 64, + } + } +} + +/// Foundry's `prop_oneof` carves up 100. We use the same shape so the +/// invariants travel: `random + edge + dictionary == 100`. +const EDGE_WEIGHT: u8 = 10; + +// --------------------------------------------------------------------------- +// Dictionary — Foundry analogue of `crates/evm/fuzz/src/strategies/state.rs` +// --------------------------------------------------------------------------- + +/// Typed pool of "interesting values" mined from the Move program. Roughly +/// analogous to Foundry's `FuzzDictionary`, but Move-shaped: instead of +/// account addresses + bytecode PUSH bytes + storage values from the EVM DB, +/// we collect named addresses + module-constant values from the model. +/// +/// `fixtures` holds per-parameter-name pools harvested from constants whose +/// name starts with `FIXTURE_` / `fixture_` (case-insensitive). The +/// remainder of the name, lowercased, is the parameter key. E.g. +/// `const FIXTURE_AMOUNT: u64 = 42;` populates `fixtures["amount"]`. +#[derive(Default, Debug)] +pub struct FuzzDictionary { + pub addresses: Vec, + pub uints: Vec, + pub bools: Vec, + pub fixtures: BTreeMap, +} + +/// Typed buckets for a single parameter's fixtures. +#[derive(Default, Debug, Clone)] +pub struct FixturePool { + pub addresses: Vec, + pub uints: Vec, + pub bools: Vec, +} + +impl FixturePool { + fn is_empty(&self) -> bool { + self.addresses.is_empty() && self.uints.is_empty() && self.bools.is_empty() + } +} + +const FIXTURE_PREFIX: &str = "fixture_"; + +impl FuzzDictionary { + /// Walk the [`GlobalEnv`] and harvest: + /// - every resolved named-address alias, + /// - every primitive-typed module constant, + /// - per-name fixture pools from `FIXTURE_` constants. + pub fn from_env(env: &GlobalEnv) -> Self { + let mut d = FuzzDictionary::default(); + + for addr in env.get_address_alias_map().values() { + d.addresses.push(*addr); + } + + for module in env.get_modules() { + for c in module.get_named_constants() { + let raw_name = env.symbol_pool().string(c.get_name()).to_string(); + let lowered = raw_name.to_lowercase(); + let value = c.get_value(); + + // Dictionary-wide insertion. + insert_value_into_buckets(env, &value, &mut d.addresses, &mut d.uints, &mut d.bools); + + // Fixture insertion when the name matches the prefix. + if let Some(rest) = lowered.strip_prefix(FIXTURE_PREFIX) { + if !rest.is_empty() { + let pool = d.fixtures.entry(rest.to_string()).or_default(); + insert_value_into_buckets( + env, + &value, + &mut pool.addresses, + &mut pool.uints, + &mut pool.bools, + ); + } + } + } + } + + d.addresses.sort_unstable(); + d.addresses.dedup(); + d.uints.sort(); + d.uints.dedup(); + d.bools.sort(); + d.bools.dedup(); + for pool in d.fixtures.values_mut() { + pool.addresses.sort_unstable(); + pool.addresses.dedup(); + pool.uints.sort(); + pool.uints.dedup(); + pool.bools.sort(); + pool.bools.dedup(); + } + d + } + + /// Lookup a fixture pool by lowercased parameter name. Returns `None` + /// when no fixtures were declared for that name. + pub fn fixture_for(&self, param_name_lowered: &str) -> Option<&FixturePool> { + self.fixtures + .get(param_name_lowered) + .filter(|p| !p.is_empty()) + } +} + +/// Slot a model AST `Value` into the appropriate typed bucket(s). Address +/// aliases that resolve are flattened to their numeric form. +fn insert_value_into_buckets( + env: &GlobalEnv, + value: &Value, + addrs: &mut Vec, + uints: &mut Vec, + bools: &mut Vec, +) { + match value { + Value::Address(Address::Numerical(a)) => addrs.push(*a), + Value::Address(Address::Symbolic(s)) => { + if let Some(a) = env.resolve_address_alias(*s) { + addrs.push(a); + } + }, + Value::Number(n) => uints.push(n.clone()), + Value::Bool(b) => bools.push(*b), + // Vector / ByteArray / Tuple are skipped for now — see Phase-4 design notes. + _ => {}, + } +} + +// --------------------------------------------------------------------------- +// DefaultFuzzSource — Foundry analogue of `FuzzedExecutor` +// --------------------------------------------------------------------------- + +/// Built-in fuzz value source: deterministic per `(config.seed, sample-seed)`, +/// honors Move primitive types, and mixes random + edge + dictionary draws. +pub struct DefaultFuzzSource { + pub config: FuzzConfig, + pub dictionary: FuzzDictionary, +} + +impl DefaultFuzzSource { + /// Construct a source whose dictionary is harvested from `env`. Use this + /// from the unit-test entrypoint — the dictionary is rebuilt per + /// compilation. + pub fn new(env: &GlobalEnv, config: FuzzConfig) -> Self { + Self { + dictionary: FuzzDictionary::from_env(env), + config, + } + } + + /// Construct a source with an empty dictionary. Useful in tests where the + /// dictionary pollution shouldn't matter. + pub fn with_empty_dictionary(config: FuzzConfig) -> Self { + Self { + dictionary: FuzzDictionary::default(), + config, + } + } +} + +impl FuzzValueSource for DefaultFuzzSource { + fn sample( + &self, + ty: &Type, + param_name: &str, + domain: &Domain, + exclude: &Domain, + n: usize, + seed: u64, + ) -> Result, String> { + let count = if n == 0 { self.config.runs } else { n }; + // Mix the configured base seed with the parameter-specific seed so that + // two parameters of the same type don't generate identical streams. + let mut rng = Rng(self.config.seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(seed)); + let fixture_pool = self.dictionary.fixture_for(¶m_name.to_lowercase()); + + match prim_kind(ty) { + Some(PrimKind::AddressLike(addr_like)) => sample_addresses( + &mut rng, + count, + addr_like, + domain, + exclude, + &self.dictionary, + fixture_pool, + ), + Some(PrimKind::Uint(width)) => sample_uints( + &mut rng, + count, + width, + domain, + exclude, + &self.dictionary, + fixture_pool, + self.config.dictionary_weight, + self.config.max_retry_multiplier, + ), + Some(PrimKind::Bool) => { + sample_bools(&mut rng, count, domain, exclude, fixture_pool) + }, + None => Err(format!( + "fuzz: cannot sample for parameter type `{:?}`; only address/signer, \ + uN (u8..u256), and bool are supported by the default source", + ty + )), + } + } + + fn shrink( + &self, + ty: &Type, + _param_name: &str, + current: &MoveValue, + domain: &Domain, + exclude: &Domain, + ) -> Option { + shrink_value(ty, current, domain, exclude) + } + + fn mutate( + &self, + ty: &Type, + _param_name: &str, + current: &MoveValue, + domain: &Domain, + exclude: &Domain, + seed: u64, + ) -> Option { + let mut rng = Rng(self.config.seed.wrapping_add(seed)); + mutate_value(&mut rng, ty, current, domain, exclude, &self.dictionary) + } + + fn base_seed(&self) -> Option { + Some(self.config.seed) + } +} + +// --------------------------------------------------------------------------- +// RNG — inline SplitMix64 +// --------------------------------------------------------------------------- + +/// Deterministic SplitMix64 — chosen instead of a `rand` dependency because +/// we only need uniform `u64` output, never a distribution from the `rand` +/// crate. Quality is sufficient for property-style fuzzing. +struct Rng(u64); + +impl Rng { + fn next_u64(&mut self) -> u64 { + self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15); + let mut z = self.0; + z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB); + z ^ (z >> 31) + } + + fn pick<'a, T>(&mut self, xs: &'a [T]) -> Option<&'a T> { + if xs.is_empty() { + None + } else { + Some(&xs[(self.next_u64() as usize) % xs.len()]) + } + } +} + +// --------------------------------------------------------------------------- +// Type classification +// --------------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug)] +enum PrimKind { + AddressLike(AddressLike), + Uint(UintWidth), + Bool, +} + +#[derive(Copy, Clone, Debug)] +enum AddressLike { + Address, + Signer, +} + +#[derive(Copy, Clone, Debug)] +enum UintWidth { + U8, + U16, + U32, + U64, + U128, + U256, +} + +fn prim_kind(ty: &Type) -> Option { + match ty { + Type::Primitive(p) => match p { + PrimitiveType::Address => Some(PrimKind::AddressLike(AddressLike::Address)), + PrimitiveType::Signer => Some(PrimKind::AddressLike(AddressLike::Signer)), + PrimitiveType::Bool => Some(PrimKind::Bool), + PrimitiveType::U8 => Some(PrimKind::Uint(UintWidth::U8)), + PrimitiveType::U16 => Some(PrimKind::Uint(UintWidth::U16)), + PrimitiveType::U32 => Some(PrimKind::Uint(UintWidth::U32)), + PrimitiveType::U64 => Some(PrimKind::Uint(UintWidth::U64)), + PrimitiveType::U128 => Some(PrimKind::Uint(UintWidth::U128)), + PrimitiveType::U256 => Some(PrimKind::Uint(UintWidth::U256)), + _ => None, + }, + Type::Reference(_, inner) => match &**inner { + Type::Primitive(PrimitiveType::Signer) => { + Some(PrimKind::AddressLike(AddressLike::Signer)) + }, + _ => None, + }, + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Per-type samplers +// --------------------------------------------------------------------------- + +fn sample_bools( + rng: &mut Rng, + n: usize, + domain: &Domain, + exclude: &Domain, + fixtures: Option<&FixturePool>, +) -> Result, String> { + // A range constraint (`b in lo..hi`) is meaningless for `bool` and would + // otherwise be silently dropped, leaving the domain unrestricted. Reject it + // with a clear diagnostic rather than ignoring the user's intent. + if !domain.ranges.is_empty() || !exclude.ranges.is_empty() { + return Err( + "fuzz: range constraints (`lo..hi`) are not supported on `bool` parameters; \ + use literals (e.g. `b in [true]` or `b != false`)" + .to_string(), + ); + } + let dom_bools: Vec = domain.literals.iter().filter_map(extract_bool).collect(); + let exc_bools: Vec = exclude.literals.iter().filter_map(extract_bool).collect(); + let mut pool: Vec = if dom_bools.is_empty() { + vec![false, true] + } else { + dom_bools + }; + if let Some(f) = fixtures { + // Fixtures don't expand the bool universe but bias the picker — Foundry + // achieves this via weighting; we just duplicate them in the pool. + pool.extend(f.bools.iter().copied()); + } + let pool: Vec = pool.into_iter().filter(|b| !exc_bools.contains(b)).collect(); + if pool.is_empty() { + return Err("fuzz: bool domain is empty after exclusions".to_string()); + } + let mut out = Vec::with_capacity(n); + for _ in 0..n { + out.push(MoveValue::Bool(*rng.pick(&pool).unwrap())); + } + Ok(out) +} + +fn sample_addresses( + rng: &mut Rng, + n: usize, + kind: AddressLike, + domain: &Domain, + exclude: &Domain, + dict: &FuzzDictionary, + fixtures: Option<&FixturePool>, +) -> Result, String> { + // Reject range bounds that don't fit the 32-byte address space at + // plan-build time, so `bigint_to_address`'s truncation is never the thing + // that silently narrows a user's constraint. + validate_address_domain(domain, exclude)?; + let dom_addrs: Vec = domain + .literals + .iter() + .filter_map(extract_address) + .collect(); + let exc_addrs: Vec = exclude + .literals + .iter() + .filter_map(extract_address) + .collect(); + + // Ranges on addresses are interpreted as [lo, hi] address-byte intervals. + let dom_ranges = parse_address_ranges(&domain.ranges); + let exc_ranges = parse_address_ranges(&exclude.ranges); + let domain_active = !dom_addrs.is_empty() || !dom_ranges.is_empty(); + + let wrap = |addr: AccountAddress| match kind { + AddressLike::Address => MoveValue::Address(addr), + AddressLike::Signer => MoveValue::Signer(addr), + }; + let is_excluded = |addr: &AccountAddress| -> bool { + exc_addrs.contains(addr) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(addr, lo, hi, *inc)) + }; + let in_domain = |addr: &AccountAddress| -> bool { + !domain_active + || dom_addrs.contains(addr) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(addr, lo, hi, *inc)) + }; + + let mut out = Vec::with_capacity(n); + let mut tries = 0usize; + let cap = n.saturating_mul(64).max(1); + + // If the user explicitly listed addresses in the domain, prefer those — + // they are almost certainly the values they want exercised. + if !dom_addrs.is_empty() { + for a in &dom_addrs { + if !is_excluded(a) { + out.push(wrap(*a)); + if out.len() == n { + return Ok(out); + } + } + } + } + + // Same for fixtures: drain them upfront so the user always sees them. + if let Some(f) = fixtures { + for a in &f.addresses { + if in_domain(a) && !is_excluded(a) { + out.push(wrap(*a)); + if out.len() == n { + return Ok(out); + } + } + } + } + + // Finite literal domain with no ranges: random/edge draws almost never + // land in the listed set, so draw straight from it (with repeats) to fill + // `n` rather than returning fewer values and capping the run count. + let literal_only = !dom_addrs.is_empty() && dom_ranges.is_empty(); + + let random_address = |rng: &mut Rng| { + let mut bytes = [0u8; AccountAddress::LENGTH]; + for chunk in bytes.chunks_exact_mut(8) { + chunk.copy_from_slice(&rng.next_u64().to_le_bytes()); + } + AccountAddress::new(bytes) + }; + + while out.len() < n && tries < cap { + tries += 1; + if literal_only { + match rng.pick(&dom_addrs).copied() { + Some(a) if !is_excluded(&a) => out.push(wrap(a)), + Some(_) => {}, + None => break, + } + continue; + } + let pick = rng.next_u64() % 100; + let is_edge = pick < u64::from(EDGE_WEIGHT); + let candidate = if !dom_ranges.is_empty() { + // A domain range is active. A full-width random address essentially + // never lands in a bounded interval, so sample *within* a randomly + // chosen range (mirroring `sample_uints`) instead of rejecting draws + // until the retry budget is exhausted. Spend the edge budget on + // range-bracketed boundary values for coverage, honoring the + // half-open upper bound so the excluded `hi` is never emitted. + let (lo, hi, inc) = rng.pick(&dom_ranges).unwrap(); + if is_edge { + bigint_to_address(range_edge_endpoint(rng, lo, hi, *inc)) + } else { + bigint_to_address(sample_bigint_in_range(rng, lo, hi, *inc)) + } + } else if is_edge { + // Edge: well-known anchors. + let edges = [ + AccountAddress::ZERO, + AccountAddress::ONE, + AccountAddress::from_hex_literal("0x2").unwrap_or(AccountAddress::ONE), + ]; + *rng.pick(&edges).unwrap() + } else if !dict.addresses.is_empty() { + *rng.pick(&dict.addresses).unwrap() + } else { + random_address(rng) + }; + if !in_domain(&candidate) || is_excluded(&candidate) { + continue; + } + out.push(wrap(candidate)); + } + + if out.is_empty() { + return Err( + "fuzz: could not generate any address values satisfying the given constraints" + .to_string(), + ); + } + Ok(out) +} + +fn sample_uints( + rng: &mut Rng, + n: usize, + width: UintWidth, + domain: &Domain, + exclude: &Domain, + dict: &FuzzDictionary, + fixtures: Option<&FixturePool>, + dictionary_weight: u8, + max_retry_multiplier: usize, +) -> Result, String> { + // Reject constraint values that don't fit the type up front — the same + // policy `coerce_numeric_to_width` applies to concrete `#[test]` values — so + // `a != 300` on a `u8` is a clear error rather than a silently-wrapped + // `a != 44`. Once validated, every literal is in `[0, max]`, so membership + // checks line up with the reduced random candidates below. + validate_uint_domain(width, domain, exclude)?; + let edges = uint_edges(width); + let modulus = uint_modulus(width); + let dom_lits: Vec = domain.literals.iter().filter_map(extract_bigint).collect(); + let exc_lits: Vec = exclude.literals.iter().filter_map(extract_bigint).collect(); + let dom_ranges = parse_int_ranges(&domain.ranges); + let exc_ranges = parse_int_ranges(&exclude.ranges); + let domain_active = !dom_lits.is_empty() || !dom_ranges.is_empty(); + + let is_excluded = |v: &BigInt| -> bool { + exc_lits.contains(v) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(v, lo, hi, *inc)) + }; + let in_domain = |v: &BigInt| -> bool { + !domain_active + || dom_lits.contains(v) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(v, lo, hi, *inc)) + }; + + let mut out = Vec::with_capacity(n); + + // Seed with explicit domain literals first. + for lit in &dom_lits { + if !is_excluded(lit) { + if let Some(v) = bigint_to_move_value(lit.clone(), width) { + out.push(v); + if out.len() == n { + return Ok(out); + } + } + } + } + + // Fixtures next: declared per-parameter values flow in before the + // randomized pool. + if let Some(f) = fixtures { + for v in &f.uints { + if in_domain(v) && !is_excluded(v) { + if let Some(mv) = bigint_to_move_value(v.clone(), width) { + out.push(mv); + if out.len() == n { + return Ok(out); + } + } + } + } + } + + // Pick weights partition [0,100): [0,edge_cutoff)=edge, [edge_cutoff, + // dict_cutoff)=dictionary, [dict_cutoff,100)=random (the fall-through). + let dictionary_weight = dictionary_weight.min(100); + let edge_cutoff = u64::from(EDGE_WEIGHT); + let dict_cutoff = edge_cutoff + u64::from(dictionary_weight); + + let mut tries = 0usize; + let cap = n.saturating_mul(max_retry_multiplier).max(1); + // When the domain is a finite set of literals (no ranges), random/edge/dict + // draws almost never land in the set, so we'd return far fewer than `n` + // values — which then caps the whole function's run count via the zip in + // the plan builder. Draw straight from the literal set instead. + let literal_only = domain_active && dom_ranges.is_empty(); + + while out.len() < n && tries < cap { + tries += 1; + let pick = rng.next_u64() % 100; + let candidate = if literal_only { + // Cycle through the listed values (with repeats) to fill `n`. + match rng.pick(&dom_lits) { + Some(v) => v.clone(), + None => break, + } + } else if pick < edge_cutoff { + // Edge sample. If a domain range is active, draw an edge value + // bracketed against the active range; otherwise use the type edges. + if let Some((lo, hi, inc)) = rng.pick(&dom_ranges).cloned() { + range_edge_endpoint(rng, &lo, &hi, inc) + } else { + rng.pick(&edges).cloned().unwrap_or_else(BigInt::default) + } + } else if pick < dict_cutoff && !dict.uints.is_empty() { + rng.pick(&dict.uints).cloned().unwrap_or_else(BigInt::default) + } else if !dom_ranges.is_empty() { + // Random within an active domain range. + let (lo, hi, inc) = rng.pick(&dom_ranges).cloned().unwrap(); + sample_bigint_in_range(rng, &lo, &hi, inc) + } else { + // Full-width random draw. Going through `u128` here would cap u128 + // at 2^128-1 (unreachable max) and clamp u256 to its low 128 bits, + // so draw enough limbs to cover the whole type instead. + random_bigint_below(rng, &modulus) + }; + // Coerce candidate into the type's representable range. + let candidate = reduce_into_range(candidate, &modulus); + if !in_domain(&candidate) || is_excluded(&candidate) { + continue; + } + if let Some(v) = bigint_to_move_value(candidate, width) { + out.push(v); + } + } + + if out.is_empty() { + return Err( + "fuzz: could not generate any uint values satisfying the given constraints" + .to_string(), + ); + } + Ok(out) +} + +// --------------------------------------------------------------------------- +// AttributeValue extraction & range parsing +// --------------------------------------------------------------------------- + +fn extract_bool(v: &AttributeValue) -> Option { + match v { + AttributeValue::Value(_, Value::Bool(b)) => Some(*b), + _ => None, + } +} + +fn extract_address(v: &AttributeValue) -> Option { + match v { + AttributeValue::Value(_, Value::Address(Address::Numerical(a))) => Some(*a), + _ => None, + } +} + +fn extract_bigint(v: &AttributeValue) -> Option { + match v { + AttributeValue::Value(_, Value::Number(n)) => Some(n.clone()), + AttributeValue::Value(_, Value::Address(Address::Numerical(a))) => { + Some(BigInt::from_bytes_be(Sign::Plus, a.as_ref())) + }, + _ => None, + } +} + +fn parse_int_ranges(ranges: &[RangeSpec]) -> Vec<(BigInt, BigInt, bool)> { + ranges + .iter() + .filter_map(|r| { + let lo = extract_bigint(&r.lo)?; + let hi = extract_bigint(&r.hi)?; + Some((lo, hi, r.inclusive_hi)) + }) + .collect() +} + +fn parse_address_ranges(ranges: &[RangeSpec]) -> Vec<(BigInt, BigInt, bool)> { + // Treat an address range identically to a numeric range over the + // 256-bit big-endian interpretation of the address bytes. + parse_int_ranges(ranges) +} + +fn in_int_range(v: &BigInt, lo: &BigInt, hi: &BigInt, inclusive_hi: bool) -> bool { + if v < lo { + return false; + } + if inclusive_hi { + v <= hi + } else { + v < hi + } +} + +fn address_in_range(addr: &AccountAddress, lo: &BigInt, hi: &BigInt, inclusive_hi: bool) -> bool { + let v = BigInt::from_bytes_be(Sign::Plus, addr.as_ref()); + in_int_range(&v, lo, hi, inclusive_hi) +} + +/// Reject `#[test]` fuzz constraints whose literal/range values don't fit the +/// integer width, mirroring the policy `coerce_numeric_to_width` enforces for +/// concrete values. Returning `Err` here surfaces as a labeled plan-build +/// diagnostic (see `materialize_param_values`), so an out-of-range constraint +/// is a clear compile error instead of a silently-wrapped value. +fn validate_uint_domain(width: UintWidth, domain: &Domain, exclude: &Domain) -> Result<(), String> { + let modulus = uint_modulus(width); + let max = &modulus - 1; + for v in domain + .literals + .iter() + .chain(exclude.literals.iter()) + .filter_map(extract_bigint) + { + if v > max { + return Err(format!( + "fuzz: value {} is out of range for this integer parameter (max {})", + v, max + )); + } + } + for r in domain.ranges.iter().chain(exclude.ranges.iter()) { + if let Some(lo) = extract_bigint(&r.lo) { + if lo > max { + return Err(format!( + "fuzz: range bound {} is out of range for this integer parameter (max {})", + lo, max + )); + } + } + if let Some(hi) = extract_bigint(&r.hi) { + // An exclusive upper bound may equal the modulus (it denotes "up to + // max, inclusive"); an inclusive one must be <= max. + let hi_limit = if r.inclusive_hi { &max } else { &modulus }; + if &hi > hi_limit { + return Err(format!( + "fuzz: range bound {} is out of range for this integer parameter (max {})", + hi, max + )); + } + } + } + Ok(()) +} + +/// Reject address-range bounds that don't fit the 32-byte address space, so the +/// generator (`bigint_to_address`) and the membership filter (`address_in_range`) +/// can never disagree about an out-of-space bound. +fn validate_address_domain(domain: &Domain, exclude: &Domain) -> Result<(), String> { + let max = (BigInt::from(1) << 256) - 1; + for r in domain.ranges.iter().chain(exclude.ranges.iter()) { + for bound in [&r.lo, &r.hi] { + if let Some(v) = extract_bigint(bound) { + if v.sign() == Sign::Minus || v > max { + return Err(format!( + "fuzz: address range bound {} does not fit the 32-byte address space", + v + )); + } + } + } + } + Ok(()) +} + +/// Interpret a `BigInt` as a 256-bit big-endian address. Negative inputs are +/// clamped to `0x0` (the bottom of the address space) — they can arise from +/// range-edge arithmetic on degenerate ranges and have no address meaning. +/// Values wider than 32 bytes are truncated to their low 32 bytes, matching +/// `address_in_range`'s big-endian interpretation. Address range bounds are +/// validated against the 32-byte space at plan-build time (see +/// `validate_address_domain`), so the truncation path is defensive only. +fn bigint_to_address(n: BigInt) -> AccountAddress { + if n.sign() == Sign::Minus { + return AccountAddress::ZERO; + } + let (_sign, be) = n.to_bytes_be(); + let mut buf = [0u8; AccountAddress::LENGTH]; + if be.len() >= AccountAddress::LENGTH { + buf.copy_from_slice(&be[be.len() - AccountAddress::LENGTH..]); + } else { + buf[AccountAddress::LENGTH - be.len()..].copy_from_slice(&be); + } + AccountAddress::new(buf) +} + +// --------------------------------------------------------------------------- +// Numeric helpers +// --------------------------------------------------------------------------- + +/// Reduce `n` into the half-open range `[0, modulus)`, handling negative +/// inputs. Used to coerce both sampled candidates and user-supplied literals +/// into a uint type's representable range so membership checks and emitted +/// values stay consistent. +fn reduce_into_range(n: BigInt, modulus: &BigInt) -> BigInt { + ((n % modulus) + modulus) % modulus +} + +fn uint_modulus(width: UintWidth) -> BigInt { + match width { + UintWidth::U8 => BigInt::from(1u64) << 8, + UintWidth::U16 => BigInt::from(1u64) << 16, + UintWidth::U32 => BigInt::from(1u64) << 32, + UintWidth::U64 => BigInt::from(1u128) << 64, + UintWidth::U128 => BigInt::from(1u128) << 127 << 1, + UintWidth::U256 => BigInt::from(1u128) << 128 << 128, + } +} + +fn uint_edges(width: UintWidth) -> Vec { + let m = uint_modulus(width); + let max = &m - 1; + let half: BigInt = &m / 2; + vec![ + BigInt::from(0), + BigInt::from(1), + BigInt::from(2), + &half - 1, + half.clone(), + &half + 1, + &max - 1, + max, + ] +} + +fn sample_bigint_in_range(rng: &mut Rng, lo: &BigInt, hi: &BigInt, inclusive_hi: bool) -> BigInt { + let exclusive_hi = if inclusive_hi { + hi + BigInt::from(1) + } else { + hi.clone() + }; + let span = &exclusive_hi - lo; + if span <= BigInt::from(0) { + return lo.clone(); + } + // Generate a random BigInt 0..span and offset by lo. We approximate with + // a fixed pool of u64 limbs sufficient for any Move primitive (u256 fits + // in 4 limbs); a small modulo bias is acceptable for fuzz purposes. + let mut limbs = Vec::with_capacity(4); + for _ in 0..4 { + limbs.push(rng.next_u64()); + } + let raw = BigInt::from_slice(Sign::Plus, &limbs_to_u32(&limbs)); + lo + raw % span +} + +/// Pick a boundary value for a range, honoring the half-open upper bound (so +/// the excluded `hi` is never returned). Every endpoint is clamped to `[lo, +/// hi_edge]`, so a degenerate range like `0..1` (whose naive `hi_edge - 1` +/// would be `-1`) yields only in-range values instead of negatives or +/// out-of-bounds picks. Shared by `sample_uints` and `sample_addresses`. +fn range_edge_endpoint(rng: &mut Rng, lo: &BigInt, hi: &BigInt, inclusive_hi: bool) -> BigInt { + let one = BigInt::from(1); + let hi_edge = if inclusive_hi { hi.clone() } else { hi - &one }; + if hi_edge <= *lo { + return lo.clone(); + } + let endpoints = [ + lo.clone(), + (lo + &one).min(hi_edge.clone()), + (&hi_edge - &one).max(lo.clone()), + hi_edge, + ]; + rng.pick(&endpoints).cloned().unwrap_or_else(|| lo.clone()) +} + +fn limbs_to_u32(u64s: &[u64]) -> Vec { + let mut out = Vec::with_capacity(u64s.len() * 2); + for n in u64s { + out.push((*n & 0xFFFF_FFFF) as u32); + out.push((*n >> 32) as u32); + } + out +} + +/// Draw a (uniform-ish) random `BigInt` in `[0, modulus)` for any Move uint +/// width. Generates one extra limb beyond the modulus width before reducing, +/// so the high end of wide types (u128 max, the upper 128 bits of u256) is +/// reachable; a small modulo bias is acceptable for fuzzing. +fn random_bigint_below(rng: &mut Rng, modulus: &BigInt) -> BigInt { + if modulus <= &BigInt::from(1) { + return BigInt::from(0); + } + // Bits in `modulus`; one extra 64-bit limb makes the reduction bias + // negligible across the whole range. + let limbs = ((modulus.bits() / 64) + 1).max(1) as usize; + let mut words = Vec::with_capacity(limbs); + for _ in 0..limbs { + words.push(rng.next_u64()); + } + let raw = BigInt::from_slice(Sign::Plus, &limbs_to_u32(&words)); + raw % modulus +} + +// --------------------------------------------------------------------------- +// Shrinking — Topic 3 +// --------------------------------------------------------------------------- + +/// Produce one shrink candidate closer to "minimal" than `current`. Order is +/// deterministic: zero first, then halving, then decrement. The runner calls +/// this in a loop and stops when the shrunk value either passes the test or +/// when this returns `None`. +fn shrink_value( + ty: &Type, + current: &MoveValue, + domain: &Domain, + exclude: &Domain, +) -> Option { + let kind = prim_kind(ty)?; + let dom_lits; + let dom_ranges; + let exc_lits; + let exc_ranges; + let domain_active; + + match kind { + PrimKind::Uint(width) => { + dom_lits = domain + .literals + .iter() + .filter_map(extract_bigint) + .collect::>(); + dom_ranges = parse_int_ranges(&domain.ranges); + exc_lits = exclude + .literals + .iter() + .filter_map(extract_bigint) + .collect::>(); + exc_ranges = parse_int_ranges(&exclude.ranges); + domain_active = !dom_lits.is_empty() || !dom_ranges.is_empty(); + let cur = move_value_to_bigint(current)?; + // Build candidates in order from "most aggressive shrink" to least. + let candidates: Vec = vec![ + BigInt::from(0), + &cur / 2, + &cur - 1, + ]; + for c in candidates { + if c == cur { + continue; + } + if c < BigInt::from(0) { + continue; + } + let in_dom = !domain_active + || dom_lits.contains(&c) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(&c, lo, hi, *inc)); + let excluded = exc_lits.contains(&c) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(&c, lo, hi, *inc)); + if in_dom && !excluded { + return bigint_to_move_value(c, width); + } + } + None + }, + PrimKind::Bool => match current { + MoveValue::Bool(true) => { + let exc: Vec = exclude.literals.iter().filter_map(extract_bool).collect(); + let dom: Vec = domain.literals.iter().filter_map(extract_bool).collect(); + let domain_active = !dom.is_empty(); + let in_dom = !domain_active || dom.contains(&false); + if in_dom && !exc.contains(&false) { + Some(MoveValue::Bool(false)) + } else { + None + } + }, + _ => None, + }, + PrimKind::AddressLike(kind) => { + let cur = match current { + MoveValue::Address(a) | MoveValue::Signer(a) => *a, + _ => return None, + }; + let dom_addrs: Vec = domain + .literals + .iter() + .filter_map(extract_address) + .collect(); + let exc_addrs: Vec = exclude + .literals + .iter() + .filter_map(extract_address) + .collect(); + let dom_ranges = parse_address_ranges(&domain.ranges); + let exc_ranges = parse_address_ranges(&exclude.ranges); + let domain_active = !dom_addrs.is_empty() || !dom_ranges.is_empty(); + // Address "shrink" candidates: 0x0, 0x1, and any domain-literal that's "smaller". + let mut candidates = vec![AccountAddress::ZERO, AccountAddress::ONE]; + for a in &dom_addrs { + if a.as_ref() < cur.as_ref() { + candidates.push(*a); + } + } + for c in candidates { + if c == cur { + continue; + } + let in_dom = !domain_active + || dom_addrs.contains(&c) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(&c, lo, hi, *inc)); + let excluded = exc_addrs.contains(&c) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(&c, lo, hi, *inc)); + if in_dom && !excluded { + return Some(match kind { + AddressLike::Address => MoveValue::Address(c), + AddressLike::Signer => MoveValue::Signer(c), + }); + } + } + None + }, + } +} + +fn move_value_to_bigint(v: &MoveValue) -> Option { + match v { + MoveValue::U8(x) => Some(BigInt::from(*x)), + MoveValue::U16(x) => Some(BigInt::from(*x)), + MoveValue::U32(x) => Some(BigInt::from(*x)), + MoveValue::U64(x) => Some(BigInt::from(*x)), + MoveValue::U128(x) => Some(BigInt::from(*x)), + MoveValue::U256(x) => { + // u256::U256 → big-endian bytes → BigInt + let bytes_le = x.to_le_bytes(); + let mut bytes_be = bytes_le; + bytes_be.reverse(); + Some(BigInt::from_bytes_be(Sign::Plus, &bytes_be)) + }, + _ => None, + } +} + +// --------------------------------------------------------------------------- +// Mutation — Topic 2 (corpus-driven exploration) +// --------------------------------------------------------------------------- + +/// Produce one mutated value near `current`. Mirrors Foundry's +/// `mutate_param_value` at `crates/evm/fuzz/src/strategies/param.rs`: bit +/// flips on the low byte, increment/decrement, swap from the dictionary, +/// or pick a domain literal. Result must satisfy the original constraints. +fn mutate_value( + rng: &mut Rng, + ty: &Type, + current: &MoveValue, + domain: &Domain, + exclude: &Domain, + dict: &FuzzDictionary, +) -> Option { + let kind = prim_kind(ty)?; + match kind { + PrimKind::Uint(width) => { + let cur = move_value_to_bigint(current)?; + let m = uint_modulus(width); + let choice = rng.next_u64() % 5; + let cand = match choice { + 0 => &cur + 1, + 1 => (&cur + &m - 1) % &m, // decrement with wraparound + 2 => cur.clone() ^ BigInt::from(rng.next_u64() & 0xFF), + 3 => { + if let Some(d) = rng.pick(&dict.uints) { + d.clone() + } else { + &cur ^ BigInt::from(1) + } + }, + _ => { + let lits: Vec = + domain.literals.iter().filter_map(extract_bigint).collect(); + rng.pick(&lits).cloned().unwrap_or(cur.clone()) + }, + }; + let cand = reduce_into_range(cand, &m); + if cand == cur { + return None; + } + // Domain/exclude filter. + let dom_lits: Vec = + domain.literals.iter().filter_map(extract_bigint).collect(); + let dom_ranges = parse_int_ranges(&domain.ranges); + let exc_lits: Vec = + exclude.literals.iter().filter_map(extract_bigint).collect(); + let exc_ranges = parse_int_ranges(&exclude.ranges); + let active = !dom_lits.is_empty() || !dom_ranges.is_empty(); + let in_dom = !active + || dom_lits.contains(&cand) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(&cand, lo, hi, *inc)); + let excluded = exc_lits.contains(&cand) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| in_int_range(&cand, lo, hi, *inc)); + if in_dom && !excluded { + bigint_to_move_value(cand, width) + } else { + None + } + }, + PrimKind::Bool => match current { + MoveValue::Bool(b) => { + let flipped = !*b; + let exc: Vec = + exclude.literals.iter().filter_map(extract_bool).collect(); + let dom: Vec = domain.literals.iter().filter_map(extract_bool).collect(); + let active = !dom.is_empty(); + let in_dom = !active || dom.contains(&flipped); + if in_dom && !exc.contains(&flipped) { + Some(MoveValue::Bool(flipped)) + } else { + None + } + }, + _ => None, + }, + PrimKind::AddressLike(kind) => { + let cur = match current { + MoveValue::Address(a) | MoveValue::Signer(a) => *a, + _ => return None, + }; + let choice = rng.next_u64() % 3; + let cand = match choice { + 0 => *rng.pick(&dict.addresses).unwrap_or(&AccountAddress::ZERO), + 1 => { + // Bit flip in the low byte. + let mut bytes = cur.into_bytes(); + let last = bytes.len() - 1; + bytes[last] ^= 1; + AccountAddress::new(bytes) + }, + _ => { + let lits: Vec = domain + .literals + .iter() + .filter_map(extract_address) + .collect(); + *rng.pick(&lits).unwrap_or(&cur) + }, + }; + if cand == cur { + return None; + } + let dom_addrs: Vec = domain + .literals + .iter() + .filter_map(extract_address) + .collect(); + let exc_addrs: Vec = exclude + .literals + .iter() + .filter_map(extract_address) + .collect(); + let dom_ranges = parse_address_ranges(&domain.ranges); + let exc_ranges = parse_address_ranges(&exclude.ranges); + let active = !dom_addrs.is_empty() || !dom_ranges.is_empty(); + let in_dom = !active + || dom_addrs.contains(&cand) + || dom_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(&cand, lo, hi, *inc)); + let excluded = exc_addrs.contains(&cand) + || exc_ranges + .iter() + .any(|(lo, hi, inc)| address_in_range(&cand, lo, hi, *inc)); + if in_dom && !excluded { + Some(match kind { + AddressLike::Address => MoveValue::Address(cand), + AddressLike::Signer => MoveValue::Signer(cand), + }) + } else { + None + } + }, + } +} + +fn bigint_to_move_value(n: BigInt, width: UintWidth) -> Option { + let m = uint_modulus(width); + let n = reduce_into_range(n, &m); + match width { + UintWidth::U8 => n.to_u64().map(|x| MoveValue::U8(x as u8)), + UintWidth::U16 => n.to_u64().map(|x| MoveValue::U16(x as u16)), + UintWidth::U32 => n.to_u64().map(|x| MoveValue::U32(x as u32)), + UintWidth::U64 => n.to_u64().map(MoveValue::U64), + UintWidth::U128 => n.to_u128().map(MoveValue::U128), + UintWidth::U256 => { + // Take the low 32 bytes (big-endian) of `n`. + let (_sign, bytes_be) = n.to_bytes_be(); + let mut buf = [0u8; 32]; + let off = 32usize.saturating_sub(bytes_be.len()); + buf[off..].copy_from_slice(&bytes_be[bytes_be.len().saturating_sub(32)..]); + // u256::U256 has a `from_be_bytes`/`from_le_bytes` API; use whichever is present. + Some(MoveValue::U256(u256::U256::from_le_bytes(&{ + let mut le = buf; + le.reverse(); + le + }))) + }, + } +} diff --git a/third_party/move/move-compiler-v2/src/fuzz_corpus.rs b/third_party/move/move-compiler-v2/src/fuzz_corpus.rs new file mode 100644 index 00000000000..94d15c44e35 --- /dev/null +++ b/third_party/move/move-compiler-v2/src/fuzz_corpus.rs @@ -0,0 +1,239 @@ +// Copyright (c) Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! On-disk fuzz corpus. +//! +//! Layout — one BCS-encoded file per `(module, test)`: +//! +//! ```text +//! /failures/...bcs Vec> +//! /seeds/...bcs Vec> +//! ``` +//! +//! - `failures/` — argument vectors that previously caused this test to fail. +//! Always replayed first on the next run, so a regression never silently +//! disappears. +//! - `seeds/` — argument vectors deemed interesting (e.g. mutated from +//! prior runs). Replayed if `--fuzz-corpus-replay-seeds` is set. +//! +//! This is intentionally schema-thin: only the `Vec` arguments +//! are persisted, not the full `ArgOrigin` metadata. Replayed entries run as +//! `Fixed`-origin TestCases — they reproduce the failing input but are not +//! eligible for further shrinking. The user can still get shrinking on the +//! fresh fuzz draws that run alongside them. + +use anyhow::{anyhow, Context, Result}; +use move_core_types::{ + account_address::AccountAddress, language_storage::ModuleId, u256, value::MoveValue, +}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeSet, + fs, + path::{Path, PathBuf}, +}; + +/// Wire format for corpus entries. `MoveValue` cannot be (de)serialized +/// without a `MoveTypeLayout`, so we round-trip through this proxy that +/// covers exactly the primitive set the default fuzz source produces. +#[derive(Debug, Clone, Serialize, Deserialize)] +enum WireValue { + U8(u8), + U16(u16), + U32(u32), + U64(u64), + U128(u128), + U256([u8; 32]), + Bool(bool), + Address([u8; AccountAddress::LENGTH]), + Signer([u8; AccountAddress::LENGTH]), +} + +impl WireValue { + fn from_move(value: &MoveValue) -> Result { + Ok(match value { + MoveValue::U8(x) => WireValue::U8(*x), + MoveValue::U16(x) => WireValue::U16(*x), + MoveValue::U32(x) => WireValue::U32(*x), + MoveValue::U64(x) => WireValue::U64(*x), + MoveValue::U128(x) => WireValue::U128(*x), + MoveValue::U256(x) => WireValue::U256(x.to_le_bytes()), + MoveValue::Bool(b) => WireValue::Bool(*b), + MoveValue::Address(a) => WireValue::Address(a.into_bytes()), + MoveValue::Signer(a) => WireValue::Signer(a.into_bytes()), + other => { + return Err(anyhow!( + "corpus: unsupported MoveValue variant `{:?}` (only primitives are serialized)", + other + )); + }, + }) + } + + fn into_move(self) -> MoveValue { + match self { + WireValue::U8(x) => MoveValue::U8(x), + WireValue::U16(x) => MoveValue::U16(x), + WireValue::U32(x) => MoveValue::U32(x), + WireValue::U64(x) => MoveValue::U64(x), + WireValue::U128(x) => MoveValue::U128(x), + WireValue::U256(bytes) => MoveValue::U256(u256::U256::from_le_bytes(&bytes)), + WireValue::Bool(b) => MoveValue::Bool(b), + WireValue::Address(a) => MoveValue::Address(AccountAddress::new(a)), + WireValue::Signer(a) => MoveValue::Signer(AccountAddress::new(a)), + } + } +} + +fn to_wire(args: &[MoveValue]) -> Result> { + args.iter().map(WireValue::from_move).collect() +} + +fn from_wire(args: Vec) -> Vec { + args.into_iter().map(WireValue::into_move).collect() +} + +/// Subdirectory holding regression cases that previously failed. +const FAILURES_SUBDIR: &str = "failures"; + +/// Subdirectory holding mutated/seeded interesting cases. +const SEEDS_SUBDIR: &str = "seeds"; + +/// Compose the on-disk filename for one `(module, test)` pair. +fn corpus_filename(module_id: &ModuleId, test_name: &str) -> String { + // Test names may contain `[`/`]`/`=`/`,` from expansion suffixes; replace + // them with `_` so filenames stay portable. + let sanitized: String = test_name + .chars() + .map(|c| match c { + 'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' => c, + _ => '_', + }) + .collect(); + format!( + "{}.{}.{}.bcs", + module_id.address().short_str_lossless(), + module_id.name().as_str(), + sanitized + ) +} + +fn read_corpus_file(path: &Path) -> Result>> { + let bytes = fs::read(path).with_context(|| format!("reading {}", path.display()))?; + let wire: Vec> = bcs::from_bytes(&bytes) + .with_context(|| format!("decoding {}", path.display()))?; + Ok(wire.into_iter().map(from_wire).collect()) +} + +fn write_corpus_file(path: &Path, cases: &[Vec]) -> Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent) + .with_context(|| format!("creating {}", parent.display()))?; + } + let wire: Vec> = cases + .iter() + .map(|c| to_wire(c)) + .collect::>()?; + let bytes = bcs::to_bytes(&wire).context("encoding corpus")?; + fs::write(path, bytes).with_context(|| format!("writing {}", path.display()))?; + Ok(()) +} + +/// Load failure-regression entries for `(module, test)`. Returns an empty +/// vector when no file exists. +pub fn load_failures( + corpus_dir: &Path, + module_id: &ModuleId, + test_name: &str, +) -> Result>> { + let path = corpus_dir + .join(FAILURES_SUBDIR) + .join(corpus_filename(module_id, test_name)); + if !path.exists() { + return Ok(Vec::new()); + } + read_corpus_file(&path) +} + +/// Load seed entries for `(module, test)`. Returns an empty vector when no +/// file exists. +pub fn load_seeds( + corpus_dir: &Path, + module_id: &ModuleId, + test_name: &str, +) -> Result>> { + let path = corpus_dir + .join(SEEDS_SUBDIR) + .join(corpus_filename(module_id, test_name)); + if !path.exists() { + return Ok(Vec::new()); + } + read_corpus_file(&path) +} + +/// Append `args` to the `subdir` corpus file for `(module, test)`, de-duping +/// against the existing entries. Idempotent. `what` names the entry kind for +/// error context. +fn append_entry( + corpus_dir: &Path, + subdir: &str, + module_id: &ModuleId, + test_name: &str, + args: &[MoveValue], + what: &str, +) -> Result<()> { + let path = corpus_dir + .join(subdir) + .join(corpus_filename(module_id, test_name)); + let mut existing = if path.exists() { + read_corpus_file(&path)? + } else { + Vec::new() + }; + let mut seen: BTreeSet> = existing + .iter() + .filter_map(|e| to_wire(e).ok().and_then(|w| bcs::to_bytes(&w).ok())) + .collect(); + let key = to_wire(args).and_then(|w| bcs::to_bytes(&w).map_err(Into::into)); + let key = match key { + Ok(k) => k, + // Propagate rather than silently dropping: an entry we can't persist + // must not look like a successfully-saved one. + Err(e) => return Err(e.context(format!("corpus: cannot serialize {} arguments", what))), + }; + if seen.insert(key) { + existing.push(args.to_vec()); + write_corpus_file(&path, &existing)?; + } + Ok(()) +} + +/// Append `args` to the failures file for `(module, test)`, de-duping against +/// the existing entries. Idempotent. +pub fn append_failure( + corpus_dir: &Path, + module_id: &ModuleId, + test_name: &str, + args: &[MoveValue], +) -> Result<()> { + append_entry(corpus_dir, FAILURES_SUBDIR, module_id, test_name, args, "failing") +} + +/// Append `args` to the seeds file for `(module, test)`. +pub fn append_seed( + corpus_dir: &Path, + module_id: &ModuleId, + test_name: &str, + args: &[MoveValue], +) -> Result<()> { + append_entry(corpus_dir, SEEDS_SUBDIR, module_id, test_name, args, "seed") +} + +/// Standard path resolution. Pass through to expose the layout. +pub fn failures_dir(corpus_dir: &Path) -> PathBuf { + corpus_dir.join(FAILURES_SUBDIR) +} + +pub fn seeds_dir(corpus_dir: &Path) -> PathBuf { + corpus_dir.join(SEEDS_SUBDIR) +} diff --git a/third_party/move/move-compiler-v2/src/lib.rs b/third_party/move/move-compiler-v2/src/lib.rs index 223287b9a2f..918688bba99 100644 --- a/third_party/move/move-compiler-v2/src/lib.rs +++ b/third_party/move/move-compiler-v2/src/lib.rs @@ -8,6 +8,8 @@ pub mod env_pipeline; mod experiments; pub mod external_checks; mod file_format_generator; +pub mod fuzz; +pub mod fuzz_corpus; pub mod lint_common; pub mod logging; pub mod options; diff --git a/third_party/move/move-compiler-v2/src/lint_common.rs b/third_party/move/move-compiler-v2/src/lint_common.rs index 87c25b4b246..b3e101c8cf1 100644 --- a/third_party/move/move-compiler-v2/src/lint_common.rs +++ b/third_party/move/move-compiler-v2/src/lint_common.rs @@ -42,6 +42,16 @@ fn parse_lint_skip_attribute( ); BTreeSet::new() }, + Attribute::Constrained(id, ..) => { + env.error( + &env.get_node_loc(*id), + &format!( + "expected `#[{}(...)]`, not a constrained value", + LintAttribute::SKIP + ), + ); + BTreeSet::new() + }, Attribute::Apply(id, _, attrs) => { if attrs.is_empty() { env.error( @@ -59,6 +69,13 @@ fn parse_lint_skip_attribute( ); None }, + Attribute::Constrained(id, ..) => { + env.error( + &env.get_node_loc(*id), + "did not expect a constrained value, expected only the names of the lint checks to be skipped", + ); + None + }, Attribute::Apply(id, name, sub_attrs) => { if !sub_attrs.is_empty() { env.error(&env.get_node_loc(*id), "unexpected nested attributes"); diff --git a/third_party/move/move-compiler-v2/src/plan_builder.rs b/third_party/move/move-compiler-v2/src/plan_builder.rs index f0a4bf6dbc4..b90d38a7fe3 100644 --- a/third_party/move/move-compiler-v2/src/plan_builder.rs +++ b/third_party/move/move-compiler-v2/src/plan_builder.rs @@ -11,7 +11,12 @@ //! includes info about each '#[test]' function: name, arguments to provide, and expected failure or //! success. -use crate::options::Options; +use crate::{ + fuzz::{ + ArgOrigin, Domain, FuzzPlanMetadata, FuzzValueSource, NoFuzzSource, ParamSpec, RangeSpec, + }, + options::Options, +}; use codespan_reporting::diagnostic::Severity; use legacy_move_compiler::{ shared::known_attributes::{AttributeKind, TestingAttribute}, @@ -19,48 +24,89 @@ use legacy_move_compiler::{ }; use move_command_line_common::{address::NumericalAddress, parser::NumberFormat}; use move_core_types::{ - identifier::Identifier, language_storage::ModuleId, value::MoveValue, vm_status::StatusCode, + identifier::Identifier, language_storage::ModuleId, u256, value::MoveValue, + vm_status::StatusCode, }; use move_model::{ - ast::{Address, Attribute, AttributeValue, ModuleName, Value}, + ast::{Address, Attribute, AttributeValue, ConstraintOp, ModuleName, Value}, model::{FunctionEnv, GlobalEnv, Loc, ModuleEnv, Parameter}, symbol::Symbol, ty::{PrimitiveType, Type}, }; -use num::{BigInt, ToPrimitive}; -use std::collections::BTreeMap; +use num::{bigint::Sign, BigInt, ToPrimitive}; +use std::collections::{BTreeMap, BTreeSet}; + +/// Sentinel run count handed to `FuzzValueSource::sample`: `0` means "use the +/// source's own configured `runs`" (e.g. `FuzzConfig::runs`, driven by +/// `--fuzz-runs`). The planner is generic over the source and has no config of +/// its own, so it defers the count to the source rather than hardcoding it. +const FUZZ_RUNS_FROM_SOURCE: usize = 0; +/// Cap on test-case expansion to guard against accidental explosion. Applies to +/// the *product* of the pairwise matrix expansion and the fuzz run count, so it +/// must stay comfortably above [`fuzz::DEFAULT_FUZZ_RUNS`] to leave room for +/// matrix+fuzz combinations (2048 / 64 = 32 matrix rows of headroom). +/// +/// [`fuzz::DEFAULT_FUZZ_RUNS`]: crate::fuzz::DEFAULT_FUZZ_RUNS +const MAX_FUZZ_CASES: usize = 2048; //*************************************************************************** // Test Plan Building //*************************************************************************** +/// Output of plan-building: the test plans plus a sidecar map of fuzz +/// metadata keyed by `(ModuleId, expanded_test_name)`. The metadata is what +/// lets the runner shrink failing fuzz cases and mutate corpus entries. +#[derive(Debug, Clone)] +pub struct TestPlanBuild { + pub plans: Vec, + pub fuzz_metadata: FuzzPlanMetadata, +} + // Constructs a test plan for each module in `env.target`. This also validates the structure of the // attributes as the test plan is constructed. pub fn construct_test_plan( env: &GlobalEnv, package_filter: Option, ) -> Option> { + construct_test_plan_with_fuzz_source(env, package_filter, &NoFuzzSource) + .map(|build| build.plans) +} + +/// Like [`construct_test_plan`], but the caller can supply a [`FuzzValueSource`] to materialize +/// values for implicit-fuzz or `in`/`!=` constrained parameters. Returns a [`TestPlanBuild`] +/// carrying both the per-module test plans and the [`FuzzPlanMetadata`] sidecar. +pub fn construct_test_plan_with_fuzz_source( + env: &GlobalEnv, + package_filter: Option, + fuzz_source: &dyn FuzzValueSource, +) -> Option { let options = env.get_extension::().expect("options"); if !options.compile_test_code { return None; } - Some( - env.get_modules() - .filter_map(|module| { - if module.is_primary_target() { - construct_module_test_plan(env, package_filter, module) - } else { - None - } - }) - .collect(), - ) + let mut metadata = FuzzPlanMetadata::default(); + let plans: Vec = env + .get_modules() + .filter_map(|module| { + if module.is_primary_target() { + construct_module_test_plan(env, package_filter, fuzz_source, &mut metadata, module) + } else { + None + } + }) + .collect(); + Some(TestPlanBuild { + plans, + fuzz_metadata: metadata, + }) } fn construct_module_test_plan( env: &GlobalEnv, _package_filter: Option, + fuzz_source: &dyn FuzzValueSource, + metadata: &mut FuzzPlanMetadata, module: ModuleEnv, ) -> Option { // TODO (#12885): what is a package? Do we need this code? @@ -69,14 +115,35 @@ fn construct_module_test_plan( // } let current_module = module.get_name(); - let tests: BTreeMap<_, _> = module + // Key fuzz metadata by the SAME `ModuleId` that `ModuleTestPlan::new` builds + // (numeric address + the module's name string). Deriving it from + // `module.get_identifier()` instead is a trap: that returns `None` for source + // (non-bytecode) modules, so the plan would still be built from the name + // string while every metadata insert was silently skipped — leaving the + // runner unable to recognize fuzz cases (no seed banner, no shrinking). + let module_id_for_meta = { + let addr_bytes = match current_module.addr() { + Address::Numerical(num_addr) => Some(*num_addr), + Address::Symbolic(sym) => env.resolve_address_alias(*sym), + }; + let name = Identifier::new(env.symbol_pool().string(current_module.name()).to_string()).ok(); + match (addr_bytes, name) { + (Some(addr), Some(name)) => Some(ModuleId::new(addr, name)), + _ => None, + } + }; + + let expanded: Vec = module .get_functions() - .filter_map(|func| { - let func_name = func.get_name_str(); - build_test_info(env, current_module, func) - .map(|test_case| (func_name.clone(), test_case)) - }) + .flat_map(|func| build_test_info(env, current_module, fuzz_source, func)) .collect(); + let mut tests: BTreeMap = BTreeMap::new(); + for ex in expanded { + if let Some(module_id) = module_id_for_meta.as_ref() { + metadata.insert(module_id.clone(), ex.case.test_name.clone(), ex.origins); + } + tests.insert(ex.case.test_name.clone(), ex.case); + } let module_id = module.get_identifier(); if tests.is_empty() { @@ -105,11 +172,19 @@ fn construct_module_test_plan( } } +/// One expanded `#[test]` case: a `TestCase` ready for the runner plus the +/// per-argument origin that lets the runner shrink/mutate when appropriate. +pub struct ExpandedCase { + pub case: TestCase, + pub origins: Vec, +} + fn build_test_info( env: &GlobalEnv, current_module: &ModuleName, + fuzz_source: &dyn FuzzValueSource, function: FunctionEnv, -) -> Option { +) -> Vec { let fn_name_str = function.get_name_str(); let fn_id_loc = function.get_id_loc(); @@ -132,7 +207,7 @@ fn build_test_info( let abort_loc = env.get_node_loc(abort_id); env.error_with_labels(&fn_id_loc, fn_msg, vec![(abort_loc, abort_msg.to_string())]); } - return None; + return Vec::new(); }, Some(test_attribute) => test_attribute, }; @@ -157,112 +232,778 @@ fn build_test_info( ]); } - let test_annotation_params = parse_test_attribute(env, test_attribute, 0); + let specs = match parse_test_attribute(env, test_attribute, 0) { + Some(specs) => specs, + None => return Vec::new(), + }; + + let parameters: Vec<_> = function.get_parameters_ref().iter().cloned().collect(); + + // We separate deterministic dimensions (Concrete/Matrix) from fuzz dimensions so that + // explicit matrices Cartesian-multiply but independent fuzz draws *zip* together: with + // `#[test(a, b)]` the user expects N runs total, each binding `a[i]` and `b[i]`, not + // N² combinations. Matches Foundry's `[fuzz] runs = N` semantics. + let mut had_error = false; + let mut had_fuzz = false; + enum Dim { + Det(Vec), + Fuzz { + values: Vec, + param_name: String, + ty: Type, + domain: Domain, + exclude: Domain, + }, + } + let mut dims: Vec<(Symbol, Dim)> = Vec::with_capacity(parameters.len()); + for (param_index, param) in parameters.iter().enumerate() { + let Parameter(var, ty, var_loc) = param; + let owned_default; + let spec_ref = match specs.get(var) { + Some(s) => s, + None => { + // A parameter with no explicit `#[test(...)]` assignment is + // treated as an *implicit fuzz* input over an unrestricted + // domain. This intentionally replaces the legacy compiler's + // hard "Missing test parameter assignment" error: a bare + // `#[test] fun f(a: u64)` now expands into fuzz cases when a + // `FuzzValueSource` is registered (the move-unit-test runner + // installs `DefaultFuzzSource`), and reports a clear "no fuzz + // value source" diagnostic when one is not. This is a + // deliberate behavior change — see the runner-facing docs in + // `tests/unit_test/test/fuzz_implicit.move`. + owned_default = ParamSpec::Fuzz { + domain: Domain::default(), + exclude: Domain::default(), + }; + &owned_default + }, + }; + let is_fuzz = matches!(spec_ref, ParamSpec::Fuzz { .. }); + let param_name = env.symbol_pool().string(*var); + match materialize_param_values( + env, + fuzz_source, + &fn_id_loc, + &test_attribute_loc, + var_loc, + ty, + param_name.as_str(), + // Per-parameter salt: derived from the parameter position so two + // fuzz parameters of the same type draw distinct value streams + // rather than identical ones. The source mixes this with its own + // base seed (`--fuzz-seed`). + param_index as u64, + spec_ref, + ) { + Some(values) => { + if is_fuzz { + had_fuzz = true; + let (domain, exclude) = match spec_ref { + ParamSpec::Fuzz { domain, exclude } => (domain.clone(), exclude.clone()), + _ => unreachable!(), + }; + dims.push((*var, Dim::Fuzz { + values, + param_name: param_name.to_string(), + ty: ty.clone(), + domain, + exclude, + })); + } else { + dims.push((*var, Dim::Det(values))); + } + }, + None => had_error = true, + } + } + + if had_error { + return Vec::new(); + } + + let expected_failure = match abort_attribute_opt { + None => None, + Some(abort_attribute) => parse_failure_attribute(env, current_module, abort_attribute), + }; + + // Pairwise (2-way) covering over deterministic dimensions; zip across fuzz + // dimensions. Explicit matrices used to Cartesian-multiply (`∏ lenᵢ`), which + // bloats combinatorially: three `[1,2,3]` matrices alone were 27 cases. Most + // interaction bugs are 2-way, so we instead generate a pairwise covering + // array — every pair of values across any two matrix params still appears, + // but the case count collapses to roughly the product of the two largest + // dimensions. Pairwise == Cartesian for 0/1/2 matrix params, so this only + // shrinks expansions with three or more. Independent fuzz draws still *zip*: + // `#[test(a, b)]` is N runs binding `a[i]`/`b[i]`, not N² (Foundry's + // `[fuzz] runs = N` semantics). + let det_positions: Vec = dims + .iter() + .enumerate() + .filter_map(|(i, (_, d))| matches!(d, Dim::Det(_)).then_some(i)) + .collect(); + let det_lens: Vec = det_positions + .iter() + .map(|&i| match &dims[i].1 { + Dim::Det(vs) => vs.len(), + Dim::Fuzz { .. } => unreachable!(), + }) + .collect(); + // Each row selects a value-index for every deterministic dim (in + // `det_positions` order); `det_order_of_pos[i]` maps a `dims` position back + // to its column in a row, or `None` for fuzz dims. + let det_rows = pairwise_index_rows(&det_lens); + let mut det_order_of_pos: Vec> = vec![None; dims.len()]; + for (col, &pos) in det_positions.iter().enumerate() { + det_order_of_pos[pos] = Some(col); + } + let fuzz_runs: usize = dims + .iter() + .filter_map(|(_, d)| { + if let Dim::Fuzz { values, .. } = d { + Some(values.len()) + } else { + None + } + }) + .min() + .unwrap_or(1); + let total = det_rows.len().saturating_mul(fuzz_runs); + if total > MAX_FUZZ_CASES { + env.error( + &fn_id_loc, + &format!( + "#[test] expansion would produce {} cases (cap: {}). Narrow the matrix, fuzz \ + domain, or `--fuzz-runs`.", + total, MAX_FUZZ_CASES + ), + ); + return Vec::new(); + } + + if had_fuzz { + env.diag( + Severity::Note, + &fn_id_loc, + &format!( + "fuzz: expanded `{}` to {} case{}", + fn_name_str, + total, + if total == 1 { "" } else { "s" } + ), + ); + } + + if dims.is_empty() { + // Zero-arg function: a single case with no arguments and the bare function name. + return vec![ExpandedCase { + case: TestCase { + test_name: fn_name_str.to_string(), + function_name: fn_name_str.to_string(), + arguments: Vec::new(), + expected_failure, + }, + origins: Vec::new(), + }]; + } - let mut arguments = Vec::new(); - for param in function.get_parameters_ref() { - let Parameter(var, ty, var_loc) = ¶m; + let is_single = total == 1; - match test_annotation_params.get(var) { - Some(MoveValue::Address(addr)) => match ty { - Type::Primitive(PrimitiveType::Signer) => arguments.push(MoveValue::Signer(*addr)), - Type::Reference(_, inner) if **inner == Type::Primitive(PrimitiveType::Signer) => { - arguments.push(MoveValue::Signer(*addr)); + // For each pairwise row over the deterministic dims, run `fuzz_runs` zipped + // draws over the fuzz dims. With no fuzz dims this is just the pairwise rows; + // with no deterministic dims `det_rows` is a single empty row, so it reduces + // to the zipped fuzz draws. + let mut cases = Vec::with_capacity(total); + for det_row in &det_rows { + for fuzz_iter in 0..fuzz_runs { + let mut arguments = Vec::with_capacity(dims.len()); + let mut suffix_parts = Vec::with_capacity(dims.len()); + let mut origins = Vec::with_capacity(dims.len()); + for (i, (var, d)) in dims.iter().enumerate() { + let v = match d { + Dim::Det(vs) => { + let col = det_order_of_pos[i].expect("deterministic dim has a column"); + &vs[det_row[col]] + }, + Dim::Fuzz { values, .. } => &values[fuzz_iter % values.len()], + }; + arguments.push(v.clone()); + suffix_parts.push(format!( + "{}={}", + var.display(env.symbol_pool()), + format_move_value(v) + )); + origins.push(match d { + Dim::Det(_) => ArgOrigin::Fixed, + Dim::Fuzz { + param_name, + ty, + domain, + exclude, + .. + } => ArgOrigin::Fuzz { + param_name: param_name.clone(), + ty: ty.clone(), + domain: domain.clone(), + exclude: exclude.clone(), + }, + }); + } + // The display name embeds the case ordinal so it is unique even when + // two expansions draw the same argument values (e.g. a `bool` fuzz + // param, or a narrow domain). Without the ordinal these collide in + // the per-module `BTreeMap` and cases are silently + // dropped. The ordinal is the case's position in `cases`. + let test_name = if is_single { + fn_name_str.to_string() + } else { + format!( + "{}#{}[{}]", + fn_name_str, + cases.len(), + suffix_parts.join(",") + ) + }; + cases.push(ExpandedCase { + case: TestCase { + test_name, + function_name: fn_name_str.to_string(), + arguments, + expected_failure: expected_failure.clone(), }, - Type::Primitive(PrimitiveType::Address) => { - arguments.push(MoveValue::Address(*addr)) + origins, + }); + } + } + cases +} + +/// Build a 2-way (pairwise) covering array over deterministic matrix +/// dimensions, returning one row of value-indices per generated test case. +/// +/// Each entry of `lens` is the number of values a dimension can take; the +/// returned rows are index-tuples (`row[k]` selects a value for dimension `k`) +/// such that for *every* pair of dimensions, *every* combination of their +/// values appears in at least one row. This is the default expansion for +/// explicit `#[test]` matrices: most interaction bugs are 2-way, so pairwise +/// preserves that coverage while turning a full Cartesian product (`∏ lenᵢ`) +/// into roughly the product of the two largest dimensions. +/// +/// Degenerate inputs collapse to the exhaustive answer: zero dims yield one +/// empty row, one dim yields one row per value, and two dims yield the full +/// Cartesian product (pairwise *is* Cartesian when there are only two +/// parameters). Implemented with IPOG (In-Parameter-Order, General), which is +/// fully deterministic — no RNG — so expansions are reproducible run to run. +fn pairwise_index_rows(lens: &[usize]) -> Vec> { + // Sentinel for an unassigned ("don't care") slot during construction. + const FREE: usize = usize::MAX; + + if lens.is_empty() { + return vec![Vec::new()]; + } + if lens.iter().any(|&l| l == 0) { + // A zero-length dimension produces no cases at all; callers reject this + // earlier (`Empty matrix []`), but stay defensive rather than index + // out of bounds below. + return Vec::new(); + } + if lens.len() == 1 { + return (0..lens[0]).map(|v| vec![v]).collect(); + } + + // Seed with the full Cartesian product of the first two dimensions — the + // exact pairwise solution for two parameters. + let mut rows: Vec> = Vec::new(); + for a in 0..lens[0] { + for b in 0..lens[1] { + let mut row = vec![FREE; lens.len()]; + row[0] = a; + row[1] = b; + rows.push(row); + } + } + + // Extend one parameter at a time (IPOG horizontal then vertical growth). + for p in 2..lens.len() { + // Pairs still needing coverage between an earlier param `j < p` and `p`, + // encoded as `(j, value_of_j, value_of_p)`. A BTreeSet keeps iteration + // order deterministic. + let mut uncovered: BTreeSet<(usize, usize, usize)> = BTreeSet::new(); + for j in 0..p { + for vj in 0..lens[j] { + for vp in 0..lens[p] { + uncovered.insert((j, vj, vp)); + } + } + } + + // Horizontal growth: give each existing row the value for `p` that + // covers the most still-uncovered pairs. + for row in rows.iter_mut() { + if uncovered.is_empty() { + break; + } + let mut best_val = 0; + let mut best_gain = -1i64; + for vp in 0..lens[p] { + let gain = (0..p) + .filter(|&j| row[j] != FREE && uncovered.contains(&(j, row[j], vp))) + .count() as i64; + if gain > best_gain { + best_gain = gain; + best_val = vp; + } + } + row[p] = best_val; + for (j, &vj) in row.iter().enumerate().take(p) { + if vj != FREE { + uncovered.remove(&(j, vj, best_val)); + } + } + } + + // Vertical growth: cover the remaining pairs with new rows, merging into + // a row added during this pass whenever both slots are free or already + // agree. + let mut added: Vec> = Vec::new(); + while let Some(&(j, vj, vp)) = uncovered.iter().next() { + uncovered.remove(&(j, vj, vp)); + let mut merged = false; + for row in added.iter_mut() { + let j_ok = row[j] == FREE || row[j] == vj; + let p_ok = row[p] == FREE || row[p] == vp; + if j_ok && p_ok { + row[j] = vj; + row[p] = vp; + merged = true; + break; + } + } + if !merged { + let mut row = vec![FREE; lens.len()]; + row[j] = vj; + row[p] = vp; + added.push(row); + } + } + rows.extend(added); + } + + // Fill any remaining don't-care slots with a valid value (index 0); every + // required pair is already covered, so this only ever adds coverage. + for row in rows.iter_mut() { + for slot in row.iter_mut() { + if *slot == FREE { + *slot = 0; + } + } + } + rows +} + +/// Compact human-readable rendering for a `MoveValue`, used in expanded +/// test-case suffixes like `foo[a=@0x1,b=42]`. Also reused by the unit-test +/// runner to render shrink counterexamples, so the two stay in lock-step. +pub fn format_move_value(v: &MoveValue) -> String { + match v { + MoveValue::Address(a) | MoveValue::Signer(a) => format!("@{}", a.short_str_lossless()), + MoveValue::U8(x) => x.to_string(), + MoveValue::U16(x) => x.to_string(), + MoveValue::U32(x) => x.to_string(), + MoveValue::U64(x) => x.to_string(), + MoveValue::U128(x) => x.to_string(), + MoveValue::U256(x) => x.to_string(), + MoveValue::Bool(b) => b.to_string(), + other => format!("{:?}", other), + } +} + +/// Turn a [`ParamSpec`] into the concrete list of `MoveValue`s for that parameter. +/// Returns `None` and reports an error on type mismatch or fuzz-source failure. +fn materialize_param_values( + env: &GlobalEnv, + fuzz_source: &dyn FuzzValueSource, + fn_id_loc: &Loc, + test_attribute_loc: &Loc, + var_loc: &Loc, + ty: &Type, + param_name: &str, + seed: u64, + spec: &ParamSpec, +) -> Option> { + match spec { + ParamSpec::Concrete(v) => coerce_to_param_type(env, fn_id_loc, test_attribute_loc, var_loc, ty, v.clone()) + .map(|v| vec![v]), + ParamSpec::Matrix(vs) => { + if vs.is_empty() { + // `_a = []` would produce zero test cases for this dim, which + // collapses Cartesian expansion to zero total cases and trips + // the dimension-indexing loop in `build_test_info`. + env.error_with_labels(fn_id_loc, "unable to generate test", vec![ + ( + test_attribute_loc.clone(), + "Empty matrix `[]` produces no test cases".to_string(), + ), + ( + var_loc.clone(), + "Corresponding to this parameter".to_string(), + ), + ]); + return None; + } + let mut out = Vec::with_capacity(vs.len()); + for v in vs { + let coerced = coerce_to_param_type( + env, + fn_id_loc, + test_attribute_loc, + var_loc, + ty, + v.clone(), + )?; + out.push(coerced); + } + Some(out) + }, + ParamSpec::Fuzz { domain, exclude } => { + match fuzz_source.sample( + ty, + param_name, + domain, + exclude, + // `0` => let the source use its configured `runs` (`--fuzz-runs`). + FUZZ_RUNS_FROM_SOURCE, + seed, + ) + { + Ok(vs) if vs.is_empty() => { + env.error_with_labels(fn_id_loc, "unable to generate test", vec![ + ( + test_attribute_loc.clone(), + "Fuzz source returned no values for this parameter".to_string(), + ), + ( + var_loc.clone(), + "Corresponding to this parameter".to_string(), + ), + ]); + None }, - _ => { - let err_msg = "Unexpected argument type: expect an address or a signer"; - let invalid_test = "unable to generate test"; - env.error_with_labels(&fn_id_loc, invalid_test, vec![ - (test_attribute_loc.clone(), err_msg.to_string()), + Ok(vs) => Some(vs), + Err(msg) => { + env.error_with_labels(fn_id_loc, "unable to generate test", vec![ + (test_attribute_loc.clone(), msg), ( var_loc.clone(), "Corresponding to this parameter".to_string(), ), ]); + None }, + } + }, + } +} + +/// Apply the same signer/address coercion logic the legacy `#[test(a = @0x..)]` +/// code used. Returns `None` and reports an error on type mismatch. +fn coerce_to_param_type( + env: &GlobalEnv, + fn_id_loc: &Loc, + test_attribute_loc: &Loc, + var_loc: &Loc, + ty: &Type, + value: MoveValue, +) -> Option { + match (&value, ty) { + (MoveValue::Address(addr), Type::Primitive(PrimitiveType::Signer)) => { + Some(MoveValue::Signer(*addr)) + }, + (MoveValue::Address(addr), Type::Reference(_, inner)) + if **inner == Type::Primitive(PrimitiveType::Signer) => + { + Some(MoveValue::Signer(*addr)) + }, + (MoveValue::Address(_), Type::Primitive(PrimitiveType::Address)) => Some(value), + (MoveValue::Bool(_), Type::Primitive(PrimitiveType::Bool)) => Some(value), + // Integer carrier -> the parameter's actual width, with a range check. + (_, Type::Primitive(prim)) if is_uint_prim(prim) => match move_value_as_bigint(&value) { + Some(n) => { + coerce_numeric_to_width(env, fn_id_loc, test_attribute_loc, var_loc, prim, &n) }, - Some(value) => arguments.push(value.clone()), None => { - let missing_param_msg = "Missing test parameter assignment in test. Expected a \ - parameter to be assigned in this attribute"; - let invalid_test = "unable to generate test"; - env.error_with_labels(&fn_id_loc, invalid_test, vec![ - (test_attribute_loc.clone(), missing_param_msg.to_string()), - ( - var_loc.clone(), - "Corresponding to this parameter".to_string(), - ), - ]); + coerce_type_error(env, fn_id_loc, test_attribute_loc, var_loc); + None }, - } + }, + _ => { + coerce_type_error(env, fn_id_loc, test_attribute_loc, var_loc); + None + }, } +} - let expected_failure = match abort_attribute_opt { - None => None, - Some(abort_attribute) => parse_failure_attribute(env, current_module, abort_attribute), - }; +fn is_uint_prim(p: &PrimitiveType) -> bool { + matches!( + p, + PrimitiveType::U8 + | PrimitiveType::U16 + | PrimitiveType::U32 + | PrimitiveType::U64 + | PrimitiveType::U128 + | PrimitiveType::U256 + ) +} - Some(TestCase { - test_name: fn_name_str.to_string(), - arguments, - expected_failure, +/// Extract a `BigInt` from any integer `MoveValue`. Used to reinterpret a +/// `u256` literal carrier into the parameter's declared width. +fn move_value_as_bigint(v: &MoveValue) -> Option { + match v { + MoveValue::U8(x) => Some(BigInt::from(*x)), + MoveValue::U16(x) => Some(BigInt::from(*x)), + MoveValue::U32(x) => Some(BigInt::from(*x)), + MoveValue::U64(x) => Some(BigInt::from(*x)), + MoveValue::U128(x) => Some(BigInt::from(*x)), + MoveValue::U256(x) => { + let mut be = x.to_le_bytes(); + be.reverse(); + Some(BigInt::from_bytes_be(Sign::Plus, &be)) + }, + _ => None, + } +} + +/// Convert `n` to a `MoveValue` of the given uint width, reporting a range +/// error (and returning `None`) when it does not fit. +fn coerce_numeric_to_width( + env: &GlobalEnv, + fn_id_loc: &Loc, + test_attribute_loc: &Loc, + var_loc: &Loc, + prim: &PrimitiveType, + n: &BigInt, +) -> Option { + let max: BigInt = match prim { + PrimitiveType::U8 => BigInt::from(u8::MAX), + PrimitiveType::U16 => BigInt::from(u16::MAX), + PrimitiveType::U32 => BigInt::from(u32::MAX), + PrimitiveType::U64 => BigInt::from(u64::MAX), + PrimitiveType::U128 => BigInt::from(u128::MAX), + PrimitiveType::U256 => (BigInt::from(1) << 256) - BigInt::from(1), + _ => return None, + }; + if n.sign() == Sign::Minus || n > &max { + env.error_with_labels(fn_id_loc, "unable to generate test", vec![ + ( + test_attribute_loc.clone(), + format!("value {} is out of range for `{:?}`", n, prim), + ), + ( + var_loc.clone(), + "Corresponding to this parameter".to_string(), + ), + ]); + return None; + } + Some(match prim { + PrimitiveType::U8 => MoveValue::U8(n.to_u64().unwrap() as u8), + PrimitiveType::U16 => MoveValue::U16(n.to_u64().unwrap() as u16), + PrimitiveType::U32 => MoveValue::U32(n.to_u64().unwrap() as u32), + PrimitiveType::U64 => MoveValue::U64(n.to_u64().unwrap()), + PrimitiveType::U128 => MoveValue::U128(n.to_u128().unwrap()), + PrimitiveType::U256 => { + let (_sign, be) = n.to_bytes_be(); + let mut buf = [0u8; 32]; + buf[32 - be.len()..].copy_from_slice(&be); + buf.reverse(); + MoveValue::U256(u256::U256::from_le_bytes(&buf)) + }, + _ => return None, }) } +fn coerce_type_error(env: &GlobalEnv, fn_id_loc: &Loc, test_attribute_loc: &Loc, var_loc: &Loc) { + env.error_with_labels(fn_id_loc, "unable to generate test", vec![ + ( + test_attribute_loc.clone(), + "Unexpected argument type: expected an address, signer, bool, or integer".to_string(), + ), + ( + var_loc.clone(), + "Corresponding to this parameter".to_string(), + ), + ]); +} + //*************************************************************************** // Attribute parsers //*************************************************************************** +/// Parse the contents of `#[test(...)]` into one [`ParamSpec`] per named +/// parameter. Returns `None` if a fatal structural error was encountered (and +/// the caller should abandon test-case generation for this function). fn parse_test_attribute( env: &GlobalEnv, test_attribute: &Attribute, depth: usize, -) -> BTreeMap { +) -> Option> { match test_attribute { Attribute::Apply(id, _, _) if depth > 0 => { let aloc = env.get_node_loc(*id); env.error(&aloc, "Unexpected nested attribute in test declaration"); - BTreeMap::new() + None }, - Attribute::Apply(_id, sym, vec) => { + Attribute::Apply(_id, sym, inner) => { assert!( *TestingAttribute::TEST == env.symbol_pool().string(*sym).to_string(), "ICE: We should only be parsing a raw test attribute" ); - vec.iter() - .flat_map(|attr| parse_test_attribute(env, attr, depth + 1)) - .collect() - }, - Attribute::Assign(id, sym, val) => { - if depth != 1 { - let aloc = env.get_node_loc(*id); - env.error(&aloc, "Unexpected nested attribute in test declaration"); - return BTreeMap::new(); + let mut specs: BTreeMap = BTreeMap::new(); + for attr in inner { + if !merge_test_param_entry(env, &mut specs, attr) { + // entry-level errors have already been reported; keep processing the rest + } } + Some(specs) + }, + Attribute::Assign(id, _, _) | Attribute::Constrained(id, _, _, _) => { + let aloc = env.get_node_loc(*id); + env.error( + &aloc, + "Unexpected top-level form for #[test]; expected `#[test(...)]`", + ); + None + }, + } +} - let value = match convert_attribute_value_to_move_value(env, val) { - Some(move_value) => move_value, - None => { - let aloc = env.get_node_loc(*id); - let assign_loc = env.get_node_loc(*id); - env.error_with_labels(&assign_loc, "Unsupported attribute value", vec![( - aloc, - "Assigned in this attribute".to_string(), - )]); - return BTreeMap::new(); +/// Process one entry within `#[test(...)]` (e.g. `a = @0x1`, `a in 1..=10`, +/// `a != [..]`) and merge it into `specs`. Returns `true` on success. +fn merge_test_param_entry( + env: &GlobalEnv, + specs: &mut BTreeMap, + attr: &Attribute, +) -> bool { + match attr { + Attribute::Assign(id, sym, val) => { + let entry_loc = env.get_node_loc(*id); + // List literal on the RHS expands to a Matrix; anything else is a single value. + let new_spec = match val { + AttributeValue::List(_, items) => { + let mut values = Vec::with_capacity(items.len()); + for item in items { + match convert_attribute_value_to_move_value(env, item) { + Some(v) => values.push(v), + None => { + let iloc = attribute_value_loc(env, item); + env.error(&iloc, "Unsupported value in test matrix"); + return false; + }, + } + } + ParamSpec::Matrix(values) + }, + _ => match convert_attribute_value_to_move_value(env, val) { + Some(v) => ParamSpec::Concrete(v), + None => { + env.error_with_labels(&entry_loc, "Unsupported attribute value", vec![( + entry_loc.clone(), + "Assigned in this attribute".to_string(), + )]); + return false; + }, + }, + }; + insert_or_reject(env, specs, *sym, new_spec, &entry_loc) + }, + Attribute::Constrained(id, sym, op, val) => { + let entry_loc = env.get_node_loc(*id); + // Build/extend a Fuzz spec for this parameter. If `_a = ...` was already + // seen, restore the existing spec after reporting the mix error so the + // function's other parameters can still be analyzed coherently. + let existing = specs.remove(sym); + let (mut domain, mut exclude) = match existing { + None => (Domain::default(), Domain::default()), + Some(ParamSpec::Fuzz { domain, exclude }) => (domain, exclude), + Some(other) => { + env.error( + &entry_loc, + "Cannot mix `=` with `!=` / `in` for the same parameter", + ); + specs.insert(*sym, other); + return false; }, }; + let target = match op { + ConstraintOp::In => &mut domain, + ConstraintOp::Ne => &mut exclude, + }; + fold_into_domain(val, target); + specs.insert(*sym, ParamSpec::Fuzz { domain, exclude }); + true + }, + Attribute::Apply(id, _, _) => { + let aloc = env.get_node_loc(*id); + env.error(&aloc, "Unexpected nested attribute in test declaration"); + false + }, + } +} - let mut args = BTreeMap::new(); - args.insert(*sym, value); - args +/// Insert `new_spec` for `sym`, or report a duplicate / mixed-form error. +fn insert_or_reject( + env: &GlobalEnv, + specs: &mut BTreeMap, + sym: Symbol, + new_spec: ParamSpec, + entry_loc: &Loc, +) -> bool { + if specs.contains_key(&sym) { + env.error( + entry_loc, + "Duplicate or conflicting spec for this parameter (use one of `=`, `in`, or `!=`)", + ); + return false; + } + specs.insert(sym, new_spec); + true +} + +/// Flatten a model-AST `AttributeValue` into literals and ranges inside the +/// given [`Domain`]. Unions and nested lists are flattened recursively; +/// anything else lands in `literals`. +fn fold_into_domain(value: &AttributeValue, dom: &mut Domain) { + match value { + AttributeValue::Range { + lo, + hi, + inclusive_hi, + .. + } => dom.ranges.push(RangeSpec { + lo: (**lo).clone(), + hi: (**hi).clone(), + inclusive_hi: *inclusive_hi, + }), + AttributeValue::List(_, items) | AttributeValue::Union(_, items) => { + for item in items { + fold_into_domain(item, dom); + } }, + leaf => dom.literals.push(leaf.clone()), } } +fn attribute_value_loc(env: &GlobalEnv, value: &AttributeValue) -> Loc { + let id = match value { + AttributeValue::Value(id, _) => *id, + AttributeValue::Name(id, _, _) => *id, + AttributeValue::List(id, _) => *id, + AttributeValue::Range { id, .. } => *id, + AttributeValue::Union(id, _) => *id, + }; + env.get_node_loc(id) +} + fn parse_failure_attribute( env: &GlobalEnv, current_module: &ModuleName, @@ -280,6 +1021,14 @@ fn parse_failure_attribute( )]); None }, + Attribute::Constrained(id, _, _, _) => { + let aloc = env.get_node_loc(*id); + env.error( + &aloc, + "Constraint operators (`!=`, `in`) are not supported in #[expected_failure(...)]", + ); + None + }, Attribute::Apply(id, sym, attrs) => { assert!( TestingAttribute::EXPECTED_FAILURE == env.symbol_pool().string(*sym).to_string(), @@ -493,6 +1242,15 @@ fn check_attribute_unassigned(env: &GlobalEnv, kind: &str, attr: Attribute) -> O env.error(&attr_loc, &msg); None }, + Attribute::Constrained(id, sym, _, _) => { + assert!(env.symbol_pool().string(sym).to_string() == kind); + let attr_loc = env.get_node_loc(id); + env.error( + &attr_loc, + "Constraint operators (`!=`, `in`) are not supported in expected failure attributes", + ); + None + }, } } @@ -516,6 +1274,14 @@ fn get_assigned_attribute( env.error(&loc, &msg); None }, + Attribute::Constrained(id, _, _, _) => { + let loc = env.get_node_loc(id); + env.error( + &loc, + "Constraint operators (`!=`, `in`) are not supported in expected failure attributes", + ); + None + }, } } @@ -541,6 +1307,16 @@ fn convert_location(env: &GlobalEnv, attr: Attribute) -> Option { )]); None }, + AttributeValue::List(id, _) + | AttributeValue::Range { id, .. } + | AttributeValue::Union(id, _) => { + let vloc = env.get_node_loc(id); + env.error_with_labels(&loc, "invalid attribute value", vec![( + vloc, + "Expected a module identifier, e.g. 'std::vector'".to_string(), + )]); + None + }, } } @@ -562,6 +1338,16 @@ fn convert_constant_value_u64_constant_or_value( let vloc = env.get_node_loc(*id); (vloc, opt_module_name, sym) }, + AttributeValue::List(id, _) + | AttributeValue::Range { id, .. } + | AttributeValue::Union(id, _) => { + let loc = env.get_node_loc(*id); + env.error( + &loc, + "Expected a numeric constant or value; list, range, and union forms are not supported here", + ); + return None; + }, }; let module_env: ModuleEnv = if let Some(module_name) = opt_module_name { if let Some(module_env) = env.find_module(module_name) { @@ -693,17 +1479,39 @@ fn convert_attribute_value_to_move_value( env: &GlobalEnv, value: &AttributeValue, ) -> Option { - // Only addresses are allowed + // Addresses, bools, and integer literals are accepted. Integers are carried + // as a `u256` placeholder here because the parameter's actual width is not + // known until `coerce_to_param_type` runs; coercion narrows (with a range + // check) to the real type. match value { AttributeValue::Value(_id, Value::Address(addr)) => match addr { Address::Numerical(num) => Some(*num), Address::Symbolic(sym) => env.resolve_address_alias(*sym), } .map(MoveValue::Address), + AttributeValue::Value(_id, Value::Bool(b)) => Some(MoveValue::Bool(*b)), + AttributeValue::Value(_id, Value::Number(n)) => bigint_to_u256_carrier(n), _ => None, } } +/// Carry a non-negative integer literal as a `u256` `MoveValue`. Returns `None` +/// for negative or larger-than-`u256` values (which cannot appear for a Move +/// integer literal, but are rejected defensively). +fn bigint_to_u256_carrier(n: &BigInt) -> Option { + if n.sign() == Sign::Minus { + return None; + } + let (_sign, be) = n.to_bytes_be(); + if be.len() > 32 { + return None; + } + let mut buf = [0u8; 32]; + buf[32 - be.len()..].copy_from_slice(&be); + buf.reverse(); // to little-endian for U256::from_le_bytes + Some(MoveValue::U256(u256::U256::from_le_bytes(&buf))) +} + fn check_location(env: &GlobalEnv, loc: Loc, attr: &str, location: Option) -> Option { if location.is_none() { let msg = format!( @@ -715,3 +1523,81 @@ fn check_location(env: &GlobalEnv, loc: Loc, attr: &str, location: Option) } location } + +#[cfg(test)] +mod tests { + use super::pairwise_index_rows; + use std::collections::BTreeSet; + + /// Every row must be a valid index-tuple for the given dimension sizes. + fn assert_in_bounds(lens: &[usize], rows: &[Vec]) { + for row in rows { + assert_eq!(row.len(), lens.len()); + for (k, &v) in row.iter().enumerate() { + assert!(v < lens[k], "value {} out of bounds for dim {} (len {})", v, k, lens[k]); + } + } + } + + /// The covering property: for every pair of dimensions, every combination + /// of their values appears in at least one row. + fn assert_pairwise_covered(lens: &[usize], rows: &[Vec]) { + for i in 0..lens.len() { + for j in (i + 1)..lens.len() { + let seen: BTreeSet<(usize, usize)> = + rows.iter().map(|r| (r[i], r[j])).collect(); + assert_eq!( + seen.len(), + lens[i] * lens[j], + "dims ({i},{j}) with lens ({},{}) not fully covered: {} of {}", + lens[i], + lens[j], + seen.len(), + lens[i] * lens[j] + ); + } + } + } + + #[test] + fn degenerate_dimensions() { + assert_eq!(pairwise_index_rows(&[]), vec![Vec::::new()]); + assert_eq!(pairwise_index_rows(&[3]), vec![vec![0], vec![1], vec![2]]); + // A zero-length dimension yields no rows at all. + assert!(pairwise_index_rows(&[2, 0, 3]).is_empty()); + } + + #[test] + fn two_dims_are_full_cartesian() { + let lens = [2usize, 3]; + let rows = pairwise_index_rows(&lens); + assert_eq!(rows.len(), 6); + assert_in_bounds(&lens, &rows); + assert_pairwise_covered(&lens, &rows); + } + + #[test] + fn three_plus_dims_cover_all_pairs_and_shrink() { + // 3^3 = 27 full Cartesian; pairwise must cover every pair yet stay well + // under the product (the pairwise lower bound here is 3*3 = 9). + let lens = [3usize, 3, 3]; + let rows = pairwise_index_rows(&lens); + assert_in_bounds(&lens, &rows); + assert_pairwise_covered(&lens, &rows); + assert!(rows.len() < 27, "expected shrink below full Cartesian, got {}", rows.len()); + assert!(rows.len() >= 9, "cannot cover all pairs with fewer than 9 rows"); + + // Mixed sizes and more dimensions still satisfy the covering property. + for lens in [ + vec![2usize, 3, 4], + vec![4usize, 3, 2, 5], + vec![2usize, 2, 2, 2, 2], + ] { + let rows = pairwise_index_rows(&lens); + assert_in_bounds(&lens, &rows); + assert_pairwise_covered(&lens, &rows); + let full: usize = lens.iter().product(); + assert!(rows.len() <= full); + } + } +} diff --git a/third_party/move/move-compiler-v2/tests/testsuite.rs b/third_party/move/move-compiler-v2/tests/testsuite.rs index 5d462e71317..e48ec6dfed4 100644 --- a/third_party/move/move-compiler-v2/tests/testsuite.rs +++ b/third_party/move/move-compiler-v2/tests/testsuite.rs @@ -6,8 +6,10 @@ use anyhow::bail; use codespan_reporting::{diagnostic::Severity, term::termcolor::Buffer}; use libtest_mimic::{Arguments, Trial}; use move_compiler_v2::{ - annotate_units, disassemble_compiled_units, logging, pipeline, plan_builder, - run_bytecode_verifier, run_file_format_gen, Experiment, Options, + annotate_units, disassemble_compiled_units, + fuzz::{DefaultFuzzSource, FuzzConfig}, + logging, pipeline, plan_builder, run_bytecode_verifier, run_file_format_gen, Experiment, + Options, }; use move_model::{metadata::LanguageVersion, model::GlobalEnv, sourcifier::Sourcifier}; use move_prover_test_utils::{baseline_test, extract_test_directives}; @@ -641,7 +643,8 @@ fn run_flow_similar_to_compiler(config: &TestConfig, options: &Options) -> anyho // Build the test plan here to parse and validate any test-related attributes in the AST. // In real use, this is run outside of the compilation process, but the needed info is // available in `env` once we finish the AST. - plan_builder::construct_test_plan(&env, None); + let fuzz_source = DefaultFuzzSource::new(&env, FuzzConfig::default()); + plan_builder::construct_test_plan_with_fuzz_source(&env, None, &fuzz_source); ok = check_diags(&mut test_output.borrow_mut(), &env, options); } diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.exp new file mode 100644 index 00000000000..edef6e92a72 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.exp @@ -0,0 +1,46 @@ + +Diagnostics: +note: fuzz: expanded `ne_single` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:6:16 + │ +6 │ public fun ne_single(_a: signer) { } + │ ^^^^^^^^^ + +note: fuzz: expanded `ne_list` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:9:16 + │ +9 │ public fun ne_list(_a: signer) { } + │ ^^^^^^^ + +note: fuzz: expanded `in_list` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:12:16 + │ +12 │ public fun in_list(_a: signer) { } + │ ^^^^^^^ + +note: fuzz: expanded `in_inclusive_range` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:15:16 + │ +15 │ public fun in_inclusive_range(_a: signer) { } + │ ^^^^^^^^^^^^^^^^^^ + +note: fuzz: expanded `in_half_open_range` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:18:16 + │ +18 │ public fun in_half_open_range(_a: signer) { } + │ ^^^^^^^^^^^^^^^^^^ + +note: fuzz: expanded `in_union` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:21:16 + │ +21 │ public fun in_union(_a: signer) { } + │ ^^^^^^^^ + +note: fuzz: expanded `in_with_excludes` to 64 cases + ┌─ tests/unit_test/test/fuzz_constraints.move:26:16 + │ +26 │ public fun in_with_excludes(_a: signer) { } + │ ^^^^^^^^^^^^^^^^ + + +============ bytecode verification succeeded ======== diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.move new file mode 100644 index 00000000000..cf87522c497 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_constraints.move @@ -0,0 +1,27 @@ +// New grammar: `name in ` and `name != ` build a fuzz spec. +// Parser acceptance is asserted by the absence of a parse error; with no +// FuzzValueSource registered the planner reports a clear diagnostic. +module 0x1::M { + #[test(_a != @0x42)] + public fun ne_single(_a: signer) { } + + #[test(_a != [@0x42, @0x41])] + public fun ne_list(_a: signer) { } + + #[test(_a in [@0x1, @0x2, @0x3])] + public fun in_list(_a: signer) { } + + #[test(_a in 1..=10)] + public fun in_inclusive_range(_a: signer) { } + + #[test(_a in 1..10)] + public fun in_half_open_range(_a: signer) { } + + #[test(_a in @0x1 | @0x5..=@0x10 | @0x20)] + public fun in_union(_a: signer) { } + + // Combining `in` and `!=` on the same parameter is allowed; the domain + // narrows and the exclude set accumulates. + #[test(_a in [@0x1, @0x2, @0x3], _a != @0x2)] + public fun in_with_excludes(_a: signer) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.exp new file mode 100644 index 00000000000..504bc80fdca --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.exp @@ -0,0 +1,9 @@ + +Diagnostics: +error: unable to generate test + ┌─ tests/unit_test/test/fuzz_empty_matrix.move:6:16 + │ +5 │ #[test(_a = [])] + │ ------------- Empty matrix `[]` produces no test cases +6 │ public fun empty_matrix(_a: u64) { } + │ ^^^^^^^^^^^^ -- Corresponding to this parameter diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.move new file mode 100644 index 00000000000..b072e1bfd17 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_empty_matrix.move @@ -0,0 +1,7 @@ +// `_a = []` is a programmer error: it would produce zero cases for this +// dimension. We report a diagnostic at plan-build time instead of crashing +// the runner with an out-of-bounds index. +module 0x1::M { + #[test(_a = [])] + public fun empty_matrix(_a: u64) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.exp new file mode 100644 index 00000000000..c3a34d7fc1e --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.exp @@ -0,0 +1,10 @@ + +Diagnostics: +note: fuzz: expanded `fuzz_with_fixtures` to 64 cases + ┌─ tests/unit_test/test/fuzz_fixtures.move:11:16 + │ +11 │ public fun fuzz_with_fixtures(_amount: u64, _recipient: address, _salt: u32) { } + │ ^^^^^^^^^^^^^^^^^^ + + +============ bytecode verification succeeded ======== diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.move new file mode 100644 index 00000000000..92dc0017bb0 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_fixtures.move @@ -0,0 +1,12 @@ +// User-declared fixtures. The default fuzz source mines `FIXTURE_` +// constants from the module and pipes them into the matching parameter's +// candidate pool ahead of random + edge values. +module 0x1::M { + const FIXTURE_AMOUNT: u64 = 42; + const FIXTURE_AMOUNT_HI: u64 = 18446744073709551610; + const FIXTURE_RECIPIENT: address = @0xCAFE; + + // `amount` and `recipient` get fixture-biased draws; `salt` does not. + #[test] + public fun fuzz_with_fixtures(_amount: u64, _recipient: address, _salt: u32) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.exp new file mode 100644 index 00000000000..a6d4fc028b1 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.exp @@ -0,0 +1,16 @@ + +Diagnostics: +note: fuzz: expanded `bare_with_signer` to 64 cases + ┌─ tests/unit_test/test/fuzz_implicit.move:6:16 + │ +6 │ public fun bare_with_signer(_a: signer) { } + │ ^^^^^^^^^^^^^^^^ + +note: fuzz: expanded `bare_with_two` to 64 cases + ┌─ tests/unit_test/test/fuzz_implicit.move:9:16 + │ +9 │ public fun bare_with_two(_a: signer, _b: address) { } + │ ^^^^^^^^^^^^^ + + +============ bytecode verification succeeded ======== diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.move new file mode 100644 index 00000000000..96b5a716d9c --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_implicit.move @@ -0,0 +1,37 @@ +// Bare #[test] on a function with parameters now treats the parameters as +// implicit fuzz inputs. With no FuzzValueSource registered the compiler reports +// a clear diagnostic instead of the old "Missing test parameter assignment". +module 0x1::M { + #[test] + public fun bare_with_signer(_a: signer) { } + + #[test] + public fun bare_with_two(_a: signer, _b: address) { } + + // No parameters: no fuzz, no error. + #[test] + public fun bare_zero_args() { } +} + +// Implicit fuzzing — intended behavior (read me) +// =============================================== +// A `#[test]` function whose parameters are NOT explicitly assigned (no +// `#[test(a = ..)]`, `a in ..`, or `a != ..`) treats each unassigned parameter +// as an implicit fuzz input over an unrestricted domain. This is a deliberate, +// backwards-incompatible change from the legacy compiler, which rejected such a +// test with a hard "Missing test parameter assignment in test" error. +// +// What you observe depends on whether a `FuzzValueSource` is registered: +// * move-unit-test runner: installs `DefaultFuzzSource`, so each parameter is +// sampled and the test expands into `--fuzz-runs` cases (default 16). A +// bare `#[test] fun f(a: u64)` therefore RUNS rather than failing to build. +// * compiler-only golden tests (this suite): a source is registered, so the +// diagnostics show `fuzz: expanded to N cases`. With no source at all, +// the planner reports a clear "no fuzz value source registered" error +// instead of the old missing-assignment error. +// +// Functions with zero parameters are unaffected: no fuzzing, no error. +// +// Migration note: a pre-existing test that relied on the missing-assignment +// error to flag an under-specified signature will now be fuzzed instead. Assign +// the parameter explicitly (e.g. `#[test(a = 0)]`) to pin it to a fixed value. diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.exp new file mode 100644 index 00000000000..90b32906711 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.exp @@ -0,0 +1,2 @@ + +============ bytecode verification succeeded ======== diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.move new file mode 100644 index 00000000000..bff09207b52 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_matrix.move @@ -0,0 +1,12 @@ +// Matrix expansion: `a = [...]` produces one test case per element. +module 0x1::M { + #[test(_a = [@0x1, @0x2, @0x3])] + public fun matrix_single(_a: signer) { } + + #[test(_a = [@0x1, @0x2], _b = [@0xa, @0xb])] + public fun matrix_cartesian(_a: signer, _b: signer) { } + + // Singleton matrix [v] behaves like `a = v`. + #[test(_a = [@0x1])] + public fun matrix_singleton(_a: signer) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.exp new file mode 100644 index 00000000000..1ae6704f615 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.exp @@ -0,0 +1,13 @@ + +Diagnostics: +error: Cannot mix `=` with `!=` / `in` for the same parameter + ┌─ tests/unit_test/test/fuzz_mix_assign_constraint.move:3:23 + │ +3 │ #[test(_a = @0x1, _a != @0x2)] + │ ^^^^^^^^^^ + +error: Cannot mix `=` with `!=` / `in` for the same parameter + ┌─ tests/unit_test/test/fuzz_mix_assign_constraint.move:6:12 + │ +6 │ #[test(_a != @0x2, _a = @0x1)] + │ ^^^^^^^^^^ diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.move new file mode 100644 index 00000000000..f8011125026 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_mix_assign_constraint.move @@ -0,0 +1,8 @@ +// Cannot mix `=` with `!=` / `in` on the same parameter. +module 0x1::M { + #[test(_a = @0x1, _a != @0x2)] + public fun mix_eq_then_ne(_a: signer) { } + + #[test(_a != @0x2, _a = @0x1)] + public fun mix_ne_then_eq(_a: signer) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.exp new file mode 100644 index 00000000000..b5ebd0e7580 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.exp @@ -0,0 +1,17 @@ + +Diagnostics: +error: unable to generate test + ┌─ tests/unit_test/test/fuzz_out_of_range.move:6:16 + │ +5 │ #[test(_a != 300)] + │ --------------- fuzz: value 300 is out of range for this integer parameter (max 255) +6 │ public fun exclude_out_of_range(_a: u8) { } + │ ^^^^^^^^^^^^^^^^^^^^ -- Corresponding to this parameter + +error: unable to generate test + ┌─ tests/unit_test/test/fuzz_out_of_range.move:9:16 + │ +8 │ #[test(_a in 250..300)] + │ -------------------- fuzz: range bound 300 is out of range for this integer parameter (max 255) +9 │ public fun range_out_of_range(_a: u8) { } + │ ^^^^^^^^^^^^^^^^^^ -- Corresponding to this parameter diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.move new file mode 100644 index 00000000000..29d956a24b7 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_out_of_range.move @@ -0,0 +1,10 @@ +// Out-of-range fuzz constraints are rejected at plan-build time — the same +// policy applied to concrete `#[test(a = ..)]` values — rather than silently +// wrapped (e.g. `!= 300` on a u8 must NOT become `!= 44`). +module 0x1::M { + #[test(_a != 300)] + public fun exclude_out_of_range(_a: u8) { } + + #[test(_a in 250..300)] + public fun range_out_of_range(_a: u8) { } +} diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.exp b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.exp new file mode 100644 index 00000000000..83681753d1f --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.exp @@ -0,0 +1,52 @@ + +Diagnostics: +note: fuzz: expanded `fuzz_u64` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:7:16 + │ +7 │ public fun fuzz_u64(_a: u64) { } + │ ^^^^^^^^ + +note: fuzz: expanded `fuzz_u8` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:10:16 + │ +10 │ public fun fuzz_u8(_a: u8) { } + │ ^^^^^^^ + +note: fuzz: expanded `fuzz_bool` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:13:16 + │ +13 │ public fun fuzz_bool(_a: bool) { } + │ ^^^^^^^^^ + +note: fuzz: expanded `fuzz_address` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:16:16 + │ +16 │ public fun fuzz_address(_a: address) { } + │ ^^^^^^^^^^^^ + +note: fuzz: expanded `fuzz_pair` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:19:16 + │ +19 │ public fun fuzz_pair(_a: u64, _b: address) { } + │ ^^^^^^^^^ + +note: fuzz: expanded `fuzz_range_u64` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:22:16 + │ +22 │ public fun fuzz_range_u64(_a: u64) { } + │ ^^^^^^^^^^^^^^ + +note: fuzz: expanded `fuzz_exclude_u64` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:25:16 + │ +25 │ public fun fuzz_exclude_u64(_a: u64) { } + │ ^^^^^^^^^^^^^^^^ + +note: fuzz: expanded `fuzz_addr_list` to 64 cases + ┌─ tests/unit_test/test/fuzz_primitives.move:28:16 + │ +28 │ public fun fuzz_addr_list(_a: signer) { } + │ ^^^^^^^^^^^^^^ + + +============ bytecode verification succeeded ======== diff --git a/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.move b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.move new file mode 100644 index 00000000000..a0018150ee7 --- /dev/null +++ b/third_party/move/move-compiler-v2/tests/unit_test/test/fuzz_primitives.move @@ -0,0 +1,29 @@ +// End-to-end: implicit fuzz on primitive types now produces actual test cases. +// Diagnostics-side, all we see is the `[NOTE] fuzz: expanded …` line per +// function — the expanded cases live in the runner's plan, not in compiler +// diagnostics. +module 0x1::M { + #[test] + public fun fuzz_u64(_a: u64) { } + + #[test] + public fun fuzz_u8(_a: u8) { } + + #[test] + public fun fuzz_bool(_a: bool) { } + + #[test] + public fun fuzz_address(_a: address) { } + + #[test] + public fun fuzz_pair(_a: u64, _b: address) { } + + #[test(_a in 1..=10)] + public fun fuzz_range_u64(_a: u64) { } + + #[test(_a != 42)] + public fun fuzz_exclude_u64(_a: u64) { } + + #[test(_a in [@0x1, @0x2, @0x3])] + public fun fuzz_addr_list(_a: signer) { } +} diff --git a/third_party/move/move-model/src/ast.rs b/third_party/move/move-model/src/ast.rs index 462901dbf53..4a9183adb3e 100644 --- a/third_party/move/move-model/src/ast.rs +++ b/third_party/move/move-model/src/ast.rs @@ -70,18 +70,35 @@ pub struct SpecFunDecl { pub enum AttributeValue { Value(NodeId, Value), Name(NodeId, Option, Symbol), + List(NodeId, Vec), + Range { + id: NodeId, + lo: Box, + hi: Box, + inclusive_hi: bool, + }, + Union(NodeId, Vec), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConstraintOp { + Ne, + In, } #[derive(Debug, Clone)] pub enum Attribute { Apply(NodeId, Symbol, Vec), Assign(NodeId, Symbol, AttributeValue), + Constrained(NodeId, Symbol, ConstraintOp, AttributeValue), } impl Attribute { pub fn name(&self) -> Symbol { match self { - Attribute::Assign(_, s, _) | Attribute::Apply(_, s, _) => *s, + Attribute::Assign(_, s, _) + | Attribute::Apply(_, s, _) + | Attribute::Constrained(_, s, _, _) => *s, } } @@ -91,7 +108,9 @@ impl Attribute { pub fn node_id(&self) -> NodeId { match self { - Attribute::Assign(id, _, _) | Attribute::Apply(id, _, _) => *id, + Attribute::Assign(id, _, _) + | Attribute::Apply(id, _, _) + | Attribute::Constrained(id, _, _, _) => *id, } } } diff --git a/third_party/move/move-model/src/builder/module_builder.rs b/third_party/move/move-model/src/builder/module_builder.rs index 13eda135ea3..eb743eb23d5 100644 --- a/third_party/move/move-model/src/builder/module_builder.rs +++ b/third_party/move/move-model/src/builder/module_builder.rs @@ -4,10 +4,10 @@ use crate::{ ast::{ - AccessSpecifier, Address, Attribute, AttributeValue, Condition, ConditionKind, Exp, - ExpData, FriendDecl, ModuleName, Operation, Pattern, PropertyBag, PropertyValue, - QualifiedSymbol, Spec, SpecBlockInfo, SpecBlockTarget, SpecFunDecl, SpecVarDecl, TempIndex, - UseDecl, Value, + AccessSpecifier, Address, Attribute, AttributeValue, Condition, ConditionKind, + ConstraintOp, Exp, ExpData, FriendDecl, ModuleName, Operation, Pattern, PropertyBag, + PropertyValue, QualifiedSymbol, Spec, SpecBlockInfo, SpecBlockTarget, SpecFunDecl, + SpecVarDecl, TempIndex, UseDecl, Value, }, builder::{ exp_builder::ExpTranslator, @@ -400,66 +400,102 @@ impl ModuleBuilder<'_, '_> { Attribute::Apply(node_id, sym, self.translate_attributes(vs)) }, EA::Attribute_::Assigned(n, v) => { - let value_node_id = self - .parent - .env - .new_node(self.parent.to_loc(&v.loc), Type::Tuple(vec![])); - let v = match &v.value { - EA::AttributeValue_::Value(val) => { - let val = if let Some((val, _)) = ExpTranslator::new(self) - .translate_value_free(val, &ErrorMessageContext::General) - { - val - } else { - // Error reported - Value::Bool(false) - }; - AttributeValue::Value(value_node_id, val) - }, - EA::AttributeValue_::Module(mident) => { - let addr_bytes = self.parent.resolve_address( - &self.parent.to_loc(&mident.loc), - &mident.value.address, - ); - let module_name = ModuleName::from_address_bytes_and_name( - addr_bytes, - self.symbol_pool() - .make(mident.value.module.0.value.as_str()), - ); - // TODO support module attributes more than via empty string - AttributeValue::Name( - value_node_id, - Some(module_name), - self.symbol_pool().make(""), - ) - }, - EA::AttributeValue_::ModuleAccess(macc) => match macc.value { - EA::ModuleAccess_::Name(n) => AttributeValue::Name( - value_node_id, - None, - self.symbol_pool().make(n.value.as_str()), - ), - EA::ModuleAccess_::ModuleAccess(mident, n, _) => { - let (_, macc) = self.check_no_variant_and_convert_maccess(macc); - let addr_bytes = self.parent.resolve_address( - &self.parent.to_loc(&macc.loc), - &mident.value.address, - ); - let module_name = ModuleName::from_address_bytes_and_name( - addr_bytes, - self.symbol_pool() - .make(mident.value.module.0.value.as_str()), - ); - AttributeValue::Name( - value_node_id, - Some(module_name), - self.symbol_pool().make(n.value.as_str()), - ) - }, - }, - }; + let v = self.translate_attribute_value(v); Attribute::Assign(node_id, self.symbol_pool().make(n.value.as_str()), v) }, + EA::Attribute_::Constrained(n, op, v) => { + let v = self.translate_attribute_value(v); + let op = match op { + EA::ConstraintOp::Ne => ConstraintOp::Ne, + EA::ConstraintOp::In => ConstraintOp::In, + }; + Attribute::Constrained(node_id, self.symbol_pool().make(n.value.as_str()), op, v) + }, + } + } + + fn translate_attribute_value(&mut self, v: &EA::AttributeValue) -> AttributeValue { + let value_node_id = self + .parent + .env + .new_node(self.parent.to_loc(&v.loc), Type::Tuple(vec![])); + match &v.value { + EA::AttributeValue_::Value(val) => { + let val = if let Some((val, _)) = ExpTranslator::new(self) + .translate_value_free(val, &ErrorMessageContext::General) + { + val + } else { + // Error reported + Value::Bool(false) + }; + AttributeValue::Value(value_node_id, val) + }, + EA::AttributeValue_::Module(mident) => { + let addr_bytes = self.parent.resolve_address( + &self.parent.to_loc(&mident.loc), + &mident.value.address, + ); + let module_name = ModuleName::from_address_bytes_and_name( + addr_bytes, + self.symbol_pool() + .make(mident.value.module.0.value.as_str()), + ); + // TODO support module attributes more than via empty string + AttributeValue::Name( + value_node_id, + Some(module_name), + self.symbol_pool().make(""), + ) + }, + EA::AttributeValue_::ModuleAccess(macc) => match macc.value { + EA::ModuleAccess_::Name(n) => AttributeValue::Name( + value_node_id, + None, + self.symbol_pool().make(n.value.as_str()), + ), + EA::ModuleAccess_::ModuleAccess(mident, n, _) => { + let (_, macc) = self.check_no_variant_and_convert_maccess(macc); + let addr_bytes = self.parent.resolve_address( + &self.parent.to_loc(&macc.loc), + &mident.value.address, + ); + let module_name = ModuleName::from_address_bytes_and_name( + addr_bytes, + self.symbol_pool() + .make(mident.value.module.0.value.as_str()), + ); + AttributeValue::Name( + value_node_id, + Some(module_name), + self.symbol_pool().make(n.value.as_str()), + ) + }, + }, + EA::AttributeValue_::List(items) => { + let items = items + .iter() + .map(|item| self.translate_attribute_value(item)) + .collect(); + AttributeValue::List(value_node_id, items) + }, + EA::AttributeValue_::Range { + lo, + hi, + inclusive_hi, + } => AttributeValue::Range { + id: value_node_id, + lo: Box::new(self.translate_attribute_value(lo)), + hi: Box::new(self.translate_attribute_value(hi)), + inclusive_hi: *inclusive_hi, + }, + EA::AttributeValue_::Union(items) => { + let items = items + .iter() + .map(|item| self.translate_attribute_value(item)) + .collect(); + AttributeValue::Union(value_node_id, items) + }, } } } diff --git a/third_party/move/move-prover/move-docgen/src/docgen.rs b/third_party/move/move-prover/move-docgen/src/docgen.rs index dd5a75e2bee..8340831dc6c 100644 --- a/third_party/move/move-prover/move-docgen/src/docgen.rs +++ b/third_party/move/move-prover/move-docgen/src/docgen.rs @@ -565,6 +565,47 @@ impl<'env> Docgen<'env> { } } + /// Gets a readable version of an attribute value. + fn gen_attribute_value(&self, value: &AttributeValue) -> String { + match value { + AttributeValue::Value(_node_id, value) => self.env.display(value).to_string(), + AttributeValue::Name(_node_id, module_name_option, symbol2) => { + let symbol2_name = self.name_string(*symbol2).to_string(); + let module_prefix = match module_name_option { + None => "".to_string(), + Some(ref module_name) => { + format!("{}::", module_name.display_full(self.env)) + }, + }; + format!("{}{}", module_prefix, symbol2_name) + }, + AttributeValue::List(_, items) => { + let inner = items + .iter() + .map(|i| self.gen_attribute_value(i)) + .join(", "); + format!("[{}]", inner) + }, + AttributeValue::Range { + lo, + hi, + inclusive_hi, + .. + } => { + format!( + "{}{}{}", + self.gen_attribute_value(lo), + if *inclusive_hi { "..=" } else { ".." }, + self.gen_attribute_value(hi) + ) + }, + AttributeValue::Union(_, items) => items + .iter() + .map(|i| self.gen_attribute_value(i)) + .join(" | "), + } + } + /// Gets a readable version of an attribute. fn gen_attribute(&self, attribute: &Attribute) -> String { let annotation_body: String = match attribute { @@ -579,22 +620,24 @@ impl<'env> Docgen<'env> { }, Attribute::Assign(_node_id, symbol, attribute_value) => { let symbol_string = self.name_string(*symbol).to_string(); - match attribute_value { - AttributeValue::Value(_node_id, value) => { - let value_string = self.env.display(value); - format!("{} = {}", symbol_string, value_string) - }, - AttributeValue::Name(_node_id, module_name_option, symbol2) => { - let symbol2_name = self.name_string(*symbol2).to_string(); - let module_prefix = match module_name_option { - None => "".to_string(), - Some(ref module_name) => { - format!("{}::", module_name.display_full(self.env)) - }, - }; - format!("{} = {}{}", symbol_string, module_prefix, symbol2_name) - }, - } + format!( + "{} = {}", + symbol_string, + self.gen_attribute_value(attribute_value) + ) + }, + Attribute::Constrained(_node_id, symbol, op, attribute_value) => { + let symbol_string = self.name_string(*symbol).to_string(); + let op_str = match op { + move_model::ast::ConstraintOp::Ne => "!=", + move_model::ast::ConstraintOp::In => "in", + }; + format!( + "{} {} {}", + symbol_string, + op_str, + self.gen_attribute_value(attribute_value) + ) }, }; annotation_body diff --git a/third_party/move/tools/move-cli/src/base/test.rs b/third_party/move/tools/move-cli/src/base/test.rs index 71c4f1fd929..838e6945ef1 100644 --- a/third_party/move/tools/move-cli/src/base/test.rs +++ b/third_party/move/tools/move-cli/src/base/test.rs @@ -11,7 +11,10 @@ use legacy_move_compiler::{ unit_test::TestPlan, }; use move_command_line_common::files::{FileHash, MOVE_COVERAGE_MAP_EXTENSION}; -use move_compiler_v2::plan_builder as plan_builder_v2; +use move_compiler_v2::{ + fuzz::{random_seed, DefaultFuzzSource, FuzzConfig, FuzzValueSource}, + plan_builder as plan_builder_v2, +}; use move_core_types::effects::ChangeSet; use move_coverage::coverage_map::{output_map_to_file, CoverageMap}; use move_package::{ @@ -20,7 +23,7 @@ use move_package::{ }; use move_unit_test::{ test_reporter::{UnitTestFactory, UnitTestFactoryWithCostTable}, - UnitTestingConfig, + FuzzRunnerCtx, UnitTestingConfig, }; use move_vm_runtime::tracing::{LOGGING_FILE_WRITER, TRACING_ENABLED}; use move_vm_test_utils::gas_schedule::CostTable; @@ -37,6 +40,7 @@ use std::{ ops::Deref, path::{Path, PathBuf}, process::ExitStatus, + sync::Arc, }; // if not windows nor unix #[cfg(not(any(target_family = "windows", target_family = "unix")))] @@ -221,6 +225,11 @@ pub fn run_move_unit_tests_with_factory = + Arc::new(DefaultFuzzSource::new(&env, fuzz_config)); + let built = plan_builder_v2::construct_test_plan_with_fuzz_source( + &env, + Some(root_package_in_model), + fuzz_source.as_ref(), + ); + // Keep the fuzz metadata/source alongside the plan so the runner can + // shrink failing fuzz cases; `None` (no tests / not compiled) is fine. + let (built_test_plan, fuzz_ctx) = match built { + Some(build) => { + let ctx: Arc = Arc::new(FuzzRunnerCtx { + metadata: build.fuzz_metadata, + source: fuzz_source.clone(), + corpus_dir: None, + }); + (Some(build.plans), Some(ctx)) + }, + None => (None, None), + }; - test_plan = Some((built_test_plan, files.clone(), units.clone())); + test_plan = Some((built_test_plan, fuzz_ctx, files.clone(), units.clone())); Ok((files, units, env)) }, )?; @@ -257,16 +297,17 @@ pub fn run_move_unit_tests_with_factory, + /// When set, the runner appends failing fuzz arguments to + /// `/failures/...` and replays prior entries on next run. + pub corpus_dir: Option, +} use move_core_types::{effects::ChangeSet, language_storage::ModuleId}; use move_model::metadata::{CompilerVersion, LanguageVersion}; use move_package::compilation::compiled_package::build_and_report_v2_driver; @@ -30,6 +50,19 @@ use test_reporter::UnitTestFactory; /// The default value bounding the amount of gas consumed in a test. const DEFAULT_EXECUTION_BOUND: u64 = 1_000_000; +/// Default number of threads used to run tests. Single source of truth shared +/// by the `--threads` CLI default and `UnitTestingConfig::default`, which must +/// agree (the latter is what every programmatic embedder gets). +const DEFAULT_NUM_THREADS: usize = 8; + +/// Upper bound on regression cases replayed from the corpus per function. +/// Compile-time fuzz expansion is capped by `MAX_FUZZ_CASES` in the plan +/// builder, but regression cases are appended to the plan *after* planning, so +/// without a ceiling here a corpus that has grown across many CI runs would run +/// an unbounded number of in-process VM executions. When the corpus exceeds +/// this, the most recent entries are replayed and the overflow is reported. +const MAX_REGRESSION_REPLAYS_PER_FN: usize = 256; + #[derive(Debug, Parser, Clone)] #[clap(author, version, about)] pub struct UnitTestingConfig { @@ -44,7 +77,7 @@ pub struct UnitTestingConfig { /// Number of threads to use for running tests. #[clap( name = "num_threads", - default_value_t = 8, + default_value_t = DEFAULT_NUM_THREADS, short = 't', long = "threads" )] @@ -102,6 +135,28 @@ pub struct UnitTestingConfig { /// Verbose mode #[clap(short = 'v', long = "verbose")] pub verbose: bool, + + /// Number of values to sample per implicit-fuzz `#[test]` parameter. + #[clap(long = "fuzz-runs", default_value_t = DEFAULT_FUZZ_RUNS)] + pub fuzz_runs: usize, + + /// Base seed for the fuzz value source. When omitted, a fresh random seed is + /// drawn per run (logged at the top of each fuzz batch) so every run searches + /// differently; pass an explicit value to pin it and reproduce a prior run. + #[clap(long = "fuzz-seed")] + pub fuzz_seed: Option, + + /// Percentage weight (0..=100) of dictionary draws against random+edge + /// draws when fuzzing primitive parameters. Mirrors Foundry's + /// `dictionary_weight`. + #[clap(long = "fuzz-dictionary-weight", default_value_t = DEFAULT_FUZZ_DICTIONARY_WEIGHT)] + pub fuzz_dictionary_weight: u8, + + /// Directory used as the fuzz corpus. When set, regression cases from + /// `/failures/` are replayed alongside fresh fuzz draws, and any + /// fuzz-generated test that fails is appended to it. Disabled by default. + #[clap(long = "fuzz-corpus-dir")] + pub fuzz_corpus_dir: Option, } fn format_module_id(module_id: &ModuleId) -> String { @@ -116,7 +171,7 @@ impl Default for UnitTestingConfig { fn default() -> Self { Self { filter: None, - num_threads: 8, + num_threads: DEFAULT_NUM_THREADS, report_statistics: false, report_storage_on_error: false, report_stacktrace_on_abort: false, @@ -127,6 +182,10 @@ impl Default for UnitTestingConfig { verbose: false, list: false, named_address_values: vec![], + fuzz_runs: DEFAULT_FUZZ_RUNS, + fuzz_seed: None, + fuzz_dictionary_weight: DEFAULT_FUZZ_DICTIONARY_WEIGHT, + fuzz_corpus_dir: None, } } } @@ -145,10 +204,14 @@ impl UnitTestingConfig { &self, source_files: Vec, deps: Vec, + // Whether to replay the regression corpus into this plan. The deps-only + // pass in `build_test_plan` discards everything but files/module_info, + // so replaying there is wasted disk I/O — callers pass `false` for it. + replay_corpus: bool, ) -> Option { let addresses = verify_and_create_named_address_mapping(self.named_address_values.clone()).ok()?; - let (test_plan, files, units) = { + let (build_opt, files, units, fuzz_source) = { let options = move_compiler_v2::Options { compile_test_code: true, testing: true, @@ -163,10 +226,95 @@ impl UnitTestingConfig { ..Default::default() }; let (files, units, env) = build_and_report_v2_driver(options).unwrap(); - let test_plan = plan_builder_v2::construct_test_plan(&env, None); - (test_plan, files, units) + let fuzz_config = FuzzConfig { + runs: self.fuzz_runs, + // Omitted `--fuzz-seed` => fresh random seed per run. + seed: self.fuzz_seed.unwrap_or_else(random_seed), + dictionary_weight: self.fuzz_dictionary_weight, + ..FuzzConfig::default() + }; + let fuzz_source: Arc = + Arc::new(DefaultFuzzSource::new(&env, fuzz_config)); + let build_opt = plan_builder_v2::construct_test_plan_with_fuzz_source( + &env, + None, + fuzz_source.as_ref(), + ); + (build_opt, files, units, fuzz_source) }; - test_plan.map(|tests| TestPlan::new(tests, files, units, vec![])) + build_opt.map(|build| { + let mut plans = build.plans; + // Topic 2: replay regression corpus by appending saved failing + // argument vectors as extra TestCases. The runner re-runs them + // before the fresh fuzz draws. + if let Some(corpus_dir) = self.fuzz_corpus_dir.as_ref().filter(|_| replay_corpus) { + for module_plan in plans.iter_mut() { + let module_id = module_plan.module_id.clone(); + // Collect one representative `expected_failure` per real + // function symbol. Deduping by `function_name` (the real + // Move symbol, not the decorated display name) means each + // function's corpus file is read exactly once, regardless + // of how many expanded cases share it. + let mut stems: BTreeMap> = BTreeMap::new(); + for case in module_plan.tests.values() { + stems + .entry(case.function_name.clone()) + .or_insert_with(|| case.expected_failure.clone()); + } + for (stem, expected_failure) in stems { + let regressions = + fuzz_corpus::load_failures(corpus_dir, &module_id, &stem) + .unwrap_or_default(); + // Bound replays per function. Corpus entries are + // appended in discovery order, so the newest failures + // are at the tail — keep those and skip the oldest + // overflow, reporting what was dropped rather than + // silently truncating. + let skip = regressions + .len() + .saturating_sub(MAX_REGRESSION_REPLAYS_PER_FN); + if skip > 0 { + // Plan assembly runs before any `TestOutput`/diagnostic + // sink exists (the compiler `GlobalEnv` is already + // dropped, and the corpus is unknown to the compiler), + // so stderr is the only channel available here. Keep it + // a single, clearly-prefixed line. + eprintln!( + "warning: fuzz: `{}` has {} saved regression failures; replaying \ + the most recent {} and skipping {} (trim the corpus or raise the \ + replay cap)", + stem, + regressions.len(), + MAX_REGRESSION_REPLAYS_PER_FN, + skip, + ); + } + for (i, args) in regressions.into_iter().enumerate().skip(skip) { + let replay_name = format!("{}#regression[{}]", stem, i); + module_plan.tests.insert( + replay_name.clone(), + TestCase { + test_name: replay_name, + // Real symbol so the runner can load the + // function; the `#regression[..]` name is + // display-only. + function_name: stem.clone(), + arguments: args, + expected_failure: expected_failure.clone(), + }, + ); + } + } + } + } + let mut plan = TestPlan::new(plans, files, units, vec![]); + plan.runner_metadata = Some(Arc::new(FuzzRunnerCtx { + metadata: build.fuzz_metadata, + source: fuzz_source, + corpus_dir: self.fuzz_corpus_dir.clone(), + })); + plan + }) } /// Build a test plan from a unit test config @@ -175,9 +323,9 @@ impl UnitTestingConfig { let TestPlan { files, module_info, .. - } = self.compile_to_test_plan(deps.clone(), vec![])?; + } = self.compile_to_test_plan(deps.clone(), vec![], false)?; - let mut test_plan = self.compile_to_test_plan(self.source_files.clone(), deps)?; + let mut test_plan = self.compile_to_test_plan(self.source_files.clone(), deps, true)?; test_plan.module_info.extend(module_info); test_plan.files.extend(files); Some(test_plan) diff --git a/third_party/move/tools/move-unit-test/src/test_runner.rs b/third_party/move/tools/move-unit-test/src/test_runner.rs index 61ed138d48f..c81dab9c5b2 100644 --- a/third_party/move/tools/move-unit-test/src/test_runner.rs +++ b/third_party/move/tools/move-unit-test/src/test_runner.rs @@ -9,13 +9,15 @@ use crate::{ UnitTestFactory, }, }; +use crate::FuzzRunnerCtx; use anyhow::Result; use colored::*; use legacy_move_compiler::unit_test::{ ExpectedFailure, ModuleTestPlan, NamedOrBytecodeModule, TestCase, TestPlan, }; +use move_compiler_v2::fuzz::ArgOrigin; use move_binary_format::{ - errors::{Location, VMResult}, + errors::{Location, VMError, VMResult}, file_format::CompiledModule, }; use move_bytecode_utils::Modules; @@ -47,6 +49,9 @@ pub struct SharedTestingConfig { #[allow(dead_code)] // used by some features source_files: Vec, record_writeset: bool, + /// Set when a fuzz source was attached to the [`TestPlan`]. The runner + /// uses this to shrink failing fuzz cases into a minimal counterexample. + fuzz_ctx: Option>, } pub struct TestRunner { @@ -139,6 +144,11 @@ impl TestRunner { starting_storage_state.apply(genesis_state)?; } + let fuzz_ctx = tests + .runner_metadata + .as_ref() + .and_then(|m| m.clone().downcast::().ok()); + Ok(Self { testing_config: SharedTestingConfig { save_storage_state_on_failure, @@ -146,6 +156,7 @@ impl TestRunner { starting_storage_state, source_files, record_writeset, + fuzz_ctx, }, num_threads, tests, @@ -224,6 +235,20 @@ impl TestOutput<'_, '_, W> { .unwrap() } + /// One-line banner printed at the top of a fuzz batch (all expanded cases of + /// one fuzzed function) carrying the seed needed to reproduce that batch. + fn fuzz_header(&self, fn_name: &str, seed: u64) { + writeln!( + self.writer.lock().unwrap(), + "[ {} ] {}::{} (seed={})", + "FUZZ".bold().bright_cyan(), + format_module_id(&self.test_plan.module_id), + fn_name, + seed + ) + .unwrap() + } + fn timeout(&self, fn_name: &str) { writeln!( self.writer.lock().unwrap(), @@ -234,9 +259,249 @@ impl TestOutput<'_, '_, W> { ) .unwrap(); } + +} + +/// Project a `VMError` onto the `MoveError` identity used to compare failures +/// (status, sub-status, location, message). `MoveError`'s `PartialEq` ignores +/// the message, so two failures are "the same bug" when those first three match. +fn move_error_of(e: &VMError) -> MoveError { + MoveError( + e.major_status(), + e.sub_status(), + e.location().clone(), + e.message().cloned(), + ) +} + +/// Human-readable argument vector used in shrink output. Renders each value +/// through the compiler's `format_move_value` so the shrink counterexample and +/// the expanded-case name (built in the plan builder) format identically. +/// Per-case pass/fail classification, computed once per case so the caller can +/// either print it immediately (non-fuzz) or fold it into a [`FuzzBatch`]. +enum CaseStatus { + Pass, + Fail, + Timeout, +} + +/// One fuzzed function's accumulated outcome. A fuzz batch is reported as a +/// single line — every drawn value gathered into a per-parameter array — +/// instead of one line per expanded case. If any draw failed, the batch reports +/// `FAIL` and lists the failing draws; otherwise it reports `PASS` and lists all +/// draws. +struct FuzzBatch { + /// Real function symbol (undecorated), used as the reported name. + function_name: String, + /// `(argument index, parameter name)` for each fuzzed parameter, in argument + /// order. Fixed arguments (e.g. a pinned signer) are excluded. + cols: Vec<(usize, String)>, + /// Formatted fuzzed-argument values, one row per case; each row has one entry + /// per `cols` entry, in `cols` order. + passed: Vec>, + failed: Vec>, +} + +impl FuzzBatch { + fn new(function_name: String, cols: Vec<(usize, String)>) -> Self { + Self { + function_name, + cols, + passed: Vec::new(), + failed: Vec::new(), + } + } + + fn record(&mut self, passed: bool, test_info: &TestCase) { + let row: Vec = self + .cols + .iter() + .map(|(i, _)| { + move_compiler_v2::plan_builder::format_move_value(&test_info.arguments[*i]) + }) + .collect(); + if passed { + self.passed.push(row); + } else { + self.failed.push(row); + } + } + + /// Transpose `rows` over `cols` into a `p0=[v0,v1,..],p1=[..]` suffix. + fn suffix(&self, rows: &[Vec]) -> String { + self.cols + .iter() + .enumerate() + .map(|(c, (_, name))| { + let vals: Vec<&str> = rows.iter().map(|r| r[c].as_str()).collect(); + format!("{}=[{}]", name, vals.join(",")) + }) + .collect::>() + .join(",") + } + + /// Emit the single aggregated status line: `FAIL` listing the failing draws + /// when any case failed, else `PASS` listing every draw. + fn flush(self, output: &TestOutput) { + if self.failed.is_empty() { + let decorated = format!("{}[{}]", self.function_name, self.suffix(&self.passed)); + output.pass(&decorated); + } else { + let decorated = format!("{}[{}]", self.function_name, self.suffix(&self.failed)); + output.fail(&decorated); + } + } } impl SharedTestingConfig { + /// Topic 2: write the failing argument vector to the regression corpus + /// when a corpus directory is configured. Prefers the shrunk-minimal + /// vector when available — that's the cleanest reproducer to persist. + /// The base fuzz seed to advertise for `function_name`, or `None` when the + /// case is not fuzz-origin (fixed matrix/concrete args) or the source has no + /// reproducible seed. `function_name` is the expanded case name, matching how + /// the fuzz metadata is keyed. + fn fuzz_seed_for(&self, test_plan: &ModuleTestPlan, function_name: &str) -> Option { + let ctx = self.fuzz_ctx.as_ref()?; + let origins = ctx.metadata.get(&test_plan.module_id, function_name)?; + if origins.iter().all(|o| matches!(o, ArgOrigin::Fixed)) { + return None; + } + ctx.source.base_seed() + } + + /// The fuzzed argument columns for `function_name`: `(arg_index, param_name)` + /// for each `Fuzz`-origin parameter, in argument order. Fixed arguments (e.g. + /// a pinned signer) are excluded so the aggregated batch line lists only the + /// values that actually varied. `function_name` is the expanded case name, + /// matching how the fuzz metadata is keyed. + fn fuzz_cols(&self, test_plan: &ModuleTestPlan, function_name: &str) -> Vec<(usize, String)> { + let Some(ctx) = self.fuzz_ctx.as_ref() else { + return Vec::new(); + }; + let Some(origins) = ctx.metadata.get(&test_plan.module_id, function_name) else { + return Vec::new(); + }; + origins + .iter() + .enumerate() + .filter_map(|(i, o)| match o { + ArgOrigin::Fuzz { param_name, .. } => Some((i, param_name.clone())), + ArgOrigin::Fixed => None, + }) + .collect() + } + + fn persist_to_corpus( + &self, + test_plan: &ModuleTestPlan, + function_name: &str, + test_info: &TestCase, + shrunk: Option<&[move_core_types::value::MoveValue]>, + ) { + let Some(ctx) = self.fuzz_ctx.as_ref() else { return }; + let Some(dir) = ctx.corpus_dir.as_ref() else { return }; + // Only persist if this case was fuzz-origin. Regressions don't need + // saving again — they're already on disk. + let origins = ctx.metadata.get(&test_plan.module_id, function_name); + if origins.is_none() || origins.unwrap().iter().all(|o| matches!(o, ArgOrigin::Fixed)) { + return; + } + // Key the corpus file by the real function symbol so it round-trips + // with the replay loader (which also keys by `function_name`). Parsing + // the decorated display name would break now that it carries a `#idx`. + let stem = test_info.function_name.as_str(); + let args = shrunk.unwrap_or(test_info.arguments.as_slice()); + // Best-effort: ignore filesystem errors so a stuck corpus path doesn't + // mask the underlying test failure. + let _ = move_compiler_v2::fuzz_corpus::append_failure( + dir, + &test_plan.module_id, + stem, + args, + ); + } + + /// If the failing case was a fuzz-generated case, walk the shrinker until + /// no further shrink reproduces the failure. Returns the minimal failing + /// argument vector, or `None` when shrinking is not applicable (no fuzz + /// context, no fuzz arguments, or already minimal). + /// + /// `original` is the failure being minimized. A shrink candidate is only + /// accepted when it reproduces the *same* failure (same status code, sub + /// status, and abort location) — accepting *any* error would let the + /// shrinker wander onto an unrelated abort (or out-of-gas) and report a + /// "minimal counterexample" that doesn't actually trigger the original bug. + /// + /// Bound: 100 total shrink steps per case. Each step tries one shrink per + /// fuzzed argument and accepts the first one that still reproduces. + fn shrink_if_fuzz( + &self, + test_plan: &ModuleTestPlan, + function_name: &str, + test_info: &TestCase, + factory: &Mutex, + original: &MoveError, + ) -> Option> { + let ctx = self.fuzz_ctx.as_ref()?; + let origins = ctx.metadata.get(&test_plan.module_id, function_name)?; + if origins.iter().all(|o| matches!(o, ArgOrigin::Fixed)) { + return None; + } + let mut current = test_info.arguments.clone(); + let mut improved_at_least_once = false; + for _ in 0..100 { + let mut improved = false; + for (i, origin) in origins.iter().enumerate() { + let (param_name, ty, domain, exclude) = match origin { + ArgOrigin::Fuzz { + param_name, + ty, + domain, + exclude, + } => (param_name, ty, domain, exclude), + ArgOrigin::Fixed => continue, + }; + let candidate_value = ctx.source.shrink( + ty, + param_name, + ¤t[i], + domain, + exclude, + ); + let Some(smaller) = candidate_value else { + continue; + }; + let mut candidate = current.clone(); + candidate[i] = smaller; + let probe = TestCase { + test_name: test_info.test_name.clone(), + function_name: test_info.function_name.clone(), + arguments: candidate.clone(), + expected_failure: test_info.expected_failure.clone(), + }; + let (_, _, exec_result, _) = + self.execute_via_move_vm(test_plan, function_name, &probe, factory); + // Accept only if the candidate reproduces the *same* failure + // (status, sub-status, location — see `move_error_of`). + if matches!(&exec_result, Err(e) if &move_error_of(e) == original) { + current = candidate; + improved = true; + improved_at_least_once = true; + break; + } + } + if !improved { + break; + } + } + if improved_at_least_once { + Some(current) + } else { + None + } + } + #[allow(clippy::field_reassign_with_default)] fn execute_via_move_vm( &self, @@ -264,7 +529,11 @@ impl SharedTestingConfig { let result = module_storage .load_function( &test_plan.module_id, - IdentStr::new(function_name).unwrap(), + // Load by the real Move function symbol, NOT the (possibly + // decorated/unique) display name in `function_name` — the + // latter can contain `#`/`[`/`]`/`=` from fuzz/matrix expansion + // and is not a valid identifier. + IdentStr::new(&test_info.function_name).unwrap(), // No type args for now. &[], ) @@ -327,7 +596,32 @@ impl SharedTestingConfig { ) -> TestStatistics { let mut stats = TestStatistics::new(); + // Fuzz cases of one function are reported as a single aggregated line + // (every drawn value gathered into per-parameter arrays) rather than one + // line per case. `batch` holds the in-progress fuzz batch: cases of one + // function are contiguous in this `BTreeMap` (keyed by the `fn#idx[..]` + // display name, which shares the function prefix), so we flush on the + // function transition and once more after the loop. Non-fuzz cases keep + // printing one line each. + let mut batch: Option = None; for (function_name, test_info) in &test_plan.tests { + let fuzz_seed = self.fuzz_seed_for(test_plan, function_name); + if batch.as_ref().map(|b| b.function_name.as_str()) + != Some(test_info.function_name.as_str()) + { + if let Some(prev) = batch.take() { + prev.flush(output); + } + if let Some(seed) = fuzz_seed { + // Seed banner once, at the top of the batch. + output.fuzz_header(&test_info.function_name, seed); + batch = Some(FuzzBatch::new( + test_info.function_name.clone(), + self.fuzz_cols(test_plan, function_name), + )); + } + } + let (cs_result, ext_result, exec_result, test_run_info) = self.execute_via_move_vm(test_plan, function_name, test_info, factory); @@ -356,37 +650,34 @@ impl SharedTestingConfig { } }; - match exec_result { + // Classify the case, running the same stats / shrink / corpus side + // effects as before, but defer the printed status so a fuzz batch can + // be collapsed into one line at flush time. + let status = match exec_result { Err(err) => { - let actual_err = MoveError( - err.major_status(), - err.sub_status(), - err.location().clone(), - err.message().cloned(), - ); + let actual_err = move_error_of(&err); assert!(err.major_status() != StatusCode::EXECUTED); match test_info.expected_failure.as_ref() { Some(ExpectedFailure::Expected) => { - output.pass(function_name); stats.test_success(test_run_info, test_plan); + CaseStatus::Pass }, Some(ExpectedFailure::ExpectedWithError(expected_err)) if expected_err == &actual_err => { - output.pass(function_name); stats.test_success(test_run_info, test_plan); + CaseStatus::Pass }, Some(ExpectedFailure::ExpectedWithCodeDEPRECATED(code)) if actual_err.0 == StatusCode::ABORTED && actual_err.1.is_some() && actual_err.1.unwrap() == *code => { - output.pass(function_name); stats.test_success(test_run_info, test_plan); + CaseStatus::Pass }, // incorrect cases Some(ExpectedFailure::ExpectedWithError(expected_err)) => { - output.fail(function_name); stats.test_failure( TestFailure::new( FailureReason::wrong_error(expected_err.clone(), actual_err), @@ -395,10 +686,10 @@ impl SharedTestingConfig { save_session_state(), ), test_plan, - ) + ); + CaseStatus::Fail }, Some(ExpectedFailure::ExpectedWithCodeDEPRECATED(expected_code)) => { - output.fail(function_name); stats.test_failure( TestFailure::new( FailureReason::wrong_abort_deprecated( @@ -410,11 +701,19 @@ impl SharedTestingConfig { save_session_state(), ), test_plan, - ) + ); + CaseStatus::Fail }, None if err.major_status() == StatusCode::OUT_OF_GAS => { - // Ran out of ticks, report a test timeout and log a test failure - output.timeout(function_name); + // A gas blow-up is a real, replayable fuzz finding, so + // persist the failing input so the regression doesn't + // silently vanish next run. We deliberately do NOT + // shrink it: shrinking searches for a *smaller* input + // that still hits OUT_OF_GAS, but smaller inputs almost + // always consume less gas, so each probe is a full + // gas-bounded re-execution that nearly always fails to + // reproduce. No-op for non-fuzz cases. + self.persist_to_corpus(test_plan, function_name, test_info, None); stats.test_failure( TestFailure::new( FailureReason::timeout(), @@ -423,10 +722,27 @@ impl SharedTestingConfig { save_session_state(), ), test_plan, - ) + ); + CaseStatus::Timeout }, None => { - output.fail(function_name); + // If this failure originated from a fuzz-sampled case, + // shrink it to a minimal counterexample (reproducing the + // *same* error) and persist the failing arguments so the + // next run replays them. No-op for non-fuzz cases. + let shrunk = self.shrink_if_fuzz( + test_plan, + function_name, + test_info, + factory, + &actual_err, + ); + self.persist_to_corpus( + test_plan, + function_name, + test_info, + shrunk.as_deref(), + ); stats.test_failure( TestFailure::new( FailureReason::unexpected_error(actual_err), @@ -435,14 +751,14 @@ impl SharedTestingConfig { save_session_state(), ), test_plan, - ) + ); + CaseStatus::Fail }, } }, Ok(_) => { // Expected the test to fail, but it executed if test_info.expected_failure.is_some() { - output.fail(function_name); stats.test_failure( TestFailure::new( FailureReason::no_error(), @@ -451,16 +767,32 @@ impl SharedTestingConfig { save_session_state(), ), test_plan, - ) + ); + CaseStatus::Fail } else { // Expected the test to execute fully and it did - output.pass(function_name); stats.test_success(test_run_info, test_plan); + CaseStatus::Pass } }, + }; + + match batch.as_mut() { + // Fuzz batch: accumulate; the aggregated line prints at flush. + Some(b) => b.record(matches!(status, CaseStatus::Pass), test_info), + // Non-fuzz: report the case immediately, as before. + None => match status { + CaseStatus::Pass => output.pass(function_name), + CaseStatus::Fail => output.fail(function_name), + CaseStatus::Timeout => output.timeout(function_name), + }, } } + if let Some(prev) = batch.take() { + prev.flush(output); + } + stats } diff --git a/third_party/move/tools/move-unit-test/tests/fuzz_runner.rs b/third_party/move/tools/move-unit-test/tests/fuzz_runner.rs new file mode 100644 index 00000000000..efa513ef229 --- /dev/null +++ b/third_party/move/tools/move-unit-test/tests/fuzz_runner.rs @@ -0,0 +1,175 @@ +// Copyright (c) Aptos Foundation +// SPDX-License-Identifier: Apache-2.0 + +//! End-to-end coverage for fuzz-expanded `#[test]` execution. +//! +//! The compiler-side golden tests only check diagnostics; they never run the +//! Move VM. These tests build a plan AND execute it, which is the path where an +//! expanded case name (e.g. `prop#3[_x=42]`) must NOT be used as the function +//! identifier — doing so previously panicked in `IdentStr::new(..).unwrap()`. + +use move_unit_test::{test_reporter::UnitTestFactoryWithCostTable, UnitTestingConfig}; +use std::io::Write; + +/// Build a `UnitTestingConfig` for a single in-memory Move source string. +fn config_for(source: &str) -> (tempfile::TempDir, UnitTestingConfig) { + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("fuzz_mod.move"); + let mut f = std::fs::File::create(&src).unwrap(); + f.write_all(source.as_bytes()).unwrap(); + let config = UnitTestingConfig { + num_threads: 1, + source_files: vec![src.to_str().unwrap().to_owned()], + dep_files: move_stdlib::move_stdlib_files(), + named_address_values: move_stdlib::move_stdlib_named_addresses() + .into_iter() + .collect(), + ..UnitTestingConfig::default() + }; + (dir, config) +} + +fn run(config: &UnitTestingConfig) -> (String, bool, usize) { + let plan = config.build_test_plan().expect("test plan should build"); + let total_cases: usize = plan.module_tests.values().map(|m| m.tests.len()).sum(); + let (buffer, ok) = config + .run_and_report_unit_tests( + plan, + None, + None, + Vec::new(), + UnitTestFactoryWithCostTable::new(None, None), + ) + .expect("running unit tests should not error"); + (String::from_utf8(buffer).unwrap(), ok, total_cases) +} + +use move_compiler_v2::fuzz::DEFAULT_FUZZ_RUNS; + +/// Implicit-fuzz parameters expand into `DEFAULT_FUZZ_RUNS` *uniquely named* +/// cases that all execute without panicking. Before the fix, the decorated +/// case name was fed to the VM loader and panicked; and a `bool` parameter +/// collapsed to 2 cases via map-key collision instead of staying at 16. +#[test] +fn implicit_fuzz_cases_run_without_panic() { + let (_dir, config) = config_for( + r#" +module 0x42::fuzz_mod { + #[test] + fun prop_u8(_x: u8) { } + + #[test] + fun prop_bool(_b: bool) { } +} +"#, + ); + let (output, ok, total) = run(&config); + // The plan still expands to one uniquely-named TestCase per draw (this count + // comes from the plan, not the printed lines). + assert_eq!( + total, + 2 * DEFAULT_FUZZ_RUNS, + "each fuzz fn must expand to {} unique cases; output:\n{}", + DEFAULT_FUZZ_RUNS, + output + ); + assert!(ok, "all fuzz cases should pass; output:\n{}", output); + // At report time, each fuzzed function collapses to a single aggregated PASS + // line (every draw gathered into per-parameter arrays), preceded by one FUZZ + // seed banner. Two fuzzed functions => 2 of each. + assert_eq!( + output.matches("[ PASS").count(), + 2, + "each fuzz fn should report one aggregated PASS line; output:\n{}", + output + ); + assert_eq!( + output.matches("[ FUZZ").count(), + 2, + "each fuzz fn should print one seed banner; output:\n{}", + output + ); +} + +/// A fuzz batch whose cases fail collapses to a single aggregated `[ FAIL ]` +/// line (the failing draws gathered into the array), not one line per case, and +/// still fails the run. Guards the FAIL side of the batch reporting. +#[test] +fn failing_fuzz_batch_reports_single_fail_line() { + let (_dir, config) = config_for( + r#" +module 0x42::fuzz_mod { + #[test] + fun always_aborts(_x: u8) { abort 7 } +} +"#, + ); + let (output, ok, total) = run(&config); + assert_eq!( + total, DEFAULT_FUZZ_RUNS, + "the batch still expands to the full run count; output:\n{}", + output + ); + assert!(!ok, "a failing fuzz batch must fail the run; output:\n{}", output); + assert_eq!( + output.matches("[ FAIL").count(), + 1, + "the whole batch should collapse to one aggregated FAIL line; output:\n{}", + output + ); + assert_eq!( + output.matches("[ PASS").count(), + 0, + "no case passed, so no PASS line; output:\n{}", + output + ); + assert_eq!( + output.matches("[ FUZZ").count(), + 1, + "one seed banner for the batch; output:\n{}", + output + ); +} + +/// Explicit numeric matrices expand deterministically and run. This exercises +/// the numeric `Concrete`/`Matrix` coercion path (previously only addresses +/// were accepted). +#[test] +fn numeric_matrix_cases_run() { + let (_dir, config) = config_for( + r#" +module 0x42::fuzz_mod { + #[test(_n = [10, 20, 30])] + fun matrix_u64(_n: u64) { } +} +"#, + ); + let (output, ok, total) = run(&config); + assert_eq!(total, 3, "matrix should produce 3 cases; output:\n{}", output); + assert!(ok, "matrix cases should pass; output:\n{}", output); + assert_eq!(output.matches("[ PASS").count(), 3, "output:\n{}", output); +} + +/// Multiple explicit matrices expand *pairwise* (2-way covering), not as a full +/// Cartesian product. Three `[_,_,_]` matrices would be 3×3×3 = 27 cases under +/// the old Cartesian expansion; pairwise covers every pair of values across any +/// two parameters in 10 cases. This guards the default-pairwise behavior. +#[test] +fn multi_matrix_expands_pairwise_not_cartesian() { + let (_dir, config) = config_for( + r#" +module 0x42::fuzz_mod { + #[test(_a = [1, 2, 3], _b = [1, 2, 3], _c = [1, 2, 3])] + fun matrix3(_a: u64, _b: u64, _c: u64) { } +} +"#, + ); + let (output, ok, total) = run(&config); + assert_eq!( + total, 10, + "3x3x3 matrices should expand pairwise to 10 cases (27 under full Cartesian); output:\n{}", + output + ); + assert!(ok, "pairwise matrix cases should pass; output:\n{}", output); + assert_eq!(output.matches("[ PASS").count(), 10, "output:\n{}", output); +}