prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use std::fs;
use std::path::PathBuf;

use crate::identity::IdentityHash;
use crate::interface_discovery::{
    AdvertisedInterfaceType, AdvertisedTransport, AdvertisementDetails, DiscoveredInterface,
    DiscoveredInterfaceId, DiscoveryAdvertisement, DiscoveryCatalog, DiscoveryEnvelopeSecurity,
    DiscoveryProvenance, GeographicLocation, StampValue,
};
use crate::interfaces::{InterfaceId, INTERFACE_ID_LEN};
use crate::units::{HopCount, InstantMillis};
use crate::wire::TransportId;

use super::document::ArchivedFloat;
use super::{
    ArchiveRecordError, DiscoveryArchive, DiscoveryArchiveError, DiscoveryArchiveFileState,
    DiscoveryArchiveRecord, DISCOVERED_INTERFACES_FILE, MAX_ARCHIVE_BYTES,
};

fn test_path(name: &str) -> PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map_or(0, |duration| duration.as_nanos());
    std::env::temp_dir().join(format!(
        "prns-discovery-archive-{}-{nanos}-{name}",
        std::process::id()
    ))
}

#[test]
fn removing_a_discovery_is_persisted() {
    let dir = test_path("remove");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    let id = DiscoveredInterfaceId::from_bytes([0x11; 32]);
    let mut catalog = DiscoveryCatalog::new();
    catalog
        .observe(discovered(2_000))
        .expect("the growable catalog accepts the test record");
    loaded.archive.record(catalog.get(id).unwrap()).unwrap();

    loaded
        .archive
        .record(DiscoveryArchiveRecord::remove(id))
        .unwrap();

    assert!(loaded.archive.is_empty());
    assert!(DiscoveryArchive::load(path).unwrap().catalog.is_empty());
    let _ = fs::remove_dir_all(dir);
}

fn discovered(received_at: u64) -> DiscoveredInterface {
    DiscoveredInterface {
        id: DiscoveredInterfaceId::from_bytes([0x11; 32]),
        name: String::from("Public Backbone"),
        advertisement: DiscoveryAdvertisement {
            interface_type: AdvertisedInterfaceType::Backbone,
            transport: AdvertisedTransport::Enabled(TransportId::new([0x22; 16])),
            name: Some(String::from("Public Backbone")),
            location: GeographicLocation::UNKNOWN,
            details: AdvertisementDetails::Reachable {
                host: String::from("backbone.example"),
                port: 4242,
            },
            published_ifac: None,
        },
        stamp_value: StampValue::new(19).unwrap(),
        provenance: DiscoveryProvenance {
            announced_by: IdentityHash::new([0x33; 16]),
            hops: HopCount(2),
            received_on: InterfaceId::new([0x44; INTERFACE_ID_LEN]),
            received_at: InstantMillis(received_at),
            envelope_security: DiscoveryEnvelopeSecurity::NetworkEncrypted,
            signed_flag: true,
        },
    }
}

