prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use alloc::string::String;

use crate::wire::TransportId;

#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum AdvertisedInterfaceType {
    Backbone,
    TcpServer,
    TcpClient,
    I2p,
    RNode,
    Weave,
    Kiss,
}

impl AdvertisedInterfaceType {
    pub const fn rns_name(self) -> &'static str {
        match self {
            Self::Backbone => "BackboneInterface",
            Self::TcpServer => "TCPServerInterface",
            Self::TcpClient => "TCPClientInterface",
            Self::I2p => "I2PInterface",
            Self::RNode => "RNodeInterface",
            Self::Weave => "WeaveInterface",
            Self::Kiss => "KISSInterface",
        }
    }

    pub fn from_rns_name(name: &str) -> Option<Self> {
        match name {
            "BackboneInterface" => Some(Self::Backbone),
            "TCPServerInterface" => Some(Self::TcpServer),
            "TCPClientInterface" => Some(Self::TcpClient),
            "I2PInterface" => Some(Self::I2p),
            "RNodeInterface" => Some(Self::RNode),
            "WeaveInterface" => Some(Self::Weave),
            "KISSInterface" => Some(Self::Kiss),
            _ => None,
        }
    }
}

#[derive(Debug, PartialEq, Eq)]
pub enum AdvertisedTransport {
    Enabled(TransportId),
    Disabled(TransportId),
}

impl AdvertisedTransport {
    pub const fn from_wire(enabled: bool, transport_id: TransportId) -> Self {
        if enabled {
            Self::Enabled(transport_id)
        } else {
            Self::Disabled(transport_id)
        }
    }

    pub const fn is_enabled(&self) -> bool {
        matches!(self, Self::Enabled(_))
    }

    pub const fn transport_id(&self) -> &TransportId {
        match self {
            Self::Enabled(transport_id) | Self::Disabled(transport_id) => transport_id,
        }
    }
}

#[derive(Debug, PartialEq)]
pub struct GeographicLocation {
    pub latitude: Option<f64>,
    pub longitude: Option<f64>,
    pub height: Option<f64>,
}

impl GeographicLocation {
    pub const UNKNOWN: Self = Self {
        latitude: None,
        longitude: None,
        height: None,
    };
}

#[derive(Debug, PartialEq, Eq)]
pub struct PublishedIfac {
    pub network_name: Option<String>,
    pub passphrase: Option<String>,
}

#[derive(Debug, PartialEq)]
pub enum AdvertisementDetails {
    None,
    Reachable {
        host: String,
        port: u16,
    },
    I2p {
        address: String,
    },
    RNode {
        frequency_hz: u64,
        bandwidth_hz: u32,
        spreading_factor: u8,
        coding_rate: u8,
    },
    Weave {
        frequency_hz: u64,
        bandwidth_hz: u32,
        channel: u32,
        modulation: String,
    },
    Kiss {
        frequency_hz: u64,
        bandwidth_hz: u32,
        modulation: String,
    },
}

impl AdvertisementDetails {
    pub fn matches(&self, interface_type: AdvertisedInterfaceType) -> bool {
        matches!(
            (interface_type, self),
            (AdvertisedInterfaceType::Backbone, Self::Reachable { .. })
                | (AdvertisedInterfaceType::TcpServer, Self::Reachable { .. })
                | (AdvertisedInterfaceType::TcpClient, Self::None)
                | (AdvertisedInterfaceType::I2p, Self::I2p { .. })
                | (AdvertisedInterfaceType::RNode, Self::RNode { .. })
                | (AdvertisedInterfaceType::Weave, Self::Weave { .. })
                | (AdvertisedInterfaceType::Kiss, Self::Kiss { .. })
        )
    }
}

#[derive(Debug, PartialEq)]
pub struct DiscoveryAdvertisement {
    pub interface_type: AdvertisedInterfaceType,
    pub transport: AdvertisedTransport,
    pub name: Option<String>,
    pub location: GeographicLocation,
    pub details: AdvertisementDetails,
    pub published_ifac: Option<PublishedIfac>,
}

pub(crate) fn invalid_reachable_on(advertisement: &DiscoveryAdvertisement) -> Option<&str> {
    match &advertisement.details {
        AdvertisementDetails::Reachable { host, .. } if !address_is_valid(host) => Some(host),
        AdvertisementDetails::I2p { address } if !address_is_valid(address) => Some(address),
        AdvertisementDetails::Reachable { .. } | AdvertisementDetails::I2p { .. } => None,
        AdvertisementDetails::None
        | AdvertisementDetails::RNode { .. }
        | AdvertisementDetails::Weave { .. }
        | AdvertisementDetails::Kiss { .. } => None,
    }
}

fn address_is_valid(address: &str) -> bool {
    address.parse::<core::net::IpAddr>().is_ok() || hostname_is_valid(address)
}

fn hostname_is_valid(hostname: &str) -> bool {
    let hostname = hostname.strip_suffix('.').unwrap_or(hostname);
    if hostname.is_empty() || hostname.len() > 253 || !hostname.is_ascii() {
        return false;
    }
    let mut labels = hostname.split('.');
    let Some(last) = labels.next_back() else {
        return false;
    };
    if last.bytes().all(|byte| byte.is_ascii_digit()) {
        return false;
    }
    hostname.split('.').all(|label| {
        !label.is_empty()
            && label.len() <= 63
            && label
                .bytes()
                .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-')
            && !label.starts_with('-')
            && !label.ends_with('-')
    })
}

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

    fn reachable(host: &str) -> DiscoveryAdvertisement {
        DiscoveryAdvertisement {
            interface_type: AdvertisedInterfaceType::Backbone,
            transport: AdvertisedTransport::Disabled(TransportId::new([0x11; 16])),
            name: None,
            location: GeographicLocation::UNKNOWN,
            details: AdvertisementDetails::Reachable {
                host: String::from(host),
                port: 4242,
            },
            published_ifac: None,
        }
    }

    #[test]
    fn reference_address_rules_accept_addresses_and_dns_names() {
        for host in [
            "192.0.2.1",
            "2001:db8::1",
            "router.example",
            "router.example.",
            "peer.b32.i2p",
        ] {
            assert_eq!(invalid_reachable_on(&reachable(host)), None);
        }
    }

    #[test]
    fn reference_address_rules_reject_non_hosts_and_numeric_dns_tails() {
        for host in [
            "",
            "not a host",
            "-router.example",
            "router-.example",
            "host.123",
        ] {
            assert_eq!(invalid_reachable_on(&reachable(host)), Some(host));
        }
    }
}