Gunnar

nordisk/tunnr-mullvad

src/net.rs

//! The **one** prefix-containment primitive in this constellation.
//!
//! # Why a range needs its own containment test, and why it lives here
//!
//! Every fact this crate ships about Mullvad is a *range*: the addresses their
//! device registration assigns, the resolvers they run, the ports their relays
//! listen on. A range with no containment test is a comment. So the crate that
//! ships the ranges ships the test for them, and there is exactly one copy of
//! the bit arithmetic.
//!
//! What deliberately does **not** live here is the parse of a `10.64.0.0/10`
//! *string* out of a wg-quick config file. That is config hygiene, it applies
//! to every provider and to plain WireGuard, and it belongs to whoever owns the
//! config surface (in this constellation, tunnr's `wg-appliance` payload).
//! That parser delegates the bit comparison to [`prefix_eq`] rather than
//! keeping a second copy — one writer of the arithmetic, one writer of the
//! parse.
//!
//! # The bug this primitive exists to not have
//!
//! `10.64.0.0/10` is not byte-aligned. A containment check written as "does the
//! address start with `10.64`" accepts `10.64.*` and rejects `10.67.114.172` —
//! an address Mullvad's own registration hands out. A check written as "does it
//! start with `10.`" accepts `10.200.0.1`, which Mullvad never issues. Both
//! look right in a test that only ever tries `10.64.0.1`, which is why the
//! tests below drive **both sides of every boundary**.

/// Do `a` and `b` agree on their first `bits` bits?
///
/// Byte-wise on raw octets, so it works for a v4 `[u8; 4]` and a v6 `[u8; 16]`
/// without a second implementation and without pulling in an IP-network crate.
///
/// `bits` beyond `8 * a.len()` is a caller error and returns `false` rather
/// than panicking or wrapping: a nonsense prefix must not be read as "matches
/// everything".
pub fn prefix_eq(a: &[u8], b: &[u8], bits: u32) -> bool {
    if a.len() != b.len() || bits as usize > a.len() * 8 {
        return false;
    }
    let whole = (bits / 8) as usize;
    if a[..whole] != b[..whole] {
        return false;
    }
    let rest = bits % 8;
    if rest == 0 {
        return true;
    }
    // `rest` is 1..=7 here, so the shift is in range and the mask is never 0.
    let mask = 0xffu8 << (8 - rest);
    a[whole] & mask == b[whole] & mask
}

/// An IPv4 network: a base address and a prefix length.
///
/// A tuple would have done, and did in the first version of this code, which is
/// exactly why it is a type now: `(Ipv4Addr, u32)` and `(Ipv4Addr, u8)` were
/// both in the tree and nothing stopped a caller passing the prefix where the
/// address went.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct V4Net {
    /// The base address. Host bits below `prefix` are ignored, not asserted to
    /// be zero — `10.64.0.1/10` and `10.64.0.0/10` describe the same set and a
    /// constant written the first way must not silently mean something else.
    pub addr: std::net::Ipv4Addr,
    /// The prefix length in bits, 0..=32.
    pub prefix: u32,
}

impl V4Net {
    /// A network, written the way a constant reads.
    pub const fn new(a: u8, b: u8, c: u8, d: u8, prefix: u32) -> V4Net {
        V4Net {
            addr: std::net::Ipv4Addr::new(a, b, c, d),
            prefix,
        }
    }

    /// Does this network contain `ip`?
    ///
    /// An IPv6 address is never contained in a v4 network. That is a real
    /// answer and not an error: a v4 prefix genuinely covers no v6 address.
    pub fn contains(&self, ip: std::net::IpAddr) -> bool {
        match ip {
            std::net::IpAddr::V4(v4) => {
                prefix_eq(&self.addr.octets(), &v4.octets(), self.prefix)
            }
            std::net::IpAddr::V6(_) => false,
        }
    }
}

