prns-core 0.3.4

Pure Reticulum engine and wire contract for Personal Reticulum
use alloc::vec;
use alloc::vec::Vec;

use proptest::prelude::*;
use rmpv::Value;

use super::*;

const RNS_1_4_2_FIXTURE: &[u8] = b"\x82\xc4\x10\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x11\x83\xa6source\xc4\x10\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xa5until\xcb\x41\xd9\x54\xfc\x40\x08\x00\x00\xa6reason\xa8operator\xc4\x10\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x22\x83\xa6source\xc4\x10\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xaa\xa5until\xc0\xa6reason\xc0";

fn source() -> IdentityHash {
    IdentityHash::new([0xaa; 16])
}

fn fixture_entries() -> Vec<BlackholedIdentity<&'static str>> {
    vec![
        BlackholedIdentity {
            identity: IdentityHash::new([0x11; 16]),
            source: source(),
            expiry: BlackholeExpiry::At(InstantMillis(1_700_000_000_125)),
            reason: Some("operator"),
        },
        BlackholedIdentity {
            identity: IdentityHash::new([0x22; 16]),
            source: source(),
            expiry: BlackholeExpiry::Indefinite,
            reason: None,
        },
    ]
}

#[test]
fn decodes_the_rns_1_4_2_umsgpack_file_and_applies_reload_expiry() {
    let decoded = RnsBlackholeTable::decode_source_file(
        RNS_1_4_2_FIXTURE,
        source(),
        InstantMillis(1_700_000_000_124),
    )
    .map(RnsBlackholeTable::into_entries);
    assert_eq!(
        decoded,
        Ok(fixture_entries()
            .into_iter()
            .map(|entry| BlackholedIdentity {
                identity: entry.identity,
                source: entry.source,
                expiry: entry.expiry,
                reason: entry.reason.map(String::from),
            })
            .collect())
    );

    let at_equality = RnsBlackholeTable::decode_source_file(
        RNS_1_4_2_FIXTURE,
        source(),
        InstantMillis(1_700_000_000_125),
    )
    .map(RnsBlackholeTable::into_entries);
    assert_eq!(
        at_equality,
        Ok(vec![BlackholedIdentity {
            identity: IdentityHash::new([0x22; 16]),
            source: source(),
            expiry: BlackholeExpiry::Indefinite,
            reason: None,
        }])
    );
}

#[test]
fn encodes_exactly_what_rns_1_4_2_umsgpack_emits() {
    assert!(
        RnsBlackholeTable::from_source_entries(source(), fixture_entries())
            .encode_message_pack()
            .is_ok_and(|bytes| bytes == RNS_1_4_2_FIXTURE)
    );
}

#[test]
fn source_encoding_excludes_entries_owned_by_another_source() {
    let mut entries = fixture_entries();
    entries.push(BlackholedIdentity {
        identity: IdentityHash::new([0x33; 16]),
        source: IdentityHash::new([0xbb; 16]),
        expiry: BlackholeExpiry::Indefinite,
        reason: None,
    });
    assert!(RnsBlackholeTable::from_source_entries(source(), entries)
        .encode_message_pack()
        .is_ok_and(|bytes| bytes == RNS_1_4_2_FIXTURE));
}

#[test]
fn published_table_requires_and_preserves_each_entry_source() {
    let decoded = RnsBlackholeTable::decode_published_table(RNS_1_4_2_FIXTURE, InstantMillis(0))
        .map(RnsBlackholeTable::into_entries);
    assert!(decoded.is_ok_and(|entries| {
        entries.len() == 2 && entries.iter().all(|entry| entry.source == source())
    }));

    let missing_source = Value::Map(vec![(
        Value::Binary(vec![0x44; 16]),
        Value::Map(vec![(Value::from("until"), Value::Nil)]),
    )]);
    assert_eq!(
        RnsBlackholeTable::decode_published_table(&encode_value(missing_source), InstantMillis(0)),
        Err(RnsBlackholeDecodeError::InvalidSource)
    );
}

#[test]
fn duplicate_identities_and_fields_use_the_last_stock_map_value() {
    let identity = Value::Binary(vec![0x44; 16]);
    let value = Value::Map(vec![
        (identity.clone(), Value::Nil),
        (
            identity,
            Value::Map(vec![
                (Value::from("source"), Value::from("invalid")),
                (Value::from("source"), Value::Binary(vec![0xaa; 16])),
                (Value::from("until"), Value::from("invalid")),
                (Value::from("until"), Value::Nil),
                (Value::from("reason"), Value::from(9)),
                (Value::from("reason"), Value::from("last")),
            ]),
        ),
    ]);
    assert_eq!(
        RnsBlackholeTable::decode_published_table(&encode_value(value), InstantMillis(0))
            .map(RnsBlackholeTable::into_entries),
        Ok(vec![BlackholedIdentity {
            identity: IdentityHash::new([0x44; 16]),
            source: source(),
            expiry: BlackholeExpiry::Indefinite,
            reason: Some(String::from("last")),
        }])
    );
}