#[test]
fn archive_round_trip_restores_catalog_history_and_manual_configuration() {
    let dir = test_path("round-trip");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    assert_eq!(loaded.file_state, DiscoveryArchiveFileState::Missing);
    loaded.archive.persist().unwrap();

    let mut catalog = DiscoveryCatalog::new();
    let id = DiscoveredInterfaceId::from_bytes([0x11; 32]);
    catalog
        .observe(discovered(2_000))
        .expect("the growable catalog accepts the test record");
    let stored = catalog.get(id).unwrap();
    loaded.archive.record(stored).unwrap();

    let text = fs::read_to_string(&path).unwrap();
    assert!(text.contains("BackboneClientInterface"));
    assert!(text.contains("target_host = 'backbone.example'"));
    assert!(text.contains("\"network_encrypted\""));
    let json: serde_json::Value = serde_json::from_str(&text).unwrap();
    let entry = json["interfaces"]
        .as_object()
        .and_then(|interfaces| interfaces.values().next())
        .and_then(|interface| interface["configuration_entry"].as_str())
        .unwrap();
    assert_eq!(
        entry,
        concat!(
            "[[Public Backbone (111111111111)]]\n",
            "  type = BackboneClientInterface\n",
            "  target_host = 'backbone.example'\n",
            "  target_port = 4242\n",
            "  enabled = Yes\n",
            "  transport_identity = 22222222222222222222222222222222",
        )
    );

    let restored = DiscoveryArchive::load(path).unwrap();
    let restored_record = restored.catalog.get(id).unwrap();
    assert_eq!(restored_record.first_heard(), InstantMillis(2_000));
    assert_eq!(restored_record.last_heard(), InstantMillis(2_000));
    assert_eq!(restored_record.observation_count().get(), 1);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn rediscovery_after_catalog_expiry_keeps_durable_history() {
    let dir = test_path("rediscovery");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    let id = DiscoveredInterfaceId::from_bytes([0x11; 32]);

    let mut first_catalog = DiscoveryCatalog::new();
    first_catalog
        .observe(discovered(1_000))
        .expect("the growable catalog accepts the test record");
    loaded
        .archive
        .record(first_catalog.get(id).unwrap())
        .unwrap();

    let mut second_catalog = DiscoveryCatalog::new();
    second_catalog
        .observe(discovered(9_000))
        .expect("the growable catalog accepts the test record");
    loaded
        .archive
        .record(second_catalog.get(id).unwrap())
        .unwrap();

    let restored = DiscoveryArchive::load(path).unwrap();
    let restored_record = restored.catalog.get(id).unwrap();
    assert_eq!(restored_record.first_heard(), InstantMillis(1_000));
    assert_eq!(restored_record.last_heard(), InstantMillis(9_000));
    assert_eq!(restored_record.observation_count().get(), 2);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn non_finite_locations_round_trip_without_invalid_json_numbers() {
    let value = ArchivedFloat::from_value(f64::NAN);
    let encoded = serde_json::to_string(&value).unwrap();
    assert_eq!(encoded, "\"NaN\"");
    let decoded: ArchivedFloat = serde_json::from_str(&encoded).unwrap();
    assert!(decoded.decode("latitude").unwrap().is_nan());
}

#[test]
fn invalid_archives_are_left_byte_identical() {
    let dir = test_path("invalid");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    fs::create_dir_all(&dir).unwrap();
    let original = b"this is not a discovery archive\n";
    fs::write(&path, original).unwrap();

    assert!(DiscoveryArchive::load(path.clone()).is_err());
    assert_eq!(fs::read(&path).unwrap(), original);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn edited_archives_cannot_bypass_reachable_address_validation() {
    let dir = test_path("invalid-address");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    let mut catalog = DiscoveryCatalog::new();
    let interface = discovered(2_000);
    let id = interface.id;
    catalog
        .observe(interface)
        .expect("the growable catalog accepts the test record");
    loaded.archive.record(catalog.get(id).unwrap()).unwrap();
    let invalid = fs::read_to_string(&path)
        .unwrap()
        .replace("backbone.example", "not a host");
    fs::write(&path, &invalid).unwrap();

    let result = DiscoveryArchive::load(path.clone());

    assert!(matches!(
        result,
        Err(DiscoveryArchiveError::InvalidRecord {
            source: ArchiveRecordError::InvalidReachableAddress { .. },
            ..
        })
    ));
    assert_eq!(fs::read_to_string(&path).unwrap(), invalid);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn edited_archives_cannot_restore_unattainable_stamp_values() {
    let dir = test_path("invalid-stamp");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    let mut catalog = DiscoveryCatalog::new();
    let interface = discovered(2_000);
    let id = interface.id;
    catalog
        .observe(interface)
        .expect("the growable catalog accepts the test record");
    loaded.archive.record(catalog.get(id).unwrap()).unwrap();
    let invalid = fs::read_to_string(&path)
        .unwrap()
        .replace("\"stamp_value\": 19", "\"stamp_value\": 257");
    fs::write(&path, &invalid).unwrap();

    let result = DiscoveryArchive::load(path.clone());

    assert!(matches!(
        result,
        Err(DiscoveryArchiveError::InvalidRecord {
            source: ArchiveRecordError::StampValue(_),
            ..
        })
    ));
    assert_eq!(fs::read_to_string(&path).unwrap(), invalid);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn serialized_interfaces_are_ordered_by_discovery_id() {
    let dir = test_path("ordering");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    let mut catalog = DiscoveryCatalog::new();
    let second_id = DiscoveredInterfaceId::from_bytes([0x22; 32]);
    let first_id = DiscoveredInterfaceId::from_bytes([0x11; 32]);

    let mut second = discovered(2_000);
    second.id = second_id;
    catalog
        .observe(second)
        .expect("the growable catalog accepts the test record");
    loaded
        .archive
        .record(catalog.get(second_id).unwrap())
        .unwrap();

    let mut first = discovered(1_000);
    first.id = first_id;
    catalog
        .observe(first)
        .expect("the growable catalog accepts the test record");
    loaded
        .archive
        .record(catalog.get(first_id).unwrap())
        .unwrap();

    let text = fs::read_to_string(&path).unwrap();
    let first_key = "11".repeat(32);
    let second_key = "22".repeat(32);
    let first_position = text.find(&first_key).unwrap();
    let second_position = text.find(&second_key).unwrap();
    assert!(first_position < second_position);
    let _ = fs::remove_dir_all(dir);
}

#[test]
fn an_archive_never_writes_a_file_it_would_refuse_to_load() {
    let dir = test_path("write-limit");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let mut loaded = DiscoveryArchive::load(path.clone()).unwrap();
    loaded.archive.persist().unwrap();
    let original = fs::read(&path).unwrap();
    let mut interface = discovered(2_000);
    interface.name = "x".repeat(MAX_ARCHIVE_BYTES);
    let id = interface.id;
    let mut catalog = DiscoveryCatalog::new();
    catalog
        .observe(interface)
        .expect("the growable catalog accepts the test record");

    let result = loaded.archive.record(catalog.get(id).unwrap());

    assert!(matches!(
        result,
        Err(DiscoveryArchiveError::TooLarge { .. })
    ));
    assert!(loaded.archive.is_empty());
    assert_eq!(fs::read(&path).unwrap(), original);
    let _ = fs::remove_dir_all(dir);
}

#[cfg(unix)]
#[test]
fn archive_replacements_are_owner_only() {
    use std::os::unix::fs::PermissionsExt;

    let dir = test_path("permissions");
    let path = dir.join(DISCOVERED_INTERFACES_FILE);
    let loaded = DiscoveryArchive::load(path.clone()).unwrap();
    loaded.archive.persist().unwrap();

    assert_eq!(
        fs::metadata(&path).unwrap().permissions().mode() & 0o777,
        0o600
    );
    let _ = fs::remove_dir_all(dir);
}