prns-core 0.3.4

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

use super::*;
use crate::identity::in_memory::InMemoryNodeIdentity;
use crate::identity::{IdentitySigner, RemoteIdentity};
use crate::interface_discovery::{
    ingest_discovery_announce, AdvertisedInterfaceType, AdvertisedTransport, AdvertisementDetails,
    AutoConnectPolicy, AutoConnectRoutingPolicy, DiscoveryDecryptionError, DiscoveryIntake,
    DiscoverySourcePolicy, GeographicLocation, InterfaceDiscoveryPolicy, PublishedIfac,
};
use crate::routing::announce::AnnounceObservation;
use crate::units::HopCount;
use crate::wire::TransportId;

fn backbone() -> DiscoveryAdvertisement {
    DiscoveryAdvertisement {
        interface_type: AdvertisedInterfaceType::Backbone,
        transport: AdvertisedTransport::Enabled(TransportId::new([
            0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd,
            0xee, 0xff,
        ])),
        name: Some(String::from("Public Backbone")),
        location: GeographicLocation {
            latitude: Some(12.5),
            longitude: Some(-34.25),
            height: Some(123.0),
        },
        details: AdvertisementDetails::Reachable {
            host: String::from("router.example"),
            port: 4242,
        },
        published_ifac: Some(PublishedIfac {
            network_name: Some(String::from("mesh")),
            passphrase: Some(String::from("secret")),
        }),
    }
}

fn stamp_cost(value: u16) -> StampCost {
    match StampCost::new(value) {
        Ok(value) => value,
        Err(error) => panic!("unexpected stamp cost error: {error}"),
    }
}

fn prepared(security: DiscoveryPublicationSecurity) -> PreparedDiscoveryAdvertisement {
    let mut candidate = 0u8;
    match prepare_discovery_publication(
        &backbone(),
        stamp_cost(8),
        security,
        |bytes| {
            bytes.fill(0);
            bytes[31] = candidate;
            candidate = candidate.wrapping_add(1);
            Ok::<_, core::convert::Infallible>(())
        },
        || false,
    ) {
        DiscoveryPublicationPreparation::Prepared(prepared) => prepared,
        other => panic!("unexpected publication preparation: {other:?}"),
    }
}

#[test]
fn deterministic_stamping_reproduces_the_reference_payload() {
    let prepared = prepared(DiscoveryPublicationSecurity::Plaintext);
    assert_eq!(prepared.stamp()[31], 0xb6);
    assert_eq!(prepared.stamp_value().get(), 8);
    assert_eq!(prepared.stamp_attempts(), 183);
    let app_data = match frame_discovery_publication(&prepared, |_| {
        Err(DiscoveryPublicationEncryptionError::NetworkIdentityUnavailable)
    }) {
        Ok(app_data) => app_data,
        Err(error) => panic!("unexpected frame error: {error:?}"),
    };
    assert_eq!(app_data[0], 0);
    assert_eq!(
        &app_data[1..1 + prepared.packed_advertisement().len()],
        prepared.packed_advertisement(),
    );
    assert_eq!(&app_data[app_data.len() - STAMP_SIZE..], prepared.stamp());
}

#[test]
fn a_cached_stamp_is_revalidated_and_reused_without_entropy_work() {
    let advertisement = backbone();
    let first = prepare_discovery_publication(
        &advertisement,
        stamp_cost(1),
        DiscoveryPublicationSecurity::Plaintext,
        |candidate| {
            candidate.fill(0x42);
            Ok::<_, core::convert::Infallible>(())
        },
        || false,
    );
    let DiscoveryPublicationPreparation::Prepared(first) = first else {
        panic!("the first stamp should prepare");
    };
    let cached = *first.stamp();
    let entropy_calls = core::cell::Cell::new(0);
    let second = prepare_discovery_publication_with_stamp_cache(
        &advertisement,
        stamp_cost(1),
        DiscoveryPublicationSecurity::Plaintext,
        |hash| {
            assert_eq!(hash.as_bytes(), first.advertisement_hash().as_bytes());
            Some(cached)
        },
        |_| {
            entropy_calls.set(entropy_calls.get() + 1);
            Ok::<_, core::convert::Infallible>(())
        },
        || false,
    );
    let DiscoveryPublicationPreparation::Prepared(second) = second else {
        panic!("the cached stamp should prepare");
    };
    assert_eq!(second.stamp(), &cached);
    assert_eq!(second.stamp_value(), first.stamp_value());
    assert_eq!(second.stamp_attempts(), 0);
    assert_eq!(entropy_calls.get(), 0);
}

