From ef25e07880dd472f3578ccb716008031ee8da26f Mon Sep 17 00:00:00 2001 From: Quantum Explorer Date: Thu, 21 May 2026 06:42:36 +0700 Subject: [PATCH] Validate hop-one batch reference targets --- .../src/version/grovedb_versions.rs | 1 + grovedb-version/src/version/v1.rs | 1 + grovedb-version/src/version/v2.rs | 1 + grovedb-version/src/version/v3.rs | 1 + grovedb/src/batch/mod.rs | 34 ++---- grovedb/src/tests/batch_unit_tests.rs | 110 +++++++++++++++++- 6 files changed, 124 insertions(+), 24 deletions(-) diff --git a/grovedb-version/src/version/grovedb_versions.rs b/grovedb-version/src/version/grovedb_versions.rs index 1a75c7378..f24858d28 100644 --- a/grovedb-version/src/version/grovedb_versions.rs +++ b/grovedb-version/src/version/grovedb_versions.rs @@ -50,6 +50,7 @@ pub struct GroveDBApplyBatchVersions { pub apply_batch_with_element_flags_update: FeatureVersion, pub apply_partial_batch_with_element_flags_update: FeatureVersion, pub estimated_case_operations_for_batch: FeatureVersion, + pub validate_reference_hop_limits: FeatureVersion, } #[derive(Clone, Debug, Default)] diff --git a/grovedb-version/src/version/v1.rs b/grovedb-version/src/version/v1.rs index 43a58e46f..bdf340ef5 100644 --- a/grovedb-version/src/version/v1.rs +++ b/grovedb-version/src/version/v1.rs @@ -30,6 +30,7 @@ pub const GROVE_V1: GroveVersion = GroveVersion { apply_batch_with_element_flags_update: 0, apply_partial_batch_with_element_flags_update: 0, estimated_case_operations_for_batch: 0, + validate_reference_hop_limits: 0, }, element: GroveDBElementMethodVersions { delete: 0, diff --git a/grovedb-version/src/version/v2.rs b/grovedb-version/src/version/v2.rs index 46351bf7a..c57c6b5c9 100644 --- a/grovedb-version/src/version/v2.rs +++ b/grovedb-version/src/version/v2.rs @@ -30,6 +30,7 @@ pub const GROVE_V2: GroveVersion = GroveVersion { apply_batch_with_element_flags_update: 0, apply_partial_batch_with_element_flags_update: 0, estimated_case_operations_for_batch: 0, + validate_reference_hop_limits: 0, }, element: GroveDBElementMethodVersions { delete: 0, diff --git a/grovedb-version/src/version/v3.rs b/grovedb-version/src/version/v3.rs index 039891bd1..2a4a054bf 100644 --- a/grovedb-version/src/version/v3.rs +++ b/grovedb-version/src/version/v3.rs @@ -30,6 +30,7 @@ pub const GROVE_V3: GroveVersion = GroveVersion { apply_batch_with_element_flags_update: 0, apply_partial_batch_with_element_flags_update: 0, estimated_case_operations_for_batch: 0, + validate_reference_hop_limits: 1, }, element: GroveDBElementMethodVersions { delete: 0, diff --git a/grovedb/src/batch/mod.rs b/grovedb/src/batch/mod.rs index 6742a8873..0aaeae2b4 100644 --- a/grovedb/src/batch/mod.rs +++ b/grovedb/src/batch/mod.rs @@ -1389,25 +1389,13 @@ where .split_last() .expect("path validated non-empty above"); - // Fast path: `recursions_allowed == 1` means the user-declared - // `max_reference_hop` budget allows exactly one more hop. Under - // the well-formed-user contract, that one hop must land on an - // `Item` (or `SumItem` / `ItemWithSumItem`) terminal — pointing - // at another `Reference` would violate the user's own budget. - // - // For an `Item` terminal the merk-stored `value_hash` IS the - // terminal's simple hash `H(serialize(item))`, which is exactly - // what `insert_reference` bakes into the dependent ref via - // `Op::PutCombinedReference`. So we can skip a full element - // decode and read the value_hash directly. - // - // Ill-formed input (`max_hop = 1` pointing at a `Reference`) - // is out of scope: this fast path would return the target's - // merk-combined hash as if it were a simple hash, producing a - // hash mismatch that `verify_grovedb` later reports. The - // contract is the user's to uphold; we don't pay the price of - // an extra dispatch on every well-formed hop=1 ref. - if recursions_allowed == 1 { + if grove_version + .grovedb_versions + .apply_batch + .validate_reference_hop_limits + == 0 + && recursions_allowed == 1 + { let merk = match self.merks.entry(reference_path.to_vec()) { HashMapEntry::Occupied(o) => o.into_mut(), HashMapEntry::Vacant(v) => v.insert(cost_return_on_error!( @@ -1448,10 +1436,10 @@ where return Ok(referenced_element_value_hash).wrap_with_cost(cost); } - // Slow path: `recursions_allowed > 1`. Dispatch on whether the - // target is being modified in this same batch. Neither branch - // needs the merk handle here — the helpers open (or reuse the - // cached) merk themselves via `self.merks.entry(..)`. + // Dispatch on whether the target is being modified in this same + // batch. The on-disk branch always decodes the target so a final + // hop that lands on another reference is rejected with + // ReferenceLimit instead of borrowing the intermediate value hash. if let Some(referenced_path) = intermediate_reference_info { // Target is in batch (refresh). Hop through the op's new // path; budget decrements by one for this hop. diff --git a/grovedb/src/tests/batch_unit_tests.rs b/grovedb/src/tests/batch_unit_tests.rs index f7b0b0e09..9928ac121 100644 --- a/grovedb/src/tests/batch_unit_tests.rs +++ b/grovedb/src/tests/batch_unit_tests.rs @@ -17,7 +17,7 @@ mod tests { }; use crate::reference_path::ReferencePathType; use crate::tests::{common::EMPTY_PATH, make_empty_grovedb, make_test_grovedb, TEST_LEAF}; - use crate::Element; + use crate::{Element, Error}; // =================================================================== // Group 1: NonMerkTreeMeta::to_tree_type() and count() @@ -1146,6 +1146,114 @@ mod tests { assert_eq!(result, Element::new_item(b"hop1_val".to_vec())); } + #[test] + fn test_batch_ref_hop_one_to_existing_reference_errors() { + let grove_version = GroveVersion::latest(); + let db = make_test_grovedb(grove_version); + + db.insert( + [TEST_LEAF].as_ref(), + b"target", + Element::new_item(b"hop1_val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert target"); + db.insert( + [TEST_LEAF].as_ref(), + b"existing_ref", + Element::new_reference_with_hops( + ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"target".to_vec(), + ]), + Some(1), + ), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert existing reference"); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref_to_ref".to_vec(), + Element::new_reference_with_hops( + ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"existing_ref".to_vec(), + ]), + Some(1), + ), + )]; + + assert!(matches!( + db.apply_batch(ops, None, None, grove_version).unwrap(), + Err(Error::ReferenceLimit) + )); + let issues = db + .verify_grovedb(None, true, true, grove_version) + .expect("verify grovedb after rejected batch"); + assert!(issues.is_empty(), "verification issues: {:?}", issues); + } + + #[test] + fn test_batch_ref_hop_one_to_existing_reference_legacy_version_allows() { + let mut legacy_version = GroveVersion::latest().clone(); + legacy_version + .grovedb_versions + .apply_batch + .validate_reference_hop_limits = 0; + let grove_version = &legacy_version; + let db = make_test_grovedb(grove_version); + + db.insert( + [TEST_LEAF].as_ref(), + b"target", + Element::new_item(b"hop1_val".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert target"); + db.insert( + [TEST_LEAF].as_ref(), + b"existing_ref", + Element::new_reference_with_hops( + ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"target".to_vec(), + ]), + Some(1), + ), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert existing reference"); + + let ops = vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![TEST_LEAF.to_vec()], + b"ref_to_ref".to_vec(), + Element::new_reference_with_hops( + ReferencePathType::AbsolutePathReference(vec![ + TEST_LEAF.to_vec(), + b"existing_ref".to_vec(), + ]), + Some(1), + ), + )]; + + db.apply_batch(ops, None, None, grove_version) + .unwrap() + .expect("legacy hop validation should accept final-hop references"); + } + /// RefreshReference with trust_refresh_reference=false reads the element /// from disk before processing. /// RefreshReference on an element that is NOT a reference on disk.