From 5d7311597a52180490cded5bf73ff55f923b29d2 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 18:44:00 +0200 Subject: [PATCH 01/26] fix(p2p): judge ipv4-mapped addresses by their embedded ipv4 form --- p2p/src/peer_manager/address_groups.rs | 70 +++++++++++--- p2p/src/peer_manager/tests/addresses.rs | 87 ++++++++++++++++++ p2p/types/src/global_ip.rs | 31 +++++++ p2p/types/src/peer_address.rs | 116 +++++++++++++++++++----- p2p/types/src/socket_address.rs | 69 +++++++++++++- 5 files changed, 337 insertions(+), 36 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 404bd9d8d2..fffc854e96 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -13,8 +13,6 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::net::{Ipv4Addr, Ipv6Addr}; - use crate::types::peer_address::PeerAddress; // IPv4 addresses grouped into /16 subnets @@ -39,17 +37,18 @@ impl AddressGroup { /// /// See `NetGroupManager::GetGroup` in Bitcoin Core for a reference. pub fn from_peer_address(address: &PeerAddress) -> AddressGroup { + // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that they + // land in the same group as the IPv4 address they represent. + let std_ip = crate::types::socket_address::canonical_ip_addr( + std::net::SocketAddr::from(address).ip(), + ); if address.is_global_unicast_ip() { - match address { - PeerAddress::Ip4(addr) => AddressGroup::PublicV4( - Ipv4Addr::from(addr.ip).octets()[0..IPV4_GROUP_BYTES] - .try_into() - .expect("must be valid"), + match std_ip { + std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( + ip.octets()[0..IPV4_GROUP_BYTES].try_into().expect("must be valid"), ), - PeerAddress::Ip6(addr) => AddressGroup::PublicV6( - Ipv6Addr::from(addr.ip).octets()[0..IPV6_GROUP_BYTES] - .try_into() - .expect("must be valid"), + std::net::IpAddr::V6(ip) => AddressGroup::PublicV6( + ip.octets()[0..IPV6_GROUP_BYTES].try_into().expect("must be valid"), ), } } else if address.is_loopback() { @@ -91,3 +90,52 @@ mod tests { ); } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + + fn mapped_v6_address(ip: std::net::Ipv4Addr) -> PeerAddress { + let o = ip.octets(); + PeerAddress::Ip6(crate::types::peer_address::PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port: 12345, + }) + } + + #[test] + fn mapped_addresses_are_grouped_like_the_embedded_ipv4() { + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::LOCALHOST)), + AddressGroup::Local + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( + 192, 168, 1, 1 + ))), + AddressGroup::Private + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( + 10, 0, 0, 1 + ))), + AddressGroup::Private + ); + assert_eq!( + AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( + 1, 2, 3, 4 + ))), + AddressGroup::PublicV4([1, 2]) + ); + } +} diff --git a/p2p/src/peer_manager/tests/addresses.rs b/p2p/src/peer_manager/tests/addresses.rs index fa737d987a..6e7a7e1480 100644 --- a/p2p/src/peer_manager/tests/addresses.rs +++ b/p2p/src/peer_manager/tests/addresses.rs @@ -852,3 +852,90 @@ async fn accept_outbound_connection( peer_id } + +// An inbound peer announcing an IPv4-mapped IPv6 form of a non-routable address must be +// rejected exactly like the plain IPv4 form, and a mapped routable address must be stored +// in its IPv4 form. +#[tracing::instrument(skip(seed))] +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +#[tokio::test] +async fn announce_mapped_ipv4_address_follows_ipv4_rules(#[case] seed: Seed) { + type TestNetworkingService = DefaultNetworkingService; + + let mut rng = make_seedable_rng(seed); + + let bind_address = TestTransportChannel::make_address(); + let config = Arc::new(config::create_unit_test_config()); + let p2p_config = Arc::new(test_p2p_config()); + let time_getter = BasicTestTimeGetter::new(); + let (mut pm, _peer_mgr_event_sender, _shutdown_sender, _subscribers_sender) = + make_peer_manager_custom::( + TestTransportChannel::make_transport(), + bind_address.into(), + Arc::clone(&config), + p2p_config, + time_getter.get_time_getter(), + make_seedable_rng(rng.random()), + ) + .await; + + let peer_address = TestAddressMaker::new_random_address(&mut rng); + let peer_id = PeerId::new(); + let peer_info = PeerInfo { + peer_id, + protocol_version: TEST_PROTOCOL_VERSION, + network: *config.magic_bytes(), + software_version: *config.software_version(), + user_agent: mintlayer_core_user_agent(), + common_services: NodeType::Full.into(), + }; + pm.accept_connection( + peer_address.into(), + bind_address.into(), + ConnectionDirection::Inbound, + peer_info, + None, + ); + assert_eq!(pm.peers.len(), 1); + + let mapped = |ip: std::net::Ipv4Addr, port: u16| { + let o = ip.octets(); + PeerAddress::Ip6(p2p_types::peer_address::PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port, + }) + }; + + // Non-routable embedded IPv4 addresses are rejected like their plain form; nothing + // must enter the known address table. + for ip in [ + std::net::Ipv4Addr::LOCALHOST, + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(192, 168, 1, 1), + std::net::Ipv4Addr::new(169, 254, 1, 1), + ] { + pm.handle_announce_addr_request(peer_id, mapped(ip, 12345)); + } + assert_eq!(pm.peerdb.known_addresses_count(), 0); + + // A mapped routable address is accepted and stored in its IPv4 form. + pm.handle_announce_addr_request(peer_id, mapped(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345)); + assert_eq!(pm.peerdb.known_addresses_count(), 1); + let known = pm.peerdb.known_addresses().next().copied().unwrap(); + assert_eq!( + known.socket_addr(), + "8.8.8.8:12345".parse::().unwrap() + ); +} diff --git a/p2p/types/src/global_ip.rs b/p2p/types/src/global_ip.rs index 74edfa5de5..273550ce95 100644 --- a/p2p/types/src/global_ip.rs +++ b/p2p/types/src/global_ip.rs @@ -83,6 +83,13 @@ impl IsGlobalIp for std::net::Ipv6Addr { (addr.segments()[0] == 0x2001) && (addr.segments()[1] == 0xdb8) } + // An IPv4-mapped address is semantically an IPv4 address; judge it by the embedded + // IPv4 rules. Without this, `::ffff:127.0.0.1` or `::ffff:10.0.0.1` pass the IPv6 + // checks despite representing non-routable IPv4 addresses. + if let Some(ipv4) = self.to_ipv4_mapped() { + return ipv4.is_global_unicast_ip(); + } + // Copied from the unstable method `std::net::Ipv6Addr::is_unicast_global`. !self.is_multicast() && !self.is_loopback() @@ -152,4 +159,28 @@ mod tests { ); } } + + #[test] + fn ipv4_mapped_ipv6_addresses_are_judged_by_the_embedded_ipv4() { + let non_global_mapped = [ + "::ffff:127.0.0.1", // Loopback + "::ffff:10.0.0.1", // Private + "::ffff:192.168.1.1", // Private + "::ffff:172.16.0.1", // Private + "::ffff:169.254.1.1", // Link-local + "::ffff:0.0.0.0", // Unspecified + "::ffff:224.0.0.1", // Multicast + ]; + for ip in non_global_mapped { + assert!( + !is_global_unicast(ip), + "{ip} is expected to be not global unicast IP address" + ); + } + + assert!( + is_global_unicast("::ffff:8.8.8.8"), + "mapped global IPv4 is expected to be global unicast IP address" + ); + } } diff --git a/p2p/types/src/peer_address.rs b/p2p/types/src/peer_address.rs index 72b777bf83..160463a359 100644 --- a/p2p/types/src/peer_address.rs +++ b/p2p/types/src/peer_address.rs @@ -13,10 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use std::{ - fmt::Display, - net::{Ipv4Addr, Ipv6Addr}, -}; +use std::fmt::Display; use serialization::{Decode, Encode}; @@ -52,11 +49,13 @@ pub enum PeerAddress { impl PeerAddress { pub fn is_loopback(&self) -> bool { - std::net::SocketAddr::from(self).ip().is_loopback() + crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) + .is_loopback() } pub fn is_global_unicast_ip(&self) -> bool { - std::net::SocketAddr::from(self).ip().is_global_unicast_ip() + crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) + .is_global_unicast_ip() } /// If the address is eligible for being sent to peers via AddrListResponse, return Some, @@ -64,26 +63,21 @@ impl PeerAddress { /// /// The address is eligible if it has a public routable IP and any valid (non-zero) port. /// Private and local IPs are allowed if `allow_discover_private_ips` is true. + /// + /// IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form, so the eligibility + /// check sees the IPv4 address they represent and the returned address is the IPv4 form. pub fn as_discoverable_socket_address( &self, allow_discover_private_ips: bool, ) -> Option { - match self { - PeerAddress::Ip4(socket) - if (Ipv4Addr::from(socket.ip).is_global_unicast_ip() - || allow_discover_private_ips) - && socket.port != 0 => - { - Some(SocketAddress::new(self.into())) - } - PeerAddress::Ip6(socket) - if (Ipv6Addr::from(socket.ip).is_global_unicast_ip() - || allow_discover_private_ips) - && socket.port != 0 => - { - Some(SocketAddress::new(self.into())) - } - _ => None, + let std_address = + crate::socket_address::canonical_socket_address(std::net::SocketAddr::from(self)); + if (std_address.ip().is_global_unicast_ip() || allow_discover_private_ips) + && std_address.port() != 0 + { + Some(SocketAddress::new(std_address)) + } else { + None } } } @@ -126,3 +120,81 @@ impl From<&PeerAddress> for std::net::SocketAddr { } } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + + fn mapped_v6(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + let o = ip.octets(); + PeerAddress::Ip6(PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port, + }) + } + + fn plain_v4(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + PeerAddress::Ip4(PeerAddressIp4 { + ip: ip.into(), + port, + }) + } + + #[test] + fn mapped_non_routable_addresses_are_not_discoverable() { + let non_routable = [ + std::net::Ipv4Addr::LOCALHOST, + std::net::Ipv4Addr::new(10, 0, 0, 1), + std::net::Ipv4Addr::new(192, 168, 1, 1), + std::net::Ipv4Addr::new(172, 16, 0, 1), + std::net::Ipv4Addr::new(169, 254, 1, 1), + std::net::Ipv4Addr::UNSPECIFIED, + ]; + for ip in non_routable { + let address = mapped_v6(ip, 12345); + assert!( + address.as_discoverable_socket_address(false).is_none(), + "mapped {ip} must not pass the discoverable gate" + ); + // The plain form is rejected by the same gate, both forms must agree. + assert!(plain_v4(ip, 12345).as_discoverable_socket_address(false).is_none()); + } + } + + #[test] + fn mapped_routable_address_is_discoverable_in_the_ipv4_form() { + let address = mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345); + let socket_address = address + .as_discoverable_socket_address(false) + .expect("mapped global address must pass the discoverable gate"); + assert_eq!( + socket_address.socket_addr(), + std::net::SocketAddr::V4(std::net::SocketAddrV4::new( + std::net::Ipv4Addr::new(8, 8, 8, 8), + 12345 + )) + ); + } + + #[test] + fn zero_port_is_rejected_in_the_mapped_form() { + let address = mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 0); + assert!(address.as_discoverable_socket_address(false).is_none()); + } + + #[test] + fn mapped_loopback_is_detected_by_is_loopback() { + assert!(mapped_v6(std::net::Ipv4Addr::LOCALHOST, 12345).is_loopback()); + assert!(!mapped_v6(std::net::Ipv4Addr::new(8, 8, 8, 8), 12345).is_loopback()); + } +} diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index db9a07fc17..507aa3c183 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -15,7 +15,7 @@ use std::{ fmt::Display, - net::{AddrParseError, IpAddr, SocketAddr}, + net::{AddrParseError, IpAddr, SocketAddr, SocketAddrV4}, str::FromStr, }; @@ -47,12 +47,15 @@ impl SocketAddress { self.0 } + /// The IP address with IPv4-mapped IPv6 addresses reduced to the embedded IPv4 form, + /// so that classification, bans and connection limits treat `::ffff:a.b.c.d` as the + /// IPv4 address it represents. pub fn ip_addr(&self) -> IpAddr { - self.0.ip() + canonical_ip_addr(self.0.ip()) } pub fn as_bannable(&self) -> BannableAddress { - BannableAddress::new(self.0.ip()) + BannableAddress::new(self.ip_addr()) } pub fn as_peer_address(&self) -> PeerAddress { @@ -60,6 +63,29 @@ impl SocketAddress { } } +/// Reduce an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) to the embedded IPv4 address; +/// any other address is returned unchanged. +pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { + match ip { + IpAddr::V6(v6) => match v6.to_ipv4_mapped() { + Some(v4) => IpAddr::V4(v4), + None => IpAddr::V6(v6), + }, + v4 => v4, + } +} + +/// Same reduction as [`canonical_ip_addr`], applied to a socket address including its port. +pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => SocketAddr::V4(SocketAddrV4::new(v4, v6.port())), + None => SocketAddr::V6(v6), + }, + v4 => v4, + } +} + impl Display for SocketAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { std::fmt::Display::fmt(&self.0, f) @@ -79,3 +105,40 @@ impl From for SocketAddress { SocketAddress::new(value) } } + +#[cfg(test)] +mod mapped_address_tests { + use super::*; + use crate::bannable_address::BannableAddress; + + #[test] + fn mapped_addresses_are_canonicalized() { + let mapped: SocketAddr = "[::ffff:1.2.3.4]:12345".parse().unwrap(); + let socket_address = SocketAddress::new(mapped); + + assert_eq!( + socket_address.ip_addr(), + IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4)) + ); + assert_eq!( + socket_address.as_bannable(), + BannableAddress::new(IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4))) + ); + + // Non-mapped addresses are unchanged. + let plain_v6: SocketAddr = "[2a00:1450:4017:815::200e]:12345".parse().unwrap(); + assert_eq!( + SocketAddress::new(plain_v6).ip_addr(), + IpAddr::V6("2a00:1450:4017:815::200e".parse().unwrap()) + ); + } + + #[test] + fn canonical_socket_address_reduces_mapped_form() { + let mapped: SocketAddr = "[::ffff:127.0.0.1]:12345".parse().unwrap(); + assert_eq!( + canonical_socket_address(mapped), + SocketAddr::V4(SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 12345)) + ); + } +} From e645c6487054282869f70ccb3b1791f8a36509ee Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 19:03:09 +0200 Subject: [PATCH 02/26] fix(p2p): key bans on the canonical form of mapped addresses --- Cargo.lock | 1 + p2p/src/peer_manager/address_groups.rs | 55 ++++++++++-------------- p2p/src/peer_manager/mod.rs | 6 ++- p2p/src/peer_manager/tests/addresses.rs | 18 +------- p2p/src/peer_manager/tests/utils.rs | 22 +++++++++- p2p/types/Cargo.toml | 1 + p2p/types/src/bannable_address.rs | 56 ++++++++++++++++++------- p2p/types/src/socket_address.rs | 17 ++++---- 8 files changed, 98 insertions(+), 78 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 2f2aa860e9..f9c23501c0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6421,6 +6421,7 @@ dependencies = [ "parity-scale-codec", "rpc-description", "serde", + "serde_json", "serialization", ] diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index fffc854e96..c91f68fad6 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -13,7 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::types::peer_address::PeerAddress; +use crate::types::{global_ip::IsGlobalIp, peer_address::PeerAddress}; // IPv4 addresses grouped into /16 subnets pub const IPV4_GROUP_BYTES: usize = 2; @@ -38,11 +38,12 @@ impl AddressGroup { /// See `NetGroupManager::GetGroup` in Bitcoin Core for a reference. pub fn from_peer_address(address: &PeerAddress) -> AddressGroup { // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that they - // land in the same group as the IPv4 address they represent. + // land in the same group as the IPv4 address they represent. All checks run on the + // canonical form. let std_ip = crate::types::socket_address::canonical_ip_addr( std::net::SocketAddr::from(address).ip(), ); - if address.is_global_unicast_ip() { + if std_ip.is_global_unicast_ip() { match std_ip { std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( ip.octets()[0..IPV4_GROUP_BYTES].try_into().expect("must be valid"), @@ -51,7 +52,7 @@ impl AddressGroup { ip.octets()[0..IPV6_GROUP_BYTES].try_into().expect("must be valid"), ), } - } else if address.is_loopback() { + } else if std_ip.is_loopback() { AddressGroup::Local } else { AddressGroup::Private @@ -95,46 +96,36 @@ mod tests { mod mapped_address_tests { use super::*; - fn mapped_v6_address(ip: std::net::Ipv4Addr) -> PeerAddress { - let o = ip.octets(); - PeerAddress::Ip6(crate::types::peer_address::PeerAddressIp6 { - ip: std::net::Ipv6Addr::new( - 0, - 0, - 0, - 0, - 0, - 0xffff, - u16::from_be_bytes([o[0], o[1]]), - u16::from_be_bytes([o[2], o[3]]), - ) - .into(), - port: 12345, - }) - } - #[test] fn mapped_addresses_are_grouped_like_the_embedded_ipv4() { + use crate::peer_manager::tests::utils::mapped_v6_peer_address; + assert_eq!( - AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::LOCALHOST)), + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::LOCALHOST, + 12345 + )), AddressGroup::Local ); assert_eq!( - AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( - 192, 168, 1, 1 - ))), + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(192, 168, 1, 1), + 12345 + )), AddressGroup::Private ); assert_eq!( - AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( - 10, 0, 0, 1 - ))), + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(10, 0, 0, 1), + 12345 + )), AddressGroup::Private ); assert_eq!( - AddressGroup::from_peer_address(&mapped_v6_address(std::net::Ipv4Addr::new( - 1, 2, 3, 4 - ))), + AddressGroup::from_peer_address(&mapped_v6_peer_address( + std::net::Ipv4Addr::new(1, 2, 3, 4), + 12345 + )), AddressGroup::PublicV4([1, 2]) ); } diff --git a/p2p/src/peer_manager/mod.rs b/p2p/src/peer_manager/mod.rs index 4b12c69905..887d9763b8 100644 --- a/p2p/src/peer_manager/mod.rs +++ b/p2p/src/peer_manager/mod.rs @@ -1445,7 +1445,9 @@ where } let socket_addr = addr.socket_addr(); - cur_conn_ip_port_to_role_map.insert((socket_addr.ip(), socket_addr.port()), role); + // Key on the canonical ip so that a mapped form (::ffff:a.b.c.d) counts towards + // the same per-ip connection limit as the plain ipv4 form. + cur_conn_ip_port_to_role_map.insert((addr.ip_addr(), socket_addr.port()), role); } let needed_outbound_full_relay_conn_count = { @@ -2078,7 +2080,7 @@ where ) -> bool { assert!(new_peer_role.is_outbound()); - let new_peer_ip = new_peer_addr.socket_addr().ip(); + let new_peer_ip = new_peer_addr.ip_addr(); let new_peer_port = new_peer_addr.socket_addr().port(); existing_connections.range((new_peer_ip, 0)..=(new_peer_ip, u16::MAX)).all( |((_, existing_peer_port), existing_peer_role)| { diff --git a/p2p/src/peer_manager/tests/addresses.rs b/p2p/src/peer_manager/tests/addresses.rs index 6e7a7e1480..3e750bff7f 100644 --- a/p2p/src/peer_manager/tests/addresses.rs +++ b/p2p/src/peer_manager/tests/addresses.rs @@ -900,23 +900,7 @@ async fn announce_mapped_ipv4_address_follows_ipv4_rules(#[case] seed: Seed) { ); assert_eq!(pm.peers.len(), 1); - let mapped = |ip: std::net::Ipv4Addr, port: u16| { - let o = ip.octets(); - PeerAddress::Ip6(p2p_types::peer_address::PeerAddressIp6 { - ip: std::net::Ipv6Addr::new( - 0, - 0, - 0, - 0, - 0, - 0xffff, - u16::from_be_bytes([o[0], o[1]]), - u16::from_be_bytes([o[2], o[3]]), - ) - .into(), - port, - }) - }; + let mapped = crate::peer_manager::tests::utils::mapped_v6_peer_address; // Non-routable embedded IPv4 addresses are rejected like their plain form; nothing // must enter the known address table. diff --git a/p2p/src/peer_manager/tests/utils.rs b/p2p/src/peer_manager/tests/utils.rs index 12d1e89a2e..dfc60512f6 100644 --- a/p2p/src/peer_manager/tests/utils.rs +++ b/p2p/src/peer_manager/tests/utils.rs @@ -21,7 +21,8 @@ use common::{chain::ChainConfig, primitives::user_agent::mintlayer_core_user_age use logging::log; use p2p_test_utils::wait_for_recv; use p2p_types::{ - PeerId, bannable_address::BannableAddress, services::Service, socket_address::SocketAddress, + PeerId, bannable_address::BannableAddress, peer_address::PeerAddress, services::Service, + socket_address::SocketAddress, }; use test_utils::{BasicTestTimeGetter, assert_matches_return_val}; use utils_networking::IpOrSocketAddress; @@ -346,3 +347,22 @@ pub async fn add_reserved_peer( result_receiver.await.unwrap().unwrap(); } + +// Build an IPv4-mapped IPv6 peer address (::ffff:a.b.c.d). +pub(crate) fn mapped_v6_peer_address(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { + let o = ip.octets(); + PeerAddress::Ip6(crate::types::peer_address::PeerAddressIp6 { + ip: std::net::Ipv6Addr::new( + 0, + 0, + 0, + 0, + 0, + 0xffff, + u16::from_be_bytes([o[0], o[1]]), + u16::from_be_bytes([o[2], o[3]]), + ) + .into(), + port, + }) +} diff --git a/p2p/types/Cargo.toml b/p2p/types/Cargo.toml index 40601766b7..585a7a5a9d 100644 --- a/p2p/types/Cargo.toml +++ b/p2p/types/Cargo.toml @@ -16,3 +16,4 @@ parity-scale-codec.workspace = true serde.workspace = true [dev-dependencies] +serde_json.workspace = true diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index adc230922a..49fc0e86e6 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -19,25 +19,32 @@ use std::{ str::FromStr, }; -use serde::{Deserialize, Serialize}; - -#[derive( - Debug, - Copy, - Clone, - PartialEq, - Eq, - PartialOrd, - Ord, - Serialize, - Deserialize, - rpc_description::HasValueHint, -)] +#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, rpc_description::HasValueHint)] pub struct BannableAddress(IpAddr); impl BannableAddress { pub fn new(ip: IpAddr) -> Self { - Self(ip) + // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that a mapped + // address and the plain IPv4 address it represents share one ban key. + Self(crate::socket_address::canonical_ip_addr(ip)) + } + + pub fn ip_addr(&self) -> IpAddr { + self.0 + } +} + +impl serde::Serialize for BannableAddress { + fn serialize(&self, serializer: S) -> Result { + self.0.serialize(serializer) + } +} + +// Deserialize routes through `new` so that persisted bans stored in the mapped form are +// treated as their plain IPv4 form when loaded. +impl<'de> serde::Deserialize<'de> for BannableAddress { + fn deserialize>(deserializer: D) -> Result { + IpAddr::deserialize(deserializer).map(BannableAddress::new) } } @@ -51,6 +58,23 @@ impl FromStr for BannableAddress { type Err = AddrParseError; fn from_str(s: &str) -> Result { - IpAddr::from_str(s).map(BannableAddress) + IpAddr::from_str(s).map(BannableAddress::new) + } +} + +#[cfg(test)] +mod bannable_address_tests { + use super::*; + + #[test] + fn mapped_addresses_share_the_ban_key_with_the_embedded_ipv4() { + let plain = IpAddr::V4(std::net::Ipv4Addr::new(1, 2, 3, 4)); + let mapped: IpAddr = "::ffff:1.2.3.4".parse().unwrap(); + assert_eq!(BannableAddress::new(mapped), BannableAddress::new(plain)); + + // The serde round trip folds the mapped form into the plain form. + let serialized = serde_json::to_string(&BannableAddress::new(mapped)).unwrap(); + let deserialized: BannableAddress = serde_json::from_str(&serialized).unwrap(); + assert_eq!(deserialized, BannableAddress::new(plain)); } } diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index 507aa3c183..da8592b822 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -15,7 +15,7 @@ use std::{ fmt::Display, - net::{AddrParseError, IpAddr, SocketAddr, SocketAddrV4}, + net::{AddrParseError, IpAddr, SocketAddr}, str::FromStr, }; @@ -48,8 +48,10 @@ impl SocketAddress { } /// The IP address with IPv4-mapped IPv6 addresses reduced to the embedded IPv4 form, - /// so that classification, bans and connection limits treat `::ffff:a.b.c.d` as the - /// IPv4 address it represents. + /// so that classification, bans and deduplication treat `::ffff:a.b.c.d` as the IPv4 + /// address it represents. Note that code that keys on the raw form must use this + /// accessor (or [`canonical_socket_address`]) on both the insert and the lookup side; + /// [`socket_addr`] returns the address unchanged. pub fn ip_addr(&self) -> IpAddr { canonical_ip_addr(self.0.ip()) } @@ -77,13 +79,7 @@ pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { /// Same reduction as [`canonical_ip_addr`], applied to a socket address including its port. pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { - match addr { - SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { - Some(v4) => SocketAddr::V4(SocketAddrV4::new(v4, v6.port())), - None => SocketAddr::V6(v6), - }, - v4 => v4, - } + SocketAddr::new(canonical_ip_addr(addr.ip()), addr.port()) } impl Display for SocketAddress { @@ -110,6 +106,7 @@ impl From for SocketAddress { mod mapped_address_tests { use super::*; use crate::bannable_address::BannableAddress; + use std::net::SocketAddrV4; #[test] fn mapped_addresses_are_canonicalized() { From 0f16b35d429f68a74104fe1c0744186e100a1508 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 19:11:56 +0200 Subject: [PATCH 03/26] fix(p2p): preserve ipv6 scope in canonical address helper --- p2p/src/peer_manager/tests/utils.rs | 7 +++++-- p2p/types/src/socket_address.rs | 9 ++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/p2p/src/peer_manager/tests/utils.rs b/p2p/src/peer_manager/tests/utils.rs index dfc60512f6..d641d04180 100644 --- a/p2p/src/peer_manager/tests/utils.rs +++ b/p2p/src/peer_manager/tests/utils.rs @@ -21,7 +21,10 @@ use common::{chain::ChainConfig, primitives::user_agent::mintlayer_core_user_age use logging::log; use p2p_test_utils::wait_for_recv; use p2p_types::{ - PeerId, bannable_address::BannableAddress, peer_address::PeerAddress, services::Service, + PeerId, + bannable_address::BannableAddress, + peer_address::{PeerAddress, PeerAddressIp6}, + services::Service, socket_address::SocketAddress, }; use test_utils::{BasicTestTimeGetter, assert_matches_return_val}; @@ -351,7 +354,7 @@ pub async fn add_reserved_peer( // Build an IPv4-mapped IPv6 peer address (::ffff:a.b.c.d). pub(crate) fn mapped_v6_peer_address(ip: std::net::Ipv4Addr, port: u16) -> PeerAddress { let o = ip.octets(); - PeerAddress::Ip6(crate::types::peer_address::PeerAddressIp6 { + PeerAddress::Ip6(PeerAddressIp6 { ip: std::net::Ipv6Addr::new( 0, 0, diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index da8592b822..bb4cb26a6c 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -78,8 +78,15 @@ pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { } /// Same reduction as [`canonical_ip_addr`], applied to a socket address including its port. +/// Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are preserved. pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { - SocketAddr::new(canonical_ip_addr(addr.ip()), addr.port()) + match addr { + SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { + Some(v4) => SocketAddr::new(IpAddr::V4(v4), v6.port()), + None => SocketAddr::V6(v6), + }, + v4 => v4, + } } impl Display for SocketAddress { From 2a0bd9da2485b1873924126f6fa034cafc7bf1b0 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 19:14:43 +0200 Subject: [PATCH 04/26] test(p2p): pin the untouched ipv6 form in the canonical helper --- p2p/types/src/socket_address.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index bb4cb26a6c..ba2de7f845 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -145,4 +145,15 @@ mod mapped_address_tests { SocketAddr::V4(SocketAddrV4::new(std::net::Ipv4Addr::LOCALHOST, 12345)) ); } + + #[test] + fn canonical_socket_address_preserves_v6_flowinfo_and_scope_id() { + let scoped = SocketAddr::V6(std::net::SocketAddrV6::new( + "fe80::1".parse().unwrap(), + 12345, + 7, + 9, + )); + assert_eq!(canonical_socket_address(scoped), scoped); + } } From 22c6b46c79c4690a4b10ae1b1398f8d991e83d9e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 19:32:39 +0200 Subject: [PATCH 05/26] fix(p2p): canonicalize socket addresses at construction --- p2p/src/peer_manager/address_groups.rs | 14 ++++----- p2p/src/peer_manager/mod.rs | 12 ++++---- p2p/types/src/socket_address.rs | 41 +++++++++++++++++++------- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index c91f68fad6..4ab4912a61 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -13,7 +13,9 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::types::{global_ip::IsGlobalIp, peer_address::PeerAddress}; +use crate::types::{ + global_ip::IsGlobalIp, peer_address::PeerAddress, socket_address::canonical_ip_addr, +}; // IPv4 addresses grouped into /16 subnets pub const IPV4_GROUP_BYTES: usize = 2; @@ -37,12 +39,10 @@ impl AddressGroup { /// /// See `NetGroupManager::GetGroup` in Bitcoin Core for a reference. pub fn from_peer_address(address: &PeerAddress) -> AddressGroup { - // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that they - // land in the same group as the IPv4 address they represent. All checks run on the - // canonical form. - let std_ip = crate::types::socket_address::canonical_ip_addr( - std::net::SocketAddr::from(address).ip(), - ); + // PeerAddress is not canonicalized at construction, so the reduction to the + // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run + // on the canonical form. + let std_ip = canonical_ip_addr(std::net::SocketAddr::from(address).ip()); if std_ip.is_global_unicast_ip() { match std_ip { std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( diff --git a/p2p/src/peer_manager/mod.rs b/p2p/src/peer_manager/mod.rs index 887d9763b8..e6363fc621 100644 --- a/p2p/src/peer_manager/mod.rs +++ b/p2p/src/peer_manager/mod.rs @@ -1445,9 +1445,10 @@ where } let socket_addr = addr.socket_addr(); - // Key on the canonical ip so that a mapped form (::ffff:a.b.c.d) counts towards - // the same per-ip connection limit as the plain ipv4 form. - cur_conn_ip_port_to_role_map.insert((addr.ip_addr(), socket_addr.port()), role); + // SocketAddress is canonical by construction, so a mapped form + // (::ffff:a.b.c.d) counts towards the same per-ip connection limit as the + // plain ipv4 form. + cur_conn_ip_port_to_role_map.insert((socket_addr.ip(), socket_addr.port()), role); } let needed_outbound_full_relay_conn_count = { @@ -2080,8 +2081,9 @@ where ) -> bool { assert!(new_peer_role.is_outbound()); - let new_peer_ip = new_peer_addr.ip_addr(); - let new_peer_port = new_peer_addr.socket_addr().port(); + let new_peer_addr_socket = new_peer_addr.socket_addr(); + let new_peer_ip = new_peer_addr_socket.ip(); + let new_peer_port = new_peer_addr_socket.port(); existing_connections.range((new_peer_ip, 0)..=(new_peer_ip, u16::MAX)).all( |((_, existing_peer_port), existing_peer_role)| { self.may_allow_outbound_connection_to_existing_ip( diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index ba2de7f845..b7e2e88e74 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -19,7 +19,7 @@ use std::{ str::FromStr, }; -use serde::{Deserialize, Serialize}; +use serde::Serialize; use crate::{bannable_address::BannableAddress, peer_address::PeerAddress}; @@ -32,28 +32,34 @@ use crate::{bannable_address::BannableAddress, peer_address::PeerAddress}; PartialOrd, Ord, Serialize, - Deserialize, Hash, rpc_description::HasValueHint, )] pub struct SocketAddress(SocketAddr); +// Deserialize routes through the constructor so that mapped forms are folded on the way in, +// matching the wire format of the derived `Serialize`. +impl<'de> serde::Deserialize<'de> for SocketAddress { + fn deserialize>(deserializer: D) -> Result { + SocketAddr::deserialize(deserializer).map(SocketAddress::new) + } +} + impl SocketAddress { + /// The stored address is canonical by construction: IPv4-mapped IPv6 forms are reduced + /// to the embedded IPv4 form, so classification, bans, deduplication and storage treat + /// `::ffff:a.b.c.d` as the IPv4 address it represents. pub fn new(addr: SocketAddr) -> Self { - Self(addr) + Self(canonical_socket_address(addr)) } pub fn socket_addr(&self) -> SocketAddr { self.0 } - /// The IP address with IPv4-mapped IPv6 addresses reduced to the embedded IPv4 form, - /// so that classification, bans and deduplication treat `::ffff:a.b.c.d` as the IPv4 - /// address it represents. Note that code that keys on the raw form must use this - /// accessor (or [`canonical_socket_address`]) on both the insert and the lookup side; - /// [`socket_addr`] returns the address unchanged. + /// The IP address of the canonical form; see [`SocketAddress::new`]. pub fn ip_addr(&self) -> IpAddr { - canonical_ip_addr(self.0.ip()) + self.0.ip() } pub fn as_bannable(&self) -> BannableAddress { @@ -99,7 +105,7 @@ impl FromStr for SocketAddress { type Err = AddrParseError; fn from_str(s: &str) -> Result { - SocketAddr::from_str(s).map(SocketAddress) + SocketAddr::from_str(s).map(SocketAddress::new) } } @@ -156,4 +162,19 @@ mod mapped_address_tests { )); assert_eq!(canonical_socket_address(scoped), scoped); } + + #[test] + fn mapped_forms_are_canonicalized_on_construction() { + let plain: SocketAddr = "1.2.3.4:12345".parse().unwrap(); + let mapped: SocketAddr = "[::ffff:1.2.3.4]:12345".parse().unwrap(); + assert_eq!(SocketAddress::new(mapped), SocketAddress::new(plain)); + assert_eq!( + "[::ffff:1.2.3.4]:12345".parse::().unwrap(), + SocketAddress::new(plain) + ); + assert_eq!( + serde_json::from_str::("\"[::ffff:1.2.3.4]:12345\"").unwrap(), + SocketAddress::new(plain) + ); + } } From 35f5e14d7ee261579283ded66a0bc9306064bcd2 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 19:57:56 +0200 Subject: [PATCH 06/26] fix(p2p): fold legacy mapped address rows on peer db load --- p2p/src/peer_manager/peerdb/storage.rs | 5 ++ p2p/src/peer_manager/peerdb/storage_impl.rs | 60 +++++++++++++++++++ p2p/src/peer_manager/peerdb/storage_load.rs | 8 +++ p2p/src/peer_manager/peerdb/tests.rs | 66 +++++++++++++++++++++ 4 files changed, 139 insertions(+) diff --git a/p2p/src/peer_manager/peerdb/storage.rs b/p2p/src/peer_manager/peerdb/storage.rs index d91af220c0..f23a7d2ab4 100644 --- a/p2p/src/peer_manager/peerdb/storage.rs +++ b/p2p/src/peer_manager/peerdb/storage.rs @@ -71,6 +71,11 @@ pub trait PeerDbStorageWrite { fn add_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; + + /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so + /// that they are keyed by the canonical form; without this, removals would miss the + /// legacy rows and bans could resurrect after a restart. + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<()>; } // Note: here we want to say something like: diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 05bf141e95..2bcde3f571 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -124,6 +124,66 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()> { Ok(self.storage().get_mut::().del(address.to_string())?) } + + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<()> { + let storage = self.storage(); + + fn canonical_socket_address_spelling(key: &str) -> Option { + let address = key.parse::().ok()?; + let canonical = address.to_string(); + (canonical != key).then_some(canonical) + } + + fn canonical_bannable_address_spelling(key: &str) -> Option { + let address = key.parse::().ok()?; + let canonical = address.to_string(); + (canonical != key).then_some(canonical) + } + + rekey_mapped_rows( + &mut storage.get_mut::(), + canonical_socket_address_spelling, + )?; + rekey_mapped_rows( + &mut storage.get_mut::(), + canonical_socket_address_spelling, + )?; + rekey_mapped_rows( + &mut storage.get_mut::(), + canonical_bannable_address_spelling, + )?; + rekey_mapped_rows( + &mut storage.get_mut::(), + canonical_bannable_address_spelling, + )?; + + Ok(()) + } +} + +/// Rewrite rows keyed by a non-canonical spelling of an IPv4 address (the mapped form +/// `::ffff:a.b.c.d` written by an older version) so that the key matches the canonical +/// spelling used at runtime, keeping the value unchanged. +fn rekey_mapped_rows( + map: &mut storage::MapMut<'_, storage::TransactionRw<'_, B, Schema>, DBM>, + canonical_spelling: impl Fn(&str) -> Option, +) -> crate::Result<()> +where + B: storage::SharedBackend, + DBM: storage::schema::DbMap, + DBM::Value: serialization::Encode + serialization::EncodeLike + Clone, +{ + let mut rewrites = Vec::new(); + for (key, value) in map.prefix_iter(&())? { + if let Some(canonical) = canonical_spelling(&key) { + rewrites.push((key, canonical, value.decode())); + } + } + for (legacy_key, canonical_key, value) in rewrites { + map.del(&legacy_key)?; + map.put(&canonical_key, value)?; + } + Ok(()) } impl PeerDbStorageRead for PeerDbStoreTxRo<'_, B> { diff --git a/p2p/src/peer_manager/peerdb/storage_load.rs b/p2p/src/peer_manager/peerdb/storage_load.rs index adefdfe1d8..779833a75f 100644 --- a/p2p/src/peer_manager/peerdb/storage_load.rs +++ b/p2p/src/peer_manager/peerdb/storage_load.rs @@ -83,6 +83,14 @@ impl LoadedStorage { } fn load_storage_v3(storage: &S) -> crate::Result { + { + // Rewrite any rows written by an older version under the mapped spelling of an + // IPv4 address, so the stored keys match the canonical form used at runtime. + let mut write_tx = storage.transaction_rw()?; + write_tx.normalize_legacy_mapped_address_keys()?; + write_tx.commit()?; + } + let tx = storage.transaction_ro()?; let known_addresses = tx.get_known_addresses()?.into_iter().collect::>(); diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 1b3c88385c..308f28b369 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1088,3 +1088,69 @@ fn new_addr_table(peerdb: &PeerDb) -> &Table { fn tried_addr_table(peerdb: &PeerDb) -> &Table { peerdb.address_tables.tried_addr_table() } + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; + use crate::peer_manager::peerdb::storage_load::LoadedStorage; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + use p2p_types::{bannable_address::BannableAddress, socket_address::SocketAddress}; + use storage::MakeMapRef as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + // Seed the store with rows keyed by the mapped spelling, as an older version could + // have written them before the canonicalization was introduced. + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + tx.storage() + .get_mut::() + .put("[::ffff:1.2.3.4]:12345".to_owned(), KnownAddressState::New) + .unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)) + .unwrap(); + tx.commit().unwrap(); + } + + let peerdb_config = test_p2p_config().peer_manager_config.peerdb_config; + LoadedStorage::load_storage(&db_store, &peerdb_config, &mut rng).unwrap(); + + let tx = db_store.transaction_ro().unwrap(); + assert!( + tx.storage() + .get::() + .get("[::ffff:1.2.3.4]:12345") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("1.2.3.4:12345") + .unwrap() + .is_some() + ); + assert!( + tx.storage() + .get::() + .get("::ffff:1.2.3.4") + .unwrap() + .is_none() + ); + assert!(tx.storage().get::().get("1.2.3.4").unwrap().is_some()); + + // Sanity check on the canonical spellings, mirroring what the runtime writes. + let canonical_socket: SocketAddress = "1.2.3.4:12345".parse().unwrap(); + assert_eq!(canonical_socket.to_string(), "1.2.3.4:12345"); + let canonical_bannable: BannableAddress = "1.2.3.4".parse().unwrap(); + assert_eq!(canonical_bannable.to_string(), "1.2.3.4"); +} From 3f023bc5a0bed977f20e3dd3b422cc698b97333e Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:07:09 +0200 Subject: [PATCH 07/26] fix(p2p): keep legacy address rows from clobbering fresh ones --- p2p/src/peer_manager/peerdb/storage_impl.rs | 13 +++++- p2p/src/peer_manager/peerdb/tests.rs | 51 +++++++++++++++++++++ 2 files changed, 63 insertions(+), 1 deletion(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 2bcde3f571..5f50fab4cb 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -129,12 +129,20 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { let storage = self.storage(); fn canonical_socket_address_spelling(key: &str) -> Option { + // Mapped spellings always start with one of these prefixes, so the parse can + // be skipped for the vast majority of keys on every startup. + if !(key.starts_with("[::ffff:") || key.starts_with("::ffff:")) { + return None; + } let address = key.parse::().ok()?; let canonical = address.to_string(); (canonical != key).then_some(canonical) } fn canonical_bannable_address_spelling(key: &str) -> Option { + if !key.starts_with("::ffff:") { + return None; + } let address = key.parse::().ok()?; let canonical = address.to_string(); (canonical != key).then_some(canonical) @@ -181,7 +189,10 @@ where } for (legacy_key, canonical_key, value) in rewrites { map.del(&legacy_key)?; - map.put(&canonical_key, value)?; + // A canonical row written by this version supersedes the legacy one. + if map.get(&canonical_key)?.is_none() { + map.put(&canonical_key, value)?; + } } Ok(()) } diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 308f28b369..b8af4021d4 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1154,3 +1154,54 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { let canonical_bannable: BannableAddress = "1.2.3.4".parse().unwrap(); assert_eq!(canonical_bannable.to_string(), "1.2.3.4"); } + +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::DBKnownAddresses; + use crate::peer_manager::peerdb::storage_load::LoadedStorage; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + use storage::MakeMapRef as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + let mut known = tx.storage().get_mut::(); + known + .put( + "[::ffff:1.2.3.4]:12345".to_owned(), + KnownAddressState::Tried, + ) + .unwrap(); + known.put("1.2.3.4:12345".to_owned(), KnownAddressState::New).unwrap(); + tx.commit().unwrap(); + } + + let peerdb_config = test_p2p_config().peer_manager_config.peerdb_config; + LoadedStorage::load_storage(&db_store, &peerdb_config, &mut rng).unwrap(); + + let tx = db_store.transaction_ro().unwrap(); + assert_eq!( + tx.storage() + .get::() + .get("1.2.3.4:12345") + .unwrap() + .unwrap() + .decode(), + KnownAddressState::New + ); + assert!( + tx.storage() + .get::() + .get("[::ffff:1.2.3.4]:12345") + .unwrap() + .is_none() + ); +} From 0675d14483f30419fc0ebebbec1bffdd7f16929c Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:15:10 +0200 Subject: [PATCH 08/26] test(p2p): cover the ban rows in the legacy fold test --- p2p/src/peer_manager/peerdb/tests.rs | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index b8af4021d4..d3404580ee 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1160,7 +1160,7 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { #[case(Seed::from_entropy())] fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; - use crate::peer_manager::peerdb::storage_impl::DBKnownAddresses; + use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; use crate::peer_manager::peerdb::storage_load::LoadedStorage; use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; use crate::peer_manager::peerdb_common::storage::TransactionRw as _; @@ -1181,6 +1181,10 @@ fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { ) .unwrap(); known.put("1.2.3.4:12345".to_owned(), KnownAddressState::New).unwrap(); + + let mut banned = tx.storage().get_mut::(); + banned.put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)).unwrap(); + banned.put("1.2.3.4".to_owned(), Duration::from_secs(120)).unwrap(); tx.commit().unwrap(); } @@ -1204,4 +1208,22 @@ fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { .unwrap() .is_none() ); + + // The same merge rule applies to ban rows: the canonical ban keeps its value. + assert_eq!( + tx.storage() + .get::() + .get("1.2.3.4") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(120) + ); + assert!( + tx.storage() + .get::() + .get("::ffff:1.2.3.4") + .unwrap() + .is_none() + ); } From 0e72b1e36a2bf6860894aecd08c2fab411b84318 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:20:37 +0200 Subject: [PATCH 09/26] fix(p2p): hoist the legacy key helpers above their first use --- p2p/src/peer_manager/peerdb/storage_impl.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 5f50fab4cb..5b96b3e8df 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -126,8 +126,6 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<()> { - let storage = self.storage(); - fn canonical_socket_address_spelling(key: &str) -> Option { // Mapped spellings always start with one of these prefixes, so the parse can // be skipped for the vast majority of keys on every startup. @@ -148,6 +146,8 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { (canonical != key).then_some(canonical) } + let storage = self.storage(); + rekey_mapped_rows( &mut storage.get_mut::(), canonical_socket_address_spelling, From 1cce2d85b36a11b5844909f839bf9999400591fd Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:34:59 +0200 Subject: [PATCH 10/26] fix(p2p): merge folded address rows and decode them safely --- p2p/src/peer_manager/address_groups.rs | 3 +- p2p/src/peer_manager/peerdb/storage_impl.rs | 37 ++++++++++++++++----- p2p/src/peer_manager/peerdb/tests.rs | 7 ++-- p2p/types/src/socket_address.rs | 3 ++ 4 files changed, 37 insertions(+), 13 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 4ab4912a61..2194c8b394 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -41,7 +41,8 @@ impl AddressGroup { pub fn from_peer_address(address: &PeerAddress) -> AddressGroup { // PeerAddress is not canonicalized at construction, so the reduction to the // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run - // on the canonical form. + // on the canonical form. Note that other IPv4-embedded encodings (6to4, Teredo, + // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6. let std_ip = canonical_ip_addr(std::net::SocketAddr::from(address).ip()); if std_ip.is_global_unicast_ip() { match std_ip { diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 5b96b3e8df..f1991ff32c 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -151,18 +151,27 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { rekey_mapped_rows( &mut storage.get_mut::(), canonical_socket_address_spelling, + // A legacy row marked as tried keeps its selection preference. + |canonical, legacy| match legacy { + KnownAddressState::Tried => legacy, + KnownAddressState::New => canonical, + }, )?; rekey_mapped_rows( &mut storage.get_mut::(), canonical_socket_address_spelling, + |_canonical, _legacy| (), )?; rekey_mapped_rows( &mut storage.get_mut::(), canonical_bannable_address_spelling, + // The later unban time wins so the migration cannot shorten a ban. + std::cmp::max, )?; rekey_mapped_rows( &mut storage.get_mut::(), canonical_bannable_address_spelling, + std::cmp::max, )?; Ok(()) @@ -171,28 +180,38 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { /// Rewrite rows keyed by a non-canonical spelling of an IPv4 address (the mapped form /// `::ffff:a.b.c.d` written by an older version) so that the key matches the canonical -/// spelling used at runtime, keeping the value unchanged. +/// spelling used at runtime, merging the value with an existing canonical row via +/// `merge` when one is already present. fn rekey_mapped_rows( map: &mut storage::MapMut<'_, storage::TransactionRw<'_, B, Schema>, DBM>, canonical_spelling: impl Fn(&str) -> Option, + merge: impl Fn(DBM::Value, DBM::Value) -> DBM::Value, ) -> crate::Result<()> where B: storage::SharedBackend, DBM: storage::schema::DbMap, - DBM::Value: serialization::Encode + serialization::EncodeLike + Clone, + DBM::Value: serialization::Encode + serialization::EncodeLike, { let mut rewrites = Vec::new(); - for (key, value) in map.prefix_iter(&())? { + for (key, encoded) in map.prefix_iter(&())? { if let Some(canonical) = canonical_spelling(&key) { - rewrites.push((key, canonical, value.decode())); + // Decode explicitly so a corrupt row is reported instead of panicking. + let value = ::decode_all(&mut encoded.as_ref()) + .map_err(|err| { + P2pError::InvalidStorageState(format!( + "Failed to decode the value of the row keyed by {key}: {err}" + )) + })?; + rewrites.push((key, canonical, value)); } } - for (legacy_key, canonical_key, value) in rewrites { + for (legacy_key, canonical_key, legacy_value) in rewrites { map.del(&legacy_key)?; - // A canonical row written by this version supersedes the legacy one. - if map.get(&canonical_key)?.is_none() { - map.put(&canonical_key, value)?; - } + let value = match map.get(&canonical_key)? { + Some(existing) => merge(existing.decode(), legacy_value), + None => legacy_value, + }; + map.put(&canonical_key, value)?; } Ok(()) } diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index d3404580ee..b5e8cfccec 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1158,7 +1158,7 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { #[rstest] #[trace] #[case(Seed::from_entropy())] -fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { +fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; use crate::peer_manager::peerdb::storage_load::LoadedStorage; @@ -1192,6 +1192,7 @@ fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { LoadedStorage::load_storage(&db_store, &peerdb_config, &mut rng).unwrap(); let tx = db_store.transaction_ro().unwrap(); + // The legacy Tried state merges over the canonical New state. assert_eq!( tx.storage() .get::() @@ -1199,7 +1200,7 @@ fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { .unwrap() .unwrap() .decode(), - KnownAddressState::New + KnownAddressState::Tried ); assert!( tx.storage() @@ -1209,7 +1210,7 @@ fn folded_legacy_rows_do_not_clobber_canonical_ones(#[case] seed: Seed) { .is_none() ); - // The same merge rule applies to ban rows: the canonical ban keeps its value. + // The same merge rule applies to ban rows: the later unban time wins. assert_eq!( tx.storage() .get::() diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index b7e2e88e74..90abd0d002 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -73,6 +73,9 @@ impl SocketAddress { /// Reduce an IPv4-mapped IPv6 address (`::ffff:a.b.c.d`) to the embedded IPv4 address; /// any other address is returned unchanged. +/// +/// Note that only the mapped form is reduced. Other IPv4-embedded encodings (6to4, +/// Teredo, NAT64, IPv4-translated) are treated as plain IPv6 addresses. pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { match ip { IpAddr::V6(v6) => match v6.to_ipv4_mapped() { From 4168ab9479c12e7665097e22a02ae14ab912c566 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:44:15 +0200 Subject: [PATCH 11/26] fix(p2p): decode both folded address rows safely and pin the merge --- p2p/src/peer_manager/peerdb/storage_impl.rs | 13 ++++++++++++- p2p/src/peer_manager/peerdb/tests.rs | 14 +++++++++++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index f1991ff32c..5154ebe6ec 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -208,7 +208,18 @@ where for (legacy_key, canonical_key, legacy_value) in rewrites { map.del(&legacy_key)?; let value = match map.get(&canonical_key)? { - Some(existing) => merge(existing.decode(), legacy_value), + Some(existing) => { + // Decode explicitly so a corrupt canonical row is reported too. + let existing_value = ::decode_all( + &mut existing.as_ref(), + ) + .map_err(|err| { + P2pError::InvalidStorageState(format!( + "Failed to decode the value of the row keyed by {canonical_key}: {err}" + )) + })?; + merge(existing_value, legacy_value) + } None => legacy_value, }; map.put(&canonical_key, value)?; diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index b5e8cfccec..824f60afd9 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1185,6 +1185,8 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { let mut banned = tx.storage().get_mut::(); banned.put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)).unwrap(); banned.put("1.2.3.4".to_owned(), Duration::from_secs(120)).unwrap(); + banned.put("::ffff:5.6.7.8".to_owned(), Duration::from_secs(300)).unwrap(); + banned.put("5.6.7.8".to_owned(), Duration::from_secs(120)).unwrap(); tx.commit().unwrap(); } @@ -1210,7 +1212,8 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { .is_none() ); - // The same merge rule applies to ban rows: the later unban time wins. + // The same merge rule applies to ban rows: the later unban time wins, regardless of + // whether it comes from the legacy or the canonical row. assert_eq!( tx.storage() .get::() @@ -1220,6 +1223,15 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { .decode(), Duration::from_secs(120) ); + assert_eq!( + tx.storage() + .get::() + .get("5.6.7.8") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(300) + ); assert!( tx.storage() .get::() From d88a80ce70c966592412c98006e1fee377512847 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 20:49:21 +0200 Subject: [PATCH 12/26] test(p2p): assert folded legacy ban rows are removed --- p2p/src/peer_manager/peerdb/tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 824f60afd9..14a72f608e 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1232,6 +1232,13 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { .decode(), Duration::from_secs(300) ); + assert!( + tx.storage() + .get::() + .get("::ffff:5.6.7.8") + .unwrap() + .is_none() + ); assert!( tx.storage() .get::() From a56d180bc112fd6af7a12507677d8b4c90810cdf Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:02:21 +0200 Subject: [PATCH 13/26] refactor(p2p): skip the legacy fold commit and derive ban serialization --- p2p/src/peer_manager/peerdb/storage.rs | 2 +- p2p/src/peer_manager/peerdb/storage_impl.rs | 19 +++++++----- p2p/src/peer_manager/peerdb/storage_load.rs | 7 +++-- p2p/types/src/bannable_address.rs | 34 ++++++++++++--------- 4 files changed, 36 insertions(+), 26 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage.rs b/p2p/src/peer_manager/peerdb/storage.rs index f23a7d2ab4..e6d0354d58 100644 --- a/p2p/src/peer_manager/peerdb/storage.rs +++ b/p2p/src/peer_manager/peerdb/storage.rs @@ -75,7 +75,7 @@ pub trait PeerDbStorageWrite { /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so /// that they are keyed by the canonical form; without this, removals would miss the /// legacy rows and bans could resurrect after a restart. - fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<()>; + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result; } // Note: here we want to say something like: diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 5154ebe6ec..412ce7f966 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -125,7 +125,7 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { Ok(self.storage().get_mut::().del(address.to_string())?) } - fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result<()> { + fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result { fn canonical_socket_address_spelling(key: &str) -> Option { // Mapped spellings always start with one of these prefixes, so the parse can // be skipped for the vast majority of keys on every startup. @@ -147,8 +147,9 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } let storage = self.storage(); + let mut rewrote_any = false; - rekey_mapped_rows( + rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), canonical_socket_address_spelling, // A legacy row marked as tried keeps its selection preference. @@ -157,24 +158,24 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { KnownAddressState::New => canonical, }, )?; - rekey_mapped_rows( + rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), canonical_socket_address_spelling, |_canonical, _legacy| (), )?; - rekey_mapped_rows( + rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), canonical_bannable_address_spelling, // The later unban time wins so the migration cannot shorten a ban. std::cmp::max, )?; - rekey_mapped_rows( + rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), canonical_bannable_address_spelling, std::cmp::max, )?; - Ok(()) + Ok(rewrote_any) } } @@ -186,7 +187,7 @@ fn rekey_mapped_rows( map: &mut storage::MapMut<'_, storage::TransactionRw<'_, B, Schema>, DBM>, canonical_spelling: impl Fn(&str) -> Option, merge: impl Fn(DBM::Value, DBM::Value) -> DBM::Value, -) -> crate::Result<()> +) -> crate::Result where B: storage::SharedBackend, DBM: storage::schema::DbMap, @@ -205,6 +206,7 @@ where rewrites.push((key, canonical, value)); } } + let mut rewrote_any = false; for (legacy_key, canonical_key, legacy_value) in rewrites { map.del(&legacy_key)?; let value = match map.get(&canonical_key)? { @@ -223,8 +225,9 @@ where None => legacy_value, }; map.put(&canonical_key, value)?; + rewrote_any = true; } - Ok(()) + Ok(rewrote_any) } impl PeerDbStorageRead for PeerDbStoreTxRo<'_, B> { diff --git a/p2p/src/peer_manager/peerdb/storage_load.rs b/p2p/src/peer_manager/peerdb/storage_load.rs index 779833a75f..f78820cfc7 100644 --- a/p2p/src/peer_manager/peerdb/storage_load.rs +++ b/p2p/src/peer_manager/peerdb/storage_load.rs @@ -87,8 +87,11 @@ impl LoadedStorage { // Rewrite any rows written by an older version under the mapped spelling of an // IPv4 address, so the stored keys match the canonical form used at runtime. let mut write_tx = storage.transaction_rw()?; - write_tx.normalize_legacy_mapped_address_keys()?; - write_tx.commit()?; + // Commit only when rows were actually rewritten, so the common case of an + // already-canonical database stays read-only. + if write_tx.normalize_legacy_mapped_address_keys()? { + write_tx.commit()?; + } } let tx = storage.transaction_ro()?; diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index 49fc0e86e6..cd7355e523 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -19,9 +19,27 @@ use std::{ str::FromStr, }; -#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, rpc_description::HasValueHint)] +#[derive( + Debug, + Copy, + Clone, + PartialEq, + Eq, + PartialOrd, + Ord, + serde::Serialize, + rpc_description::HasValueHint, +)] pub struct BannableAddress(IpAddr); +// Deserialize routes through `new` so that persisted bans stored in the mapped form are +// treated as their plain IPv4 form when loaded. +impl<'de> serde::Deserialize<'de> for BannableAddress { + fn deserialize>(deserializer: D) -> Result { + IpAddr::deserialize(deserializer).map(BannableAddress::new) + } +} + impl BannableAddress { pub fn new(ip: IpAddr) -> Self { // IPv4-mapped IPv6 addresses are reduced to the embedded IPv4 form so that a mapped @@ -34,20 +52,6 @@ impl BannableAddress { } } -impl serde::Serialize for BannableAddress { - fn serialize(&self, serializer: S) -> Result { - self.0.serialize(serializer) - } -} - -// Deserialize routes through `new` so that persisted bans stored in the mapped form are -// treated as their plain IPv4 form when loaded. -impl<'de> serde::Deserialize<'de> for BannableAddress { - fn deserialize>(deserializer: D) -> Result { - IpAddr::deserialize(deserializer).map(BannableAddress::new) - } -} - impl Display for BannableAddress { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { std::fmt::Display::fmt(&self.0, f) From 09ec47b08706712d299fb34403b6a300d394e8f6 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:10:19 +0200 Subject: [PATCH 14/26] docs(p2p): spell out the legacy fold contract and pin its return value --- p2p/src/peer_manager/peerdb/storage.rs | 7 +++++ p2p/src/peer_manager/peerdb/tests.rs | 40 ++++++++++++++++++++++++++ p2p/types/src/bannable_address.rs | 2 ++ 3 files changed, 49 insertions(+) diff --git a/p2p/src/peer_manager/peerdb/storage.rs b/p2p/src/peer_manager/peerdb/storage.rs index e6d0354d58..7e1a3a4c41 100644 --- a/p2p/src/peer_manager/peerdb/storage.rs +++ b/p2p/src/peer_manager/peerdb/storage.rs @@ -75,6 +75,13 @@ pub trait PeerDbStorageWrite { /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so /// that they are keyed by the canonical form; without this, removals would miss the /// legacy rows and bans could resurrect after a restart. + /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so + /// that they are keyed by the canonical form; without this, removals would miss the + /// legacy rows and bans could resurrect after a restart. + /// + /// Returns whether any rows were rewritten. A `false` result guarantees no writes were + /// staged on this transaction, so callers may skip committing it; a `true` result means + /// the transaction must be committed for the rewrites to take effect. fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result; } diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 14a72f608e..49d98476b0 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1089,6 +1089,46 @@ fn tried_addr_table(peerdb: &PeerDb) -> &Table { peerdb.address_tables.tried_addr_table() } +#[rstest] +#[trace] +#[case(Seed::from_entropy())] +fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: Seed) { + use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; + use crate::peer_manager::peerdb::storage_impl::DBKnownAddresses; + use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; + use crate::peer_manager::peerdb_common::storage::TransactionRw as _; + + let mut rng = make_seedable_rng(seed); + let db_store = peerdb_inmemory_store(); + + { + let mut tx = db_store.transaction_rw().unwrap(); + tx.set_version(CURRENT_STORAGE_VERSION).unwrap(); + tx.set_salt(Salt::new_random(&mut rng)).unwrap(); + tx.commit().unwrap(); + } + + // A store whose keys are already canonical needs no rewrites, so the caller can + // skip committing the migration transaction. + { + let mut tx = db_store.transaction_rw().unwrap(); + assert!(!tx.normalize_legacy_mapped_address_keys().unwrap()); + } + + { + let mut tx = db_store.transaction_rw().unwrap(); + let mut known = tx.storage().get_mut::(); + known.put("[::ffff:1.2.3.4]:12345".to_owned(), KnownAddressState::New).unwrap(); + tx.commit().unwrap(); + } + + { + let mut tx = db_store.transaction_rw().unwrap(); + assert!(tx.normalize_legacy_mapped_address_keys().unwrap()); + tx.commit().unwrap(); + } +} + #[rstest] #[trace] #[case(Seed::from_entropy())] diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index cd7355e523..653576efe1 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -30,6 +30,8 @@ use std::{ serde::Serialize, rpc_description::HasValueHint, )] +// Note: the derived newtype `Serialize` delegates to the inner `IpAddr`, matching the +// manual `Deserialize` below; keep the two impls wire-compatible if this changes. pub struct BannableAddress(IpAddr); // Deserialize routes through `new` so that persisted bans stored in the mapped form are From 5f9f1669dc56ce3d8a4bcdaf5be087cc8b810eda Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:13:37 +0200 Subject: [PATCH 15/26] docs(p2p): deduplicate the legacy fold contract and pin its commit --- p2p/src/peer_manager/peerdb/storage.rs | 3 --- p2p/src/peer_manager/peerdb/tests.rs | 10 ++++++++++ 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage.rs b/p2p/src/peer_manager/peerdb/storage.rs index 7e1a3a4c41..20f6df260e 100644 --- a/p2p/src/peer_manager/peerdb/storage.rs +++ b/p2p/src/peer_manager/peerdb/storage.rs @@ -72,9 +72,6 @@ pub trait PeerDbStorageWrite { fn add_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; fn del_anchor_address(&mut self, address: &SocketAddress) -> crate::Result<()>; - /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so - /// that they are keyed by the canonical form; without this, removals would miss the - /// legacy rows and bans could resurrect after a restart. /// Rewrite rows keyed by the mapped spelling (`::ffff:a.b.c.d`) of an IPv4 address so /// that they are keyed by the canonical form; without this, removals would miss the /// legacy rows and bans could resurrect after a restart. diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 49d98476b0..90c0b670ef 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1127,6 +1127,16 @@ fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: assert!(tx.normalize_legacy_mapped_address_keys().unwrap()); tx.commit().unwrap(); } + + // Pin the other half of the contract: committing a `true` result persists the rewrite. + { + use storage::MakeMapRef as _; + + let tx = db_store.transaction_ro().unwrap(); + let known = tx.storage().get::(); + assert!(known.get("[::ffff:1.2.3.4]:12345").unwrap().is_none()); + assert!(known.get("1.2.3.4:12345").unwrap().is_some()); + } } #[rstest] From a7c9b45d67df009c8cf1f5f94f92152c99f6a61a Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:25:35 +0200 Subject: [PATCH 16/26] docs(p2p): note the grouping of other ipv4-embedded encodings --- p2p/src/peer_manager/address_groups.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 2194c8b394..b979a4db08 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -42,7 +42,10 @@ impl AddressGroup { // PeerAddress is not canonicalized at construction, so the reduction to the // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run // on the canonical form. Note that other IPv4-embedded encodings (6to4, Teredo, - // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6. + // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6: 6to4 + // keeps per-/32 granularity through its leading bytes, while all Teredo + // (2001:0::/32) and all NAT64 (64:ff9b::/96) addresses share one group bucket + // each, unlike the Teredo-aware grouping of Bitcoin Core's GetGroup. let std_ip = canonical_ip_addr(std::net::SocketAddr::from(address).ip()); if std_ip.is_global_unicast_ip() { match std_ip { From e92126c52db054a3f8025356c52c0f1bfa981f77 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:30:50 +0200 Subject: [PATCH 17/26] docs(p2p): complete the embedded encoding grouping note --- p2p/src/peer_manager/address_groups.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index b979a4db08..9565a89183 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -43,9 +43,11 @@ impl AddressGroup { // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run // on the canonical form. Note that other IPv4-embedded encodings (6to4, Teredo, // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6: 6to4 - // keeps per-/32 granularity through its leading bytes, while all Teredo - // (2001:0::/32) and all NAT64 (64:ff9b::/96) addresses share one group bucket - // each, unlike the Teredo-aware grouping of Bitcoin Core's GetGroup. + // keeps IPv6-style /32 granularity, though only the first two octets of the + // embedded IPv4 fall inside the key, while all Teredo (2001:0::/32), all NAT64 + // (64:ff9b::/96), and all IPv4-translated (::ffff:0:0:0/96) addresses collapse + // into one group bucket each (the all-zero one for IPv4-translated), unlike the + // Teredo-aware grouping of Bitcoin Core's GetGroup. let std_ip = canonical_ip_addr(std::net::SocketAddr::from(address).ip()); if std_ip.is_global_unicast_ip() { match std_ip { From 41009f7272e43bbf1211f62d723bbacea8a99050 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:47:44 +0200 Subject: [PATCH 18/26] test(p2p): pin the canonical ip helper and both merge directions --- p2p/src/peer_manager/peerdb/tests.rs | 21 ++++++++++++++++++++- p2p/types/src/peer_address.rs | 16 ++++++++++++---- 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 90c0b670ef..a25321fa49 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1094,7 +1094,7 @@ fn tried_addr_table(peerdb: &PeerDb) -> &Table { #[case(Seed::from_entropy())] fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: Seed) { use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; - use crate::peer_manager::peerdb::storage_impl::DBKnownAddresses; + use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; use crate::peer_manager::peerdb_common::storage::TransactionRw as _; @@ -1119,6 +1119,10 @@ fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: let mut tx = db_store.transaction_rw().unwrap(); let mut known = tx.storage().get_mut::(); known.put("[::ffff:1.2.3.4]:12345".to_owned(), KnownAddressState::New).unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)) + .unwrap(); tx.commit().unwrap(); } @@ -1136,6 +1140,9 @@ fn normalize_legacy_mapped_address_keys_reports_whether_it_rewrote(#[case] seed: let known = tx.storage().get::(); assert!(known.get("[::ffff:1.2.3.4]:12345").unwrap().is_none()); assert!(known.get("1.2.3.4:12345").unwrap().is_some()); + let banned = tx.storage().get::(); + assert!(banned.get("::ffff:1.2.3.4").unwrap().is_none()); + assert!(banned.get("1.2.3.4").unwrap().is_some()); } } @@ -1231,6 +1238,8 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { ) .unwrap(); known.put("1.2.3.4:12345".to_owned(), KnownAddressState::New).unwrap(); + known.put("[::ffff:9.9.9.9]:9999".to_owned(), KnownAddressState::New).unwrap(); + known.put("9.9.9.9:9999".to_owned(), KnownAddressState::Tried).unwrap(); let mut banned = tx.storage().get_mut::(); banned.put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)).unwrap(); @@ -1254,6 +1263,16 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { .decode(), KnownAddressState::Tried ); + // The reverse pair: a legacy New row must not downgrade a canonical Tried row. + assert_eq!( + tx.storage() + .get::() + .get("9.9.9.9:9999") + .unwrap() + .unwrap() + .decode(), + KnownAddressState::Tried + ); assert!( tx.storage() .get::() diff --git a/p2p/types/src/peer_address.rs b/p2p/types/src/peer_address.rs index 160463a359..960eed99d7 100644 --- a/p2p/types/src/peer_address.rs +++ b/p2p/types/src/peer_address.rs @@ -48,14 +48,22 @@ pub enum PeerAddress { } impl PeerAddress { - pub fn is_loopback(&self) -> bool { + /// The IP address in canonical form, where an IPv4-mapped IPv6 address + /// (`::ffff:a.b.c.d`) is reduced to the embedded IPv4 address. + /// + /// Note that the derived `Eq`/`Hash`/`Ord` still distinguish the mapped form from + /// the plain form, because the wire format keeps them distinct; deduplicate or key + /// collections on this canonical value rather than on the address itself. + pub fn canonical_ip(&self) -> std::net::IpAddr { crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) - .is_loopback() + } + + pub fn is_loopback(&self) -> bool { + self.canonical_ip().is_loopback() } pub fn is_global_unicast_ip(&self) -> bool { - crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) - .is_global_unicast_ip() + self.canonical_ip().is_global_unicast_ip() } /// If the address is eligible for being sent to peers via AddrListResponse, return Some, From 51034eec44be5fa9980e7a3bda7f8aa45372cfad Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 21:51:28 +0200 Subject: [PATCH 19/26] docs(p2p): correct the equality note on the canonical ip helper --- p2p/types/src/peer_address.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/p2p/types/src/peer_address.rs b/p2p/types/src/peer_address.rs index 960eed99d7..38a73897bb 100644 --- a/p2p/types/src/peer_address.rs +++ b/p2p/types/src/peer_address.rs @@ -51,9 +51,9 @@ impl PeerAddress { /// The IP address in canonical form, where an IPv4-mapped IPv6 address /// (`::ffff:a.b.c.d`) is reduced to the embedded IPv4 address. /// - /// Note that the derived `Eq`/`Hash`/`Ord` still distinguish the mapped form from - /// the plain form, because the wire format keeps them distinct; deduplicate or key - /// collections on this canonical value rather than on the address itself. + /// Note that the derived `Eq` still distinguishes the mapped form from the plain + /// form, because the wire format keeps them distinct; deduplicate or key collections + /// on this canonical value rather than on the address itself. pub fn canonical_ip(&self) -> std::net::IpAddr { crate::socket_address::canonical_ip_addr(std::net::SocketAddr::from(self).ip()) } From 2c85f1e040e87e653aef3686e2222f2ab0758264 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 22:06:05 +0200 Subject: [PATCH 20/26] refactor(p2p): single-source the canonical ip of a peer address --- p2p/src/peer_manager/address_groups.rs | 6 ++---- p2p/src/peer_manager/peerdb/storage_load.rs | 2 ++ p2p/types/src/bannable_address.rs | 5 ++--- 3 files changed, 6 insertions(+), 7 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 9565a89183..13fa356291 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -13,9 +13,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use crate::types::{ - global_ip::IsGlobalIp, peer_address::PeerAddress, socket_address::canonical_ip_addr, -}; +use crate::types::{global_ip::IsGlobalIp, peer_address::PeerAddress}; // IPv4 addresses grouped into /16 subnets pub const IPV4_GROUP_BYTES: usize = 2; @@ -48,7 +46,7 @@ impl AddressGroup { // (64:ff9b::/96), and all IPv4-translated (::ffff:0:0:0/96) addresses collapse // into one group bucket each (the all-zero one for IPv4-translated), unlike the // Teredo-aware grouping of Bitcoin Core's GetGroup. - let std_ip = canonical_ip_addr(std::net::SocketAddr::from(address).ip()); + let std_ip = address.canonical_ip(); if std_ip.is_global_unicast_ip() { match std_ip { std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( diff --git a/p2p/src/peer_manager/peerdb/storage_load.rs b/p2p/src/peer_manager/peerdb/storage_load.rs index f78820cfc7..5b696f9db5 100644 --- a/p2p/src/peer_manager/peerdb/storage_load.rs +++ b/p2p/src/peer_manager/peerdb/storage_load.rs @@ -92,6 +92,8 @@ impl LoadedStorage { if write_tx.normalize_legacy_mapped_address_keys()? { write_tx.commit()?; } + // TODO: once older databases are no longer expected, replace this + // every-startup scan with a one-time versioned migration or a stored marker. } let tx = storage.transaction_ro()?; diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index 653576efe1..a6f7abb315 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -78,9 +78,8 @@ mod bannable_address_tests { let mapped: IpAddr = "::ffff:1.2.3.4".parse().unwrap(); assert_eq!(BannableAddress::new(mapped), BannableAddress::new(plain)); - // The serde round trip folds the mapped form into the plain form. - let serialized = serde_json::to_string(&BannableAddress::new(mapped)).unwrap(); - let deserialized: BannableAddress = serde_json::from_str(&serialized).unwrap(); + // Deserializing the mapped spelling directly must fold it to the plain form. + let deserialized: BannableAddress = serde_json::from_str("\"::ffff:1.2.3.4\"").unwrap(); assert_eq!(deserialized, BannableAddress::new(plain)); } } From 34da41bfe79523534341f9ef62696b85af308ba1 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 22:22:17 +0200 Subject: [PATCH 21/26] fix(p2p): harden the address fold paths and their documentation --- p2p/src/peer_manager/address_groups.rs | 16 ++++--- p2p/src/peer_manager/peerdb/storage_impl.rs | 16 ++++++- p2p/src/peer_manager/peerdb/tests.rs | 50 ++++++++++++++++++++- p2p/types/src/bannable_address.rs | 2 +- p2p/types/src/socket_address.rs | 6 ++- 5 files changed, 79 insertions(+), 11 deletions(-) diff --git a/p2p/src/peer_manager/address_groups.rs b/p2p/src/peer_manager/address_groups.rs index 13fa356291..309295c6f2 100644 --- a/p2p/src/peer_manager/address_groups.rs +++ b/p2p/src/peer_manager/address_groups.rs @@ -41,19 +41,23 @@ impl AddressGroup { // embedded IPv4 form of an IPv4-mapped IPv6 address happens here. All checks run // on the canonical form. Note that other IPv4-embedded encodings (6to4, Teredo, // NAT64, IPv4-translated) are not reduced and are grouped as plain IPv6: 6to4 - // keeps IPv6-style /32 granularity, though only the first two octets of the - // embedded IPv4 fall inside the key, while all Teredo (2001:0::/32), all NAT64 + // keeps per-/16 granularity of the embedded IPv4 (the key holds the 6to4 prefix + // plus its first two octets), while all Teredo (2001:0::/32), all NAT64 // (64:ff9b::/96), and all IPv4-translated (::ffff:0:0:0/96) addresses collapse - // into one group bucket each (the all-zero one for IPv4-translated), unlike the - // Teredo-aware grouping of Bitcoin Core's GetGroup. + // into one group bucket each, unlike the Teredo-aware grouping of Bitcoin + // Core's GetGroup. let std_ip = address.canonical_ip(); if std_ip.is_global_unicast_ip() { match std_ip { std::net::IpAddr::V4(ip) => AddressGroup::PublicV4( - ip.octets()[0..IPV4_GROUP_BYTES].try_into().expect("must be valid"), + ip.octets()[0..IPV4_GROUP_BYTES] + .try_into() + .expect("group size must not exceed the IPv4 octet count"), ), std::net::IpAddr::V6(ip) => AddressGroup::PublicV6( - ip.octets()[0..IPV6_GROUP_BYTES].try_into().expect("must be valid"), + ip.octets()[0..IPV6_GROUP_BYTES] + .try_into() + .expect("group size must not exceed the IPv6 octet count"), ), } } else if std_ip.is_loopback() { diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 412ce7f966..441a18579e 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -132,7 +132,13 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { if !(key.starts_with("[::ffff:") || key.starts_with("::ffff:")) { return None; } - let address = key.parse::().ok()?; + let address = match key.parse::() { + Ok(address) => address, + Err(err) => { + logging::log::warn!("Skipping unparseable legacy address key {key}: {err}"); + return None; + } + }; let canonical = address.to_string(); (canonical != key).then_some(canonical) } @@ -141,7 +147,13 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { if !key.starts_with("::ffff:") { return None; } - let address = key.parse::().ok()?; + let address = match key.parse::() { + Ok(address) => address, + Err(err) => { + logging::log::warn!("Skipping unparseable legacy ban key {key}: {err}"); + return None; + } + }; let canonical = address.to_string(); (canonical != key).then_some(canonical) } diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index a25321fa49..6b5e75d40e 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1217,7 +1217,9 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { #[case(Seed::from_entropy())] fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { use crate::peer_manager::peerdb::storage::PeerDbStorageWrite; - use crate::peer_manager::peerdb::storage_impl::{DBBannedAddresses, DBKnownAddresses}; + use crate::peer_manager::peerdb::storage_impl::{ + DBAnchorAddresses, DBBannedAddresses, DBDiscouragedAddresses, DBKnownAddresses, + }; use crate::peer_manager::peerdb::storage_load::LoadedStorage; use crate::peer_manager::peerdb::{CURRENT_STORAGE_VERSION, salt::Salt}; use crate::peer_manager::peerdb_common::storage::TransactionRw as _; @@ -1246,6 +1248,19 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { banned.put("1.2.3.4".to_owned(), Duration::from_secs(120)).unwrap(); banned.put("::ffff:5.6.7.8".to_owned(), Duration::from_secs(300)).unwrap(); banned.put("5.6.7.8".to_owned(), Duration::from_secs(120)).unwrap(); + + tx.storage() + .get_mut::() + .put("::ffff:7.7.7.7".to_owned(), Duration::from_secs(30)) + .unwrap(); + tx.storage() + .get_mut::() + .put("7.7.7.7".to_owned(), Duration::from_secs(90)) + .unwrap(); + tx.storage() + .get_mut::() + .put("[::ffff:8.8.8.8]:8888".to_owned(), ()) + .unwrap(); tx.commit().unwrap(); } @@ -1315,4 +1330,37 @@ fn folded_legacy_rows_merge_with_canonical_ones(#[case] seed: Seed) { .unwrap() .is_none() ); + + // Discouraged rows follow the same later-time-wins rule, and anchor rows keep + // their unit value when folded. + assert_eq!( + tx.storage() + .get::() + .get("7.7.7.7") + .unwrap() + .unwrap() + .decode(), + Duration::from_secs(90) + ); + assert!( + tx.storage() + .get::() + .get("::ffff:7.7.7.7") + .unwrap() + .is_none() + ); + assert!( + tx.storage() + .get::() + .get("8.8.8.8:8888") + .unwrap() + .is_some() + ); + assert!( + tx.storage() + .get::() + .get("[::ffff:8.8.8.8]:8888") + .unwrap() + .is_none() + ); } diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index a6f7abb315..ae18bf1c8f 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -38,7 +38,7 @@ pub struct BannableAddress(IpAddr); // treated as their plain IPv4 form when loaded. impl<'de> serde::Deserialize<'de> for BannableAddress { fn deserialize>(deserializer: D) -> Result { - IpAddr::deserialize(deserializer).map(BannableAddress::new) + ::deserialize(deserializer).map(BannableAddress::new) } } diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index 90abd0d002..09ade02fef 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -41,7 +41,7 @@ pub struct SocketAddress(SocketAddr); // matching the wire format of the derived `Serialize`. impl<'de> serde::Deserialize<'de> for SocketAddress { fn deserialize>(deserializer: D) -> Result { - SocketAddr::deserialize(deserializer).map(SocketAddress::new) + ::deserialize(deserializer).map(SocketAddress::new) } } @@ -88,6 +88,10 @@ pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { /// Same reduction as [`canonical_ip_addr`], applied to a socket address including its port. /// Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are preserved. +/// Same reduction as [`canonical_ip_addr`], applied to a socket address including its +/// port. Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are +/// preserved. Mapped forms are assumed to carry zero flowinfo/scope id (as RFC 4291 +/// requires), which are then dropped along with the IPv6 framing. pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { match addr { SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { From cbdd4111d9632f9002f4f925e451a48c0471dd1f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sat, 17 Oct 2026 22:29:34 +0200 Subject: [PATCH 22/26] fix(p2p): name the affected table in the legacy fold warnings --- p2p/src/peer_manager/peerdb/storage_impl.rs | 22 +++++++++++++-------- p2p/types/src/socket_address.rs | 2 -- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 441a18579e..6f67d12cce 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -126,7 +126,7 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result { - fn canonical_socket_address_spelling(key: &str) -> Option { + fn canonical_socket_address_spelling(table: &'static str, key: &str) -> Option { // Mapped spellings always start with one of these prefixes, so the parse can // be skipped for the vast majority of keys on every startup. if !(key.starts_with("[::ffff:") || key.starts_with("::ffff:")) { @@ -135,7 +135,10 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { let address = match key.parse::() { Ok(address) => address, Err(err) => { - logging::log::warn!("Skipping unparseable legacy address key {key}: {err}"); + logging::log::warn!( + "Skipping unparseable legacy {table} key {key}: {err}; reading the \ + table keeps failing until the row is removed" + ); return None; } }; @@ -143,14 +146,17 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { (canonical != key).then_some(canonical) } - fn canonical_bannable_address_spelling(key: &str) -> Option { + fn canonical_bannable_address_spelling(table: &'static str, key: &str) -> Option { if !key.starts_with("::ffff:") { return None; } let address = match key.parse::() { Ok(address) => address, Err(err) => { - logging::log::warn!("Skipping unparseable legacy ban key {key}: {err}"); + logging::log::warn!( + "Skipping unparseable legacy {table} key {key}: {err}; reading the \ + table keeps failing until the row is removed" + ); return None; } }; @@ -163,7 +169,7 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - canonical_socket_address_spelling, + |key| canonical_socket_address_spelling("known addresses", key), // A legacy row marked as tried keeps its selection preference. |canonical, legacy| match legacy { KnownAddressState::Tried => legacy, @@ -172,18 +178,18 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - canonical_socket_address_spelling, + |key| canonical_socket_address_spelling("anchor addresses", key), |_canonical, _legacy| (), )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - canonical_bannable_address_spelling, + |key| canonical_bannable_address_spelling("banned addresses", key), // The later unban time wins so the migration cannot shorten a ban. std::cmp::max, )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - canonical_bannable_address_spelling, + |key| canonical_bannable_address_spelling("discouraged addresses", key), std::cmp::max, )?; diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index 09ade02fef..9e78c7dca7 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -86,8 +86,6 @@ pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { } } -/// Same reduction as [`canonical_ip_addr`], applied to a socket address including its port. -/// Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are preserved. /// Same reduction as [`canonical_ip_addr`], applied to a socket address including its /// port. Non-mapped IPv6 addresses are returned untouched, so flowinfo and scope id are /// preserved. Mapped forms are assumed to carry zero flowinfo/scope id (as RFC 4291 From ab7cc9eb027112c160755cb59c5059228b8aafe7 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 18 Oct 2026 12:15:44 +0200 Subject: [PATCH 23/26] fix(p2p): pin mapped address assumptions and refine the key scan --- p2p/src/peer_manager/peerdb/storage_impl.rs | 15 ++++++++------- p2p/types/src/socket_address.rs | 9 ++++++++- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 6f67d12cce..7c9b02dd48 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -127,17 +127,18 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result { fn canonical_socket_address_spelling(table: &'static str, key: &str) -> Option { - // Mapped spellings always start with one of these prefixes, so the parse can - // be skipped for the vast majority of keys on every startup. - if !(key.starts_with("[::ffff:") || key.starts_with("::ffff:")) { + // Socket addresses were persisted through their Display form, which brackets + // IPv6 addresses, so the parse can be skipped for the vast majority of keys + // on every startup. + if !key.starts_with("[::ffff:") { return None; } let address = match key.parse::() { Ok(address) => address, Err(err) => { logging::log::warn!( - "Skipping unparseable legacy {table} key {key}: {err}; reading the \ - table keeps failing until the row is removed" + "Skipping unparseable legacy {table} key {key}: {err}; the row is \ + ignored until it is removed" ); return None; } @@ -154,8 +155,8 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { Ok(address) => address, Err(err) => { logging::log::warn!( - "Skipping unparseable legacy {table} key {key}: {err}; reading the \ - table keeps failing until the row is removed" + "Skipping unparseable legacy {table} key {key}: {err}; the row is \ + ignored until it is removed" ); return None; } diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index 9e78c7dca7..8ac49f0879 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -93,7 +93,14 @@ pub fn canonical_ip_addr(ip: IpAddr) -> IpAddr { pub fn canonical_socket_address(addr: SocketAddr) -> SocketAddr { match addr { SocketAddr::V6(v6) => match v6.ip().to_ipv4_mapped() { - Some(v4) => SocketAddr::new(IpAddr::V4(v4), v6.port()), + Some(v4) => { + debug_assert_eq!( + (v6.flowinfo(), v6.scope_id()), + (0, 0), + "mapped IPv4 form is assumed to carry no flowinfo or scope id", + ); + SocketAddr::new(IpAddr::V4(v4), v6.port()) + } None => SocketAddr::V6(v6), }, v4 => v4, From 5633ad65d0d2493394cbc75a8b07cf627ec3581c Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 18 Oct 2026 15:35:50 +0200 Subject: [PATCH 24/26] docs(p2p): note that ban keys normalize on serialization --- p2p/types/src/bannable_address.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/p2p/types/src/bannable_address.rs b/p2p/types/src/bannable_address.rs index ae18bf1c8f..5f991e2e7b 100644 --- a/p2p/types/src/bannable_address.rs +++ b/p2p/types/src/bannable_address.rs @@ -35,7 +35,10 @@ use std::{ pub struct BannableAddress(IpAddr); // Deserialize routes through `new` so that persisted bans stored in the mapped form are -// treated as their plain IPv4 form when loaded. +// treated as their plain IPv4 form when loaded. Serialization round-trips are therefore +// normalizing, not identity-preserving: a value serialized as `"::ffff:a.b.c.d"` reads +// back as `a.b.c.d`. This is intended (one address, one ban key), but any serialized +// consumer of this type sees the canonical form after a round-trip. impl<'de> serde::Deserialize<'de> for BannableAddress { fn deserialize>(deserializer: D) -> Result { ::deserialize(deserializer).map(BannableAddress::new) From 80caa413c04f53c270cf0307ad782b16c9fad12f Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 18 Oct 2026 18:12:51 +0200 Subject: [PATCH 25/26] fix(p2p): drop unparseable legacy mapped rows on peer db load --- p2p/src/peer_manager/peerdb/storage_impl.rs | 102 +++++++++++--------- p2p/src/peer_manager/peerdb/tests.rs | 16 +++ p2p/types/src/socket_address.rs | 1 - 3 files changed, 75 insertions(+), 44 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 7c9b02dd48..3093c4d0a2 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -126,43 +126,37 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } fn normalize_legacy_mapped_address_keys(&mut self) -> crate::Result { - fn canonical_socket_address_spelling(table: &'static str, key: &str) -> Option { - // Socket addresses were persisted through their Display form, which brackets - // IPv6 addresses, so the parse can be skipped for the vast majority of keys - // on every startup. - if !key.starts_with("[::ffff:") { - return None; + fn canonical_spelling( + table: &'static str, + key: &str, + prefix: &str, + ) -> LegacyKeyAction + where + ::Err: std::fmt::Display, + { + // Legacy mapped spellings always start with the prefix, so the parse can + // be skipped for the vast majority of keys on every startup. Socket + // addresses were persisted through their Display form, which brackets + // IPv6 addresses. + if !key.starts_with(prefix) { + return LegacyKeyAction::Keep; } - let address = match key.parse::() { + let address = match key.parse::() { Ok(address) => address, Err(err) => { logging::log::warn!( - "Skipping unparseable legacy {table} key {key}: {err}; the row is \ - ignored until it is removed" + "Dropping unparseable legacy {table} key {key}: {err}; the row \ + cannot be read by this version" ); - return None; + return LegacyKeyAction::Drop; } }; let canonical = address.to_string(); - (canonical != key).then_some(canonical) - } - - fn canonical_bannable_address_spelling(table: &'static str, key: &str) -> Option { - if !key.starts_with("::ffff:") { - return None; + if canonical == key { + LegacyKeyAction::Keep + } else { + LegacyKeyAction::Rewrite(canonical) } - let address = match key.parse::() { - Ok(address) => address, - Err(err) => { - logging::log::warn!( - "Skipping unparseable legacy {table} key {key}: {err}; the row is \ - ignored until it is removed" - ); - return None; - } - }; - let canonical = address.to_string(); - (canonical != key).then_some(canonical) } let storage = self.storage(); @@ -170,7 +164,7 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_socket_address_spelling("known addresses", key), + |key| canonical_spelling::("known addresses", key, "[::ffff:"), // A legacy row marked as tried keeps its selection preference. |canonical, legacy| match legacy { KnownAddressState::Tried => legacy, @@ -179,18 +173,18 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_socket_address_spelling("anchor addresses", key), + |key| canonical_spelling::("anchor addresses", key, "[::ffff:"), |_canonical, _legacy| (), )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_bannable_address_spelling("banned addresses", key), + |key| canonical_spelling::("banned addresses", key, "::ffff:"), // The later unban time wins so the migration cannot shorten a ban. std::cmp::max, )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_bannable_address_spelling("discouraged addresses", key), + |key| canonical_spelling::("discouraged addresses", key, "::ffff:"), std::cmp::max, )?; @@ -198,13 +192,25 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } } +/// What to do with a key during the legacy fold. +enum LegacyKeyAction { + // Not a legacy mapped-form key; leave the row alone. + Keep, + // A legacy mapped-form key that no longer parses; drop the row. It cannot + // be loaded by the regular read path either, so keeping it would fail the + // startup load on every run. + Drop, + // Rewrite the row under the canonical spelling of the same address. + Rewrite(String), +} + /// Rewrite rows keyed by a non-canonical spelling of an IPv4 address (the mapped form /// `::ffff:a.b.c.d` written by an older version) so that the key matches the canonical /// spelling used at runtime, merging the value with an existing canonical row via -/// `merge` when one is already present. +/// `merge` when one is already present. Rows whose key no longer parses are dropped. fn rekey_mapped_rows( map: &mut storage::MapMut<'_, storage::TransactionRw<'_, B, Schema>, DBM>, - canonical_spelling: impl Fn(&str) -> Option, + key_action: impl Fn(&str) -> LegacyKeyAction, merge: impl Fn(DBM::Value, DBM::Value) -> DBM::Value, ) -> crate::Result where @@ -213,19 +219,29 @@ where DBM::Value: serialization::Encode + serialization::EncodeLike, { let mut rewrites = Vec::new(); + let mut drops = Vec::new(); for (key, encoded) in map.prefix_iter(&())? { - if let Some(canonical) = canonical_spelling(&key) { - // Decode explicitly so a corrupt row is reported instead of panicking. - let value = ::decode_all(&mut encoded.as_ref()) - .map_err(|err| { - P2pError::InvalidStorageState(format!( - "Failed to decode the value of the row keyed by {key}: {err}" - )) - })?; - rewrites.push((key, canonical, value)); + match key_action(&key) { + LegacyKeyAction::Keep => {} + LegacyKeyAction::Drop => drops.push(key), + LegacyKeyAction::Rewrite(canonical) => { + // Decode explicitly so a corrupt row is reported instead of panicking. + let value = + ::decode_all(&mut encoded.as_ref()) + .map_err(|err| { + P2pError::InvalidStorageState(format!( + "Failed to decode the value of the row keyed by {key}: {err}" + )) + })?; + rewrites.push((key, canonical, value)); + } } } let mut rewrote_any = false; + for legacy_key in drops { + map.del(&legacy_key)?; + rewrote_any = true; + } for (legacy_key, canonical_key, legacy_value) in rewrites { map.del(&legacy_key)?; let value = match map.get(&canonical_key)? { diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 6b5e75d40e..59b85be730 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1175,6 +1175,15 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { .get_mut::() .put("::ffff:1.2.3.4".to_owned(), Duration::from_secs(60)) .unwrap(); + // A legacy mapped key that no longer parses cannot be loaded by the regular + // read path either; the fold must drop it instead of failing every startup. + tx.storage() + .get_mut::() + .put( + "[::ffff:not-an-address]:12345".to_owned(), + KnownAddressState::New, + ) + .unwrap(); tx.commit().unwrap(); } @@ -1189,6 +1198,13 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { .unwrap() .is_none() ); + assert!( + tx.storage() + .get::() + .get("[::ffff:not-an-address]:12345") + .unwrap() + .is_none() + ); assert!( tx.storage() .get::() diff --git a/p2p/types/src/socket_address.rs b/p2p/types/src/socket_address.rs index 8ac49f0879..98b1017575 100644 --- a/p2p/types/src/socket_address.rs +++ b/p2p/types/src/socket_address.rs @@ -130,7 +130,6 @@ impl From for SocketAddress { #[cfg(test)] mod mapped_address_tests { use super::*; - use crate::bannable_address::BannableAddress; use std::net::SocketAddrV4; #[test] From 544a5dabe8c7c82a3af56714b215a4fbd4502762 Mon Sep 17 00:00:00 2001 From: nullPointerEnjoyer Date: Sun, 18 Oct 2026 18:19:39 +0200 Subject: [PATCH 26/26] refactor(p2p): name the legacy mapped key prefixes --- p2p/src/peer_manager/peerdb/storage_impl.rs | 35 ++++++++++++++++++--- p2p/src/peer_manager/peerdb/tests.rs | 11 +++++++ 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/p2p/src/peer_manager/peerdb/storage_impl.rs b/p2p/src/peer_manager/peerdb/storage_impl.rs index 3093c4d0a2..7b4587a77a 100644 --- a/p2p/src/peer_manager/peerdb/storage_impl.rs +++ b/p2p/src/peer_manager/peerdb/storage_impl.rs @@ -159,12 +159,21 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { } } + const MAPPED_SOCKET_ADDRESS_PREFIX: &str = "[::ffff:"; + const MAPPED_BANNABLE_ADDRESS_PREFIX: &str = "::ffff:"; + let storage = self.storage(); let mut rewrote_any = false; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_spelling::("known addresses", key, "[::ffff:"), + |key| { + canonical_spelling::( + "known addresses", + key, + MAPPED_SOCKET_ADDRESS_PREFIX, + ) + }, // A legacy row marked as tried keeps its selection preference. |canonical, legacy| match legacy { KnownAddressState::Tried => legacy, @@ -173,18 +182,36 @@ impl PeerDbStorageWrite for PeerDbStoreTxRw<'_, B> { )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_spelling::("anchor addresses", key, "[::ffff:"), + |key| { + canonical_spelling::( + "anchor addresses", + key, + MAPPED_SOCKET_ADDRESS_PREFIX, + ) + }, |_canonical, _legacy| (), )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_spelling::("banned addresses", key, "::ffff:"), + |key| { + canonical_spelling::( + "banned addresses", + key, + MAPPED_BANNABLE_ADDRESS_PREFIX, + ) + }, // The later unban time wins so the migration cannot shorten a ban. std::cmp::max, )?; rewrote_any |= rekey_mapped_rows( &mut storage.get_mut::(), - |key| canonical_spelling::("discouraged addresses", key, "::ffff:"), + |key| { + canonical_spelling::( + "discouraged addresses", + key, + MAPPED_BANNABLE_ADDRESS_PREFIX, + ) + }, std::cmp::max, )?; diff --git a/p2p/src/peer_manager/peerdb/tests.rs b/p2p/src/peer_manager/peerdb/tests.rs index 59b85be730..f8b06b6187 100644 --- a/p2p/src/peer_manager/peerdb/tests.rs +++ b/p2p/src/peer_manager/peerdb/tests.rs @@ -1184,6 +1184,10 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { KnownAddressState::New, ) .unwrap(); + tx.storage() + .get_mut::() + .put("::ffff:not-an-ip".to_owned(), Duration::from_secs(60)) + .unwrap(); tx.commit().unwrap(); } @@ -1205,6 +1209,13 @@ fn legacy_mapped_address_rows_are_folded_on_load(#[case] seed: Seed) { .unwrap() .is_none() ); + assert!( + tx.storage() + .get::() + .get("::ffff:not-an-ip") + .unwrap() + .is_none() + ); assert!( tx.storage() .get::()