#[test]
fn network_encryption_is_injected_and_never_confused_with_plaintext() {
    let prepared = prepared(DiscoveryPublicationSecurity::NetworkEncrypted);
    let expected_body = prepared.plaintext_body();
    let app_data = match frame_discovery_publication(&prepared, |body| {
        assert_eq!(body, expected_body);
        Ok(vec![
            0xa5;
            crate::identity::ENCRYPTION_EPHEMERAL_PUBLIC_KEY_LEN
                + sealed_len(body.len())
        ])
    }) {
        Ok(app_data) => app_data,
        Err(error) => panic!("unexpected frame error: {error:?}"),
    };
    assert_eq!(
        app_data,
        encode_encrypted_envelope(&vec![
            0xa5;
            crate::identity::ENCRYPTION_EPHEMERAL_PUBLIC_KEY_LEN
                + sealed_len(expected_body.len())
        ])
    );
}

#[test]
fn malformed_encryption_output_cannot_be_framed_as_a_discovery_announce() {
    let prepared = prepared(DiscoveryPublicationSecurity::NetworkEncrypted);
    let expected = crate::identity::ENCRYPTION_EPHEMERAL_PUBLIC_KEY_LEN
        + sealed_len(prepared.plaintext_body().len());
    assert_eq!(
        frame_discovery_publication(&prepared, |_| Ok(Vec::new())),
        Err(DiscoveryPublicationFrameError::EncryptionOutputLength {
            actual: 0,
            expected,
        })
    );
}

#[test]
fn network_encrypted_publication_round_trips_through_the_shared_identity_crypto() {
    let identity = InMemoryNodeIdentity::from_secret_key_bytes(&[0x37; 64]);
    let remote = RemoteIdentity::from_public_keys(
        identity.encryption_public_key(),
        identity.signing_public_key(),
    );
    let prepared = prepared(DiscoveryPublicationSecurity::NetworkEncrypted);
    let app_data = match frame_discovery_publication(&prepared, |body| {
        let required =
            crate::identity::ENCRYPTION_EPHEMERAL_PUBLIC_KEY_LEN + sealed_len(body.len());
        let mut ciphertext = vec![0u8; required];
        let written = remote
            .encrypt(
                &crate::crypto::X25519SecretKey::new([0x45; 32]),
                &[0x56; crate::identity::ENCRYPTION_IV_LEN],
                body,
                &mut ciphertext,
            )
            .map_err(DiscoveryPublicationEncryptionError::Identity)?;
        ciphertext.truncate(written);
        Ok(ciphertext)
    }) {
        Ok(app_data) => app_data,
        Err(error) => panic!("unexpected frame error: {error:?}"),
    };
    let policy = InterfaceDiscoveryPolicy::enabled(
        stamp_cost(8),
        DiscoverySourcePolicy::Open,
        AutoConnectPolicy::Disabled,
        AutoConnectRoutingPolicy {
            gravity: crate::interfaces::InterfaceGravity::ZERO,
            announces_to_internal: false,
        },
    );
    let outcome = ingest_discovery_announce(
        &policy,
        AnnounceObservation {
            destination: crate::interface_discovery::discovery_destination_hash(
                &identity.identity_hash(),
            ),
            announced_identity: identity.identity_hash(),
            hops: HopCount(1),
            source_interface: InterfaceId::new([0x91; 8]),
            arrived_at: InstantMillis(8_000),
            app_data: &app_data,
            is_path_response: false,
        },
        |ciphertext| {
            let mut plaintext = vec![0u8; ciphertext.len()];
            let written = identity
                .decrypt(ciphertext, &mut plaintext)
                .map_err(DiscoveryDecryptionError::Identity)?;
            plaintext.truncate(written);
            Ok(plaintext)
        },
    );
    let DiscoveryIntake::Discovered(discovered) = outcome else {
        panic!("encrypted publication should be discovered: {outcome:?}");
    };
    assert_eq!(discovered.name, "Public Backbone");
}