#[test]
fn malformed_shapes_fail_as_typed_decode_errors() {
    assert_eq!(
        RnsBlackholeTable::decode_source_file(&[0x90], source(), InstantMillis(0)),
        Err(RnsBlackholeDecodeError::ExpectedMap)
    );
    assert_eq!(
        RnsBlackholeTable::decode_source_file(&[0x80, 0x00], source(), InstantMillis(0)),
        Err(RnsBlackholeDecodeError::TrailingData)
    );
}

#[test]
fn persisted_numeric_forms_follow_rns_reload_semantics() {
    let integer = source_map(Value::from(2));
    assert_eq!(
        RnsBlackholeTable::decode_source_file(&integer, source(), InstantMillis(1_999))
            .map(RnsBlackholeTable::into_entries),
        Ok(vec![BlackholedIdentity {
            identity: IdentityHash::new([0x44; 16]),
            source: source(),
            expiry: BlackholeExpiry::At(InstantMillis(2_000)),
            reason: None,
        }])
    );
    assert!(
        RnsBlackholeTable::decode_source_file(&integer, source(), InstantMillis(2_000))
            .is_ok_and(|entries| entries.into_entries().is_empty())
    );

    let fractional = source_map(Value::F64(2.0005));
    assert_eq!(
        RnsBlackholeTable::decode_source_file(&fractional, source(), InstantMillis(2_000))
            .map(RnsBlackholeTable::into_entries),
        Ok(vec![BlackholedIdentity {
            identity: IdentityHash::new([0x44; 16]),
            source: source(),
            expiry: BlackholeExpiry::At(InstantMillis(2_000)),
            reason: None,
        }])
    );

    for expired in [Value::from(-1), Value::from(0), Value::F64(f64::NAN)] {
        assert!(RnsBlackholeTable::decode_source_file(
            &source_map(expired),
            source(),
            InstantMillis(0)
        )
        .is_ok_and(|entries| entries.into_entries().is_empty()));
    }
}

#[test]
fn source_file_is_authoritative_and_short_hashes_are_skipped() {
    let replacement_source = IdentityHash::new([0xcc; 16]);
    let decoded = RnsBlackholeTable::decode_source_file(
        RNS_1_4_2_FIXTURE,
        replacement_source,
        InstantMillis(0),
    )
    .map(RnsBlackholeTable::into_entries);
    assert!(decoded.is_ok_and(|entries| entries
        .iter()
        .all(|entry| entry.source == replacement_source)));

    let value = Value::Map(vec![
        (
            Value::Binary(vec![0x55; 15]),
            Value::Map(vec![(Value::from("until"), Value::Nil)]),
        ),
        (
            Value::Binary(vec![0x44; 16]),
            Value::Map(vec![(Value::from("until"), Value::Nil)]),
        ),
    ]);
    assert!(RnsBlackholeTable::decode_source_file(
        &encode_value(value),
        source(),
        InstantMillis(0)
    )
    .is_ok_and(|entries| entries.into_entries().len() == 1));
}

proptest! {
    #[test]
    fn arbitrary_blackhole_input_is_a_total_decode(bytes in prop::collection::vec(any::<u8>(), 0..512)) {
        let _ = RnsBlackholeTable::decode_source_file(&bytes, source(), InstantMillis(0));
        let _ = RnsBlackholeTable::decode_published_table(&bytes, InstantMillis(0));
    }
}

#[test]
fn declared_giant_map_does_not_preallocate_from_untrusted_length() {
    assert_eq!(
        RnsBlackholeTable::decode_source_file(
            &[0xdf, 0xff, 0xff, 0xff, 0xff],
            source(),
            InstantMillis(0),
        ),
        Err(RnsBlackholeDecodeError::MessagePack)
    );
}

fn source_map(until: Value) -> Vec<u8> {
    encode_value(Value::Map(vec![(
        Value::Binary(vec![0x44; 16]),
        Value::Map(vec![(Value::from("until"), until)]),
    )]))
}

fn encode_value(value: Value) -> Vec<u8> {
    let mut bytes = Vec::new();
    assert!(rmpv::encode::write_value(&mut bytes, &value).is_ok());
    bytes
}