mirror of
https://github.com/jmcorgan/fips.git
synced 2026-08-12 09:33:23 +00:00
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:
+5
-1
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user