#[test]
fn oversized_payloads_fail_before_stamp_work() {
    let mut advertisement = backbone();
    advertisement.name = Some("x".repeat(MAX_ANNOUNCE_APP_DATA_LEN));
    let entropy_calls = core::cell::Cell::new(0);
    let outcome = prepare_discovery_publication(
        &advertisement,
        stamp_cost(1),
        DiscoveryPublicationSecurity::Plaintext,
        |_| {
            entropy_calls.set(entropy_calls.get() + 1);
            Ok::<_, core::convert::Infallible>(())
        },
        || false,
    );
    assert!(matches!(
        outcome,
        DiscoveryPublicationPreparation::AppDataTooLong { .. }
    ));
    assert_eq!(entropy_calls.get(), 0);
}

#[test]
fn invalid_reachable_addresses_fail_before_stamp_work() {
    let mut advertisement = backbone();
    advertisement.details = AdvertisementDetails::Reachable {
        host: String::from("not a host"),
        port: 4242,
    };
    let entropy_calls = core::cell::Cell::new(0);
    let outcome = prepare_discovery_publication(
        &advertisement,
        stamp_cost(1),
        DiscoveryPublicationSecurity::Plaintext,
        |_| {
            entropy_calls.set(entropy_calls.get() + 1);
            Ok::<_, core::convert::Infallible>(())
        },
        || false,
    );
    assert_eq!(
        outcome,
        DiscoveryPublicationPreparation::InvalidReachableOn {
            value: String::from("not a host"),
        }
    );
    assert_eq!(entropy_calls.get(), 0);
}

#[test]
fn the_schedule_selects_one_most_overdue_interface_deterministically() {
    let first = InterfaceId::new([1; 8]);
    let second = InterfaceId::new([2; 8]);
    let mut schedule = match DiscoveryPublicationSchedule::new([
        DiscoveryPublicationTiming {
            interface: first,
            interval: DurationMillis(100),
        },
        DiscoveryPublicationTiming {
            interface: second,
            interval: DurationMillis(100),
        },
    ]) {
        Ok(schedule) => schedule,
        Err(error) => panic!("unexpected schedule error: {error:?}"),
    };
    assert_eq!(schedule.next_due(InstantMillis(1_000)), Some(first));
    assert_eq!(schedule.record_attempt(first, InstantMillis(1_000)), Ok(()));
    assert_eq!(schedule.next_due(InstantMillis(1_000)), Some(second));
    assert_eq!(
        schedule.record_attempt(second, InstantMillis(1_010)),
        Ok(())
    );
    assert_eq!(schedule.next_due(InstantMillis(1_100)), None);
    assert_eq!(schedule.next_due(InstantMillis(1_101)), Some(first));
    assert_eq!(schedule.record_attempt(first, InstantMillis(1_101)), Ok(()));
    assert_eq!(schedule.next_due(InstantMillis(1_111)), Some(second));
}

#[test]
fn invalid_schedule_shapes_are_rejected_at_construction() {
    let interface = InterfaceId::new([3; 8]);
    assert!(matches!(
        DiscoveryPublicationSchedule::new([DiscoveryPublicationTiming {
            interface,
            interval: DurationMillis(0),
        }]),
        Err(DiscoveryPublicationScheduleError::ZeroInterval { .. })
    ));
    assert!(matches!(
        DiscoveryPublicationSchedule::new([
            DiscoveryPublicationTiming {
                interface,
                interval: DurationMillis(1),
            },
            DiscoveryPublicationTiming {
                interface,
                interval: DurationMillis(2),
            },
        ]),
        Err(DiscoveryPublicationScheduleError::DuplicateInterface { .. })
    ));
}