irontide-session 1.0.1

BitTorrent session management: peers, torrents, and piece selection
Documentation
//! BEP 40: Canonical Peer Priority.
//!
//! Deterministic, symmetric priority function so that all peers in a swarm
//! agree on which connections are most important to keep.
//!
//! Reference: <https://www.bittorrent.org/beps/bep_0040.html>

use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};

use irontide_core::crc32c;

/// Compute BEP 40 canonical peer priority for a pair of IP addresses.
///
/// The result is deterministic and symmetric: `f(a, b) == f(b, a)`.
/// Higher values indicate more important connections that should be
/// retained preferentially during peer eviction.
///
/// # Algorithm
///
/// 1. Normalize: map IPv4 addresses to IPv4-mapped IPv6 (`::ffff:a.b.c.d`)
///    when the other address is IPv6, so mixed pairs use a common format.
/// 2. Mask to subnet: IPv4 → /24 (zero last octet), IPv6 → /48 (zero last 10 bytes).
/// 3. If both masked addresses are identical (same subnet), fall back to the
///    original unmasked addresses for greater differentiation.
/// 4. Order the two addresses numerically (smaller first).
/// 5. Concatenate into a stack-allocated buffer and compute CRC32C.
pub fn canonical_peer_priority(a: IpAddr, b: IpAddr) -> u32 {
    let (a_norm, b_norm) = normalize_pair(a, b);
    let (masked_a, masked_b) = mask_ips(a_norm, b_norm);

    // Determine which bytes to hash: masked (different subnets) or full (same subnet).
    let (left, right) = if masked_a == masked_b {
        // Same subnet — use full unmasked IPs for differentiation.
        order_ips(a_norm, b_norm)
    } else {
        order_ips(masked_a, masked_b)
    };

    // Stack buffer: worst case is two IPv6 addresses = 32 bytes.
    let mut buf = [0u8; 32];
    let len_a = write_ip_bytes(&left, &mut buf, 0);
    let len_b = write_ip_bytes(&right, &mut buf, len_a);

    crc32c(&buf[..len_a + len_b])
}

/// Normalize a pair of IP addresses so both are in the same address family.
///
/// If one is IPv4 and the other is IPv6, the IPv4 address is mapped to
/// `::ffff:a.b.c.d` so that both can be masked and compared with IPv6 rules.
/// If both are the same family, they are returned unchanged.
fn normalize_pair(a: IpAddr, b: IpAddr) -> (IpAddr, IpAddr) {
    match (a, b) {
        (IpAddr::V4(v4), IpAddr::V6(_)) => (IpAddr::V6(v4.to_ipv6_mapped()), b),
        (IpAddr::V6(_), IpAddr::V4(v4)) => (a, IpAddr::V6(v4.to_ipv6_mapped())),
        _ => (a, b),
    }
}

/// Apply subnet masking and same-subnet fallback per BEP 40.
///
/// - IPv4: mask to /24 (zero the last octet).
/// - IPv6: mask to /48 (zero the last 10 bytes).
fn mask_ips(a: IpAddr, b: IpAddr) -> (IpAddr, IpAddr) {
    (mask_single(a), mask_single(b))
}

/// Mask a single IP address to its subnet prefix.
fn mask_single(ip: IpAddr) -> IpAddr {
    match ip {
        IpAddr::V4(v4) => {
            let octets = v4.octets();
            IpAddr::V4(Ipv4Addr::new(octets[0], octets[1], octets[2], 0))
        }
        IpAddr::V6(v6) => {
            let octets = v6.octets();
            let mut masked = [0u8; 16];
            // /48 = keep first 6 bytes, zero the rest.
            masked[..6].copy_from_slice(&octets[..6]);
            IpAddr::V6(Ipv6Addr::from(masked))
        }
    }
}

/// Order two IP addresses numerically (smaller first) for deterministic hashing.
fn order_ips(a: IpAddr, b: IpAddr) -> (IpAddr, IpAddr) {
    let a_bytes = ip_to_bytes(a);
    let b_bytes = ip_to_bytes(b);
    if a_bytes <= b_bytes { (a, b) } else { (b, a) }
}

/// Convert an IP address to a fixed-width 16-byte representation for ordering.
///
/// IPv4 is zero-padded in positions 0..12 (matching IPv4-mapped IPv6 layout)
/// so that IPv4 and IPv6 addresses sort in a consistent namespace.
fn ip_to_bytes(ip: IpAddr) -> [u8; 16] {
    match ip {
        IpAddr::V4(v4) => {
            let mut buf = [0u8; 16];
            buf[12..16].copy_from_slice(&v4.octets());
            buf
        }
        IpAddr::V6(v6) => v6.octets(),
    }
}

