prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use super::*;
use crate::identity::IdentityHash;
use crate::interface_discovery::{
    generate_stamp, AdvertisedInterfaceType, AdvertisedTransport, AdvertisementDetails,
    AdvertisementHash, DiscoveryAdvertisement, DiscoveryEnvelopeSecurity, DiscoveryProvenance,
    GeographicLocation, StampCost, StampGeneration, StampValue, DISCOVERY_EXPIRES_AFTER,
    DISCOVERY_STALE_AFTER, DISCOVERY_UNKNOWN_AFTER,
};
use crate::interfaces::InterfaceId;
use crate::units::HopCount;
use crate::wire::TransportId;
use core::num::NonZeroU64;

fn stamp_value(cost: u16) -> StampValue {
    let cost = match StampCost::new(cost) {
        Ok(cost) => cost,
        Err(error) => panic!("unexpected stamp cost: {error}"),
    };
    let hash = AdvertisementHash::from_hash([0x5a; 32]);
    let mut nonce = 0u64;
    match generate_stamp(
        &hash,
        cost,
        |candidate| {
            candidate.fill(0);
            candidate[..8].copy_from_slice(&nonce.to_be_bytes());
            nonce = nonce.saturating_add(1);
            Ok::<(), ()>(())
        },
        || false,
    ) {
        StampGeneration::Generated(generated) => generated.value,
        StampGeneration::Cancelled | StampGeneration::EntropyFailure(()) => {
            panic!("deterministic stamp generation should succeed")
        }
    }
}

fn discovered(
    id: u8,
    received_at: InstantMillis,
    stamp_value: StampValue,
    host: &str,
) -> DiscoveredInterface {
    DiscoveredInterface {
        id: DiscoveredInterfaceId::from_bytes([id; 32]),
        name: alloc::format!("Interface {id}"),
        advertisement: DiscoveryAdvertisement {
            interface_type: AdvertisedInterfaceType::Backbone,
            transport: AdvertisedTransport::Enabled(TransportId::new([id; 16])),
            name: None,
            location: GeographicLocation::UNKNOWN,
            details: AdvertisementDetails::Reachable {
                host: String::from(host),
                port: 4242,
            },
            published_ifac: None,
        },
        stamp_value,
        provenance: DiscoveryProvenance {
            announced_by: IdentityHash::new([id; 16]),
            hops: HopCount(id),
            received_on: InterfaceId::new([id; 8]),
            received_at,
            envelope_security: DiscoveryEnvelopeSecurity::Plaintext,
            signed_flag: false,
        },
    }
}

#[test]
fn observations_preserve_history_refresh_content_and_reject_time_regression() {
    let value = stamp_value(1);
    let mut catalog = DiscoveryCatalog::new();
    let id = DiscoveredInterfaceId::from_bytes([1; 32]);

    assert_eq!(
        catalog.observe(discovered(1, InstantMillis(1_000), value, "one.example")),
        Ok(DiscoveryCatalogUpdate::Added { id })
    );
    assert_eq!(
        catalog.observe(discovered(1, InstantMillis(2_000), value, "one.example")),
        Ok(DiscoveryCatalogUpdate::Refreshed {
            id,
            refresh: DiscoveryCatalogRefresh::AdvertisementUnchanged,
        })
    );
    assert_eq!(
        catalog.observe(discovered(1, InstantMillis(3_000), value, "two.example")),
        Ok(DiscoveryCatalogUpdate::Refreshed {
            id,
            refresh: DiscoveryCatalogRefresh::AdvertisementChanged,
        })
    );
    assert_eq!(
        catalog.observe(discovered(1, InstantMillis(2_999), value, "old.example")),
        Ok(DiscoveryCatalogUpdate::IgnoredOutOfOrder {
            id,
            received_at: InstantMillis(2_999),
            last_heard: InstantMillis(3_000),
        })
    );

    let record = catalog.get(id).expect("the observed interface remains");
    assert_eq!(record.first_heard(), InstantMillis(1_000));
    assert_eq!(record.last_heard(), InstantMillis(3_000));
    assert_eq!(record.observation_count().get(), 3);
    assert_eq!(
        record.interface().advertisement.details,
        AdvertisementDetails::Reachable {
            host: String::from("two.example"),
            port: 4242,
        }
    );
}

