ulagen 1.0.0

Generate random IPv6 Unique Local Address (ULA) /48 prefixes per RFC 4193, as a library and CLI.
Documentation
// SPDX-License-Identifier: MIT
//
// Copyright (c) 2024 Erik Kline
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.

#![doc = include_str!("../README.md")]
#![deny(missing_docs)]
#![deny(rustdoc::broken_intra_doc_links)]

use std::fmt;
use std::net::Ipv6Addr;
use std::num::ParseIntError;
use std::str::FromStr;

use rand::Rng;
use rand::rngs::ThreadRng;

/// The default prefix length for ULA allocations as defined by
/// [RFC 4193 §3.1](https://www.rfc-editor.org/rfc/rfc4193.html#section-3.1).
pub const ULA_PREFIX_LEN: u8 = 48;

/// The first byte of every IPv6 ULA: `0xfd`. This is `fc00::/7` with the
/// "locally assigned" (`L`) bit set to 1, per
/// [RFC 4193 §3.1](https://www.rfc-editor.org/rfc/rfc4193.html#section-3.1).
///
/// `fc00::/8` is reserved by RFC 4193 for an as-yet-undefined centrally
/// assigned scheme; `fd00::/8` is the half currently usable for self-generated
/// ULAs.
pub const ULA_FIRST_OCTET: u8 = 0xfd;

/// An IPv6 address paired with a prefix length, i.e. an IPv6 network or
/// subnet.
///
/// This type does not normalise the address: the host bits below
/// `prefix_len` are preserved as-is. For ULAs generated by this crate the
/// host bits are always zero.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct Ipv6Prefix {
    addr: Ipv6Addr,
    prefix_len: u8,
}

impl Ipv6Prefix {
    /// Build a new prefix from an address and a length.
    ///
    /// Returns [`None`] if `prefix_len` is greater than 128.
    #[must_use]
    pub const fn new(addr: Ipv6Addr, prefix_len: u8) -> Option<Self> {
        if prefix_len > 128 {
            None
        } else {
            Some(Self { addr, prefix_len })
        }
    }

    /// The IPv6 address component.
    #[must_use]
    pub const fn addr(&self) -> Ipv6Addr {
        self.addr
    }

    /// The prefix length, in bits (`0..=128`).
    #[must_use]
    pub const fn prefix_len(&self) -> u8 {
        self.prefix_len
    }
}

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

/// Errors produced when parsing an [`Ipv6Prefix`] from a string.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ParsePrefixError {
    /// The input did not contain a `/` separator.
    MissingSlash,
    /// The address component could not be parsed as an [`Ipv6Addr`].
    InvalidAddress(std::net::AddrParseError),
    /// The prefix length component could not be parsed as a `u8`.
    InvalidLength(ParseIntError),
    /// The prefix length was numerically valid but greater than 128.
    LengthOutOfRange(u8),
}

impl fmt::Display for ParsePrefixError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::MissingSlash => f.write_str("missing '/' separator"),
            Self::InvalidAddress(e) => write!(f, "invalid IPv6 address: {e}"),
            Self::InvalidLength(e) => write!(f, "invalid prefix length: {e}"),
            Self::LengthOutOfRange(n) => write!(f, "prefix length {n} exceeds 128"),
        }
    }
}

impl std::error::Error for ParsePrefixError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::InvalidAddress(e) => Some(e),
            Self::InvalidLength(e) => Some(e),
            _ => None,
        }
    }
}

impl FromStr for Ipv6Prefix {
    type Err = ParsePrefixError;

    fn from_str(s: &str) -> Result<Self, Self::Err> {
        let (addr_part, len_part) = s.split_once('/').ok_or(ParsePrefixError::MissingSlash)?;
        let addr: Ipv6Addr = addr_part
            .parse()
            .map_err(ParsePrefixError::InvalidAddress)?;
        let prefix_len: u8 = len_part.parse().map_err(ParsePrefixError::InvalidLength)?;
        Self::new(addr, prefix_len).ok_or(ParsePrefixError::LengthOutOfRange(prefix_len))
    }
}

impl From<Ipv6Prefix> for (Ipv6Addr, u8) {
    fn from(p: Ipv6Prefix) -> Self {
        (p.addr, p.prefix_len)
    }
}