/// Write the raw bytes of an IP address into `buf` at `offset` for hashing.
///
/// Unlike [`ip_to_bytes`] (which pads to 16 bytes for comparison), this writes
/// only the native octets: 4 bytes for IPv4, 16 bytes for IPv6. Returns the
/// number of bytes written.
fn write_ip_bytes(ip: &IpAddr, buf: &mut [u8], offset: usize) -> usize {
    match ip {
        IpAddr::V4(v4) => {
            let octets = v4.octets();
            buf[offset..offset + 4].copy_from_slice(&octets);
            4
        }
        IpAddr::V6(v6) => {
            let octets = v6.octets();
            buf[offset..offset + 16].copy_from_slice(&octets);
            16
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn ip(s: &str) -> IpAddr {
        s.parse().expect("valid test IP")
    }

    // ── Test 1: IPv4 different subnets (/24 masking applied) ────────

    #[test]
    fn priority_ipv4_different_subnets() {
        // 123.213.32.x and 98.76.54.x are different /24 subnets.
        // The last octet should be masked away, so .10 vs .11 in the
        // same /24 should produce the same priority against a different subnet.
        let p1 = canonical_peer_priority(ip("123.213.32.10"), ip("98.76.54.32"));
        let p2 = canonical_peer_priority(ip("123.213.32.99"), ip("98.76.54.1"));
        assert_eq!(
            p1, p2,
            "different last octets in different /24s should produce same priority"
        );
        assert_ne!(p1, 0, "priority should be non-zero");
    }

    // ── Test 2: IPv4 same subnet (full IPs used) ───────────────────

    #[test]
    fn priority_ipv4_same_subnet() {
        // Same /24 subnet → full IPs are used, so different last octets
        // should produce different priorities.
        let p1 = canonical_peer_priority(ip("10.0.0.1"), ip("10.0.0.2"));
        let p2 = canonical_peer_priority(ip("10.0.0.1"), ip("10.0.0.3"));
        assert_ne!(
            p1, p2,
            "same /24 should use full IPs, giving different priorities"
        );
    }

    // ── Test 3: IPv6 different subnets (/48 masking applied) ────────

    #[test]
    fn priority_ipv6_different_subnets() {
        // Different /48 prefixes. Bytes after the first 6 should be masked.
        let p1 = canonical_peer_priority(ip("2001:db8:1::1"), ip("2001:db8:2::1"));
        let p2 = canonical_peer_priority(ip("2001:db8:1::ffff"), ip("2001:db8:2::abcd"));
        assert_eq!(
            p1, p2,
            "different suffixes in different /48s should produce same priority"
        );
        assert_ne!(p1, 0);
    }

    // ── Test 4: IPv6 same subnet (full IPs used) ───────────────────

    #[test]
    fn priority_ipv6_same_subnet() {
        // Same /48 prefix → full IPs should be used.
        let p1 = canonical_peer_priority(ip("2001:db8:1::1"), ip("2001:db8:1::2"));
        let p2 = canonical_peer_priority(ip("2001:db8:1::1"), ip("2001:db8:1::3"));
        assert_ne!(
            p1, p2,
            "same /48 should use full IPs, giving different priorities"
        );
    }

    // ── Test 5: Symmetry ────────────────────────────────────────────

    #[test]
    fn priority_is_symmetric() {
        let pairs = [
            (ip("123.213.32.10"), ip("98.76.54.32")),
            (ip("10.0.0.1"), ip("10.0.0.2")),
            (ip("2001:db8:1::1"), ip("2001:db8:2::2")),
            (ip("192.168.1.1"), ip("::ffff:192.168.1.2")),
        ];
        for (a, b) in &pairs {
            assert_eq!(
                canonical_peer_priority(*a, *b),
                canonical_peer_priority(*b, *a),
                "priority must be symmetric for {a} and {b}"
            );
        }
    }

    // ── Test 6: Determinism ─────────────────────────────────────────

    #[test]
    fn priority_deterministic() {
        let a = ip("55.55.55.55");
        let b = ip("66.66.66.66");
        let first = canonical_peer_priority(a, b);
        for _ in 0..100 {
            assert_eq!(
                canonical_peer_priority(a, b),
                first,
                "must be deterministic"
            );
        }
    }

    // ── Test 7: Mixed v4/v6 ─────────────────────────────────────────

    #[test]
    fn priority_mixed_v4_v6() {
        let v4 = ip("192.168.1.1");
        let v6 = ip("2001:db8::1");
        let p = canonical_peer_priority(v4, v6);
        assert_ne!(p, 0, "mixed v4/v6 should produce a non-zero priority");
        // Symmetry must still hold for mixed pairs.
        assert_eq!(
            canonical_peer_priority(v4, v6),
            canonical_peer_priority(v6, v4),
        );
    }

    // ── Test 8: Known reference vectors ─────────────────────────────

    #[test]
    fn priority_known_vectors() {
        // Pinned regression values — computed once and verified against the
        // BEP 40 algorithm (subnet masking, numeric ordering, CRC32C).
        // Different /24: mask → [98,76,54,0] ++ [123,213,32,0] → CRC32C
        let a = ip("123.213.32.10");
        let b = ip("98.76.54.32");
        assert_eq!(canonical_peer_priority(a, b), 0xCDDE_2768);

        // Same /24: full IPs → [10,0,0,1] ++ [10,0,0,2] → CRC32C
        assert_eq!(
            canonical_peer_priority(ip("10.0.0.1"), ip("10.0.0.2")),
            0xCC62_1322,
        );
    }
}