From 03d91e811a1cb698d16ef537ade967890346431c Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 11 Jun 2026 15:54:02 +0200 Subject: [PATCH 1/2] fix(replication): require aged repair hints before audits --- src/replication/audit.rs | 42 ++++++-- src/replication/config.rs | 7 ++ src/replication/mod.rs | 5 +- src/replication/pruning.rs | 6 +- src/replication/types.rs | 209 +++++++++++++++++++++++++++++++------ tests/e2e/replication.rs | 15 ++- 6 files changed, 238 insertions(+), 46 deletions(-) diff --git a/src/replication/audit.rs b/src/replication/audit.rs index f074b9c4..3bbeaff9 100644 --- a/src/replication/audit.rs +++ b/src/replication/audit.rs @@ -4,6 +4,7 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Instant; use crate::logging::{debug, info, warn}; use rand::seq::SliceRandom; @@ -167,11 +168,13 @@ pub async fn audit_tick_with_repair_proofs( let peer_keys = { let mut proofs = repair_proofs.write().await; + let now = Instant::now(); mature_audit_keys_for_peer( &challenged_peer, sampled_key_groups, &mut proofs, current_sync_epoch, + now, ) }; @@ -349,12 +352,19 @@ fn mature_audit_keys_for_peer( sampled_key_groups: Vec<(XorName, HashSet)>, repair_proofs: &mut RepairProofs, current_sync_epoch: u64, + now: Instant, ) -> Vec { sampled_key_groups .into_iter() .filter_map(|(key, close_peers)| { repair_proofs - .has_mature_replica_hint(challenged_peer, &key, &close_peers, current_sync_epoch) + .has_mature_replica_hint( + challenged_peer, + &key, + &close_peers, + current_sync_epoch, + now, + ) .then_some(key) }) .collect() @@ -720,6 +730,7 @@ pub async fn handle_audit_challenge( #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; + use crate::replication::config::REPAIR_HINT_MIN_AGE; use crate::replication::protocol::compute_audit_digest; use crate::replication::types::{BootstrapClaimObservation, NeighborSyncState}; use crate::storage::LmdbStorageConfig; @@ -1367,6 +1378,7 @@ mod tests { const MISSING_PROOF_KEY_BYTE: u8 = 0xB3; const STABLE_CHURN_KEY_BYTE: u8 = 0xB4; const EVICTED_KEY_BYTE: u8 = 0xB5; + const FRESH_HINT_KEY_BYTE: u8 = 0xB6; const XOR_NAME_LEN: usize = 32; let challenged_peer = peer_id_from_bytes([CHALLENGED_PEER_BYTE; XOR_NAME_LEN]); @@ -1377,34 +1389,50 @@ mod tests { let missing_proof_key = [MISSING_PROOF_KEY_BYTE; XOR_NAME_LEN]; let stable_churn_key = [STABLE_CHURN_KEY_BYTE; XOR_NAME_LEN]; let evicted_key = [EVICTED_KEY_BYTE; XOR_NAME_LEN]; + let fresh_hint_key = [FRESH_HINT_KEY_BYTE; XOR_NAME_LEN]; let close_group = HashSet::from([challenged_peer, other_peer]); let changed_close_group = HashSet::from([challenged_peer, new_peer]); let evicted_close_group = HashSet::from([other_peer, new_peer]); let mut repair_proofs = RepairProofs::new(); + let mature_hinted_at = Instant::now(); + let now = mature_hinted_at + .checked_add(REPAIR_HINT_MIN_AGE) + .unwrap_or(mature_hinted_at); - assert!(repair_proofs.record_replica_hint_sent( + assert!(repair_proofs.record_replica_hint_sent_at( challenged_peer, mature_key, &close_group, HINT_EPOCH, + mature_hinted_at, )); - assert!(repair_proofs.record_replica_hint_sent( + assert!(repair_proofs.record_replica_hint_sent_at( challenged_peer, same_epoch_key, &close_group, CURRENT_EPOCH, + mature_hinted_at, )); - assert!(repair_proofs.record_replica_hint_sent( + assert!(repair_proofs.record_replica_hint_sent_at( challenged_peer, stable_churn_key, &close_group, HINT_EPOCH, + mature_hinted_at, )); - assert!(repair_proofs.record_replica_hint_sent( + assert!(repair_proofs.record_replica_hint_sent_at( challenged_peer, evicted_key, &close_group, HINT_EPOCH, + mature_hinted_at, + )); + assert!(repair_proofs.record_replica_hint_sent_at( + challenged_peer, + fresh_hint_key, + &close_group, + HINT_EPOCH, + now, )); let sampled_key_groups = vec![ @@ -1413,18 +1441,20 @@ mod tests { (missing_proof_key, close_group.clone()), (stable_churn_key, changed_close_group), (evicted_key, evicted_close_group), + (fresh_hint_key, close_group.clone()), ]; let peer_keys = mature_audit_keys_for_peer( &challenged_peer, sampled_key_groups, &mut repair_proofs, CURRENT_EPOCH, + now, ); assert_eq!( peer_keys, vec![mature_key, stable_churn_key], - "mature proofs for stable close-group peers should become audit keys, while same-epoch, missing, and evicted-peer proofs should not" + "mature proofs for stable close-group peers should become audit keys, while same-epoch, fresh, missing, and evicted-peer proofs should not" ); } diff --git a/src/replication/config.rs b/src/replication/config.rs index 1ca8b3db..3337cf23 100644 --- a/src/replication/config.rs +++ b/src/replication/config.rs @@ -58,6 +58,13 @@ const NEIGHBOR_SYNC_COOLDOWN_SECS: u64 = 60 * 60; // 1 hour /// Per-peer minimum spacing between successive syncs with the same peer. pub const NEIGHBOR_SYNC_COOLDOWN: Duration = Duration::from_secs(NEIGHBOR_SYNC_COOLDOWN_SECS); +/// Minimum age for a replica repair hint before the hinted peer can be audited +/// for that key. +const REPAIR_HINT_MIN_AGE_SECS: u64 = 60 * 60; // 1 hour +/// Minimum age for a replica repair hint before the hinted peer can be audited +/// for that key. +pub const REPAIR_HINT_MIN_AGE: Duration = Duration::from_secs(REPAIR_HINT_MIN_AGE_SECS); + /// Minimum self-lookup cadence. const SELF_LOOKUP_INTERVAL_MIN_SECS: u64 = 5 * 60; /// Maximum self-lookup cadence. diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 0e0995c7..050d9db7 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -1675,8 +1675,9 @@ async fn run_neighbor_sync_round( // prune pass and DHT snapshot so other tasks are not starved. let cycle_complete = sync_state.read().await.is_cycle_complete(); if cycle_complete { - // A completed local neighbor-sync cycle matures key-specific repair - // proofs recorded in earlier epochs. + // A completed local neighbor-sync cycle advances the epoch component + // of repair-proof maturity. The per-key wall-clock minimum age is + // checked when audits are selected. { let mut history = sync_history.write().await; for record in history.values_mut() { diff --git a/src/replication/pruning.rs b/src/replication/pruning.rs index 4618ab09..fb3737ea 100644 --- a/src/replication/pruning.rs +++ b/src/replication/pruning.rs @@ -70,7 +70,7 @@ pub struct PrunePassContext<'a> { pub sync_state: &'a Arc>, /// Key-specific repair proofs used to gate prune-confirmation audits. pub repair_proofs: &'a Arc>, - /// Current local neighbor-sync cycle epoch. + /// Current local neighbor-sync cycle epoch for repair-proof maturity. pub current_sync_epoch: u64, /// Whether remote prune-confirmation audits are allowed this pass. pub allow_remote_prune_audits: bool, @@ -353,6 +353,7 @@ async fn evaluate_record_prune_key( ¤t_close_peers, ctx.repair_proofs, ctx.current_sync_epoch, + now, ) .await { @@ -452,10 +453,11 @@ async fn target_peers_have_mature_repair_proofs( current_close_peers: &HashSet, repair_proofs: &Arc>, current_sync_epoch: u64, + now: Instant, ) -> bool { let mut proofs = repair_proofs.write().await; target_peers.iter().all(|peer| { - proofs.has_mature_replica_hint(peer, key, current_close_peers, current_sync_epoch) + proofs.has_mature_replica_hint(peer, key, current_close_peers, current_sync_epoch, now) }) } diff --git a/src/replication/types.rs b/src/replication/types.rs index ec74e76d..0b1838ed 100644 --- a/src/replication/types.rs +++ b/src/replication/types.rs @@ -11,6 +11,7 @@ use std::time::{Duration, Instant}; use serde::{Deserialize, Serialize}; use crate::ant_protocol::XorName; +use crate::replication::config::REPAIR_HINT_MIN_AGE; use saorsa_core::identity::PeerId; // --------------------------------------------------------------------------- @@ -273,6 +274,8 @@ impl PeerSyncRecord { struct RepairProof { /// Local neighbor-sync cycle epoch when the hint was sent. hinted_at_epoch: u64, + /// Monotonic local time when the hint was sent. + hinted_at: Instant, } /// Repair proofs for one key, tied to the close-group snapshot they were @@ -325,6 +328,41 @@ impl RepairProofs { key: XorName, current_close_peers: &HashSet, hinted_at_epoch: u64, + ) -> bool { + self.insert_replica_hint_sent( + peer, + key, + current_close_peers, + hinted_at_epoch, + Instant::now(), + ) + } + + /// Record that `peer` was sent a replica repair hint at a caller-provided + /// time. + /// + /// This is exposed only for deterministic tests and test harnesses. Normal + /// production callers use [`Self::record_replica_hint_sent`] so the proof + /// timestamp is captured internally at send-recording time. + #[cfg(any(test, feature = "test-utils"))] + pub fn record_replica_hint_sent_at( + &mut self, + peer: PeerId, + key: XorName, + current_close_peers: &HashSet, + hinted_at_epoch: u64, + hinted_at: Instant, + ) -> bool { + self.insert_replica_hint_sent(peer, key, current_close_peers, hinted_at_epoch, hinted_at) + } + + fn insert_replica_hint_sent( + &mut self, + peer: PeerId, + key: XorName, + current_close_peers: &HashSet, + hinted_at_epoch: u64, + hinted_at: Instant, ) -> bool { self.reconcile_key_close_group(&key, current_close_peers); @@ -341,9 +379,13 @@ impl RepairProofs { return false; } - entry - .peer_proofs - .insert(peer, RepairProof { hinted_at_epoch }); + entry.peer_proofs.insert( + peer, + RepairProof { + hinted_at_epoch, + hinted_at, + }, + ); true } @@ -351,20 +393,25 @@ impl RepairProofs { /// /// The check invalidates proofs for peers that have left the current /// self-inclusive close group. A proof is mature only after at least one - /// later local sync-cycle epoch. + /// later local sync-cycle epoch and the repair hint is at least + /// [`REPAIR_HINT_MIN_AGE`] old. pub fn has_mature_replica_hint( &mut self, peer: &PeerId, key: &XorName, current_close_peers: &HashSet, current_epoch: u64, + now: Instant, ) -> bool { self.reconcile_key_close_group(key, current_close_peers); self.proofs_by_key .get(key) .and_then(|entry| entry.peer_proofs.get(peer)) - .is_some_and(|proof| proof.hinted_at_epoch < current_epoch) + .is_some_and(|proof| { + proof.hinted_at_epoch < current_epoch + && now.saturating_duration_since(proof.hinted_at) >= REPAIR_HINT_MIN_AGE + }) } /// Remove all repair proofs for a key, e.g. after local deletion. @@ -594,6 +641,14 @@ mod tests { PeerId::from_bytes(bytes) } + fn mature_hint_times() -> (Instant, Instant) { + let hinted_at = Instant::now(); + let now = hinted_at + .checked_add(REPAIR_HINT_MIN_AGE) + .unwrap_or(hinted_at); + (hinted_at, now) + } + // -- FetchCandidate ordering ------------------------------------------- #[test] @@ -748,12 +803,13 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at(peer, key, &close_peers, HINT_EPOCH, hinted_at,)); assert!( - proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), - "sent hint should make key auditable for that peer" + proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), + "old sent hint should make key auditable for that peer" ); } @@ -766,11 +822,18 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(!proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH)); + assert!(!proofs.record_replica_hint_sent_at( + peer, + key, + &close_peers, + HINT_EPOCH, + hinted_at, + )); assert!( - !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), + !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), "peers outside current close group must not get repair proof" ); } @@ -784,16 +847,48 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at(peer, key, &close_peers, HINT_EPOCH, hinted_at,)); assert!( - !proofs.has_mature_replica_hint(&peer, &key, &close_peers, HINT_EPOCH), + !proofs.has_mature_replica_hint(&peer, &key, &close_peers, HINT_EPOCH, now), "same-cycle proof should not be audit-eligible" ); assert!( - proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), - "proof should mature after a later local sync-cycle epoch" + proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), + "old proof should mature after a later local sync-cycle epoch" + ); + } + + #[test] + fn repair_proofs_require_min_hint_age() { + const HINT_EPOCH: u64 = 7; + const CURRENT_EPOCH: u64 = HINT_EPOCH + 1; + + let key = [0xA8; 32]; + let peer = peer_id_from_byte(1); + let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]); + let mut proofs = RepairProofs::new(); + let hinted_at = Instant::now(); + + assert!(proofs.record_replica_hint_sent_at(peer, key, &close_peers, HINT_EPOCH, hinted_at)); + + assert!( + !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, hinted_at), + "fresh repair hints should not be audit-eligible" + ); + assert!( + proofs.has_mature_replica_hint( + &peer, + &key, + &close_peers, + CURRENT_EPOCH, + hinted_at + .checked_add(REPAIR_HINT_MIN_AGE) + .unwrap_or(hinted_at), + ), + "repair hints should mature once they are at least the minimum age" ); } @@ -806,14 +901,15 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at(peer, key, &close_peers, HINT_EPOCH, hinted_at,)); assert!( - !proofs.record_replica_hint_sent(peer, key, &close_peers, REPEATED_HINT_EPOCH), + !proofs.record_replica_hint_sent_at(peer, key, &close_peers, REPEATED_HINT_EPOCH, now), "duplicate hint in the same close group should keep existing proof" ); assert!( - proofs.has_mature_replica_hint(&peer, &key, &close_peers, REPEATED_HINT_EPOCH), + proofs.has_mature_replica_hint(&peer, &key, &close_peers, REPEATED_HINT_EPOCH, now), "duplicate hint must not reset an already mature proof" ); } @@ -831,20 +927,39 @@ mod tests { let old_group = HashSet::from([stable_peer, departing_peer, retained_peer]); let changed_group = HashSet::from([stable_peer, retained_peer, new_peer]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(stable_peer, key, &old_group, HINT_EPOCH)); - assert!(proofs.record_replica_hint_sent(departing_peer, key, &old_group, HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at( + stable_peer, + key, + &old_group, + HINT_EPOCH, + hinted_at, + )); + assert!(proofs.record_replica_hint_sent_at( + departing_peer, + key, + &old_group, + HINT_EPOCH, + hinted_at, + )); assert!( - proofs.has_mature_replica_hint(&stable_peer, &key, &changed_group, CURRENT_EPOCH), + proofs.has_mature_replica_hint(&stable_peer, &key, &changed_group, CURRENT_EPOCH, now), "stable peers should keep mature repair proofs across unrelated close-group churn" ); assert!( - !proofs.has_mature_replica_hint(&departing_peer, &key, &changed_group, CURRENT_EPOCH), + !proofs.has_mature_replica_hint( + &departing_peer, + &key, + &changed_group, + CURRENT_EPOCH, + now, + ), "peers that left the close group should lose repair proofs" ); assert!( - !proofs.has_mature_replica_hint(&new_peer, &key, &changed_group, CURRENT_EPOCH), + !proofs.has_mature_replica_hint(&new_peer, &key, &changed_group, CURRENT_EPOCH, now), "new close-group peers need their own repair hint before auditing" ); } @@ -861,26 +976,40 @@ mod tests { let old_group = HashSet::from([returning_peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let changed_group = HashSet::from([new_peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(returning_peer, key, &old_group, FIRST_HINT_EPOCH,)); + assert!(proofs.record_replica_hint_sent_at( + returning_peer, + key, + &old_group, + FIRST_HINT_EPOCH, + hinted_at, + )); assert!( - !proofs.has_mature_replica_hint(&new_peer, &key, &changed_group, SECOND_HINT_EPOCH), + !proofs.has_mature_replica_hint( + &new_peer, + &key, + &changed_group, + SECOND_HINT_EPOCH, + now + ), "new close-group peer should not inherit another peer's repair proof" ); assert!( - !proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH), + !proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH, now), "a peer that re-enters must receive a fresh repair hint" ); - assert!(proofs.record_replica_hint_sent( + assert!(proofs.record_replica_hint_sent_at( returning_peer, key, &old_group, SECOND_HINT_EPOCH, + hinted_at, )); assert!( - proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH), + proofs.has_mature_replica_hint(&returning_peer, &key, &old_group, CURRENT_EPOCH, now), "fresh repair hint after re-entry should be eligible once mature" ); } @@ -895,18 +1024,31 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer, peer_id_from_byte(2), peer_id_from_byte(3)]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, FIRST_HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at( + peer, + key, + &close_peers, + FIRST_HINT_EPOCH, + hinted_at, + )); proofs.remove_peer(&peer); assert!( - !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), + !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), "routing-table removal should clear proof even if peer re-enters same close group" ); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, SECOND_HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at( + peer, + key, + &close_peers, + SECOND_HINT_EPOCH, + hinted_at, + )); assert!( - proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), + proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), "fresh hint after re-entry should become eligible after a later epoch" ); } @@ -920,12 +1062,13 @@ mod tests { let peer = peer_id_from_byte(1); let close_peers = HashSet::from([peer]); let mut proofs = RepairProofs::new(); + let (hinted_at, now) = mature_hint_times(); - assert!(proofs.record_replica_hint_sent(peer, key, &close_peers, HINT_EPOCH)); + assert!(proofs.record_replica_hint_sent_at(peer, key, &close_peers, HINT_EPOCH, hinted_at,)); proofs.remove_key(&key); assert!( - !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH), + !proofs.has_mature_replica_hint(&peer, &key, &close_peers, CURRENT_EPOCH, now), "deleted local key should not retain repair proof entries" ); } diff --git a/tests/e2e/replication.rs b/tests/e2e/replication.rs index 83fc792f..27ff5595 100644 --- a/tests/e2e/replication.rs +++ b/tests/e2e/replication.rs @@ -7,7 +7,7 @@ use super::TestHarness; use ant_node::client::compute_address; -use ant_node::replication::config::REPLICATION_PROTOCOL_ID; +use ant_node::replication::config::{REPAIR_HINT_MIN_AGE, REPLICATION_PROTOCOL_ID}; use ant_node::replication::protocol::{ compute_audit_digest, AuditChallenge, AuditResponse, FetchRequest, FetchResponse, FreshReplicationOffer, FreshReplicationResponse, NeighborSyncRequest, ReplicationMessage, @@ -22,7 +22,7 @@ use saorsa_core::{P2PNode, TrustEvent}; use serial_test::serial; use std::collections::HashSet; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use tokio::sync::RwLock; /// Maximum time to wait for replication propagation in tests. @@ -148,9 +148,18 @@ async fn record_repair_proofs_for_peers( .map(|node| node.peer_id) .collect(); let mut proofs = repair_proofs.write().await; + let hinted_at = Instant::now() + .checked_sub(REPAIR_HINT_MIN_AGE) + .unwrap_or_else(Instant::now); for peer in peers { assert!( - proofs.record_replica_hint_sent(*peer, *key, &close_peers, hinted_at_epoch), + proofs.record_replica_hint_sent_at( + *peer, + *key, + &close_peers, + hinted_at_epoch, + hinted_at + ), "test target should be in close group for repair-proof recording" ); } From bdbfdfc2ae88ccb6607012032363e6814ae73e1b Mon Sep 17 00:00:00 2001 From: Warm Beer Date: Thu, 11 Jun 2026 17:12:30 +0200 Subject: [PATCH 2/2] fix(replication): make repair proof pruning test deterministic --- src/replication/mod.rs | 2 ++ src/replication/pruning.rs | 11 ++++++++++- tests/e2e/replication.rs | 18 ++++++++++++------ 3 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/replication/mod.rs b/src/replication/mod.rs index 050d9db7..0a20c7de 100644 --- a/src/replication/mod.rs +++ b/src/replication/mod.rs @@ -1703,6 +1703,8 @@ async fn run_neighbor_sync_round( sync_state, repair_proofs, current_sync_epoch, + #[cfg(any(test, feature = "test-utils"))] + repair_proof_now: None, allow_remote_prune_audits, }) .await; diff --git a/src/replication/pruning.rs b/src/replication/pruning.rs index fb3737ea..536ca259 100644 --- a/src/replication/pruning.rs +++ b/src/replication/pruning.rs @@ -72,6 +72,9 @@ pub struct PrunePassContext<'a> { pub repair_proofs: &'a Arc>, /// Current local neighbor-sync cycle epoch for repair-proof maturity. pub current_sync_epoch: u64, + /// Test-only clock override for repair-proof maturity checks. + #[cfg(any(test, feature = "test-utils"))] + pub repair_proof_now: Option, /// Whether remote prune-confirmation audits are allowed this pass. pub allow_remote_prune_audits: bool, } @@ -173,6 +176,8 @@ pub async fn run_prune_pass( sync_state, repair_proofs: &repair_proofs, current_sync_epoch: 0, + #[cfg(any(test, feature = "test-utils"))] + repair_proof_now: None, allow_remote_prune_audits, }) .await @@ -347,13 +352,17 @@ async fn evaluate_record_prune_key( } let current_close_peers: HashSet = closest.iter().map(|node| node.peer_id).collect(); + #[cfg(any(test, feature = "test-utils"))] + let repair_proof_now = ctx.repair_proof_now.unwrap_or(now); + #[cfg(not(any(test, feature = "test-utils")))] + let repair_proof_now = now; if !target_peers_have_mature_repair_proofs( key, &target_peers, ¤t_close_peers, ctx.repair_proofs, ctx.current_sync_epoch, - now, + repair_proof_now, ) .await { diff --git a/tests/e2e/replication.rs b/tests/e2e/replication.rs index 27ff5595..6302d3f0 100644 --- a/tests/e2e/replication.rs +++ b/tests/e2e/replication.rs @@ -139,7 +139,7 @@ async fn record_repair_proofs_for_peers( peers: &[PeerId], key: &[u8; 32], hinted_at_epoch: u64, -) { +) -> Instant { let close_peers: HashSet = p2p_node .dht_manager() .find_closest_nodes_local_with_self(key, config.close_group_size) @@ -148,9 +148,10 @@ async fn record_repair_proofs_for_peers( .map(|node| node.peer_id) .collect(); let mut proofs = repair_proofs.write().await; - let hinted_at = Instant::now() - .checked_sub(REPAIR_HINT_MIN_AGE) - .unwrap_or_else(Instant::now); + let hinted_at = Instant::now(); + let repair_proof_now = hinted_at + .checked_add(REPAIR_HINT_MIN_AGE) + .unwrap_or(hinted_at); for peer in peers { assert!( proofs.record_replica_hint_sent_at( @@ -164,6 +165,7 @@ async fn record_repair_proofs_for_peers( ); } drop(proofs); + repair_proof_now } /// Fresh write happy path (Section 18 #1). @@ -517,7 +519,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { .await .expect("put gate record on pruner"); store_record_on_peers(&harness, &gate_targets, &gate_address, &gate_content).await; - record_repair_proofs_for_peers( + let gate_repair_proof_now = record_repair_proofs_for_peers( &repair_proofs, &pruner_p2p, &config, @@ -536,6 +538,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { sync_state: &sync_state, repair_proofs: &repair_proofs, current_sync_epoch: CURRENT_EPOCH, + repair_proof_now: Some(gate_repair_proof_now), allow_remote_prune_audits: false, }) .await; @@ -554,6 +557,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { sync_state: &sync_state, repair_proofs: &repair_proofs, current_sync_epoch: CURRENT_EPOCH, + repair_proof_now: Some(gate_repair_proof_now), allow_remote_prune_audits: true, }) .await; @@ -578,7 +582,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { &missing_content, ) .await; - record_repair_proofs_for_peers( + let missing_repair_proof_now = record_repair_proofs_for_peers( &repair_proofs, &pruner_p2p, &config, @@ -597,6 +601,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { sync_state: &sync_state, repair_proofs: &repair_proofs, current_sync_epoch: CURRENT_EPOCH, + repair_proof_now: Some(missing_repair_proof_now), allow_remote_prune_audits: true, }) .await; @@ -623,6 +628,7 @@ async fn test_prune_pass_requires_remote_confirmation_before_delete() { sync_state: &sync_state, repair_proofs: &repair_proofs, current_sync_epoch: CURRENT_EPOCH, + repair_proof_now: Some(missing_repair_proof_now), allow_remote_prune_audits: true, }) .await;