#[test]
fn ranking_is_status_then_stamp_then_recency_then_stable_id() {
    let day = 24 * 60 * 60 * 1_000;
    let now = InstantMillis(10 * day);
    let low = stamp_value(1);
    let high = stamp_value(8);
    assert!(high > low);

    let mut catalog = DiscoveryCatalog::new();
    for interface in [
        discovered(6, InstantMillis(now.0 - 200), high, "six.example"),
        discovered(5, InstantMillis(now.0 - 100), low, "five.example"),
        discovered(
            2,
            InstantMillis(now.0 - DISCOVERY_UNKNOWN_AFTER.0 - 1),
            high,
            "two.example",
        ),
        discovered(
            3,
            InstantMillis(now.0 - DISCOVERY_STALE_AFTER.0 - 1),
            high,
            "three.example",
        ),
        discovered(
            4,
            InstantMillis(now.0 - DISCOVERY_EXPIRES_AFTER.0 - 1),
            high,
            "four.example",
        ),
        discovered(1, InstantMillis(now.0 - 200), high, "one.example"),
    ] {
        catalog
            .observe(interface)
            .expect("the growable catalog accepts every test record");
    }

    assert_eq!(
        catalog
            .ranked_records(now)
            .into_iter()
            .map(DiscoveryRecord::id)
            .collect::<Vec<_>>(),
        vec![
            DiscoveredInterfaceId::from_bytes([1; 32]),
            DiscoveredInterfaceId::from_bytes([6; 32]),
            DiscoveredInterfaceId::from_bytes([5; 32]),
            DiscoveredInterfaceId::from_bytes([2; 32]),
            DiscoveredInterfaceId::from_bytes([3; 32]),
            DiscoveredInterfaceId::from_bytes([4; 32]),
        ]
    );
}

#[test]
fn expiry_removes_only_after_the_reference_boundary() {
    let value = stamp_value(1);
    let mut catalog = DiscoveryCatalog::new();
    catalog
        .observe(discovered(1, InstantMillis(1_000), value, "one.example"))
        .expect("the growable catalog accepts the test record");

    assert!(catalog
        .remove_expired(InstantMillis(1_000 + DISCOVERY_EXPIRES_AFTER.0))
        .is_empty());
    let removed = catalog.remove_expired(InstantMillis(1_000 + DISCOVERY_EXPIRES_AFTER.0 + 1));
    assert_eq!(removed.len(), 1);
    assert_eq!(removed[0].id(), DiscoveredInterfaceId::from_bytes([1; 32]));
    assert!(catalog.is_empty());
}

#[test]
fn restored_records_preserve_history_and_continue_counting() {
    let value = stamp_value(1);
    let mut catalog = DiscoveryCatalog::new();
    let id = DiscoveredInterfaceId::from_bytes([1; 32]);
    let observation_count =
        DiscoveryObservationCount::from_non_zero(NonZeroU64::MIN.saturating_add(6));

    assert_eq!(
        catalog.restore(DiscoveryCatalogSeed {
            interface: discovered(1, InstantMillis(3_000), value, "one.example"),
            first_heard: InstantMillis(1_000),
            observation_count,
        }),
        Ok(())
    );
    assert_eq!(
        catalog.observe(discovered(1, InstantMillis(4_000), value, "one.example")),
        Ok(DiscoveryCatalogUpdate::Refreshed {
            id,
            refresh: DiscoveryCatalogRefresh::AdvertisementUnchanged,
        })
    );

    let record = catalog.get(id).expect("the restored interface remains");
    assert_eq!(record.first_heard(), InstantMillis(1_000));
    assert_eq!(record.last_heard(), InstantMillis(4_000));
    assert_eq!(record.observation_count().get(), 8);
}

#[test]
fn restore_rejects_invalid_history_and_duplicate_ids() {
    let value = stamp_value(1);
    let mut catalog = DiscoveryCatalog::new();
    let observation_count = DiscoveryObservationCount::FIRST;

    assert_eq!(
        catalog.restore(DiscoveryCatalogSeed {
            interface: discovered(1, InstantMillis(1_000), value, "one.example"),
            first_heard: InstantMillis(1_001),
            observation_count,
        }),
        Err(DiscoveryCatalogRestoreError::FirstHeardAfterLastHeard {
            first_heard: InstantMillis(1_001),
            last_heard: InstantMillis(1_000),
        })
    );

    assert_eq!(
        catalog.restore(DiscoveryCatalogSeed {
            interface: discovered(1, InstantMillis(1_000), value, "one.example"),
            first_heard: InstantMillis(1_000),
            observation_count,
        }),
        Ok(())
    );
    assert_eq!(
        catalog.restore(DiscoveryCatalogSeed {
            interface: discovered(1, InstantMillis(2_000), value, "one.example"),
            first_heard: InstantMillis(1_000),
            observation_count,
        }),
        Err(DiscoveryCatalogRestoreError::Duplicate(
            DiscoveredInterfaceId::from_bytes([1; 32])
        ))
    );
}