commit 16cd783aa1851763fc549ecb39c1967ac033de14
parent 2865b97d40e5f2b0eaa642ebadacc1ea370320ba
Author: Joris Hartog <jorishartog@hotmail.com>
Date: Wed, 19 Aug 2026 14:41:19 +0200
Harden compact snapshot decoder
Diffstat:
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}");
+ }
+ }
+}