iuna

iuna

iuna - experimental mainnet-candidate protocol
git clone https://getiuna.org/git/iuna.git
Log | Files | Refs | README | LICENSE

commit 16cd783aa1851763fc549ecb39c1967ac033de14
parent 2865b97d40e5f2b0eaa642ebadacc1ea370320ba
Author: Joris Hartog <jorishartog@hotmail.com>
Date:   Wed, 19 Aug 2026 14:41:19 +0200

Harden compact snapshot decoder

Diffstat:
MROADMAP.md | 2+-
Msrc/adapters/chain_store/compact.rs | 269++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------
2 files changed, 235 insertions(+), 36 deletions(-)

diff --git a/ROADMAP.md b/ROADMAP.md @@ -42,7 +42,7 @@ These items are not protocol rules. They are the attack and reliability checks t - [x] P2P envelope item limits reject batches only above their configured boundaries. - [x] Stratum endpoint has explicit DoS limits: maximum line size, maximum jobs per session, idle timeout, and connection/session caps. - [x] Fork and snapshot adversarial tests cover same-height leader-quality choice, taller valid forks inside finality, invalid late snapshot blocks, and pending transaction carry-forward after reorg. -- [ ] Compact snapshot decoder has malformed-input tests for huge lengths, oversized varints, trailing bytes, truncated payloads, invalid tags, and random byte inputs without panics or excessive allocation. +- [x] Compact snapshot decoder has malformed-input tests for huge lengths, oversized varints, trailing bytes, truncated payloads, invalid tags, and random byte inputs without panics or excessive allocation. - [ ] Supply invariant tests cover mixed burns, fees, PoW mine actions, reorgs, no replay, and no double spend. ### Should Before Mainnet diff --git a/src/adapters/chain_store/compact.rs b/src/adapters/chain_store/compact.rs @@ -7,6 +7,10 @@ use crate::domain::{ const COMPACT_SNAPSHOT_MAGIC: &[u8] = b"IUNA-SNAPSHOT"; const COMPACT_SNAPSHOT_VERSION: u8 = 4; +const MAX_COMPACT_GENESIS_ALLOCATIONS: usize = 100_000; +const MAX_COMPACT_SNAPSHOT_BLOCKS: usize = 10_000; +const MAX_COMPACT_VEC_ITEMS: usize = 10_000; +const MAX_COMPACT_BYTE_FIELD: usize = 8 * 1024 * 1024; pub(super) fn encode_compact_snapshot(snapshot: &ChainSnapshot) -> Result<Vec<u8>> { let mut writer = CompactWriter::default(); @@ -48,7 +52,8 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { if version != COMPACT_SNAPSHOT_VERSION { bail!("unsupported compact chain snapshot version {version}"); } - let genesis_count = reader.usize()?; + let genesis_count = + reader.bounded_usize("genesis allocation count", MAX_COMPACT_GENESIS_ALLOCATIONS)?; let mut genesis_allocations = std::collections::BTreeMap::new(); for _ in 0..genesis_count { let address = reader.hex()?; @@ -57,7 +62,7 @@ pub(super) fn decode_compact_snapshot(bytes: &[u8]) -> Result<ChainSnapshot> { } let vdf_rounds = reader.varint()?; let launch_profile = decode_launch_profile(&mut reader)?; - let block_count = reader.usize()?; + let block_count = reader.bounded_usize("block count", MAX_COMPACT_SNAPSHOT_BLOCKS)?; let mut blocks = Vec::with_capacity(block_count); let mut prev_hash = "0".repeat(64); for height in 0..block_count { @@ -148,7 +153,12 @@ fn decode_block_body( None }; let burn_bundle_section = decode_burn_bundle_section(reader)?; - let transactions = decode_vec(reader, decode_transaction)?; + let transactions = decode_vec( + reader, + "block transaction count", + MAX_COMPACT_VEC_ITEMS, + decode_transaction, + )?; let hash = reader.hex()?; Ok(Block { height, @@ -186,19 +196,29 @@ fn encode_burn_bundle_section( } fn decode_burn_bundle_section(reader: &mut CompactReader<'_>) -> Result<BurnBundleSection> { - let signatures = decode_vec(reader, |reader| { - Ok(BurnBundleSignature { - slot: u8::try_from(reader.varint()?).context("burn bundle slot does not fit u8")?, - member: reader.hex()?, - signature: reader.hex()?, - }) - })?; - let burns = decode_vec(reader, |reader| { - Ok(MaskedBurn { - burn: decode_transaction(reader)?, - bundle_mask: reader.u8()?, - }) - })?; + let signatures = decode_vec( + reader, + "burn bundle signature count", + MAX_COMPACT_VEC_ITEMS, + |reader| { + Ok(BurnBundleSignature { + slot: u8::try_from(reader.varint()?).context("burn bundle slot does not fit u8")?, + member: reader.hex()?, + signature: reader.hex()?, + }) + }, + )?; + let burns = decode_vec( + reader, + "burn bundle burn count", + MAX_COMPACT_VEC_ITEMS, + |reader| { + Ok(MaskedBurn { + burn: decode_transaction(reader)?, + bundle_mask: reader.u8()?, + }) + }, + )?; Ok(BurnBundleSection { signatures, burns }) } @@ -308,16 +328,21 @@ fn encode_inputs(writer: &mut CompactWriter, inputs: &[TxInput]) -> Result<()> { } fn decode_inputs(reader: &mut CompactReader<'_>) -> Result<Vec<TxInput>> { - decode_vec(reader, |reader| { - Ok(TxInput { - outpoint: OutPoint { - txid: reader.hexish()?, - index: reader.u32()?, - }, - owner: reader.hex()?, - signature: reader.hexish()?, - }) - }) + decode_vec( + reader, + "transaction input count", + MAX_COMPACT_VEC_ITEMS, + |reader| { + Ok(TxInput { + outpoint: OutPoint { + txid: reader.hexish()?, + index: reader.u32()?, + }, + owner: reader.hex()?, + signature: reader.hexish()?, + }) + }, + ) } fn encode_outputs(writer: &mut CompactWriter, outputs: &[TxOutput]) -> Result<()> { @@ -330,19 +355,26 @@ fn encode_outputs(writer: &mut CompactWriter, outputs: &[TxOutput]) -> Result<() } fn decode_outputs(reader: &mut CompactReader<'_>) -> Result<Vec<TxOutput>> { - decode_vec(reader, |reader| { - Ok(TxOutput { - address: reader.hex()?, - amount: reader.varint()?, - }) - }) + decode_vec( + reader, + "transaction output count", + MAX_COMPACT_VEC_ITEMS, + |reader| { + Ok(TxOutput { + address: reader.hex()?, + amount: reader.varint()?, + }) + }, + ) } fn decode_vec<T>( reader: &mut CompactReader<'_>, + label: &str, + max_len: usize, mut decode: impl FnMut(&mut CompactReader<'_>) -> Result<T>, ) -> Result<Vec<T>> { - let len = reader.usize()?; + let len = reader.bounded_usize(label, max_len)?; let mut values = Vec::with_capacity(len); for _ in 0..len { values.push(decode(reader)?); @@ -478,6 +510,14 @@ impl<'a> CompactReader<'a> { .context("compact integer does not fit usize") } + fn bounded_usize(&mut self, label: &str, max: usize) -> Result<usize> { + let len = self.usize()?; + if len > max { + bail!("compact {label} {len} exceeds limit {max}"); + } + Ok(len) + } + fn u32(&mut self) -> Result<u32> { self.varint()? .try_into() @@ -485,13 +525,13 @@ impl<'a> CompactReader<'a> { } fn string(&mut self) -> Result<String> { - let len = self.usize()?; + let len = self.bounded_usize("string length", MAX_COMPACT_BYTE_FIELD)?; let bytes = self.take(len)?; String::from_utf8(bytes.to_vec()).context("compact string is not valid UTF-8") } fn hex(&mut self) -> Result<String> { - let len = self.usize()?; + let len = self.bounded_usize("byte field length", MAX_COMPACT_BYTE_FIELD)?; Ok(hex_encode(self.take(len)?)) } @@ -533,3 +573,162 @@ fn hex_encode(bytes: impl AsRef<[u8]>) -> String { .map(|byte| format!("{byte:02x}")) .collect() } + +#[cfg(test)] +mod tests { + use std::panic; + + use crate::domain::LaunchProfile; + + use super::{ + COMPACT_SNAPSHOT_MAGIC, COMPACT_SNAPSHOT_VERSION, CompactWriter, MAX_COMPACT_BYTE_FIELD, + MAX_COMPACT_GENESIS_ALLOCATIONS, MAX_COMPACT_SNAPSHOT_BLOCKS, MAX_COMPACT_VEC_ITEMS, + decode_compact_snapshot, encode_launch_profile, + }; + + fn snapshot_prefix(block_count: u64) -> Vec<u8> { + let mut writer = CompactWriter::default(); + writer.bytes(COMPACT_SNAPSHOT_MAGIC); + writer.u8(COMPACT_SNAPSHOT_VERSION); + writer.varint(0); + writer.varint(1); + encode_launch_profile(&mut writer, &LaunchProfile::default()); + writer.varint(block_count); + writer.into_inner() + } + + fn empty_snapshot_bytes() -> Vec<u8> { + snapshot_prefix(0) + } + + fn block_body_prefix(writer: &mut CompactWriter) { + writer.varint(1); + writer.hex(&"0".repeat(64)).unwrap(); + } + + fn block_body_through_leader_proof_flag(writer: &mut CompactWriter) { + block_body_prefix(writer); + writer.u8(0); + writer.varint(0); + writer.varint(0); + writer.varint(0); + writer.string("0:0"); + } + + fn block_body_through_transaction_count(writer: &mut CompactWriter, tx_count: u64) { + block_body_through_leader_proof_flag(writer); + writer.bool(false); + writer.varint(0); + writer.varint(0); + writer.varint(tx_count); + } + + fn assert_decode_error_contains(bytes: &[u8], expected: &str) { + let error = decode_compact_snapshot(bytes).unwrap_err().to_string(); + assert!( + error.contains(expected), + "expected error containing {expected:?}, got {error:?}" + ); + } + + #[test] + fn compact_snapshot_decoder_rejects_huge_lengths_before_allocation() { + let mut huge_genesis = Vec::new(); + let mut writer = CompactWriter::default(); + writer.bytes(COMPACT_SNAPSHOT_MAGIC); + writer.u8(COMPACT_SNAPSHOT_VERSION); + writer.varint(MAX_COMPACT_GENESIS_ALLOCATIONS as u64 + 1); + huge_genesis.extend(writer.into_inner()); + assert_decode_error_contains(&huge_genesis, "genesis allocation count"); + + let huge_blocks = snapshot_prefix(MAX_COMPACT_SNAPSHOT_BLOCKS as u64 + 1); + assert_decode_error_contains(&huge_blocks, "block count"); + + let mut huge_transactions = snapshot_prefix(1); + let mut writer = CompactWriter::default(); + block_body_through_transaction_count(&mut writer, MAX_COMPACT_VEC_ITEMS as u64 + 1); + huge_transactions.extend(writer.into_inner()); + assert_decode_error_contains(&huge_transactions, "block transaction count"); + + let mut huge_string = snapshot_prefix(1); + let mut writer = CompactWriter::default(); + block_body_prefix(&mut writer); + writer.u8(0); + writer.varint(0); + writer.varint(0); + writer.varint(0); + writer.varint(MAX_COMPACT_BYTE_FIELD as u64 + 1); + huge_string.extend(writer.into_inner()); + assert_decode_error_contains(&huge_string, "string length"); + } + + #[test] + fn compact_snapshot_decoder_rejects_oversized_varints() { + let mut bytes = Vec::new(); + bytes.extend_from_slice(COMPACT_SNAPSHOT_MAGIC); + bytes.push(COMPACT_SNAPSHOT_VERSION); + bytes.extend_from_slice(&[0xff; 10]); + + assert_decode_error_contains(&bytes, "compact varint is too large"); + } + + #[test] + fn compact_snapshot_decoder_rejects_trailing_bytes() { + let mut bytes = empty_snapshot_bytes(); + bytes.push(0); + + assert_decode_error_contains(&bytes, "trailing bytes"); + } + + #[test] + fn compact_snapshot_decoder_rejects_truncated_payloads() { + let bytes = empty_snapshot_bytes(); + for len in 0..bytes.len() { + assert!( + decode_compact_snapshot(&bytes[..len]).is_err(), + "truncated compact snapshot of length {len} decoded successfully" + ); + } + } + + #[test] + fn compact_snapshot_decoder_rejects_invalid_tags() { + let mut invalid_finalizer_mode = snapshot_prefix(1); + let mut writer = CompactWriter::default(); + block_body_prefix(&mut writer); + writer.u8(9); + invalid_finalizer_mode.extend(writer.into_inner()); + assert_decode_error_contains(&invalid_finalizer_mode, "invalid finalizer mode tag 9"); + + let mut invalid_bool = snapshot_prefix(1); + let mut writer = CompactWriter::default(); + block_body_through_leader_proof_flag(&mut writer); + writer.u8(2); + invalid_bool.extend(writer.into_inner()); + assert_decode_error_contains(&invalid_bool, "invalid compact bool tag 2"); + + let mut invalid_transaction = snapshot_prefix(1); + let mut writer = CompactWriter::default(); + block_body_through_transaction_count(&mut writer, 1); + writer.u8(99); + invalid_transaction.extend(writer.into_inner()); + assert_decode_error_contains(&invalid_transaction, "invalid transaction tag 99"); + } + + #[test] + fn compact_snapshot_decoder_random_bytes_do_not_panic() { + let mut state = 0x5eed_5eed_1234_5678_u64; + for len in 0..256 { + let mut bytes = Vec::with_capacity(len); + for _ in 0..len { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1); + bytes.push((state >> 32) as u8); + } + + let result = panic::catch_unwind(|| { + let _ = decode_compact_snapshot(&bytes); + }); + assert!(result.is_ok(), "decoder panicked for random length {len}"); + } + } +}