diff --git a/docs/book/src/count-indexed-tree.md b/docs/book/src/count-indexed-tree.md index 847a1fe90..92e8dfac5 100644 --- a/docs/book/src/count-indexed-tree.md +++ b/docs/book/src/count-indexed-tree.md @@ -779,16 +779,21 @@ ordering. Top-k descending iteration encounters them last. ## Limitations and non-goals -- **State sync does not support indexed trees.** A database containing - any indexed tree cannot be snapshot-synced: the restorer binds a - restored subtree to its parent with the two-input `combine_hash`, - which can never reproduce an indexed element's three-input binding, - and subtree discovery never enumerates the derived per-axis secondary - namespaces. Both the source (`fetch_chunk`) and target (subtree - discovery) sides reject with `Error::NotSupported` before any chunk is - produced or committed, so the failure is loud and early rather than a - half-restored database — but note the rejection is **database-wide**: - one indexed tree anywhere disables snapshot sync for the whole grove. +- **State sync transfers an indexed subtree as one group.** The target + requests the primary with a header request built from its + hash-verified element (axis tags plus secondary root keys), the + source answers with an *indexed header* — the primary root hash and + each axis secondary's root hash, which the element itself never + stores — bundled with the primary's root chunk, and the per-axis + secondaries transfer as ordinary Merk chunks addressed by their + derived prefixes (they cannot be rebuilt locally: a secondary's root + commits to its write-history-dependent AVL shape). The header is only + a hint for per-chunk verification; once the primary and every + secondary are restored, the target unconditionally recomputes the + three-input binding (`combine_hash_three`, with the canonical + `axes_digest` for the multi-axis variant) from the *actual* restored + root hashes and requires it to match the element value hash bound + into the restored parent. - **Generic writes into an indexed primary are rejected.** `db.insert`, `db.delete` and `clear_subtree` targeting an indexed primary return `Error::NotSupported`, because none of them can mirror the change into diff --git a/grovedb/Cargo.toml b/grovedb/Cargo.toml index 0f3af7ff3..f1882d974 100644 --- a/grovedb/Cargo.toml +++ b/grovedb/Cargo.toml @@ -55,7 +55,15 @@ grovedb-epoch-based-storage-flags = { version = "5.0.1", path = "../grovedb-epoc criterion = { workspace = true } hex = { workspace = true } +# Process RSS sampling for the `#[ignore]`d state-sync restore +# memory-ceiling harness (`tests/replication_scale_tests.rs`). The memory +# that harness measures lives in RocksDB's C++ `WriteBatchWithIndex`, so it +# has to be read from the OS rather than from a Rust allocator hook. +libc = "0.2" pretty_assertions = "1.4.0" +# Adversarial-input properties for the untrusted state-sync decode +# surfaces (`tests/replication_fuzz_tests.rs`). +proptest = "1.10.0" rand = { workspace = true } rand_distr = "0.6" assert_matches = { workspace = true } diff --git a/grovedb/src/lib.rs b/grovedb/src/lib.rs index 329c6d8be..d2f293e66 100644 --- a/grovedb/src/lib.rs +++ b/grovedb/src/lib.rs @@ -3216,11 +3216,14 @@ impl GroveDb { /// - `BulkAppendTree`: `blake3("bulk_state" || mmr_root || dense_root)` /// - `MmrTree`: the MMR root hash /// - `DenseAppendOnlyFixedSizeTree`: the dense tree root hash + /// - `PrivateDocumentStore`: `blake3("pds_state" || config_hash || + /// bulk_state_root)` /// /// For empty trees this returns the same conventions the insert path /// binds into the parent: `EMPTY_COMMITMENT_TREE_STATE_ROOT` for an - /// empty commitment tree, `NULL_HASH` (the empty Merk root) for the - /// other three types. + /// empty commitment tree, the config-parametrized empty state root for + /// an empty private document store, and `NULL_HASH` (the empty Merk + /// root) for the other three types. /// /// Returns an error if `element` is not a non-Merk data tree, or if the /// payload cannot be read back as a consistent tree of the declared @@ -3326,6 +3329,41 @@ impl GroveDb { )) }) } + Element::PrivateDocumentStore(total_count, entry_size, chunk_power, _) => { + // The state root binds the committed config even when the + // store is empty, so the empty case is the + // config-parametrized empty root rather than NULL_HASH. + if *total_count == 0 { + return Ok( + grovedb_private_document_store::empty_private_document_store_state_root( + *entry_size, + *chunk_power, + ), + ); + } + let storage_ctx = self + .db + .get_transactional_storage_context(subtree_path, None, transaction) + .unwrap(); + let store = grovedb_private_document_store::PrivateDocumentStore::from_state( + *total_count, + *entry_size, + *chunk_power, + storage_ctx, + ) + .unwrap() + .map_err(|e| { + Error::CorruptedData(format!( + "cannot open private document store of {total_count} entries from \ + payload: {e}" + )) + })?; + store.compute_current_state_root_from_values().map_err(|e| { + Error::CorruptedData(format!( + "cannot compute private document store state root from payload: {e}" + )) + }) + } _ => Err(Error::InternalError(format!( "compute_non_merk_state_root called on a non append-only element: {}", element.type_str() diff --git a/grovedb/src/operations/indexed_tree.rs b/grovedb/src/operations/indexed_tree.rs index 3e48b6f52..b0a184e7a 100644 --- a/grovedb/src/operations/indexed_tree.rs +++ b/grovedb/src/operations/indexed_tree.rs @@ -115,6 +115,40 @@ pub(crate) fn axis_secondary_tree_type(axis: IndexAxis) -> TreeType { } } +/// Decode the configured axes of an indexed-tree element as +/// `(axis, secondary_root_key)` pairs in canonical element order: the +/// single implicit axis for PCIT / PSIT, the 1..=3 entry TLV for PCPSIT. +/// +/// Errors if `element` is not an indexed-tree variant or a PCPSIT axis tag +/// is invalid. The one intentional non-caller is +/// `cleanup_dedicated_indexed_child_storage`, which must stay tolerant of +/// an invalid tag (cleanup of a corrupt element should still clear the +/// valid axes rather than fail). +pub(crate) fn indexed_element_axes( + element: &Element, +) -> Result>)>, Error> { + match element.underlying() { + Element::ProvableCountIndexedTree(_, s, ..) => Ok(vec![(IndexAxis::Count, s.clone())]), + Element::ProvableSumIndexedTree(_, s, ..) => Ok(vec![(IndexAxis::Sum, s.clone())]), + Element::ProvableCountProvableSumIndexedTree(_, _, _, axes, _) => axes + .iter() + .map(|(tag, root_key)| { + IndexAxis::try_from_tag(*tag) + .map(|axis| (axis, root_key.clone())) + .map_err(|e| { + Error::CorruptedData(format!( + "invalid axis tag in indexed-tree element: {e}" + )) + }) + }) + .collect(), + other => Err(Error::CorruptedData(format!( + "expected an indexed-tree element, got {}", + other.type_str() + ))), + } +} + /// One primary entry's mirror-relevant state. /// /// The aggregates decide the row's sort key and carried sum; the value @@ -421,31 +455,12 @@ impl GroveDb { .map_err(Error::MerkError) ) }; - let axes: Vec<(IndexAxis, Option>)> = match element.underlying() { - Element::ProvableCountIndexedTree(_, s, ..) => vec![(IndexAxis::Count, s.clone())], - Element::ProvableSumIndexedTree(_, s, ..) => vec![(IndexAxis::Sum, s.clone())], - Element::ProvableCountProvableSumIndexedTree(_, _, _, axes, _) => { - let mut out = Vec::with_capacity(axes.len()); - for (tag, root_key) in axes { - let axis = cost_return_on_error_no_add!( - cost, - IndexAxis::try_from_tag(*tag).map_err(|e| Error::CorruptedData(format!( - "open_indexed_secondaries_for_batch: invalid axis tag: {e}" - ))) - ); - out.push((axis, root_key.clone())); - } - out - } - other => { - return Err(Error::CorruptedData(format!( - "open_indexed_secondaries_for_batch: parent element is not an indexed tree, \ - got {}", - other.type_str() - ))) - .wrap_with_cost(cost); - } - }; + let axes: Vec<(IndexAxis, Option>)> = cost_return_on_error_no_add!( + cost, + indexed_element_axes(&element).map_err(|e| Error::CorruptedData(format!( + "open_indexed_secondaries_for_batch: {e}" + ))) + ); let mut merks = Vec::with_capacity(axes.len()); for (axis, root_key) in axes { @@ -943,29 +958,12 @@ impl GroveDb { &mut cost, Element::get(&parent_merk, indexed_key, true, grove_version).map_err(Error::MerkError) ); - let axes: Vec<(IndexAxis, Option>)> = match indexed_element.underlying() { - Element::ProvableCountIndexedTree(_, s, ..) => vec![(IndexAxis::Count, s.clone())], - Element::ProvableSumIndexedTree(_, s, ..) => vec![(IndexAxis::Sum, s.clone())], - Element::ProvableCountProvableSumIndexedTree(_, _, _, axes_tlv, _) => { - let mut out = Vec::with_capacity(axes_tlv.len()); - for (tag, root_key) in axes_tlv { - let axis = cost_return_on_error_no_add!( - cost, - IndexAxis::try_from_tag(*tag).map_err(|e| Error::CorruptedData(format!( - "reconcile_indexed_tree_secondaries: invalid axis tag: {e}" - ))) - ); - out.push((axis, root_key.clone())); - } - out - } - _ => { - return Err(Error::CorruptedData( - "parent element at the indexed key is not an indexed tree".to_string(), - )) - .wrap_with_cost(cost); - } - }; + let axes: Vec<(IndexAxis, Option>)> = cost_return_on_error_no_add!( + cost, + indexed_element_axes(&indexed_element).map_err(|e| Error::CorruptedData(format!( + "reconcile_indexed_tree_secondaries: {e}" + ))) + ); // The tightest ceiling across the configured axes (avg prepends a // 16-byte sort key against count/sum's 8). let max_item_key_len = axes @@ -4589,3 +4587,61 @@ mod direct_axis_mirror_tests { ); } } + +#[cfg(test)] +mod indexed_element_axes_tests { + use super::*; + + /// Every indexed variant decodes to its configured axes in canonical + /// element order: the single implicit axis for PCIT / PSIT, the TLV + /// entries for PCPSIT. + #[test] + fn indexed_element_axes_decodes_every_indexed_variant() { + assert_eq!( + indexed_element_axes(&Element::empty_provable_count_indexed_tree()).unwrap(), + vec![(IndexAxis::Count, None)] + ); + assert_eq!( + indexed_element_axes(&Element::empty_provable_sum_indexed_tree()).unwrap(), + vec![(IndexAxis::Sum, None)] + ); + let pcpsit = Element::empty_provable_count_provable_sum_indexed_tree(vec![ + (IndexAxis::Count.tag(), Some(b"count_root".to_vec())), + (IndexAxis::Sum.tag(), None), + ]) + .expect("valid axes"); + assert_eq!( + indexed_element_axes(&pcpsit).unwrap(), + vec![ + (IndexAxis::Count, Some(b"count_root".to_vec())), + (IndexAxis::Sum, None), + ] + ); + } + + /// The decode fails closed on anything that is not an indexed tree and + /// on a stored PCPSIT axis tag no axis maps to (the constructors + /// validate tags, but a corrupt stored element can carry any byte). + #[test] + fn indexed_element_axes_rejects_non_indexed_elements_and_invalid_tags() { + for element in [ + Element::empty_tree(), + Element::new_item(vec![1]), + Element::empty_sum_tree(), + ] { + let err = indexed_element_axes(&element).expect_err("not an indexed tree"); + assert!( + matches!(&err, Error::CorruptedData(m) if m.contains("expected an indexed-tree element")), + "{err}" + ); + } + + let corrupt = + Element::ProvableCountProvableSumIndexedTree(None, 0, 0, vec![(0xFF, None)], None); + let err = indexed_element_axes(&corrupt).expect_err("invalid axis tag"); + assert!( + matches!(&err, Error::CorruptedData(m) if m.contains("invalid axis tag")), + "{err}" + ); + } +} diff --git a/grovedb/src/replication.rs b/grovedb/src/replication.rs index 37de575eb..d7caf478f 100644 --- a/grovedb/src/replication.rs +++ b/grovedb/src/replication.rs @@ -1,5 +1,7 @@ +pub(crate) mod indexed_sync; pub(crate) mod non_merk_sync; mod state_sync_session; +mod verify; use std::pin::Pin; @@ -7,7 +9,7 @@ use grovedb_merk::{tree::hash::CryptoHash, tree_type::TreeType, ChunkProducer}; use grovedb_path::SubtreePath; use grovedb_version::{check_grovedb_v0, version::GroveVersion}; -pub use self::state_sync_session::MultiStateSyncSession; +pub use self::state_sync_session::{MultiStateSyncSession, CONST_GROUP_PACKING_SIZE}; use crate::{ replication::utils::{pack_nested_bytes, unpack_nested_bytes}, util::TxRef, @@ -29,32 +31,251 @@ pub type ChunkIdentifier = ( Vec>, ); -/// Current version of the state sync protocol. +/// The state sync protocol version this build speaks. +/// +/// State sync speaks exactly one protocol version: both sides pass this +/// value, and every entry point (`fetch_chunk`, `start_snapshot_syncing`, +/// `apply_chunk`) rejects any other with a descriptive error. The constant +/// — and the `version` parameter threading through those entry points — +/// exists so a future incompatible wire change can bump it and old/new +/// peers fail fast with a clear error instead of failing midway with an +/// opaque hash mismatch. pub const CURRENT_STATE_SYNC_VERSION: u16 = 1; +/// Aux-storage key holding the "a state sync restore was applied to this +/// database but never finished" marker. +/// +/// Written by the first intermediate commit of a +/// [`RestoreCommitMode::Incremental`] session and deleted by that +/// session's final commit, both inside the same transaction as the data +/// they describe. See [`GroveDb::has_incomplete_restore`]. +pub(crate) const INCOMPLETE_RESTORE_AUX_KEY: &[u8] = b"grovedb_state_sync_restore_in_progress"; + +/// How much restored chunk payload an [`RestoreCommitMode::Incremental`] +/// session accumulates before it takes the next intermediate commit. +/// +/// A payload budget rather than a memory budget because it is the +/// quantity the session can count exactly: RocksDB's only handle on the +/// transaction's write-batch size copies the whole batch to report it. +/// +/// Peak memory is not a clean multiple of this number, because a commit +/// can only land on a subtree boundary — the effective granularity is +/// `max(budget, largest single subtree)`, and below that the budget stops +/// buying anything. Measured on the medium scale tier (1.31 GiB source +/// grove, `restore_memory_ceiling_tier_medium`), peak footprint increment +/// over the pre-sync baseline: +/// +/// ```text +/// atomic 7046 MiB (5.24x the source) 52.3 s +/// 128 MiB budget 2666 MiB (1.98x) 53.0 s +/// 64 MiB budget 2357 MiB (1.75x) 48.2 s +/// 16 MiB budget 1428 MiB (1.06x) 81.5 s +/// ``` +/// +/// 16 MiB is the default because it is the smallest budget measured to +/// hold a gigabyte-scale restore near the size of the source rather than +/// a multiple of it; the wall-clock it costs is paid once, on a node that +/// is not yet serving. +pub const DEFAULT_RESTORE_CHUNK_BUDGET_BYTES: u64 = 16 * 1024 * 1024; + +/// How a restore session persists the subtrees it has rebuilt. +/// +/// The trade this enum exposes is memory against crash atomicity, and +/// both sides of it are legitimate; see the variants. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RestoreCommitMode { + /// Every write of the sync stays in one RocksDB transaction until + /// [`GroveDb::commit_session`] has verified the restored root hash + /// against the offered `app_hash` (issue #775). Nothing reaches the + /// database early, so an aborted, failed or crashed restore leaves + /// the destination exactly as it was. + /// + /// The cost is that the entire restored state is resident: the + /// transaction's `WriteBatchWithIndex` holds every key and value, + /// and committing it copies the batch into a memtable, so peak + /// memory is a multiple of the *whole* source grove and grows with + /// it forever. + #[default] + Atomic, + /// Commit at proven-safe boundaries once `budget_bytes` of chunk + /// payload has accumulated, so peak memory tracks the budget and the + /// largest single subtree rather than the size of the whole state. + /// + /// The residual term is real and worth stating: a subtree's restorer + /// holds its storage context for the subtree's whole life, so no + /// commit can land inside one. Peak memory is therefore + /// `O(budget + largest subtree)`, not `O(budget)`. On Platform state + /// the largest subtree is one contract's document index — far smaller + /// than the grove, but it does grow with adoption, and driving the + /// bound below it would need commit points inside a Merk restore. + /// + /// The final commit still verifies the root hash and still refuses + /// to commit on a mismatch — what is given up is only the *rollback*: + /// a restore that fails or is interrupted after the first + /// intermediate commit leaves partially restored, hash-unverified + /// data behind. Such a database is marked (see + /// [`GroveDb::has_incomplete_restore`]) and must be discarded, not + /// used. Callers that already wipe-and-re-sync a failed restore — + /// drive-abci's restore sentinel does — lose nothing by choosing + /// this mode. + Incremental { + /// Chunk payload bytes to accumulate between intermediate + /// commits. See [`DEFAULT_RESTORE_CHUNK_BUDGET_BYTES`]. + budget_bytes: u64, + /// How many subtrees may be part-restored at the same time. + /// + /// This is the other half of the memory bound, and without it + /// the byte budget does nothing. A commit is only safe once + /// every in-flight restorer has released the transaction, so the + /// budget can only be *spent* at a moment when no subtree is + /// part-restored. Left uncapped, the restore puts every subtree + /// discovered under a parent in flight at once -- a Platform + /// grove's root fans out to a handful of very large subtrees -- + /// and that moment does not arrive until nearly the whole state + /// is already in the write batch. Measured on the medium tier: + /// uncapped, a 64 MiB budget over 1 GiB of payload took exactly + /// one intermediate commit and moved peak memory by 7%. + /// + /// Peak memory is therefore roughly + /// `budget_bytes + (this many partly-restored subtrees)`. One is + /// the tightest bound and the default; raising it trades memory + /// for more chunk requests in flight across subtrees. + max_subtrees_in_flight: usize, + }, +} + +impl RestoreCommitMode { + /// The standard bounded-memory configuration. + pub const fn incremental() -> Self { + RestoreCommitMode::Incremental { + budget_bytes: DEFAULT_RESTORE_CHUNK_BUDGET_BYTES, + max_subtrees_in_flight: 1, + } + } + + /// Whether this mode ever commits before the root hash is verified. + pub const fn is_incremental(&self) -> bool { + matches!(self, RestoreCommitMode::Incremental { .. }) + } +} + #[cfg(feature = "minimal")] impl GroveDb { - /// Starts a new state synchronization session with the given app hash and batch size. + /// Starts a new state synchronization session with the given app hash, + /// batch size and state sync protocol version, ready to apply the root + /// chunk requested by `app_hash`. + /// + /// Rejects any `version` other than [`CURRENT_STATE_SYNC_VERSION`] up + /// front, with the same error the other entry points use. A session + /// pinned to an unsupported version could never apply a chunk (every + /// `apply_chunk` would fail the version checks), so refusing here + /// turns a dead-on-arrival session into a descriptive error at the + /// point the caller can act on it. pub fn start_syncing_session( &self, app_hash: [u8; 32], subtrees_batch_size: usize, - ) -> Pin>> { - MultiStateSyncSession::new(self, app_hash, subtrees_batch_size) + version: u16, + grove_version: &GroveVersion, + ) -> Result>>, Error> { + self.start_syncing_session_with_mode( + app_hash, + subtrees_batch_size, + version, + RestoreCommitMode::default(), + grove_version, + ) + } + + /// [`GroveDb::start_syncing_session`] with an explicit + /// [`RestoreCommitMode`]. + pub fn start_syncing_session_with_mode( + &self, + app_hash: [u8; 32], + subtrees_batch_size: usize, + version: u16, + commit_mode: RestoreCommitMode, + grove_version: &GroveVersion, + ) -> Result>>, Error> { + check_grovedb_v0!( + "start_snapshot_syncing", + grove_version + .grovedb_versions + .replication + .start_snapshot_syncing + ); + if version != CURRENT_STATE_SYNC_VERSION { + return Err(Error::CorruptedData(format!( + "Unsupported state sync protocol version {version}; this build speaks version \ + {CURRENT_STATE_SYNC_VERSION}" + ))); + } + // A destination still marked from an abandoned incremental restore + // holds unverified data that a new session would neither clear nor + // re-verify: the new restore's root hash check cannot see orphaned + // entries under prefixes it declares empty, and its own final + // commit would erase the marker. Refuse until the directory is + // discarded. + if self.has_incomplete_restore()? { + return Err(Error::CorruptedData( + "cannot start a state sync session: the database holds an incomplete restore \ + (an earlier incremental restore never reached a verified commit); discard the \ + directory and sync into a fresh one" + .to_string(), + )); + } + if subtrees_batch_size == 0 { + return Err(Error::InternalError( + "subtrees_batch_size cannot be zero".to_string(), + )); + } + let mut session = + MultiStateSyncSession::new(self, app_hash, subtrees_batch_size, version, commit_mode); + session.add_subtree_sync_info( + SubtreePath::empty(), + app_hash, + None, + [0u8; 32], + grove_version, + )?; + Ok(session) + } + + /// Whether this database holds a partially applied, hash-unverified + /// state sync restore. + /// + /// Only a [`RestoreCommitMode::Incremental`] session can leave one: + /// it marks the database inside its first intermediate commit and + /// clears the mark inside the final, root-hash-verified commit, so + /// the marker survives exactly the window in which the database + /// contains restore writes that were never checked against the + /// offered `app_hash`. A `true` here means the contents are + /// meaningless and the directory must be discarded — it is never a + /// state to resume from or serve reads out of. + /// + /// Callers running an [`RestoreCommitMode::Atomic`] restore never + /// need this: that mode cannot leave partial state behind. + /// + /// Every session constructor refuses to start while the marker is + /// set, so a marked directory cannot be "repaired" by syncing into it + /// again: the new restore would clear the marker on its own success + /// while leaving the earlier, unverified entries in place. + pub fn has_incomplete_restore(&self) -> Result { + self.get_aux(INCOMPLETE_RESTORE_AUX_KEY, None) + .value + .map(|marker| marker.is_some()) } /// Commits a completed state synchronization session. /// - /// Verifies the final GroveDB root hash matches the expected `app_hash` - /// before committing. Returns an error if the hashes don't match. + /// Verifies the final root against `app_hash` and authenticates restored + /// element bytes before committing. Returns an error on any mismatch. pub fn commit_session( &self, session: Pin>, grove_version: &GroveVersion, ) -> Result<(), Error> { - session - .commit(grove_version) - .inspect_err(|e| eprintln!("Failed to commit session: {:?}", e)) + session.commit(grove_version) } /// Fetches a chunk of data from the database based on the given global @@ -87,20 +308,27 @@ impl GroveDb { /// /// # Notes /// - /// - Only `CURRENT_STATE_SYNC_VERSION` is supported. + /// - Only [`CURRENT_STATE_SYNC_VERSION`] is supported. /// - If the `packed_global_chunk_id` matches the `root_app_hash` length, it /// is treated as a single ID. /// - Otherwise, it is unpacked into multiple nested chunk IDs. /// - The function opens a `Merk` tree for each chunk and retrieves the /// associated data. /// - Empty trees return an empty byte vector. + /// - The request shape is bounded to what an honest target sends: at + /// most [`CONST_GROUP_PACKING_SIZE`] global chunk ids per request, + /// at most that many local chunk ids per global id, and exactly one + /// page cursor per append-only subtree. Larger requests are refused + /// before anything is served, since every id costs a buffered chunk. /// - Non-Merk append-only subtrees (`CommitmentTree`, `MmrTree`, - /// `BulkAppendTree`, `DenseAppendOnlyFixedSizeTree`) are served as - /// cursor-based entry pages instead of Merk chunks. A request for one - /// of these subtrees without a page cursor returns - /// `Error::NotSupported`. - /// - Indexed-tree requests and populated `PrivateDocumentStore` - /// requests return `Error::NotSupported`. + /// `BulkAppendTree`, `DenseAppendOnlyFixedSizeTree`, + /// `PrivateDocumentStore`) are served as cursor-based entry pages + /// instead of Merk chunks. A request for one of these subtrees + /// without a page cursor returns `Error::NotSupported`. + /// - Indexed-tree primaries are served as a header page (carrying the + /// primary and per-axis secondary root hashes) followed by ordinary + /// Merk chunks; the axis secondaries are served as ordinary + /// by-prefix Merk chunks (see `indexed_sync`). pub fn fetch_chunk( &self, packed_global_chunk_id: &[u8], @@ -115,11 +343,11 @@ impl GroveDb { let tx = TxRef::new(&self.db, transaction); - // For now, only CURRENT_STATE_SYNC_VERSION is supported if version != CURRENT_STATE_SYNC_VERSION { - return Err(Error::CorruptedData( - "Unsupported state sync protocol version".to_string(), - )); + return Err(Error::CorruptedData(format!( + "Unsupported state sync protocol version {version}; this build speaks version \ + {CURRENT_STATE_SYNC_VERSION}" + ))); } let mut global_chunk_ids: Vec> = vec![]; @@ -130,45 +358,84 @@ impl GroveDb { global_chunk_ids.extend(unpack_nested_bytes(packed_global_chunk_id)?); } + // Every id in the request costs one bounded chunk or page to serve + // and every chunk is buffered before the response is packed, so the + // request shape is what bounds the response size. An honest target + // never packs more than `CONST_GROUP_PACKING_SIZE` global ids per + // request nor more than that many local ids per global id (see + // `apply_chunk`); anything beyond is a peer trying to make this + // node build a response arbitrarily larger than its request. + if global_chunk_ids.len() > CONST_GROUP_PACKING_SIZE { + return Err(Error::CorruptedData(format!( + "state sync request carries too many global chunk ids: {} > {}", + global_chunk_ids.len(), + CONST_GROUP_PACKING_SIZE + ))); + } + let mut global_chunk_bytes: Vec> = vec![]; for global_chunk_id in global_chunk_ids { let (chunk_prefix, root_key, tree_type, nested_chunk_ids) = utils::decode_global_chunk_id(global_chunk_id.as_slice(), &root_app_hash)?; + if nested_chunk_ids.len() > CONST_GROUP_PACKING_SIZE { + return Err(Error::CorruptedData(format!( + "state sync request carries too many local chunk ids for one subtree: {} > \ + {}", + nested_chunk_ids.len(), + CONST_GROUP_PACKING_SIZE + ))); + } - // State sync does not yet support indexed trees. Reject on the - // source side too (target-side discovery also rejects) so a - // peer requesting an indexed-tree chunk gets a descriptive - // error rather than a chunk that would fail root-hash - // verification on apply (indexed primaries commit a - // three-input combine_hash_three the restorer cannot match, - // and their axis secondary namespaces are never enumerated). - if tree_type.is_indexed_primary() { - return Err(Error::NotSupported( - "state sync does not yet support indexed trees \ - (ProvableCountIndexedTree / ProvableSumIndexedTree / \ - ProvableCountProvableSumIndexedTree)" - .to_string(), - )); + // The initial request for an indexed primary is a single + // header request carrying the axis tags and secondary root + // keys; answer it with the indexed header plus the primary's + // root chunk. Any other request for an indexed primary is an + // ordinary Merk chunk request and falls through to the + // generic serving below. + if tree_type.is_indexed_primary() + && nested_chunk_ids + .first() + .is_some_and(|id| indexed_sync::is_indexed_header_request(id)) + { + if nested_chunk_ids.len() != 1 { + return Err(Error::CorruptedData( + "an indexed header request must be the only chunk id in its global chunk" + .to_string(), + )); + } + let payload = self.serve_indexed_header_page( + chunk_prefix, + root_key, + tree_type, + &nested_chunk_ids[0], + tx.as_ref(), + grove_version, + )?; + global_chunk_bytes.push(pack_nested_bytes(vec![payload])?); + continue; } // Non-Merk append-only trees (CommitmentTree / MmrTree / - // BulkAppendTree / DenseAppendOnlyFixedSizeTree) have no Merk - // nodes to chunk — their payload is served as target-driven - // entry pages instead. The target encodes a page cursor into - // every local chunk id; a request without one comes from a - // peer speaking the pre-#785 protocol, which cannot sync - // these subtrees. (Other non-Merk types without a replay arm - // — PrivateDocumentStore — fall through to the Merk path, - // which serves them empty or rejects them populated below.) + // BulkAppendTree / DenseAppendOnlyFixedSizeTree / + // PrivateDocumentStore) have no Merk nodes to chunk — their + // payload is served as target-driven entry pages instead. The + // target encodes a page cursor into every local chunk id, so + // a request without one is malformed. if non_merk_sync::supports_entry_replay(tree_type) { if nested_chunk_ids.is_empty() { return Err(Error::NotSupported( - "append-only subtree chunk request is missing its page \ - cursor — the requesting peer does not support state \ - sync of append-only trees (see issue #785)" - .to_string(), + "append-only subtree chunk request is missing its page cursor".to_string(), )); } + // The target asks for exactly one page per round; every + // extra cursor would cost another `MAX_PAGE_BYTES` page. + if nested_chunk_ids.len() > 1 { + return Err(Error::CorruptedData(format!( + "state sync request carries too many page cursors for one append-only \ + subtree: {} > 1", + nested_chunk_ids.len() + ))); + } let mut local_chunk_bytes: Vec> = vec![]; for chunk_id in &nested_chunk_ids { local_chunk_bytes.push(self.fetch_non_merk_page( @@ -204,16 +471,6 @@ impl GroveDb { if merk.is_empty_tree().unwrap() { local_chunk_bytes.push(vec![]); } else { - // A non-Merk data tree whose namespace is populated but that - // has no entry-replay arm (PrivateDocumentStore, see issues - // #783 / #784): there are no Merk nodes to chunk, so fail - // descriptively instead of dying in the chunk producer. - if tree_type.uses_non_merk_data_storage() { - return Err(Error::NotSupported(format!( - "state sync does not yet support populated {tree_type} subtrees \ - (non-Merk data storage without an entry-replay arm)" - ))); - } let mut chunk_producer = ChunkProducer::new(&merk).map_err(|e| { Error::CorruptedData(format!( "failed to create chunk producer by prefix tx:{} with:{}", @@ -296,39 +553,39 @@ impl GroveDb { version: u16, grove_version: &GroveVersion, ) -> Result>>, Error> { - check_grovedb_v0!( - "start_snapshot_syncing", - grove_version - .grovedb_versions - .replication - .start_snapshot_syncing - ); - // For now, only CURRENT_STATE_SYNC_VERSION is supported - if version != CURRENT_STATE_SYNC_VERSION { - return Err(Error::CorruptedData( - "Unsupported state sync protocol version".to_string(), - )); - } - - if subtrees_batch_size == 0 { - return Err(Error::InternalError( - "subtrees_batch_size cannot be zero".to_string(), - )); - } - - let root_prefix = [0u8; 32]; - - let mut session = self.start_syncing_session(app_hash, subtrees_batch_size); - - session.add_subtree_sync_info( - SubtreePath::empty(), + self.start_snapshot_syncing_with_mode( app_hash, - None, - root_prefix, + subtrees_batch_size, + version, + RestoreCommitMode::default(), grove_version, - )?; + ) + } - Ok(session) + /// [`GroveDb::start_snapshot_syncing`] with an explicit + /// [`RestoreCommitMode`]. + /// + /// Memory-constrained callers that already discard and re-sync a + /// failed restore should pass [`RestoreCommitMode::incremental`]: + /// peak memory then tracks the configured chunk budget instead of + /// the size of the state being restored. Read + /// [`RestoreCommitMode::Incremental`] first — it changes what a + /// crashed restore leaves on disk. + pub fn start_snapshot_syncing_with_mode( + &self, + app_hash: CryptoHash, + subtrees_batch_size: usize, + version: u16, + commit_mode: RestoreCommitMode, + grove_version: &GroveVersion, + ) -> Result>>, Error> { + self.start_syncing_session_with_mode( + app_hash, + subtrees_batch_size, + version, + commit_mode, + grove_version, + ) } } diff --git a/grovedb/src/replication/indexed_sync.rs b/grovedb/src/replication/indexed_sync.rs new file mode 100644 index 000000000..cc3ff2d21 --- /dev/null +++ b/grovedb/src/replication/indexed_sync.rs @@ -0,0 +1,418 @@ +//! State-sync support for indexed trees (`ProvableCountIndexedTree`, +//! `ProvableSumIndexedTree`, `ProvableCountProvableSumIndexedTree`). +//! +//! An indexed subtree is one primary Merk at the ordinary path prefix plus +//! one ordinary secondary Merk per configured axis at the derived prefix +//! `Blake3(primary_prefix ‖ axis_tag)`. Three properties shape the +//! protocol: +//! +//! - The parent element commits +//! `combine_hash_three(value_hash(element_bytes), primary_root_hash, +//! secondary_slot)` where `secondary_slot` is the single secondary root +//! hash (PCIT / PSIT) or the canonical `axes_digest` (PCPSIT). The +//! element itself stores root KEYS only, never root hashes, so the +//! target cannot derive any per-Merk expected root hash from what it +//! has restored — those hashes must cross the wire as an **indexed +//! header**. +//! - A secondary's root hash commits to its AVL shape, which is +//! write-history-dependent — the target cannot rebuild it locally from +//! the primary (see `operations/indexed_tree.rs`). Secondaries are +//! chunk-transferred like ordinary Merks, addressed by prefix. +//! - The header is untrusted (any peer can claim any hashes). Per-chunk +//! verification against the header is early-abort DoS protection only; +//! the security boundary is the **unconditional finalize-time joint +//! check** ([`verify_indexed_binding`]): once the primary and every +//! secondary of a group are fully restored, their *actual* recomputed +//! root hashes are combined and compared against the parent-bound +//! element value hash, which is itself protected by the already +//! verified parent chunk chain up to the app hash. +//! +//! Wire flow (target-driven, like the rest of the protocol): +//! +//! 1. The target discovers an indexed child, opens its group, and requests +//! the primary with a single [`IndexedHeaderRequest`] local chunk id — +//! marker-prefixed so it can never be confused with a Merk traversal +//! instruction (whose bytes are only `0x00` / `0x01`) — carrying the +//! axis tags and secondary root keys from the target's hash-verified +//! element (the source cannot recover them from a one-way prefix). +//! 2. The source answers with `pack([header, root_chunk_ops])`: the +//! [`IndexedHeader`] (primary root hash + per-axis secondary root +//! hashes) and the primary's root chunk (empty for an empty primary). +//! 3. The target constructs the primary `Restorer` with +//! `Restorer::new(merk, header.primary_root_hash, None)` (direct +//! root-hash comparison — the three-input parent binding is checked at +//! group finalize instead), activates one ordinary Merk chunk restore +//! per axis against the header's secondary hashes, and requests them by +//! prefix. Subsequent primary chunks are ordinary Merk chunks. +//! 4. As each group member completes, its actual root hash is recorded; +//! when the last lands, [`verify_indexed_binding`] accepts or rejects +//! the whole group. + +use grovedb_element::indexed::IndexAxis; +use grovedb_merk::{ + tree::{ + hash::{axes_digest, combine_hash_three}, + CryptoHash, + }, + tree_type::TreeType, + ChunkProducer, +}; +use grovedb_storage::rocksdb_storage::RocksDbStorage; + +use crate::{ + operations::indexed_tree::axis_secondary_tree_type, + replication::utils::{encode_vec_ops, pack_nested_bytes}, + Element, Error, GroveDb, SubtreePrefix, Transaction, +}; + +/// Marker byte opening an [`IndexedHeaderRequest`] local chunk id. +/// +/// Merk traversal-instruction chunk ids consist solely of `0x00` / `0x01` +/// bytes (`vec_bytes_as_traversal_instruction` rejects anything else), so +/// this byte makes the header request unambiguous among an indexed +/// primary's local chunk ids. +const INDEXED_HEADER_REQUEST_MARKER: u8 = 0xFE; + +/// Length of one `(tag, root_key_len)` fixed part in a header request. +const REQUEST_AXIS_FIXED_LEN: usize = 1 + 2; + +/// Returns true when a local chunk id addressed to an indexed primary is a +/// header request rather than a Merk traversal instruction. +pub(crate) fn is_indexed_header_request(chunk_id: &[u8]) -> bool { + chunk_id.first() == Some(&INDEXED_HEADER_REQUEST_MARKER) +} + +/// The target-encoded first request for an indexed primary: the configured +/// axis tags and their secondary root keys, read from the target's +/// hash-verified element. The source needs them to open the secondary +/// Merks (their prefixes are one-way hashes and layered Merks do not +/// persist their own root keys). +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndexedHeaderRequest { + /// `(axis_tag, secondary_root_key)` in canonical element order. + pub axes: Vec<(u8, Option>)>, +} + +impl IndexedHeaderRequest { + /// Layout: `MARKER ‖ n(1) ‖ (tag(1) ‖ root_key_len(2 BE) ‖ root_key)*n`. + /// A missing root key (empty secondary) encodes as length 0. + pub(crate) fn encode(&self) -> Vec { + let mut out = vec![INDEXED_HEADER_REQUEST_MARKER, self.axes.len() as u8]; + for (tag, root_key) in &self.axes { + out.push(*tag); + let key = root_key.as_deref().unwrap_or_default(); + out.extend_from_slice(&(key.len() as u16).to_be_bytes()); + out.extend_from_slice(key); + } + out + } + + pub(crate) fn decode(bytes: &[u8]) -> Result { + let err = + |what: &str| Error::CorruptedData(format!("malformed indexed header request: {what}")); + let mut rest = bytes + .strip_prefix(&[INDEXED_HEADER_REQUEST_MARKER][..]) + .ok_or_else(|| err("missing marker byte"))?; + let (&count, tail) = rest + .split_first() + .ok_or_else(|| err("missing axis count"))?; + rest = tail; + if !(1..=3).contains(&count) { + return Err(err("axis count must be 1..=3")); + } + let mut axes = Vec::with_capacity(count as usize); + for _ in 0..count { + if rest.len() < REQUEST_AXIS_FIXED_LEN { + return Err(err("truncated axis entry")); + } + let tag = rest[0]; + let key_len = u16::from_be_bytes([rest[1], rest[2]]) as usize; + rest = &rest[REQUEST_AXIS_FIXED_LEN..]; + if rest.len() < key_len { + return Err(err("truncated root key")); + } + let (key, tail) = rest.split_at(key_len); + rest = tail; + axes.push((tag, (!key.is_empty()).then(|| key.to_vec()))); + } + if !rest.is_empty() { + return Err(err("trailing bytes")); + } + Ok(IndexedHeaderRequest { axes }) + } +} + +/// The source's answer to an [`IndexedHeaderRequest`]: the root hashes the +/// element does not carry. A HINT ONLY — per-chunk verification against it +/// bounds how much garbage a byzantine source can make the target chew, +/// but acceptance is decided solely by [`verify_indexed_binding`] over the +/// actually restored Merks. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct IndexedHeader { + /// Root hash of the primary Merk. + pub primary_root_hash: CryptoHash, + /// `(axis_tag, secondary_root_hash)` in canonical element order. An + /// empty secondary reports `NULL_HASH` (the empty Merk root). + pub axes: Vec<(u8, CryptoHash)>, +} + +impl IndexedHeader { + /// Layout: `primary_root_hash(32) ‖ n(1) ‖ (tag(1) ‖ hash(32))*n`. + pub(crate) fn encode(&self) -> Vec { + let mut out = Vec::with_capacity(33 + 33 * self.axes.len()); + out.extend_from_slice(&self.primary_root_hash); + out.push(self.axes.len() as u8); + for (tag, hash) in &self.axes { + out.push(*tag); + out.extend_from_slice(hash); + } + out + } + + pub(crate) fn decode(bytes: &[u8]) -> Result { + let err = |what: &str| Error::CorruptedData(format!("malformed indexed header: {what}")); + if bytes.len() < 33 { + return Err(err("too short for primary root hash and axis count")); + } + let primary_root_hash: CryptoHash = bytes[0..32].try_into().expect("checked length"); + let count = bytes[32] as usize; + if !(1..=3).contains(&count) { + return Err(err("axis count must be 1..=3")); + } + let rest = &bytes[33..]; + if rest.len() != count * 33 { + return Err(err("axis section length mismatch")); + } + let axes = rest + .as_chunks::<33>() + .0 + .iter() + .map(|chunk| (chunk[0], chunk[1..33].try_into().expect("checked length"))) + .collect(); + Ok(IndexedHeader { + primary_root_hash, + axes, + }) + } +} + +/// The unconditional finalize-time joint verification for one indexed +/// group — the security boundary of indexed-tree state sync. +/// +/// `primary_root` and `secondary_roots` are the ACTUAL root hashes +/// recomputed from the fully restored Merks (never the header's claims). +/// Recomputes the element's three-input binding — +/// `combine_hash_three(value_hash, primary_root, secondary_root)` for the +/// single-axis variants, `combine_hash_three(value_hash, primary_root, +/// axes_digest(axes))` for PCPSIT (an empty secondary contributes +/// `NULL_HASH`, exactly as the write path does) — and requires it to equal +/// the element value hash bound into the restored, hash-verified parent. +pub(crate) fn verify_indexed_binding( + element: &Element, + actual_value_hash: &CryptoHash, + elem_value_hash: &CryptoHash, + primary_root: &CryptoHash, + secondary_roots: &[(u8, CryptoHash)], +) -> Result<(), Error> { + let secondary_slot = match element.underlying() { + Element::ProvableCountIndexedTree(..) | Element::ProvableSumIndexedTree(..) => { + match secondary_roots { + [(_, hash)] => *hash, + other => { + return Err(Error::InternalError(format!( + "single-axis indexed group finalized with {} secondary roots", + other.len() + ))); + } + } + } + Element::ProvableCountProvableSumIndexedTree(..) => axes_digest(secondary_roots).unwrap(), + other => { + return Err(Error::InternalError(format!( + "verify_indexed_binding called on a non-indexed element: {}", + other.type_str() + ))); + } + }; + let combined = combine_hash_three(actual_value_hash, primary_root, &secondary_slot).unwrap(); + if combined != *elem_value_hash { + return Err(Error::CorruptedData(format!( + "indexed subtree joint verification failed: combined hash {} does not match the \ + parent binding {}", + hex::encode(combined), + hex::encode(elem_value_hash), + ))); + } + Ok(()) +} + +// ── Source side ───────────────────────────────────────────────────────── + +impl GroveDb { + /// Serve the header page for an indexed primary: the + /// [`IndexedHeader`] plus the primary's root chunk, packed as + /// `pack([header, root_chunk_ops])` (`root_chunk_ops` is empty for an + /// empty primary). + /// + /// Every field of `request` is peer-controlled: an invalid axis tag or + /// a root key that does not open a Merk produces a bounded descriptive + /// error, and a wrong-but-openable request only yields hashes the + /// target's joint verification will reject. + pub(crate) fn serve_indexed_header_page( + &self, + chunk_prefix: SubtreePrefix, + root_key: Option>, + tree_type: TreeType, + request_bytes: &[u8], + transaction: &Transaction, + grove_version: &grovedb_version::version::GroveVersion, + ) -> Result, Error> { + let request = IndexedHeaderRequest::decode(request_bytes)?; + + let merk = self + .open_transactional_merk_by_prefix( + chunk_prefix, + root_key, + tree_type, + transaction, + None, + grove_version, + ) + .value + .map_err(|e| { + Error::CorruptedData(format!( + "failed to open indexed primary by prefix {}: {e}", + hex::encode(chunk_prefix) + )) + })?; + let primary_root_hash = merk.root_hash().unwrap(); + + let mut axes = Vec::with_capacity(request.axes.len()); + for (tag, secondary_root_key) in &request.axes { + let axis = IndexAxis::try_from_tag(*tag).map_err(|e| { + Error::CorruptedData(format!("invalid axis tag in indexed header request: {e}")) + })?; + let secondary_prefix = + RocksDbStorage::secondary_prefix_for(&chunk_prefix, *tag).unwrap(); + let secondary_merk = self + .open_transactional_merk_by_prefix( + secondary_prefix, + secondary_root_key.clone(), + axis_secondary_tree_type(axis), + transaction, + None, + grove_version, + ) + .value + .map_err(|e| { + Error::CorruptedData(format!( + "failed to open indexed secondary (axis {axis:?}) by prefix: {e}" + )) + })?; + axes.push((*tag, secondary_merk.root_hash().unwrap())); + } + + let header = IndexedHeader { + primary_root_hash, + axes, + }; + + let root_chunk_ops = if merk.is_empty_tree().unwrap() { + Vec::new() + } else { + let mut chunk_producer = ChunkProducer::new(&merk).map_err(|e| { + Error::CorruptedData(format!( + "failed to create indexed primary chunk producer: {e}" + )) + })?; + let (chunk, _) = chunk_producer.chunk(&[], grove_version).map_err(|e| { + Error::CorruptedData(format!("failed to produce indexed primary root chunk: {e}")) + })?; + encode_vec_ops(chunk)? + }; + + pack_nested_bytes(vec![header.encode(), root_chunk_ops]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn indexed_header_request_roundtrip() { + let request = IndexedHeaderRequest { + axes: vec![ + (0, Some(b"count_root".to_vec())), + (1, None), + (2, Some(vec![0xFF; 40])), + ], + }; + let encoded = request.encode(); + assert!(is_indexed_header_request(&encoded)); + assert_eq!(IndexedHeaderRequest::decode(&encoded).unwrap(), request); + + let single = IndexedHeaderRequest { + axes: vec![(1, None)], + }; + assert_eq!( + IndexedHeaderRequest::decode(&single.encode()).unwrap(), + single + ); + } + + #[test] + fn indexed_header_request_rejects_malformed() { + // Empty / wrong marker / bad counts. + assert!(IndexedHeaderRequest::decode(&[]).is_err()); + assert!(IndexedHeaderRequest::decode(&[0x00, 1]).is_err()); + assert!(IndexedHeaderRequest::decode(&[INDEXED_HEADER_REQUEST_MARKER]).is_err()); + assert!(IndexedHeaderRequest::decode(&[INDEXED_HEADER_REQUEST_MARKER, 0]).is_err()); + assert!(IndexedHeaderRequest::decode(&[INDEXED_HEADER_REQUEST_MARKER, 4]).is_err()); + // Truncated axis entry and truncated root key. + assert!(IndexedHeaderRequest::decode(&[INDEXED_HEADER_REQUEST_MARKER, 1, 0]).is_err()); + assert!( + IndexedHeaderRequest::decode(&[INDEXED_HEADER_REQUEST_MARKER, 1, 0, 0, 5, 1, 2]) + .is_err() + ); + // Trailing bytes. + let mut encoded = IndexedHeaderRequest { + axes: vec![(0, None)], + } + .encode(); + encoded.push(0); + assert!(IndexedHeaderRequest::decode(&encoded).is_err()); + // A traversal instruction is never mistaken for a header request. + assert!(!is_indexed_header_request(&[0x01, 0x00, 0x01])); + assert!(!is_indexed_header_request(&[])); + } + + #[test] + fn indexed_header_roundtrip_and_malformed() { + let header = IndexedHeader { + primary_root_hash: [7u8; 32], + axes: vec![(0, [1u8; 32]), (1, [2u8; 32]), (2, [3u8; 32])], + }; + assert_eq!(IndexedHeader::decode(&header.encode()).unwrap(), header); + + let single = IndexedHeader { + primary_root_hash: [9u8; 32], + axes: vec![(1, [4u8; 32])], + }; + assert_eq!(IndexedHeader::decode(&single.encode()).unwrap(), single); + + assert!(IndexedHeader::decode(&[]).is_err()); + assert!(IndexedHeader::decode(&[0u8; 32]).is_err()); + // Zero axes. + let mut zero = vec![0u8; 33]; + zero[32] = 0; + assert!(IndexedHeader::decode(&zero).is_err()); + // Axis section length mismatch. + let mut short = single.encode(); + short.pop(); + assert!(IndexedHeader::decode(&short).is_err()); + let mut long = single.encode(); + long.push(0); + assert!(IndexedHeader::decode(&long).is_err()); + } +} diff --git a/grovedb/src/replication/non_merk_sync.rs b/grovedb/src/replication/non_merk_sync.rs index 9526df4c2..d0d3a7502 100644 --- a/grovedb/src/replication/non_merk_sync.rs +++ b/grovedb/src/replication/non_merk_sync.rs @@ -1,7 +1,8 @@ //! State-sync support for the non-Merk append-only tree family //! (`CommitmentTree`, `MmrTree`, `BulkAppendTree`, -//! `DenseAppendOnlyFixedSizeTree`) — see -//! . +//! `DenseAppendOnlyFixedSizeTree`, `PrivateDocumentStore`) — see +//! (and #783 / #784 for +//! `PrivateDocumentStore`). //! //! These tree types keep an always-empty Merk (`root_key = None`) and store //! their payload as raw non-Element entries in the subtree's data namespace, @@ -47,10 +48,11 @@ //! size cap still bounds the single-entry case (entries have no //! protocol-level maximum size). //! -//! `PrivateDocumentStore` also uses non-Merk data storage but has no -//! entry-replay arm yet (issues #783 / #784): a populated one is rejected -//! with a descriptive `NotSupported` on both sides, an empty one syncs -//! through the ordinary Merk path exactly as before. +//! `PrivateDocumentStore` replays through [`PrivateDocumentStore::append`] +//! (issues #783 / #784), so the committed `entry_size` from the target's +//! hash-verified element is enforced on every replayed entry, and the +//! config-binding state root is recomputed and checked at finalize like +//! every other type in the family. use grovedb_bulk_append_tree::{deserialize_chunk_blob, BulkAppendTree}; use grovedb_commitment_tree::{CommitmentFrontier, COMMITMENT_TREE_DATA_KEY}; @@ -63,6 +65,7 @@ use grovedb_merkle_mountain_range::{ leaf_to_pos, mmr_size_to_leaf_count, MMRStoreReadOps, MmrNode, MmrStore, MMR, }; use grovedb_path::SubtreePath; +use grovedb_private_document_store::PrivateDocumentStore; use grovedb_storage::{Storage, StorageContext}; use grovedb_version::version::GroveVersion; @@ -85,10 +88,11 @@ const NON_MERK_CHUNK_ID_LEN: usize = 17; /// Whether state sync transfers this (non-Merk) tree type by entry replay. /// -/// This is deliberately narrower than -/// [`TreeType::uses_non_merk_data_storage`]: that predicate also covers -/// `PrivateDocumentStore`, which has no replay arm yet and must keep failing -/// closed with a descriptive error rather than being routed here. +/// Covers every tree type for which +/// [`TreeType::uses_non_merk_data_storage`] is true. Kept as its own +/// predicate (rather than aliasing that one) so a future non-Merk type +/// without a replay arm fails closed here instead of being routed into +/// replay it does not support. pub(crate) fn supports_entry_replay(tree_type: TreeType) -> bool { matches!( tree_type, @@ -96,10 +100,15 @@ pub(crate) fn supports_entry_replay(tree_type: TreeType) -> bool { | TreeType::MmrTree | TreeType::BulkAppendTree(_) | TreeType::DenseAppendOnlyFixedSizeTree(_) + | TreeType::PrivateDocumentStore(_) ) } -/// Element-level twin of [`supports_entry_replay`]. +/// Element-level twin of [`supports_entry_replay`]. Discovery no longer +/// needs it (every non-Merk type now has a replay arm), so it survives +/// only to pin element-level parity with the tree-type predicate in +/// tests. +#[cfg(test)] pub(crate) fn element_supports_entry_replay(element: &Element) -> bool { matches!( element.underlying(), @@ -107,6 +116,7 @@ pub(crate) fn element_supports_entry_replay(element: &Element) -> bool { | Element::MmrTree(..) | Element::BulkAppendTree(..) | Element::DenseAppendOnlyFixedSizeTree(..) + | Element::PrivateDocumentStore(..) ) } @@ -144,11 +154,12 @@ pub(crate) struct NonMerkChunkId { /// First entry position (0-based) this page should start at. pub start: u64, /// Type-specific size state from the element: `total_count` for - /// commitment/bulk trees, `mmr_size` for MMR trees, entry `count` for - /// dense trees. + /// commitment/bulk trees and private document stores, `mmr_size` for + /// MMR trees, entry `count` for dense trees. pub state: u64, /// Type-specific parameter from the element: `chunk_power` for - /// commitment/bulk trees, `height` for dense trees, 0 for MMR trees. + /// commitment/bulk trees and private document stores, `height` for + /// dense trees, 0 for MMR trees. pub param: u8, } @@ -284,7 +295,14 @@ impl GroveDb { let id = NonMerkChunkId::decode(chunk_id_bytes)?; match tree_type { - TreeType::CommitmentTree(_) | TreeType::BulkAppendTree(_) => { + // A private document store's payload IS a bulk append tree + // (the wrapper only adds entry-size validation and the + // config-binding state root, neither of which affects how + // stored entries are read), so all three serve pages through + // `BulkAppendTree::from_state`. + TreeType::CommitmentTree(_) + | TreeType::BulkAppendTree(_) + | TreeType::PrivateDocumentStore(_) => { // For a commitment tree, the first page also carries the // serialized Sinsemilla frontier: it is an accumulator over // the whole append history and cannot be replayed from @@ -519,6 +537,9 @@ impl NonMerkRestorer { Element::DenseAppendOnlyFixedSizeTree(count, height, _) => { (*count as u64, *count as u64, *height) } + Element::PrivateDocumentStore(total_count, _entry_size, chunk_power, _) => { + (*total_count, *total_count, *chunk_power) + } other => { return Err(Error::InternalError(format!( "NonMerkRestorer::new called on a non append-only element: {}", @@ -730,6 +751,37 @@ impl NonMerkRestorer { })?; } } + Element::PrivateDocumentStore(_, entry_size, ..) => { + // Replay through the store wrapper (not the raw bulk tree) + // so the committed entry_size from the target's + // hash-verified element is enforced on every wire entry + // before anything is written. + let entry_size = *entry_size; + let ctx = db + .db + .get_immediate_storage_context(subtree_path, tx) + .unwrap(); + let mut store = + PrivateDocumentStore::from_state(self.replayed, entry_size, self.param, ctx) + .unwrap() + .map_err(|e| { + Error::CorruptedData(format!( + "cannot open partially replayed private document store ({} entries): {e}", + self.replayed + )) + })?; + store + .append_many(entries.iter().map(Vec::as_slice), grove_version) + .unwrap() + .map_err(|e| { + Error::CorruptedData(format!( + "cannot replay private document store entries: {e}" + )) + })?; + store.commit_mmr(grove_version).map_err(|e| { + Error::CorruptedData(format!("cannot flush replayed document store MMR: {e}")) + })?; + } _ => unreachable!("NonMerkRestorer::new only accepts append-only elements"), } @@ -948,19 +1000,19 @@ mod tests { } #[test] - fn entry_replay_predicate_excludes_private_document_store() { + fn entry_replay_predicate_covers_non_merk_family() { assert!(supports_entry_replay(TreeType::CommitmentTree(4))); assert!(supports_entry_replay(TreeType::MmrTree)); assert!(supports_entry_replay(TreeType::BulkAppendTree(4))); assert!(supports_entry_replay( TreeType::DenseAppendOnlyFixedSizeTree(4) )); - assert!(!supports_entry_replay(TreeType::PrivateDocumentStore(4))); + assert!(supports_entry_replay(TreeType::PrivateDocumentStore(4))); assert!(!supports_entry_replay(TreeType::NormalTree)); - assert!(TreeType::PrivateDocumentStore(4).uses_non_merk_data_storage()); + assert!(!supports_entry_replay(TreeType::ProvableCountTree)); assert!(element_supports_entry_replay(&Element::empty_mmr_tree())); - assert!(!element_supports_entry_replay( + assert!(element_supports_entry_replay( &Element::empty_private_document_store(16, 4).unwrap() )); assert!(!element_supports_entry_replay(&Element::empty_tree())); diff --git a/grovedb/src/replication/state_sync_session.rs b/grovedb/src/replication/state_sync_session.rs index 7135357c5..f2b0a9ac6 100644 --- a/grovedb/src/replication/state_sync_session.rs +++ b/grovedb/src/replication/state_sync_session.rs @@ -2,29 +2,32 @@ use std::{ collections::{BTreeMap, BTreeSet}, fmt, marker::PhantomPinned, - mem, pin::Pin, }; +use grovedb_element::indexed::IndexAxis; use grovedb_merk::{ + element::costs::ElementCostExtensions, tree::{kv::ValueDefinedCostType, value_hash}, tree_type::TreeType, - CryptoHash, Restorer, + CryptoHash, Merk, Restorer, }; use grovedb_path::SubtreePath; use grovedb_storage::{ rocksdb_storage::{PrefixedRocksDbImmediateStorageContext, RocksDbStorage}, - StorageContext, + Storage, StorageContext, }; use grovedb_version::version::GroveVersion; use super::{ - non_merk_sync::{element_supports_entry_replay, supports_entry_replay, NonMerkRestorer}, + indexed_sync::{verify_indexed_binding, IndexedHeader, IndexedHeaderRequest}, + non_merk_sync::{supports_entry_replay, NonMerkRestorer}, utils::{decode_vec_ops, encode_global_chunk_id, path_to_string}, - CURRENT_STATE_SYNC_VERSION, + RestoreCommitMode, CURRENT_STATE_SYNC_VERSION, INCOMPLETE_RESTORE_AUX_KEY, }; use crate::{ element::elements_iterator::ElementIteratorExtensions, + operations::indexed_tree::{axis_secondary_tree_type, indexed_element_axes}, replication, replication::utils::{pack_nested_bytes, unpack_nested_bytes}, Element, Error, GroveDb, Transaction, @@ -38,10 +41,17 @@ pub(crate) type SubtreePrefix = [u8; 32]; /// The restore backend for one subtree: Merk chunk restore for ordinary /// subtrees, entry replay for the non-Merk append-only tree family /// (CommitmentTree / MmrTree / BulkAppendTree / -/// DenseAppendOnlyFixedSizeTree) — see issue #785. +/// DenseAppendOnlyFixedSizeTree / PrivateDocumentStore) — see issues #785 +/// and #783 / #784. enum SubtreeRestorer<'db> { Merk(Restorer>), NonMerk(NonMerkRestorer), + /// An indexed primary waiting for its header + /// page: the actual `Restorer` cannot be constructed until the + /// [`IndexedHeader`] delivers the expected primary root hash. Holds + /// the opened Merk; `None` only transiently while the header page is + /// being processed. + IndexedPending(Option>>), } /// Struct governing the state synchronization of one subtree. @@ -79,10 +89,10 @@ impl SubtreeStateSyncInfo<'_> { /// synchronization. /// /// # Returns - /// - `Ok(Vec>)`: A vector of global chunk IDs (each represented as - /// a vector of bytes) that can be fetched from sources for further - /// synchronization. Ownership of the `SubtreeStateSyncInfo` is - /// transferred back to the caller. + /// - `Ok((Vec>, Option))`: the next local chunk + /// IDs to fetch for this subtree, plus — exactly once per indexed + /// primary — the decoded [`IndexedHeader`] the session must use to + /// activate the group's axis secondaries. /// - `Err(Error)`: An error if the chunk cannot be applied. /// /// # Behavior @@ -108,7 +118,7 @@ impl SubtreeStateSyncInfo<'_> { chunk_id: &[u8], chunk_data: &[u8], grove_version: &GroveVersion, - ) -> Result>, Error> { + ) -> Result<(Vec>, Option), Error> { let mut res = vec![]; if !self.pending_chunks.contains(chunk_id) { @@ -117,6 +127,54 @@ impl SubtreeStateSyncInfo<'_> { )); } self.pending_chunks.remove(chunk_id); + + // An indexed primary's first response is its header page: + // `pack([header, root_chunk_ops])`. Construct the real Merk + // restorer against the header's primary root hash (direct + // root-hash comparison — the three-input parent binding is + // verified at group finalize), process the bundled root chunk, + // and hand the header up so the session can activate the axis + // secondaries. + if matches!(self.restorer, SubtreeRestorer::IndexedPending(_)) { + let SubtreeRestorer::IndexedPending(pending_merk) = + std::mem::replace(&mut self.restorer, SubtreeRestorer::IndexedPending(None)) + else { + unreachable!("matched IndexedPending above"); + }; + let merk = pending_merk.ok_or_else(|| { + Error::InternalError("indexed primary header page processed twice".to_string()) + })?; + let sections = unpack_nested_bytes(chunk_data)?; + let [header_bytes, root_chunk_ops]: [Vec; 2] = + sections.try_into().map_err(|_| { + Error::CorruptedData( + "indexed header page must carry exactly a header and a root chunk" + .to_string(), + ) + })?; + let header = IndexedHeader::decode(&header_bytes)?; + let mut restorer = Restorer::new(merk, header.primary_root_hash, None); + if !root_chunk_ops.is_empty() { + let ops = decode_vec_ops(&root_chunk_ops)?; + match restorer.process_chunk(&[], ops, grove_version) { + Ok(next_chunk_ids) => { + self.num_processed_chunks += 1; + for next_chunk_id in next_chunk_ids { + self.pending_chunks.insert(next_chunk_id.clone()); + res.push(next_chunk_id); + } + } + Err(e) => { + return Err(Error::InternalError(format!( + "Unable to process indexed primary root chunk: {e}" + ))); + } + } + } + self.restorer = SubtreeRestorer::Merk(restorer); + return Ok((res, Some(header))); + } + match &mut self.restorer { SubtreeRestorer::Merk(restorer) => { if !chunk_data.is_empty() { @@ -160,9 +218,12 @@ impl SubtreeStateSyncInfo<'_> { res.push(next_chunk_id); } } + SubtreeRestorer::IndexedPending(_) => { + unreachable!("handled before the match above"); + } } - Ok(res) + Ok((res, None)) } } @@ -208,6 +269,51 @@ pub struct MultiStateSyncSession<'db> { /// Metadata for newly discovered subtrees that are pending processing. pending_discovered_subtrees: Option, + /// Whether restored data may be committed before the final root hash + /// check, and with what payload budget between commits. + commit_mode: RestoreCommitMode, + + /// Chunk payload bytes applied since the last intermediate commit (or + /// since the session started). Always maintained; only consulted in + /// [`RestoreCommitMode::Incremental`]. + bytes_since_commit: u64, + + /// Number of intermediate commits taken so far. Zero for the whole + /// life of an [`RestoreCommitMode::Atomic`] session, which is what + /// the atomicity regression test asserts. + intermediate_commits: usize, + + /// Number of times a due intermediate commit was refused *solely* + /// because an indexed group was still in flight. Non-zero is what + /// makes the group-splitting test non-vacuous. + commits_deferred_for_open_group: usize, + + /// Set when chunk application or an intermediate commit failed. + /// Application may already have consumed a restorer or changed pending + /// work, so every subsequent application and commit must refuse. + failed: bool, + + /// In-flight indexed-tree groups, keyed by the + /// primary's prefix. A group is removed — after passing the joint + /// verification — once its primary and every axis secondary have been + /// fully restored. + indexed_groups: BTreeMap, + + /// Maps each in-flight axis secondary's derived prefix to its owning + /// `(primary_prefix, axis_tag)`. + secondary_owner: BTreeMap, + + /// Every subtree prefix the session has ever discovered — the root, + /// every child subtree read out of a restored parent, every axis + /// secondary announced by an indexed header. Entries are never + /// removed. [`Self::is_sync_completed`] requires all of them to be in + /// `processed_prefixes`, so completeness is a checked invariant rather + /// than a property of the bookkeeping between discovery and + /// activation never dropping an entry: the final root hash check + /// cannot see a missing branch, because the restored parent already + /// commits to the child's hash whether or not the child was restored. + discovered_prefixes: BTreeSet, + /// Transaction used for the synchronization process. /// This is placed last to ensure it is dropped last. transaction: Transaction<'db>, @@ -216,23 +322,142 @@ pub struct MultiStateSyncSession<'db> { _pin: PhantomPinned, } +/// Outcome of the safe-point test run at a drained discovery boundary. +/// See [`MultiStateSyncSession::intermediate_commit_decision`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IntermediateCommitDecision { + /// Budget spent and the session is at a safe point: commit. + Take, + /// Budget spent and no restorer holds the transaction, but an + /// indexed group's joint verification has not run yet. Committing + /// here would split the group; wait for it instead. + DeferForOpenIndexedGroup, + /// Atomic mode, or the payload budget is not spent yet. + NotDue, +} + +/// Target-side tracking of one indexed subtree's transfer: the +/// parent-bound hashes to verify against, the configured axes, and the +/// actual root hashes of members restored so far. +struct IndexedSyncGroup { + /// Path of the primary subtree (for error reporting). + path: Vec>, + /// The indexed element as decoded from the restored parent. + element: Element, + /// `value_hash(element_bytes)` from the parent. + actual_value_hash: CryptoHash, + /// The three-input combined element value hash bound into the parent. + elem_value_hash: CryptoHash, + /// `(axis_tag, secondary_prefix, secondary_root_key)` in canonical + /// element order. + axes: Vec<(u8, SubtreePrefix, Option>)>, + /// The wire header, once received. A hint for per-chunk verification + /// only — the joint check uses the actual restored root hashes. + header: Option, + /// Actual root hash of the fully restored primary. + primary_root: Option, + /// Actual root hashes of fully restored secondaries, by axis tag. + secondary_roots: BTreeMap, +} + impl<'db> MultiStateSyncSession<'db> { - /// Initializes a new state sync session. - pub fn new(db: &'db GroveDb, app_hash: [u8; 32], subtrees_batch_size: usize) -> Pin> { + /// Initializes a new state sync session speaking the given state sync + /// protocol version. + /// + /// Raw constructor: it does not validate `version`. Crate-private so + /// every external session goes through + /// [`GroveDb::start_syncing_session`] / + /// [`GroveDb::start_snapshot_syncing`], which reject unsupported + /// versions before a session exists. Tests use this directly to build + /// a session pinned to a version other than the wire's and exercise + /// the session-consistency check in [`Self::apply_chunk`]. + pub(crate) fn new( + db: &'db GroveDb, + app_hash: [u8; 32], + subtrees_batch_size: usize, + version: u16, + commit_mode: RestoreCommitMode, + ) -> Pin> { Box::pin(MultiStateSyncSession { db, transaction: db.start_transaction(), current_prefixes: Default::default(), processed_prefixes: Default::default(), app_hash, - version: CURRENT_STATE_SYNC_VERSION, + version, subtrees_batch_size, num_processed_subtrees_in_batch: 0, pending_discovered_subtrees: None, + commit_mode, + bytes_since_commit: 0, + intermediate_commits: 0, + commits_deferred_for_open_group: 0, + failed: false, + indexed_groups: Default::default(), + secondary_owner: Default::default(), + discovered_prefixes: Default::default(), _pin: PhantomPinned, }) } + /// How this session persists restored data. See + /// [`RestoreCommitMode`]. + pub fn commit_mode(&self) -> RestoreCommitMode { + self.commit_mode + } + + /// How many intermediate commits this session has taken. Always `0` + /// for [`RestoreCommitMode::Atomic`]; a non-zero value means the + /// destination database already holds restore writes that have not + /// been verified against the offered `app_hash`. + pub fn intermediate_commits(&self) -> usize { + self.intermediate_commits + } + + /// How many times an intermediate commit was due and otherwise safe + /// but was held back because an indexed group's joint verification + /// had not run yet. See + /// [`MultiStateSyncSession::intermediate_commit_decision`]. + pub fn commits_deferred_for_open_group(&self) -> usize { + self.commits_deferred_for_open_group + } + + /// Chunk payload bytes applied since the last intermediate commit + /// (or since the session started). + /// + /// This is the session's own proxy for how much restored state is + /// currently sitting uncommitted in the transaction — the quantity + /// [`RestoreCommitMode::Incremental`]'s `budget_bytes` is meant to + /// bound. It only ever grows in [`RestoreCommitMode::Atomic`], where + /// holding the whole restore is the point. + pub fn uncommitted_payload_bytes(&self) -> u64 { + self.bytes_since_commit + } + + /// Whether an indexed-tree group is part-restored right now, i.e. its + /// primary and axis secondaries have not all completed and the joint + /// verification binding them to the parent has not run. No + /// intermediate commit may land while this is true; see + /// [`Self::intermediate_commit_decision`]. + pub fn has_open_indexed_group(&self) -> bool { + !self.indexed_groups.is_empty() + } + + /// Replace the app hash the final commit checks against. + /// + /// A test seam, and deliberately not reachable outside the crate: it + /// exists so the root-hash gate can be exercised on a session that + /// has already taken intermediate commits, which no honest chunk + /// stream can produce (every per-subtree chunk is verified on the way + /// in, so a stream that reaches `commit` at all reaches it with the + /// right composition). + #[cfg(test)] + pub(crate) fn set_app_hash_for_test(self: &mut Pin>, app_hash: [u8; 32]) { + // SAFETY: `app_hash` is a plain array field; only `transaction` is + // protected by the pin. + unsafe { self.as_mut().get_unchecked_mut() }.app_hash = app_hash; + } + /// Returns true if there are no prefixes currently being synced. pub fn is_empty(&self) -> bool { self.current_prefixes.is_empty() @@ -241,17 +466,24 @@ impl<'db> MultiStateSyncSession<'db> { /// Returns true if all subtrees have been fully synchronized. /// Returns false if sync has never started (no prefixes processed). pub fn is_sync_completed(&self) -> bool { - if self.current_prefixes.is_empty() && self.processed_prefixes.is_empty() { + if self.failed || !self.current_prefixes.is_empty() || self.processed_prefixes.is_empty() { return false; } - for subtree_state_info in self.current_prefixes.values() { - if !subtree_state_info.pending_chunks.is_empty() { - return false; - } + if self.pending_discovered_subtrees.is_some() { + return false; } - if self.pending_discovered_subtrees.is_some() { + // An indexed group still tracked here has members whose joint + // verification has not run yet (e.g. secondaries not activated). + if !self.indexed_groups.is_empty() { + return false; + } + + // Every prefix ever discovered must have been fully restored. See + // the field's documentation for why the root hash check cannot + // stand in for this. + if !self.discovered_prefixes.is_subset(&self.processed_prefixes) { return false; } @@ -260,10 +492,14 @@ impl<'db> MultiStateSyncSession<'db> { /// Commits the sync session by finalizing the underlying transaction. /// - /// Before committing, verifies that the GroveDB root hash matches the - /// expected `app_hash` to ensure the overall composition of all restored - /// subtrees is correct. + /// Before committing, verifies the root against `app_hash` and binds + /// element bytes, including references, to the authenticated value hashes. pub fn commit(self: Pin>, grove_version: &GroveVersion) -> Result<(), Error> { + if self.failed { + return Err(Error::CorruptedData( + "cannot commit a failed state sync session".to_string(), + )); + } if !self.is_sync_completed() { return Err(Error::CorruptedData( "cannot commit an incomplete state sync session".to_string(), @@ -279,12 +515,22 @@ impl<'db> MultiStateSyncSession<'db> { // Individual subtree chunks are hash-verified during restore, but we must also // verify the overall GroveDB root to ensure the composition is correct. // - // TODO(https://github.com/dashpay/grovedb/issues/775): This check is not - // fully atomic. apply_chunk() flushes completed - // subtree batches via set_new_transaction()/commit_transaction(), so on - // mismatch only the last transaction is rolled back while earlier subtrees - // remain on disk. A full fix requires staging all subtree commits and only - // persisting them after root hash verification passes. + // INVARIANT (https://github.com/dashpay/grovedb/issues/775), for the + // default `RestoreCommitMode::Atomic`: every write of the sync — all + // restored subtrees across every discovery batch — stays inside + // `session.transaction` until this check passes. Nothing is persisted + // early; a mismatch here (or dropping the session at any point before + // commit) rolls the destination back to its pre-sync state. + // `subtrees_batch_size` only paces subtree discovery; it must never + // reintroduce intermediate commits, and `discovery_batch_full` keeps + // that true by returning `false` for the budget in atomic mode. + // + // `RestoreCommitMode::Incremental` deliberately trades that rollback + // away for a memory ceiling that does not grow with the state (see the + // variant's documentation). The check below still runs and still + // gates the final commit; what it no longer does is undo the earlier + // ones, which is why such a database carries the unfinished-restore + // marker until this point is reached. let actual_root_hash = session .db .root_hash(Some(&session.transaction), grove_version) @@ -300,6 +546,21 @@ impl<'db> MultiStateSyncSession<'db> { ))); } + // Chunk hashes authenticate carried value hashes, not necessarily + // element bytes. References need all target subtrees present before + // their combined hashes can be checked. This also covers plain + // trees, which do not run the Merk aggregate rewrite. + session + .db + .verify_restored_value_hashes(&session.transaction, grove_version)?; + + // The root hash and element bytes are verified: retire the + // unfinished-restore marker in the transaction that makes the + // complete, checked restore visible. + if session.intermediate_commits > 0 { + session.set_incomplete_restore_marker(false)?; + } + session .db .commit_transaction(session.transaction) @@ -308,24 +569,6 @@ impl<'db> MultiStateSyncSession<'db> { Ok(()) } - // SAFETY: This is unsafe as it requires `self.current_prefixes` to be empty - // so no storage contexts hold references to the transaction being replaced. - unsafe fn set_new_transaction( - self: &mut Pin>>, - ) -> Result<(), Error> { - if !self.current_prefixes.is_empty() { - return Err(Error::InternalError( - "current_prefixes must be empty before replacing transaction".to_string(), - )); - } - let this = unsafe { Pin::as_mut(self).get_unchecked_mut() }; - let old_tx = mem::replace(&mut this.transaction, this.db.start_transaction()); - self.db.commit_transaction(old_tx).value.map_err(|e| { - Error::InternalError(format!("failed to commit old transaction during sync: {e}")) - })?; - Ok(()) - } - /// Adds synchronization information for a subtree into the current /// synchronization session. /// @@ -362,7 +605,7 @@ impl<'db> MultiStateSyncSession<'db> { /// - This function uses unsafe code to create a reference to the /// transaction. Ensure that the transaction is properly managed and the /// lifetime guarantees are respected. - pub fn add_subtree_sync_info<'b, B: AsRef<[u8]>>( + pub(crate) fn add_subtree_sync_info<'b, B: AsRef<[u8]>>( self: &mut Pin>>, path: SubtreePath<'b, B>, hash: CryptoHash, @@ -370,6 +613,13 @@ impl<'db> MultiStateSyncSession<'db> { chunk_prefix: [u8; 32], grove_version: &GroveVersion, ) -> Result, Error> { + if self.failed { + return Err(Error::InternalError( + "state sync session has failed".to_string(), + )); + } + // Covers the grove root, which no discovery pass produces. + self.as_mut().discovered_prefixes().insert(chunk_prefix); let transaction_ref: &'db Transaction<'db> = unsafe { let tx: &Transaction<'db> = &self.as_ref().transaction; &*(tx as *const _) @@ -381,11 +631,9 @@ impl<'db> MultiStateSyncSession<'db> { { if supports_entry_replay(tree_type) { // Non-Merk append-only subtree: restored by replaying leaf - // entries rather than Merk chunks (see issue #785). The - // Merk opened above is structurally empty for these types - // and is not needed. (A PrivateDocumentStore reaches the - // Merk restorer below instead: discovery only lets an - // EMPTY one through, which the Merk path handles.) + // entries rather than Merk chunks (see issues #785 and + // #783 / #784). The Merk opened above is structurally + // empty for these types and is not needed. drop(merk); let element = element.ok_or_else(|| { Error::InternalError( @@ -419,7 +667,26 @@ impl<'db> MultiStateSyncSession<'db> { vec![first_chunk_id], ); } - let restorer = Restorer::new(merk, hash, actual_hash); + // Legacy direct inserts stored these empty trees with a plain + // H(element) value hash, before the layered insert path was + // enabled. Such entries can survive a GroveVersion upgrade. + // Authenticate the exact empty element before translating that + // binding to NULL_HASH for Merk: a populated element, different + // tree family, or mismatched value bytes must never take this arm. + let legacy_empty = actual_hash == Some(hash) + && matches!( + element.as_ref().map(Element::underlying), + Some( + Element::CountSumTree(None, 0, 0, _) + | Element::ProvableCountTree(None, 0, _) + | Element::ProvableCountSumTree(None, 0, 0, _) + ) + ); + let restorer = if legacy_empty { + Restorer::new(merk, grovedb_merk::tree::hash::NULL_HASH, None) + } else { + Restorer::new(merk, hash, actual_hash) + }; let mut sync_info = SubtreeStateSyncInfo::new(restorer); sync_info.pending_chunks.insert(vec![]); sync_info.root_key = root_key.clone(); @@ -466,6 +733,485 @@ impl<'db> MultiStateSyncSession<'db> { &mut unsafe { self.get_unchecked_mut() }.pending_discovered_subtrees } + /// Whether discovery should stop activating new subtrees and let the + /// in-flight ones drain. + /// + /// Two independent reasons close a discovery batch. `subtrees_batch_size` + /// paces how many subtrees are in flight at once and is what the atomic + /// mode uses. The payload budget is the bounded-memory mode's lever: it + /// is what turns "the write set has grown past what we are willing to + /// hold" into a drained `current_prefixes`, which is the only state in + /// which an intermediate commit is safe (see + /// [`Self::intermediate_commit_decision`]). Counting subtrees could not do + /// that job — a grove of ten fat subtrees never reaches a subtree-count + /// boundary at all, and Platform-shaped state is exactly that shape. + fn discovery_batch_full(&self) -> bool { + if self.num_processed_subtrees_in_batch >= self.subtrees_batch_size { + return true; + } + match self.commit_mode { + RestoreCommitMode::Atomic => false, + RestoreCommitMode::Incremental { budget_bytes, .. } => { + self.bytes_since_commit >= budget_bytes + || self.current_prefixes.len() >= self.in_flight_limit() + } + } + } + + /// The effective cap on part-restored subtrees. + /// + /// Clamped to at least one because zero is a hang, not a + /// configuration: with no slots the session defers every discovered + /// subtree forever, `apply_chunk` returns no next chunk ids, and the + /// caller's queue drains while `is_sync_completed()` stays false. The + /// field is public, so refusing to honour a zero here is cheaper than + /// trusting every caller to avoid it. + fn in_flight_limit(&self) -> usize { + match self.commit_mode { + RestoreCommitMode::Atomic => usize::MAX, + RestoreCommitMode::Incremental { + max_subtrees_in_flight, + .. + } => max_subtrees_in_flight.max(1), + } + } + + /// How many more subtrees may be put in flight right now, or `None` + /// for "no limit" (the atomic mode, which activates everything a + /// parent discovers at once). + fn free_in_flight_slots(&self) -> Option { + match self.commit_mode { + RestoreCommitMode::Atomic => None, + RestoreCommitMode::Incremental { .. } => Some( + self.in_flight_limit() + .saturating_sub(self.current_prefixes.len()), + ), + } + } + + /// What to do about an intermediate commit at a drained discovery + /// boundary. + /// + /// A commit is *wanted* when the session is in bounded-memory mode and + /// has accumulated its payload budget. Two conditions make it *safe*, + /// and they are the load-bearing part: + /// + /// - `current_prefixes` must be empty. Every live `SubtreeStateSyncInfo` + /// owns a `PrefixedRocksDbImmediateStorageContext` built from a + /// `&'db Transaction` conjured out of the pinned session + /// (`add_subtree_sync_info` and friends). Replacing the transaction + /// under a live context would dangle that reference. An empty map is + /// the proof that none exists. + /// - `indexed_groups` must be empty. An indexed subtree is verified as + /// a *group*: `note_indexed_member_complete` runs + /// `verify_indexed_binding` over the primary's restored root hash + /// together with every axis secondary's, and only that joint check + /// ties the group to the hash the parent committed to. Committing + /// while a group is open would persist members whose only binding to + /// the parent has not been checked yet. + /// + /// The second condition is not theoretical: a group's secondaries are + /// activated from `pending_discovered_subtrees`, so a group routinely + /// stays open across a boundary at which `current_prefixes` has + /// already drained. [`Self::commits_deferred_for_open_group`] counts + /// how often that happened, which is how the regression test proves + /// the guard is doing work rather than describing an impossible case. + fn intermediate_commit_decision(&self) -> IntermediateCommitDecision { + let budget_reached = match self.commit_mode { + RestoreCommitMode::Atomic => false, + RestoreCommitMode::Incremental { budget_bytes, .. } => { + self.bytes_since_commit >= budget_bytes + } + }; + if !budget_reached || !self.current_prefixes.is_empty() { + return IntermediateCommitDecision::NotDue; + } + if !self.indexed_groups.is_empty() { + return IntermediateCommitDecision::DeferForOpenIndexedGroup; + } + IntermediateCommitDecision::Take + } + + /// Persist everything restored so far and continue the sync in a + /// fresh transaction. + /// + /// Only ever called from a state + /// [`Self::intermediate_commit_decision`] has approved. The first such + /// commit also stamps the database as holding an unfinished restore; + /// [`Self::commit`] clears the stamp in the same transaction that + /// passes the root hash check, so the marker is present for exactly + /// the window in which the destination holds unverified data. + fn intermediate_commit(self: &mut Pin>>) -> Result<(), Error> { + // SAFETY: only `transaction` is protected by the pin, and it is + // replaced here rather than moved out from under a borrower: + // `intermediate_commit_decision` established that no storage + // context holds a reference to it. + let session = unsafe { self.as_mut().get_unchecked_mut() }; + if !session.current_prefixes.is_empty() || !session.indexed_groups.is_empty() { + return Err(Error::InternalError( + "refusing an intermediate state sync commit at an unsafe point".to_string(), + )); + } + let db = session.db; + + if session.intermediate_commits == 0 { + session.set_incomplete_restore_marker(true)?; + } + + // The swap has to happen before the commit (`Tx::commit` consumes + // the transaction), so a failed commit leaves the session holding + // a fresh transaction with the failed batch's writes gone. That is + // an unrecoverable hole in the middle of the restore, so mark the + // session dead rather than letting a caller that ignored this + // error drive it further -- the final root hash check would very + // likely catch the gap, but "very likely" is not a guarantee worth + // depending on. + let finished = std::mem::replace(&mut session.transaction, db.start_transaction()); + if let Err(e) = db.commit_transaction(finished).value { + session.failed = true; + return Err(Error::InternalError(format!( + "failed to commit intermediate sync batch: {e}" + ))); + } + + session.bytes_since_commit = 0; + session.intermediate_commits += 1; + Ok(()) + } + + /// Write or delete the unfinished-restore marker inside the session's + /// current transaction, so it lands with the data it describes. + fn set_incomplete_restore_marker(&self, present: bool) -> Result<(), Error> { + let storage = self + .db + .db + .get_immediate_storage_context(SubtreePath::empty(), &self.transaction) + .unwrap(); + let result = if present { + storage.put_aux(INCOMPLETE_RESTORE_AUX_KEY, &self.app_hash, None) + } else { + storage.delete_aux(INCOMPLETE_RESTORE_AUX_KEY, None) + }; + result + .unwrap() + .map_err(|e| Error::InternalError(format!("failed to write restore marker: {e}"))) + } + + fn commits_deferred_for_open_group_mut( + self: Pin<&mut MultiStateSyncSession<'db>>, + ) -> &mut usize { + // SAFETY: we only access a single field and do not move the struct; + // the pin invariant only protects `transaction` from being moved. + &mut unsafe { self.get_unchecked_mut() }.commits_deferred_for_open_group + } + + fn bytes_since_commit(self: Pin<&mut MultiStateSyncSession<'db>>) -> &mut u64 { + // SAFETY: we only access a single field and do not move the struct; + // the pin invariant only protects `transaction` from being moved. + &mut unsafe { self.get_unchecked_mut() }.bytes_since_commit + } + + fn indexed_groups( + self: Pin<&mut MultiStateSyncSession<'db>>, + ) -> &mut BTreeMap { + // SAFETY: we only access a single field and do not move the struct; + // the pin invariant only protects `transaction` from being moved. + &mut unsafe { self.get_unchecked_mut() }.indexed_groups + } + + fn secondary_owner( + self: Pin<&mut MultiStateSyncSession<'db>>, + ) -> &mut BTreeMap { + // SAFETY: we only access a single field and do not move the struct; + // the pin invariant only protects `transaction` from being moved. + &mut unsafe { self.get_unchecked_mut() }.secondary_owner + } + + fn discovered_prefixes( + self: Pin<&mut MultiStateSyncSession<'db>>, + ) -> &mut BTreeSet { + // SAFETY: we only access a single field and do not move the struct; + // the pin invariant only protects `transaction` from being moved. + &mut unsafe { self.get_unchecked_mut() }.discovered_prefixes + } + + /// Registers an indexed subtree group and opens its primary for + /// restore. + /// + /// The primary starts in the header-pending state: its single pending + /// chunk is the [`IndexedHeaderRequest`] carrying the axis tags and + /// secondary root keys from the hash-verified element; the responding + /// header page delivers the root hashes needed to construct the + /// actual restorer. Axis secondaries are activated when that header + /// arrives (see [`Self::register_indexed_header`]). + fn add_indexed_primary_sync_info( + self: &mut Pin>>, + path: Vec>, + elem_value_hash: CryptoHash, + actual_value_hash: CryptoHash, + element: Element, + chunk_prefix: SubtreePrefix, + grove_version: &GroveVersion, + ) -> Result, Error> { + let transaction_ref: &'db Transaction<'db> = unsafe { + let tx: &Transaction<'db> = &self.as_ref().transaction; + &*(tx as *const _) + }; + + let subtree_path: Vec<&[u8]> = path.iter().map(|vec| vec.as_slice()).collect(); + let path_ref: &[&[u8]] = &subtree_path; + let (merk, root_key, tree_type, _element) = self + .db + .open_merk_for_replication(path_ref.into(), transaction_ref, grove_version) + .map_err(|e| { + Error::InternalError(format!( + "Unable to open indexed primary for replication: {e}" + )) + })?; + if !tree_type.is_indexed_primary() { + return Err(Error::InternalError(format!( + "expected an indexed primary at {:?}, got {tree_type:?}", + path_to_string(&path) + ))); + } + + let axes_pairs = indexed_element_axes(&element)?; + let mut axes = Vec::with_capacity(axes_pairs.len()); + let mut request_axes = Vec::with_capacity(axes_pairs.len()); + for (axis, secondary_root_key) in axes_pairs { + let secondary_prefix = + RocksDbStorage::secondary_prefix_for(&chunk_prefix, axis.tag()).unwrap(); + axes.push((axis.tag(), secondary_prefix, secondary_root_key.clone())); + request_axes.push((axis.tag(), secondary_root_key)); + } + let header_request = IndexedHeaderRequest { axes: request_axes }.encode(); + + for (tag, secondary_prefix, _) in &axes { + self.as_mut() + .secondary_owner() + .insert(*secondary_prefix, (chunk_prefix, *tag)); + } + self.as_mut().indexed_groups().insert( + chunk_prefix, + IndexedSyncGroup { + path: path.clone(), + element, + actual_value_hash, + elem_value_hash, + axes, + header: None, + primary_root: None, + secondary_roots: BTreeMap::new(), + }, + ); + + let mut sync_info = SubtreeStateSyncInfo { + restorer: SubtreeRestorer::IndexedPending(Some(merk)), + root_key: root_key.clone(), + tree_type, + pending_chunks: Default::default(), + current_path: path, + num_processed_chunks: 0, + }; + sync_info.pending_chunks.insert(header_request.clone()); + self.as_mut() + .current_prefixes() + .insert(chunk_prefix, sync_info); + encode_global_chunk_id(chunk_prefix, root_key, tree_type, vec![header_request]) + } + + /// Activates the Merk chunk restore of one axis secondary, verified + /// per-chunk against the group header's hash for that axis. Called + /// only after the group's header arrived. + fn add_indexed_secondary_sync_info( + self: &mut Pin>>, + secondary_prefix: SubtreePrefix, + primary_prefix: SubtreePrefix, + axis_tag: u8, + grove_version: &GroveVersion, + ) -> Result, Error> { + let group = self.indexed_groups.get(&primary_prefix).ok_or_else(|| { + Error::InternalError("indexed secondary has no registered group".to_string()) + })?; + let header = group.header.as_ref().ok_or_else(|| { + Error::InternalError("indexed secondary activated before the group header".to_string()) + })?; + let expected_root_hash = header + .axes + .iter() + .find(|(tag, _)| *tag == axis_tag) + .map(|(_, hash)| *hash) + .ok_or_else(|| { + Error::InternalError("group header is missing the requested axis".to_string()) + })?; + let root_key = group + .axes + .iter() + .find(|(tag, ..)| *tag == axis_tag) + .map(|(_, _, root_key)| root_key.clone()) + .ok_or_else(|| { + Error::InternalError("group axes are missing the requested axis".to_string()) + })?; + let axis = IndexAxis::try_from_tag(axis_tag) + .map_err(|e| Error::CorruptedData(format!("invalid axis tag in indexed group: {e}")))?; + let tree_type = axis_secondary_tree_type(axis); + + let transaction_ref: &'db Transaction<'db> = unsafe { + let tx: &Transaction<'db> = &self.as_ref().transaction; + &*(tx as *const _) + }; + let storage = self + .db + .db + .get_immediate_storage_context_by_subtree_prefix(secondary_prefix, transaction_ref) + .unwrap(); + let merk = if root_key.is_some() { + Merk::open_layered_with_root_key( + storage, + root_key.clone(), + tree_type, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .map_err(|e| { + Error::CorruptedData(format!("cannot open indexed secondary for restore: {e}")) + }) + .unwrap()? + } else { + Merk::open_base( + storage, + tree_type, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .map_err(|e| { + Error::CorruptedData(format!( + "cannot open empty indexed secondary for restore: {e}" + )) + }) + .unwrap()? + }; + let restorer = Restorer::new(merk, expected_root_hash, None); + let mut sync_info = SubtreeStateSyncInfo::new(restorer); + sync_info.pending_chunks.insert(vec![]); + sync_info.root_key = root_key.clone(); + sync_info.tree_type = tree_type; + // Secondaries live at a derived prefix, not a path; current_path + // stays empty and completion skips subtree discovery for them. + self.as_mut() + .current_prefixes() + .insert(secondary_prefix, sync_info); + encode_global_chunk_id(secondary_prefix, root_key, tree_type, vec![]) + } + + /// Stores a received indexed header on its group after validating + /// that its axis tags exactly match the element's configured axes, + /// and returns the metadata entries that activate the group's + /// secondaries. + fn register_indexed_header( + self: &mut Pin>>, + primary_prefix: SubtreePrefix, + header: IndexedHeader, + ) -> Result { + let group = self + .as_mut() + .indexed_groups() + .get_mut(&primary_prefix) + .ok_or_else(|| { + Error::InternalError("received an indexed header for an unknown group".to_string()) + })?; + if group.header.is_some() { + return Err(Error::InternalError( + "received a second indexed header for the same group".to_string(), + )); + } + if header.axes.len() != group.axes.len() + || header + .axes + .iter() + .zip(group.axes.iter()) + .any(|((header_tag, _), (group_tag, ..))| header_tag != group_tag) + { + return Err(Error::CorruptedData( + "indexed header axes do not match the element's configured axes".to_string(), + )); + } + let mut metadata = SubtreesMetadata::new(); + for (tag, secondary_prefix, _) in &group.axes { + metadata.data.insert( + *secondary_prefix, + SubtreeMetadata::IndexedSecondary { + primary_prefix, + axis_tag: *tag, + }, + ); + } + group.header = Some(header); + for prefix in metadata.data.keys() { + self.as_mut().discovered_prefixes().insert(*prefix); + } + Ok(metadata) + } + + /// Records the actual restored root hash of a completed subtree that + /// is a member of an indexed group (no-op otherwise) and, once the + /// whole group is restored, runs the unconditional joint verification + /// against the parent binding. The recorded hashes come from the + /// restored Merks themselves — the wire header plays no part here. + fn note_indexed_member_complete( + self: &mut Pin>>, + chunk_prefix: SubtreePrefix, + actual_root_hash: CryptoHash, + ) -> Result<(), Error> { + let (primary_prefix, axis_tag) = if self.indexed_groups.contains_key(&chunk_prefix) { + (chunk_prefix, None) + } else if let Some((primary_prefix, axis_tag)) = self.secondary_owner.get(&chunk_prefix) { + (*primary_prefix, Some(*axis_tag)) + } else { + return Ok(()); + }; + + let groups = self.as_mut().indexed_groups(); + let group = groups + .get_mut(&primary_prefix) + .expect("membership checked above"); + match axis_tag { + None => group.primary_root = Some(actual_root_hash), + Some(tag) => { + group.secondary_roots.insert(tag, actual_root_hash); + } + } + let group_complete = group.header.is_some() + && group.primary_root.is_some() + && group.secondary_roots.len() == group.axes.len(); + if !group_complete { + return Ok(()); + } + + let group = groups.remove(&primary_prefix).expect("present above"); + for (_, secondary_prefix, _) in &group.axes { + self.as_mut().secondary_owner().remove(secondary_prefix); + } + let secondary_roots: Vec<(u8, CryptoHash)> = group + .axes + .iter() + .map(|(tag, ..)| (*tag, group.secondary_roots[tag])) + .collect(); + verify_indexed_binding( + &group.element, + &group.actual_value_hash, + &group.elem_value_hash, + &group.primary_root.expect("checked complete above"), + &secondary_roots, + ) + .map_err(|e| { + Error::CorruptedData(format!( + "indexed subtree at {:?} failed joint verification: {e}", + path_to_string(&group.path) + )) + }) + } + /// Applies a chunk during the state synchronization process. /// This method should be called by ABCI when the `ApplySnapshotChunk` /// method is invoked. @@ -499,6 +1245,8 @@ impl<'db> MultiStateSyncSession<'db> { /// format. /// - This function modifies the state of the synchronization session, so it /// must be used carefully to maintain correctness and avoid errors. + /// - An error after chunk application begins abandons the session. Drop + /// it and start a new restore; further application and commit refuse. /// - The pinned `self` ensures that the session cannot be moved in memory, /// preserving consistency during the synchronization process. pub fn apply_chunk( @@ -508,15 +1256,15 @@ impl<'db> MultiStateSyncSession<'db> { version: u16, grove_version: &GroveVersion, ) -> Result>, Error> { - // For now, only CURRENT_STATE_SYNC_VERSION is supported if version != CURRENT_STATE_SYNC_VERSION { - return Err(Error::CorruptedData( - "Unsupported state sync protocol version".to_string(), - )); + return Err(Error::CorruptedData(format!( + "Unsupported state sync protocol version {version}; this build speaks version \ + {CURRENT_STATE_SYNC_VERSION}" + ))); } if version != self.version { return Err(Error::CorruptedData( - "Unsupported state sync protocol version".to_string(), + "state sync protocol version does not match the session's version".to_string(), )); } @@ -535,34 +1283,66 @@ impl<'db> MultiStateSyncSession<'db> { "Packed num of global chunkIDs and chunks are not matching".to_string(), )); } + if self.failed { + return Err(Error::InternalError( + "state sync session has failed".to_string(), + )); + } if self.is_empty() { return Err(Error::InternalError( "GroveDB is not in syncing mode".to_string(), )); } + let result = self.apply_decoded_chunks( + nested_global_chunk_ids, + nested_global_chunks, + packed_global_chunks.len() as u64, + grove_version, + ); + if result.is_err() { + // SAFETY: only the plain failure flag changes; the pinned + // transaction and every restorer remain in place until drop. + unsafe { self.as_mut().get_unchecked_mut() }.failed = true; + } + result + } + + fn apply_decoded_chunks( + self: &mut Pin>>, + nested_global_chunk_ids: Vec>, + nested_global_chunks: Vec>, + payload_bytes: u64, + grove_version: &GroveVersion, + ) -> Result>, Error> { + // Payload applied since the last commit. Counted on the wire + // bytes rather than on the transaction's write batch because + // RocksDB's only handle on that batch's size copies the whole + // batch to report it. The two are proportional (the payload *is* + // the Merk nodes being written), so a wire-byte budget bounds the + // write set within a constant factor -- which is all a memory + // budget needs to do. + *self.as_mut().bytes_since_commit() += payload_bytes; + let db = self.db; - // SAFETY: the transaction lives as long as the pinned session and is - // dropped last; the reference is only used within this call while - // the session is alive. This mirrors the pattern used by - // `add_subtree_sync_info` and `discover_new_subtrees_metadata`. - // - // ADDITIONAL INVARIANT for this call site: `set_new_transaction()` - // below replaces and commits `self.transaction`, which invalidates - // `transaction_ref`. Every use of `transaction_ref` MUST stay inside - // the per-chunk loop, above the `set_new_transaction()` call. Do not - // use `transaction_ref` after that point. - let transaction_ref: &'db Transaction<'db> = unsafe { - let tx: &Transaction<'db> = &self.as_ref().transaction; - &*(tx as *const _) - }; let mut next_global_chunk_ids: Vec> = vec![]; + let mut received_headers: Vec<(SubtreePrefix, IndexedHeader)> = vec![]; for (iter_global_chunk_id, iter_packed_chunks) in nested_global_chunk_ids .iter() .zip(nested_global_chunks.iter()) { + // SAFETY: the transaction lives as long as the pinned session and + // is dropped last. The reference is scoped to this iteration so + // it is provably dead by the time the drained-boundary block + // after the loop may replace the transaction in an intermediate + // commit. This mirrors the pattern used by + // `add_subtree_sync_info` and `discover_new_subtrees_metadata`. + let transaction_ref: &'db Transaction<'db> = unsafe { + let tx: &Transaction<'db> = &self.as_ref().transaction; + &*(tx as *const _) + }; let mut next_chunk_ids = vec![]; let (chunk_prefix, _, _, nested_local_chunk_ids) = @@ -596,13 +1376,17 @@ impl<'db> MultiStateSyncSession<'db> { for (current_local_chunk_id, current_local_chunks) in it_chunk_ids.iter().zip(current_nested_chunk_data.iter()) { - next_local_chunk_ids.extend(subtree_state_sync.apply_inner_chunk( + let (local_ids, header) = subtree_state_sync.apply_inner_chunk( db, transaction_ref, current_local_chunk_id.as_slice(), current_local_chunks.as_slice(), grove_version, - )?); + )?; + next_local_chunk_ids.extend(local_ids); + if let Some(header) = header { + received_headers.push((chunk_prefix, header)); + } } if !next_local_chunk_ids.is_empty() { @@ -621,14 +1405,43 @@ impl<'db> MultiStateSyncSession<'db> { // Subtree is finished. We can save it. let is_subtree_empty = subtree_state_sync.num_processed_chunks == 0; let mut is_non_merk_subtree = false; + // Actual root hash of a completed Merk restore, recorded + // for indexed-group members (the joint verification uses + // these, never the wire header's claims). + let mut completed_member_root: Option = None; if let Some(prefix_data) = current_prefixes.remove(&chunk_prefix) { match prefix_data.restorer { SubtreeRestorer::Merk(restorer) => { if is_subtree_empty { - // For empty subtrees, verify the restorer's underlying merk - // has a NULL root hash. A malicious peer that sends empty - // data for a non-empty subtree will be caught here (and - // also at commit time via H3 root hash verification). + // An empty payload means no chunk ever + // reached `process_chunk`, so nothing has + // been checked against the commitment the + // parent made to this subtree. Check it + // here, against the empty tree: the + // restorer holds either + // `combine_hash(H(element bytes), child + // root)` from the parent Merk (ordinary + // subtrees) or a bare root hash (indexed + // members), and an empty subtree's root is + // NULL_HASH. + // + // Nothing downstream would catch a + // byzantine source hollowing out a + // populated subtree this way. The restored + // Merk's root hash is NULL either way, and + // the final GroveDB root hash check cannot + // see it: the parent already stores the + // source-committed combined child hash and + // the root hash is never re-derived from + // the child's actual contents. + if !restorer.expects_an_empty_tree() { + return Err(Error::CorruptedData(format!( + "state sync source sent an empty payload for a subtree \ + the parent commits to as non-empty (prefix {}, path {:?})", + hex::encode(chunk_prefix), + path_to_string(&completed_path), + ))); + } let merk = restorer.into_merk(); let merk_root = merk.root_hash().unwrap(); if merk_root != grovedb_merk::tree::hash::NULL_HASH { @@ -636,11 +1449,19 @@ impl<'db> MultiStateSyncSession<'db> { "empty subtree has non-null root hash".to_string(), )); } - } else if let Err(err) = restorer.finalize(grove_version) { - return Err(Error::InternalError(format!( - "Unable to finalize Merk: {:?}", - err - ))); + completed_member_root = Some(merk_root); + } else { + match restorer.finalize_with_grovedb_elements(grove_version) { + Ok(merk) => { + completed_member_root = Some(merk.root_hash().unwrap()); + } + Err(err) => { + return Err(Error::InternalError(format!( + "Unable to finalize Merk: {:?}", + err + ))); + } + } } } SubtreeRestorer::NonMerk(non_merk_restorer) => { @@ -657,6 +1478,11 @@ impl<'db> MultiStateSyncSession<'db> { grove_version, )?; } + SubtreeRestorer::IndexedPending(_) => { + return Err(Error::InternalError( + "indexed primary completed before its header page".to_string(), + )); + } } } else { return Err(Error::InternalError(format!( @@ -665,21 +1491,35 @@ impl<'db> MultiStateSyncSession<'db> { ))); } + // Whether this prefix is an axis secondary must be read + // BEFORE the group bookkeeping below, which un-registers + // the group's members once the group resolves. + let is_indexed_secondary = self.secondary_owner.contains_key(&chunk_prefix); + self.as_mut().processed_prefixes().insert(chunk_prefix); *self.as_mut().num_processed_subtrees_in_batch() += 1; + if let Some(actual_root_hash) = completed_member_root { + self.note_indexed_member_complete(chunk_prefix, actual_root_hash)?; + } + // Non-Merk append-only subtrees never contain child // subtrees, and their data namespace holds raw payload // entries (not Elements) — running element discovery over - // it would fail. Skip it. - let new_subtrees_metadata = if is_non_merk_subtree { + // it would fail. Skip it. Indexed-axis secondaries hold + // only reference rows and live at a derived prefix with no + // path, so discovery is skipped for them too. + let new_subtrees_metadata = if is_non_merk_subtree || is_indexed_secondary { SubtreesMetadata::default() } else { self.discover_new_subtrees_metadata(&completed_path, grove_version)? }; + for prefix in new_subtrees_metadata.data.keys() { + self.as_mut().discovered_prefixes().insert(*prefix); + } - if self.num_processed_subtrees_in_batch >= self.subtrees_batch_size { + if self.discovery_batch_full() { match self.as_mut().pending_discovered_subtrees() { None => { *self.as_mut().pending_discovered_subtrees() = @@ -703,15 +1543,37 @@ impl<'db> MultiStateSyncSession<'db> { } } - if self.num_processed_subtrees_in_batch >= self.subtrees_batch_size - && self.current_prefixes.is_empty() - { - // SAFETY: we made sure `self.current_prefixes` is empty so there are no - // references to the transaction we're about to replace - unsafe { - self.set_new_transaction()?; + // Indexed headers received in this call activate their groups' + // axis secondaries, through the same discovery pacing as newly + // discovered subtrees. + for (primary_prefix, header) in received_headers { + let secondaries_metadata = self.register_indexed_header(primary_prefix, header)?; + if self.discovery_batch_full() { + match self.as_mut().pending_discovered_subtrees() { + None => { + *self.as_mut().pending_discovered_subtrees() = Some(secondaries_metadata); + } + Some(existing_subtrees_metadata) => { + existing_subtrees_metadata + .data + .extend(secondaries_metadata.data); + } + } + } else { + let res = self + .prepare_sync_state_sessions(secondaries_metadata, grove_version) + .map_err(|e| { + Error::InternalError(format!("Unable to activate indexed secondaries: {e}")) + })?; + next_global_chunk_ids.extend(res); } + } + if self.current_prefixes.is_empty() && self.pending_discovered_subtrees.is_some() { + // Batch boundary: everything restored so far stays inside the + // session transaction (see the atomicity invariant in + // `commit()`); `subtrees_batch_size` only paces how many + // subtrees are discovered and in flight at once. let new_subtrees_metadata = self.as_mut() .pending_discovered_subtrees() @@ -721,6 +1583,21 @@ impl<'db> MultiStateSyncSession<'db> { ))?; *self.as_mut().num_processed_subtrees_in_batch() = 0; + // Bounded-memory mode only: `current_prefixes` has just + // drained and no indexed group is open, so this is the one + // point in the sync at which the accumulated write set can be + // handed to RocksDB without dangling a restorer's storage + // context or splitting a group's joint verification. It must + // happen before `prepare_sync_state_sessions` below opens the + // next batch's contexts on the transaction. + match self.intermediate_commit_decision() { + IntermediateCommitDecision::Take => self.intermediate_commit()?, + IntermediateCommitDecision::DeferForOpenIndexedGroup => { + *self.as_mut().commits_deferred_for_open_group_mut() += 1; + } + IntermediateCommitDecision::NotDue => {} + } + let mut next_chunk_ids = vec![]; let discovered_chunk_ids = self @@ -788,58 +1665,27 @@ impl<'db> MultiStateSyncSession<'db> { if merk.is_empty_tree().unwrap() { return Ok(SubtreesMetadata::default()); } - let mut subtree_keys = BTreeSet::new(); + let mut subtree_elements: BTreeMap, Element> = BTreeMap::new(); let mut raw_iter = Element::iterator(merk.storage.raw_iter()).unwrap(); while let Some((key, value)) = raw_iter.next_element(grove_version).unwrap()? { if value.is_any_tree() { - // State sync does not yet support indexed trees. Their - // primaries commit a three-input `combine_hash_three` - // (vs. the two-input combine the restorer expects) and + // Indexed trees are discovered like any subtree; their + // primaries commit a three-input `combine_hash_three` and // carry secondary storage namespaces at - // `Blake3(prefix ‖ axis_tag)` that discovery never - // enumerates — so a chunk-based restore would fail - // midway with an opaque "chunk doesn't match expected - // root hash". Reject up-front here (the choke point - // where the decoded `Element` is available) with a - // descriptive error instead. - if value.is_indexed_tree() { - return Err(Error::NotSupported( - "state sync does not yet support indexed trees \ - (ProvableCountIndexedTree / ProvableSumIndexedTree / \ - ProvableCountProvableSumIndexedTree)" - .to_string(), - )); - } + // `Blake3(prefix ‖ axis_tag)` — see `indexed_sync`. // Non-Merk append-only trees (CommitmentTree / MmrTree / - // BulkAppendTree / DenseAppendOnlyFixedSizeTree) are - // discovered like any subtree; `add_subtree_sync_info` - // routes them to the entry-replay restore path instead of - // Merk chunk restore (see issue #785). - // - // Any other non-Merk data tree (PrivateDocumentStore, see - // issues #783 / #784) has no replay arm yet. An EMPTY one - // is fine — the Merk path transfers its (empty) Merk and - // the parent binding is already verified — but a populated - // one has no Merk nodes to chunk and would only fail - // opaquely in the source's chunk producer, so reject it - // up-front here where the decoded `Element` is available. - if value.uses_non_merk_data_storage() - && !element_supports_entry_replay(&value) - && value.non_merk_entry_count().unwrap_or(0) > 0 - { - return Err(Error::NotSupported(format!( - "state sync does not yet support populated {} subtrees \ - (non-Merk data storage without an entry-replay arm)", - value.type_str() - ))); - } - subtree_keys.insert(key.to_vec()); + // BulkAppendTree / DenseAppendOnlyFixedSizeTree / + // PrivateDocumentStore) are discovered like any subtree; + // `add_subtree_sync_info` routes them to the entry-replay + // restore path instead of Merk chunk restore (see issues + // #785 and #783 / #784). + subtree_elements.insert(key.to_vec(), value); } } let mut subtrees_metadata = SubtreesMetadata::new(); - for subtree_key in &subtree_keys { + for (subtree_key, element) in subtree_elements { let (elem_value, elem_value_hash) = merk .get_value_and_value_hash( subtree_key.as_slice(), @@ -861,21 +1707,101 @@ impl<'db> MultiStateSyncSession<'db> { let actual_value_hash = value_hash(&elem_value).unwrap(); let mut new_path = path_vec.to_vec(); - new_path.push(subtree_key.to_vec()); + new_path.push(subtree_key); let subtree_path: Vec<&[u8]> = new_path.iter().map(|vec| vec.as_slice()).collect(); let path: &[&[u8]] = &subtree_path; let prefix = RocksDbStorage::build_prefix(path.as_ref().into()).unwrap(); - subtrees_metadata.data.insert( - prefix, - (new_path.to_vec(), actual_value_hash, elem_value_hash), - ); + let entry = if element.is_indexed_tree() { + SubtreeMetadata::IndexedPrimary { + path: new_path, + actual_value_hash, + elem_value_hash, + element, + } + } else { + SubtreeMetadata::Ordinary { + path: new_path, + actual_value_hash, + elem_value_hash, + } + }; + subtrees_metadata.data.insert(prefix, entry); } Ok(subtrees_metadata) } + /// The order in which discovered subtrees are put in flight: axis + /// secondaries of in-flight indexed groups first, then everything + /// else in its natural [`SubtreePrefix`] order. + /// + /// The first tier is load-bearing, and it also reaches back into + /// `pending_discovered_subtrees` for secondaries parked there, + /// because a group's secondaries are routinely parked in the same + /// round its primary's descendants are discovered. + /// + /// Without it the order is raw `SubtreePrefix` order — Blake3 + /// digests, unrelated to the tree's shape. An indexed primary's + /// ordinary descendants then win activation rounds against the + /// group's secondaries purely by digest, while completing and + /// discovering yet more descendants that win the next round. The + /// group stays open the whole time, and an open group *correctly* + /// refuses every intermediate commit (its joint verification is the + /// only thing binding it to its parent, and it cannot run until every + /// member is restored). So the uncommitted write set grows to the + /// primary's entire descendancy no matter what payload budget the + /// caller set — the one thing [`RestoreCommitMode::Incremental`] + /// promises not to do. Resolving open groups first is what keeps + /// "open" a transient state rather than one the grove's shape can + /// extend indefinitely. + /// + /// Note this does not weaken the group-splitting guarantee: it makes + /// groups *close sooner*, and the commit guard in + /// [`Self::intermediate_commit_decision`] is untouched. + fn activation_order( + self: &mut Pin>>, + subtrees_metadata: SubtreesMetadata, + ) -> Vec<(SubtreePrefix, SubtreeMetadata)> { + fn is_secondary(metadata: &SubtreeMetadata) -> bool { + matches!(metadata, SubtreeMetadata::IndexedSecondary { .. }) + } + + // Secondaries parked in an earlier round outrank anything + // discovered since. + let mut ordered: Vec<(SubtreePrefix, SubtreeMetadata)> = Vec::new(); + let pending_slot = self.as_mut().pending_discovered_subtrees(); + let mut pending_drained = false; + if let Some(pending) = pending_slot.as_mut() { + let parked: Vec = pending + .data + .iter() + .filter(|(_, metadata)| is_secondary(metadata)) + .map(|(prefix, _)| *prefix) + .collect(); + for prefix in parked { + if let Some(metadata) = pending.data.remove(&prefix) { + ordered.push((prefix, metadata)); + } + } + pending_drained = pending.data.is_empty(); + } + if pending_drained { + // Never leave an empty-but-present pending batch behind: + // `is_sync_completed` reads it as "still work to do". + *pending_slot = None; + } + + let (secondaries, rest): (Vec<_>, Vec<_>) = subtrees_metadata + .data + .into_iter() + .partition(|(_, metadata)| is_secondary(metadata)); + ordered.extend(secondaries); + ordered.extend(rest); + ordered + } + /// Prepares a synchronization session for the newly discovered subtrees and /// returns the global chunk IDs of those subtrees. /// @@ -915,26 +1841,76 @@ impl<'db> MultiStateSyncSession<'db> { grove_version: &GroveVersion, ) -> Result>, Error> { let mut res = vec![]; - - for (prefix, prefix_metadata) in &subtrees_metadata.data { - if !self.processed_prefixes.contains(prefix) - && !self.current_prefixes.contains_key(prefix) + // Bounded-memory mode caps how many subtrees may be part-restored + // at once; the overflow goes back to `pending_discovered_subtrees` + // and is activated at a later drained boundary. Without this the + // cap would be advisory only -- a parent's whole fan-out is + // discovered in one go, and this is the only place it is turned + // into live restorers. + let mut free_slots = self.free_in_flight_slots(); + let mut deferred = SubtreesMetadata::new(); + + for (prefix, prefix_metadata) in self.activation_order(subtrees_metadata) { + if self.processed_prefixes.contains(&prefix) + || self.current_prefixes.contains_key(&prefix) { - let (current_path, actual_value_hash, elem_value_hash) = &prefix_metadata; - - let subtree_path: Vec<&[u8]> = - current_path.iter().map(|vec| vec.as_slice()).collect(); - let path: &[&[u8]] = &subtree_path; - - let next_chunks_ids = self.add_subtree_sync_info( - path.into(), - *elem_value_hash, - Some(*actual_value_hash), - *prefix, + continue; + } + if let Some(slots) = free_slots.as_mut() { + if *slots == 0 { + deferred.data.insert(prefix, prefix_metadata); + continue; + } + *slots -= 1; + } + let next_chunks_ids = match prefix_metadata { + SubtreeMetadata::Ordinary { + path, + actual_value_hash, + elem_value_hash, + } => { + let subtree_path: Vec<&[u8]> = path.iter().map(|vec| vec.as_slice()).collect(); + let path_ref: &[&[u8]] = &subtree_path; + + self.add_subtree_sync_info( + path_ref.into(), + elem_value_hash, + Some(actual_value_hash), + prefix, + grove_version, + )? + } + SubtreeMetadata::IndexedPrimary { + path, + actual_value_hash, + elem_value_hash, + element, + } => self.add_indexed_primary_sync_info( + path, + elem_value_hash, + actual_value_hash, + element, + prefix, grove_version, - )?; + )?, + SubtreeMetadata::IndexedSecondary { + primary_prefix, + axis_tag, + } => self.add_indexed_secondary_sync_info( + prefix, + primary_prefix, + axis_tag, + grove_version, + )?, + }; + + res.push(next_chunks_ids); + } - res.push(next_chunks_ids); + if !deferred.data.is_empty() { + match self.as_mut().pending_discovered_subtrees() { + None => *self.as_mut().pending_discovered_subtrees() = Some(deferred), + Some(pending) => pending.data.extend(deferred.data), } } @@ -942,21 +1918,53 @@ impl<'db> MultiStateSyncSession<'db> { } } +/// Metadata for one discovered subtree awaiting synchronization. +/// +/// The `actual_value_hash` (`value_hash(element_bytes)`) and +/// `elem_value_hash` (the element value hash the parent Merk committed to) +/// are required to verify the integrity of the newly constructed subtree +/// after synchronization. +pub enum SubtreeMetadata { + /// An ordinary subtree — including the non-Merk entry-replay family. + Ordinary { + /// The path of the subtree in GroveDB. + path: Vec>, + /// The subtree's actual value hash in the parent. + actual_value_hash: CryptoHash, + /// The subtree's element value hash in the parent. + elem_value_hash: CryptoHash, + }, + /// An indexed-tree primary. Carries the decoded element so the axis + /// tags and secondary root keys survive to session setup. + IndexedPrimary { + /// The path of the primary subtree in GroveDB. + path: Vec>, + /// The subtree's actual value hash in the parent. + actual_value_hash: CryptoHash, + /// The subtree's (three-input) element value hash in the parent. + elem_value_hash: CryptoHash, + /// The decoded indexed-tree element. + element: Element, + }, + /// One axis secondary of an indexed group, emitted when the group's + /// header arrives; everything else needed lives on the registered + /// group. + IndexedSecondary { + /// Prefix of the owning primary. + primary_prefix: SubtreePrefix, + /// The axis this secondary indexes. + axis_tag: u8, + }, +} + /// Struct containing metadata about the current subtrees found in GroveDB. /// This metadata is used during the state synchronization process to track /// discovered subtrees and verify their integrity after they are constructed. pub struct SubtreesMetadata { - /// A map where: - /// - **Key**: `SubtreePrefix` (the path digest of the subtree). - /// - **Value**: A tuple containing: - /// - `Vec>`: The actual path of the subtree in GroveDB. - /// - `CryptoHash`: The parent subtree's actual value hash. - /// - `CryptoHash`: The parent subtree's element value hash. - /// - /// The `parent subtree actual_value_hash` and `parent subtree - /// elem_value_hash` are required to verify the integrity of the newly - /// constructed subtree after synchronization. - pub data: BTreeMap>, CryptoHash, CryptoHash)>, + /// Discovered subtrees pending sync, keyed by their `SubtreePrefix` + /// (the path digest of the subtree, or the derived secondary prefix + /// for indexed-axis secondaries). + pub data: BTreeMap, } impl SubtreesMetadata { @@ -976,15 +1984,78 @@ impl Default for SubtreesMetadata { impl fmt::Debug for SubtreesMetadata { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { for (prefix, metadata) in self.data.iter() { - let metadata_path = &metadata.0; - let metadata_path_str = path_to_string(metadata_path); - writeln!( - f, - " prefix:{:?} -> path:{:?}", - hex::encode(prefix), - metadata_path_str, - )?; + match metadata { + SubtreeMetadata::Ordinary { path, .. } => { + writeln!( + f, + " prefix:{:?} -> path:{:?}", + hex::encode(prefix), + path_to_string(path), + )?; + } + SubtreeMetadata::IndexedPrimary { path, .. } => { + writeln!( + f, + " prefix:{:?} -> indexed primary path:{:?}", + hex::encode(prefix), + path_to_string(path), + )?; + } + SubtreeMetadata::IndexedSecondary { + primary_prefix, + axis_tag, + } => { + writeln!( + f, + " prefix:{:?} -> indexed secondary (axis {axis_tag}) of primary:{:?}", + hex::encode(prefix), + hex::encode(primary_prefix), + )?; + } + } } Ok(()) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn subtrees_metadata_debug_lists_every_variant() { + let mut metadata = SubtreesMetadata::new(); + metadata.data.insert( + [1u8; 32], + SubtreeMetadata::Ordinary { + path: vec![b"a".to_vec()], + actual_value_hash: [0u8; 32], + elem_value_hash: [0u8; 32], + }, + ); + metadata.data.insert( + [2u8; 32], + SubtreeMetadata::IndexedPrimary { + path: vec![b"a".to_vec(), b"pcit".to_vec()], + actual_value_hash: [0u8; 32], + elem_value_hash: [0u8; 32], + element: Element::empty_provable_count_indexed_tree(), + }, + ); + metadata.data.insert( + [3u8; 32], + SubtreeMetadata::IndexedSecondary { + primary_prefix: [2u8; 32], + axis_tag: 1, + }, + ); + let rendered = format!("{metadata:?}"); + assert!(rendered.contains(" -> path:"), "{rendered}"); + assert!(rendered.contains("indexed primary path:"), "{rendered}"); + assert!( + rendered.contains("indexed secondary (axis 1) of primary:"), + "{rendered}" + ); + assert_eq!(rendered.lines().count(), 3, "{rendered}"); + } +} diff --git a/grovedb/src/replication/verify.rs b/grovedb/src/replication/verify.rs new file mode 100644 index 000000000..d9db09bea --- /dev/null +++ b/grovedb/src/replication/verify.rs @@ -0,0 +1,230 @@ +//! Bind restored element bytes to the value hashes authenticated by Merk chunks. +//! +//! References can point into subtrees that arrive later, so their bindings +//! must be checked after discovery completes and before the final commit. + +use std::collections::HashSet; + +use grovedb_merk::{ + element::costs::ElementCostExtensions, + tree::{combine_hash, kv::ValueDefinedCostType, value_hash, TreeNode}, + Merk, +}; +use grovedb_storage::{rocksdb_storage::RocksDbStorage, RawIterator, Storage, StorageContext}; +use grovedb_version::version::GroveVersion; + +use crate::{ + operations::{ + get::MAX_REFERENCE_HOPS, + indexed_tree::{axis_secondary_tree_type, decode_axis_row_reference, indexed_element_axes}, + }, + reference_path::{path_from_reference_path_type, path_from_reference_qualified_path_type}, + Element, Error, GroveDb, Transaction, +}; + +/// Visit one stored node at a time without loading the whole Merk into its +/// cache or retaining a collection of reference rows. +fn visit_nodes<'db, S: StorageContext<'db>>( + merk: &Merk, + grove_version: &GroveVersion, + mut visit: impl FnMut(&TreeNode) -> Result<(), Error>, +) -> Result<(), Error> { + let mut iter = merk.storage.raw_iter(); + iter.seek_to_first().unwrap(); + while iter.valid().unwrap() { + let key = iter.key().unwrap().ok_or_else(|| { + Error::CorruptedData("missing restored node key during verification".to_string()) + })?; + let bytes = iter.value().unwrap().ok_or_else(|| { + Error::CorruptedData("missing restored node during verification".to_string()) + })?; + let node = TreeNode::decode( + key.to_vec(), + bytes, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .map_err(|e| Error::CorruptedData(format!("cannot decode restored node: {e}")))?; + visit(&node)?; + iter.next().unwrap(); + } + Ok(()) +} + +impl GroveDb { + /// Resolve without the query API's terminal-wrapper removal. Batch + /// references authenticate the stored terminal, including its wrapper. + fn restored_reference_target( + &self, + mut path: Vec>, + transaction: &Transaction, + grove_version: &GroveVersion, + ) -> Result { + let mut visited = HashSet::new(); + for _ in 0..MAX_REFERENCE_HOPS { + if !visited.insert(path.clone()) { + return Err(Error::CyclicReference); + } + let (key, parent) = path + .split_last() + .ok_or(Error::CorruptedPath("empty reference path".to_string()))?; + let element = self + .get_raw_caching_optional( + parent.into(), + key, + false, + Some(transaction), + grove_version, + ) + .value?; + match element.underlying() { + Element::Reference(reference_path, ..) + | Element::ReferenceWithSumItem(reference_path, ..) => { + path = path_from_reference_qualified_path_type(reference_path.clone(), &path)?; + } + _ => return Ok(element), + } + } + Err(Error::ReferenceLimit) + } + + pub(super) fn verify_restored_value_hashes( + &self, + transaction: &Transaction, + grove_version: &GroveVersion, + ) -> Result<(), Error> { + // Only the subtree frontier is retained. Neither item count nor + // reference count determines the memory used by this pass. + let mut pending = vec![(Vec::>::new(), None::)]; + while let Some((path, indexed_element)) = pending.pop() { + let merk = self + .open_transactional_merk_at_path( + path.as_slice().into(), + transaction, + None, + grove_version, + ) + .value?; + + visit_nodes(&merk, grove_version, |node| { + let bytes = node.value_as_slice(); + let element = Element::deserialize(bytes, grove_version)?; + let actual_value_hash = value_hash(bytes).unwrap(); + let expected_value_hash = match element.underlying() { + Element::Reference(reference_path, ..) + | Element::ReferenceWithSumItem(reference_path, ..) => { + let target_path = path_from_reference_path_type( + reference_path.clone(), + &path, + Some(node.key()), + )?; + let target = self.restored_reference_target( + target_path, + transaction, + grove_version, + )?; + let target_hash = value_hash(&target.serialize(grove_version)?).unwrap(); + let combined = combine_hash(&actual_value_hash, &target_hash).unwrap(); + if combined != *node.value_hash() + && matches!( + target, + Element::NonCounted(_) + | Element::NotSummed(_) + | Element::NotCountedOrSummed(_) + ) + { + // Direct inserts use follow_reference, which strips + // the terminal wrapper before hashing. Both write + // paths exist on disk; verify that binding too, + // rather than changing either path's consensus hash. + let unwrapped_hash = + value_hash(&target.underlying().serialize(grove_version)?).unwrap(); + combine_hash(&actual_value_hash, &unwrapped_hash).unwrap() + } else { + combined + } + } + _ if element.element_type().has_simple_value_hash() => actual_value_hash, + _ if element.is_any_tree() => { + // Child restores (including non-Merk replay and + // indexed group finalization) already verified the + // combined binding of these bytes and child roots. + if !element.uses_non_merk_data_storage() { + let mut child_path = path.clone(); + child_path.push(node.key().to_vec()); + let indexed = element.is_indexed_tree().then_some(element); + pending.push((child_path, indexed)); + } + return Ok(()); + } + _ => { + return Err(Error::CorruptedData( + "unsupported restored element value hash".to_string(), + )); + } + }; + if expected_value_hash != *node.value_hash() { + return Err(Error::CorruptedData(format!( + "restored element value hash mismatch at path {:?}, key {}", + super::utils::path_to_string(&path), + hex::encode(node.key()), + ))); + } + Ok(()) + })?; + + let Some(element) = indexed_element else { + continue; + }; + let primary_prefix = RocksDbStorage::build_prefix(path.as_slice().into()).unwrap(); + for (axis, root_key) in indexed_element_axes(&element)? { + let prefix = + RocksDbStorage::secondary_prefix_for(&primary_prefix, axis.tag()).unwrap(); + let storage = self + .db + .get_immediate_storage_context_by_subtree_prefix(prefix, transaction) + .unwrap(); + let secondary = Merk::open_layered_with_root_key( + storage, + root_key, + axis_secondary_tree_type(axis), + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .value + .map_err(Error::MerkError)?; + visit_nodes(&secondary, grove_version, |node| { + let row = Element::deserialize(node.value_as_slice(), grove_version)?; + let (target_key, _) = + decode_axis_row_reference(&row, "state sync value hash verification")?; + // Index rows bind the immediate primary node, unlike + // ordinary references, which bind the terminal item. + let target_hash = merk + .get_value_hash( + target_key, + false, + Some(&Element::value_defined_cost_for_serialized_value), + grove_version, + ) + .value + .map_err(Error::MerkError)? + .ok_or_else(|| { + Error::CorruptedData( + "restored index reference has no primary target".to_string(), + ) + })?; + let actual = + combine_hash(&value_hash(node.value_as_slice()).unwrap(), &target_hash) + .unwrap(); + if actual != *node.value_hash() { + return Err(Error::CorruptedData( + "restored index reference value hash mismatch".to_string(), + )); + } + Ok(()) + })?; + } + } + Ok(()) + } +} diff --git a/grovedb/src/tests/mod.rs b/grovedb/src/tests/mod.rs index 8257625f2..845147dae 100644 --- a/grovedb/src/tests/mod.rs +++ b/grovedb/src/tests/mod.rs @@ -98,8 +98,13 @@ mod query_result_type_tests; mod read_mode_gate_tests; mod reference_path_tests; mod reference_with_sum_item_tests; +mod replication_checkpoint_prune_tests; +mod replication_fuzz_tests; +mod replication_incremental_commit_tests; +mod replication_scale_tests; mod replication_session_tests; mod replication_utils_tests; +mod replication_version_tests; mod run_path_query_tests; mod snapshot_read_transaction_tests; mod succinctness_gap_test; diff --git a/grovedb/src/tests/replication_checkpoint_prune_tests.rs b/grovedb/src/tests/replication_checkpoint_prune_tests.rs new file mode 100644 index 000000000..16c49f0b2 --- /dev/null +++ b/grovedb/src/tests/replication_checkpoint_prune_tests.rs @@ -0,0 +1,335 @@ +//! What happens to an in-flight sync when the source prunes the snapshot +//! underneath it. +//! +//! At the GroveDB layer a "snapshot" is just a RocksDB checkpoint: a plain +//! directory of hard-linked SSTs, with no pin, lease, or refcount tying it +//! to the sessions reading it. Nothing here stops an operator (or a +//! snapshot-retention policy above this layer) from deleting that +//! directory while a slow consumer is still fetching from it, so the +//! question these tests answer is what the source does next. +//! +//! The safety property is narrow and absolute: the source may keep +//! serving, or it may start failing, but it must never serve a chunk that +//! restores to something other than the app hash the sync was offered. +//! Both permitted outcomes are asserted; the one that actually occurs is +//! then pinned exactly, so a change in behaviour shows up as a test +//! failure rather than as a silent difference in production. + +#[cfg(test)] +mod tests { + use std::{collections::VecDeque, path::Path}; + + use grovedb_version::version::GroveVersion; + use tempfile::TempDir; + + use crate::{ + replication::CURRENT_STATE_SYNC_VERSION, + tests::{make_empty_grovedb, make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, GroveDb, + }; + + /// A source with enough subtrees and entries that the fetch/apply loop + /// takes many round trips — the deletion has to land *mid-sync* to + /// probe anything. + fn multi_round_trip_source(grove_version: &GroveVersion) -> TempGroveDb { + let source = make_test_grovedb(grove_version); + for t in 0u8..8 { + let name = [b's', t]; + source + .insert( + [TEST_LEAF].as_ref(), + &name, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert subtree"); + for i in 0u16..200 { + source + .insert( + [TEST_LEAF, &name].as_ref(), + &i.to_be_bytes(), + Element::new_item(vec![t; 64]), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + } + } + source + } + + /// What a sync did when the checkpoint vanished under it. + #[derive(Debug, PartialEq, Eq)] + enum PruneOutcome { + /// The source kept serving from its open file handles and the sync + /// finished with the correct app hash. + CompletedCorrectly, + /// The source (or the client) failed cleanly. + Failed(String), + } + + /// Sync `dest` from the checkpoint at `checkpoint_path`, deleting that + /// directory after `delete_after` successful fetches. + fn sync_deleting_checkpoint_midway( + checkpoint_db: &GroveDb, + checkpoint_path: &Path, + dest: &TempGroveDb, + app_hash: [u8; 32], + delete_after: usize, + grove_version: &GroveVersion, + ) -> PruneOutcome { + let mut session = match dest.start_snapshot_syncing( + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) { + Ok(session) => session, + Err(e) => return PruneOutcome::Failed(format!("{e}")), + }; + + let mut queue: VecDeque> = VecDeque::new(); + queue.push_back(app_hash.to_vec()); + let mut fetches = 0usize; + let mut deleted = false; + + while let Some(chunk_id) = queue.pop_front() { + let chunk = match checkpoint_db.fetch_chunk( + &chunk_id, + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) { + Ok(chunk) => chunk, + Err(e) => return PruneOutcome::Failed(format!("{e}")), + }; + fetches += 1; + if !deleted && fetches >= delete_after { + std::fs::remove_dir_all(checkpoint_path) + .expect("the checkpoint directory should be removable while open"); + assert!( + !checkpoint_path.exists(), + "the checkpoint directory should be gone" + ); + deleted = true; + } + match session.apply_chunk(&chunk_id, &chunk, CURRENT_STATE_SYNC_VERSION, grove_version) + { + Ok(more) => queue.extend(more), + Err(e) => return PruneOutcome::Failed(format!("{e}")), + } + } + + assert!(deleted, "the probe never reached the deletion point"); + if !session.is_sync_completed() { + return PruneOutcome::Failed("sync did not complete".to_string()); + } + match dest.commit_session(session, grove_version) { + Ok(()) => PruneOutcome::CompletedCorrectly, + Err(e) => PruneOutcome::Failed(format!("{e}")), + } + } + + /// Deleting the checkpoint directory between `fetch_chunk` calls does + /// not disturb an in-flight sync. + /// + /// This is the POSIX unlink semantics both macOS and Linux give: the + /// open `GroveDb` holds descriptors to every SST it needs, and an + /// unlinked file stays readable through an open descriptor until the + /// last one closes. The directory entry is gone (see + /// `reopening_a_pruned_checkpoint_silently_creates_an_empty_grove` for + /// what a *fresh* open of that path does instead), but the + /// already-open source keeps serving correct chunks, so the sync + /// completes and the restored root hash matches. + /// + /// The operational consequence, and the reason this is pinned rather + /// than assumed: pruning a snapshot directory is **not** a way to cut + /// off a slow consumer. Whatever holds the source `GroveDb` open has + /// to be dropped for the disk space to come back or for the peer to + /// be disconnected. + #[test] + fn deleting_the_checkpoint_mid_sync_keeps_serving_correct_chunks() { + let grove_version = GroveVersion::latest(); + let source = multi_round_trip_source(grove_version); + + let dir = TempDir::new().expect("temp dir"); + let checkpoint_path = dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint db"); + let app_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .expect("checkpoint root hash"); + assert_eq!( + app_hash, + source.root_hash(None, grove_version).unwrap().unwrap() + ); + + let dest = make_empty_grovedb(); + let outcome = sync_deleting_checkpoint_midway( + &checkpoint_db, + &checkpoint_path, + &dest, + app_hash, + 2, + grove_version, + ); + + assert_eq!( + outcome, + PruneOutcome::CompletedCorrectly, + "an open source keeps serving from unlinked SSTs; if this now fails, the \ + behaviour changed and the failure must still be clean (never a wrong root hash)" + ); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + app_hash, + "the restored root hash must equal the offered app hash" + ); + let issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("destination verify_grovedb should run"); + assert!(issues.is_empty(), "got: {issues:?}"); + } + + /// The other half of the same fact, and the sharp edge in it. + /// + /// A source that opens its snapshot **lazily, per request** does not + /// fail closed on a pruned checkpoint: `GroveDb::open` runs with + /// RocksDB's `create_if_missing`, so opening the now-nonexistent path + /// silently creates a brand-new **empty** grove there. Such a source + /// would then answer chunk requests for the empty grove rather than + /// erroring. + /// + /// That is not a corruption hole — the client offered a specific + /// `app_hash`, and an empty grove's root hash is not it, so the sync + /// fails — but it is a silent recreation, and any caller that treats + /// "the snapshot directory opened fine" as "the snapshot is still + /// there" is wrong. Callers must hold the source `GroveDb` open for + /// the life of a sync (which is what the tutorial pattern and + /// `run_sync` do) rather than reopening it by path. + #[test] + fn reopening_a_pruned_checkpoint_silently_creates_an_empty_grove() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"k", + Element::new_item(b"v".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + + let dir = TempDir::new().expect("temp dir"); + let checkpoint_path = dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let app_hash = GroveDb::open(&checkpoint_path) + .expect("open checkpoint db") + .root_hash(None, grove_version) + .unwrap() + .expect("checkpoint root hash"); + + std::fs::remove_dir_all(&checkpoint_path).expect("remove checkpoint"); + + let reopened = GroveDb::open(&checkpoint_path) + .expect("create_if_missing means a pruned path still 'opens'"); + let reopened_hash = reopened + .root_hash(None, grove_version) + .unwrap() + .expect("root hash of the recreated grove"); + assert_ne!( + reopened_hash, app_hash, + "the recreated grove must not claim the pruned snapshot's app hash" + ); + assert_eq!( + reopened_hash, + make_empty_grovedb() + .root_hash(None, grove_version) + .unwrap() + .unwrap(), + "the reopened path is an empty grove, not the pruned snapshot" + ); + + // And the client-side protection: a session offered the original + // app hash cannot be satisfied from that empty grove. + let dest = make_empty_grovedb(); + let outcome = { + let mut session = dest + .start_snapshot_syncing(app_hash, 64, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("start session"); + reopened + .fetch_chunk(&app_hash, None, CURRENT_STATE_SYNC_VERSION, grove_version) + .and_then(|chunk| { + session + .apply_chunk(&app_hash, &chunk, CURRENT_STATE_SYNC_VERSION, grove_version) + .map(|_| ()) + }) + }; + assert!( + outcome.is_err(), + "an empty grove must not be able to answer for another grove's app hash" + ); + } + + /// A pruned checkpoint whose source handle is then dropped: the + /// destination of a sync that never committed is untouched. Pins that + /// an abandoned session leaves nothing behind, which is what makes + /// retrying against another peer safe. + #[test] + fn an_abandoned_sync_against_a_pruned_checkpoint_leaves_the_destination_empty() { + let grove_version = GroveVersion::latest(); + let source = multi_round_trip_source(grove_version); + + let dir = TempDir::new().expect("temp dir"); + let checkpoint_path = dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint db"); + let app_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .expect("checkpoint root hash"); + + let dest = make_empty_grovedb(); + let before = dest.root_hash(None, grove_version).unwrap().unwrap(); + + { + let mut session = dest + .start_snapshot_syncing(app_hash, 64, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("start session"); + let chunk = checkpoint_db + .fetch_chunk(&app_hash, None, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("first chunk"); + session + .apply_chunk(&app_hash, &chunk, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("apply first chunk"); + std::fs::remove_dir_all(&checkpoint_path).expect("prune the checkpoint"); + // Session dropped here without committing. + } + drop(checkpoint_db); + + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + before, + "an abandoned sync must leave the destination root hash unchanged" + ); + let issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("destination verify_grovedb should run"); + assert!(issues.is_empty(), "got: {issues:?}"); + } +} diff --git a/grovedb/src/tests/replication_fuzz_tests.rs b/grovedb/src/tests/replication_fuzz_tests.rs new file mode 100644 index 000000000..092ce3d84 --- /dev/null +++ b/grovedb/src/tests/replication_fuzz_tests.rs @@ -0,0 +1,725 @@ +//! Adversarial-input tests for the untrusted state-sync decode surfaces. +//! +//! Every function exercised here parses bytes a *remote peer* chose. A +//! syncing node hands them to the decoder before anything has been +//! verified, so the decoders carry three obligations, and each gets a +//! property here: +//! +//! 1. **No panic on any input.** A panic in a decoder is a remote crash of +//! a syncing node. +//! 2. **Output bounded by input.** A decoder must never turn `n` wire +//! bytes into materially more than `n` bytes of payload — otherwise a +//! small message is an allocation bomb. (The bound is on *payload* +//! bytes: `unpack_nested_bytes` documents that `Vec` headers are a +//! constant factor on top, which is why the transport still owns the +//! absolute message-size cap.) +//! 3. **Canonical framing.** Every one of these decoders rejects trailing +//! bytes and length mismatches, so `encode(decode(x)) == x` must hold +//! whenever `decode` succeeds. That is strictly stronger than +//! `decode(encode(v)) == v`: it also rules out two distinct encodings +//! of the same value, which is what lets per-chunk identity checks +//! upstream be byte comparisons. +//! +//! Random bytes almost never reach the deep branches of a structured +//! decoder, so each surface is fuzzed twice: with unstructured bytes, and +//! with a *valid encoding whose bytes have been mutated* — the input class +//! a byzantine peer actually produces. + +#[cfg(test)] +mod tests { + use grovedb_merk::{ + tree::hash::{axes_digest, combine_hash_three, CryptoHash}, + tree_type::TreeType, + }; + use proptest::prelude::*; + + use crate::{ + replication::{ + indexed_sync::{ + is_indexed_header_request, verify_indexed_binding, IndexedHeader, + IndexedHeaderRequest, + }, + non_merk_sync::{ + decode_non_merk_page, encode_non_merk_page, NonMerkChunkId, MAX_PAGE_BYTES, + MAX_PAGE_ENTRIES, + }, + utils::{ + decode_global_chunk_id, encode_global_chunk_id, pack_nested_bytes, + unpack_nested_bytes, + }, + }, + Element, + }; + + /// A fixed app hash for `decode_global_chunk_id`, which short-circuits + /// on an input equal to the app hash. No generated input can collide + /// with this by accident. + const FUZZ_APP_HASH: [u8; 32] = [0xAB; 32]; + + // ── Strategies ────────────────────────────────────────────────────── + + /// Unstructured peer bytes. + fn arbitrary_bytes() -> impl Strategy> { + prop::collection::vec(any::(), 0..512) + } + + /// Apply `mutations` single-byte edits (splice / truncate / flip) to + /// `bytes`. This is what turns a valid encoding into the near-miss + /// inputs that reach a decoder's interior error branches. + fn mutate(mut bytes: Vec, mutations: Vec<(usize, u8, u8)>) -> Vec { + for (raw_index, kind, value) in mutations { + match kind % 3 { + 0 if !bytes.is_empty() => { + let i = raw_index % bytes.len(); + bytes[i] = value; + } + 1 => { + let i = raw_index % (bytes.len() + 1); + bytes.insert(i, value); + } + _ if !bytes.is_empty() => { + let i = raw_index % bytes.len(); + bytes.remove(i); + } + _ => {} + } + } + bytes + } + + fn mutations() -> impl Strategy> { + prop::collection::vec((any::(), any::(), any::()), 0..4) + } + + fn hash_strategy() -> impl Strategy { + any::<[u8; 32]>() + } + + fn indexed_header_strategy() -> impl Strategy { + ( + hash_strategy(), + prop::collection::vec((0u8..3, hash_strategy()), 1..=3), + ) + .prop_map(|(primary_root_hash, axes)| IndexedHeader { + primary_root_hash, + axes, + }) + } + + fn indexed_header_request_strategy() -> impl Strategy { + prop::collection::vec( + ( + 0u8..3, + prop::option::of(prop::collection::vec(any::(), 1..48)), + ), + 1..=3, + ) + .prop_map(|axes| IndexedHeaderRequest { axes }) + } + + fn nested_bytes_strategy() -> impl Strategy>> { + prop::collection::vec(prop::collection::vec(any::(), 0..40), 0..12) + } + + // ── Shared assertions ─────────────────────────────────────────────── + + /// The payload bytes `unpack_nested_bytes` hands back must be exactly + /// framed by the input: 4 count bytes, then 4 length bytes plus the + /// payload per element, with nothing left over. + fn assert_unpack_is_bounded_and_exact( + input: &[u8], + parts: &[Vec], + ) -> Result<(), TestCaseError> { + let payload: usize = parts.iter().map(Vec::len).sum(); + prop_assert!( + payload <= input.len(), + "unpacked {payload} payload bytes from a {}-byte input", + input.len() + ); + prop_assert!( + parts.len() <= input.len().saturating_sub(4) / 4, + "unpacked {} elements from a {}-byte input", + parts.len(), + input.len() + ); + prop_assert_eq!( + 4 + 4 * parts.len() + payload, + input.len(), + "framing must consume the input exactly" + ); + Ok(()) + } + + // ── `unpack_nested_bytes` ─────────────────────────────────────────── + + proptest! { + /// Arbitrary bytes: never panics, and any success is exactly + /// framed, input-bounded, and re-encodes to the same bytes. + #[test] + fn unpack_nested_bytes_is_total_and_canonical(input in arbitrary_bytes()) { + if let Ok(parts) = unpack_nested_bytes(&input) { + assert_unpack_is_bounded_and_exact(&input, &parts)?; + prop_assert_eq!( + pack_nested_bytes(parts).unwrap(), + input, + "a successful decode must re-encode to the same bytes" + ); + } + } + + /// The same, driven by mutated valid packings so the interior + /// error branches (declared count, per-element length, trailing + /// bytes) are actually reached. + #[test] + fn unpack_nested_bytes_survives_mutated_packings( + parts in nested_bytes_strategy(), + muts in mutations(), + ) { + let input = mutate(pack_nested_bytes(parts).unwrap(), muts); + if let Ok(parts) = unpack_nested_bytes(&input) { + assert_unpack_is_bounded_and_exact(&input, &parts)?; + } + } + + /// Forward round trip: every value survives encode → decode. + #[test] + fn pack_nested_bytes_round_trips(parts in nested_bytes_strategy()) { + let encoded = pack_nested_bytes(parts.clone()).unwrap(); + prop_assert_eq!(unpack_nested_bytes(&encoded).unwrap(), parts); + } + } + + // ── `decode_global_chunk_id` ──────────────────────────────────────── + + /// Valid global chunk ids, from which mutants are derived. + fn global_chunk_id_strategy() -> impl Strategy> { + ( + any::<[u8; 32]>(), + prop::option::of(prop::collection::vec(any::(), 1..40)), + 0u8..=16, + nested_bytes_strategy(), + ) + .prop_map(|(prefix, root_key, tree_byte, chunk_ids)| { + let tree_type = TreeType::try_from(tree_byte).expect("0..=16 are all valid"); + encode_global_chunk_id(prefix, root_key, tree_type, chunk_ids) + .expect("components are in range") + }) + } + + fn assert_global_chunk_id_bounded( + input: &[u8], + decoded: &crate::replication::ChunkIdentifier, + ) -> Result<(), TestCaseError> { + let (_, root_key, _, nested) = decoded; + let root_key_len = root_key.as_ref().map_or(0, Vec::len); + let nested_len: usize = nested.iter().map(Vec::len).sum(); + prop_assert!( + root_key_len + nested_len <= input.len(), + "decoded {} payload bytes from a {}-byte chunk id", + root_key_len + nested_len, + input.len() + ); + prop_assert!( + nested.len() <= input.len() / 4, + "decoded {} nested chunk ids from a {}-byte chunk id", + nested.len(), + input.len() + ); + Ok(()) + } + + proptest! { + #[test] + fn decode_global_chunk_id_is_total_and_canonical(input in arbitrary_bytes()) { + prop_assume!(input.as_slice() != FUZZ_APP_HASH.as_slice()); + if let Ok(decoded) = decode_global_chunk_id(&input, &FUZZ_APP_HASH) { + assert_global_chunk_id_bounded(&input, &decoded)?; + let (prefix, root_key, tree_type, nested) = decoded; + prop_assert_eq!( + encode_global_chunk_id(prefix, root_key, tree_type, nested).unwrap(), + input, + "a successful decode must re-encode to the same bytes" + ); + } + } + + #[test] + fn decode_global_chunk_id_survives_mutated_ids( + valid in global_chunk_id_strategy(), + muts in mutations(), + ) { + let input = mutate(valid, muts); + prop_assume!(input.as_slice() != FUZZ_APP_HASH.as_slice()); + if let Ok(decoded) = decode_global_chunk_id(&input, &FUZZ_APP_HASH) { + assert_global_chunk_id_bounded(&input, &decoded)?; + } + } + + /// Every well-formed chunk id decodes back to its components. + #[test] + fn global_chunk_id_round_trips(valid in global_chunk_id_strategy()) { + prop_assume!(valid.as_slice() != FUZZ_APP_HASH.as_slice()); + let (prefix, root_key, tree_type, nested) = + decode_global_chunk_id(&valid, &FUZZ_APP_HASH).expect("valid id decodes"); + prop_assert_eq!( + encode_global_chunk_id(prefix, root_key, tree_type, nested).unwrap(), + valid + ); + } + } + + // ── `IndexedHeader` / `IndexedHeaderRequest` ──────────────────────── + + proptest! { + #[test] + fn indexed_header_decode_is_total_and_canonical(input in arbitrary_bytes()) { + if let Ok(header) = IndexedHeader::decode(&input) { + prop_assert!((1..=3).contains(&header.axes.len())); + prop_assert_eq!(input.len(), 33 + 33 * header.axes.len()); + prop_assert_eq!(header.encode(), input); + } + } + + #[test] + fn indexed_header_survives_mutated_encodings( + header in indexed_header_strategy(), + muts in mutations(), + ) { + let input = mutate(header.encode(), muts); + if let Ok(decoded) = IndexedHeader::decode(&input) { + prop_assert!((1..=3).contains(&decoded.axes.len())); + prop_assert_eq!(decoded.encode(), input); + } + } + + #[test] + fn indexed_header_round_trips(header in indexed_header_strategy()) { + prop_assert_eq!(IndexedHeader::decode(&header.encode()).unwrap(), header); + } + + #[test] + fn indexed_header_request_decode_is_total_and_canonical(input in arbitrary_bytes()) { + if let Ok(request) = IndexedHeaderRequest::decode(&input) { + prop_assert!((1..=3).contains(&request.axes.len())); + let key_bytes: usize = request + .axes + .iter() + .map(|(_, key)| key.as_ref().map_or(0, Vec::len)) + .sum(); + prop_assert!(key_bytes <= input.len()); + // Anything that decodes as a header request must also be + // recognised as one — otherwise `fetch_chunk` would route + // it to the Merk chunk producer instead. + prop_assert!(is_indexed_header_request(&input)); + prop_assert_eq!(request.encode(), input); + } + } + + #[test] + fn indexed_header_request_survives_mutated_encodings( + request in indexed_header_request_strategy(), + muts in mutations(), + ) { + let input = mutate(request.encode(), muts); + if let Ok(decoded) = IndexedHeaderRequest::decode(&input) { + prop_assert!((1..=3).contains(&decoded.axes.len())); + prop_assert_eq!(decoded.encode(), input); + } + } + + #[test] + fn indexed_header_request_round_trips(request in indexed_header_request_strategy()) { + let encoded = request.encode(); + prop_assert!(is_indexed_header_request(&encoded)); + prop_assert_eq!(IndexedHeaderRequest::decode(&encoded).unwrap(), request); + } + + /// A Merk traversal instruction (only `0x00` / `0x01` bytes) must + /// never be mistaken for a header request — the disambiguation the + /// marker byte exists for. + #[test] + fn traversal_instructions_are_never_header_requests( + instruction in prop::collection::vec(0u8..=1, 0..24), + ) { + prop_assert!(!is_indexed_header_request(&instruction)); + } + } + + // ── `NonMerkChunkId` and non-Merk pages ───────────────────────────── + + fn non_merk_page_strategy() -> impl Strategy> { + ( + any::(), + prop::collection::vec(any::(), 0..32), + prop::collection::vec(prop::collection::vec(any::(), 0..24), 0..8), + ) + .prop_map(|(more, aux, entries)| { + encode_non_merk_page(more, aux, entries).expect("page encodes") + }) + } + + proptest! { + #[test] + fn non_merk_chunk_id_decode_is_total_and_canonical(input in arbitrary_bytes()) { + if let Ok(id) = NonMerkChunkId::decode(&input) { + prop_assert_eq!(input.len(), 17); + prop_assert_eq!(id.encode(), input); + } + } + + #[test] + fn non_merk_chunk_id_round_trips(start in any::(), state in any::(), param in any::()) { + let id = NonMerkChunkId { start, state, param }; + prop_assert_eq!(NonMerkChunkId::decode(&id.encode()).unwrap(), id); + } + + #[test] + fn decode_non_merk_page_is_total_and_bounded(input in arbitrary_bytes()) { + if let Ok((more, aux, entries)) = decode_non_merk_page(&input) { + prop_assert!(entries.len() <= MAX_PAGE_ENTRIES); + let payload: usize = aux.len() + entries.iter().map(Vec::len).sum::(); + prop_assert!( + payload <= input.len(), + "decoded {payload} payload bytes from a {}-byte page", + input.len() + ); + if let Some((_last, head)) = entries.split_last() { + prop_assert!(head.iter().map(Vec::len).sum::() < MAX_PAGE_BYTES); + } + prop_assert_eq!(encode_non_merk_page(more, aux, entries).unwrap(), input); + } + } + + #[test] + fn decode_non_merk_page_survives_mutated_pages( + page in non_merk_page_strategy(), + muts in mutations(), + ) { + let input = mutate(page, muts); + if let Ok((more, aux, entries)) = decode_non_merk_page(&input) { + prop_assert!(entries.len() <= MAX_PAGE_ENTRIES); + prop_assert_eq!(encode_non_merk_page(more, aux, entries).unwrap(), input); + } + } + + #[test] + fn non_merk_page_round_trips( + more in any::(), + aux in prop::collection::vec(any::(), 0..32), + entries in prop::collection::vec(prop::collection::vec(any::(), 0..24), 0..8), + ) { + let encoded = encode_non_merk_page(more, aux.clone(), entries.clone()).unwrap(); + let (d_more, d_aux, d_entries) = decode_non_merk_page(&encoded).unwrap(); + prop_assert_eq!(d_more, more); + prop_assert_eq!(d_aux, aux); + prop_assert_eq!(d_entries, entries); + } + } + + // ── Non-vacuity guard ─────────────────────────────────────────────── + + /// Every property above is written as `if let Ok(..) = decode(input)`, + /// which asserts nothing when the decoder always rejects. That is not + /// hypothetical: unstructured bytes essentially never parse as any of + /// these formats (a random 512-byte buffer has to hit an exact length + /// and an exact count byte), which is precisely why each surface is + /// also fuzzed from *mutated valid encodings*. + /// + /// This test makes that load-bearing assumption explicit. It walks a + /// deterministic mutation corpus per surface and requires both arms to + /// be reached: at least one mutant still decodes (so the canonical + /// re-encode assertions actually run) and at least one is rejected (so + /// the corpus is not just the identity). If a future encoding change + /// made every mutant unparsable, the properties would keep "passing" + /// while asserting nothing — this fails instead. + /// Genuine edits of `valid` only — the unmutated input is deliberately + /// NOT included, so "some mutant decodes" cannot be satisfied by the + /// identity element alone. + fn deterministic_mutants(valid: &[u8]) -> Vec> { + let mut out = Vec::new(); + for i in 0..valid.len().min(48) { + for v in [0u8, 1, 2, 3, 0xFE, 0xFF] { + let mut m = valid.to_vec(); + m[i] = v; + out.push(m); + } + let mut truncated = valid.to_vec(); + truncated.truncate(i); + out.push(truncated); + let mut spliced = valid.to_vec(); + spliced.insert(i, 0xAA); + out.push(spliced); + } + out + } + + /// Run `decode` over the corpus and assert both arms are reached. + fn assert_corpus_reaches_both_arms( + surface: &str, + valid: &[u8], + decode: impl Fn(&[u8]) -> Result, + ) { + assert!( + decode(valid).is_ok(), + "{surface}: the corpus seed is not a valid encoding" + ); + let corpus = deterministic_mutants(valid); + let accepted = corpus.iter().filter(|m| decode(m).is_ok()).count(); + assert!( + accepted > 0, + "{surface}: no edited input decoded — the `if let Ok(..)` properties are vacuous" + ); + assert!( + accepted < corpus.len(), + "{surface}: every edited input decoded — the corpus never exercises a rejection" + ); + } + + #[test] + fn mutation_corpora_reach_both_the_accept_and_reject_arms() { + assert_corpus_reaches_both_arms( + "unpack_nested_bytes", + &pack_nested_bytes(vec![vec![1, 2, 3], vec![], vec![9; 10]]).unwrap(), + unpack_nested_bytes, + ); + + assert_corpus_reaches_both_arms( + "decode_global_chunk_id", + &encode_global_chunk_id( + [7u8; 32], + Some(vec![1, 2, 3, 4]), + TreeType::NormalTree, + vec![vec![0, 1], vec![1]], + ) + .unwrap(), + |b| decode_global_chunk_id(b, &FUZZ_APP_HASH), + ); + + assert_corpus_reaches_both_arms( + "IndexedHeader::decode", + &IndexedHeader { + primary_root_hash: [3u8; 32], + axes: vec![(0, [4u8; 32]), (1, [5u8; 32])], + } + .encode(), + IndexedHeader::decode, + ); + + assert_corpus_reaches_both_arms( + "IndexedHeaderRequest::decode", + &IndexedHeaderRequest { + axes: vec![(0, Some(vec![1, 2, 3])), (1, None)], + } + .encode(), + IndexedHeaderRequest::decode, + ); + + assert_corpus_reaches_both_arms( + "NonMerkChunkId::decode", + &NonMerkChunkId { + start: 5, + state: 11, + param: 2, + } + .encode(), + NonMerkChunkId::decode, + ); + + assert_corpus_reaches_both_arms( + "decode_non_merk_page", + &encode_non_merk_page(true, vec![7, 7], vec![vec![1, 2], vec![3]]).unwrap(), + decode_non_merk_page, + ); + } + + // ── Targeted error branches: `IndexedHeader::decode` ──────────────── + // + // One test per distinct rejection in the decoder, asserting the + // message and not just `is_err()`, so a branch that starts returning + // the wrong diagnosis is caught. + + fn header_decode_err(bytes: &[u8]) -> String { + format!( + "{}", + IndexedHeader::decode(bytes).expect_err("decode should reject") + ) + } + + #[test] + fn indexed_header_decode_rejects_empty_input() { + assert!(header_decode_err(&[]).contains("too short")); + } + + #[test] + fn indexed_header_decode_rejects_input_shorter_than_the_fixed_prefix() { + // 32 bytes carry the primary root hash but leave no axis count. + assert!(header_decode_err(&[0u8; 32]).contains("too short")); + } + + #[test] + fn indexed_header_decode_rejects_zero_axes() { + let mut bytes = vec![0u8; 33]; + bytes[32] = 0; + assert!(header_decode_err(&bytes).contains("axis count must be 1..=3")); + } + + #[test] + fn indexed_header_decode_rejects_more_than_three_axes() { + let mut bytes = vec![0u8; 33 + 4 * 33]; + bytes[32] = 4; + assert!(header_decode_err(&bytes).contains("axis count must be 1..=3")); + // Also the extreme: a count byte of 255. + let mut huge = vec![0u8; 33]; + huge[32] = u8::MAX; + assert!(header_decode_err(&huge).contains("axis count must be 1..=3")); + } + + #[test] + fn indexed_header_decode_rejects_a_short_axis_section() { + let header = IndexedHeader { + primary_root_hash: [1u8; 32], + axes: vec![(0, [2u8; 32])], + }; + let mut bytes = header.encode(); + bytes.pop(); + assert!(header_decode_err(&bytes).contains("axis section length mismatch")); + } + + #[test] + fn indexed_header_decode_rejects_trailing_bytes() { + let header = IndexedHeader { + primary_root_hash: [1u8; 32], + axes: vec![(0, [2u8; 32]), (1, [3u8; 32])], + }; + let mut bytes = header.encode(); + bytes.push(0); + assert!(header_decode_err(&bytes).contains("axis section length mismatch")); + } + + #[test] + fn indexed_header_decode_rejects_a_count_that_overstates_the_section() { + // Two axes' worth of bytes, but the count claims three. + let mut bytes = IndexedHeader { + primary_root_hash: [1u8; 32], + axes: vec![(0, [2u8; 32]), (1, [3u8; 32])], + } + .encode(); + bytes[32] = 3; + assert!(header_decode_err(&bytes).contains("axis section length mismatch")); + } + + // ── Targeted error branches: `verify_indexed_binding` ─────────────── + + const VALUE_HASH: CryptoHash = [0x11; 32]; + const PRIMARY_ROOT: CryptoHash = [0x22; 32]; + const SECONDARY_ROOT: CryptoHash = [0x33; 32]; + + /// The parent binding a correct single-axis group must reproduce. + fn single_axis_binding() -> CryptoHash { + combine_hash_three(&VALUE_HASH, &PRIMARY_ROOT, &SECONDARY_ROOT).unwrap() + } + + #[test] + fn verify_indexed_binding_accepts_a_correct_single_axis_group() { + for element in [ + Element::empty_provable_count_indexed_tree(), + Element::empty_provable_sum_indexed_tree(), + ] { + verify_indexed_binding( + &element, + &VALUE_HASH, + &single_axis_binding(), + &PRIMARY_ROOT, + &[(0, SECONDARY_ROOT)], + ) + .expect("a group whose actual roots reproduce the parent binding must be accepted"); + } + } + + #[test] + fn verify_indexed_binding_rejects_a_mismatched_single_axis_group() { + let err = verify_indexed_binding( + &Element::empty_provable_count_indexed_tree(), + &VALUE_HASH, + &single_axis_binding(), + &[0xEE; 32], // not the primary root the binding commits to + &[(0, SECONDARY_ROOT)], + ) + .expect_err("a wrong primary root must be rejected"); + assert!(matches!(err, crate::Error::CorruptedData(_)), "got {err:?}"); + assert!(format!("{err}").contains("indexed subtree joint verification failed")); + } + + #[test] + fn verify_indexed_binding_rejects_a_single_axis_group_with_the_wrong_arity() { + for secondaries in [ + &[][..], + &[(0, SECONDARY_ROOT), (1, SECONDARY_ROOT)][..], + &[ + (0, SECONDARY_ROOT), + (1, SECONDARY_ROOT), + (2, SECONDARY_ROOT), + ][..], + ] { + let err = verify_indexed_binding( + &Element::empty_provable_sum_indexed_tree(), + &VALUE_HASH, + &single_axis_binding(), + &PRIMARY_ROOT, + secondaries, + ) + .expect_err("a single-axis element must reject a non-singleton secondary set"); + assert!(matches!(err, crate::Error::InternalError(_)), "got {err:?}"); + assert!( + format!("{err}").contains("single-axis indexed group finalized with"), + "got {err}" + ); + } + } + + #[test] + fn verify_indexed_binding_accepts_and_rejects_a_three_axis_group() { + let element = Element::empty_provable_count_provable_sum_indexed_tree(vec![ + (0, None), + (1, None), + (2, None), + ]) + .expect("canonical three-axis configuration"); + let axes: Vec<(u8, CryptoHash)> = vec![(0, [1u8; 32]), (1, [2u8; 32]), (2, [3u8; 32])]; + let binding = + combine_hash_three(&VALUE_HASH, &PRIMARY_ROOT, &axes_digest(&axes).unwrap()).unwrap(); + + verify_indexed_binding(&element, &VALUE_HASH, &binding, &PRIMARY_ROOT, &axes) + .expect("the canonical axes digest must reproduce the binding"); + + // Reordering the axes changes the canonical digest. + let mut swapped = axes.clone(); + swapped.swap(0, 2); + let err = verify_indexed_binding(&element, &VALUE_HASH, &binding, &PRIMARY_ROOT, &swapped) + .expect_err("a reordered axis set must not reproduce the binding"); + assert!(format!("{err}").contains("indexed subtree joint verification failed")); + + // So does dropping one. + let err = + verify_indexed_binding(&element, &VALUE_HASH, &binding, &PRIMARY_ROOT, &axes[..2]) + .expect_err("a truncated axis set must not reproduce the binding"); + assert!(format!("{err}").contains("indexed subtree joint verification failed")); + } + + #[test] + fn verify_indexed_binding_rejects_a_non_indexed_element() { + let err = verify_indexed_binding( + &Element::empty_tree(), + &VALUE_HASH, + &single_axis_binding(), + &PRIMARY_ROOT, + &[(0, SECONDARY_ROOT)], + ) + .expect_err("a non-indexed element has no three-input binding to verify"); + assert!(matches!(err, crate::Error::InternalError(_)), "got {err:?}"); + assert!( + format!("{err}").contains("called on a non-indexed element"), + "got {err}" + ); + } +} diff --git a/grovedb/src/tests/replication_incremental_commit_tests.rs b/grovedb/src/tests/replication_incremental_commit_tests.rs new file mode 100644 index 000000000..184bc5c98 --- /dev/null +++ b/grovedb/src/tests/replication_incremental_commit_tests.rs @@ -0,0 +1,860 @@ +//! Bounded-memory (`RestoreCommitMode::Incremental`) restore. +//! +//! The default restore is atomic: nothing reaches the destination until +//! `commit_session` has verified the root hash, which costs a resident +//! copy of the entire state being restored (see +//! `replication_scale_tests`). The incremental mode buys a memory +//! ceiling that does not grow with the state by committing at points the +//! session proves are safe, and pays for it by giving up the rollback. +//! +//! These tests pin the three things that trade depends on: the +//! intermediate commits actually happen and produce the same grove; they +//! never land while an indexed group is only half restored; and a +//! destination left half-restored is detectably poisoned rather than +//! silently plausible. + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use grovedb_version::version::GroveVersion; + use tempfile::TempDir; + + use crate::{ + replication::{RestoreCommitMode, CURRENT_STATE_SYNC_VERSION}, + tests::{make_empty_grovedb, make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, GroveDb, + }; + + /// One byte, i.e. "a commit is due at every safe point there is". + /// + /// Every incremental test here uses it rather than a realistic budget: + /// the interesting question is never whether a large budget + /// eventually trips, it is whether the *safety* conditions hold when + /// it trips as often as it possibly can. + const COMMIT_AT_EVERY_SAFE_POINT: RestoreCommitMode = RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }; + + /// What a driven restore's session did along the way. + struct SyncOutcome { + intermediate_commits: usize, + /// How many due-and-otherwise-safe commits the session held back + /// because an indexed group was still in flight. + commits_deferred_for_open_group: usize, + /// The largest uncommitted payload the session ever carried — + /// the memory ceiling the incremental mode exists to provide. + peak_uncommitted_bytes: u64, + } + + /// Checkpoint `source`, restore it into `dest` under `commit_mode`, + /// and report what the session did. + /// + /// `dest` is supplied by the caller rather than created here so tests + /// that need to close and reopen the restored grove can own its + /// directory. + /// + fn run_incremental_sync_into( + source: &TempGroveDb, + dest: &GroveDb, + grove_version: &GroveVersion, + subtrees_batch_size: usize, + commit_mode: RestoreCommitMode, + ) -> Result { + let checkpoint_dir = TempDir::new().expect("should create temp dir for checkpoint"); + let checkpoint_path = checkpoint_dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("should create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("should open checkpoint db"); + + let app_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .expect("checkpoint root hash should be available"); + + let mut session = dest.start_snapshot_syncing_with_mode( + app_hash, + subtrees_batch_size, + CURRENT_STATE_SYNC_VERSION, + commit_mode, + grove_version, + )?; + + let mut chunk_queue: VecDeque> = VecDeque::new(); + chunk_queue.push_back(app_hash.to_vec()); + + let mut peak_uncommitted_bytes = 0u64; + while let Some(chunk_id) = chunk_queue.pop_front() { + let chunk_data = checkpoint_db.fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + )?; + let more_ids = session.apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + )?; + peak_uncommitted_bytes = + peak_uncommitted_bytes.max(session.uncommitted_payload_bytes()); + + chunk_queue.extend(more_ids); + } + + assert!(session.is_sync_completed(), "sync should have completed"); + let intermediate_commits = session.intermediate_commits(); + let commits_deferred_for_open_group = session.commits_deferred_for_open_group(); + dest.commit_session(session, grove_version)?; + + Ok(SyncOutcome { + intermediate_commits, + commits_deferred_for_open_group, + peak_uncommitted_bytes, + }) + } + + /// [`run_incremental_sync_into`] with a throwaway destination, for the + /// tests that never reopen it. + fn run_incremental_sync( + source: &TempGroveDb, + grove_version: &GroveVersion, + subtrees_batch_size: usize, + commit_mode: RestoreCommitMode, + ) -> Result<(TempGroveDb, SyncOutcome), crate::Error> { + let dest = make_empty_grovedb(); + let outcome = run_incremental_sync_into( + source, + &dest, + grove_version, + subtrees_batch_size, + commit_mode, + )?; + Ok((dest, outcome)) + } + + /// A grove with enough separate subtrees that a one-byte budget has + /// many safe points to fire at, plus items under each so the restore + /// is not degenerate. + fn multi_subtree_source(grove_version: &GroveVersion) -> TempGroveDb { + let source = make_test_grovedb(grove_version); + for tree in 0..6u8 { + let key = [b'c', b'0' + tree]; + source + .insert( + [TEST_LEAF].as_ref(), + &key, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create subtree"); + for i in 0..25u32 { + source + .insert( + [TEST_LEAF, &key].as_ref(), + &i.to_be_bytes(), + Element::new_item(vec![tree; 64]), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + } + } + source + } + + /// [`multi_subtree_source`] with two indexed trees added, so a + /// primary-plus-secondaries group is in flight while safe points come + /// and go around it. + fn indexed_source(grove_version: &GroveVersion) -> TempGroveDb { + let source = multi_subtree_source(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"pcit", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCIT"); + source + .insert( + [TEST_LEAF].as_ref(), + b"psit", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PSIT"); + for k in [b"a" as &[u8], b"b", b"c", b"d"] { + source + .insert_into_count_indexed_tree( + [TEST_LEAF, b"pcit"].as_ref(), + k, + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("insert PCIT entry"); + source + .insert( + [TEST_LEAF, b"pcit", k].as_ref(), + b"leaf", + Element::new_item(k.to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("populate PCIT entry"); + source + .insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + k, + Element::new_sum_item(k[0] as i64), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + source + } + + /// The default mode must remain exactly what it was: one transaction, + /// no early writes, whatever the byte volume. + #[test] + fn atomic_mode_takes_no_intermediate_commits() { + let grove_version = GroveVersion::latest(); + let source = indexed_source(grove_version); + + let (dest, outcome) = + run_incremental_sync(&source, grove_version, 2, RestoreCommitMode::Atomic) + .expect("atomic sync should succeed"); + + assert_eq!( + outcome.intermediate_commits, 0, + "atomic mode must never commit before commit_session" + ); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + ); + assert!(!dest.has_incomplete_restore().unwrap()); + } + + /// A zero in-flight cap must be clamped, not obeyed. + /// + /// `RestoreCommitMode::Incremental`'s fields are public, so a caller + /// can build one with `max_subtrees_in_flight: 0`. Taken literally + /// that is not a slow restore, it is a silent hang: every discovered + /// subtree is deferred, `apply_chunk` returns no next chunk ids, the + /// caller's queue drains, and `is_sync_completed()` stays false + /// forever with no error to report. This drives a real sync with that + /// configuration; if the clamp regresses, the loop in + /// `run_incremental_sync_into` exits early and the completion + /// assertion fires. + #[test] + fn a_zero_in_flight_cap_is_clamped_rather_than_hanging() { + let grove_version = GroveVersion::latest(); + let source = indexed_source(grove_version); + + let (dest, outcome) = run_incremental_sync( + &source, + grove_version, + 64, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 0, + }, + ) + .expect("a zero in-flight cap must still restore"); + + assert!(outcome.intermediate_commits > 0); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + ); + } + + /// The incremental mode commits repeatedly and still lands on the same + /// grove. + #[test] + fn incremental_mode_commits_early_and_restores_the_same_grove() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + let (dest, outcome) = + run_incremental_sync(&source, grove_version, 64, COMMIT_AT_EVERY_SAFE_POINT) + .expect("incremental sync should succeed"); + + assert!( + outcome.intermediate_commits > 0, + "a one-byte budget over a multi-subtree grove must reach at least one safe point; \ + otherwise this test proves nothing about incremental mode" + ); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + "an incrementally committed restore must produce the same root hash" + ); + assert_eq!( + dest.get( + [TEST_LEAF, b"c3"].as_ref(), + &7u32.to_be_bytes(), + None, + grove_version + ) + .unwrap() + .expect("restored item should be readable"), + Element::new_item(vec![3u8; 64]), + ); + } + + /// A subtree-count batch boundary is not the only lever any more: the + /// byte budget must produce commits even when `subtrees_batch_size` is + /// far larger than the number of subtrees in the grove, which is the + /// shape Platform state actually has (few, fat subtrees). + #[test] + fn byte_budget_commits_when_the_subtree_count_never_reaches_the_batch_size() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + // 10_000 subtrees per batch over a grove with well under a dozen: + // the subtree counter can never trip this. + let (dest, outcome) = + run_incremental_sync(&source, grove_version, 10_000, COMMIT_AT_EVERY_SAFE_POINT) + .expect("incremental sync should succeed"); + + assert!( + outcome.intermediate_commits > 0, + "the payload budget must be able to close a discovery batch on its own" + ); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + ); + } + + /// The group-splitting guarantee. + /// + /// An indexed subtree is bound to its parent only by the joint check + /// over the primary's and every secondary's restored root hash, so an + /// intermediate commit must never land between a group's members. + /// + /// With a one-byte budget a commit is due at literally every + /// boundary, which makes the guard the only thing standing between + /// the sync and a split group. The proof it is doing real work is + /// `commits_deferred_for_open_group`: a commit that was due, at a + /// boundary where nothing else objected, refused purely because a + /// group was open. If that count were zero the test would be + /// describing a situation that never arises; asserting it is non-zero + /// is what stops this from being vacuous. Deleting the + /// `indexed_groups.is_empty()` clause from + /// `intermediate_commit_decision` turns every one of those deferrals + /// into a split-group commit, and `intermediate_commit`'s own hard + /// refusal then fails the sync outright. + #[test] + fn never_splits_an_indexed_group_across_a_commit() { + let grove_version = GroveVersion::latest(); + let source = indexed_source(grove_version); + + for batch_size in [1usize, 2, 3, 64] { + let (dest, outcome) = run_incremental_sync( + &source, + grove_version, + batch_size, + COMMIT_AT_EVERY_SAFE_POINT, + ) + .unwrap_or_else(|e| { + panic!("incremental sync with batch size {batch_size} should succeed: {e}") + }); + + assert!( + outcome.commits_deferred_for_open_group > 0, + "batch size {batch_size}: no commit was ever held back for an open indexed \ + group, so this test proves nothing about the guard" + ); + assert!( + outcome.intermediate_commits > 0, + "batch size {batch_size}: no commit was taken at all" + ); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + ); + } + } + + /// How many ordinary descendants the starvation shape hangs off the + /// indexed primary, and how much payload each carries. Sized so the + /// descendants together are an order of magnitude past the budget: + /// if the group can hold every commit open across them, the peak + /// uncommitted payload is their whole sum. + const STARVING_CHILDREN: usize = 16; + const STARVING_ITEMS_PER_CHILD: u32 = 24; + const STARVING_ITEM_LEN: usize = 192; + + /// A grove shaped to starve an indexed group's axis secondary. + /// + /// Subtree activation runs in raw `SubtreePrefix` order, and prefixes + /// are Blake3 digests — an ordering with no relation to the tree's + /// shape or semantics. So the adversarial case is not exotic, it is a + /// coin flip per subtree: this simply picks the keys that land it. + /// The primary is chosen so its axis-secondary prefix sits near the + /// very top of the order, and the primary's ordinary children are + /// chosen to sit below it. + /// + /// With a small in-flight cap, each activation round then takes + /// another ordinary child in preference to the secondary. The group + /// stays open for as long as the children last, and an open group + /// refuses every intermediate commit — so the uncommitted write set + /// grows to the whole of the primary's descendants regardless of the + /// payload budget. + /// + /// Returns the source and the total item payload hung off the + /// primary, so the assertion can be written against the shape rather + /// than a magic number. + fn secondary_starving_indexed_source(grove_version: &GroveVersion) -> (TempGroveDb, u64) { + use grovedb_query::axis_query::IndexAxis; + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + // A primary whose axis-secondary prefix is high in the ordering, + // leaving most of the space below it for its children. + let (primary_key, secondary_prefix) = (0u32..) + .take(100_000) + .find_map(|i| { + let key = format!("primary{i}").into_bytes(); + let path: &[&[u8]] = &[TEST_LEAF, &key]; + let prefix = RocksDbStorage::build_prefix(path.into()).unwrap(); + let secondary = + RocksDbStorage::secondary_prefix_for(&prefix, IndexAxis::Count.tag()).unwrap(); + (secondary[0] >= 0xF0).then_some((key, secondary)) + }) + .expect("a primary with a high axis-secondary prefix must exist"); + + // Children that sort ahead of that secondary, so every + // activation round prefers them. + let child_keys: Vec> = (0u32..) + .take(100_000) + .filter_map(|i| { + let key = format!("child{i}").into_bytes(); + let path: &[&[u8]] = &[TEST_LEAF, &primary_key, &key]; + let prefix = RocksDbStorage::build_prefix(path.into()).unwrap(); + (prefix < secondary_prefix).then_some(key) + }) + .take(STARVING_CHILDREN) + .collect(); + assert_eq!( + child_keys.len(), + STARVING_CHILDREN, + "could not find enough children sorting ahead of the axis secondary" + ); + + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + &primary_key, + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCIT primary"); + for key in &child_keys { + source + .insert_into_count_indexed_tree( + [TEST_LEAF, &primary_key].as_ref(), + key, + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("insert PCIT entry"); + for i in 0..STARVING_ITEMS_PER_CHILD { + source + .insert( + [TEST_LEAF, &primary_key, key].as_ref(), + &i.to_be_bytes(), + Element::new_item(vec![i as u8; STARVING_ITEM_LEN]), + None, + None, + grove_version, + ) + .unwrap() + .expect("populate PCIT entry"); + } + } + + let descendant_payload = (STARVING_CHILDREN as u64) + * u64::from(STARVING_ITEMS_PER_CHILD) + * STARVING_ITEM_LEN as u64; + (source, descendant_payload) + } + + /// An indexed group must not be able to hold every intermediate + /// commit open across an unbounded run of ordinary descendants. + /// + /// A commit is refused while a group is open, which is correct — the + /// group's joint verification is its only binding to the parent. But + /// "open" must be a short-lived state the session drives out of, not + /// something an adversarially (or just unluckily) shaped grove can + /// extend indefinitely: the group's axis secondaries and the + /// primary's ordinary descendants share one pending map keyed by + /// Blake3 prefix, so without a priority rule the secondaries can lose + /// every activation round while the descendants keep discovering + /// more. Every commit is then deferred and the transaction grows to + /// hold an arbitrary fraction of the grove, which is exactly the + /// property [`RestoreCommitMode::Incremental`] is sold on. + /// + /// The bound asserted here is the shape of the guarantee: the peak + /// uncommitted payload must stay far below the total payload hanging + /// off the primary. Without the secondary-first activation order the + /// peak *is* that total. + #[test] + fn an_open_indexed_group_cannot_defer_commits_across_its_descendants() { + let grove_version = GroveVersion::latest(); + let (source, descendant_payload) = secondary_starving_indexed_source(grove_version); + + let budget_bytes = 4096; + let (dest, outcome) = run_incremental_sync( + &source, + grove_version, + 10_000, + RestoreCommitMode::Incremental { + budget_bytes, + max_subtrees_in_flight: 1, + }, + ) + .expect("incremental sync should succeed"); + + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + source.root_hash(None, grove_version).unwrap().unwrap(), + ); + assert!( + outcome.intermediate_commits >= STARVING_CHILDREN / 2, + "only {} intermediate commits over {STARVING_CHILDREN} descendant subtrees: commits \ + were not flowing while the group was in flight", + outcome.intermediate_commits, + ); + assert!( + outcome.peak_uncommitted_bytes < descendant_payload / 4, + "the session held {} uncommitted bytes at its peak against a {}-byte budget; the \ + primary's descendants carry {descendant_payload} bytes, so the open indexed group \ + deferred commits across them instead of resolving first", + outcome.peak_uncommitted_bytes, + budget_bytes, + ); + } + + /// A restore abandoned after its first intermediate commit leaves the + /// destination poisoned, and says so. + #[test] + fn abandoned_incremental_restore_marks_the_database() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + let checkpoint_dir = TempDir::new().expect("temp dir"); + let checkpoint_path = checkpoint_dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint"); + let app_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .unwrap(); + + let dest = make_empty_grovedb(); + assert!( + !dest.has_incomplete_restore().unwrap(), + "a fresh grove is not mid-restore" + ); + + let mut session = dest + .start_snapshot_syncing_with_mode( + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + COMMIT_AT_EVERY_SAFE_POINT, + grove_version, + ) + .expect("start syncing"); + + // Drive only until the first intermediate commit, then walk away + // exactly as a crashed or cancelled restore would. + let mut chunk_queue: VecDeque> = VecDeque::new(); + chunk_queue.push_back(app_hash.to_vec()); + while let Some(chunk_id) = chunk_queue.pop_front() { + let chunk_data = checkpoint_db + .fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("fetch chunk"); + let more = session + .apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("apply chunk"); + if session.intermediate_commits() > 0 { + break; + } + chunk_queue.extend(more); + } + assert!( + session.intermediate_commits() > 0, + "the restore must have committed something for this test to mean anything" + ); + drop(session); + + assert!( + dest.has_incomplete_restore().unwrap(), + "an abandoned incremental restore must leave the database marked as unusable" + ); + // Not a root-hash comparison: the root subtree is restored first + // and already commits to its children's hashes, so `root_hash` + // matches the target long before the children exist. Absence of a + // leaf that the completed restore does have is the honest signal. + assert!( + matches!( + dest.get( + [TEST_LEAF, b"c5"].as_ref(), + &24u32.to_be_bytes(), + None, + grove_version + ) + .unwrap(), + Err(crate::Error::PathParentLayerNotFound(_)) + | Err(crate::Error::PathKeyNotFound(_)) + | Err(crate::Error::PathNotFound(_)) + ), + "the abandoned restore is genuinely incomplete, not accidentally finished" + ); + } + + /// The marker is scoped to the window it describes: a restore that + /// runs to a verified commit leaves none behind, and the mark survives + /// reopening the database while it is set. + #[test] + fn completed_incremental_restore_clears_the_marker() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + let dest_dir = TempDir::new().expect("temp dir"); + let dest = GroveDb::open(dest_dir.path()).expect("open destination"); + let outcome = run_incremental_sync_into( + &source, + &dest, + grove_version, + 64, + COMMIT_AT_EVERY_SAFE_POINT, + ) + .expect("incremental sync should succeed"); + assert!(outcome.intermediate_commits > 0); + assert!( + !dest.has_incomplete_restore().unwrap(), + "a verified restore must not leave the destination marked" + ); + + // And the flag is durable, not process state. + drop(dest); + let reopened = GroveDb::open(dest_dir.path()).expect("reopen restored grove"); + assert!(!reopened.has_incomplete_restore().unwrap()); + } + + /// The final root hash check still gates the last commit in + /// incremental mode — losing the rollback must not mean losing the + /// check. + #[test] + fn incremental_mode_still_refuses_a_root_hash_mismatch() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + let checkpoint_dir = TempDir::new().expect("temp dir"); + let checkpoint_path = checkpoint_dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint"); + let real_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .unwrap(); + + let dest = make_empty_grovedb(); + // Claim a different app hash than the source will actually + // produce; the chunk stream is genuine, only the target is a lie. + let mut wrong_hash = real_hash; + wrong_hash[0] ^= 0xff; + + let mut session = dest + .start_snapshot_syncing_with_mode( + real_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + COMMIT_AT_EVERY_SAFE_POINT, + grove_version, + ) + .expect("start syncing"); + + let mut chunk_queue: VecDeque> = VecDeque::new(); + chunk_queue.push_back(real_hash.to_vec()); + while let Some(chunk_id) = chunk_queue.pop_front() { + let chunk_data = checkpoint_db + .fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("fetch chunk"); + let more = session + .apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("apply chunk"); + chunk_queue.extend(more); + } + assert!(session.intermediate_commits() > 0); + + // Rewrite the session's expectation to the wrong hash so the + // final check has something to reject, exactly as a byzantine + // source's stream would. + session.set_app_hash_for_test(wrong_hash); + let err = dest + .commit_session(session, grove_version) + .expect_err("a root hash mismatch must still be refused"); + assert!( + format!("{err}").contains("root hash mismatch"), + "unexpected error: {err}" + ); + assert!( + dest.has_incomplete_restore().unwrap(), + "a refused final commit leaves the earlier intermediate commits behind, and the \ + database must stay marked so the caller discards it" + ); + } + + /// A destination still carrying the incomplete-restore marker holds + /// unverified — and, once a different snapshot is restored over it, + /// orphaned — data from an abandoned incremental restore. A new + /// session into it would clear the marker on its own success while + /// leaving that data in place, unreachable from the root hash check. + /// Every session entry point therefore refuses until the directory + /// is discarded. + #[test] + fn a_marked_destination_refuses_to_start_another_restore() { + let grove_version = GroveVersion::latest(); + let source = multi_subtree_source(grove_version); + + let checkpoint_dir = TempDir::new().expect("temp dir"); + let checkpoint_path = checkpoint_dir.path().join("checkpoint"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint"); + let app_hash = checkpoint_db + .root_hash(None, grove_version) + .unwrap() + .unwrap(); + + let dest = make_empty_grovedb(); + let mut session = dest + .start_snapshot_syncing_with_mode( + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + COMMIT_AT_EVERY_SAFE_POINT, + grove_version, + ) + .expect("start syncing"); + + // Drive only until the first intermediate commit, then walk away. + let mut chunk_queue: VecDeque> = VecDeque::new(); + chunk_queue.push_back(app_hash.to_vec()); + while let Some(chunk_id) = chunk_queue.pop_front() { + let chunk_data = checkpoint_db + .fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("fetch chunk"); + let more = session + .apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("apply chunk"); + if session.intermediate_commits() > 0 { + break; + } + chunk_queue.extend(more); + } + assert!(session.intermediate_commits() > 0); + drop(session); + assert!(dest.has_incomplete_restore().unwrap()); + + for mode in [RestoreCommitMode::Atomic, COMMIT_AT_EVERY_SAFE_POINT] { + let Err(err) = dest.start_snapshot_syncing_with_mode( + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + mode, + grove_version, + ) else { + panic!("a marked destination started a {mode:?} snapshot restore"); + }; + assert!( + format!("{err}").contains("incomplete restore"), + "{mode:?}: expected the marker to be named, got {err}" + ); + let Err(err) = dest.start_syncing_session_with_mode( + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + mode, + grove_version, + ) else { + panic!("a marked destination built a bare {mode:?} session"); + }; + assert!( + format!("{err}").contains("incomplete restore"), + "{mode:?}: expected the marker to be named, got {err}" + ); + } + assert!( + dest.has_incomplete_restore().unwrap(), + "refusing to start must not clear the marker" + ); + } +} diff --git a/grovedb/src/tests/replication_scale_tests.rs b/grovedb/src/tests/replication_scale_tests.rs new file mode 100644 index 000000000..948c46ce8 --- /dev/null +++ b/grovedb/src/tests/replication_scale_tests.rs @@ -0,0 +1,861 @@ +//! Scale and memory-ceiling measurement for state sync restore. +//! +//! The default restore is **atomic**: every write of the whole sync — all +//! restored subtrees, across every discovery batch — is held in a single +//! `OptimisticTransactionDB` transaction (a `WriteBatchWithIndex`) until +//! `commit_session` verifies the root hash (see the invariant comment in +//! `state_sync_session::commit`). That write batch lives in RocksDB's C++ +//! heap, so its cost shows up in the process's memory footprint and +//! nowhere else — a Rust allocator hook would not see it. +//! +//! Setting `GROVEDB_SCALE_RESTORE_BUDGET_MIB=` runs the same +//! measurement against `RestoreCommitMode::Incremental` instead, which is +//! how the before/after comparison is reproduced. +//! +//! These tests measure that ceiling. They build a synthetic grove shaped +//! roughly like Dash Platform state (identity-like items under a flat +//! subtree, document-like items across nested per-contract subtrees, a sum +//! tree, a commitment tree, an MMR, and two indexed trees with populated +//! axes), checkpoint it, restore it into a fresh directory, and report: +//! +//! - the peak process memory footprint during the restore window +//! (sampled, plus the baseline captured just before the window so the +//! restore's own increment is visible, and a reading taken immediately +//! before `commit_session` so the write batch can be separated from the +//! commit-time flush), +//! - wall-clock of the fetch/apply/commit loop, +//! - the number of `fetch_chunk` round trips and total wire bytes, +//! - the on-disk size of the source checkpoint and of the restored target. +//! +//! Every test here is `#[ignore]`d: they take minutes and gigabytes, so CI +//! cost is zero. Run one explicitly, in **release** (the unoptimized +//! profile makes the build phase hours long): +//! +//! ```text +//! cargo test --release -p grovedb \ +//! restore_memory_ceiling_tier_small -- --ignored --nocapture +//! ``` +//! +//! `--nocapture` matters: the measurement is printed, not asserted. +//! +//! # What the number does and does not attribute +//! +//! The simulated remote peer (`checkpoint_db`) and the syncing node +//! (`target`) share one process, so the sampled window covers both sides +//! of the wire: the peer's read path (block cache fills, SST decompression +//! buffers) is counted alongside the target's write batch. The reported +//! increment is therefore an **upper bound** on what a real syncing node +//! needs, not an exact attribution. +//! +//! The bound is a tight one, and the tiers show why: RocksDB's read-side +//! caches are fixed-size, so the peer's contribution is a constant, while +//! the measured increment grows with state size. `mem before commit` +//! isolates the part that matters most — at that point the entire sync +//! write set is sitting in the transaction's `WriteBatchWithIndex` and +//! nothing has been flushed, so it is the write batch, not the flush, that +//! the reading reflects. + +#[cfg(test)] +mod tests { + use std::{ + collections::VecDeque, + path::Path, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, + time::{Duration, Instant}, + }; + + use grovedb_version::version::GroveVersion; + use tempfile::TempDir; + + use crate::{ + batch::QualifiedGroveDbOp, + replication::{RestoreCommitMode, CURRENT_STATE_SYNC_VERSION}, + Element, GroveDb, + }; + + /// Restore commit mode for this run, from the environment. + /// + /// Unset (the default) measures the atomic restore. Setting + /// `GROVEDB_SCALE_RESTORE_BUDGET_MIB=` measures the bounded-memory + /// restore with an `n` MiB payload budget, which is how the + /// before/after table in the PR is reproduced without editing code. + /// `GROVEDB_SCALE_RESTORE_IN_FLIGHT=` overrides the in-flight + /// subtree cap (default 1): + /// + /// ```text + /// GROVEDB_SCALE_RESTORE_BUDGET_MIB=64 cargo test --release -p grovedb \ + /// restore_memory_ceiling_tier_medium -- --ignored --nocapture + /// ``` + fn commit_mode_from_env() -> RestoreCommitMode { + match std::env::var("GROVEDB_SCALE_RESTORE_BUDGET_MIB") + .ok() + .and_then(|v| v.parse::().ok()) + { + Some(mib) if mib > 0 => RestoreCommitMode::Incremental { + budget_bytes: mib * 1024 * 1024, + max_subtrees_in_flight: std::env::var("GROVEDB_SCALE_RESTORE_IN_FLIGHT") + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|n| *n > 0) + .unwrap_or(1), + }, + _ => RestoreCommitMode::Atomic, + } + } + + // ── Shape of the synthetic grove ──────────────────────────────────── + + /// Parameterized "Platform-shaped" grove. Byte totals are dominated by + /// `identities` and `contracts * documents_per_contract`; the other + /// members exist so every state-sync transfer mode (Merk chunks, + /// non-Merk entry replay, indexed header + axis secondaries) is + /// exercised at scale rather than only in the round-trip unit tests. + #[derive(Debug, Clone, Copy)] + struct GroveShape { + /// Human label used in the printed report. + tier: &'static str, + /// Items under a single flat `identities` subtree. + identities: usize, + /// Bytes per identity-like item value. + identity_value_bytes: usize, + /// Number of per-contract subtrees under `documents`. + contracts: usize, + /// Document-like items in each contract's `docs` subtree. + documents_per_contract: usize, + /// Bytes per document-like item value. + document_value_bytes: usize, + /// Sum items in the `balances` sum tree. + sum_entries: usize, + /// Notes appended to the `notes` commitment tree. Sinsemilla + /// hashing dominates build time per note, so this stays small and + /// roughly constant across tiers on purpose. + commitment_entries: usize, + /// Leaves appended to the `history` MMR tree. + mmr_entries: usize, + /// Entries in each of the two indexed trees (`idx_count` / + /// `idx_sum`). + indexed_entries: usize, + } + + impl GroveShape { + /// Total number of restored key/value entries across every + /// member, i.e. the number of `WriteBatchWithIndex` skiplist + /// entries the restore's write set is at least as large as. + fn entry_count(&self) -> u64 { + (self.identities + + self.contracts * self.documents_per_contract + + self.sum_entries + + self.commitment_entries + + self.mmr_entries + + self.indexed_entries * 2) as u64 + } + + /// Bytes of item *values* in the identity-like and document-like + /// subtrees. Deliberately partial: it excludes keys, Merk node + /// overhead, and the sum / commitment / MMR / indexed members, so + /// it is a floor on the logical content, reported only as a rough + /// scale label. The on-disk and wire figures are the ones to + /// derive ratios from. + fn logical_value_bytes(&self) -> u64 { + (self.identities * self.identity_value_bytes) as u64 + + (self.contracts * self.documents_per_contract * self.document_value_bytes) as u64 + } + } + + /// ~10 MB of payload: a fast smoke run that proves the harness works. + const TIER_TINY: GroveShape = GroveShape { + tier: "tiny", + identities: 4_000, + identity_value_bytes: 256, + contracts: 2, + documents_per_contract: 8_000, + document_value_bytes: 512, + sum_entries: 2_000, + commitment_entries: 128, + mmr_entries: 2_000, + indexed_entries: 500, + }; + + /// ~100 MB of payload. + const TIER_SMALL: GroveShape = GroveShape { + tier: "small", + identities: 40_000, + identity_value_bytes: 256, + contracts: 8, + documents_per_contract: 22_000, + document_value_bytes: 512, + sum_entries: 20_000, + commitment_entries: 512, + mmr_entries: 20_000, + indexed_entries: 2_000, + }; + + /// ~1 GB of payload. + const TIER_MEDIUM: GroveShape = GroveShape { + tier: "medium", + identities: 400_000, + identity_value_bytes: 256, + contracts: 16, + documents_per_contract: 110_000, + document_value_bytes: 512, + sum_entries: 100_000, + commitment_entries: 512, + mmr_entries: 100_000, + indexed_entries: 5_000, + }; + + /// Roughly the same on-disk size as [`SHAPE_KEY_HEAVY`], reached with + /// few, large values. + /// + /// The pair exists to answer one attribution question: does the + /// restore's memory ceiling track the write batch's *data* (the + /// serialised key and value bytes) or its *index* (one skiplist entry + /// per key in the `WriteBatchWithIndex`)? At ~40x the entry count for + /// the same on-disk size, an index-dominated ceiling would show a + /// dramatically worse ratio on the key-heavy side. Measured, it does + /// not: cost per source byte matches within ~15% while cost per entry + /// differs 60x, so the batch payload is the ceiling and the skiplist + /// is a single-digit percentage of it. + const SHAPE_VALUE_HEAVY: GroveShape = GroveShape { + tier: "value-heavy", + identities: 12_000, + identity_value_bytes: 8_192, + contracts: 2, + documents_per_contract: 6_000, + document_value_bytes: 8_192, + sum_entries: 0, + commitment_entries: 0, + mmr_entries: 0, + indexed_entries: 0, + }; + + /// See [`SHAPE_VALUE_HEAVY`]: same rough on-disk size, ~40x the entry + /// count. + const SHAPE_KEY_HEAVY: GroveShape = GroveShape { + tier: "key-heavy", + identities: 500_000, + identity_value_bytes: 32, + contracts: 5, + documents_per_contract: 100_000, + document_value_bytes: 32, + sum_entries: 0, + commitment_entries: 0, + mmr_entries: 0, + indexed_entries: 0, + }; + + /// ~4 GB of payload. + const TIER_LARGE: GroveShape = GroveShape { + tier: "large", + identities: 1_200_000, + identity_value_bytes: 256, + contracts: 32, + documents_per_contract: 220_000, + document_value_bytes: 512, + sum_entries: 200_000, + commitment_entries: 512, + mmr_entries: 200_000, + indexed_entries: 10_000, + }; + + // ── Process memory sampling ───────────────────────────────────────── + + /// Current physical memory footprint of this process, in bytes. + /// + /// Read from the OS rather than from a Rust allocator hook on purpose: + /// the allocation under measurement is RocksDB's C++ + /// `WriteBatchWithIndex`, which never passes through Rust's + /// `GlobalAlloc`. Returns `None` on platforms with no reading (the + /// harness then reports zeroes instead of failing). + /// + /// On macOS this is `ri_phys_footprint`, **not** resident size. The + /// distinction decides whether the largest tiers mean anything: under + /// memory pressure macOS compresses anonymous pages, which drops them + /// out of RSS while the process still owns them. Sampling RSS makes a + /// restore that is swamping the machine look *cheaper* than one that + /// fits, which is exactly backwards. `phys_footprint` counts + /// compressed pages and does not have that failure mode. + #[cfg(target_os = "macos")] + fn current_memory_bytes() -> Option { + let mut info: libc::rusage_info_v4 = unsafe { std::mem::zeroed() }; + // SAFETY: `proc_pid_rusage` writes one `rusage_info_v4` through the + // buffer pointer when called with `RUSAGE_INFO_V4`; `info` is a + // zero-initialised value of exactly that type. + let rc = unsafe { + libc::proc_pid_rusage( + std::process::id() as libc::c_int, + libc::RUSAGE_INFO_V4, + (&raw mut info).cast(), + ) + }; + (rc == 0).then_some(info.ri_phys_footprint) + } + + /// Linux twin of the macOS reading above: field 2 of `/proc/self/statm` + /// is the resident set in pages. Linux does not compress anonymous + /// memory by default, so resident size is the comparable figure there. + #[cfg(target_os = "linux")] + fn current_memory_bytes() -> Option { + let statm = std::fs::read_to_string("/proc/self/statm").ok()?; + let pages: u64 = statm.split_whitespace().nth(1)?.parse().ok()?; + // SAFETY: `sysconf` is a pure query with no pointer arguments. + let page_size = unsafe { libc::sysconf(libc::_SC_PAGESIZE) }; + (page_size > 0).then(|| pages * page_size as u64) + } + + #[cfg(not(any(target_os = "macos", target_os = "linux")))] + fn current_memory_bytes() -> Option { + None + } + + /// Background sampler recording the high-water mark of the process + /// memory footprint over a window. + struct RssSampler { + peak: Arc, + stop: Arc, + handle: Option>, + } + + impl RssSampler { + fn start() -> Self { + let peak = Arc::new(AtomicU64::new(0)); + let stop = Arc::new(AtomicBool::new(false)); + let (peak_t, stop_t) = (Arc::clone(&peak), Arc::clone(&stop)); + let handle = std::thread::spawn(move || { + while !stop_t.load(Ordering::Relaxed) { + if let Some(rss) = current_memory_bytes() { + peak_t.fetch_max(rss, Ordering::Relaxed); + } + std::thread::sleep(Duration::from_millis(25)); + } + // One final sample so a short window is never empty. + if let Some(rss) = current_memory_bytes() { + peak_t.fetch_max(rss, Ordering::Relaxed); + } + }); + RssSampler { + peak, + stop, + handle: Some(handle), + } + } + + fn finish(mut self) -> u64 { + self.shut_down(); + self.peak.load(Ordering::Relaxed) + } + + fn shut_down(&mut self) { + self.stop.store(true, Ordering::Relaxed); + if let Some(handle) = self.handle.take() { + let _ = handle.join(); + } + } + } + + /// Stop the sampler even when the measurement panics part-way through. + /// Without this an `.expect()` anywhere between `start()` and + /// `finish()` would leave the poll loop spinning for the rest of the + /// test process. + impl Drop for RssSampler { + fn drop(&mut self) { + self.shut_down(); + } + } + + // ── Disk accounting ───────────────────────────────────────────────── + + /// Sum of file sizes under `path`, recursively. Deliberately logical + /// (not `du`'s allocated blocks): a RocksDB checkpoint hard-links its + /// SSTs, and the number wanted here is how many bytes the source would + /// actually have to serve, not how much unique disk it occupies. + fn dir_size_bytes(path: &Path) -> u64 { + let mut total = 0; + let Ok(entries) = std::fs::read_dir(path) else { + return 0; + }; + for entry in entries.flatten() { + let Ok(meta) = entry.metadata() else { continue }; + if meta.is_dir() { + total += dir_size_bytes(&entry.path()); + } else { + total += meta.len(); + } + } + total + } + + fn mib(bytes: u64) -> f64 { + bytes as f64 / (1024.0 * 1024.0) + } + + // ── Source construction ───────────────────────────────────────────── + + /// Deterministic pseudo-random-ish key so insertion order does not + /// produce a degenerate (perfectly sequential) Merk shape. + fn scattered_key(i: usize) -> Vec { + // Multiply by a large odd constant and take the big-endian bytes: + // a cheap bijection over `u64` that scatters consecutive `i`. + (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15).to_be_bytes()[..] + .iter() + .copied() + .chain((i as u32).to_be_bytes()) + .collect() + } + + /// Deterministic **incompressible** value bytes. + /// + /// This matters for the measurement, not just for realism: a filler of + /// repeated bytes compresses to nothing in RocksDB's SSTs, which would + /// shrink the reported on-disk size by an order of magnitude and + /// inflate every "peak memory versus on-disk size" ratio derived from + /// it. + /// A xorshift stream keeps the source's on-disk footprint honest. + fn filler(seed: usize, len: usize) -> Vec { + let mut state = (seed as u64).wrapping_mul(0x2545_F491_4F6C_DD1D) | 1; + let mut out = Vec::with_capacity(len); + while out.len() < len { + state ^= state << 13; + state ^= state >> 7; + state ^= state << 17; + out.extend_from_slice(&state.to_le_bytes()); + } + out.truncate(len); + out + } + + /// Apply `ops` in fixed-size batches so the *build* side never becomes + /// the memory story the test is trying to measure on the restore side. + fn apply_in_batches(db: &GroveDb, ops: Vec, grove_version: &GroveVersion) { + const BATCH: usize = 2_000; + let mut buf = Vec::with_capacity(BATCH); + for op in ops { + buf.push(op); + if buf.len() == BATCH { + db.apply_batch(std::mem::take(&mut buf), None, None, grove_version) + .unwrap() + .expect("batch insert should succeed"); + buf.reserve(BATCH); + } + } + if !buf.is_empty() { + db.apply_batch(buf, None, None, grove_version) + .unwrap() + .expect("final batch insert should succeed"); + } + } + + /// Build the Platform-shaped grove described by `shape` at `path`. + fn build_platform_shaped_grove( + path: &Path, + shape: &GroveShape, + grove_version: &GroveVersion, + ) -> GroveDb { + let db = GroveDb::open(path).expect("open source grovedb"); + + let root_trees: &[(&[u8], Element)] = &[ + (b"identities", Element::empty_tree()), + (b"documents", Element::empty_tree()), + (b"balances", Element::empty_sum_tree()), + ( + b"notes", + Element::empty_commitment_tree(4).expect("valid chunk power"), + ), + (b"history", Element::empty_mmr_tree()), + (b"idx_count", Element::empty_provable_count_indexed_tree()), + (b"idx_sum", Element::empty_provable_sum_indexed_tree()), + ]; + for (key, element) in root_trees { + db.insert( + crate::SubtreePath::empty(), + key, + element.clone(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert root subtree"); + } + + // Identity-like items: one flat, wide subtree. + let ops = (0..shape.identities) + .map(|i| { + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"identities".to_vec()], + scattered_key(i), + Element::new_item(filler(i, shape.identity_value_bytes)), + ) + }) + .collect(); + apply_in_batches(&db, ops, grove_version); + + // Document-like items: nested `documents/contract_i/docs/*`. + for c in 0..shape.contracts { + let contract = format!("contract_{c}").into_bytes(); + db.insert( + [b"documents".as_ref()].as_ref(), + &contract, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert contract subtree"); + db.insert( + [b"documents".as_ref(), contract.as_ref()].as_ref(), + b"docs", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert docs subtree"); + + let ops = (0..shape.documents_per_contract) + .map(|i| { + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"documents".to_vec(), contract.clone(), b"docs".to_vec()], + scattered_key(i), + Element::new_item(filler(i + c, shape.document_value_bytes)), + ) + }) + .collect(); + apply_in_batches(&db, ops, grove_version); + } + + // Sum tree. + let ops = (0..shape.sum_entries) + .map(|i| { + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"balances".to_vec()], + scattered_key(i), + Element::new_sum_item((i as i64) * 7 - 3), + ) + }) + .collect(); + apply_in_batches(&db, ops, grove_version); + + // Commitment tree (non-Merk entry replay + Sinsemilla frontier). + // `cmx` / `rho` / `cv_net` must be canonical Pallas field elements, + // so only the low limb varies and the high bytes stay zero. + let field_element = |v: u64| { + let mut out = [0u8; 32]; + out[..8].copy_from_slice(&v.to_le_bytes()); + out + }; + for i in 0..shape.commitment_entries { + db.commitment_tree_insert_raw( + crate::SubtreePath::empty(), + b"notes", + field_element(i as u64), + field_element(i as u64 + 1_000_000), + field_element(i as u64 + 2_000_000), + filler(i, 216), + None, + grove_version, + ) + .unwrap() + .expect("append commitment note"); + } + + // MMR tree (non-Merk entry replay). + for i in 0..shape.mmr_entries { + db.mmr_tree_append( + crate::SubtreePath::empty(), + b"history", + filler(i, 48), + None, + grove_version, + ) + .unwrap() + .expect("append mmr leaf"); + } + + // Indexed trees (header page + primary chunks + one ordinary + // Merk chunk stream per axis secondary). + for i in 0..shape.indexed_entries { + db.insert_into_count_indexed_tree( + [b"idx_count".as_ref()].as_ref(), + &scattered_key(i), + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("insert PCIT entry"); + db.insert_into_provable_sum_indexed_tree( + [b"idx_sum".as_ref()].as_ref(), + &scattered_key(i), + Element::new_sum_item((i as i64) % 97), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + + db + } + + // ── Measurement ───────────────────────────────────────────────────── + + struct Measurement { + tier: &'static str, + commit_mode: RestoreCommitMode, + /// Intermediate commits the session took. Zero in atomic mode. + intermediate_commits: usize, + entries: u64, + logical_bytes: u64, + checkpoint_bytes: u64, + restored_bytes: u64, + baseline_rss: u64, + /// Footprint sampled at the last moment before `commit_session`, i.e. + /// with the entire sync write set accumulated in the transaction's + /// `WriteBatchWithIndex` but nothing yet flushed. + pre_commit_rss: u64, + peak_rss: u64, + wall: Duration, + build_wall: Duration, + fetch_calls: u64, + wire_bytes: u64, + } + + impl Measurement { + fn report(&self) { + println!( + "\n=== state sync restore memory ceiling: tier {} ===", + self.tier + ); + println!(" restore commit mode : {:>10?}", self.commit_mode); + println!( + " intermediate commits : {:>10}", + self.intermediate_commits + ); + println!( + " item value bytes : {:>10.1} MiB", + mib(self.logical_bytes) + ); + println!( + " source checkpoint : {:>10.1} MiB", + mib(self.checkpoint_bytes) + ); + println!( + " restored target on disk: {:>10.1} MiB", + mib(self.restored_bytes) + ); + println!( + " wire bytes fetched : {:>10.1} MiB", + mib(self.wire_bytes) + ); + println!(" fetch_chunk round trips: {:>10}", self.fetch_calls); + println!( + " build wall-clock : {:>10.1} s", + self.build_wall.as_secs_f64() + ); + println!( + " restore wall-clock : {:>10.1} s", + self.wall.as_secs_f64() + ); + println!( + " mem baseline (pre-sync): {:>10.1} MiB", + mib(self.baseline_rss) + ); + println!( + " mem before commit : {:>10.1} MiB", + mib(self.pre_commit_rss) + ); + println!( + " mem peak (during sync): {:>10.1} MiB", + mib(self.peak_rss) + ); + // Which of the two costs dominates decides the remedy: if the + // peak is already reached before commit, the write batch is + // the ceiling and only a scratch/staging strategy moves it; if + // the peak arrives during commit, it is RocksDB's flush and is + // tunable with write-buffer settings. + println!( + " write-batch share : {:>10.1} % of the increment is present pre-commit", + 100.0 * self.pre_commit_rss.saturating_sub(self.baseline_rss) as f64 + / self.peak_rss.saturating_sub(self.baseline_rss).max(1) as f64 + ); + println!( + " mem increment : {:>10.1} MiB", + mib(self.peak_rss.saturating_sub(self.baseline_rss)) + ); + println!( + " peak mem / checkpoint : {:>10.2} x", + self.peak_rss as f64 / self.checkpoint_bytes.max(1) as f64 + ); + println!( + " increment / checkpoint : {:>10.2} x", + self.peak_rss.saturating_sub(self.baseline_rss) as f64 + / self.checkpoint_bytes.max(1) as f64 + ); + println!(" item entries restored : {:>10}", self.entries); + println!( + " increment / entry : {:>10.1} B", + self.peak_rss.saturating_sub(self.baseline_rss) as f64 / self.entries.max(1) as f64 + ); + } + } + + /// Build → checkpoint → restore → commit, measuring the restore window. + fn measure_restore(shape: &GroveShape) -> Measurement { + let grove_version = GroveVersion::latest(); + let work = TempDir::new().expect("temp work dir"); + + let source_path = work.path().join("source"); + let checkpoint_path = work.path().join("checkpoint"); + let target_path = work.path().join("target"); + std::fs::create_dir_all(&source_path).expect("create source dir"); + std::fs::create_dir_all(&target_path).expect("create target dir"); + + let build_start = Instant::now(); + let source = build_platform_shaped_grove(&source_path, shape, grove_version); + let build_wall = build_start.elapsed(); + let source_hash = source + .root_hash(None, grove_version) + .unwrap() + .expect("source root hash"); + source + .create_checkpoint(&checkpoint_path) + .expect("create checkpoint"); + // Drop the source DB before measuring: only the checkpoint (the + // "remote peer") and the target participate in the restore, so the + // builder's block cache and memtables must not be counted. + drop(source); + + let checkpoint_bytes = dir_size_bytes(&checkpoint_path); + let checkpoint_db = GroveDb::open(&checkpoint_path).expect("open checkpoint db"); + let target = GroveDb::open(&target_path).expect("open target db"); + + let baseline_rss = current_memory_bytes().unwrap_or(0); + let sampler = RssSampler::start(); + let sync_start = Instant::now(); + + let commit_mode = commit_mode_from_env(); + let mut session = target + .start_snapshot_syncing_with_mode( + source_hash, + 64, + CURRENT_STATE_SYNC_VERSION, + commit_mode, + grove_version, + ) + .expect("start snapshot syncing"); + + let mut queue: VecDeque> = VecDeque::new(); + queue.push_back(source_hash.to_vec()); + let mut fetch_calls = 0u64; + let mut wire_bytes = 0u64; + + while let Some(chunk_id) = queue.pop_front() { + let chunk_data = checkpoint_db + .fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("fetch chunk"); + fetch_calls += 1; + wire_bytes += chunk_data.len() as u64; + let more = session + .apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("apply chunk"); + queue.extend(more); + } + + assert!(session.is_sync_completed(), "sync should have completed"); + // The whole sync write set is in the transaction's write batch and + // nothing has been flushed yet: this reading separates the batch's + // cost from the commit-time flush that follows. + let pre_commit_rss = current_memory_bytes().unwrap_or(0); + let intermediate_commits = session.intermediate_commits(); + target + .commit_session(session, grove_version) + .expect("commit session"); + + let wall = sync_start.elapsed(); + let peak_rss = sampler.finish(); + + assert_eq!( + target.root_hash(None, grove_version).unwrap().unwrap(), + source_hash, + "restored root hash must match the source app hash" + ); + + drop(checkpoint_db); + drop(target); + let restored_bytes = dir_size_bytes(&target_path); + + Measurement { + tier: shape.tier, + commit_mode, + intermediate_commits, + entries: shape.entry_count(), + logical_bytes: shape.logical_value_bytes(), + checkpoint_bytes, + restored_bytes, + baseline_rss, + pre_commit_rss, + peak_rss, + wall, + build_wall, + fetch_calls, + wire_bytes, + } + } + + fn run_tier(shape: &GroveShape) { + let measurement = measure_restore(shape); + measurement.report(); + } + + #[test] + #[ignore = "measurement harness: minutes of runtime, run explicitly in --release"] + fn restore_memory_ceiling_tier_tiny() { + run_tier(&TIER_TINY); + } + + #[test] + #[ignore = "measurement harness: minutes of runtime, run explicitly in --release"] + fn restore_memory_ceiling_tier_small() { + run_tier(&TIER_SMALL); + } + + #[test] + #[ignore = "measurement harness: minutes of runtime and >1 GiB of disk"] + fn restore_memory_ceiling_tier_medium() { + run_tier(&TIER_MEDIUM); + } + + #[test] + #[ignore = "measurement harness: tens of minutes and >4 GiB of disk and RAM"] + fn restore_memory_ceiling_tier_large() { + run_tier(&TIER_LARGE); + } + + #[test] + #[ignore = "measurement harness: write-batch data vs index attribution"] + fn restore_memory_shape_value_heavy() { + run_tier(&SHAPE_VALUE_HEAVY); + } + + #[test] + #[ignore = "measurement harness: write-batch data vs index attribution"] + fn restore_memory_shape_key_heavy() { + run_tier(&SHAPE_KEY_HEAVY); + } +} diff --git a/grovedb/src/tests/replication_session_tests.rs b/grovedb/src/tests/replication_session_tests.rs index bfd35e9c2..6177a017f 100644 --- a/grovedb/src/tests/replication_session_tests.rs +++ b/grovedb/src/tests/replication_session_tests.rs @@ -13,24 +13,53 @@ mod tests { Element, GroveDb, }; - /// Optional in-flight mutation of a commitment tree page: + /// Optional in-flight mutation of a non-Merk entry-replay page: /// `(more, aux, entries) -> (more, aux, entries)`. Used by tamper tests. - type CtPageMutator<'a> = + type NonMerkPageMutator<'a> = &'a dyn Fn(bool, Vec, Vec>) -> (bool, Vec, Vec>); + /// Optional in-flight mutation of one whole per-subtree chunk payload: + /// `(tree_type, global_chunk_id, payload) -> payload`. Used by the + /// indexed-tree tamper tests, which need to rewrite header pages and + /// Merk chunks rather than entry-replay pages. + type GlobalChunkMutator<'a> = + &'a dyn Fn(grovedb_merk::tree_type::TreeType, &[u8], Vec) -> Vec; + /// The single sync driver behind every test in this file: checkpoint the /// source (the standard replication pattern — the tutorial does the - /// same), run the fetch/apply loop with the given subtree batch size, - /// optionally mutating commitment tree pages in flight, verify - /// completion, and commit the session. - fn run_sync( + /// same), run the fetch/apply loop with the given subtree batch size + /// and state sync protocol version, optionally mutating chunk payloads + /// in flight, verify completion, and commit the session. + fn run_sync_with_version( + source: &TempGroveDb, + grove_version: &GroveVersion, + subtrees_batch_size: usize, + mutate_page: Option, + mutate_global: Option, + version: u16, + ) -> Result { + run_sync_with_version_and_mode( + source, + grove_version, + subtrees_batch_size, + mutate_page, + mutate_global, + version, + crate::replication::RestoreCommitMode::Atomic, + ) + } + + fn run_sync_with_version_and_mode( source: &TempGroveDb, grove_version: &GroveVersion, subtrees_batch_size: usize, - mutate_ct_page: Option, + mutate_page: Option, + mutate_global: Option, + version: u16, + mode: crate::replication::RestoreCommitMode, ) -> Result { use crate::replication::{ - non_merk_sync::{decode_non_merk_page, encode_non_merk_page}, + non_merk_sync::{decode_non_merk_page, encode_non_merk_page, supports_entry_replay}, utils::{decode_global_chunk_id, pack_nested_bytes, unpack_nested_bytes}, }; @@ -48,10 +77,11 @@ mod tests { let dest = make_empty_grovedb(); - let mut session = dest.start_snapshot_syncing( + let mut session = dest.start_snapshot_syncing_with_mode( app_hash, subtrees_batch_size, - CURRENT_STATE_SYNC_VERSION, + version, + mode, grove_version, )?; @@ -60,16 +90,12 @@ mod tests { chunk_queue.push_back(app_hash.to_vec()); while let Some(chunk_id) = chunk_queue.pop_front() { - let mut chunk_data = checkpoint_db.fetch_chunk( - chunk_id.as_slice(), - None, - CURRENT_STATE_SYNC_VERSION, - grove_version, - )?; + let mut chunk_data = + checkpoint_db.fetch_chunk(chunk_id.as_slice(), None, version, grove_version)?; - if let Some(mutate) = mutate_ct_page { - // Mirror apply_chunk's unpacking to find commitment tree - // pages and run them through the mutator. + if mutate_page.is_some() || mutate_global.is_some() { + // Mirror apply_chunk's unpacking to find the per-subtree + // payloads and run them through the mutators. let global_ids: Vec> = if chunk_id.as_slice() == app_hash.as_slice() { vec![chunk_id.clone()] } else { @@ -80,10 +106,10 @@ mod tests { let mut mutated_globals = Vec::with_capacity(global_data.len()); for (gid, gdata) in global_ids.iter().zip(global_data) { let (_, _, tree_type, _) = decode_global_chunk_id(gid, &app_hash)?; - if matches!( - tree_type, - grovedb_merk::tree_type::TreeType::CommitmentTree(_) - ) { + let mut gdata = gdata; + if let Some(mutate) = mutate_page + && supports_entry_replay(tree_type) + { let pages = unpack_nested_bytes(&gdata)?; let mut mutated_pages = Vec::with_capacity(pages.len()); for page in pages { @@ -91,20 +117,46 @@ mod tests { let (more, aux, entries) = mutate(more, aux, entries); mutated_pages.push(encode_non_merk_page(more, aux, entries)?); } - mutated_globals.push(pack_nested_bytes(mutated_pages)?); - } else { - mutated_globals.push(gdata); + gdata = pack_nested_bytes(mutated_pages)?; } + if let Some(mutate) = mutate_global { + gdata = mutate(tree_type, gid, gdata); + } + mutated_globals.push(gdata); } chunk_data = pack_nested_bytes(mutated_globals)?; } - let more_ids = session.apply_chunk( - chunk_id.as_slice(), - &chunk_data, - CURRENT_STATE_SYNC_VERSION, - grove_version, - )?; + let more_ids = + match session.apply_chunk(chunk_id.as_slice(), &chunk_data, version, grove_version) + { + Ok(ids) => ids, + Err(err) => { + let committed_early = session.intermediate_commits() > 0; + // Rejection must leave the session unusable even if a + // caller ignores the original error and tries to commit. + assert!( + !session.is_sync_completed(), + "failed sync reports completion: {err}" + ); + assert!(session + .apply_chunk(&chunk_id, &chunk_data, version, grove_version) + .is_err()); + assert!( + dest.commit_session(session, grove_version).is_err(), + "failed sync committed: {err}" + ); + if committed_early { + assert!(dest.has_incomplete_restore().unwrap()); + } else { + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + grovedb_merk::tree::hash::NULL_HASH + ); + } + return Err(err); + } + }; chunk_queue.extend(more_ids); } @@ -115,10 +167,56 @@ mod tests { )); } - dest.commit_session(session, grove_version)?; + let committed_early = session.intermediate_commits() > 0; + if matches!( + mode, + crate::replication::RestoreCommitMode::Incremental { + budget_bytes: 1, + .. + } + ) { + assert!( + committed_early, + "the one-byte-budget test must exercise intermediate commits" + ); + } + if let Err(err) = dest.commit_session(session, grove_version) { + if committed_early { + assert!( + dest.has_incomplete_restore().unwrap(), + "a failed final verification must preserve the incomplete marker" + ); + } else { + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + grovedb_merk::tree::hash::NULL_HASH, + "a failed final verification must roll back atomic restore" + ); + } + return Err(err); + } + assert!(!dest.has_incomplete_restore().unwrap()); Ok(dest) } + /// [`run_sync_with_version`] at `CURRENT_STATE_SYNC_VERSION` without a + /// global-chunk mutator. + fn run_sync( + source: &TempGroveDb, + grove_version: &GroveVersion, + subtrees_batch_size: usize, + mutate_page: Option, + ) -> Result { + run_sync_with_version( + source, + grove_version, + subtrees_batch_size, + mutate_page, + None, + CURRENT_STATE_SYNC_VERSION, + ) + } + /// Helper: perform a full state sync from source to destination, /// panicking on any error. /// @@ -130,6 +228,406 @@ mod tests { run_sync(source, grove_version, 64, None).expect("state sync should succeed") } + #[test] + fn state_sync_wrapped_reference_targets_round_trip() { + use crate::{ + batch::QualifiedGroveDbOp, reference_path::ReferencePathType::SiblingReference, + replication::RestoreCommitMode, + }; + + let version = GroveVersion::latest(); + for terminal in [ + Element::new_item(b"value".to_vec()), + Element::new_sum_item(11), + Element::new_item_with_sum_item(b"value".to_vec(), 11), + ] { + let source = make_empty_grovedb(); + source + .insert( + &[] as &[&[u8]], + b"ct", + Element::empty_count_sum_tree(), + None, + None, + version, + ) + .unwrap() + .unwrap(); + let target = Element::new_non_counted(terminal.clone()).unwrap(); + let batch_ref = Element::new_reference(SiblingReference(b"target".to_vec())); + let chain = Element::new_non_counted(Element::new_reference_with_sum_item( + SiblingReference(b"batch_ref".to_vec()), + 7, + )) + .unwrap(); + // The target and both references are written together, exercising + // batch resolution through a wrapped ReferenceWithSumItem as well. + source + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"target".to_vec(), + target.clone(), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"batch_ref".to_vec(), + batch_ref, + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"chain".to_vec(), + chain.clone(), + ), + ], + None, + None, + version, + ) + .unwrap() + .unwrap(); + // Cover both paths for already-persisted batch targets: a one-hop + // stored-hash lookup and resolution through an intermediate ref. + source + .apply_batch( + vec![ + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"one_hop".to_vec(), + Element::new_reference_with_hops( + SiblingReference(b"target".to_vec()), + Some(1), + ), + ), + QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"persisted_chain".to_vec(), + Element::new_reference(SiblingReference(b"chain".to_vec())), + ), + ], + None, + None, + version, + ) + .unwrap() + .unwrap(); + // Direct inserts strip the terminal wrapper before hashing. These + // references must keep working alongside the batch-created ones. + source + .insert( + [b"ct"].as_ref(), + b"direct_ref", + Element::new_reference(SiblingReference(b"chain".to_vec())), + None, + None, + version, + ) + .unwrap() + .unwrap(); + + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + let dest = run_sync_with_version_and_mode( + &source, + version, + 1, + None, + None, + CURRENT_STATE_SYNC_VERSION, + mode, + ) + .expect("wrapped reference targets must sync"); + assert_eq!( + source.root_hash(None, version).unwrap().unwrap(), + dest.root_hash(None, version).unwrap().unwrap() + ); + assert_eq!( + dest.get_raw([b"ct"].as_ref().into(), b"target", None, version) + .unwrap() + .unwrap(), + target + ); + assert_eq!( + dest.get_raw([b"ct"].as_ref().into(), b"chain", None, version) + .unwrap() + .unwrap(), + chain + ); + for key in [ + b"batch_ref".as_slice(), + b"chain", + b"one_hop", + b"persisted_chain", + b"direct_ref", + ] { + assert_eq!( + dest.get([b"ct"].as_ref(), key, None, version) + .unwrap() + .unwrap(), + terminal + ); + } + } + } + } + + #[test] + fn state_sync_forged_reference_to_wrapped_target_is_rejected() { + use crate::{ + batch::QualifiedGroveDbOp, + reference_path::ReferencePathType::SiblingReference, + replication::{ + utils::{decode_vec_ops, encode_vec_ops, pack_nested_bytes, unpack_nested_bytes}, + RestoreCommitMode, + }, + }; + use grovedb_merk::{ + proofs::{Node, Op}, + tree_type::TreeType, + }; + use std::cell::Cell; + + let version = GroveVersion::latest(); + for direct_insert in [false, true] { + let source = make_empty_grovedb(); + source + .insert( + &[] as &[&[u8]], + b"ct", + Element::empty_count_sum_tree(), + None, + None, + version, + ) + .unwrap() + .unwrap(); + for (key, value) in [ + (b"target".as_slice(), b"value".as_slice()), + (b"other", b"wrong"), + ] { + source + .insert( + [b"ct"].as_ref(), + key, + Element::new_non_counted(Element::new_item(value.to_vec())).unwrap(), + None, + None, + version, + ) + .unwrap() + .unwrap(); + } + let reference = Element::new_reference(SiblingReference(b"target".to_vec())); + if direct_insert { + source + .insert([b"ct"].as_ref(), b"ref", reference, None, None, version) + .unwrap() + .unwrap(); + } else { + source + .apply_batch( + vec![QualifiedGroveDbOp::insert_or_replace_op( + vec![b"ct".to_vec()], + b"ref".to_vec(), + reference, + )], + None, + None, + version, + ) + .unwrap() + .unwrap(); + } + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + let mutated = Cell::new(false); + let error = run_sync_with_version_and_mode( + &source, + version, + 1, + None, + Some(&|tree_type, _, payload| { + if tree_type != TreeType::CountSumTree { + return payload; + } + let chunks = unpack_nested_bytes(&payload) + .unwrap() + .into_iter() + .map(|chunk| { + let ops = decode_vec_ops(&chunk) + .unwrap() + .into_iter() + .map(|op| match op { + Op::Push(Node::KVValueHashFeatureType( + key, + _, + hash, + feature, + )) if key == b"ref" => { + mutated.set(true); + let forged = Element::new_reference(SiblingReference( + b"other".to_vec(), + )); + Op::Push(Node::KVValueHashFeatureType( + key, + forged.serialize(version).unwrap(), + hash, + feature, + )) + } + other => other, + }) + .collect(); + encode_vec_ops(ops).unwrap() + }) + .collect(); + pack_nested_bytes(chunks).unwrap() + }), + CURRENT_STATE_SYNC_VERSION, + mode, + ) + .err() + .expect("forged reference cannot commit"); + assert!(mutated.get()); + assert!( + format!("{error}").contains("value hash mismatch"), + "{error}" + ); + } + } + } + + #[test] + fn state_sync_legacy_empty_aggregate_trees_round_trip_after_upgrade() { + use crate::replication::RestoreCommitMode; + use grovedb_version::version::{v1::GROVE_V1, v2::GROVE_V2}; + + for write_version in [&GROVE_V1, &GROVE_V2] { + let source = make_empty_grovedb(); + for (key, element) in [ + (b"cs".as_slice(), Element::empty_count_sum_tree()), + (b"pc", Element::empty_provable_count_tree()), + (b"pcs", Element::empty_provable_count_sum_tree()), + ] { + source + .insert(&[] as &[&[u8]], key, element, None, None, write_version) + .unwrap() + .unwrap(); + } + // Unchanged legacy entries retain their plain value hash after + // upgrading: the restore version cannot identify their encoding. + for read_version in [write_version, GroveVersion::latest()] { + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + let dest = run_sync_with_version_and_mode( + &source, + read_version, + 1, + None, + None, + CURRENT_STATE_SYNC_VERSION, + mode, + ) + .expect("legacy empty trees must sync"); + assert_eq!( + source.root_hash(None, read_version).unwrap().unwrap(), + dest.root_hash(None, read_version).unwrap().unwrap() + ); + for key in [b"cs".as_slice(), b"pc", b"pcs"] { + assert_eq!( + source + .get_raw((&[] as &[&[u8]]).into(), key, None, read_version) + .unwrap() + .unwrap(), + dest.get_raw((&[] as &[&[u8]]).into(), key, None, read_version) + .unwrap() + .unwrap() + ); + } + } + } + } + } + + #[test] + fn state_sync_populated_legacy_aggregate_trees_reject_empty_payloads() { + use crate::replication::{utils::pack_nested_bytes, RestoreCommitMode}; + use grovedb_merk::tree_type::TreeType; + use grovedb_version::version::v2::GROVE_V2; + use std::cell::Cell; + + for element in [ + Element::empty_count_sum_tree(), + Element::empty_provable_count_tree(), + Element::empty_provable_count_sum_tree(), + ] { + let source = make_empty_grovedb(); + source + .insert(&[] as &[&[u8]], b"ct", element, None, None, &GROVE_V2) + .unwrap() + .unwrap(); + source + .insert( + [b"ct"].as_ref(), + b"item", + Element::new_item(b"value".to_vec()), + None, + None, + &GROVE_V2, + ) + .unwrap() + .unwrap(); + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + let mutated = Cell::new(false); + let error = run_sync_with_version_and_mode( + &source, + GroveVersion::latest(), + 1, + None, + Some(&|tree_type, _, payload| { + if tree_type != TreeType::NormalTree { + mutated.set(true); + pack_nested_bytes(vec![vec![]]).unwrap() + } else { + payload + } + }), + CURRENT_STATE_SYNC_VERSION, + mode, + ) + .err() + .expect("populated trees cannot be omitted"); + assert!(mutated.get()); + assert!( + format!("{error}").contains("empty payload for a subtree"), + "{error}" + ); + } + } + } + #[test] fn start_snapshot_syncing_returns_session() { let grove_version = GroveVersion::latest(); @@ -634,6 +1132,124 @@ mod tests { ); } + /// Atomicity across discovery batches (issue #775): with a batch size + /// of 1, earlier subtrees complete (and cross a batch boundary) before + /// a later chunk fails. Nothing may be persisted by the failed sync — + /// every restored subtree must stay inside the session transaction and + /// roll back when the session is dropped. + #[test] + fn failed_chunk_after_batched_sync_rolls_back_all_restored_subtrees() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + b"sub", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("should insert subtree"); + source + .insert( + [TEST_LEAF, b"sub"].as_ref(), + b"nested", + Element::new_item(b"value".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("should insert nested item"); + + let source_hash = source + .root_hash(None, grove_version) + .unwrap() + .expect("should get source hash"); + + let dest = make_empty_grovedb(); + let empty_dest_hash = dest + .root_hash(None, grove_version) + .unwrap() + .expect("should get empty destination hash"); + let mut session = dest + .start_snapshot_syncing(source_hash, 1, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("should start snapshot syncing"); + + let root_chunk_data = source + .fetch_chunk( + source_hash.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("should fetch root chunk"); + let next_chunk_ids = session + .apply_chunk( + source_hash.as_slice(), + &root_chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("should apply root chunk"); + assert!( + !next_chunk_ids.is_empty(), + "root chunk should discover child subtree chunks" + ); + + // Even before the failure, nothing of the root subtree may be + // visible outside the session transaction. + assert_eq!( + dest.root_hash(None, grove_version) + .unwrap() + .expect("should get destination hash mid-sync"), + empty_dest_hash, + "in-flight sync must not persist restored subtrees" + ); + + let next_chunk_id = next_chunk_ids.first().expect("expected next chunk id"); + let mut corrupt_chunk_data = source + .fetch_chunk( + next_chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("should fetch child chunk"); + let last_byte = corrupt_chunk_data + .last_mut() + .expect("chunk data should not be empty"); + *last_byte ^= 0xFF; + + let err = session + .apply_chunk( + next_chunk_id.as_slice(), + &corrupt_chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect_err("corrupt child chunk should fail"); + let err_msg = format!("{err:?}"); + assert!( + err_msg.contains("Unable to finalize Merk") + || err_msg.contains("Unable to process incoming chunk") + || err_msg.contains("Unable to decode incoming chunk") + || err_msg.contains("Corrupted"), + "unexpected error: {err:?}" + ); + drop(session); + assert_eq!( + dest.root_hash(None, grove_version) + .unwrap() + .expect("should get destination hash after failed sync"), + empty_dest_hash, + "failed sync must not persist any restored subtree" + ); + } + #[test] fn sync_with_empty_subtree_succeeds() { let grove_version = GroveVersion::latest(); @@ -679,33 +1295,219 @@ mod tests { assert_eq!(source_hash, dest_hash); } + /// The other end of the empty-payload check: an entirely empty grove + /// is the one case where the ROOT subtree legitimately answers with + /// no chunk at all. Its restorer carries the `app_hash` directly + /// rather than a parent binding, so the empty-tree commitment it must + /// match is the bare `NULL_HASH` — a case the ordinary + /// `combine_hash(H(element), NULL_HASH)` form would reject. #[test] - fn is_sync_completed_returns_false_before_any_sync() { + fn sync_of_an_entirely_empty_grove_succeeds() { let grove_version = GroveVersion::latest(); - let dest = make_empty_grovedb(); - let session = crate::replication::MultiStateSyncSession::new(&dest, [0u8; 32], 64); - assert!( - !session.is_sync_completed(), - "is_sync_completed should return false when no sync has ever started" + let source = make_empty_grovedb(); + let dest = run_sync(&source, grove_version, 64, None).expect("empty grove should sync"); + + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), ); } + /// Byzantine-source coverage for the empty-payload path. + /// + /// An empty per-subtree payload is the honest wire signal for a + /// subtree that really is empty: the source finds `is_empty_tree()` + /// and sends no chunk. Nothing is applied for it, so no chunk is ever + /// verified against the hash the parent committed to. + /// + /// A source that answers "empty" for a POPULATED subtree must + /// therefore be rejected right at completion. Nothing downstream can + /// catch it: the restored Merk's root hash is NULL either way, and + /// the final GroveDB root-hash check cannot see it — the parent Merk + /// already stores the source-committed combined child hash, and the + /// root hash is never re-derived from the child's actual contents. + /// Left unchecked, a byzantine source silently nulls out any subtree + /// (and everything below it) and still commits a "verified" restore. #[test] - fn fetch_chunk_unsupported_version_error() { + fn state_sync_empty_payload_for_a_populated_subtree_is_rejected() { + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + use crate::replication::utils::{ + decode_global_chunk_id, pack_nested_bytes, unpack_nested_bytes, + }; + let grove_version = GroveVersion::latest(); let source = make_test_grovedb(grove_version); - let app_hash = source - .root_hash(None, grove_version) + source + .insert( + [TEST_LEAF].as_ref(), + b"child", + Element::empty_tree(), + None, + None, + grove_version, + ) .unwrap() - .expect("should get root hash"); - - let result = source.fetch_chunk( - &app_hash, - None, - 0, // unsupported version - grove_version, - ); + .expect("should insert child subtree"); + for i in 0..8u32 { + source + .insert( + [TEST_LEAF, b"child"].as_ref(), + &i.to_be_bytes(), + Element::new_item(vec![i as u8; 32]), + None, + None, + grove_version, + ) + .unwrap() + .expect("should insert item into child"); + } + // A grandchild, so a successful hollowing-out would also erase a + // whole branch rather than a single subtree's leaves. + source + .insert( + [TEST_LEAF, b"child"].as_ref(), + b"grandchild", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("should insert grandchild subtree"); + + let source_hash = source + .root_hash(None, grove_version) + .unwrap() + .expect("should get source hash"); + + // The subtree the byzantine source will claim is empty. + let victim_path: &[&[u8]] = &[TEST_LEAF, b"child"]; + let victim_prefix = RocksDbStorage::build_prefix(victim_path.into()).unwrap(); + + let dest = make_empty_grovedb(); + let empty_dest_hash = dest + .root_hash(None, grove_version) + .unwrap() + .expect("should get empty destination hash"); + let mut session = dest + .start_snapshot_syncing(source_hash, 64, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("should start snapshot syncing"); + + let mut chunk_queue: VecDeque> = VecDeque::new(); + chunk_queue.push_back(source_hash.to_vec()); + + let mut hollowed_out = false; + let mut apply_error = None; + while let Some(chunk_id) = chunk_queue.pop_front() { + let chunk_data = source + .fetch_chunk( + chunk_id.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + .expect("should fetch chunk"); + + // Replace the victim's payload with exactly what the source + // emits for a genuinely empty Merk: one empty local chunk. + let global_ids: Vec> = if chunk_id.as_slice() == source_hash.as_slice() { + vec![chunk_id.clone()] + } else { + unpack_nested_bytes(&chunk_id).expect("should unpack chunk ids") + }; + let global_data = + unpack_nested_bytes(&chunk_data).expect("should unpack chunk payloads"); + assert_eq!(global_ids.len(), global_data.len()); + let mut mutated = Vec::with_capacity(global_data.len()); + for (gid, gdata) in global_ids.iter().zip(global_data) { + let (prefix, ..) = + decode_global_chunk_id(gid, &source_hash).expect("should decode chunk id"); + if prefix == victim_prefix { + hollowed_out = true; + mutated.push(pack_nested_bytes(vec![vec![]]).expect("should pack empty chunk")); + } else { + mutated.push(gdata); + } + } + let chunk_data = pack_nested_bytes(mutated).expect("should repack payloads"); + + match session.apply_chunk( + chunk_id.as_slice(), + &chunk_data, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) { + Ok(more_ids) => chunk_queue.extend(more_ids), + Err(e) => { + apply_error = Some(e); + break; + } + } + } + + assert!( + hollowed_out, + "the test never reached the victim subtree, so it proves nothing" + ); + let err = apply_error.expect( + "an empty payload for a populated subtree must be rejected while applying chunks", + ); + assert!( + format!("{err}").contains("empty payload for a subtree"), + "unexpected error: {err}" + ); + + assert!( + !session.is_sync_completed(), + "rejected subtree must keep the sync incomplete" + ); + assert!( + dest.commit_session(session, grove_version).is_err(), + "rejected subtree must prevent commit" + ); + assert_eq!( + dest.root_hash(None, grove_version) + .unwrap() + .expect("should get destination hash after the rejected sync"), + empty_dest_hash, + "a rejected sync must leave the destination untouched" + ); + } + + #[test] + fn is_sync_completed_returns_false_before_any_sync() { + let dest = make_empty_grovedb(); + let session = crate::replication::MultiStateSyncSession::new( + &dest, + [0u8; 32], + 64, + CURRENT_STATE_SYNC_VERSION, + crate::replication::RestoreCommitMode::Atomic, + ); + assert!( + !session.is_sync_completed(), + "is_sync_completed should return false when no sync has ever started" + ); + } + + #[test] + fn fetch_chunk_unsupported_version_error() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + let app_hash = source + .root_hash(None, grove_version) + .unwrap() + .expect("should get root hash"); + + let result = source.fetch_chunk( + &app_hash, + None, + 0, // unsupported version + grove_version, + ); assert!( result.is_err(), @@ -726,7 +1528,13 @@ mod tests { let dest = make_empty_grovedb(); // Create a session that has never synced any chunks - let session = crate::replication::MultiStateSyncSession::new(&dest, [0xAB; 32], 64); + let session = crate::replication::MultiStateSyncSession::new( + &dest, + [0xAB; 32], + 64, + CURRENT_STATE_SYNC_VERSION, + crate::replication::RestoreCommitMode::Atomic, + ); // commit() should reject because the session is incomplete let err = dest @@ -742,204 +1550,6 @@ mod tests { } } - // ---------- Indexed-tree state-sync rejection ---------- - // - // State sync cannot yet handle indexed trees: their primaries commit a - // three-input `combine_hash_three` (the restorer only knows the - // two-input combine), and their axis secondary namespaces are never - // enumerated during discovery. Rather than failing midway with an - // opaque "chunk doesn't match expected root hash", both the source - // side (`fetch_chunk`) and the target side (discovery in - // `discover_new_subtrees_metadata`) now reject up-front with a - // descriptive `Error::NotSupported`. - - fn assert_not_supported_indexed(err: &crate::Error, context: &str) { - let msg = format!("{err:?}"); - assert!( - matches!(err, crate::Error::NotSupported(_)), - "{context}: expected Error::NotSupported, got: {msg}" - ); - assert!( - msg.contains("indexed"), - "{context}: error should mention indexed trees, got: {msg}" - ); - } - - /// Drive the full source->destination sync loop (mirroring - /// `sync_source_to_destination`) but return the first error instead of - /// panicking, so the test can assert on it. - fn try_sync_source_to_destination( - source: &TempGroveDb, - grove_version: &GroveVersion, - ) -> Result<(), crate::Error> { - run_sync(source, grove_version, 64, None).map(|_| ()) - } - - #[test] - fn state_sync_rejects_populated_pcit_up_front() { - let grove_version = GroveVersion::latest(); - let source = make_test_grovedb(grove_version); - - source - .insert( - [TEST_LEAF].as_ref(), - b"pcit", - Element::empty_provable_count_indexed_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("create PCIT"); - // Children enter EMPTY and are populated so their counts are - // DERIVED. All state sync needs is a populated PCIT; how the - // aggregate was produced is irrelevant to the rejection. - for (k, c) in &[(b"a" as &[u8], 3u64), (b"b" as &[u8], 7u64)] { - source - .insert_into_count_indexed_tree( - [TEST_LEAF, b"pcit"].as_ref(), - k, - Element::empty_provable_count_tree(), - None, - grove_version, - ) - .unwrap() - .expect("insert PCIT entry"); - for i in 0..*c { - source - .insert( - [TEST_LEAF, b"pcit", k].as_ref(), - &i.to_be_bytes(), - Element::new_item(vec![]), - None, - None, - grove_version, - ) - .unwrap() - .expect("derive PCIT entry count"); - } - } - - let err = try_sync_source_to_destination(&source, grove_version) - .expect_err("state sync of a DB containing a populated PCIT must fail up-front"); - assert_not_supported_indexed(&err, "PCIT sync"); - } - - #[test] - fn state_sync_rejects_populated_psit_up_front() { - let grove_version = GroveVersion::latest(); - let source = make_test_grovedb(grove_version); - - source - .insert( - [TEST_LEAF].as_ref(), - b"psit", - Element::empty_provable_sum_indexed_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("create PSIT"); - for (k, s) in &[(b"a" as &[u8], 4i64), (b"b" as &[u8], -2i64)] { - source - .insert_into_provable_sum_indexed_tree( - [TEST_LEAF, b"psit"].as_ref(), - k, - Element::new_sum_item(*s), - None, - grove_version, - ) - .unwrap() - .expect("insert PSIT entry"); - } - - let err = try_sync_source_to_destination(&source, grove_version) - .expect_err("state sync of a DB containing a populated PSIT must fail up-front"); - assert_not_supported_indexed(&err, "PSIT sync"); - } - - #[test] - fn fetch_chunk_source_side_rejects_indexed_tree_chunk() { - // Directly exercise the source-side `fetch_chunk` rejection: build - // the global chunk id for the PCIT subtree's own prefix and ask - // the source to produce it. The source must reject with - // NotSupported rather than emitting a chunk. - use crate::replication::utils::{encode_global_chunk_id, pack_nested_bytes}; - - let grove_version = GroveVersion::latest(); - let source = make_test_grovedb(grove_version); - - source - .insert( - [TEST_LEAF].as_ref(), - b"pcit", - Element::empty_provable_count_indexed_tree(), - None, - None, - grove_version, - ) - .unwrap() - .expect("create PCIT"); - // Empty child plus one item inside it: a non-empty PCIT whose - // count is DERIVED, which is all fetch_chunk needs to reject. - source - .insert_into_count_indexed_tree( - [TEST_LEAF, b"pcit"].as_ref(), - b"a", - Element::empty_provable_count_tree(), - None, - grove_version, - ) - .unwrap() - .expect("insert PCIT entry"); - source - .insert( - [TEST_LEAF, b"pcit", b"a"].as_ref(), - b"row", - Element::new_item(b"v".to_vec()), - None, - None, - grove_version, - ) - .unwrap() - .expect("derive PCIT entry count"); - - // Read the PCIT element to get its root key and confirm tree type. - let tx = source.start_transaction(); - let (merk, root_key, tree_type, _element) = source - .open_merk_for_replication([TEST_LEAF, b"pcit"].as_ref().into(), &tx, grove_version) - .expect("open pcit merk for replication"); - drop(merk); - assert!( - tree_type.is_indexed_primary(), - "sanity: opened tree must be an indexed primary, got {tree_type:?}" - ); - - let pcit_path: &[&[u8]] = &[TEST_LEAF, b"pcit"]; - let prefix = grovedb_storage::rocksdb_storage::RocksDbStorage::build_prefix( - pcit_path.as_ref().into(), - ) - .unwrap(); - let global_chunk_id = - encode_global_chunk_id(prefix, root_key, tree_type, vec![]).expect("encode chunk id"); - // fetch_chunk unpacks its input as nested bytes when the length - // differs from the root-hash length, then decodes each element as - // a global chunk id. Pack the single id the same way the wire - // protocol does. - let packed = pack_nested_bytes(vec![global_chunk_id]).expect("pack chunk id"); - - let err = source - .fetch_chunk( - packed.as_slice(), - Some(&tx), - CURRENT_STATE_SYNC_VERSION, - grove_version, - ) - .expect_err("source-side fetch_chunk of an indexed tree must be rejected"); - assert_not_supported_indexed(&err, "source-side fetch_chunk"); - } - fn assert_not_supported_append_only(err: &crate::Error, context: &str) { let msg = format!("{err:?}"); assert!( @@ -1075,10 +1685,10 @@ mod tests { ); } - /// A peer speaking the pre-#785 protocol requests an append-only - /// subtree the old way — with no page cursor in the global chunk id. - /// The source must reject that request descriptively instead of trying - /// (and opaquely failing) to build a Merk chunk producer. + /// An append-only subtree request must carry a page cursor in the + /// global chunk id. The source must reject a cursor-less request + /// descriptively instead of trying (and opaquely failing) to build a + /// Merk chunk producer. #[test] fn fetch_chunk_rejects_append_only_request_without_page_cursor() { use crate::replication::utils::{encode_global_chunk_id, pack_nested_bytes}; @@ -1125,7 +1735,7 @@ mod tests { let prefix = grovedb_storage::rocksdb_storage::RocksDbStorage::build_prefix(ct_path.as_ref().into()) .unwrap(); - // No nested chunk ids — the shape an old peer would send. + // No nested chunk ids — a malformed, cursor-less request. let global_chunk_id = encode_global_chunk_id(prefix, root_key, tree_type, vec![]).expect("encode chunk id"); let packed = pack_nested_bytes(vec![global_chunk_id]).expect("pack chunk id"); @@ -1245,10 +1855,10 @@ mod tests { ); } - /// `PrivateDocumentStore` also uses non-Merk data storage but has no - /// entry-replay arm yet. An EMPTY one must keep syncing through the - /// ordinary Merk path (exactly as before the append-only work), and the - /// restored store must be usable afterwards. + /// An EMPTY `PrivateDocumentStore` syncs through the entry-replay path + /// with a single empty page; verification reduces to the + /// config-parametrized empty state root, and the restored store must be + /// usable afterwards. #[test] fn state_sync_empty_private_document_store_round_trip() { let grove_version = GroveVersion::latest(); @@ -1295,14 +1905,12 @@ mod tests { ); } - /// A POPULATED `PrivateDocumentStore` cannot be transferred yet: the - /// target rejects it descriptively at discovery (never a silent - /// truncation to an empty store), and the source rejects a chunk request - /// for it descriptively too. + /// Full state-sync round trip for a POPULATED `PrivateDocumentStore` + /// (issues #783 / #784). Uses chunk_power 2 (epoch of 4) with 10 + /// fixed-size documents so the payload spans two compacted chunk blobs + /// AND the current buffer. #[test] - fn state_sync_rejects_populated_private_document_store_up_front() { - use crate::replication::utils::{encode_global_chunk_id, pack_nested_bytes}; - + fn state_sync_populated_private_document_store_round_trip() { let grove_version = GroveVersion::latest(); let source = make_test_grovedb(grove_version); @@ -1317,51 +1925,178 @@ mod tests { ) .unwrap() .expect("insert private document store"); + // A sibling item so the parent subtree holds mixed content. source - .private_document_store_insert( + .insert( [TEST_LEAF].as_ref(), - b"docs", - vec![1u8; 16], + b"sibling", + Element::new_item(b"item next to the docs".to_vec()), + None, None, grove_version, ) .unwrap() - .expect("insert document"); - - let err = try_sync_source_to_destination(&source, grove_version) - .expect_err("state sync of a DB containing a populated PDS must fail up-front"); - let msg = format!("{err:?}"); - assert!( - matches!(err, crate::Error::NotSupported(_)) && msg.contains("populated"), - "target-side: expected descriptive NotSupported, got: {msg}" - ); + .expect("insert sibling item"); - // Source side: the Merk-path request shape for this subtree. - let tx = source.start_transaction(); - let (merk, root_key, tree_type, _element) = source - .open_merk_for_replication([TEST_LEAF, b"docs"].as_ref().into(), &tx, grove_version) - .expect("open pds merk for replication"); - drop(merk); - let pds_path: &[&[u8]] = &[TEST_LEAF, b"docs"]; - let prefix = grovedb_storage::rocksdb_storage::RocksDbStorage::build_prefix( - pds_path.as_ref().into(), - ) - .unwrap(); - let global_chunk_id = - encode_global_chunk_id(prefix, root_key, tree_type, vec![]).expect("encode chunk id"); - let packed = pack_nested_bytes(vec![global_chunk_id]).expect("pack chunk id"); - let err = source - .fetch_chunk( - packed.as_slice(), - Some(&tx), - CURRENT_STATE_SYNC_VERSION, + for i in 0u8..10 { + source + .private_document_store_insert( + [TEST_LEAF].as_ref(), + b"docs", + vec![i; 16], + None, + grove_version, + ) + .unwrap() + .expect("insert document"); + } + + let dest = sync_source_to_destination(&source, grove_version); + + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "app hash must match" + ); + + // Every document survives, both in the compacted chunks (positions + // 0..8) and in the buffer (positions 8..10). + for pos in 0u64..10 { + let source_value = source + .private_document_store_get_value( + [TEST_LEAF].as_ref(), + b"docs", + pos, + None, + grove_version, + ) + .unwrap() + .expect("source document") + .expect("source document present"); + let dest_value = dest + .private_document_store_get_value( + [TEST_LEAF].as_ref(), + b"docs", + pos, + None, + grove_version, + ) + .unwrap() + .expect("dest document") + .expect("dest document present"); + assert_eq!(source_value, dest_value, "document {pos} must match"); + } + + // The destination passes a full integrity check. + let dest_issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("dest verify_grovedb should run"); + assert!( + dest_issues.is_empty(), + "destination must verify clean, got: {:?}", + dest_issues + ); + + // The restored store is fully usable for future writes: appending + // the same document on both sides keeps the states identical. + for db in [&source, &dest] { + db.private_document_store_insert( + [TEST_LEAF].as_ref(), + b"docs", + vec![42u8; 16], + None, grove_version, ) - .expect_err("populated PDS chunk request must be rejected"); - let msg = format!("{err:?}"); + .unwrap() + .expect("post-sync document insert"); + } + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "post-sync inserts must produce identical states" + ); + } + + /// Byzantine-source coverage for `PrivateDocumentStore` pages: a + /// flipped document byte, a wrong-sized document, and a dropped + /// document must all fail the sync instead of committing corrupt + /// state. The wrong-size case is rejected at replay time by the + /// committed `entry_size` from the target's hash-verified element, + /// before the finalize-time state-root check even runs. + #[test] + fn state_sync_private_document_store_tampered_pages_rejected() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + b"docs", + Element::empty_private_document_store(16, 2).expect("valid config"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert private document store"); + for i in 0u8..6 { + source + .private_document_store_insert( + [TEST_LEAF].as_ref(), + b"docs", + vec![i; 16], + None, + grove_version, + ) + .unwrap() + .expect("insert document"); + } + + // Sanity: with the identity mutation the sync completes. + try_sync_with_page_mutation(&source, grove_version, &|more, aux, entries| { + (more, aux, entries) + }) + .expect("un-tampered sync must succeed"); + + // 1. Flip one byte of one document: the replayed payload no longer + // hashes to the bound state root. + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, mut entries| { + if let Some(first) = entries.first_mut() { + first[0] ^= 0x01; + } + (more, aux, entries) + }) + .expect_err("flipped document byte must be rejected"); + assert!( + format!("{err:?}").contains("state root mismatch after replay"), + "expected state-root rejection, got: {err:?}" + ); + + // 2. A document of the wrong size: rejected by the committed + // entry_size before anything is written. + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, mut entries| { + if let Some(first) = entries.first_mut() { + first.push(0xAB); + } + (more, aux, entries) + }) + .expect_err("wrong-sized document must be rejected"); + assert!( + format!("{err:?}").contains("cannot replay private document store entries"), + "expected entry-size rejection, got: {err:?}" + ); + + // 3. Drop the last document while still claiming the page is final. + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, mut entries| { + if !more { + entries.pop(); + } + (more, aux, entries) + }) + .expect_err("dropped document must be rejected"); assert!( - matches!(err, crate::Error::NotSupported(_)) && msg.contains("populated"), - "source-side: expected descriptive NotSupported, got: {msg}" + format!("{err:?}").contains("replay incomplete"), + "expected incomplete-replay rejection, got: {err:?}" ); } @@ -1631,14 +2366,14 @@ mod tests { assert!(dest_issues.is_empty(), "got: {:?}", dest_issues); } - /// Drive the full sync loop while mutating the wire bytes of commitment - /// tree pages. Every mutation must be rejected before the session can - /// complete — the target recomputes the state root from the replayed - /// payload and checks it against the parent binding. - fn try_sync_with_ct_page_mutation( + /// Drive the full sync loop while mutating the wire bytes of non-Merk + /// entry-replay pages. Every mutation must be rejected before the + /// session can complete — the target recomputes the state root from the + /// replayed payload and checks it against the parent binding. + fn try_sync_with_page_mutation( source: &TempGroveDb, grove_version: &GroveVersion, - mutate_page: CtPageMutator, + mutate_page: NonMerkPageMutator, ) -> Result<(), crate::Error> { run_sync(source, grove_version, 64, Some(mutate_page)).map(|_| ()) } @@ -1679,28 +2414,27 @@ mod tests { } // Sanity: with the identity mutation the sync completes. - try_sync_with_ct_page_mutation(&source, grove_version, &|more, aux, entries| { + try_sync_with_page_mutation(&source, grove_version, &|more, aux, entries| { (more, aux, entries) }) .expect("un-tampered sync must succeed"); // 1. Flip one byte of one entry: the replayed payload no longer // hashes to the bound state root. - let err = - try_sync_with_ct_page_mutation(&source, grove_version, &|more, aux, mut entries| { - if let Some(first) = entries.first_mut() { - first[0] ^= 0x01; - } - (more, aux, entries) - }) - .expect_err("flipped entry byte must be rejected"); + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, mut entries| { + if let Some(first) = entries.first_mut() { + first[0] ^= 0x01; + } + (more, aux, entries) + }) + .expect_err("flipped entry byte must be rejected"); assert!( format!("{err:?}").contains("state root mismatch after replay"), "expected state-root rejection, got: {err:?}" ); // 2. Strip the frontier from the first page. - let err = try_sync_with_ct_page_mutation(&source, grove_version, &|more, _aux, entries| { + let err = try_sync_with_page_mutation(&source, grove_version, &|more, _aux, entries| { (more, Vec::new(), entries) }) .expect_err("stripped frontier must be rejected"); @@ -1711,15 +2445,14 @@ mod tests { // 3. Tamper with the frontier bytes: the recomputed sinsemilla root // diverges from the one bound into ct_state. - let err = - try_sync_with_ct_page_mutation(&source, grove_version, &|more, mut aux, entries| { - if !aux.is_empty() { - let last = aux.len() - 1; - aux[last] ^= 0x01; - } - (more, aux, entries) - }) - .expect_err("tampered frontier must be rejected"); + let err = try_sync_with_page_mutation(&source, grove_version, &|more, mut aux, entries| { + if !aux.is_empty() { + let last = aux.len() - 1; + aux[last] ^= 0x01; + } + (more, aux, entries) + }) + .expect_err("tampered frontier must be rejected"); let msg = format!("{err:?}"); assert!( msg.contains("state root mismatch after replay") @@ -1730,14 +2463,13 @@ mod tests { ); // 4. Drop the last entry while still claiming the page is final. - let err = - try_sync_with_ct_page_mutation(&source, grove_version, &|more, aux, mut entries| { - if !more { - entries.pop(); - } - (more, aux, entries) - }) - .expect_err("dropped entry must be rejected"); + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, mut entries| { + if !more { + entries.pop(); + } + (more, aux, entries) + }) + .expect_err("dropped entry must be rejected"); assert!( format!("{err:?}").contains("replay incomplete"), "expected incomplete-replay rejection, got: {err:?}" @@ -1784,21 +2516,20 @@ mod tests { } // Padded frontier: decodes to the genuine frontier, different bytes. - let err = - try_sync_with_ct_page_mutation(&source, grove_version, &|more, mut aux, entries| { - if !aux.is_empty() { - aux.push(0x00); - } - (more, aux, entries) - }) - .expect_err("padded frontier must be rejected"); + let err = try_sync_with_page_mutation(&source, grove_version, &|more, mut aux, entries| { + if !aux.is_empty() { + aux.push(0x00); + } + (more, aux, entries) + }) + .expect_err("padded frontier must be rejected"); assert!( format!("{err:?}").contains("not canonically encoded"), "expected canonical-encoding rejection, got: {err:?}" ); // Garbage that does not decode at all is rejected too. - let err = try_sync_with_ct_page_mutation(&source, grove_version, &|more, aux, entries| { + let err = try_sync_with_page_mutation(&source, grove_version, &|more, aux, entries| { let aux = if aux.is_empty() { aux } else { @@ -1837,7 +2568,7 @@ mod tests { // `[0x00]` is the canonical serialization of an EMPTY frontier: a // perfectly well-formed value that still must not be accepted. - let err = try_sync_with_ct_page_mutation(&source, grove_version, &|more, _aux, entries| { + let err = try_sync_with_page_mutation(&source, grove_version, &|more, _aux, entries| { (more, vec![0x00], entries) }) .expect_err("planted frontier on an empty commitment tree must be rejected"); @@ -1923,7 +2654,8 @@ mod tests { .expect("dense insert"); // Same sync loop as the shared driver but with subtrees_batch_size - // of 1, exercising set_new_transaction between subtrees. + // of 1, exercising the discovery-pacing batch boundary between + // subtrees. let dest = run_sync(&source, grove_version, 1, None) .expect("state sync with batch size 1 should succeed"); @@ -2131,4 +2863,1627 @@ mod tests { "got: {err:?}" ); } + + // ---------- Indexed-tree state sync ---------- + + /// Shared assertions for a completed indexed round trip: identical app + /// hash, a clean full integrity check (which re-derives every axis + /// secondary against its primary), and — via the caller's closure — + /// identical post-sync writes on both sides. + fn assert_indexed_round_trip( + source: &TempGroveDb, + dest: &TempGroveDb, + grove_version: &GroveVersion, + post_sync_write: impl Fn(&TempGroveDb), + ) { + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "app hash must match after indexed sync" + ); + let dest_issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("dest verify_grovedb should run"); + assert!( + dest_issues.is_empty(), + "destination must verify clean, got: {:?}", + dest_issues + ); + for db in [source, dest] { + post_sync_write(db); + } + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "post-sync writes must produce identical states" + ); + } + + /// Full round trip for a populated `ProvableCountIndexedTree` whose + /// entries are themselves subtrees — exercising both the axis + /// secondary transfer and subtree discovery recursion into the + /// primary's children. + #[test] + fn state_sync_populated_pcit_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + b"pcit", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCIT"); + for (k, c) in &[(b"a" as &[u8], 3u64), (b"b" as &[u8], 7u64)] { + source + .insert_into_count_indexed_tree( + [TEST_LEAF, b"pcit"].as_ref(), + k, + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("insert PCIT entry"); + for i in 0..*c { + source + .insert( + [TEST_LEAF, b"pcit", k].as_ref(), + &i.to_be_bytes(), + Element::new_item(vec![i as u8]), + None, + None, + grove_version, + ) + .unwrap() + .expect("derive PCIT entry count"); + } + } + + let dest = run_sync_with_version( + &source, + grove_version, + 64, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("sync of a populated PCIT should succeed"); + + assert_indexed_round_trip(&source, &dest, grove_version, |db| { + db.insert_into_count_indexed_tree( + [TEST_LEAF, b"pcit"].as_ref(), + b"c", + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("post-sync PCIT insert"); + }); + } + + /// Full round trip for a populated `ProvableSumIndexedTree`. + #[test] + fn state_sync_populated_psit_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + b"psit", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PSIT"); + for (k, s) in &[ + (b"a" as &[u8], 4i64), + (b"b" as &[u8], -2i64), + (b"c" as &[u8], 10i64), + ] { + source + .insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + k, + Element::new_sum_item(*s), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + + let dest = run_sync_with_version( + &source, + grove_version, + 64, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("sync of a populated PSIT should succeed"); + + assert_indexed_round_trip(&source, &dest, grove_version, |db| { + db.insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + b"d", + Element::new_sum_item(21), + None, + grove_version, + ) + .unwrap() + .expect("post-sync PSIT insert"); + }); + } + + /// Full round trip for a three-axis (count + sum + avg) + /// `ProvableCountProvableSumIndexedTree` — one primary plus three axis + /// secondaries in one group. Runs with the default batch size AND with + /// `subtrees_batch_size` of 1, which forces the group's secondaries + /// through the discovery-pacing parking path. + #[test] + fn state_sync_pcpsit_three_axes_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + let axes: Vec<(u8, Option>)> = vec![(0, None), (1, None), (2, None)]; + source + .insert( + [TEST_LEAF].as_ref(), + b"pcpsit", + Element::empty_provable_count_provable_sum_indexed_tree(axes) + .expect("canonical axes"), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PCPSIT"); + for (k, s) in &[ + (b"a" as &[u8], 5i64), + (b"b" as &[u8], -3i64), + (b"c" as &[u8], 12i64), + (b"d" as &[u8], 0i64), + ] { + source + .insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"pcpsit"].as_ref(), + k, + Element::new_item_with_sum_item(b"v".to_vec(), *s), + None, + grove_version, + ) + .unwrap() + .expect("insert PCPSIT entry"); + } + + for batch_size in [64usize, 1] { + let dest = run_sync_with_version( + &source, + grove_version, + batch_size, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .unwrap_or_else(|e| { + panic!("sync of a 3-axis PCPSIT (batch {batch_size}) failed: {e:?}") + }); + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "app hash must match (batch {batch_size})" + ); + let dest_issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("dest verify_grovedb should run"); + assert!( + dest_issues.is_empty(), + "batch {batch_size}: destination must verify clean, got: {:?}", + dest_issues + ); + } + + // Post-sync usability on the default-batch destination. + let dest = run_sync_with_version( + &source, + grove_version, + 64, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("sync should succeed"); + assert_indexed_round_trip(&source, &dest, grove_version, |db| { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"pcpsit"].as_ref(), + b"e", + Element::new_item_with_sum_item(b"w".to_vec(), 7), + None, + grove_version, + ) + .unwrap() + .expect("post-sync PCPSIT insert"); + }); + } + + /// A NESTED indexed tree: a PCPSIT entry that is itself a populated + /// PCPSIT. The outer group's primary transfer must discover the inner + /// indexed child and open a second group for it. + #[test] + fn state_sync_nested_indexed_tree_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + let axes: Vec<(u8, Option>)> = vec![(0, None), (1, None)]; + source + .insert( + [TEST_LEAF].as_ref(), + b"outer", + Element::empty_provable_count_provable_sum_indexed_tree(axes.clone()) + .expect("canonical axes"), + None, + None, + grove_version, + ) + .unwrap() + .expect("create outer PCPSIT"); + source + .insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"outer"].as_ref(), + b"plain", + Element::new_item_with_sum_item(b"v".to_vec(), 3), + None, + grove_version, + ) + .unwrap() + .expect("insert plain outer entry"); + source + .insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"outer"].as_ref(), + b"inner", + Element::empty_provable_count_provable_sum_indexed_tree(axes) + .expect("canonical axes"), + None, + grove_version, + ) + .unwrap() + .expect("insert nested PCPSIT"); + for (k, s) in &[(b"x" as &[u8], 8i64), (b"y" as &[u8], -1i64)] { + source + .insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"outer", b"inner"].as_ref(), + k, + Element::new_item_with_sum_item(b"n".to_vec(), *s), + None, + grove_version, + ) + .unwrap() + .expect("insert nested entry"); + } + + let dest = run_sync_with_version( + &source, + grove_version, + 64, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("sync of a nested indexed tree should succeed"); + + assert_indexed_round_trip(&source, &dest, grove_version, |db| { + db.insert_into_provable_count_provable_sum_indexed_tree( + [TEST_LEAF, b"outer", b"inner"].as_ref(), + b"z", + Element::new_item_with_sum_item(b"n".to_vec(), 2), + None, + grove_version, + ) + .unwrap() + .expect("post-sync nested insert"); + }); + } + + /// Empty indexed trees of all three variants round trip: the primary + /// is empty, every configured axis secondary is empty (contributing + /// `NULL_HASH` to the binding), and the joint verification still runs. + #[test] + fn state_sync_empty_indexed_trees_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + b"pcit_empty", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create empty PCIT"); + source + .insert( + [TEST_LEAF].as_ref(), + b"psit_empty", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create empty PSIT"); + let axes: Vec<(u8, Option>)> = vec![(0, None), (1, None), (2, None)]; + source + .insert( + [TEST_LEAF].as_ref(), + b"pcpsit_empty", + Element::empty_provable_count_provable_sum_indexed_tree(axes) + .expect("canonical axes"), + None, + None, + grove_version, + ) + .unwrap() + .expect("create empty PCPSIT"); + + let dest = run_sync_with_version( + &source, + grove_version, + 64, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("sync of empty indexed trees should succeed"); + + assert_indexed_round_trip(&source, &dest, grove_version, |db| { + db.insert_into_count_indexed_tree( + [TEST_LEAF, b"pcit_empty"].as_ref(), + b"a", + Element::empty_provable_count_tree(), + None, + grove_version, + ) + .unwrap() + .expect("post-sync insert into previously empty PCIT"); + }); + } + + /// Builds a small populated PSIT source for the tamper tests below. + fn tamper_test_psit_source(grove_version: &GroveVersion) -> TempGroveDb { + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"psit", + Element::empty_provable_sum_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("create PSIT"); + for (k, s) in &[(b"a" as &[u8], 4i64), (b"b" as &[u8], -2i64)] { + source + .insert_into_provable_sum_indexed_tree( + [TEST_LEAF, b"psit"].as_ref(), + k, + Element::new_sum_item(*s), + None, + grove_version, + ) + .unwrap() + .expect("insert PSIT entry"); + } + source + } + + /// Rewrites an indexed header page payload through `mutate`, leaving + /// any other payload untouched. The header page is the only indexed + /// primary payload of shape `pack([pack([header, root_chunk_ops])])`. + fn mutate_indexed_header_page( + gdata: Vec, + mutate: impl Fn(Vec, Vec) -> (Vec, Vec), + ) -> Vec { + use crate::replication::{ + indexed_sync::IndexedHeader, + utils::{pack_nested_bytes, unpack_nested_bytes}, + }; + let Ok(locals) = unpack_nested_bytes(&gdata) else { + return gdata; + }; + if locals.len() != 1 { + return gdata; + } + let Ok(sections) = unpack_nested_bytes(&locals[0]) else { + return gdata; + }; + if sections.len() != 2 || IndexedHeader::decode(§ions[0]).is_err() { + return gdata; + } + let [header_bytes, ops]: [Vec; 2] = sections.try_into().expect("checked length"); + let (header_bytes, ops) = mutate(header_bytes, ops); + pack_nested_bytes(vec![ + pack_nested_bytes(vec![header_bytes, ops]).expect("repack payload") + ]) + .expect("repack global") + } + + /// A header whose primary root hash was tampered with must be rejected + /// as soon as the bundled root chunk fails verification against it — + /// the sync never gets to commit. + #[test] + fn state_sync_indexed_tampered_header_rejected() { + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + + let err = run_sync_with_version( + &source, + grove_version, + 64, + None, + Some(&|tree_type, _gid: &[u8], gdata: Vec| { + if !tree_type.is_indexed_primary() { + return gdata; + } + mutate_indexed_header_page(gdata, |mut header_bytes, ops| { + // Flip one byte of the primary root hash. + header_bytes[0] ^= 0x01; + (header_bytes, ops) + }) + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("tampered indexed header must be rejected"); + assert!( + format!("{err:?}").contains("Unable to process indexed primary root chunk"), + "expected root-chunk rejection against the tampered header, got: {err:?}" + ); + } + + /// A CONSISTENT byzantine lie — the header claims an empty primary + /// (NULL root hash) and serves no root chunk, so every per-chunk check + /// passes trivially — must still be caught by the unconditional + /// finalize-time joint verification against the parent binding. This + /// pins the security boundary: the header is a hint, never trusted. + #[test] + fn state_sync_indexed_lying_empty_header_rejected() { + use grovedb_merk::tree::hash::NULL_HASH; + + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + + let err = run_sync_with_version( + &source, + grove_version, + 64, + None, + Some(&|tree_type, _gid: &[u8], gdata: Vec| { + use crate::replication::{ + indexed_sync::IndexedHeader, + utils::{pack_nested_bytes, unpack_nested_bytes}, + }; + // The lie must be CONSISTENT: the secondary's chunks are + // served empty too, so its empty-restore NULL_HASH check + // passes and nothing fails before the joint verification. + if matches!( + tree_type, + grovedb_merk::tree_type::TreeType::ProvableCountProvableSumTree + ) { + let locals = unpack_nested_bytes(&gdata).expect("unpack secondary payload"); + return pack_nested_bytes(vec![Vec::new(); locals.len()]) + .expect("repack empty secondary payload"); + } + if !tree_type.is_indexed_primary() { + return gdata; + } + mutate_indexed_header_page(gdata, |header_bytes, _ops| { + let mut header = + IndexedHeader::decode(&header_bytes).expect("checked decodable"); + header.primary_root_hash = NULL_HASH; + for (_, hash) in header.axes.iter_mut() { + *hash = NULL_HASH; + } + // Empty root chunk: "nothing to restore". + (header.encode(), Vec::new()) + }) + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("a consistently lying empty header must be rejected"); + assert!( + format!("{err:?}").contains("failed joint verification"), + "expected the finalize-time joint check to reject, got: {err:?}" + ); + } + + /// A tampered axis-secondary chunk must be rejected by per-chunk + /// verification against the (honest) header's secondary root hash. + #[test] + fn state_sync_indexed_tampered_secondary_chunk_rejected() { + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + + let err = run_sync_with_version( + &source, + grove_version, + 64, + None, + Some(&|tree_type, _gid: &[u8], mut gdata: Vec| { + // The PSIT fixture's only ProvableCountProvableSumTree + // payloads are its sum-axis secondary chunks. + if matches!( + tree_type, + grovedb_merk::tree_type::TreeType::ProvableCountProvableSumTree + ) && let Some(last) = gdata.last_mut() + { + *last ^= 0xFF; + } + gdata + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("tampered secondary chunk must be rejected"); + let msg = format!("{err:?}"); + assert!( + msg.contains("Unable to process incoming chunk") + || msg.contains("Unable to decode incoming chunk"), + "expected per-chunk rejection of the tampered secondary, got: {msg}" + ); + } + + // ---------- Aggregate-tree round trips ---------- + // + // One state-sync round trip per aggregate-carrying Merk tree type. + // The Provable* variants bake their aggregates into every node hash, + // so these also pin the finalize-time aggregate rewrite in the Merk + // restorer (chunk proof nodes carry subtree AGGREGATES, not own + // values); the plain variants pin correct link aggregate_data on the + // restored tree, which `verify_grovedb`'s aggregate audit checks. + + /// Build `[TEST_LEAF, name]` as `tree_element`, fill it with + /// `children`, sync, and require: identical app hash, a clean + /// destination integrity check, and identical post-sync writes. + fn aggregate_tree_round_trip( + name: &[u8], + tree_element: Element, + children: &[(&[u8], Element)], + ) { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + source + .insert( + [TEST_LEAF].as_ref(), + name, + tree_element, + None, + None, + grove_version, + ) + .unwrap() + .expect("insert aggregate tree"); + for (key, element) in children { + source + .insert( + [TEST_LEAF, name].as_ref(), + key, + element.clone(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert child"); + } + + let dest = sync_source_to_destination(&source, grove_version); + + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "app hash must match for {:?}", + String::from_utf8_lossy(name) + ); + let dest_issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("dest verify_grovedb should run"); + assert!( + dest_issues.is_empty(), + "{:?}: destination must verify clean, got: {:?}", + String::from_utf8_lossy(name), + dest_issues + ); + + // The restored tree stays writable and both sides evolve + // identically. + let (_, post_element) = &children[0]; + for db in [&source, &dest] { + db.insert( + [TEST_LEAF, name].as_ref(), + b"post_sync", + post_element.clone(), + None, + None, + grove_version, + ) + .unwrap() + .expect("post-sync insert"); + } + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + "post-sync writes must produce identical states for {:?}", + String::from_utf8_lossy(name) + ); + } + + fn sum_children() -> Vec<(&'static [u8], Element)> { + vec![ + (b"a", Element::new_sum_item(4)), + (b"b", Element::new_sum_item(-2)), + (b"c", Element::new_sum_item(10)), + (b"d", Element::new_sum_item(0)), + (b"e", Element::new_sum_item(-7)), + ] + } + + fn item_children() -> Vec<(&'static [u8], Element)> { + vec![ + (b"a", Element::new_item(b"one".to_vec())), + (b"b", Element::new_item(b"two".to_vec())), + (b"c", Element::new_item(b"three".to_vec())), + (b"d", Element::new_item(b"four".to_vec())), + (b"e", Element::new_item(b"five".to_vec())), + ] + } + + #[test] + fn state_sync_sum_tree_round_trip() { + aggregate_tree_round_trip(b"sum", Element::empty_sum_tree(), &sum_children()); + } + + #[test] + fn state_sync_big_sum_tree_round_trip() { + aggregate_tree_round_trip(b"big_sum", Element::empty_big_sum_tree(), &sum_children()); + } + + #[test] + fn state_sync_count_tree_round_trip() { + aggregate_tree_round_trip(b"count", Element::empty_count_tree(), &item_children()); + } + + #[test] + fn state_sync_count_sum_tree_round_trip() { + aggregate_tree_round_trip( + b"count_sum", + Element::empty_count_sum_tree(), + &sum_children(), + ); + } + + #[test] + fn state_sync_provable_sum_tree_round_trip() { + aggregate_tree_round_trip( + b"provable_sum", + Element::empty_provable_sum_tree(), + &sum_children(), + ); + } + + #[test] + fn state_sync_provable_count_tree_round_trip() { + aggregate_tree_round_trip( + b"provable_count", + Element::empty_provable_count_tree(), + &item_children(), + ); + } + + #[test] + fn state_sync_provable_count_sum_tree_round_trip() { + aggregate_tree_round_trip( + b"provable_count_sum", + Element::empty_provable_count_sum_tree(), + &sum_children(), + ); + } + + #[test] + fn state_sync_provable_count_provable_sum_tree_round_trip() { + aggregate_tree_round_trip( + b"pcps", + Element::empty_provable_count_provable_sum_tree(), + &sum_children(), + ); + } + + /// A deep (6-level), many-subtree hierarchy with mixed tree types at + /// every level, synced with a small batch size so discovery pacing + /// crosses batch boundaries repeatedly. + #[test] + fn state_sync_deep_hierarchy_round_trip() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + + // Level 1..=5 under TEST_LEAF: lvl1/lvl2/lvl3/lvl4/lvl5, each + // level carrying two sibling subtrees and a couple of items. + let mut path: Vec> = vec![TEST_LEAF.to_vec()]; + for level in 1u8..=5 { + let path_refs: Vec<&[u8]> = path.iter().map(|p| p.as_slice()).collect(); + let level_key = format!("lvl{level}").into_bytes(); + + // The spine subtree the next level nests into. + source + .insert( + path_refs.as_slice(), + &level_key, + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert spine subtree"); + // A sibling aggregate subtree with content. + let sibling_key = format!("side{level}").into_bytes(); + source + .insert( + path_refs.as_slice(), + &sibling_key, + Element::empty_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sibling sum tree"); + let mut sibling_path = path.clone(); + sibling_path.push(sibling_key); + let sibling_refs: Vec<&[u8]> = sibling_path.iter().map(|p| p.as_slice()).collect(); + for i in 0u8..3 { + source + .insert( + sibling_refs.as_slice(), + &[i], + Element::new_sum_item(i64::from(i) * i64::from(level)), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sibling sum item"); + } + // Items alongside the subtrees. + source + .insert( + path_refs.as_slice(), + format!("item{level}").as_bytes(), + Element::new_item(vec![level; 8]), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert level item"); + + path.push(level_key); + } + // A leaf item at the deepest level (6 levels below the root). + let path_refs: Vec<&[u8]> = path.iter().map(|p| p.as_slice()).collect(); + source + .insert( + path_refs.as_slice(), + b"deep_leaf", + Element::new_item(b"bottom".to_vec()), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert deepest item"); + + // Small batch size: discovery must park and resume repeatedly. + let dest = run_sync_with_version( + &source, + grove_version, + 2, + None, + None, + CURRENT_STATE_SYNC_VERSION, + ) + .expect("deep hierarchy sync should succeed"); + + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + ); + let deep = dest + .get(path_refs.as_slice(), b"deep_leaf", None, grove_version) + .unwrap() + .expect("deepest item must be readable on destination"); + assert_eq!(deep, Element::new_item(b"bottom".to_vec())); + let dest_issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("dest verify_grovedb should run"); + assert!(dest_issues.is_empty(), "got: {:?}", dest_issues); + } + + /// The value bytes of a `KVValueHashFeatureType` chunk node are not + /// bound by the chunk hash: the hash is computed from the carried + /// `value_hash`, never from `H(value)`. A byzantine source can keep + /// every hash-bound field of an honest node and substitute forged + /// element bytes, and the chunk still verifies against the parent's + /// commitment. Element-mode finalization derives the subtree's + /// aggregates from exactly those bytes, so the restore must refuse + /// them rather than commit a sum tree whose stored items and aggregate + /// disagree with every honest node. + #[test] + fn state_sync_forged_element_bytes_under_honest_value_hash_rejected() { + assert_state_sync_forged_element_rejected( + Element::new_sum_item(1_000_000), + crate::replication::RestoreCommitMode::Atomic, + ); + } + + #[test] + fn state_sync_item_to_reference_type_forgery_rejected() { + use crate::reference_path::ReferencePathType; + use crate::replication::RestoreCommitMode; + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + assert_state_sync_forged_element_rejected( + Element::new_reference_with_sum_item( + ReferencePathType::SiblingReference(vec![0]), + 1_000_000, + ), + mode, + ); + } + } + + #[test] + fn state_sync_normal_tree_value_hash_forgery_rejected() { + use std::cell::Cell; + + use grovedb_merk::{ + proofs::{Node, Op}, + tree::value_hash, + }; + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + use crate::replication::utils::{ + decode_vec_ops, encode_vec_ops, pack_nested_bytes, unpack_nested_bytes, + }; + + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"victim", + Element::new_item(vec![1]), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + let prefix = RocksDbStorage::build_prefix([TEST_LEAF].as_ref().into()).unwrap(); + let forged_bytes = Element::new_item(vec![2]).serialize(grove_version).unwrap(); + let forged = Cell::new(0); + let err = run_sync_with_version( + &source, + grove_version, + 1, + None, + Some(&|_, gid, gdata| { + if gid.get(..32) != Some(prefix.as_slice()) { + return gdata; + } + let chunks = unpack_nested_bytes(&gdata) + .unwrap() + .into_iter() + .map(|chunk| { + let ops = decode_vec_ops(&chunk) + .unwrap() + .into_iter() + .map(|op| match op { + Op::Push(Node::KV(key, bytes)) if key == b"victim" => { + forged.set(forged.get() + 1); + Op::Push(Node::KVValueHash( + key, + forged_bytes.clone(), + value_hash(&bytes).unwrap(), + )) + } + other => other, + }) + .collect(); + encode_vec_ops(ops).unwrap() + }) + .collect(); + pack_nested_bytes(chunks).unwrap() + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("forged plain item must not commit"); + assert!(forged.get() > 0); + assert!(format!("{err}").contains("value hash"), "{err}"); + } + + #[test] + fn state_sync_index_reference_bytes_forgery_rejected() { + use std::cell::Cell; + + use grovedb_merk::{ + proofs::{Node, Op}, + tree_type::TreeType, + }; + + use crate::replication::utils::{ + decode_vec_ops, encode_vec_ops, pack_nested_bytes, unpack_nested_bytes, + }; + + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + let forged = Cell::new(0); + let err = run_sync_with_version( + &source, + grove_version, + 1, + None, + Some(&|tree_type, _, gdata| { + if tree_type != TreeType::ProvableCountProvableSumTree { + return gdata; + } + let chunks = unpack_nested_bytes(&gdata) + .unwrap() + .into_iter() + .map(|chunk| { + let ops = decode_vec_ops(&chunk) + .unwrap() + .into_iter() + .map(|op| match op { + Op::Push(Node::KVValueHashFeatureType( + key, + bytes, + hash, + feature, + )) => { + let Element::ReferenceWithSumItem(path, hops, sum, _) = + Element::deserialize(&bytes, grove_version).unwrap() + else { + panic!("expected an index reference row"); + }; + forged.set(forged.get() + 1); + let bytes = Element::ReferenceWithSumItem( + path, + hops, + sum, + Some(vec![0xFF]), + ) + .serialize(grove_version) + .unwrap(); + Op::Push(Node::KVValueHashFeatureType( + key, bytes, hash, feature, + )) + } + other => other, + }) + .collect(); + encode_vec_ops(ops).unwrap() + }) + .collect(); + pack_nested_bytes(chunks).unwrap() + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("forged index row must not commit"); + assert!(forged.get() > 0); + assert!(format!("{err}").contains("value hash"), "{err}"); + } + + #[test] + fn state_sync_references_across_discovery_batches_round_trip() { + use crate::reference_path::ReferencePathType::{AbsolutePathReference, SiblingReference}; + use crate::replication::RestoreCommitMode; + + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"target", + Element::new_item(vec![42]), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + source + .insert( + [TEST_LEAF].as_ref(), + b"ref", + Element::new_reference(SiblingReference(b"target".to_vec())), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + source + .insert( + [ANOTHER_TEST_LEAF].as_ref(), + b"sums", + Element::empty_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + source + .insert( + [ANOTHER_TEST_LEAF, b"sums"].as_ref(), + b"chain", + Element::new_reference_with_sum_item( + AbsolutePathReference(vec![TEST_LEAF.to_vec(), b"ref".to_vec()]), + 7, + ), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + + // The index row must bind this reference's stored combined hash, + // while the reference itself binds the terminal item's bytes. + source + .insert( + [TEST_LEAF].as_ref(), + b"index", + Element::empty_provable_count_indexed_tree(), + None, + None, + grove_version, + ) + .unwrap() + .unwrap(); + source + .insert_into_count_indexed_tree( + [TEST_LEAF, b"index"].as_ref(), + b"indexed_ref", + Element::new_reference(AbsolutePathReference(vec![ + ANOTHER_TEST_LEAF.to_vec(), + b"sums".to_vec(), + b"chain".to_vec(), + ])), + None, + grove_version, + ) + .unwrap() + .unwrap(); + + for mode in [ + RestoreCommitMode::Atomic, + RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 1, + }, + ] { + let dest = run_sync_with_version_and_mode( + &source, + grove_version, + 1, + None, + None, + CURRENT_STATE_SYNC_VERSION, + mode, + ) + .expect("cross-subtree reference chain must sync"); + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap() + ); + assert_eq!( + source + .get_raw( + [ANOTHER_TEST_LEAF, b"sums"].as_ref().into(), + b"chain", + None, + grove_version + ) + .unwrap() + .unwrap(), + dest.get_raw( + [ANOTHER_TEST_LEAF, b"sums"].as_ref().into(), + b"chain", + None, + grove_version + ) + .unwrap() + .unwrap() + ); + assert!(dest + .verify_grovedb(None, true, false, grove_version) + .unwrap() + .is_empty()); + } + } + + fn assert_state_sync_forged_element_rejected( + forged_element: Element, + mode: crate::replication::RestoreCommitMode, + ) { + use std::cell::Cell; + + use grovedb_merk::proofs::{Node, Op}; + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + use crate::replication::utils::{ + decode_vec_ops, encode_vec_ops, pack_nested_bytes, unpack_nested_bytes, + }; + + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"sums", + Element::empty_sum_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum tree"); + for i in 0..8u8 { + source + .insert( + [TEST_LEAF, b"sums"].as_ref(), + &[i], + Element::new_sum_item(5), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert sum item"); + } + let victim_path: &[&[u8]] = &[TEST_LEAF, b"sums"]; + let victim_prefix = RocksDbStorage::build_prefix(victim_path.into()).unwrap(); + let victim_key = vec![3u8]; + let forged_bytes = forged_element + .serialize(grove_version) + .expect("serialize forged sum item"); + + let forged = Cell::new(0usize); + let result = run_sync_with_version_and_mode( + &source, + grove_version, + 64, + None, + Some(&|_tree_type, gid: &[u8], gdata: Vec| { + if gid.len() < 32 || gid[..32] != victim_prefix { + return gdata; + } + let locals = unpack_nested_bytes(&gdata).expect("unpack victim payload"); + let mutated: Vec> = locals + .into_iter() + .map(|local| { + if local.is_empty() { + return local; + } + let ops = decode_vec_ops(&local).expect("decode chunk ops"); + let ops: Vec = ops + .into_iter() + .map(|op| match op { + Op::Push(Node::KVValueHashFeatureType( + key, + _honest_value, + honest_value_hash, + feature, + )) if key == victim_key => { + forged.set(forged.get() + 1); + Op::Push(Node::KVValueHashFeatureType( + key, + forged_bytes.clone(), + honest_value_hash, + feature, + )) + } + other => other, + }) + .collect(); + encode_vec_ops(ops).expect("re-encode chunk ops") + }) + .collect(); + pack_nested_bytes(mutated).expect("repack victim payload") + }), + CURRENT_STATE_SYNC_VERSION, + mode, + ); + assert!( + forged.get() > 0, + "the victim node must have been forged for this test to mean anything" + ); + match result { + Ok(dest) => { + let element = dest + .get([TEST_LEAF].as_ref(), b"sums", None, grove_version) + .unwrap() + .expect("read the restored sum tree element"); + let item = dest + .get_raw( + [TEST_LEAF, b"sums"].as_ref().into(), + &victim_key, + None, + grove_version, + ) + .unwrap() + .expect("read the restored victim item"); + panic!( + "forged element bytes were accepted: parent element {element:?}, restored \ + victim item {item:?}" + ); + } + Err(err) => assert!( + format!("{err:?}").contains("value hash"), + "expected the value-hash binding to reject the forgery, got: {err:?}" + ), + } + } + + /// `fetch_chunk` serves one bounded chunk per requested id but used to + /// place no bound on how many ids one request may carry, so a peer + /// could repeat a single valid id thousands of times and have the + /// source build a response thousands of times larger than any honest + /// one. An honest target never packs more than + /// `CONST_GROUP_PACKING_SIZE` global ids, nor more than that many + /// local ids per global id, and asks for exactly one page cursor per + /// append-only subtree; anything beyond is refused up front. + #[test] + fn fetch_chunk_bounds_the_number_of_chunk_ids_per_request() { + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + use crate::replication::{ + non_merk_sync::NonMerkChunkId, + utils::{encode_global_chunk_id, pack_nested_bytes}, + CONST_GROUP_PACKING_SIZE, + }; + + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"ct", + Element::empty_commitment_tree(4).expect("valid chunk power"), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert commitment tree"); + source + .commitment_tree_insert_raw( + [TEST_LEAF].as_ref(), + b"ct", + [1u8; 32], + [2u8; 32], + [3u8; 32], + vec![0u8; 216], + None, + grove_version, + ) + .unwrap() + .expect("insert commitment tree note"); + let app_hash = source + .root_hash(None, grove_version) + .unwrap() + .expect("source root hash"); + let fetch = |packed: Vec| { + source.fetch_chunk( + packed.as_slice(), + None, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + }; + let assert_bounded = |err: crate::Error, what: &str| { + assert!( + format!("{err}").contains("too many"), + "{what}: expected a descriptive bound error, got {err}" + ); + }; + + // Global ids per request: the honest maximum is served, one more + // is refused. + let honest_max = pack_nested_bytes(vec![app_hash.to_vec(); CONST_GROUP_PACKING_SIZE]) + .expect("pack honest maximum"); + fetch(honest_max).expect("the honest maximum number of global ids must be served"); + let too_many = pack_nested_bytes(vec![app_hash.to_vec(); CONST_GROUP_PACKING_SIZE + 1]) + .expect("pack one too many"); + assert_bounded( + fetch(too_many).expect_err("one global id beyond the bound must be refused"), + "global ids", + ); + + // Local ids per global id, on an ordinary Merk subtree. + let tx = source.start_transaction(); + let (merk, root_key, tree_type, _element) = source + .open_merk_for_replication([TEST_LEAF].as_ref().into(), &tx, grove_version) + .expect("open test leaf for replication"); + drop(merk); + let leaf_path: &[&[u8]] = &[TEST_LEAF]; + let leaf_prefix = RocksDbStorage::build_prefix(leaf_path.into()).unwrap(); + let honest_max = encode_global_chunk_id( + leaf_prefix, + root_key.clone(), + tree_type, + vec![vec![]; CONST_GROUP_PACKING_SIZE], + ) + .expect("encode honest maximum"); + fetch(pack_nested_bytes(vec![honest_max]).expect("pack")) + .expect("the honest maximum number of local ids must be served"); + let too_many = encode_global_chunk_id( + leaf_prefix, + root_key, + tree_type, + vec![vec![]; CONST_GROUP_PACKING_SIZE + 1], + ) + .expect("encode one too many"); + assert_bounded( + fetch(pack_nested_bytes(vec![too_many]).expect("pack")) + .expect_err("one local id beyond the bound must be refused"), + "local ids", + ); + + // Page cursors per append-only subtree: exactly one. + let (merk, ct_root_key, ct_tree_type, _element) = source + .open_merk_for_replication([TEST_LEAF, b"ct"].as_ref().into(), &tx, grove_version) + .expect("open commitment tree for replication"); + drop(merk); + let ct_path: &[&[u8]] = &[TEST_LEAF, b"ct"]; + let ct_prefix = RocksDbStorage::build_prefix(ct_path.into()).unwrap(); + let cursor = NonMerkChunkId { + start: 0, + state: 1, + param: 4, + } + .encode(); + let one = encode_global_chunk_id( + ct_prefix, + ct_root_key.clone(), + ct_tree_type, + vec![cursor.clone()], + ) + .expect("encode one cursor"); + fetch(pack_nested_bytes(vec![one]).expect("pack")) + .expect("a single page cursor must be served"); + let two = encode_global_chunk_id( + ct_prefix, + ct_root_key, + ct_tree_type, + vec![cursor.clone(), cursor], + ) + .expect("encode two cursors"); + assert_bounded( + fetch(pack_nested_bytes(vec![two]).expect("pack")) + .expect_err("a second page cursor in one request must be refused"), + "page cursors", + ); + } + + /// A header page must carry exactly a header and a root chunk; any + /// other section count is refused before the header is even decoded. + #[test] + fn state_sync_indexed_header_page_with_wrong_section_count_rejected() { + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + + let err = run_sync_with_version( + &source, + grove_version, + 64, + None, + Some(&|tree_type, _gid: &[u8], gdata: Vec| { + use crate::replication::utils::{pack_nested_bytes, unpack_nested_bytes}; + if !tree_type.is_indexed_primary() { + return gdata; + } + let locals = unpack_nested_bytes(&gdata).expect("unpack header payload"); + let mut sections = unpack_nested_bytes(&locals[0]).expect("unpack header sections"); + sections.push(Vec::new()); + pack_nested_bytes(vec![pack_nested_bytes(sections).expect("repack sections")]) + .expect("repack payload") + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("a three-section header page must be rejected"); + assert!( + format!("{err:?}").contains("exactly a header and a root chunk"), + "got: {err:?}" + ); + } + + /// A header whose axis tags differ from the element's configured axes + /// is refused when it is registered, before any secondary is opened. + #[test] + fn state_sync_indexed_header_with_foreign_axis_tags_rejected() { + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + + let err = run_sync_with_version( + &source, + grove_version, + 64, + None, + Some(&|tree_type, _gid: &[u8], gdata: Vec| { + if !tree_type.is_indexed_primary() { + return gdata; + } + mutate_indexed_header_page(gdata, |mut header_bytes, ops| { + // Layout: primary hash (32) | count (1) | tag (1) | hash. + // The PSIT's single axis is Sum (tag 1); claim Count. + assert_eq!( + header_bytes[33], 1, + "sanity: PSIT header carries the sum axis" + ); + header_bytes[33] = 0; + (header_bytes, ops) + }) + }), + CURRENT_STATE_SYNC_VERSION, + ) + .map(|_| ()) + .expect_err("a header with foreign axis tags must be rejected"); + assert!( + format!("{err:?}").contains("do not match the element's configured axes"), + "got: {err:?}" + ); + } + + /// Every peer-controlled field of an indexed header request is + /// rejected descriptively on the source: a request that is not alone + /// in its global chunk, an unknown axis tag, and a primary root key + /// that opens nothing. (A secondary root key that names no node opens + /// as an empty Merk and is answered with the NULL hash, which the + /// target's joint verification then rejects.) + #[test] + fn fetch_chunk_rejects_malformed_indexed_header_requests() { + use grovedb_storage::rocksdb_storage::RocksDbStorage; + + use crate::replication::{ + indexed_sync::IndexedHeaderRequest, + utils::{encode_global_chunk_id, pack_nested_bytes}, + }; + + let grove_version = GroveVersion::latest(); + let source = tamper_test_psit_source(grove_version); + let tx = source.start_transaction(); + let (merk, root_key, tree_type, _element) = source + .open_merk_for_replication([TEST_LEAF, b"psit"].as_ref().into(), &tx, grove_version) + .expect("open indexed primary for replication"); + drop(merk); + assert!(tree_type.is_indexed_primary(), "sanity: {tree_type:?}"); + let path: &[&[u8]] = &[TEST_LEAF, b"psit"]; + let prefix = RocksDbStorage::build_prefix(path.into()).unwrap(); + let fetch = |global_id: Vec| { + source.fetch_chunk( + &pack_nested_bytes(vec![global_id]).expect("pack"), + Some(&tx), + CURRENT_STATE_SYNC_VERSION, + grove_version, + ) + }; + let valid_request = IndexedHeaderRequest { + axes: vec![(1, None)], + } + .encode(); + + let err = fetch( + encode_global_chunk_id( + prefix, + root_key.clone(), + tree_type, + vec![valid_request.clone(), vec![]], + ) + .unwrap(), + ) + .expect_err("a header request bundled with another id must be refused"); + assert!( + format!("{err}").contains("must be the only chunk id"), + "{err}" + ); + + let err = fetch( + encode_global_chunk_id( + prefix, + root_key.clone(), + tree_type, + vec![IndexedHeaderRequest { + axes: vec![(0xFF, None)], + } + .encode()], + ) + .unwrap(), + ) + .expect_err("an unknown axis tag must be refused"); + assert!( + format!("{err}").contains("invalid axis tag in indexed header request"), + "{err}" + ); + + let err = fetch( + encode_global_chunk_id( + prefix, + Some(b"no such node".to_vec()), + tree_type, + vec![valid_request], + ) + .unwrap(), + ) + .expect_err("a primary root key that opens nothing must be refused"); + // The layered Merk opens (a root key is only a pointer) but has no + // root node to chunk from, so the refusal comes from the producer. + assert!( + format!("{err}").contains("failed to create indexed primary chunk producer"), + "{err}" + ); + } + + /// The commit mode reports whether it ever commits early, and the + /// session constructors honour the replication feature gate like the + /// rest of the versioned API. + #[test] + fn commit_mode_predicate_and_session_version_gate() { + use crate::replication::RestoreCommitMode; + + assert!(!RestoreCommitMode::Atomic.is_incremental()); + assert!(RestoreCommitMode::incremental().is_incremental()); + assert!(RestoreCommitMode::Incremental { + budget_bytes: 1, + max_subtrees_in_flight: 0, + } + .is_incremental()); + + let mut gated = GroveVersion::latest().clone(); + gated.grovedb_versions.replication.start_snapshot_syncing = 1; + let dest = make_empty_grovedb(); + let err = dest + .start_syncing_session([0u8; 32], 64, CURRENT_STATE_SYNC_VERSION, &gated) + .map(|_| ()) + .expect_err("an unknown feature version must be refused"); + assert!( + matches!(err, crate::Error::VersionError(_)), + "expected a version error, got {err:?}" + ); + } } diff --git a/grovedb/src/tests/replication_utils_tests.rs b/grovedb/src/tests/replication_utils_tests.rs index a3b1ebff5..c1d549f55 100644 --- a/grovedb/src/tests/replication_utils_tests.rs +++ b/grovedb/src/tests/replication_utils_tests.rs @@ -514,13 +514,9 @@ mod tests { .unwrap() .expect("should get root hash"); - // Use a hypothetical future version - let result = db.fetch_chunk( - &root_hash, - None, - CURRENT_STATE_SYNC_VERSION + 1, - grove_version, - ); + // Use a hypothetical future version, one past the current one. + let future_version = CURRENT_STATE_SYNC_VERSION + 1; + let result = db.fetch_chunk(&root_hash, None, future_version, grove_version); assert!( result.is_err(), "fetch_chunk with future version should fail" diff --git a/grovedb/src/tests/replication_version_tests.rs b/grovedb/src/tests/replication_version_tests.rs new file mode 100644 index 000000000..03edb06a8 --- /dev/null +++ b/grovedb/src/tests/replication_version_tests.rs @@ -0,0 +1,355 @@ +//! Protocol-version handling of state sync. +//! +//! State sync speaks exactly one protocol version +//! ([`crate::replication::CURRENT_STATE_SYNC_VERSION`]); the constant and +//! the `version` parameters exist so a future incompatible wire change +//! can bump it and old/new peers fail fast with a clear error. These +//! tests pin the properties that make that safe: +//! +//! - Every entry point on both sides — `start_snapshot_syncing` and +//! `apply_chunk` on the target, `fetch_chunk` on the source — rejects +//! any other version with a descriptive error naming both versions. +//! - The rejection is clean: a destination that never got past it is left +//! byte-for-byte untouched and verifiably uncorrupted. +//! - A session refuses chunks applied at a version different from its +//! own, so one sync can never mix protocol versions midway. + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + + use grovedb_version::version::GroveVersion; + use tempfile::TempDir; + + use crate::{ + replication::{MultiStateSyncSession, RestoreCommitMode, CURRENT_STATE_SYNC_VERSION}, + tests::{make_empty_grovedb, make_test_grovedb, TempGroveDb, TEST_LEAF}, + Element, GroveDb, + }; + + #[test] + fn public_session_constructors_are_ready_to_restore() { + let grove_version = GroveVersion::latest(); + let source = make_test_grovedb(grove_version); + let app_hash = source.root_hash(None, grove_version).unwrap().unwrap(); + for mode in [RestoreCommitMode::Atomic, RestoreCommitMode::incremental()] { + let dest = make_empty_grovedb(); + let start = |batch_size| match mode { + RestoreCommitMode::Atomic => dest.start_syncing_session( + app_hash, + batch_size, + CURRENT_STATE_SYNC_VERSION, + grove_version, + ), + _ => dest.start_syncing_session_with_mode( + app_hash, + batch_size, + CURRENT_STATE_SYNC_VERSION, + mode, + grove_version, + ), + }; + assert!( + start(0).is_err(), + "zero-sized discovery batches cannot make progress" + ); + let mut session = start(1).expect("start a usable session"); + assert!(!session.is_empty(), "the root must already be scheduled"); + let mut queue = VecDeque::from([app_hash.to_vec()]); + while let Some(id) = queue.pop_front() { + let chunk = source + .fetch_chunk(&id, None, CURRENT_STATE_SYNC_VERSION, grove_version) + .unwrap(); + queue.extend( + session + .apply_chunk(&id, &chunk, CURRENT_STATE_SYNC_VERSION, grove_version) + .unwrap(), + ); + } + assert!(session.is_sync_completed()); + dest.commit_session(session, grove_version).unwrap(); + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + app_hash + ); + } + } + + /// A checkpoint of `source`, opened the way a serving peer would. + struct SourcePeer { + db: GroveDb, + _dir: TempDir, + } + + impl SourcePeer { + fn new(source: &TempGroveDb) -> Self { + let dir = TempDir::new().expect("temp dir for checkpoint"); + let path = dir.path().join("checkpoint"); + source + .create_checkpoint(&path) + .expect("should create checkpoint"); + SourcePeer { + db: GroveDb::open(&path).expect("should open checkpoint db"), + _dir: dir, + } + } + + fn app_hash(&self, grove_version: &GroveVersion) -> [u8; 32] { + self.db + .root_hash(None, grove_version) + .unwrap() + .expect("checkpoint root hash") + } + } + + /// Drive a full sync from `peer` into `dest` at `version`. + fn sync_from_peer( + peer: &SourcePeer, + dest: &TempGroveDb, + version: u16, + grove_version: &GroveVersion, + ) -> Result<(), crate::Error> { + let app_hash = peer.app_hash(grove_version); + let mut session = dest.start_snapshot_syncing(app_hash, 64, version, grove_version)?; + + let mut queue: VecDeque> = VecDeque::new(); + queue.push_back(app_hash.to_vec()); + + while let Some(chunk_id) = queue.pop_front() { + let chunk_data = peer + .db + .fetch_chunk(&chunk_id, None, version, grove_version)?; + let more = session.apply_chunk(&chunk_id, &chunk_data, version, grove_version)?; + queue.extend(more); + } + + if !session.is_sync_completed() { + return Err(crate::Error::InternalError( + "sync did not complete".to_string(), + )); + } + dest.commit_session(session, grove_version) + } + + /// A grove covering both transfer modes: Merk chunks and non-Merk + /// entry replay. + fn make_source(grove_version: &GroveVersion) -> TempGroveDb { + let source = make_test_grovedb(grove_version); + source + .insert( + [TEST_LEAF].as_ref(), + b"sub", + Element::empty_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert subtree"); + for i in 0u8..8 { + source + .insert( + [TEST_LEAF, b"sub"].as_ref(), + &[i], + Element::new_item(vec![i; 32]), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert item"); + } + source + .insert( + [TEST_LEAF].as_ref(), + b"mmr", + Element::empty_mmr_tree(), + None, + None, + grove_version, + ) + .unwrap() + .expect("insert mmr"); + for i in 0u8..4 { + source + .mmr_tree_append( + [TEST_LEAF].as_ref(), + b"mmr", + vec![i; 16], + None, + grove_version, + ) + .unwrap() + .expect("append mmr leaf"); + } + source + } + + /// Assert the destination is byte-for-byte the pristine empty grove it + /// was before the failed sync: a rejected sync must never leave a + /// half-restored subtree behind. + fn assert_destination_untouched( + dest: &TempGroveDb, + before: [u8; 32], + grove_version: &GroveVersion, + ) { + assert_eq!( + dest.root_hash(None, grove_version).unwrap().unwrap(), + before, + "a failed sync must leave the destination root hash unchanged" + ); + let issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("destination verify_grovedb should run"); + assert!( + issues.is_empty(), + "a failed sync must not leave the destination corrupt, got: {issues:?}" + ); + } + + /// Any version other than the one this build speaks is rejected on + /// every entry point, on both sides, with a descriptive error naming + /// the offered and the supported version — and the destination is + /// left untouched. + #[test] + fn unsupported_versions_are_rejected_on_both_sides() { + let grove_version = GroveVersion::latest(); + let source = make_source(grove_version); + let peer = SourcePeer::new(&source); + let dest = make_empty_grovedb(); + let app_hash = peer.app_hash(grove_version); + let before = dest.root_hash(None, grove_version).unwrap().unwrap(); + + for bad in [0u16, CURRENT_STATE_SYNC_VERSION + 1, 99, u16::MAX] { + // Target side: the session refuses to start. + // `MultiStateSyncSession` is not `Debug`, so the `Ok` arm has + // to be destructured rather than `expect_err`ed. + let Err(err) = dest.start_snapshot_syncing(app_hash, 64, bad, grove_version) else { + panic!("version {bad} should not have started a session"); + }; + let msg = format!("{err}"); + assert!( + msg.contains("Unsupported state sync protocol version") + && msg.contains(&format!("{bad}")) + && msg.contains(&format!("{CURRENT_STATE_SYNC_VERSION}")), + "version {bad}: the error should name both versions, got {msg}" + ); + + // Target side, lower-level constructors: the bare session + // builders refuse too, so no public path can produce a session + // that could never apply a chunk. + let Err(err) = dest.start_syncing_session(app_hash, 64, bad, grove_version) else { + panic!("version {bad} should not have built a bare session"); + }; + let msg = format!("{err}"); + assert!( + msg.contains("Unsupported state sync protocol version") + && msg.contains(&format!("{bad}")) + && msg.contains(&format!("{CURRENT_STATE_SYNC_VERSION}")), + "version {bad}: the bare constructor should name both versions, got {msg}" + ); + let Err(err) = dest.start_syncing_session_with_mode( + app_hash, + 64, + bad, + RestoreCommitMode::incremental(), + grove_version, + ) else { + panic!("version {bad} should not have built a bare incremental session"); + }; + assert!( + format!("{err}").contains("Unsupported state sync protocol version"), + "version {bad}: got {err}" + ); + + // Source side: the peer refuses to serve. + let err = peer + .db + .fetch_chunk(&app_hash, None, bad, grove_version) + .expect_err("an unsupported version cannot fetch a chunk"); + assert!( + format!("{err}").contains("Unsupported state sync protocol version"), + "version {bad}: got {err}" + ); + + // Target side, mid-session: a started session refuses too. + let mut session = dest + .start_snapshot_syncing(app_hash, 64, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("start a session at the current version"); + let chunk = peer + .db + .fetch_chunk(&app_hash, None, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("the peer serves the root chunk at the current version"); + let err = session + .apply_chunk(&app_hash, &chunk, bad, grove_version) + .expect_err("an unsupported version cannot apply a chunk"); + assert!( + format!("{err}").contains("Unsupported state sync protocol version"), + "version {bad}: got {err}" + ); + } + + assert_destination_untouched(&dest, before, grove_version); + } + + /// A session must refuse chunks applied at a version different from + /// its own even when the wire version itself is supported. Mixing + /// versions within one session is what this guard exists to stop; it + /// becomes reachable through the public API the moment a future bump + /// makes more than one version constructible. + #[test] + fn apply_chunk_rejects_a_version_that_differs_from_the_session() { + let grove_version = GroveVersion::latest(); + let source = make_source(grove_version); + let peer = SourcePeer::new(&source); + let dest = make_empty_grovedb(); + let app_hash = peer.app_hash(grove_version); + + // Every public entry point validates the version, so the only way + // to pin a session to a different one is the crate-private raw + // constructor: the wire version below then passes the supported + // check but fails the session-consistency check. + let mut session = MultiStateSyncSession::new( + &dest, + app_hash, + 64, + CURRENT_STATE_SYNC_VERSION + 1, + RestoreCommitMode::default(), + ); + let chunk = peer + .db + .fetch_chunk(&app_hash, None, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("the peer serves the root chunk at the current version"); + + let err = session + .apply_chunk(&app_hash, &chunk, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect_err("a session must reject a chunk applied at a different version"); + assert!( + format!("{err}").contains("does not match the session's version"), + "got: {err}" + ); + } + + /// Sanity anchor for the rejections above: the same grove round-trips + /// cleanly at the current version, so the failures are about version + /// handling and not about the fixture. + #[test] + fn the_fixture_round_trips_at_the_current_version() { + let grove_version = GroveVersion::latest(); + let source = make_source(grove_version); + let peer = SourcePeer::new(&source); + let dest = make_empty_grovedb(); + + sync_from_peer(&peer, &dest, CURRENT_STATE_SYNC_VERSION, grove_version) + .expect("a sync at the current version must succeed"); + + assert_eq!( + source.root_hash(None, grove_version).unwrap().unwrap(), + dest.root_hash(None, grove_version).unwrap().unwrap(), + ); + let issues = dest + .verify_grovedb(None, true, false, grove_version) + .expect("destination verify_grovedb should run"); + assert!(issues.is_empty(), "got: {issues:?}"); + } +} diff --git a/merk/src/merk/chunks.rs b/merk/src/merk/chunks.rs index 94fdb30fa..30f45f0a2 100644 --- a/merk/src/merk/chunks.rs +++ b/merk/src/merk/chunks.rs @@ -158,10 +158,11 @@ where let chunk = self .merk .walk(|maybe_walker| match maybe_walker { - Some(mut walker) => walker.traverse_and_build_chunk( + Some(mut walker) => walker.traverse_and_build_chunk_with_features( &traversal_instructions, chunk_height, tree_type, + true, grove_version, ), None => Err(Error::ChunkingError(ChunkError::EmptyTree( diff --git a/merk/src/merk/restore.rs b/merk/src/merk/restore.rs index 353b8dd1d..ff4c1580e 100644 --- a/merk/src/merk/restore.rs +++ b/merk/src/merk/restore.rs @@ -34,7 +34,10 @@ use std::collections::BTreeMap; use grovedb_storage::{Batch, StorageContext}; use grovedb_version::version::GroveVersion; +use grovedb_element::Element; + use crate::{ + element::tree_type::ElementTreeTypeExtensions, merk, merk::MerkSource, proofs::{ @@ -47,7 +50,10 @@ use crate::{ tree::{execute, Child, Tree as ProofTree}, Node, Op, }, - tree::{combine_hash, kv::ValueDefinedCostType, value_hash, RefWalker, TreeNode}, + tree::{ + combine_hash, hash::NULL_HASH, kv::ValueDefinedCostType, value_hash, AggregateData, + RefWalker, TreeNode, + }, tree_type::TreeType, CryptoHash, Error, Error::{CostsError, StorageError}, @@ -94,6 +100,7 @@ use crate::{ /// `Restorer`. pub struct Restorer { merk: Merk, + expected_root_hash: CryptoHash, chunk_id_to_root_hash: BTreeMap, CryptoHash>, parent_key_value_hash: Option, // this is used to keep track of parents whose links need to be rewritten @@ -112,6 +119,7 @@ impl<'db, S: StorageContext<'db>> Restorer { chunk_id_to_root_hash.insert(traversal_instruction_as_vec_bytes(&[]), expected_root_hash); Self { merk, + expected_root_hash, chunk_id_to_root_hash, parent_key_value_hash, parent_keys: BTreeMap::new(), @@ -125,6 +133,40 @@ impl<'db, S: StorageContext<'db>> Restorer { self.merk } + /// Whether the commitment this restorer was constructed against is + /// the commitment of an EMPTY tree. + /// + /// This is the counterpart of [`Self::verify_chunk`] for the one + /// payload that never reaches it. A source that answers a root-chunk + /// request with no chunk at all is claiming the tree is empty, and + /// because nothing is applied, nothing is checked against + /// `expected_root_hash`. Asking this question is how the caller + /// establishes that "no data" was the honest answer rather than a + /// byzantine source hollowing out a populated tree — the restored + /// Merk's own root hash cannot: it is NULL either way. + /// + /// Returns `false` once the root chunk has been processed, since the + /// question no longer applies. + pub fn expects_an_empty_tree(&self) -> bool { + let Some(expected_root_hash) = self + .chunk_id_to_root_hash + .get(&traversal_instruction_as_vec_bytes(&[])) + else { + return false; + }; + let empty_commitment = match self.parent_key_value_hash { + // Bound to a parent: the parent committed to + // `combine_hash(H(element bytes), child root hash)`, and an + // empty child's root hash is `NULL_HASH`. + Some(parent_key_value_hash) => { + combine_hash(&parent_key_value_hash, &NULL_HASH).unwrap() + } + // Unbound: the expected root hash IS the tree's root hash. + None => NULL_HASH, + }; + *expected_root_hash == empty_commitment + } + /// Processes a chunk at some chunk id, returns the chunks id's of chunks /// that can be requested pub fn process_chunk( @@ -309,6 +351,23 @@ impl<'db, S: StorageContext<'db>> Restorer { let mut batch = self.merk.storage.new_batch(); let mut new_chunk_ids = Vec::new(); + // Chunk verification cannot tell feature-type families apart: + // `Node::KV` and a `SummedMerkNode` `KVValueHashFeatureType` hash + // identically, and so do the `ProvableCounted*` variants that share + // `node_hash_with_count`. A node carrying a family foreign to this + // merk's tree type is a lie; refuse it before it is persisted, where + // it would corrupt every later aggregate computation (or panic in + // `hash_for_link`). + let tree_type = self.merk.tree_type; + let family_check = |tree: &TreeNode| -> Result<(), Error> { + if tree.node_type() != tree_type.inner_node_type() { + return Err(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "chunk node feature type does not belong to the tree type", + ))); + } + Ok(()) + }; + chunk_tree.visit_refs_track_traversal_and_parent( traversal_instruction, None, @@ -331,6 +390,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); // encode the node and add it to the batch + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) @@ -350,6 +410,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(LEFT) = proof_node.left.as_ref().map(Child::as_link); *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) } @@ -368,6 +429,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(LEFT) = proof_node.left.as_ref().map(Child::as_link); *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) } @@ -386,6 +448,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(LEFT) = proof_node.left.as_ref().map(Child::as_link); *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) } @@ -405,6 +468,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(LEFT) = proof_node.left.as_ref().map(Child::as_link); *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) } @@ -428,6 +492,7 @@ impl<'db, S: StorageContext<'db>> Restorer { *tree.slot_mut(LEFT) = proof_node.left.as_ref().map(Child::as_link); *tree.slot_mut(RIGHT) = proof_node.right.as_ref().map(Child::as_link); + family_check(&tree)?; let bytes = tree.encode(); batch.put(key, &bytes, None, None).map_err(CostsError) } @@ -498,9 +563,15 @@ impl<'db, S: StorageContext<'db>> Restorer { .last() .expect("rewrite is only called when traversal_instruction is not empty"); - let updated_key = chunk_tree - .key() - .expect("chunk tree must have a key during restore"); + // A chunk whose root is a bare `Hash` node verifies (its proof-tree + // hash IS the expected hash) but carries no key to link the parent + // to. It is untrusted network input, so refuse it descriptively. + let updated_key = + chunk_tree + .key() + .ok_or(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "non-root chunk cannot be a bare hash node", + )))?; let updated_sum = chunk_tree.aggregate_data().map_err(|e| { Error::CorruptedData(format!( "chunk tree root node must be KVValueHashFeatureType for aggregate data: {e}" @@ -627,6 +698,189 @@ impl<'db, S: StorageContext<'db>> Restorer { .map_err(StorageError) } + /// Recomputes all aggregate metadata (node OWN feature values and link + /// `aggregate_data`) bottom-up from the actual tree contents. + /// + /// Chunk proof nodes for the `Provable*` aggregate families (`KVCount`, + /// `KVSum`, `KVCountSum`) embed the node's SUBTREE aggregate — that is + /// what the verifier hashes via `node_hash_with_*` — so `write_chunk` + /// persists aggregate totals where the node's OWN contribution belongs, + /// and `Child::as_link` stores whatever value the proof child carried + /// (an aggregate for `KVCount`-family children, an OWN value for + /// `KVValueHashFeatureType` children with descendants, a placeholder + /// across chunk boundaries). None of that is authoritative. + /// + /// This pass — the aggregate counterpart of `rewrite_heights` — walks + /// the fully restored tree bottom-up and: + /// - recovers each node's own feature value by subtracting child + /// aggregates from proof-carried totals; when the caller explicitly + /// selects GroveDB element semantics, derives it from the element + /// bytes instead, as the write path does, and + /// - rewrites every link's `aggregate_data` from the recomputed child + /// subtree aggregates, + /// + /// leaving the tree indistinguishable from one built by ordinary + /// writes. Hashes are untouched: the feature type does not participate + /// in the kv hash, and node hashes always recombine aggregates live + /// from own + link values. + /// + /// What authenticates the result differs by family. For the `Provable*` + /// families the aggregate is an input to the node hash, so + /// `finalize()`'s subsequent `verify()` and root recheck catch a chunk + /// producer lying about it. For the non-provable families (`SumTree` / + /// `BigSumTree` / `CountTree` / `CountSumTree`, and the sum half of + /// `ProvableCountSumTree`) no hash covers the aggregate: in element + /// mode it is authenticated only because each simple-valued element's + /// bytes are required to match the hash-bound `value_hash` before they + /// decide the contribution (see the check below); in raw mode it is + /// whatever the proof carried and cannot be authenticated at this + /// layer. + fn rewrite_aggregates( + &mut self, + grove_version: &GroveVersion, + grove_db_elements: bool, + ) -> Result<(), Error> { + fn rewrite_child_aggregates<'s, 'db, S: StorageContext<'db>>( + tree_type: TreeType, + mut walker: RefWalker>, + batch: &mut >::Batch, + grove_version: &GroveVersion, + grove_db_elements: bool, + ) -> Result { + let mut cloned_node = TreeNode::decode( + walker.tree().key().to_vec(), + walker.tree().encode().as_slice(), + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .map_err(|_| { + Error::CorruptedState("failed to decode tree node during aggregate rewrite") + })?; + + // `write_chunk` already refuses nodes whose feature-type family + // is foreign to the tree type; this is the backstop for + // anything that reached storage another way, and it runs + // before `hash_for_link` could panic on such a mismatch. + if cloned_node.node_type() != tree_type.inner_node_type() { + return Err(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "chunk node feature type does not belong to the tree type", + ))); + } + + // Opaque Merk values can coincidentally encode an Element. + // Only an explicit GroveDB caller may interpret them as one. + if grove_db_elements { + let element = Element::deserialize(cloned_node.value_as_slice(), grove_version) + .map_err(|_| { + Error::CorruptedState("invalid element during aggregate rewrite") + })?; + // The chunk hash binds the carried `value_hash`, never the + // value bytes (`Tree::hash` digests the carried hash for + // the KVValueHash-family nodes), so a byzantine source can + // ship forged element bytes under an honest value hash and + // still pass chunk verification. Those bytes are about to + // decide the node's aggregate contribution, so for element + // types whose value hash is simply `H(bytes)` require the + // two to agree first. Subtree and reference elements carry + // a combined hash instead; subtrees are bound by the child + // restore's own commitment check. The GroveDB session must + // verify reference bindings once all target subtrees have + // arrived, before it commits the restore. + if element.element_type().has_simple_value_hash() + && value_hash(cloned_node.value_as_slice()).unwrap() + != *cloned_node.value_hash() + { + return Err(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "element value bytes do not match the hash-bound value hash", + ))); + } + let feature = element.get_feature_type(tree_type).map_err(|_| { + Error::CorruptedState("cannot derive feature type during aggregate rewrite") + })?; + cloned_node.set_feature_type(feature); + } + + let mut child_aggregates = [AggregateData::NoAggregateData; 2]; + for (slot, side) in [LEFT, RIGHT].into_iter().enumerate() { + if let Some(child_walker) = walker + .walk( + side, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .value? + { + let child_aggregate = rewrite_child_aggregates( + tree_type, + child_walker, + batch, + grove_version, + grove_db_elements, + )?; + child_aggregates[slot] = child_aggregate; + if let Some(Link::Reference { aggregate_data, .. }) = cloned_node.link_mut(side) + { + *aggregate_data = child_aggregate; + } else { + return Err(Error::CorruptedState( + "expected a reference link after walking child during aggregate \ + rewrite", + )); + } + } + } + + // Raw Merk values: the feature value on disk is + // whatever the chunk proof carried, which for a provable + // host is the node's SUBTREE total. Turn it back into the + // node's own contribution now that the children's aggregates + // are known. + if !grove_db_elements { + let own = own_contribution_from_subtree_total( + cloned_node.feature_type(), + child_aggregates[0], + child_aggregates[1], + )?; + cloned_node.set_feature_type(own); + } + + let bytes = cloned_node.encode(); + batch + .put(walker.tree().key(), &bytes, None, None) + .map_err(CostsError)?; + + cloned_node.aggregate_data().map_err(|_| { + Error::CorruptedState("cannot combine aggregates during aggregate rewrite") + }) + } + + let mut batch = self.merk.storage.new_batch(); + let Some(mut tree) = self.merk.tree.take() else { + // Empty tree: nothing to rewrite. + return Ok(()); + }; + let tree_type = self.merk.tree_type; + let walker = RefWalker::new(&mut tree, self.merk.source()); + + let result = rewrite_child_aggregates( + tree_type, + walker, + &mut batch, + grove_version, + grove_db_elements, + ); + + self.merk.tree.set(Some(tree)); + result?; + + // costs intentionally discarded — restorer does not track them + self.merk + .storage + .commit_batch(batch) + .value + .map_err(StorageError) + } + /// Rebuild restoration state from partial storage state #[allow(dead_code)] fn attempt_state_recovery(&mut self, grove_version: &GroveVersion) -> Result<(), Error> { @@ -649,7 +903,27 @@ impl<'db, S: StorageContext<'db>> Restorer { /// any placeholder heights stored during intermediate chunk processing. /// See the struct-level doc comment on [`Restorer`] for the full safety /// argument. - pub fn finalize(mut self, grove_version: &GroveVersion) -> Result, Error> { + /// Values remain opaque; their encoding never changes feature semantics. + pub fn finalize(self, grove_version: &GroveVersion) -> Result, Error> { + self.finalize_inner(grove_version, false) + } + + /// Finalizes a GroveDB subtree whose values are serialized `Element`s. + /// Own contributions are derived from those elements, including sums + /// that are not authenticated by the host's node hashes. Raw Merk callers + /// must use [`Self::finalize`] to preserve their independent features. + pub fn finalize_with_grovedb_elements( + self, + grove_version: &GroveVersion, + ) -> Result, Error> { + self.finalize_inner(grove_version, true) + } + + fn finalize_inner( + mut self, + grove_version: &GroveVersion, + grove_db_elements: bool, + ) -> Result, Error> { // ensure all chunks have been processed if !self.chunk_id_to_root_hash.is_empty() || !self.parent_keys.is_empty() { return Err(Error::ChunkRestoringError( @@ -688,17 +962,55 @@ impl<'db, S: StorageContext<'db>> Restorer { })?; } - if !self - .merk - .verify(self.merk.tree_type != TreeType::NormalTree, grove_version) - .0 - .is_empty() - { + // Aggregate metadata written during chunk processing is not + // authoritative (see `rewrite_aggregates`). Recompute it bottom-up + // for every aggregate-bearing tree type before the final `verify`. + if self.merk.tree_type != TreeType::NormalTree { + self.rewrite_aggregates(grove_version, grove_db_elements)?; + // update the root node after the aggregate rewrite + self.merk + .load_base_root( + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .value + .map_err(|_| { + Error::ChunkRestoringError(ChunkError::InternalError( + "failed to reload base root after aggregate rewrite", + )) + })?; + } + + // Full verification INCLUDING the aggregate cross-checks + // (`skip_sum_checks: false`). Historically the aggregate checks + // were skipped for aggregate-bearing tree types because restored + // aggregates were not authoritative; `rewrite_aggregates` above + // now recomputes them from the persisted nodes, so the link + // aggregates must be consistent with them. Note what this does + // and does not prove: the link hash check binds aggregates only + // for the `Provable*` families (their aggregate is hashed); for + // the others the aggregate check compares two values derived from + // the same persisted bytes, and the authentication comes from the + // value-hash binding in `rewrite_aggregates` instead. + if !self.merk.verify(false, grove_version).0.is_empty() { return Err(Error::ChunkRestoringError(ChunkError::InternalError( "restored tree invalid", ))); } + // verify() checks links, but the root has no incoming link. A + // feature rewrite can change its hash even when every link is valid. + let root_hash = self.merk.root_hash().unwrap(); + let restored_hash = match self.parent_key_value_hash { + Some(value_hash) => combine_hash(&value_hash, &root_hash).unwrap(), + None => root_hash, + }; + if restored_hash != self.expected_root_hash { + return Err(Error::ChunkRestoringError(ChunkError::InvalidChunkProof( + "restored root does not match expected root hash", + ))); + } + Ok(self.merk) } @@ -782,6 +1094,92 @@ impl<'db, S: StorageContext<'db>> Restorer { } } +/// The provable count carried by a child's recomputed aggregate, or zero +/// for an aggregate that carries no count. +fn provable_count_of(data: AggregateData) -> u64 { + match data { + AggregateData::ProvableCount(count) + | AggregateData::ProvableCountAndSum(count, _) + | AggregateData::ProvableCountAndProvableSum(count, _) => count, + _ => 0, + } +} + +/// The provable sum carried by a child's recomputed aggregate, or zero +/// for an aggregate that carries no provable sum. +fn provable_sum_of(data: AggregateData) -> i64 { + match data { + AggregateData::ProvableSum(sum) + | AggregateData::ProvableCountAndSum(_, sum) + | AggregateData::ProvableCountAndProvableSum(_, sum) => sum, + _ => 0, + } +} + +/// Recover a node's OWN aggregate contribution from the value a chunk +/// proof carried for it, given its children's recomputed aggregates. +/// +/// Used for opaque Merk values regardless of their encoding. GroveDB +/// callers explicitly derive their contributions from elements instead. +/// +/// For the `Provable*` hosts the proof node's feature value is the +/// node's SUBTREE total — that is what the verifier hashes, and it is +/// what both `Node::KVCount`/`KVSum`/`KVCountSum` and +/// `to_kv_value_hash_feature_type_node`'s `KVValueHashFeatureType` +/// fallback put on the wire. Persisting that total as the node's own +/// contribution and then re-attaching the children's aggregates would +/// count every descendant twice, so the children are subtracted back +/// out here. +/// +/// Every other feature value — including the non-provable `SumTree` / +/// `CountTree` / `CountSumTree` / `BigSumTree` family, whose aggregates +/// do not participate in the node hash and whose proof nodes therefore +/// carry the own value — is already the own contribution and is +/// returned unchanged. +fn own_contribution_from_subtree_total( + subtree_total: TreeFeatureType, + left: AggregateData, + right: AggregateData, +) -> Result { + let own_count = |total: u64| -> Result { + total + .checked_sub(provable_count_of(left)) + .and_then(|rest| rest.checked_sub(provable_count_of(right))) + .ok_or(Error::CorruptedState( + "chunk-carried subtree count is smaller than its children's counts", + )) + }; + let own_sum = |total: i64| -> Result { + total + // Undo (own + left) + right in reverse order so valid signed + // cancellation cannot overflow at an intermediate step. + .checked_sub(provable_sum_of(right)) + .and_then(|rest| rest.checked_sub(provable_sum_of(left))) + .ok_or(Error::CorruptedState( + "chunk-carried subtree sum does not decompose against its children's sums", + )) + }; + + Ok(match subtree_total { + TreeFeatureType::ProvableCountedMerkNode(count) => { + TreeFeatureType::ProvableCountedMerkNode(own_count(count)?) + } + TreeFeatureType::ProvableSummedMerkNode(sum) => { + TreeFeatureType::ProvableSummedMerkNode(own_sum(sum)?) + } + TreeFeatureType::ProvableCountedSummedMerkNode(count, sum) => { + TreeFeatureType::ProvableCountedSummedMerkNode(own_count(count)?, own_sum(sum)?) + } + TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(count, sum) => { + TreeFeatureType::ProvableCountedAndProvableSummedMerkNode( + own_count(count)?, + own_sum(sum)?, + ) + } + own => own, + }) +} + #[cfg(test)] mod tests { use grovedb_path::SubtreePath; @@ -1851,14 +2249,15 @@ mod tests { // ---------- write_chunk node dispatch coverage ---------- // - // Single-leaf chunk round-trips exercise the new `KVSum` and + // Single-leaf chunk round-trips exercise the `KVSum` and // `KVCountSum` write-chunk arms end-to-end. For a single-leaf // tree (no children) the on-disk feature_type's OWN value equals // the chunk's reported AGGREGATE value, so the restored root - // hash matches the source's root hash. (Multi-key chunks on - // Provable* trees have a separate pre-existing - // OWN-vs-AGGREGATE issue affecting `ProvableCountTree` too; - // that's out of scope here.) + // hash matches the source's root hash even without the + // finalize-time aggregate rewrite. Multi-key trees (where OWN and + // AGGREGATE diverge) are covered by + // `restore_multi_node_provable_trees_round_trip` below, which + // exercises `rewrite_aggregates`. fn single_leaf_chunk_round_trip(tree_type: TreeType, feature_type: TreeFeatureType) { let grove_version = GroveVersion::latest(); @@ -1945,4 +2344,562 @@ mod tests { TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(1, 7), ); } + + /// Multi-node chunk restore for a Provable* tree, with GroveDB + /// `Element` values (as every GroveDB subtree stores). The chunk's + /// `KVCount`-family nodes carry subtree AGGREGATES, so on inner + /// nodes OWN and AGGREGATE diverge; without the finalize-time + /// `rewrite_aggregates` pass the restored merk would recompute + /// inflated aggregates and a root hash different from the + /// (chunk-verified) source root. + fn multi_node_provable_chunk_round_trip( + tree_type: TreeType, + make_element: impl Fn(u8) -> Element, + ) { + let grove_version = GroveVersion::latest(); + let batch: Vec<(Vec, crate::tree::Op)> = (0u8..12) + .map(|i| { + let element = make_element(i); + let feature_type = element + .get_feature_type(tree_type) + .expect("feature type for element"); + let bytes = element.serialize(grove_version).expect("serialize element"); + (vec![i], crate::tree::Op::Put(bytes, feature_type)) + }) + .collect(); + drive_multi_node_chunk_round_trip(tree_type, batch); + } + + /// Build a source merk of `tree_type` from `batch`, stream every chunk + /// of it through a `Restorer`, finalize, and require the restored merk + /// to match the source's root hash and aggregate data. + fn drive_multi_node_chunk_round_trip( + tree_type: TreeType, + batch: Vec<(Vec, crate::tree::Op)>, + ) { + let grove_version = GroveVersion::latest(); + + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let mut source_merk = Merk::open_base( + storage + .get_immediate_storage_context(SubtreePath::empty(), &tx) + .unwrap(), + tree_type, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .unwrap() + .unwrap(); + source_merk + .apply::<_, Vec<_>>(&batch, &[], None, grove_version) + .unwrap() + .expect("apply elements"); + + // Empty restoration merk with the matching tree_type. + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let restoration_merk = Merk::open_base( + storage + .get_immediate_storage_context(SubtreePath::empty(), &tx) + .unwrap(), + tree_type, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .unwrap() + .unwrap(); + + // Drain the full chunk stream. + let mut chunk_producer = + ChunkProducer::new(&source_merk).expect("should create chunk producer"); + let mut restorer = Restorer::new(restoration_merk, source_merk.root_hash().unwrap(), None); + let mut pending: Vec> = vec![traversal_instruction_as_vec_bytes(&[])]; + while let Some(chunk_id) = pending.pop() { + let instruction = + vec_bytes_as_traversal_instruction(&chunk_id).expect("valid chunk id"); + let (chunk, _) = chunk_producer + .chunk( + &traversal_instruction_as_vec_bytes(&instruction), + grove_version, + ) + .expect("produce chunk"); + pending.extend( + restorer + .process_chunk(&chunk_id, chunk, grove_version) + .expect("process chunk"), + ); + } + + let restored_merk = restorer.finalize(grove_version).expect("finalize"); + assert_eq!( + source_merk.root_hash().unwrap(), + restored_merk.root_hash().unwrap(), + "multi-node restored root must match source root for {:?}", + tree_type + ); + assert_eq!( + source_merk + .root_hash_key_and_aggregate_data() + .unwrap() + .expect("source aggregate"), + restored_merk + .root_hash_key_and_aggregate_data() + .unwrap() + .expect("restored aggregate"), + "restored aggregate data must match source for {:?}", + tree_type + ); + for (key, _) in &batch { + let get_feature = |merk: &Merk<_>| { + merk.get_feature_type( + key, + false, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .unwrap() + .unwrap() + }; + assert_eq!( + get_feature(&source_merk), + get_feature(&restored_merk), + "own feature changed at key {key:?} in {tree_type:?}" + ); + } + } + + #[test] + fn restore_multi_node_provable_trees_round_trip() { + multi_node_provable_chunk_round_trip(TreeType::ProvableCountTree, |_| { + Element::new_item(b"item".to_vec()) + }); + multi_node_provable_chunk_round_trip(TreeType::ProvableSumTree, |i| { + Element::new_sum_item(i64::from(i) * 3 - 10) + }); + multi_node_provable_chunk_round_trip(TreeType::ProvableCountProvableSumTree, |i| { + Element::new_item_with_sum_item(vec![i], i64::from(i) * 5 - 20) + }); + } + + /// A value that is deliberately not a GroveDB `Element`: discriminant + /// byte 0xFE is unallocated, so both `ElementType::from_serialized_value` + /// (chunk producer) and `Element::deserialize` (aggregate rewrite) + /// reject it. This is what the exported raw-merk API stores. + fn raw_value(i: u8) -> Vec { + vec![0xFE, i, 0xAA, 0xBB] + } + + /// The same multi-node round trip over a RAW merk — values that are + /// not GroveDB `Element`s, which is the whole point of the exported + /// `ChunkProducer` / `Restorer` API. + /// + /// A non-`Element` value makes the chunk producer fall back to + /// `Node::KVValueHashFeatureType`, and for a `Provable*` host + /// `to_kv_value_hash_feature_type_node` fills that feature type with + /// the node's SUBTREE aggregate (the verifier hashes the aggregate, + /// not the own contribution). `rewrite_aggregates` cannot re-derive + /// an own contribution from such a value, so it must recover it by + /// subtracting the recomputed child aggregates from the proof-carried + /// subtree total. Retaining the total as the node's own contribution + /// double-counts every descendant, and `finalize`'s `verify` then + /// rejects a perfectly valid restore. + fn multi_node_raw_provable_chunk_round_trip( + tree_type: TreeType, + make_feature_type: impl Fn(u8) -> TreeFeatureType, + ) { + let batch: Vec<(Vec, crate::tree::Op)> = (0u8..12) + .map(|i| { + ( + vec![i], + crate::tree::Op::Put(raw_value(i), make_feature_type(i)), + ) + }) + .collect(); + drive_multi_node_chunk_round_trip(tree_type, batch); + } + + #[test] + fn restore_multi_node_raw_provable_trees_round_trip() { + multi_node_raw_provable_chunk_round_trip(TreeType::ProvableCountTree, |_| { + TreeFeatureType::ProvableCountedMerkNode(1) + }); + multi_node_raw_provable_chunk_round_trip(TreeType::ProvableSumTree, |i| { + TreeFeatureType::ProvableSummedMerkNode(i64::from(i) * 3 - 10) + }); + multi_node_raw_provable_chunk_round_trip(TreeType::ProvableCountProvableSumTree, |i| { + TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(1, i64::from(i) * 5 - 20) + }); + // The fourth aggregate `to_kv_value_hash_feature_type_node` + // substitutes: only the count is bound into the node hash, but + // both halves travel as subtree totals and both must be + // decomposed. + multi_node_raw_provable_chunk_round_trip(TreeType::ProvableCountSumTree, |i| { + TreeFeatureType::ProvableCountedSummedMerkNode(1, i64::from(i) * 4 - 14) + }); + } + + /// The raw-merk fallback must not disturb the non-provable aggregate + /// hosts, whose proof nodes carry the node's OWN feature value (their + /// aggregates do not participate in the node hash). Subtracting child + /// aggregates from an own value would corrupt exactly these. + #[test] + fn restore_multi_node_raw_non_provable_aggregate_trees_round_trip() { + multi_node_raw_provable_chunk_round_trip(TreeType::SumTree, |i| { + TreeFeatureType::SummedMerkNode(i64::from(i) * 3 - 10) + }); + multi_node_raw_provable_chunk_round_trip(TreeType::CountTree, |_| { + TreeFeatureType::CountedMerkNode(1) + }); + multi_node_raw_provable_chunk_round_trip(TreeType::CountSumTree, |i| { + TreeFeatureType::CountedSummedMerkNode(1, i64::from(i) * 2 - 6) + }); + multi_node_raw_provable_chunk_round_trip(TreeType::BigSumTree, |i| { + TreeFeatureType::BigSummedMerkNode(i128::from(i) * 7 - 30) + }); + } + + #[test] + fn restore_element_shaped_opaque_values_preserves_features() { + let grove_version = GroveVersion::latest(); + for (tree_type, feature_type) in [ + ( + TreeType::ProvableCountTree, + TreeFeatureType::ProvableCountedMerkNode(7), + ), + ( + TreeType::ProvableSumTree, + TreeFeatureType::ProvableSummedMerkNode(7), + ), + ( + TreeType::ProvableCountSumTree, + TreeFeatureType::ProvableCountedSummedMerkNode(7, 13), + ), + ( + TreeType::ProvableCountProvableSumTree, + TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(7, 13), + ), + (TreeType::SumTree, TreeFeatureType::SummedMerkNode(7)), + (TreeType::CountTree, TreeFeatureType::CountedMerkNode(7)), + ( + TreeType::CountSumTree, + TreeFeatureType::CountedSummedMerkNode(7, 13), + ), + (TreeType::BigSumTree, TreeFeatureType::BigSummedMerkNode(7)), + ] { + // Raw Merk callers choose features independently of their opaque + // value bytes, even when those bytes encode a valid Element. + let batch = (0u8..12) + .map(|i| { + ( + vec![i], + crate::tree::Op::Put( + Element::new_item(vec![i]).serialize(grove_version).unwrap(), + feature_type, + ), + ) + }) + .collect(); + drive_multi_node_chunk_round_trip(tree_type, batch); + } + } + + #[test] + fn restore_raw_provable_sums_with_signed_cancellation() { + for tree_type in [ + TreeType::ProvableSumTree, + TreeType::ProvableCountSumTree, + TreeType::ProvableCountProvableSumTree, + ] { + for sums in [[i64::MIN, i64::MAX, 1], [i64::MAX, i64::MIN, -1]] { + // Sorted batch construction puts key 1 at the root. Forward + // aggregation (own + left) + right fits in i64 at every step. + let batch = sums + .into_iter() + .enumerate() + .map(|(i, sum)| { + let feature = match tree_type { + TreeType::ProvableSumTree => { + TreeFeatureType::ProvableSummedMerkNode(sum) + } + TreeType::ProvableCountSumTree => { + TreeFeatureType::ProvableCountedSummedMerkNode(1, sum) + } + _ => TreeFeatureType::ProvableCountedAndProvableSummedMerkNode(1, sum), + }; + ( + vec![i as u8], + crate::tree::Op::Put(raw_value(i as u8), feature), + ) + }) + .collect(); + drive_multi_node_chunk_round_trip(tree_type, batch); + } + } + } + + #[test] + fn restore_rechecks_root_after_element_feature_rewrite() { + let grove_version = GroveVersion::latest(); + for parent_hash in [None, Some([42; 32])] { + let value = Element::new_item(vec![1]).serialize(grove_version).unwrap(); + // A one-node proof commits to count 7, while the Element's own + // count is 1. There are no child links for Merk::verify to check. + let node = Node::KVCount(vec![0], value, 7); + let root_hash = ProofTree::from(node.clone()).hash().unwrap(); + let expected = parent_hash.map_or(root_hash, |parent| { + combine_hash(&parent, &root_hash).unwrap() + }); + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let merk = Merk::open_base( + storage + .get_immediate_storage_context(SubtreePath::empty(), &tx) + .unwrap(), + TreeType::ProvableCountTree, + None::<&fn(&[u8], &GroveVersion) -> Option>, + grove_version, + ) + .unwrap() + .unwrap(); + let mut restorer = Restorer::new(merk, expected, parent_hash); + assert!(restorer + .process_chunk(&[], vec![Op::Push(node)], grove_version) + .unwrap() + .is_empty()); + let Err(err) = restorer.finalize_with_grovedb_elements(grove_version) else { + panic!("root rewrite must be rejected"); + }; + assert!( + err.to_string().contains("restored root does not match"), + "{err}" + ); + } + } + + /// Opens an empty restoration merk of `tree_type` on `storage` inside + /// `tx`. + fn open_restoration_merk<'a>( + storage: &'a TempStorage, + tx: &'a >::Transaction, + tree_type: TreeType, + ) -> Merk> { + Merk::open_base( + storage + .get_immediate_storage_context(SubtreePath::empty(), tx) + .unwrap(), + tree_type, + None::<&fn(&[u8], &GroveVersion) -> Option>, + GroveVersion::latest(), + ) + .unwrap() + .unwrap() + } + + /// The value bytes of a `KVValueHashFeatureType` chunk node are not + /// bound by the chunk hash — the hash is computed from the carried + /// `value_hash`, never from `H(value)`. A byzantine source can + /// therefore keep every hash-bound field of an honest node and swap in + /// forged element bytes. Element-mode finalization derives the node's + /// aggregate contribution from those bytes, so it must first require + /// them to match the hash-bound value hash whenever the element type + /// has a simple (non-combined) value hash. + #[test] + fn restore_with_grovedb_elements_rejects_forged_value_bytes_under_honest_value_hash() { + let grove_version = GroveVersion::latest(); + for (tree_type, honest, forged, feature) in [ + ( + TreeType::SumTree, + Element::new_sum_item(5), + Element::new_sum_item(1_000_000), + TreeFeatureType::SummedMerkNode(5), + ), + ( + TreeType::CountTree, + Element::new_item(vec![1]), + Element::new_non_counted(Element::new_item(vec![1])).unwrap(), + TreeFeatureType::CountedMerkNode(1), + ), + ( + TreeType::CountSumTree, + Element::new_item_with_sum_item(vec![1], 5), + Element::new_item_with_sum_item(vec![1], -5), + TreeFeatureType::CountedSummedMerkNode(1, 5), + ), + ( + TreeType::BigSumTree, + Element::new_sum_item(5), + Element::new_sum_item(1_000_000), + TreeFeatureType::BigSummedMerkNode(5), + ), + ] { + let honest_bytes = honest.serialize(grove_version).unwrap(); + let forged_bytes = forged.serialize(grove_version).unwrap(); + let honest_value_hash = value_hash(&honest_bytes).unwrap(); + + let honest_node = Node::KVValueHashFeatureType( + vec![0], + honest_bytes.clone(), + honest_value_hash, + feature, + ); + let forged_node = Node::KVValueHashFeatureType( + vec![0], + forged_bytes.clone(), + honest_value_hash, + feature, + ); + let root_hash = ProofTree::from(honest_node.clone()).hash().unwrap(); + assert_eq!( + root_hash, + ProofTree::from(forged_node.clone()).hash().unwrap(), + "sanity: the forged node hashes identically, so chunk verification alone cannot \ + tell them apart ({tree_type:?})" + ); + + // The honest chunk restores. + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let merk = open_restoration_merk(&storage, &tx, tree_type); + let mut restorer = Restorer::new(merk, root_hash, None); + assert!( + !restorer.expects_an_empty_tree(), + "a populated commitment never reads as empty" + ); + restorer + .process_chunk(&[], vec![Op::Push(honest_node)], grove_version) + .unwrap(); + assert!( + !restorer.expects_an_empty_tree(), + "once the root chunk is processed the question no longer applies" + ); + let restored = restorer + .finalize_with_grovedb_elements(grove_version) + .unwrap_or_else(|e| panic!("honest chunk must finalize for {tree_type:?}: {e}")); + drop(restored); + + // The forged chunk passes verification but must be refused at + // element-mode finalization. + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let merk = open_restoration_merk(&storage, &tx, tree_type); + let mut restorer = Restorer::new(merk, root_hash, None); + restorer + .process_chunk(&[], vec![Op::Push(forged_node)], grove_version) + .expect("chunk verification cannot see the forgery"); + let Err(err) = restorer.finalize_with_grovedb_elements(grove_version) else { + panic!( + "forged value bytes under an honest value hash were accepted for {tree_type:?}" + ); + }; + assert!( + err.to_string().contains("value hash"), + "{tree_type:?}: unexpected error {err}" + ); + } + } + + /// Nothing in chunk verification ties a node's feature-type family to + /// the merk's tree type: `Node::KV` and a `SummedMerkNode` + /// `KVValueHashFeatureType` hash identically, and so do the + /// `ProvableCounted*` variants that share `node_hash_with_count`. + /// Finalization must refuse a chunk whose nodes carry a foreign + /// family instead of persisting them (or, for the provable pair, + /// panicking in `hash_for_link` at the new root recheck). + #[test] + fn restore_rejects_feature_type_family_foreign_to_the_tree_type() { + let grove_version = GroveVersion::latest(); + + let vh = value_hash(&raw_value(0)).unwrap(); + for (tree_type, node) in [ + // A plain KV node (BasicMerkNode) offered for a SumTree. + (TreeType::SumTree, Node::KV(vec![0], raw_value(0))), + // A ProvableCountedSummedMerkNode offered for a + // ProvableCountTree: same node hash as + // ProvableCountedMerkNode(1), different family. + ( + TreeType::ProvableCountTree, + Node::KVValueHashFeatureType( + vec![0], + raw_value(0), + vh, + TreeFeatureType::ProvableCountedSummedMerkNode(1, 7), + ), + ), + // A summed node offered for a NormalTree, which has no + // aggregate rewrite pass to catch it later. + ( + TreeType::NormalTree, + Node::KVValueHashFeatureType( + vec![0], + raw_value(0), + vh, + TreeFeatureType::SummedMerkNode(5), + ), + ), + // A provable count node offered for a NormalTree. + ( + TreeType::NormalTree, + Node::KVCount(vec![0], raw_value(0), 1), + ), + ] { + let root_hash = ProofTree::from(node.clone()).hash().unwrap(); + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let merk = open_restoration_merk(&storage, &tx, tree_type); + let mut restorer = Restorer::new(merk, root_hash, None); + let Err(err) = restorer.process_chunk(&[], vec![Op::Push(node)], grove_version) else { + panic!("a foreign feature-type family was written into a {tree_type:?}"); + }; + assert!( + err.to_string().contains("feature type"), + "{tree_type:?}: {err}" + ); + } + } + + /// A non-root chunk consisting of a single `Hash` node verifies (its + /// proof-tree hash IS the expected hash) but has no key to rewrite the + /// parent link with. That must be a descriptive error, never a panic: + /// the chunk is network input from an untrusted source. + #[test] + fn test_hash_only_non_root_chunk_returns_error_not_panic() { + let grove_version = GroveVersion::latest(); + let mut merk = TempMerk::new(grove_version); + let batch = make_batch_seq(0..15); + merk.apply::<_, Vec<_>>(&batch, &[], None, grove_version) + .unwrap() + .expect("apply failed"); + let mut chunk_producer = ChunkProducer::new(&merk).expect("should create chunk producer"); + + let storage = TempStorage::new(); + let tx = storage.start_transaction(); + let restoration_merk = open_restoration_merk(&storage, &tx, TreeType::NormalTree); + let mut restorer = Restorer::new(restoration_merk, merk.root_hash().unwrap(), None); + + let (root_chunk, _) = chunk_producer + .chunk(&[], grove_version) + .expect("root chunk"); + let next_chunk_ids = restorer + .process_chunk(&[], root_chunk, grove_version) + .expect("root chunk should process"); + let child_id = next_chunk_ids + .first() + .cloned() + .expect("a 15-key tree has child chunks"); + let expected_child_hash = *restorer + .chunk_id_to_root_hash + .get(&child_id) + .expect("child chunk is pending"); + + let result = restorer.process_chunk( + &child_id, + vec![Op::Push(Node::Hash(expected_child_hash))], + grove_version, + ); + assert!( + matches!(result, Err(ChunkRestoringError(InvalidChunkProof(_)))), + "hash-only non-root chunk must be rejected, got {result:?}" + ); + } } diff --git a/merk/src/proofs/chunk/chunk.rs b/merk/src/proofs/chunk/chunk.rs index e58bb2703..9db1659ac 100644 --- a/merk/src/proofs/chunk/chunk.rs +++ b/merk/src/proofs/chunk/chunk.rs @@ -68,7 +68,7 @@ where cost_return_on_error!( &mut cost, - self.create_chunk_internal(&mut proof, depth, tree_type, grove_version) + self.create_chunk_internal(&mut proof, depth, tree_type, false, grove_version) ); Ok(proof).wrap_with_cost(cost) @@ -79,6 +79,7 @@ where proof: &mut Vec, remaining_depth: usize, tree_type: TreeType, + preserve_features: bool, grove_version: &GroveVersion, ) -> CostResult<(), Error> { let mut cost = OperationCost::default(); @@ -107,12 +108,34 @@ where .expect("confirmed is some"); cost_return_on_error!( &mut cost, - left.create_chunk_internal(proof, remaining_depth - 1, tree_type, grove_version) + left.create_chunk_internal( + proof, + remaining_depth - 1, + tree_type, + preserve_features, + grove_version + ) ); } // Determine the correct node type based on element type and tree type - let node = self.create_proof_node_for_chunk(tree_type); + // Restoration must preserve features omitted by query proof nodes, + // including for opaque values that happen to encode an Element. + // Trunk/branch proofs keep their existing node selection: their + // verifiers require value-binding node types for Item elements. + let node = if preserve_features + && matches!( + tree_type, + TreeType::SumTree + | TreeType::BigSumTree + | TreeType::CountTree + | TreeType::CountSumTree + | TreeType::ProvableCountSumTree + ) { + self.to_kv_value_hash_feature_type_node() + } else { + self.create_proof_node_for_chunk(tree_type) + }; proof.push(Op::Push(node)); if has_left_child { @@ -131,7 +154,13 @@ where if let Some(mut right) = maybe_right { cost_return_on_error!( &mut cost, - right.create_chunk_internal(proof, remaining_depth - 1, tree_type, grove_version) + right.create_chunk_internal( + proof, + remaining_depth - 1, + tree_type, + preserve_features, + grove_version + ) ); proof.push(Op::Child); @@ -187,13 +216,43 @@ where depth: usize, tree_type: TreeType, grove_version: &GroveVersion, + ) -> CostResult, Error> { + self.traverse_and_build_chunk_with_features( + instructions, + depth, + tree_type, + false, + grove_version, + ) + } + + /// The chunk producer preserves restoration metadata, while trunk and + /// branch query proofs use their established compact node types. + pub(crate) fn traverse_and_build_chunk_with_features( + &mut self, + instructions: &[bool], + depth: usize, + tree_type: TreeType, + preserve_features: bool, + grove_version: &GroveVersion, ) -> CostResult, Error> { let mut cost = OperationCost::default(); // base case if instructions.is_empty() { // we are at the desired node - return self.create_chunk(depth, tree_type, grove_version); + let mut proof = Vec::new(); + cost_return_on_error!( + &mut cost, + self.create_chunk_internal( + &mut proof, + depth, + tree_type, + preserve_features, + grove_version + ) + ); + return Ok(proof).wrap_with_cost(cost); } // link must exist @@ -218,7 +277,13 @@ where // recurse on child child - .traverse_and_build_chunk(&instructions[1..], depth, tree_type, grove_version) + .traverse_and_build_chunk_with_features( + &instructions[1..], + depth, + tree_type, + preserve_features, + grove_version, + ) .add_cost(cost) } diff --git a/merk/src/tree/mod.rs b/merk/src/tree/mod.rs index fe275f2d9..328dd7942 100644 --- a/merk/src/tree/mod.rs +++ b/merk/src/tree/mod.rs @@ -409,6 +409,17 @@ impl TreeNode { self.inner.kv.feature_type } + /// Replaces this node's feature type in place, leaving key, value and + /// cached hashes untouched (the feature type does not participate in + /// the kv hash). Used by chunk restoration to re-derive a node's OWN + /// aggregate contribution — chunk proof nodes for the `Provable*` + /// families carry subtree AGGREGATES, not own values (see + /// `Restorer::rewrite_aggregates`). + #[inline] + pub(crate) fn set_feature_type(&mut self, feature_type: TreeFeatureType) { + self.inner.kv.feature_type = feature_type; + } + /// Returns the root node's key as a slice. #[inline] pub fn key_as_ref(&self) -> &Vec {