prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use super::*;
use crate::engine::RouteSnapshot;
use crate::identity::IdentityHash;
use crate::interfaces::IfacSize;
use crate::interfaces::{
    ConnectionState, InterfaceId, InterfaceKind, InterfaceSnapshot, Membership, TransferRates,
};
use crate::routing::NextHop;
use crate::routing::{BlackholeExpiry, BlackholedIdentity};
use crate::units::InstantMillis;
use crate::wire::DestinationHash;
use core::time::Duration;

use super::wire_names::interface;

fn decode(bytes: &[u8]) -> rmpv::Value {
    match rmpv::decode::read_value(&mut std::io::Cursor::new(bytes)) {
        Ok(value) => value,
        Err(error) => panic!("encoded management reply must be MessagePack: {error}"),
    }
}

#[test]
fn remote_status_matches_the_rns_1_4_2_outer_shape() {
    let status = RnsInterfaceStats::new(Vec::new()).with_transport(RnsTransportStatus::new(
        IdentityHash::new([0x11; 16]),
        Some(IdentityHash::new([0x22; 16])),
        Duration::from_millis(1_500),
    ));
    let expected = bytes_from_hex(
        "928aaa696e746572666163657390a372786200a374786200a372787300a374787300a3727373c0ac7472616e73706f72745f6964c41011111111111111111111111111111111aa6e6574776f726b5f6964c41022222222222222222222222222222222b07472616e73706f72745f757074696d65cb3ff8000000000000af70726f62655f726573706f6e646572c002",
    );

    assert_eq!(status.encode_remote_response(Some(2)), Ok(expected));
}

#[test]
fn interface_stats_preserve_live_counters_and_access_code_fields() {
    let id = InterfaceId::from_channel_tag(InterfaceKind::TcpClient, b"stats");
    let stats = RnsInterfaceStats::new(vec![RnsInterfaceStatsEntry::new(
        Some(String::from("Public TCP")),
        InterfaceSnapshot {
            id,
            mode: crate::interfaces::InterfaceMode::Boundary,
            gravity: crate::interfaces::InterfaceGravity::new(-12),
            connection: ConnectionState::Degraded,
            failure_reason: None,
            rx_bytes: 10,
            tx_bytes: 20,
            transfer_rates: Some(TransferRates {
                rx_bps: 30,
                tx_bps: 40,
            }),
            destinations: 0,
            links: 0,
            transported_links: 0,
            membership: Membership::Independent,
        },
        Some(RnsInterfaceAccessCode::new(
            [0x33; 64],
            IfacSize::WIDE,
            Some(String::from("ops")),
        )),
    )]);
    let Ok(encoded) = stats.encode_message_pack() else {
        panic!("interface stats must encode");
    };
    let rmpv::Value::Map(fields) = decode(&encoded) else {
        panic!("interface stats must be a map");
    };

    assert!(fields.iter().any(|(key, value)| {
        key.as_str() == Some(interface::RECEIVE_BYTES) && value.as_u64() == Some(10)
    }));
    let interfaces = fields
        .iter()
        .find_map(|(key, value)| (key.as_str() == Some(interface::INTERFACES)).then_some(value))
        .and_then(rmpv::Value::as_array);
    assert!(interfaces.is_some_and(|entries| entries.len() == 1));
    let entry = interfaces
        .and_then(|entries| entries.first())
        .and_then(rmpv::Value::as_map);
    assert!(entry.is_some_and(|fields| {
        fields
            .iter()
            .any(|(key, value)| key.as_str() == Some(interface::MODE) && value.as_i64() == Some(5))
            && fields.iter().any(|(key, value)| {
                key.as_str() == Some(interface::GRAVITY) && value.as_i64() == Some(-12)
            })
    }));
}

#[test]
fn local_interface_fallback_names_match_stock_rnstatus_categories() {
    let server = InterfaceId::from_channel_tag(InterfaceKind::LocalServer, b"server");
    let client = InterfaceId::from_channel_tag(InterfaceKind::LocalClient, b"client");
    assert!(interface_name(server).starts_with("Shared Instance["));
    assert!(interface_name(client).starts_with("LocalInterface["));
}

#[test]
fn path_rate_and_blackhole_tables_keep_stock_shapes() {
    let destination = DestinationHash::new([0x42; 16]);
    let route = RouteSnapshot {
        destination,
        hops: 2,
        via: NextHop::Direct,
        learned_at: InstantMillis(1_000),
        last_relayed_at: InstantMillis(1_500),
        expires_at: InstantMillis(2_000),
        interface: InterfaceId::from_channel_tag(InterfaceKind::TcpClient, b"route"),
    };
    let Ok(path_bytes) = RnsPathTable::new(vec![route]).encode_message_pack() else {
        panic!("path table must encode");
    };
    let Ok(rate_bytes) = RnsAnnounceRateTable::new(vec![RnsAnnounceRateEntry::new(
        destination,
        InstantMillis(1_500),
        InstantMillis(4_250),
        4,
        vec![InstantMillis(1_000), InstantMillis(1_250)],
    )])
    .encode_message_pack() else {
        panic!("rate table must encode");
    };
    let blackholes = RnsBlackholeTable::from_entries([BlackholedIdentity {
        identity: IdentityHash::new([0x11; 16]),
        source: IdentityHash::new([0xaa; 16]),
        expiry: BlackholeExpiry::At(InstantMillis(1_700_000_000_125)),
        reason: Some("operator"),
    }]);
    let Ok(blackhole_bytes) = blackholes.encode_message_pack() else {
        panic!("blackhole table must encode");
    };

    assert!(matches!(decode(&path_bytes), rmpv::Value::Array(rows) if rows.len() == 1));
    assert!(matches!(decode(&rate_bytes), rmpv::Value::Array(rows) if rows.len() == 1));
    assert_eq!(
        blackhole_bytes,
        bytes_from_hex(
            "81c4101111111111111111111111111111111183a6736f75726365c410aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa5756e74696ccb41d954fc40080000a6726561736f6ea86f70657261746f72",
        )
    );
}

fn bytes_from_hex(value: &str) -> Vec<u8> {
    value
        .as_bytes()
        .chunks_exact(2)
        .map(|pair| match core::str::from_utf8(pair) {
            Ok(pair) => match u8::from_str_radix(pair, 16) {
                Ok(byte) => byte,
                Err(error) => panic!("fixture must be hexadecimal: {error}"),
            },
            Err(error) => panic!("fixture must be UTF-8: {error}"),
        })
        .collect()
}