Skip to content

Commit 39d3695

Browse files
authored
fix: move check in AcctDiff::decode and use filtered len in AcctDiff::encode (#160)
* fix bug in AcctDiff::decode * fix bug in AcctDiff::encode * version bump to 0.34.4
1 parent c220551 commit 39d3695

2 files changed

Lines changed: 329 additions & 3 deletions

File tree

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "trevm"
3-
version = "0.34.3"
3+
version = "0.34.4"
44
rust-version = "1.83.0"
55
edition = "2021"
66
authors = ["init4"]

src/journal/coder.rs

Lines changed: 328 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,10 @@ impl JournalEncode for AcctDiff<'_> {
340340

341341
fn encode(&self, buf: &mut dyn BufMut) {
342342
self.outcome.encode(buf);
343-
buf.put_u32(self.storage_diff.len() as u32);
343+
// Only changed slots are serialized, so the count prefix must exclude unchanged slots to
344+
// match the entries written below (and `serialized_size`).
345+
let changed = self.storage_diff.values().filter(|slot| slot.is_changed()).count();
346+
buf.put_u32(changed as u32);
344347
for (slot, value) in &self.storage_diff {
345348
if value.is_changed() {
346349
slot.encode(buf);
@@ -553,9 +556,9 @@ impl JournalDecode for StorageSlot {
553556

554557
impl JournalDecode for AcctDiff<'static> {
555558
fn decode(buf: &mut &[u8]) -> Result<Self> {
559+
check_len!(buf, "AcctDiff", ACCT_DIFF_MIN_BYTES);
556560
let outcome = JournalDecode::decode(buf)?;
557561

558-
check_len!(buf, "StorageDiffLen", ACCT_DIFF_MIN_BYTES);
559562
let storage_diff_len: u32 = JournalDecode::decode(buf)?;
560563

561564
let mut storage_diff = BTreeMap::new();
@@ -645,6 +648,34 @@ mod test {
645648
assert_eq!(&dec, expected, "{ty_name}");
646649
}
647650

651+
/// A non-trivial [`AccountInfo`] used to build test fixtures.
652+
fn sample_info() -> AccountInfo {
653+
AccountInfo {
654+
balance: U256::from(38238923),
655+
nonce: 38238923,
656+
code_hash: B256::repeat_byte(0xa),
657+
code: None,
658+
account_id: None,
659+
}
660+
}
661+
662+
/// Assert that the full encoding of `value` decodes, while every strict prefix of it is
663+
/// rejected. Truncating a valid buffer must always overrun, so this pins the length-check
664+
/// boundary of every decode path and guards against mis-ordered or off-by-one checks - the
665+
/// class of the empty-storage `AcctDiff` regression.
666+
#[track_caller]
667+
fn assert_truncation_rejected<T: JournalDecode>(value: &T) {
668+
let enc = JournalEncode::encoded(value);
669+
let ty_name = core::any::type_name::<T>();
670+
T::decode(&mut enc.as_ref()).expect("full buffer should decode");
671+
for len in 0..enc.len() {
672+
let mut prefix = &enc[..len];
673+
if T::decode(&mut prefix).is_ok() {
674+
panic!("{ty_name}: prefix of length {len} must be rejected but decoded");
675+
}
676+
}
677+
}
678+
648679
#[test]
649680
fn roundtrips() {
650681
roundtrip(&Cow::<'static, u8>::Owned(1u8));
@@ -726,4 +757,299 @@ mod test {
726757
};
727758
roundtrip(&bsi);
728759
}
760+
761+
#[test]
762+
fn empty_storage_diff_roundtrips() {
763+
let acc_info = AccountInfo {
764+
balance: U256::from(1),
765+
nonce: 1,
766+
code_hash: B256::repeat_byte(0xa),
767+
code: None,
768+
account_id: None,
769+
};
770+
771+
// Standalone AcctDiff with zero storage slots, for both single-info outcomes.
772+
let created = AcctDiff {
773+
outcome: InfoOutcome::Created(Cow::Owned(acc_info.clone())),
774+
storage_diff: BTreeMap::new(),
775+
};
776+
roundtrip(&created);
777+
let destroyed = AcctDiff {
778+
outcome: InfoOutcome::Destroyed(Cow::Owned(acc_info)),
779+
storage_diff: BTreeMap::new(),
780+
};
781+
roundtrip(&destroyed);
782+
783+
// A BundleStateIndex whose only account has empty storage and which has no new
784+
// contracts - the trailing bytes after the account's outcome are just the two u32
785+
// length prefixes, far fewer than ACCT_DIFF_MIN_BYTES.
786+
let bsi = BundleStateIndex {
787+
state: vec![(Address::repeat_byte(0xa), created)].into_iter().collect(),
788+
new_contracts: BTreeMap::new(),
789+
};
790+
roundtrip(&bsi);
791+
}
792+
793+
#[test]
794+
fn additional_roundtrips() {
795+
// The `Option` codec is part of the public trait surface but is not exercised by the
796+
// composite types above, so cover both variants directly.
797+
roundtrip(&Option::<u8>::None);
798+
roundtrip(&Some(42u8));
799+
800+
// Only the legacy bytecode variant is covered by `roundtrips`; cover the EIP-7702
801+
// delegation variant and the empty-bytecode edge case here.
802+
roundtrip(&Bytecode::new_eip7702(Address::repeat_byte(0xb)));
803+
roundtrip(&Bytecode::new_raw(Bytes::new()));
804+
805+
// Only created/diff outcomes are covered by `roundtrips`; cover a standalone destroyed
806+
// outcome here.
807+
roundtrip(&InfoOutcome::Destroyed(Cow::Owned(sample_info())));
808+
809+
// An empty index has neither state nor contracts.
810+
roundtrip(&BundleStateIndex::default());
811+
812+
// The header codec delegates to alloy RLP.
813+
roundtrip(&Header::default());
814+
}
815+
816+
#[test]
817+
fn truncated_buffers_are_rejected() {
818+
assert_truncation_rejected(&7u8);
819+
assert_truncation_rejected(&7u32);
820+
assert_truncation_rejected(&7u64);
821+
assert_truncation_rejected(&B256::repeat_byte(0xa));
822+
assert_truncation_rejected(&Address::repeat_byte(0xa));
823+
assert_truncation_rejected(&U256::from(38238923));
824+
assert_truncation_rejected(&sample_info());
825+
826+
assert_truncation_rejected(&Option::<u8>::None);
827+
assert_truncation_rejected(&Some(7u8));
828+
829+
// Every `InfoOutcome` variant.
830+
assert_truncation_rejected(&InfoOutcome::Created(Cow::Owned(sample_info())));
831+
assert_truncation_rejected(&InfoOutcome::Destroyed(Cow::Owned(sample_info())));
832+
assert_truncation_rejected(&InfoOutcome::Diff {
833+
old: Cow::Owned(sample_info()),
834+
new: Cow::Owned(sample_info()),
835+
});
836+
837+
// Every `StorageSlot` variant: created, changed, deleted.
838+
assert_truncation_rejected(&StorageSlot::new_changed(U256::ZERO, U256::from(9)));
839+
assert_truncation_rejected(&StorageSlot::new_changed(U256::from(9), U256::from(3)));
840+
assert_truncation_rejected(&StorageSlot::new_changed(U256::from(9), U256::ZERO));
841+
842+
// `AcctDiff` at its minimum size (empty storage) and populated, for both the single-info
843+
// and two-info outcomes.
844+
assert_truncation_rejected(&AcctDiff {
845+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
846+
storage_diff: BTreeMap::new(),
847+
});
848+
assert_truncation_rejected(&AcctDiff {
849+
outcome: InfoOutcome::Diff {
850+
old: Cow::Owned(sample_info()),
851+
new: Cow::Owned(sample_info()),
852+
},
853+
storage_diff: vec![(
854+
U256::from(3),
855+
Cow::Owned(StorageSlot::new_changed(U256::ZERO, U256::from(9))),
856+
)]
857+
.into_iter()
858+
.collect(),
859+
});
860+
861+
// `Bytecode`: legacy, empty, and EIP-7702.
862+
assert_truncation_rejected(&Bytecode::new_raw(Bytes::from(vec![1, 2, 3])));
863+
assert_truncation_rejected(&Bytecode::new_raw(Bytes::new()));
864+
assert_truncation_rejected(&Bytecode::new_eip7702(Address::repeat_byte(0xb)));
865+
866+
// `BundleStateIndex`: empty and populated.
867+
assert_truncation_rejected(&BundleStateIndex::default());
868+
assert_truncation_rejected(&BundleStateIndex {
869+
state: vec![(
870+
Address::repeat_byte(0xa),
871+
AcctDiff {
872+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
873+
storage_diff: BTreeMap::new(),
874+
},
875+
)]
876+
.into_iter()
877+
.collect(),
878+
new_contracts: vec![(
879+
B256::repeat_byte(0xa),
880+
Cow::Owned(Bytecode::new_raw(Bytes::from(vec![1, 2, 3]))),
881+
)]
882+
.into_iter()
883+
.collect(),
884+
});
885+
}
886+
887+
#[test]
888+
fn acct_diff_minimum_size_boundary() {
889+
let created = AcctDiff {
890+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
891+
storage_diff: BTreeMap::new(),
892+
};
893+
let enc = created.encoded();
894+
895+
// An empty-storage created/destroyed diff is exactly the minimum encodable size.
896+
assert_eq!(enc.len(), ACCT_DIFF_MIN_BYTES);
897+
898+
// Exactly the minimum decodes...
899+
let decoded =
900+
<AcctDiff<'static> as JournalDecode>::decode(&mut enc.as_ref()).expect("min decodes");
901+
assert_eq!(decoded, created);
902+
903+
// ...and one byte short overruns against the whole `AcctDiff` via the up-front length
904+
// check, not against a phantom second outcome. This is the exact regression the fix
905+
// guards: previously the check ran after the outcome was consumed and demanded another
906+
// `ACCT_DIFF_MIN_BYTES`, rejecting this valid minimum diff.
907+
let mut short = &enc[..enc.len() - 1];
908+
let error = <AcctDiff<'static> as JournalDecode>::decode(&mut short).unwrap_err();
909+
assert_eq!(
910+
error,
911+
JournalDecodeError::Overrun {
912+
ty_name: "AcctDiff",
913+
expected: ACCT_DIFF_MIN_BYTES,
914+
remaining: ACCT_DIFF_MIN_BYTES - 1,
915+
}
916+
);
917+
}
918+
919+
#[test]
920+
fn invalid_tags_are_rejected() {
921+
// `InfoOutcome` accepts only tags 0..=2; the tag is checked before any payload.
922+
let error = <InfoOutcome<'static> as JournalDecode>::decode(&mut &[3u8][..]).unwrap_err();
923+
assert_eq!(
924+
error,
925+
JournalDecodeError::InvalidTag { ty_name: "InfoOutcome", tag: 3, max_expected: 2 }
926+
);
927+
928+
// `StorageSlot` validates its tag only after consuming the 32-byte present value, so the
929+
// buffer must carry that value for the tag check to be reached.
930+
let mut slot_buf = vec![4u8];
931+
slot_buf.extend_from_slice(&[0u8; 32]);
932+
let error = StorageSlot::decode(&mut slot_buf.as_slice()).unwrap_err();
933+
assert_eq!(
934+
error,
935+
JournalDecodeError::InvalidTag { ty_name: "StorageSlot", tag: 4, max_expected: 3 }
936+
);
937+
938+
// `Bytecode` uses tags 0 and 2; 1 and 3 are invalid. The body length prefix must be
939+
// present and consumable before the tag is validated.
940+
let error = Bytecode::decode(&mut &[1u8, 0, 0, 0, 0][..]).unwrap_err();
941+
assert_eq!(
942+
error,
943+
JournalDecodeError::InvalidTag { ty_name: "Bytecode", tag: 1, max_expected: 2 }
944+
);
945+
let error = Bytecode::decode(&mut &[3u8, 0, 0, 0, 0][..]).unwrap_err();
946+
assert_eq!(
947+
error,
948+
JournalDecodeError::InvalidTag { ty_name: "Bytecode", tag: 3, max_expected: 2 }
949+
);
950+
951+
// `Option` accepts only tags 0 and 1.
952+
let error = <Option<u8> as JournalDecode>::decode(&mut &[2u8][..]).unwrap_err();
953+
assert_eq!(
954+
error,
955+
JournalDecodeError::InvalidTag { ty_name: "Option<T>", tag: 2, max_expected: 1 }
956+
);
957+
}
958+
959+
#[test]
960+
fn unchanged_storage_slot_is_rejected() {
961+
// The journal must never contain unchanged storage; decoding one is a hard error. As
962+
// above, the tag is only reached after the present value is consumed.
963+
let mut buf = vec![TAG_STORAGE_UNCHANGED];
964+
buf.extend_from_slice(&[0u8; 32]);
965+
let error = StorageSlot::decode(&mut buf.as_slice()).unwrap_err();
966+
assert_eq!(error, JournalDecodeError::UnchangedStorage);
967+
}
968+
969+
// Regression: `AcctDiff::encode` must write the count of *changed* slots, not the full map
970+
// length. Unchanged slots are never serialized (and would panic in `StorageSlot::encode`),
971+
// so a count that includes them makes the decoder read phantom slots: an overrun for a
972+
// standalone diff, or silent corruption of the trailing data inside a `BundleStateIndex`.
973+
#[test]
974+
fn acct_diff_encode_drops_unchanged_storage() {
975+
let changed = StorageSlot::new_changed(U256::from(1), U256::from(2));
976+
let unchanged = StorageSlot::new(U256::from(7));
977+
978+
let with_unchanged = AcctDiff {
979+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
980+
storage_diff: vec![
981+
(U256::from(3), Cow::Owned(changed)),
982+
(U256::from(4), Cow::Owned(unchanged)),
983+
]
984+
.into_iter()
985+
.collect(),
986+
};
987+
988+
// Only the changed slot is on the wire, so the decoded diff omits the unchanged slot.
989+
let expected = AcctDiff {
990+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
991+
storage_diff: vec![(U256::from(3), Cow::Owned(changed))].into_iter().collect(),
992+
};
993+
994+
let enc = with_unchanged.encoded();
995+
// `serialized_size` already excludes unchanged slots; the encoding must agree.
996+
assert_eq!(enc.len(), with_unchanged.serialized_size());
997+
let decoded = <AcctDiff<'static> as JournalDecode>::decode(&mut enc.as_ref())
998+
.expect("diff with an unchanged slot should decode");
999+
assert_eq!(decoded, expected);
1000+
}
1001+
1002+
#[test]
1003+
fn bundle_state_index_encode_drops_unchanged_storage() {
1004+
let changed = StorageSlot::new_changed(U256::from(1), U256::from(2));
1005+
let unchanged = StorageSlot::new(U256::from(7));
1006+
1007+
let bsi = BundleStateIndex {
1008+
state: vec![(
1009+
Address::repeat_byte(0xa),
1010+
AcctDiff {
1011+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
1012+
storage_diff: vec![
1013+
(U256::from(3), Cow::Owned(changed)),
1014+
(U256::from(4), Cow::Owned(unchanged)),
1015+
]
1016+
.into_iter()
1017+
.collect(),
1018+
},
1019+
)]
1020+
.into_iter()
1021+
.collect(),
1022+
// Trailing contracts that an over-large slot count would consume as phantom storage.
1023+
new_contracts: vec![(
1024+
B256::repeat_byte(0xb),
1025+
Cow::Owned(Bytecode::new_raw(Bytes::from(vec![1, 2, 3]))),
1026+
)]
1027+
.into_iter()
1028+
.collect(),
1029+
};
1030+
1031+
let expected = BundleStateIndex {
1032+
state: vec![(
1033+
Address::repeat_byte(0xa),
1034+
AcctDiff {
1035+
outcome: InfoOutcome::Created(Cow::Owned(sample_info())),
1036+
storage_diff: vec![(U256::from(3), Cow::Owned(changed))].into_iter().collect(),
1037+
},
1038+
)]
1039+
.into_iter()
1040+
.collect(),
1041+
new_contracts: vec![(
1042+
B256::repeat_byte(0xb),
1043+
Cow::Owned(Bytecode::new_raw(Bytes::from(vec![1, 2, 3]))),
1044+
)]
1045+
.into_iter()
1046+
.collect(),
1047+
};
1048+
1049+
let enc = bsi.encoded();
1050+
assert_eq!(enc.len(), bsi.serialized_size());
1051+
let decoded = <BundleStateIndex<'static> as JournalDecode>::decode(&mut enc.as_ref())
1052+
.expect("index with an unchanged slot should decode");
1053+
assert_eq!(decoded, expected);
1054+
}
7291055
}

0 commit comments

Comments
 (0)