impl std::fmt::Display for V4Net {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(f, "{}/{}", self.addr, self.prefix)
    }
}

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

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

    /// RED-WHEN-BROKEN — the non-byte-aligned prefix, driven on **both** sides
    /// of every boundary. A whole-octet comparison passes the first two of
    /// these and fails the rest.
    #[test]
    fn a_sub_octet_prefix_is_exact_on_both_sides_of_its_boundary() {
        let ten_64 = V4Net::new(10, 64, 0, 0, 10);
        // Inside: the first address, a real Mullvad-issued one, and the last.
        assert!(ten_64.contains(ip("10.64.0.0")));
        assert!(ten_64.contains(ip("10.64.0.1")));
        assert!(ten_64.contains(ip("10.67.114.172")), "a real registration");
        assert!(ten_64.contains(ip("10.127.255.255")), "the last address");
        // Outside, by ONE on each side. These are the two assertions a
        // `starts_with("10.64")` or `starts_with("10.")` check gets wrong.
        assert!(!ten_64.contains(ip("10.63.255.255")));
        assert!(!ten_64.contains(ip("10.128.0.0")));
        assert!(!ten_64.contains(ip("10.200.0.1")));
        assert!(!ten_64.contains(ip("11.64.0.1")));

        let hundred_64 = V4Net::new(100, 64, 0, 0, 10);
        assert!(hundred_64.contains(ip("100.64.0.7")));
        assert!(hundred_64.contains(ip("100.127.255.255")));
        assert!(!hundred_64.contains(ip("100.63.255.255")));
        assert!(!hundred_64.contains(ip("100.128.0.1")));
        // The two /10s are genuinely different networks — a test that only
        // ever used one could not tell them apart.
        assert!(!ten_64.contains(ip("100.64.0.7")));
        assert!(!hundred_64.contains(ip("10.64.0.1")));
    }

    #[test]
    fn a_v4_network_covers_no_v6_address_and_that_is_an_answer() {
        assert!(!V4Net::new(0, 0, 0, 0, 0).contains(ip("::1")));
        assert!(!V4Net::new(0, 0, 0, 0, 0).contains(ip("2606:4700::1111")));
        // …while a v4 /0 does cover every v4 address.
        assert!(V4Net::new(0, 0, 0, 0, 0).contains(ip("8.8.8.8")));
        assert!(V4Net::new(0, 0, 0, 0, 0).contains(ip("10.64.0.1")));
    }

    #[test]
    fn a_thirty_two_covers_itself_and_nothing_next_to_it() {
        let one = V4Net::new(10, 64, 0, 1, 32);
        assert!(one.contains(ip("10.64.0.1")));
        assert!(!one.contains(ip("10.64.0.0")));
        assert!(!one.contains(ip("10.64.0.2")));
    }

    /// RED-WHEN-BROKEN — host bits in the base address must be ignored, or a
    /// constant written `10.64.0.1/10` (which is how the gateway is spelled in
    /// Mullvad's own relay list) would describe a different set from
    /// `10.64.0.0/10`.
    #[test]
    fn host_bits_in_the_base_address_do_not_change_the_set() {
        let written_sloppily = V4Net::new(10, 67, 114, 172, 10);
        let written_cleanly = V4Net::new(10, 64, 0, 0, 10);
        for probe in ["10.64.0.1", "10.127.255.255", "10.128.0.0", "10.63.0.0"] {
            assert_eq!(
                written_sloppily.contains(ip(probe)),
                written_cleanly.contains(ip(probe)),
                "{probe} must be judged the same either way"
            );
        }
    }

    /// RED-WHEN-BROKEN — a nonsense prefix must not read as "matches
    /// everything". A `<<` with an out-of-range shift is a panic in debug and
    /// nonsense in release; the guard is checked, not assumed.
    #[test]
    fn an_impossible_prefix_matches_nothing_rather_than_everything() {
        assert!(!prefix_eq(&[10, 64, 0, 0], &[10, 64, 0, 0], 33));
        assert!(!prefix_eq(&[10, 64, 0, 0], &[10, 64, 0, 0], 999));
        // …and length mismatch (a v4 octet array against a v6 one) is false,
        // never a slice-index panic.
        assert!(!prefix_eq(&[10, 64, 0, 0], &[0u8; 16], 8));
        // The identity case still works, or every assertion above is vacuous.
        assert!(prefix_eq(&[10, 64, 0, 0], &[10, 64, 0, 0], 32));
        assert!(prefix_eq(&[10, 64, 0, 0], &[10, 99, 0, 0], 8));
        assert!(!prefix_eq(&[10, 64, 0, 0], &[10, 99, 0, 0], 16));
    }

    #[test]
    fn a_zero_prefix_covers_the_whole_family() {
        assert!(prefix_eq(&[1, 2, 3, 4], &[255, 255, 255, 255], 0));
        assert!(prefix_eq(&[0u8; 16], &[0xffu8; 16], 0));
    }

    #[test]
    fn display_reads_the_way_the_range_is_written_down() {
        assert_eq!(V4Net::new(10, 64, 0, 0, 10).to_string(), "10.64.0.0/10");
        assert_eq!(V4Net::new(100, 64, 0, 0, 10).to_string(), "100.64.0.0/10");
    }
}