mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-10 08:37:02 +00:00
Merge refactor-sans-io: FMP sans-IO connection-lifecycle on the next line
This commit is contained in:
+1
-282
@@ -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
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user