Merge refactor-sans-io: FMP sans-IO connection-lifecycle on the next line

This commit is contained in:
Johnathan Corgan
2026-07-07 07:32:02 +00:00
40 changed files with 3877 additions and 2165 deletions
+1 -282
View File
@@ -1,67 +1,9 @@
//! Link-layer message types: handshake, link control, disconnect, session datagram.
//! Link-layer message types: the shared frame catalog and session datagram.
use super::ProtocolError;
use crate::NodeAddr;
use std::fmt;
// ============================================================================
// Handshake Message Types
// ============================================================================
/// Handshake message type identifiers.
///
/// These messages are exchanged during Noise XX handshake before link
/// encryption is established. They use the same TLV framing as link
/// messages but payloads are not encrypted (except Noise-internal encryption).
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum HandshakeMessageType {
/// Noise XX message 1: initiator sends ephemeral key.
/// Payload: 33 bytes (ephemeral pubkey).
Msg1 = 0x01,
/// Noise XX message 2: responder sends ephemeral + encrypted static + epoch.
/// Payload: 106+ bytes (33 ephemeral + 49 encrypted static + 24 encrypted epoch + negotiation).
Msg2 = 0x02,
/// Noise XX message 3: initiator sends encrypted static + epoch.
/// Payload: 73+ bytes (49 encrypted static + 24 encrypted epoch + negotiation).
Msg3 = 0x03,
}
impl HandshakeMessageType {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x01 => Some(HandshakeMessageType::Msg1),
0x02 => Some(HandshakeMessageType::Msg2),
0x03 => Some(HandshakeMessageType::Msg3),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
/// Check if a byte represents a handshake message type.
pub fn is_handshake(b: u8) -> bool {
matches!(b, 0x01..=0x03)
}
}
impl fmt::Display for HandshakeMessageType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
HandshakeMessageType::Msg1 => "Msg1",
HandshakeMessageType::Msg2 => "Msg2",
HandshakeMessageType::Msg3 => "Msg3",
};
write!(f, "{}", name)
}
}
// ============================================================================
// Link-Layer Message Types
// ============================================================================
@@ -151,120 +93,6 @@ impl fmt::Display for LinkMessageType {
}
}
// ============================================================================
// Disconnect Reason Codes
// ============================================================================
/// Reason for an orderly disconnect notification.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[repr(u8)]
pub enum DisconnectReason {
/// Normal shutdown (operator requested).
Shutdown = 0x00,
/// Restarting (may reconnect soon).
Restart = 0x01,
/// Protocol error encountered.
ProtocolError = 0x02,
/// Transport failure.
TransportFailure = 0x03,
/// Resource exhaustion (memory, connections).
ResourceExhaustion = 0x04,
/// Authentication or security policy violation.
SecurityViolation = 0x05,
/// Configuration change (peer removed from config).
ConfigurationChange = 0x06,
/// Timeout or keepalive failure.
Timeout = 0x07,
/// Unspecified reason.
Other = 0xFF,
}
impl DisconnectReason {
/// Try to convert from a byte.
pub fn from_byte(b: u8) -> Option<Self> {
match b {
0x00 => Some(DisconnectReason::Shutdown),
0x01 => Some(DisconnectReason::Restart),
0x02 => Some(DisconnectReason::ProtocolError),
0x03 => Some(DisconnectReason::TransportFailure),
0x04 => Some(DisconnectReason::ResourceExhaustion),
0x05 => Some(DisconnectReason::SecurityViolation),
0x06 => Some(DisconnectReason::ConfigurationChange),
0x07 => Some(DisconnectReason::Timeout),
0xFF => Some(DisconnectReason::Other),
_ => None,
}
}
/// Convert to a byte.
pub fn to_byte(self) -> u8 {
self as u8
}
}
impl fmt::Display for DisconnectReason {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let name = match self {
DisconnectReason::Shutdown => "Shutdown",
DisconnectReason::Restart => "Restart",
DisconnectReason::ProtocolError => "ProtocolError",
DisconnectReason::TransportFailure => "TransportFailure",
DisconnectReason::ResourceExhaustion => "ResourceExhaustion",
DisconnectReason::SecurityViolation => "SecurityViolation",
DisconnectReason::ConfigurationChange => "ConfigurationChange",
DisconnectReason::Timeout => "Timeout",
DisconnectReason::Other => "Other",
};
write!(f, "{}", name)
}
}
// ============================================================================
// Disconnect Message
// ============================================================================
/// Orderly disconnect notification sent before closing a peer link.
///
/// Sent as a link-layer message (type 0x50) inside an encrypted frame.
/// Allows the receiving peer to immediately clean up state rather than
/// waiting for timeout-based detection.
///
/// ## Wire Format
///
/// | Offset | Field | Size | Notes |
/// |--------|----------|--------|------------------------|
/// | 0 | msg_type | 1 byte | 0x50 |
/// | 1 | reason | 1 byte | DisconnectReason value |
#[derive(Clone, Debug)]
pub struct Disconnect {
/// Reason for disconnection.
pub reason: DisconnectReason,
}
impl Disconnect {
/// Create a new Disconnect message.
pub fn new(reason: DisconnectReason) -> Self {
Self { reason }
}
/// Encode as link-layer plaintext (msg_type + reason).
pub fn encode(&self) -> [u8; 2] {
[LinkMessageType::Disconnect.to_byte(), self.reason.to_byte()]
}
/// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, ProtocolError> {
if payload.is_empty() {
return Err(ProtocolError::MessageTooShort {
expected: 1,
got: 0,
});
}
let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other);
Ok(Self { reason })
}
}
// ============================================================================
// Session Datagram (Link-Layer Encapsulation)
// ============================================================================
@@ -427,40 +255,6 @@ pub type MessageType = LinkMessageType;
mod tests {
use super::*;
// ===== HandshakeMessageType Tests =====
#[test]
fn test_handshake_message_type_roundtrip() {
let types = [
HandshakeMessageType::Msg1,
HandshakeMessageType::Msg2,
HandshakeMessageType::Msg3,
];
for ty in types {
let byte = ty.to_byte();
let restored = HandshakeMessageType::from_byte(byte);
assert_eq!(restored, Some(ty));
}
}
#[test]
fn test_handshake_message_type_invalid() {
assert!(HandshakeMessageType::from_byte(0x00).is_none());
assert!(HandshakeMessageType::from_byte(0x04).is_none());
assert!(HandshakeMessageType::from_byte(0x10).is_none());
}
#[test]
fn test_handshake_message_type_is_handshake() {
assert!(HandshakeMessageType::is_handshake(0x01));
assert!(HandshakeMessageType::is_handshake(0x02));
assert!(HandshakeMessageType::is_handshake(0x03));
assert!(!HandshakeMessageType::is_handshake(0x00));
assert!(!HandshakeMessageType::is_handshake(0x04));
assert!(!HandshakeMessageType::is_handshake(0x10));
}
// ===== LinkMessageType Tests =====
#[test]
@@ -490,81 +284,6 @@ mod tests {
assert!(LinkMessageType::from_byte(0x40).is_none());
}
// ===== DisconnectReason Tests =====
#[test]
fn test_disconnect_reason_roundtrip() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::TransportFailure,
DisconnectReason::ResourceExhaustion,
DisconnectReason::SecurityViolation,
DisconnectReason::ConfigurationChange,
DisconnectReason::Timeout,
DisconnectReason::Other,
];
for reason in reasons {
let byte = reason.to_byte();
let restored = DisconnectReason::from_byte(byte);
assert_eq!(restored, Some(reason));
}
}
#[test]
fn test_disconnect_reason_unknown_byte() {
assert!(DisconnectReason::from_byte(0x08).is_none());
assert!(DisconnectReason::from_byte(0x80).is_none());
assert!(DisconnectReason::from_byte(0xFE).is_none());
}
// ===== Disconnect Message Tests =====
#[test]
fn test_disconnect_encode_decode() {
let msg = Disconnect::new(DisconnectReason::Shutdown);
let encoded = msg.encode();
assert_eq!(encoded.len(), 2);
assert_eq!(encoded[0], 0x50); // LinkMessageType::Disconnect
assert_eq!(encoded[1], 0x00); // DisconnectReason::Shutdown
// Decode from payload (after msg_type byte)
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Shutdown);
}
#[test]
fn test_disconnect_all_reasons() {
let reasons = [
DisconnectReason::Shutdown,
DisconnectReason::Restart,
DisconnectReason::ProtocolError,
DisconnectReason::Other,
];
for reason in reasons {
let msg = Disconnect::new(reason);
let encoded = msg.encode();
let decoded = Disconnect::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.reason, reason);
}
}
#[test]
fn test_disconnect_decode_empty_payload() {
let result = Disconnect::decode(&[]);
assert!(result.is_err());
}
#[test]
fn test_disconnect_decode_unknown_reason() {
let decoded = Disconnect::decode(&[0x80]).unwrap();
assert_eq!(decoded.reason, DisconnectReason::Other);
}
// ===== SessionDatagram Tests =====
fn make_node_addr(val: u8) -> NodeAddr {
+1 -7
View File
@@ -23,7 +23,6 @@
mod error;
mod filter;
mod link;
mod negotiation;
pub(crate) mod session;
mod tree;
@@ -31,12 +30,7 @@ mod tree;
pub use error::ProtocolError;
pub use filter::{FilterAnnounce, FilterNack};
pub use link::{
Disconnect, DisconnectReason, HandshakeMessageType, LinkMessageType,
SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
};
pub use negotiation::{
FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR, FMP_FEAT_WANTS_RR,
FMP_FEAT_WANTS_SR, NEGOTIATION_HEADER_SIZE, NegotiationPayload, NodeProfile, TlvEntry,
LinkMessageType, SESSION_DATAGRAM_HEADER_SIZE, SessionDatagram, SessionDatagramRef,
};
pub use session::{
FspFlags, FspInnerFlags, PATH_MTU_NOTIFICATION_SIZE, PathMtuNotification,
-552
View File
@@ -1,552 +0,0 @@
//! Protocol negotiation payload codec.
//!
//! Encodes/decodes the negotiation payload embedded in XX handshake
//! messages (msg2/msg3). Each layer (FMP, FSP) uses the same wire
//! format with layer-specific version ranges and feature catalogs.
//!
//! ## Wire Format
//!
//! ```text
//! Byte 0: format (must be 0)
//! Byte 1: [version_min:4 high][version_max:4 low]
//! Bytes 2-9: feature bitfield (64 bits, LE)
//! Bytes 10+: TLV entries, each:
//! [field_num:2 LE][length:2 LE][value:N]
//! ```
//!
//! ## Node Profile Decision Tree
//!
//! Profiles are self-declared (bits 0-2 of the feature bitfield):
//!
//! - **Full** (0): Full routing. Combines bloom filters from children,
//! forwards transit traffic, participates in spanning tree.
//! - **NonRouting** (1): Tree participation but no transit forwarding.
//! Receives bloom filters (one-way: F→N) but does not send them.
//! The full peer inserts N's identity via `leaf_dependents`.
//! - **Leaf** (2): Single upstream peer, no tree/bloom/transit.
//! Full peer inserts L's identity via `leaf_dependents`.
//!
//! **Link pairing rule**: at least one side must be Full. Invalid
//! pairings (N↔N, N↔L, L↔L) are rejected during FMP negotiation.
//!
//! **Routing implications**: `forward_lookup_request()` only considers
//! Full peers as transit. `peer_inbound_filters()` excludes non-Full
//! peers from bloom filter merging.
use super::ProtocolError;
/// Size of the fixed negotiation header (format + version + features).
pub const NEGOTIATION_HEADER_SIZE: usize = 10;
/// Format byte value for the initial negotiation format.
const NEGOTIATION_FORMAT_V0: u8 = 0;
// --- FMP feature bitfield constants ---
/// Mask for the 3-bit node profile enum (bits 0-2).
pub const FMP_FEAT_PROFILE_MASK: u64 = 0x07;
/// Bit 3: Can provide MMP sender reports.
pub const FMP_FEAT_PROVIDES_SR: u64 = 1 << 3;
/// Bit 4: Can provide MMP receiver reports.
pub const FMP_FEAT_PROVIDES_RR: u64 = 1 << 4;
/// Bit 5: Want MMP sender reports from peer.
pub const FMP_FEAT_WANTS_SR: u64 = 1 << 5;
/// Bit 6: Want MMP receiver reports from peer.
pub const FMP_FEAT_WANTS_RR: u64 = 1 << 6;
// --- Node profile enum ---
/// Node profile advertised during FMP negotiation.
///
/// Encoded in bits 0-2 of the FMP feature bitfield. Self-declared (not
/// AND-intersected). At least one side of a link must be `Full` or the
/// link is rejected.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NodeProfile {
/// Full routing node. Combines bloom filters, forwards transit.
Full = 0,
/// Non-routing node. Tree participation, one-way bloom receipt,
/// no transit forwarding.
NonRouting = 1,
/// Leaf node. Single upstream peer, no tree/bloom/transit.
Leaf = 2,
}
impl std::fmt::Display for NodeProfile {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Full => write!(f, "full"),
Self::NonRouting => write!(f, "non-routing"),
Self::Leaf => write!(f, "leaf"),
}
}
}
impl TryFrom<u8> for NodeProfile {
type Error = ProtocolError;
fn try_from(value: u8) -> Result<Self, Self::Error> {
match value {
0 => Ok(Self::Full),
1 => Ok(Self::NonRouting),
2 => Ok(Self::Leaf),
_ => Err(ProtocolError::Malformed(format!(
"unknown node profile: {value}"
))),
}
}
}
/// A TLV entry in the negotiation payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlvEntry {
/// Field number identifying this TLV.
pub field_num: u16,
/// Raw value bytes.
pub value: Vec<u8>,
}
/// Protocol negotiation payload.
///
/// Carried in XX msg2/msg3 encrypted payloads. Shared codec for both
/// FMP and FSP layers, with layer-specific version ranges and feature
/// bit assignments.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NegotiationPayload {
/// Format byte (must be 0).
pub format: u8,
/// Minimum protocol version supported (4-bit, 0-15).
pub version_min: u8,
/// Maximum protocol version supported (4-bit, 0-15).
pub version_max: u8,
/// Feature bitfield (64 bits, LE).
pub features: u64,
/// Optional TLV extension entries.
pub tlv_entries: Vec<TlvEntry>,
}
impl NegotiationPayload {
/// Create a new negotiation payload.
pub fn new(version_min: u8, version_max: u8, features: u64) -> Self {
Self {
format: NEGOTIATION_FORMAT_V0,
version_min,
version_max,
features,
tlv_entries: Vec::new(),
}
}
/// Add a TLV entry.
pub fn with_tlv(mut self, field_num: u16, value: Vec<u8>) -> Self {
self.tlv_entries.push(TlvEntry { field_num, value });
self
}
/// Encode to wire format.
pub fn encode(&self) -> Vec<u8> {
let mut buf = Vec::with_capacity(NEGOTIATION_HEADER_SIZE);
buf.push(self.format);
buf.push((self.version_min << 4) | (self.version_max & 0x0F));
buf.extend_from_slice(&self.features.to_le_bytes());
for entry in &self.tlv_entries {
buf.extend_from_slice(&entry.field_num.to_le_bytes());
let len = entry.value.len() as u16;
buf.extend_from_slice(&len.to_le_bytes());
buf.extend_from_slice(&entry.value);
}
buf
}
/// Decode from wire format.
pub fn decode(data: &[u8]) -> Result<Self, ProtocolError> {
if data.len() < NEGOTIATION_HEADER_SIZE {
return Err(ProtocolError::MessageTooShort {
expected: NEGOTIATION_HEADER_SIZE,
got: data.len(),
});
}
let format = data[0];
if format != NEGOTIATION_FORMAT_V0 {
return Err(ProtocolError::Malformed(format!(
"unknown negotiation format: {format}"
)));
}
let version_min = data[1] >> 4;
let version_max = data[1] & 0x0F;
if version_min > version_max {
return Err(ProtocolError::Malformed(format!(
"version_min ({version_min}) > version_max ({version_max})"
)));
}
let features = u64::from_le_bytes(data[2..10].try_into().unwrap());
let mut tlv_entries = Vec::new();
let mut offset = NEGOTIATION_HEADER_SIZE;
while offset < data.len() {
// Need at least 4 bytes for field_num + length
if offset + 4 > data.len() {
return Err(ProtocolError::Malformed("truncated TLV header".to_string()));
}
let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
let length =
u16::from_le_bytes(data[offset + 2..offset + 4].try_into().unwrap()) as usize;
offset += 4;
if offset + length > data.len() {
return Err(ProtocolError::Malformed(format!(
"TLV field {field_num}: declared length {length} exceeds remaining data {}",
data.len() - offset
)));
}
let value = data[offset..offset + length].to_vec();
offset += length;
tlv_entries.push(TlvEntry { field_num, value });
}
Ok(Self {
format,
version_min,
version_max,
features,
tlv_entries,
})
}
/// Agree on a protocol version with a peer's negotiation payload.
///
/// Returns `min(our_max, their_max)`, rejecting if the agreed version
/// is below either side's minimum.
pub fn agree_version(&self, other: &Self) -> Result<u8, ProtocolError> {
let agreed = self.version_max.min(other.version_max);
if agreed < self.version_min || agreed < other.version_min {
return Err(ProtocolError::Malformed(format!(
"version mismatch: ours [{},{}] theirs [{},{}]",
self.version_min, self.version_max, other.version_min, other.version_max
)));
}
Ok(agreed)
}
// --- FMP-specific helpers ---
/// Build an FMP negotiation payload for the given node profile.
///
/// Sets the profile bits and MMP wants/provides defaults for the profile.
pub fn fmp(version_min: u8, version_max: u8, profile: NodeProfile) -> Self {
let (provides_sr, provides_rr, wants_sr, wants_rr) = match profile {
NodeProfile::Full => (true, true, true, true),
NodeProfile::NonRouting => (true, true, false, true),
NodeProfile::Leaf => (false, true, false, false),
};
let mut features = (profile as u8 as u64) & FMP_FEAT_PROFILE_MASK;
if provides_sr {
features |= FMP_FEAT_PROVIDES_SR;
}
if provides_rr {
features |= FMP_FEAT_PROVIDES_RR;
}
if wants_sr {
features |= FMP_FEAT_WANTS_SR;
}
if wants_rr {
features |= FMP_FEAT_WANTS_RR;
}
Self::new(version_min, version_max, features)
}
/// Extract the node profile from the FMP feature bitfield.
pub fn node_profile(&self) -> Result<NodeProfile, ProtocolError> {
let raw = (self.features & FMP_FEAT_PROFILE_MASK) as u8;
NodeProfile::try_from(raw)
}
/// Whether this peer can provide MMP sender reports.
pub fn provides_sr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_SR != 0
}
/// Whether this peer can provide MMP receiver reports.
pub fn provides_rr(&self) -> bool {
self.features & FMP_FEAT_PROVIDES_RR != 0
}
/// Whether this peer wants MMP sender reports.
pub fn wants_sr(&self) -> bool {
self.features & FMP_FEAT_WANTS_SR != 0
}
/// Whether this peer wants MMP receiver reports.
pub fn wants_rr(&self) -> bool {
self.features & FMP_FEAT_WANTS_RR != 0
}
/// Validate that two profiles form a valid link pairing.
///
/// At least one side must be `Full` or the link is rejected.
pub fn validate_profiles(ours: NodeProfile, theirs: NodeProfile) -> Result<(), ProtocolError> {
if ours != NodeProfile::Full && theirs != NodeProfile::Full {
return Err(ProtocolError::Malformed(format!(
"invalid profile pairing: {} <-> {} (at least one must be full)",
ours, theirs
)));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_encode_decode_roundtrip() {
let payload = NegotiationPayload::new(1, 3, 0x00000000_0000002A);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
}
#[test]
fn test_encode_decode_with_tlv() {
let payload = NegotiationPayload::new(0, 1, 0)
.with_tlv(1, vec![0xAA, 0xBB])
.with_tlv(256, vec![0x01, 0x02, 0x03, 0x04]);
let encoded = payload.encode();
// 10 header + (2+2+2) + (2+2+4) = 10 + 6 + 8 = 24
assert_eq!(encoded.len(), 24);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, payload);
assert_eq!(decoded.tlv_entries.len(), 2);
assert_eq!(decoded.tlv_entries[0].field_num, 1);
assert_eq!(decoded.tlv_entries[0].value, vec![0xAA, 0xBB]);
assert_eq!(decoded.tlv_entries[1].field_num, 256);
assert_eq!(decoded.tlv_entries[1].value, vec![0x01, 0x02, 0x03, 0x04]);
}
#[test]
fn test_version_agreement_basic() {
let ours = NegotiationPayload::new(1, 3, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 2);
}
#[test]
fn test_version_agreement_mismatch() {
let ours = NegotiationPayload::new(3, 5, 0);
let theirs = NegotiationPayload::new(1, 2, 0);
assert!(ours.agree_version(&theirs).is_err());
}
#[test]
fn test_version_agreement_asymmetric() {
// Ours: [2,5], theirs: [1,4] → agreed = min(5,4) = 4, 4 >= 2 and 4 >= 1 → ok
let ours = NegotiationPayload::new(2, 5, 0);
let theirs = NegotiationPayload::new(1, 4, 0);
assert_eq!(ours.agree_version(&theirs).unwrap(), 4);
// Ours: [1,4], theirs: [2,5] → agreed = min(4,5) = 4, 4 >= 1 and 4 >= 2 → ok
assert_eq!(theirs.agree_version(&ours).unwrap(), 4);
}
#[test]
fn test_unknown_format_rejected() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
data[0] = 1; // Set format to 1
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_invalid_version_range() {
let mut data = NegotiationPayload::new(0, 0, 0).encode();
// Set version_min=5, version_max=3 (invalid: min > max)
data[1] = (5 << 4) | 3;
assert!(NegotiationPayload::decode(&data).is_err());
}
#[test]
fn test_unknown_tlv_forward_compat() {
// Unknown field_nums should be preserved through encode/decode
let payload = NegotiationPayload::new(0, 1, 0).with_tlv(9999, vec![0xFF, 0xFE, 0xFD]);
let encoded = payload.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.tlv_entries.len(), 1);
assert_eq!(decoded.tlv_entries[0].field_num, 9999);
assert_eq!(decoded.tlv_entries[0].value, vec![0xFF, 0xFE, 0xFD]);
}
#[test]
fn test_empty_payload() {
let payload = NegotiationPayload::new(0, 0, 0);
let encoded = payload.encode();
assert_eq!(encoded.len(), NEGOTIATION_HEADER_SIZE);
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded.version_min, 0);
assert_eq!(decoded.version_max, 0);
assert_eq!(decoded.features, 0);
assert!(decoded.tlv_entries.is_empty());
}
#[test]
fn test_truncated_payload() {
// Less than header size
assert!(NegotiationPayload::decode(&[0u8; 5]).is_err());
assert!(NegotiationPayload::decode(&[]).is_err());
}
#[test]
fn test_truncated_tlv() {
let payload = NegotiationPayload::new(0, 1, 0).with_tlv(1, vec![0xAA, 0xBB, 0xCC]);
let mut encoded = payload.encode();
// Truncate the TLV value (remove last byte)
encoded.pop();
assert!(NegotiationPayload::decode(&encoded).is_err());
// Truncate to just partial TLV header (only 2 of 4 header bytes)
let mut partial = NegotiationPayload::new(0, 1, 0).encode();
partial.extend_from_slice(&[0x01, 0x00]); // Only field_num, no length
assert!(NegotiationPayload::decode(&partial).is_err());
}
// --- Node profile tests ---
#[test]
fn test_node_profile_try_from() {
assert_eq!(NodeProfile::try_from(0).unwrap(), NodeProfile::Full);
assert_eq!(NodeProfile::try_from(1).unwrap(), NodeProfile::NonRouting);
assert_eq!(NodeProfile::try_from(2).unwrap(), NodeProfile::Leaf);
assert!(NodeProfile::try_from(3).is_err());
assert!(NodeProfile::try_from(7).is_err());
}
#[test]
fn test_fmp_payload_full_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(p.wants_sr());
assert!(p.wants_rr());
}
#[test]
fn test_fmp_payload_nonrouting_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::NonRouting);
assert_eq!(p.node_profile().unwrap(), NodeProfile::NonRouting);
assert!(p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(p.wants_rr());
}
#[test]
fn test_fmp_payload_leaf_profile() {
let p = NegotiationPayload::fmp(1, 1, NodeProfile::Leaf);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Leaf);
assert!(!p.provides_sr());
assert!(p.provides_rr());
assert!(!p.wants_sr());
assert!(!p.wants_rr());
}
#[test]
fn test_fmp_payload_roundtrip() {
for profile in [
NodeProfile::Full,
NodeProfile::NonRouting,
NodeProfile::Leaf,
] {
let original = NegotiationPayload::fmp(1, 1, profile);
let encoded = original.encode();
let decoded = NegotiationPayload::decode(&encoded).unwrap();
assert_eq!(decoded, original);
assert_eq!(decoded.node_profile().unwrap(), profile);
}
}
#[test]
fn test_zero_features_is_full() {
// Full=0 means zero-initialized bitfield defaults to most capable
let p = NegotiationPayload::new(1, 1, 0);
assert_eq!(p.node_profile().unwrap(), NodeProfile::Full);
assert!(!p.provides_sr());
assert!(!p.wants_sr());
}
// --- Profile validation tests ---
#[test]
fn test_validate_profiles_valid() {
// F↔F
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Full).is_ok()
);
// F↔N
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::NonRouting)
.is_ok()
);
// N↔F
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Full)
.is_ok()
);
// F↔L
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Full, NodeProfile::Leaf).is_ok()
);
// L↔F
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Full).is_ok()
);
}
#[test]
fn test_validate_profiles_invalid() {
// N↔N
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::NonRouting)
.is_err()
);
// N↔L
assert!(
NegotiationPayload::validate_profiles(NodeProfile::NonRouting, NodeProfile::Leaf)
.is_err()
);
// L↔N
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::NonRouting)
.is_err()
);
// L↔L
assert!(
NegotiationPayload::validate_profiles(NodeProfile::Leaf, NodeProfile::Leaf).is_err()
);
}
}