/// Generate a random ULA `/48` prefix using the supplied RNG.
///
/// The address is constructed so that:
///
/// * The first octet is `0xfd` (i.e. `fc00::/7` with the `L` bit set; see
///   [RFC 4193 §3.1](https://www.rfc-editor.org/rfc/rfc4193.html#section-3.1)).
/// * The next 40 bits — the "Global ID" — are filled from `rng`.
/// * The remaining bits below `/48` are zero.
///
/// RFC 4193 §3.2 recommends generating the Global ID with sufficient
/// entropy that two independently generated prefixes are unlikely to
/// collide. Callers should pass a cryptographically secure RNG such as
/// [`rand::rngs::SysRng`] or the default [`rand::rng`] (which is a
/// CSPRNG seeded from the OS).
///
/// # Example
///
/// ```
/// use ulagen::{generate_ula_prefix_with, ULA_FIRST_OCTET, ULA_PREFIX_LEN};
///
/// let mut rng = rand::rng();
/// let prefix = generate_ula_prefix_with(&mut rng);
/// assert_eq!(prefix.prefix_len(), ULA_PREFIX_LEN);
/// assert_eq!(prefix.addr().octets()[0], ULA_FIRST_OCTET);
/// ```
pub fn generate_ula_prefix_with<R: Rng + ?Sized>(rng: &mut R) -> Ipv6Prefix {
    let mut octets = [0u8; 16];
    rng.fill_bytes(&mut octets[..6]);
    octets[0] = ULA_FIRST_OCTET;
    // Zero the host bits below /48 (octets 6..16 are already zero, but be
    // explicit for clarity and future-proofing if callers tweak the slice).
    for o in &mut octets[6..] {
        *o = 0;
    }
    Ipv6Prefix {
        addr: Ipv6Addr::from(octets),
        prefix_len: ULA_PREFIX_LEN,
    }
}

/// Generate a random ULA `/48` prefix using [`rand::rng`].
///
/// Convenience wrapper around [`generate_ula_prefix_with`].
///
/// # Example
///
/// ```
/// let prefix = ulagen::generate_ula_prefix();
/// println!("{prefix}");
/// ```
#[must_use]
pub fn generate_ula_prefix() -> Ipv6Prefix {
    let mut rng: ThreadRng = rand::rng();
    generate_ula_prefix_with(&mut rng)
}

#[cfg(test)]
mod tests {
    use super::*;
    use rand::SeedableRng;
    use rand::rngs::StdRng;

    #[test]
    fn prefix_is_in_fd00_8() {
        for _ in 0..1000 {
            let p = generate_ula_prefix();
            assert_eq!(p.addr().octets()[0], 0xfd);
            assert_eq!(p.prefix_len(), 48);
        }
    }

    #[test]
    fn host_bits_below_48_are_zero() {
        for _ in 0..256 {
            let p = generate_ula_prefix();
            let octets = p.addr().octets();
            for (i, b) in octets.iter().enumerate().skip(6) {
                assert_eq!(*b, 0, "octet {i} should be zero, got {b:#x}");
            }
        }
    }

    #[test]
    fn deterministic_with_seeded_rng() {
        let mut rng = StdRng::seed_from_u64(0xDEAD_BEEF);
        let a = generate_ula_prefix_with(&mut rng);
        let mut rng = StdRng::seed_from_u64(0xDEAD_BEEF);
        let b = generate_ula_prefix_with(&mut rng);
        assert_eq!(a, b);
    }

    #[test]
    fn display_round_trips() {
        let p = "fd12:3456:789a::/48".parse::<Ipv6Prefix>().unwrap();
        assert_eq!(p.to_string(), "fd12:3456:789a::/48");
        assert_eq!(p.prefix_len(), 48);
    }

    #[test]
    fn parse_errors() {
        assert!(matches!(
            "fd12::".parse::<Ipv6Prefix>(),
            Err(ParsePrefixError::MissingSlash)
        ));
        assert!(matches!(
            "not-an-addr/48".parse::<Ipv6Prefix>(),
            Err(ParsePrefixError::InvalidAddress(_))
        ));
        assert!(matches!(
            "fd12::/notanum".parse::<Ipv6Prefix>(),
            Err(ParsePrefixError::InvalidLength(_))
        ));
        assert!(matches!(
            "fd12::/200".parse::<Ipv6Prefix>(),
            Err(ParsePrefixError::LengthOutOfRange(200))
        ));
    }

    #[test]
    fn new_validates_length() {
        assert!(Ipv6Prefix::new(Ipv6Addr::UNSPECIFIED, 129).is_none());
        assert!(Ipv6Prefix::new(Ipv6Addr::UNSPECIFIED, 128).is_some());
        assert!(Ipv6Prefix::new(Ipv6Addr::UNSPECIFIED, 0).is_some());
    }
}