Add FMP node profile negotiation with mixed-profile integration tests

NodeProfile enum (Full/NonRouting/Leaf) with FMP feature bitfield:
bits 0-2 profile, bits 3-6 MMP wants/provides, bit 7 bloom filter
size negotiable. Bloom size TLV always sent with min=max=1KB.

Config mapping: leaf_only -> Leaf, disable_routing -> NonRouting.
Profile and agreed bloom size stored on PeerConnection and
ActivePeer. Handshake msg2/msg3 carry FMP negotiation payload
with profile validation and bloom size agreement.

MMP report sending gated by profile wants/provides. Parent
selection and routing constraints skip non-full peers. One-way
bloom filters for non-routing peers (F inserts N as dependent).
Leaf mode: single-peer enforcement, suppress tree announces and
discovery forwarding.

Mixed-profile integration test: A(Full) + B(Full) + C(NonRouting)
+ D(Leaf) with 9 connectivity assertions.
This commit is contained in:
Johnathan Corgan
2026-04-11 08:16:01 +00:00
parent 8357200b0e
commit f4278f1dd7
20 changed files with 1323 additions and 299 deletions
+5 -1
View File
@@ -37,7 +37,11 @@ pub use link::{
pub use tree::TreeAnnounce;
pub use filter::FilterAnnounce;
pub use discovery::{LookupRequest, LookupResponse};
pub use negotiation::{NegotiationPayload, TlvEntry, NEGOTIATION_HEADER_SIZE};
pub use negotiation::{
BloomSizeRange, NegotiationPayload, NodeProfile, TlvEntry, NEGOTIATION_HEADER_SIZE,
FMP_FEAT_BLOOM_SIZE_NEG, FMP_FEAT_PROFILE_MASK, FMP_FEAT_PROVIDES_RR, FMP_FEAT_PROVIDES_SR,
FMP_FEAT_WANTS_RR, FMP_FEAT_WANTS_SR, TLV_BLOOM_SIZE,
};
pub use session::{
CoordsRequired, FspFlags, FspInnerFlags, MtuExceeded, PathBroken, PathMtuNotification,
SessionAck, SessionFlags, SessionMessageType, SessionMsg3, SessionReceiverReport,
+396
View File
@@ -22,6 +22,74 @@ 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;
/// Bit 7: Bloom filter size is negotiable (check TLV).
pub const FMP_FEAT_BLOOM_SIZE_NEG: u64 = 1 << 7;
// --- TLV field numbers ---
/// TLV field for bloom filter size classes: `[min_class:1][max_class:1]`.
pub const TLV_BLOOM_SIZE: u16 = 1;
// --- 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 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}"
))),
}
}
}
/// Bloom filter size class range from TLV negotiation.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BloomSizeRange {
/// Minimum supported size class (512 << min_class bytes).
pub min_class: u8,
/// Maximum supported size class (512 << max_class bytes).
pub max_class: u8,
}
/// A TLV entry in the negotiation payload.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TlvEntry {
@@ -163,6 +231,146 @@ impl NegotiationPayload {
}
Ok(agreed)
}
// --- FMP-specific helpers ---
/// Build an FMP negotiation payload for the given node profile.
///
/// Sets the profile bits, MMP wants/provides defaults for the profile,
/// bloom size negotiable bit, and bloom size TLV with the current
/// default size class (min=max=V1_SIZE_CLASS).
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;
}
features |= FMP_FEAT_BLOOM_SIZE_NEG;
let bloom_size_class = crate::bloom::V1_SIZE_CLASS;
Self::new(version_min, version_max, features)
.with_tlv(TLV_BLOOM_SIZE, vec![bloom_size_class, bloom_size_class])
}
/// 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
}
/// Whether bloom filter size is negotiable.
pub fn bloom_size_negotiable(&self) -> bool {
self.features & FMP_FEAT_BLOOM_SIZE_NEG != 0
}
/// Extract bloom size range from TLV, if present.
pub fn bloom_size_range(&self) -> Result<Option<BloomSizeRange>, ProtocolError> {
for entry in &self.tlv_entries {
if entry.field_num == TLV_BLOOM_SIZE {
if entry.value.len() != 2 {
return Err(ProtocolError::Malformed(format!(
"bloom size TLV: expected 2 bytes, got {}",
entry.value.len()
)));
}
let min_class = entry.value[0];
let max_class = entry.value[1];
if min_class > max_class {
return Err(ProtocolError::Malformed(format!(
"bloom size: min_class ({min_class}) > max_class ({max_class})"
)));
}
if max_class as usize >= crate::bloom::SIZE_CLASS_BYTES.len() {
return Err(ProtocolError::Malformed(format!(
"bloom size: max_class ({max_class}) exceeds known size classes"
)));
}
return Ok(Some(BloomSizeRange { min_class, max_class }));
}
}
Ok(None)
}
/// 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(())
}
/// Agree on a bloom filter size class with a peer.
///
/// Returns `min(our_max, their_max)`, rejecting if below either
/// side's minimum. Both sides must have the bloom size TLV and the
/// negotiable bit set.
pub fn agree_bloom_size(&self, other: &Self) -> Result<u8, ProtocolError> {
if !self.bloom_size_negotiable() || !other.bloom_size_negotiable() {
return Err(ProtocolError::Malformed(
"bloom size negotiation: both sides must set negotiable bit".to_string(),
));
}
let ours = self.bloom_size_range()?.ok_or_else(|| {
ProtocolError::Malformed("bloom size negotiation: missing TLV (ours)".to_string())
})?;
let theirs = other.bloom_size_range()?.ok_or_else(|| {
ProtocolError::Malformed("bloom size negotiation: missing TLV (theirs)".to_string())
})?;
let agreed = ours.max_class.min(theirs.max_class);
if agreed < ours.min_class || agreed < theirs.min_class {
return Err(ProtocolError::Malformed(format!(
"bloom size mismatch: ours [{},{}] theirs [{},{}]",
ours.min_class, ours.max_class, theirs.min_class, theirs.max_class
)));
}
Ok(agreed)
}
}
#[cfg(test)]
@@ -286,4 +494,192 @@ mod tests {
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());
assert!(p.bloom_size_negotiable());
let range = p.bloom_size_range().unwrap().unwrap();
assert_eq!(range.min_class, crate::bloom::V1_SIZE_CLASS);
assert_eq!(range.max_class, crate::bloom::V1_SIZE_CLASS);
}
#[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());
}
// --- Bloom size agreement tests ---
#[test]
fn test_bloom_size_agreement_identical() {
let a = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
let b = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert_eq!(a.agree_bloom_size(&b).unwrap(), crate::bloom::V1_SIZE_CLASS);
}
#[test]
fn test_bloom_size_agreement_different_ranges() {
// a supports [0,2], b supports [1,3]
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 2]);
let b = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![1, 3]);
// agreed = min(2,3) = 2, 2 >= 0 and 2 >= 1 → ok
assert_eq!(a.agree_bloom_size(&b).unwrap(), 2);
assert_eq!(b.agree_bloom_size(&a).unwrap(), 2);
}
#[test]
fn test_bloom_size_agreement_mismatch() {
// a supports [0,0], b supports [2,3]
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 0]);
let b = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![2, 3]);
// agreed = min(0,3) = 0, 0 < 2 → reject
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_missing_bit() {
let a = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
let b = NegotiationPayload::new(1, 1, 0); // no negotiable bit
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_missing_tlv() {
let a = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG); // bit set but no TLV
let b = NegotiationPayload::fmp(1, 1, NodeProfile::Full);
assert!(a.agree_bloom_size(&b).is_err());
}
#[test]
fn test_bloom_size_tlv_bad_length() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![1]); // only 1 byte, need 2
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_tlv_inverted_range() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![3, 1]); // min > max
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_tlv_class_out_of_range() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG)
.with_tlv(TLV_BLOOM_SIZE, vec![0, 4]); // max_class=4 exceeds SIZE_CLASS_BYTES
assert!(p.bloom_size_range().is_err());
}
#[test]
fn test_bloom_size_no_tlv_returns_none() {
let p = NegotiationPayload::new(1, 1, FMP_FEAT_BLOOM_SIZE_NEG);
assert_eq!(p.bloom_size_range().unwrap(), None);
}
}