From 36fabe3673573f581c17671b61bad93b785c1803 Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 14 Jun 2026 21:58:22 +0200 Subject: [PATCH 1/2] fix(serialization): bound ValDef.id to Int.MaxValue (getUIntExact) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JVM `ValDefSerializer.parse` reads `ValDef.id` with `getUIntExact` (`getUInt().toIntExact`), rejecting an id VLQ above `0x7fffffff` at deserialize (ArithmeticException, before any eval). sigma-rust read the id with the shared unbounded `ValId::sigma_parse` (`get_u32`) and accepted ids up to `0xffffffff`, binding + evaluating where the JVM rejects. Add `ValId::sigma_parse_exact` (rejects `> i32::MAX`) and use it for `ValDef.id` only. `ValUse`/`FuncValue` ids stay on the wrapping `getUInt().toInt` path (`ValId::sigma_parse`) — bounding them would over-reject. The `ValId` Arbitrary is constrained to in-range ids so `ValDef` still round-trips. Reject vector: ValDef.id_int_max_bound (SANTA v6 audit, REL-WIRE-ID-01). Co-Authored-By: Claude Opus 4.8 (1M context) --- ergotree-ir/src/mir/val_def.rs | 49 ++++++++++++++++++++++++++++++++-- 1 file changed, 47 insertions(+), 2 deletions(-) diff --git a/ergotree-ir/src/mir/val_def.rs b/ergotree-ir/src/mir/val_def.rs index 38e56da96..b9768dbd0 100644 --- a/ergotree-ir/src/mir/val_def.rs +++ b/ergotree-ir/src/mir/val_def.rs @@ -21,7 +21,13 @@ use proptest_derive::Arbitrary; /// Variable id #[derive(PartialEq, Eq, Hash, Debug, Clone, Copy, From, Display, Ord, PartialOrd)] #[cfg_attr(feature = "arbitrary", derive(Arbitrary))] -pub struct ValId(pub u32); +pub struct ValId( + // Valid variable ids fit in a signed `Int` (`ValDef.id` is read with + // `getUIntExact`); keep the generator in range so `ValDef` round-trips + // through the bounded parse. `ValUse` accepts the wider wire range but its + // ids reference a `ValDef`, so in-range generation stays representative. + #[cfg_attr(feature = "arbitrary", proptest(strategy = "0u32..=(i32::MAX as u32)"))] pub u32, +); impl ValId { pub(crate) fn sigma_serialize(&self, w: &mut W) -> core2::io::Result<()> { @@ -32,6 +38,24 @@ impl ValId { let id = r.get_u32()?; Ok(ValId(id)) } + + /// Parse a `ValId` with the `getUIntExact` bound the JVM `ValDefSerializer` + /// applies to `ValDef.id`: the unsigned VLQ must fit in a signed `Int` + /// (`<= 0x7fffffff`), else deserialization fails (JVM `toIntExact` throws + /// `ArithmeticException`, before any eval). `ValUse`/`FuncValue` ids use the + /// wrapping `getUInt().toInt` and deliberately stay on the plain + /// `sigma_parse` — bounding them would over-reject. + pub(crate) fn sigma_parse_exact( + r: &mut R, + ) -> Result { + let id = r.get_u32()?; + if id > i32::MAX as u32 { + return Err(SigmaParsingError::Misc(alloc::format!( + "ValDef id {id} exceeds Int.MaxValue (getUIntExact)" + ))); + } + Ok(ValId(id)) + } } /** IR node for let-bound expressions `let x = rhs` which is ValDef. @@ -65,7 +89,7 @@ impl SigmaSerializable for ValDef { } fn sigma_parse(r: &mut R) -> Result { - let id = ValId::sigma_parse(r)?; + let id = ValId::sigma_parse_exact(r)?; let rhs = Expr::sigma_parse(r)?; r.val_def_type_store().insert(id, rhs.tpe()); Ok(ValDef { @@ -95,4 +119,25 @@ mod tests { prop_assert_eq![sigma_serialize_roundtrip(&e), e]; } } + + #[test] + #[allow(clippy::unwrap_used)] + fn valdef_id_getuintexact_bound() { + // JVM `ValDefSerializer` reads `ValDef.id` with `getUIntExact`, so an id + // VLQ above Int.MaxValue is rejected at deserialize. The inclusive max + // 0x7fffffff (`ffffffff07`) binds; 0x80000000 (`8080808008`) errors. + // ValUse keeps the wrapping `getUInt().toInt` read and still accepts it. + use crate::serialization::sigma_byte_reader::from_bytes; + let accept = [0xffu8, 0xff, 0xff, 0xff, 0x07]; + let reject = [0x80u8, 0x80, 0x80, 0x80, 0x08]; + assert_eq!( + ValId::sigma_parse_exact(&mut from_bytes(&accept[..])).unwrap(), + ValId(0x7fff_ffff) + ); + assert!(ValId::sigma_parse_exact(&mut from_bytes(&reject[..])).is_err()); + assert_eq!( + ValId::sigma_parse(&mut from_bytes(&reject[..])).unwrap(), + ValId(0x8000_0000) + ); + } } From 063d85d9c9e5d3a3543996b91e1eec9f544d7dbd Mon Sep 17 00:00:00 2001 From: Muad'Dib Date: Sun, 14 Jun 2026 22:03:43 +0200 Subject: [PATCH 2/2] fix(serialization): gate PropertyCall on the method's min ErgoTree version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit JVM `SMethod.fromIds` rejects a method whose required ErgoTree version exceeds the tree's, at deserialize. `MethodCall::sigma_parse` mirrors this (`r.tree_version() < method.min_version → Err`) but `PropertyCall::sigma_parse` did not. A v6 property in a pre-v3 tree — e.g. `Global.none[UnsignedBigInt]` (min_version V3) — was therefore accepted. Parsing is structural over the whole tree, so this holds even when the property sits in a dead `if` branch (deserialize does not lazily skip it). Add the same gate to `PropertyCall::sigma_parse`. v3 trees are unaffected (V3 < V3 is false); V0 properties (Context.dataInputs, …) are unaffected. Reject vector: Global.none_pre_v3_dead_branch (SANTA v6 audit, V6-PROPERTY-TYPEARG-GATE-01). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/serialization/property_call.rs | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/ergotree-ir/src/serialization/property_call.rs b/ergotree-ir/src/serialization/property_call.rs index 9f1f3429d..c9521ae09 100644 --- a/ergotree-ir/src/serialization/property_call.rs +++ b/ergotree-ir/src/serialization/property_call.rs @@ -26,6 +26,18 @@ impl SigmaSerializable for PropertyCall { let obj = Expr::sigma_parse(r)?; let method = SMethod::from_ids(type_id, method_id)?.specialize_for(obj.tpe(), Vec::new())?; + // Reject a method whose required ErgoTree version exceeds the tree's, at + // DESERIALIZE — the JVM gates this in `SMethod.fromIds`, so it fires + // structurally over the whole tree (a dead `if` branch is still parsed, + // not lazily skipped). `MethodCall` already does this; `PropertyCall` + // (e.g. `Global.none[T]`) was missing it, so a v6 property in a pre-v3 + // tree was accepted. + if r.tree_version() < method.method_raw.min_version { + return Err(SigmaParsingError::UnknownMethodId( + method_id, + type_id.value(), + )); + } Ok(PropertyCall::new(obj, method)?) } } @@ -34,10 +46,15 @@ impl SigmaSerializable for PropertyCall { #[cfg(feature = "arbitrary")] #[allow(clippy::unwrap_used)] mod tests { + use crate::ergo_tree::ErgoTreeVersion; use crate::mir::expr::Expr; use crate::mir::property_call::PropertyCall; + use crate::serialization::roundtrip_new_feature; use crate::serialization::sigma_serialize_roundtrip; use crate::types::scontext; + use crate::types::sglobal; + use crate::types::stype::SType; + use alloc::vec; #[test] fn ser_roundtrip_property() { @@ -45,4 +62,22 @@ mod tests { let expr = Expr::PropertyCall(mc.into()); assert_eq![sigma_serialize_roundtrip(&expr), expr]; } + + #[test] + fn versioned_roundtrip() { + // `Global.none` is a v6 (V3) property; a pre-V3 ErgoTree must reject it + // at parse, mirroring MethodCall's version gate. (SANTA v6 audit: the + // dead-branch `Global.none[UnsignedBigInt]` over-acceptance, + // V6-PROPERTY-TYPEARG-GATE-01.) + let pc: Expr = PropertyCall::new( + Expr::Global, + sglobal::NONE_METHOD + .clone() + .specialize_for(SType::SGlobal, vec![]) + .unwrap(), + ) + .unwrap() + .into(); + roundtrip_new_feature(&pc, ErgoTreeVersion::V3); + } }