@@ -166,6 +166,19 @@ fn did_to_kad_key(did: &str) -> kad::RecordKey {
166166 kad:: RecordKey :: new ( & format ! ( "/gitlawb/did/{did}" ) . as_bytes ( ) )
167167}
168168
169+ /// Returns true when `address` was explicitly configured for `peer` through
170+ /// AddKnownPeer. Explicit addresses are retained in Kademlia even when an
171+ /// overlapping Identify lease expires or is evicted.
172+ fn is_explicit_address (
173+ explicit_addresses : & HashMap < PeerId , HashSet < Multiaddr > > ,
174+ peer : & PeerId ,
175+ address : & Multiaddr ,
176+ ) -> bool {
177+ explicit_addresses
178+ . get ( peer)
179+ . is_some_and ( |addresses| addresses. contains ( address) )
180+ }
181+
169182#[ derive( Debug ) ]
170183struct IdentifyAddress {
171184 address : Multiaddr ,
@@ -189,7 +202,6 @@ struct IdentifyAddressChanges {
189202#[ derive( Debug , Default ) ]
190203struct IdentifyAddressBook {
191204 peers : HashMap < PeerId , IdentifyPeerAddresses > ,
192- insertion_order : VecDeque < ( PeerId , Multiaddr ) > ,
193205 address_count : usize ,
194206}
195207
@@ -212,89 +224,64 @@ impl IdentifyAddressBook {
212224 if reported. is_empty ( ) && !self . peers . contains_key ( & peer_id) {
213225 return changes;
214226 }
215- self . peers
227+ let state = self
228+ . peers
216229 . entry ( peer_id)
217230 . or_insert_with ( || IdentifyPeerAddresses {
218231 addresses : VecDeque :: new ( ) ,
219232 window_started : now,
220233 new_addresses : 0 ,
221234 } ) ;
222235
223- if let Some ( state) = self . peers . get_mut ( & peer_id) {
224- if now. saturating_duration_since ( state. window_started ) >= IDENTIFY_ADDRESS_WINDOW {
225- state. window_started = now;
226- state. new_addresses = 0 ;
227- }
236+ if now. saturating_duration_since ( state. window_started ) >= IDENTIFY_ADDRESS_WINDOW {
237+ state. window_started = now;
238+ state. new_addresses = 0 ;
228239 }
229240
230241 // Refresh every retained address in the bounded report before admission.
231- let mut refreshed = Vec :: new ( ) ;
232- if let Some ( state) = self . peers . get_mut ( & peer_id) {
233- for existing in & mut state. addresses {
234- if reported. contains ( & existing. address ) {
235- existing. expires_at = now + IDENTIFY_ADDRESS_TTL ;
236- refreshed. push ( existing. address . clone ( ) ) ;
237- }
238- }
239- }
240- // Record refreshed addresses so the Identify handler can re-admit
242+ // Refreshed addresses are reported so the Identify handler can re-admit
241243 // them to Kademlia. Kademlia may have dropped a retained address
242244 // after a failed dial or never kept it for a full k-bucket, and a
243245 // TTL refresh alone would never re-add it.
244- for address in refreshed {
245- self . remove_insertion_token ( peer_id, & address) ;
246- self . insertion_order . push_back ( ( peer_id, address. clone ( ) ) ) ;
247- changes. refreshed . push ( address) ;
246+ for existing in & mut state. addresses {
247+ if reported. contains ( & existing. address ) {
248+ existing. expires_at = now + IDENTIFY_ADDRESS_TTL ;
249+ changes. refreshed . push ( existing. address . clone ( ) ) ;
250+ }
248251 }
249252
250- let mut new_addresses = self
251- . peers
252- . get ( & peer_id )
253- . map ( |state| {
254- reported
253+ let mut new_addresses: Vec < Multiaddr > = reported
254+ . iter ( )
255+ . filter ( |address| {
256+ !state
257+ . addresses
255258 . iter ( )
256- . filter ( |address| {
257- !state
258- . addresses
259- . iter ( )
260- . any ( |existing| & existing. address == * address)
261- } )
262- . cloned ( )
263- . collect :: < Vec < _ > > ( )
259+ . any ( |existing| & existing. address == * address)
264260 } )
265- . unwrap_or_else ( || reported. iter ( ) . cloned ( ) . collect ( ) ) ;
261+ . cloned ( )
262+ . collect ( ) ;
266263 // HashSet iteration is deliberately unordered. Sort new candidates so
267264 // an oversized Identify report has deterministic admission behavior.
268265 new_addresses. sort ( ) ;
269266
270267 for address in new_addresses {
271- if self
272- . peers
273- . get ( & peer_id)
274- . is_some_and ( |state| state. new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT )
275- {
268+ if state. new_addresses >= IDENTIFY_NEW_ADDRESS_LIMIT {
276269 break ;
277270 }
278271
279- let evicted = self . peers . get_mut ( & peer_id) . and_then ( |state| {
280- if state. addresses . len ( ) < IDENTIFY_ADDRESS_LIMIT {
281- return None ;
282- }
283- let index = state
272+ let evicted = if state. addresses . len ( ) < IDENTIFY_ADDRESS_LIMIT {
273+ None
274+ } else {
275+ state
284276 . addresses
285277 . iter ( )
286- . position ( |existing| !reported. contains ( & existing. address ) ) ? ;
287- state. addresses . remove ( index)
288- } ) ;
278+ . position ( |existing| !reported. contains ( & existing. address ) )
279+ . and_then ( |index| state. addresses . remove ( index) )
280+ } ;
289281 if let Some ( evicted) = evicted {
290- self . remove_insertion_token ( peer_id, & evicted. address ) ;
291282 self . address_count -= 1 ;
292283 changes. removed . push ( ( peer_id, evicted. address ) ) ;
293- } else if self
294- . peers
295- . get ( & peer_id)
296- . is_some_and ( |state| state. addresses . len ( ) >= IDENTIFY_ADDRESS_LIMIT )
297- {
284+ } else if state. addresses . len ( ) >= IDENTIFY_ADDRESS_LIMIT {
298285 // Every retained address was refreshed in this report. Keep
299286 // that stable subset and drop surplus new candidates.
300287 break ;
@@ -310,19 +297,20 @@ impl IdentifyAddressBook {
310297 break ;
311298 }
312299
313- if let Some ( state) = self . peers . get_mut ( & peer_id) {
314- state. new_addresses += 1 ;
315- }
300+ state. new_addresses += 1 ;
301+ state. addresses . push_back ( IdentifyAddress {
302+ address : address. clone ( ) ,
303+ expires_at : now + IDENTIFY_ADDRESS_TTL ,
304+ } ) ;
305+ self . address_count += 1 ;
306+ changes. added . push ( address) ;
307+ }
316308
317- if let Some ( state) = self . peers . get_mut ( & peer_id) {
318- state. addresses . push_back ( IdentifyAddress {
319- address : address. clone ( ) ,
320- expires_at : now + IDENTIFY_ADDRESS_TTL ,
321- } ) ;
322- self . insertion_order . push_back ( ( peer_id, address. clone ( ) ) ) ;
323- self . address_count += 1 ;
324- changes. added . push ( address) ;
325- }
309+ // Do not keep a row when every candidate was rejected: a zero-address
310+ // row is invisible to address_count and would otherwise linger until
311+ // the expiry tick. This matches expire_peer, which drops empty rows.
312+ if state. addresses . is_empty ( ) {
313+ self . peers . remove ( & peer_id) ;
326314 }
327315
328316 changes
@@ -343,10 +331,7 @@ impl IdentifyAddressBook {
343331 . get_mut ( & peer_id)
344332 . map ( |state| Self :: expire_state ( state, now) )
345333 . unwrap_or_default ( ) ;
346- for address in & expired {
347- self . remove_insertion_token ( peer_id, address) ;
348- self . address_count -= 1 ;
349- }
334+ self . address_count -= expired. len ( ) ;
350335 if self
351336 . peers
352337 . get ( & peer_id)
@@ -373,18 +358,6 @@ impl IdentifyAddressBook {
373358 state. addresses = retained;
374359 expired
375360 }
376-
377- fn remove_insertion_token ( & mut self , peer_id : PeerId , address : & Multiaddr ) {
378- if let Some ( index) = self
379- . insertion_order
380- . iter ( )
381- . position ( |( token_peer, token_address) | {
382- token_peer == & peer_id && token_address == address
383- } )
384- {
385- self . insertion_order . remove ( index) ;
386- }
387- }
388361}
389362
390363/// Combined libp2p behaviour.
@@ -523,10 +496,7 @@ pub async fn start(
523496 tokio:: select! {
524497 _ = identify_cleanup. tick( ) => {
525498 for ( peer_id, address) in identify_addresses. expire( Instant :: now( ) ) {
526- if !explicit_addresses
527- . get( & peer_id)
528- . is_some_and( |addresses| addresses. contains( & address) )
529- {
499+ if !is_explicit_address( & explicit_addresses, & peer_id, & address) {
530500 swarm. behaviour_mut( ) . kademlia. remove_address( & peer_id, & address) ;
531501 }
532502 }
@@ -622,9 +592,7 @@ pub async fn start(
622592 & info. listen_addrs,
623593 ) ;
624594 for ( removed_peer, addr) in changes. removed {
625- if !explicit_addresses
626- . get( & removed_peer)
627- . is_some_and( |addresses| addresses. contains( & addr) )
595+ if !is_explicit_address( & explicit_addresses, & removed_peer, & addr)
628596 {
629597 swarm
630598 . behaviour_mut( )
@@ -748,7 +716,6 @@ mod tests {
748716 ) ;
749717 }
750718 assert ! ( book. peers. is_empty( ) ) ;
751- assert ! ( book. insertion_order. is_empty( ) ) ;
752719 assert_eq ! ( book. address_count, 0 ) ;
753720 }
754721
@@ -815,7 +782,6 @@ mod tests {
815782 assert_eq ! ( changes. refreshed, vec![ ( * refreshed) . clone( ) ] ) ;
816783 assert_eq ! ( book. peers. len( ) , IDENTIFY_GLOBAL_ADDRESS_LIMIT ) ;
817784 assert_eq ! ( book. address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT ) ;
818- assert_eq ! ( book. insertion_order. len( ) , book. address_count) ;
819785 assert_eq ! (
820786 book. peers[ peer]
821787 . addresses
@@ -826,6 +792,62 @@ mod tests {
826792 ) ;
827793 }
828794
795+ #[ test]
796+ fn identify_per_peer_eviction_applies_at_full_global_budget ( ) {
797+ let now = Instant :: now ( ) ;
798+ let mut book = IdentifyAddressBook :: default ( ) ;
799+ // The victim peer holds a full per-peer row while the rest of the
800+ // global budget is filled by one-address peers.
801+ let victim = PeerId :: random ( ) ;
802+ let victim_addresses: Vec < Multiaddr > = ( 0 ..IDENTIFY_ADDRESS_LIMIT )
803+ . map ( |index| identify_address ( victim, index as u8 + 1 , 10_000 + index as u16 ) )
804+ . collect ( ) ;
805+ book. update ( victim, now, & victim_addresses) ;
806+ for index in 0 ..IDENTIFY_GLOBAL_ADDRESS_LIMIT - IDENTIFY_ADDRESS_LIMIT {
807+ let peer = PeerId :: random ( ) ;
808+ let address = identify_address ( peer, 200 , 20_000 + index as u16 ) ;
809+ book. update ( peer, now, & [ address] ) ;
810+ }
811+ assert_eq ! ( book. address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT ) ;
812+
813+ // The victim reports a ninth address after the rate window rolls over.
814+ // Per-peer eviction must still apply at global saturation: the oldest
815+ // non-reported address is evicted to make room, and the global count
816+ // is unchanged. This ordering only holds because eviction runs before
817+ // the global-budget check; hoisting that check above the eviction
818+ // block makes this test fail.
819+ let ninth = identify_address ( victim, 100 , 30_000 ) ;
820+ let changes = book. update (
821+ victim,
822+ now + IDENTIFY_ADDRESS_WINDOW ,
823+ std:: slice:: from_ref ( & ninth) ,
824+ ) ;
825+ assert_eq ! ( changes. added, vec![ ninth] ) ;
826+ assert_eq ! ( changes. removed, vec![ ( victim, victim_addresses[ 0 ] . clone( ) ) ] ) ;
827+ assert_eq ! ( book. address_count, IDENTIFY_GLOBAL_ADDRESS_LIMIT ) ;
828+ assert_eq ! ( book. peers[ & victim] . addresses. len( ) , IDENTIFY_ADDRESS_LIMIT ) ;
829+ }
830+
831+ #[ test]
832+ fn explicit_address_helper_detects_retained_addresses ( ) {
833+ let peer = PeerId :: random ( ) ;
834+ let other = PeerId :: random ( ) ;
835+ let address = identify_address ( peer, 1 , 10_000 ) ;
836+ let mut explicit: HashMap < PeerId , HashSet < Multiaddr > > = HashMap :: new ( ) ;
837+ // Unknown peer: nothing is explicit.
838+ assert ! ( !is_explicit_address( & explicit, & peer, & address) ) ;
839+ explicit. entry ( peer) . or_default ( ) . insert ( address. clone ( ) ) ;
840+ assert ! ( is_explicit_address( & explicit, & peer, & address) ) ;
841+ // The same address under a different peer is not retained.
842+ assert ! ( !is_explicit_address( & explicit, & other, & address) ) ;
843+ // A different address for the same peer is not retained.
844+ assert ! ( !is_explicit_address(
845+ & explicit,
846+ & peer,
847+ & identify_address( peer, 2 , 10_001 )
848+ ) ) ;
849+ }
850+
829851 #[ test]
830852 fn identify_addresses_are_capped_with_fifo_eviction ( ) {
831853 let peer_id = PeerId :: random ( ) ;
0 commit comments