Merge refactor-sans-io: sans-IO proto cleanup into the next line

Forward-merge the sans-IO cleanup series (bloom fpr, discovery const/RNG-injection,
mmp state decomposition, mmp shell dissolution, STP clock-free classify core, STP
declaration split, shared proto/coord relocation, shared rate limiter/backoff,
typed proto errors dropping thiserror, shared bounds-checked codec reader/writer,
core/alloc import sweep, no_std-shaped filter math) onto the next wire-format line.

Reconciled master structure against the next wire semantics: the mmp role-module
split adopts next slim report format (spin-bit stays dropped, sender/receiver
build next reports), the discovery and fmp codecs keep next TLV and profile
negotiation while moving to the typed error, and the parent-eval handler keeps the
Full/Leaf profile filter under the new ParentEval/is_switch_suppressed seam.
This commit is contained in:
Johnathan Corgan
2026-07-08 17:52:24 +00:00
62 changed files with 2891 additions and 2473 deletions
Generated
+1
View File
@@ -1087,6 +1087,7 @@ dependencies = [
"hex",
"hkdf",
"libc",
"libm",
"mdns-sd",
"nostr",
"nostr-sdk",
+1
View File
@@ -17,6 +17,7 @@ secp256k1 = { version = "0.30", features = ["rand", "global-context"] }
sha2 = "0.10"
hkdf = "0.12"
ring = "0.17"
libm = "0.2"
rand = "0.10.1"
crossbeam-channel = "0.5"
thiserror = "2.0"
+2 -2
View File
@@ -34,8 +34,8 @@ use thiserror::Error;
pub use gateway::{ConntrackConfig, GatewayConfig, GatewayDnsConfig, PortForward, Proto};
pub use node::{
BloomConfig, BuffersConfig, CacheConfig, ControlConfig, DiscoveryConfig, LimitsConfig,
NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig, RekeyConfig,
RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
MmpConfig, NodeConfig, NostrDiscoveryConfig, NostrDiscoveryPolicy, RateLimitConfig,
RekeyConfig, RetryConfig, SessionConfig, SessionMmpConfig, TreeConfig,
};
pub use peer::{ConnectPolicy, PeerAddress, PeerConfig};
pub use transport::{
+65 -1
View File
@@ -7,7 +7,6 @@
use serde::{Deserialize, Serialize};
use super::IdentityConfig;
use crate::mmp::MmpConfig;
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
// ============================================================================
@@ -731,6 +730,41 @@ impl SessionConfig {
}
}
/// MMP configuration (`node.mmp.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MmpConfig {
/// Operating mode (`node.mmp.mode`).
#[serde(default)]
pub mode: MmpMode,
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
#[serde(default = "MmpConfig::default_log_interval_secs")]
pub log_interval_secs: u64,
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
#[serde(default = "MmpConfig::default_owd_window_size")]
pub owd_window_size: usize,
}
impl Default for MmpConfig {
fn default() -> Self {
Self {
mode: MmpMode::default(),
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
}
}
}
impl MmpConfig {
fn default_log_interval_secs() -> u64 {
DEFAULT_LOG_INTERVAL_SECS
}
fn default_owd_window_size() -> usize {
DEFAULT_OWD_WINDOW_SIZE
}
}
/// Session-layer Metrics Measurement Protocol (`node.session_mmp.*`).
///
/// Separate from link-layer `node.mmp.*` to allow independent mode/interval
@@ -1112,6 +1146,36 @@ impl NodeConfig {
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = MmpConfig::default();
assert_eq!(config.mode, MmpMode::Full);
assert_eq!(config.log_interval_secs, 30);
assert_eq!(config.owd_window_size, 32);
}
#[test]
fn test_config_yaml_parse() {
let yaml = r#"
mode: lightweight
log_interval_secs: 60
owd_window_size: 48
"#;
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.mode, MmpMode::Lightweight);
assert_eq!(config.log_interval_secs, 60);
assert_eq!(config.owd_window_size, 48);
}
#[test]
fn test_config_yaml_partial() {
let yaml = "mode: minimal";
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.mode, MmpMode::Minimal);
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
}
#[test]
fn test_ecn_config_defaults() {
let c = EcnConfig::default();
+4 -2
View File
@@ -16,7 +16,6 @@ pub mod discovery;
#[cfg(target_os = "linux")]
pub mod gateway;
pub mod identity;
pub mod mmp;
pub mod node;
pub mod noise;
pub mod peer;
@@ -24,6 +23,7 @@ pub mod perf_profile;
pub(crate) mod proto;
#[cfg(test)]
pub(crate) mod testutil;
mod time;
pub mod transport;
pub mod upper;
pub mod utils;
@@ -43,7 +43,9 @@ pub use upper::config::{DnsConfig, TunConfig};
pub use discovery::{BootstrapHandoffResult, EstablishedTraversal};
// Re-export tree types (relocated from tree:: to proto::stp)
pub use proto::stp::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError, TreeState};
pub use proto::stp::{
CoordEntry, CoordError, ParentDeclaration, TreeCoordinate, TreeError, TreeState,
};
// Re-export bloom filter types (relocated from bloom:: to proto::bloom)
pub use proto::bloom::{BloomError, BloomFilter, BloomState};
-97
View File
@@ -1,97 +0,0 @@
//! Metrics Measurement Protocol (MMP) — node-side shell.
//!
//! The MMP protocol core (estimators, sender/receiver/metrics/path-MTU state
//! machines, report codecs and the reporting decisions) lives in the sans-IO
//! [`crate::proto::mmp`] module. What remains here is the shell-only surface:
//! the serde node configuration ([`MmpConfig`]) and the monotonic millisecond
//! clock ([`mono_ms`]) the shell reads at the edge and injects into the owned
//! `u64`-ms state.
use std::sync::LazyLock;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
/// Monotonic millisecond clock for the MMP time seam.
///
/// The owned `proto::mmp` state takes injected `u64` milliseconds rather than
/// reading a clock. The shell reads this process-monotonic clock at each edge
/// and passes the value in; only deltas between two `mono_ms()` reads are ever
/// compared, so the (process-relative) epoch is immaterial.
pub(crate) fn mono_ms() -> u64 {
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
START.elapsed().as_millis() as u64
}
/// MMP configuration (`node.mmp.*`).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MmpConfig {
/// Operating mode (`node.mmp.mode`).
#[serde(default)]
pub mode: MmpMode,
/// Periodic operator log interval in seconds (`node.mmp.log_interval_secs`).
#[serde(default = "MmpConfig::default_log_interval_secs")]
pub log_interval_secs: u64,
/// OWD trend ring buffer size (`node.mmp.owd_window_size`).
#[serde(default = "MmpConfig::default_owd_window_size")]
pub owd_window_size: usize,
}
impl Default for MmpConfig {
fn default() -> Self {
Self {
mode: MmpMode::default(),
log_interval_secs: DEFAULT_LOG_INTERVAL_SECS,
owd_window_size: DEFAULT_OWD_WINDOW_SIZE,
}
}
}
impl MmpConfig {
fn default_log_interval_secs() -> u64 {
DEFAULT_LOG_INTERVAL_SECS
}
fn default_owd_window_size() -> usize {
DEFAULT_OWD_WINDOW_SIZE
}
}
#[cfg(test)]
mod tests {
use super::MmpConfig;
use crate::proto::mmp::{DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MmpMode};
#[test]
fn test_config_default() {
let config = MmpConfig::default();
assert_eq!(config.mode, MmpMode::Full);
assert_eq!(config.log_interval_secs, 30);
assert_eq!(config.owd_window_size, 32);
}
#[test]
fn test_config_yaml_parse() {
let yaml = r#"
mode: lightweight
log_interval_secs: 60
owd_window_size: 48
"#;
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.mode, MmpMode::Lightweight);
assert_eq!(config.log_interval_secs, 60);
assert_eq!(config.owd_window_size, 48);
}
#[test]
fn test_config_yaml_partial() {
let yaml = "mode: minimal";
let config: MmpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.mode, MmpMode::Minimal);
assert_eq!(config.log_interval_secs, DEFAULT_LOG_INTERVAL_SECS);
assert_eq!(config.owd_window_size, DEFAULT_OWD_WINDOW_SIZE);
}
}
+2 -2
View File
@@ -87,7 +87,7 @@ impl Node {
// operator to see one clear message, not spam.
let max_fpr = self.config().node.bloom.max_inbound_fpr;
let out_fill = sent_filter.fill_ratio();
let out_fpr = out_fill.powi(sent_filter.hash_count() as i32);
let out_fpr = sent_filter.fpr();
if out_fpr > max_fpr {
let now = std::time::Instant::now();
let should_warn = self
@@ -213,7 +213,7 @@ impl Node {
// to wipe a victim's contribution to aggregation.
let max_fpr = self.config().node.bloom.max_inbound_fpr;
let fill = announce.filter.fill_ratio();
let fpr = fill.powi(announce.filter.hash_count() as i32);
let fpr = announce.filter.fpr();
if fpr > max_fpr {
self.metrics()
.bloom
+8 -4
View File
@@ -7,13 +7,13 @@
use crate::node::Node;
use crate::node::reject::DiscoveryReject;
use crate::proto::discovery::{DiscoveryAction, LookupRequest, LookupResponse};
use crate::proto::discovery::{
DiscoveryAction, LookupRequest, LookupResponse, MAX_RECENT_DISCOVERY_REQUESTS,
};
use crate::transport::{TransportAddr, TransportId};
use crate::{NodeAddr, PeerIdentity};
use tracing::{debug, info, trace, warn};
const MAX_RECENT_DISCOVERY_REQUESTS: usize = 4096;
/// Shell adapter exposing the live routing tables to the sans-IO discovery
/// core's `RoutingView` read seam. Lives in `node` so it can read `Node`'s
/// private `peers` map and call the crate-private tree/bloom predicates.
@@ -500,7 +500,11 @@ impl Node {
let origin = *self.node_addr();
let min_mtu = self.config().tun.mtu();
let request = LookupRequest::generate(*target, origin, ttl, min_mtu);
let request_id = {
use rand::RngExt;
rand::rng().random()
};
let request = LookupRequest::new(request_id, *target, origin, ttl, min_mtu);
// Tree-peer selection restricted to Full peers meeting min_mtu, plus the
// single encode, live in the sans-IO core. The core keeps the tree-only
+2 -2
View File
@@ -256,7 +256,7 @@ impl Node {
};
// MMP per-frame processing and statistics
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
let ce_flag = header.flags & FLAG_CE != 0;
if let Some(peer) = self.peers.get_mut(&node_addr) {
@@ -356,7 +356,7 @@ impl Node {
} else {
return;
};
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
let mut address_changed = false;
if let Some(peer) = self.peers.get_mut(node_addr) {
peer.reset_decrypt_failures();
+14 -8
View File
@@ -13,6 +13,7 @@ use crate::proto::mmp::{
LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, ReceiverReport, RrLog,
SenderReport,
};
use crate::proto::stp::ParentEval;
use std::time::{Duration, Instant};
use tracing::{debug, info, trace, warn};
@@ -153,7 +154,7 @@ impl Node {
// Process the report: computes RTT from timestamp echo, updates
// loss rate, goodput rate, jitter trend, and ETX.
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
let (first_rtt, rr_log) =
mmp.metrics
.process_receiver_report(&rr, our_timestamp_ms, now_ms);
@@ -198,12 +199,17 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mono_now_ms = crate::mmp::mono_ms();
let mono_now_ms = crate::time::mono_ms();
let skip = self.non_full_peers();
if let Some(new_parent) =
self.tree_state
.evaluate_parent(&peer_costs, &skip, mono_now_ms)
{
// Compute the flap-dampening / hold-down veto at the edge; a mandatory
// switch bypasses it, a discretionary one is taken only if not suppressed.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
let new_parent = match self.tree_state.evaluate_parent(&peer_costs, &skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = new_parent {
let new_seq = self.tree_state.my_declaration().sequence() + 1;
let flap_dampened =
self.tree_state
@@ -278,7 +284,7 @@ impl Node {
///
/// Called from the tick handler. Also emits periodic operator logs.
pub(in crate::node) async fn check_mmp_reports(&mut self) {
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
// Build one report-gating snapshot per peer, resolving every timing read
// shell-side into a `bool`. `send_sr`/`send_rr` come from the peer's
@@ -427,7 +433,7 @@ impl Node {
// Monotonic ms for the MMP receiver's injected-`u64` liveness clock; the
// Instant `now` is still used for the shell-owned heartbeat timing and
// the session-start fallback (both `ActivePeer` Instants).
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
let heartbeat_interval = Duration::from_secs(self.config().node.heartbeat_interval_secs);
let dead_timeout = Duration::from_secs(self.config().node.link_dead_timeout_secs);
let dead_timeout_ms = dead_timeout.as_millis() as u64;
+5 -5
View File
@@ -321,7 +321,7 @@ impl Node {
if let Some(entry) = self.sessions.get_mut(src_addr)
&& let Some(mmp) = entry.mmp_mut()
{
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
mmp.receiver
.record_recv(header.counter, timestamp, plaintext.len(), ce_flag, now_ms);
let _inner_flags = FspInnerFlags::from_byte(inner_flags_byte);
@@ -1031,7 +1031,7 @@ impl Node {
/// Called from the tick handler. Also emits periodic session MMP logs.
/// Uses the collect-then-send pattern to avoid borrowing conflicts.
pub(in crate::node) async fn check_session_mmp_reports(&mut self) {
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
// Build one report-gating snapshot per session, resolving every timing
// read shell-side into a `bool`. The snapshots own only
@@ -1298,7 +1298,7 @@ impl Node {
let (_first_rtt, rr_log) =
mmp.metrics
.process_receiver_report(&rr, our_timestamp_ms, crate::mmp::mono_ms());
.process_receiver_report(&rr, our_timestamp_ms, crate::time::mono_ms());
// Re-emit the operator trace the core used to log mid-decision.
super::mmp::log_rr_outcome(&rr, our_timestamp_ms, rr_log);
@@ -1368,7 +1368,7 @@ impl Node {
let old_mtu = mmp.path_mtu.current_mtu();
let changed = mmp
.path_mtu
.apply_notification(notif.path_mtu, crate::mmp::mono_ms());
.apply_notification(notif.path_mtu, crate::time::mono_ms());
let new_mtu = mmp.path_mtu.current_mtu();
if !changed {
@@ -1606,7 +1606,7 @@ impl Node {
let old_mtu = mmp.path_mtu.current_mtu();
if mmp
.path_mtu
.apply_notification(msg.mtu, crate::mmp::mono_ms())
.apply_notification(msg.mtu, crate::time::mono_ms())
{
let new_mtu = mmp.path_mtu.current_mtu();
info!(
+1 -1
View File
@@ -667,7 +667,7 @@ impl SessionEntry {
self.rekey_jitter_secs = draw_rekey_jitter();
// Reset MMP counters to avoid metric discontinuity
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
if let Some(mmp) = &mut self.mmp {
mmp.reset_for_rekey(now_ms);
}
+5 -5
View File
@@ -1144,11 +1144,11 @@ async fn test_open_discovery_sweep_queues_eligible_skips_filtered() {
/// (t=1100ms, 3100ms, 7100ms) and unreachable at t=15100ms.
/// 2. **Fresh `initiate_lookup` per attempt** — `req_initiated` counter
/// increments by exactly one on each retry. The actual `request_id`
/// is generated by `LookupRequest::generate(...)` via `rand::random()`
/// inside `initiate_lookup` and is not stored on the originator
/// side, so per-attempt freshness is verified indirectly: each
/// `req_initiated` increment corresponds to one fresh
/// `LookupRequest::generate` call.
/// is drawn via `rand::rng().random()` at the shell inside
/// `initiate_lookup` and passed to `LookupRequest::new(...)`; it is
/// not stored on the originator side, so per-attempt freshness is
/// verified indirectly: each `req_initiated` increment corresponds
/// to one fresh `initiate_lookup` call.
/// 3. **Final-timeout state transitions** — `pending_lookups` entry is
/// removed, `discovery.resp_timed_out` counter ticks, queued packet
/// is drained, and an ICMPv6 Destination Unreachable frame is
+12 -12
View File
@@ -12,12 +12,12 @@
//!
//! Assertions capture what the code does today, surprising or not.
//!
//! Report-generation is probed through the reused `src/mmp/` primitives
//! Report-generation is probed through the reused `proto/mmp/` primitives
//! (`should_send_report` / `should_send_notification`): after a handler tick,
//! a *consumed* interval reads as "not due" (the report was built) while an
//! *ungated* interval still reads as "due" (the report was suppressed by the
//! mode/flag gate). This survives the later refactor because those primitives
//! stay in `src/mmp/` unchanged.
//! stay in `proto/mmp/` unchanged.
//!
//! Two `#[cfg(test)]` production seams are used, both on `ActivePeer`:
//! * `test_init_mmp(mode)` — attach link MMP with a chosen mode to a
@@ -55,7 +55,7 @@ fn arm_link_mmp(node: &mut Node, addr: &NodeAddr) {
let mmp = node.get_peer_mut(addr).unwrap().mmp_mut().unwrap();
mmp.sender.record_sent(1, 100, 500);
mmp.receiver
.record_recv(1, 100, 500, false, crate::mmp::mono_ms());
.record_recv(1, 100, 500, false, crate::time::mono_ms());
}
/// Complete an in-memory Noise XX handshake, returning the initiator session.
@@ -110,7 +110,7 @@ fn arm_session_mmp(node: &mut Node, addr: &NodeAddr) {
let mmp = node.sessions.get_mut(addr).unwrap().mmp_mut().unwrap();
mmp.sender.record_sent(1, 100, 500);
mmp.receiver
.record_recv(1, 100, 500, false, crate::mmp::mono_ms());
.record_recv(1, 100, 500, false, crate::time::mono_ms());
}
// ===========================================================================
@@ -128,7 +128,7 @@ async fn mmp_full_mode_builds_sender_and_receiver_reports() {
node.check_mmp_reports().await;
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
let now = crate::mmp::mono_ms();
let now = crate::time::mono_ms();
assert!(
!mmp.sender.should_send_report(now),
"Full mode consumes the sender interval (SenderReport built)"
@@ -150,7 +150,7 @@ async fn mmp_lightweight_mode_builds_receiver_report_only() {
node.check_mmp_reports().await;
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
let now = crate::mmp::mono_ms();
let now = crate::time::mono_ms();
assert!(
mmp.sender.should_send_report(now),
"Lightweight mode suppresses the SenderReport (sender interval intact)"
@@ -171,7 +171,7 @@ async fn mmp_minimal_mode_builds_nothing() {
node.check_mmp_reports().await;
let mmp = node.get_peer(&addr).unwrap().mmp().unwrap();
let now = crate::mmp::mono_ms();
let now = crate::time::mono_ms();
assert!(
mmp.sender.should_send_report(now),
"Minimal mode suppresses the SenderReport"
@@ -195,7 +195,7 @@ async fn mmp_should_log_marks_logged_once_per_interval() {
.unwrap()
.mmp()
.unwrap()
.should_log(crate::mmp::mono_ms()),
.should_log(crate::time::mono_ms()),
"a freshly created peer is due for its first operator log"
);
@@ -207,7 +207,7 @@ async fn mmp_should_log_marks_logged_once_per_interval() {
.unwrap()
.mmp()
.unwrap()
.should_log(crate::mmp::mono_ms()),
.should_log(crate::time::mono_ms()),
"after one tick the log is marked and not due again within the interval"
);
}
@@ -227,7 +227,7 @@ async fn session_full_mode_builds_sender_and_receiver_reports() {
node.check_session_mmp_reports().await;
let mmp = node.get_session(&addr).unwrap().mmp().unwrap();
let now = crate::mmp::mono_ms();
let now = crate::time::mono_ms();
assert!(
!mmp.sender.should_send_report(now),
"Full session consumes the sender interval"
@@ -255,7 +255,7 @@ async fn session_minimal_mode_still_sends_path_mtu() {
.path_mtu
.observe_incoming_mtu(1200);
let now_before = crate::mmp::mono_ms();
let now_before = crate::time::mono_ms();
assert!(
node.get_session(&addr)
.unwrap()
@@ -269,7 +269,7 @@ async fn session_minimal_mode_still_sends_path_mtu() {
node.check_session_mmp_reports().await;
let mmp = node.get_session(&addr).unwrap().mmp().unwrap();
let now = crate::mmp::mono_ms();
let now = crate::time::mono_ms();
assert!(
mmp.sender.should_send_report(now),
"Minimal mode suppresses the session SenderReport"
+1 -1
View File
@@ -1909,7 +1909,7 @@ async fn test_tun_outbound_path_mtu_generates_ptb() {
let entry = nodes[0].node.get_session_mut(&node1_addr).unwrap();
let mmp = entry.mmp_mut().unwrap();
mmp.path_mtu
.apply_notification(reduced_mtu, crate::mmp::mono_ms());
.apply_notification(reduced_mtu, crate::time::mono_ms());
assert_eq!(mmp.path_mtu.current_mtu(), reduced_mtu);
}
+17 -5
View File
@@ -330,9 +330,18 @@ impl Node {
// Monotonic ms for the flap-dampening / hold-down timers (distinct from
// the wall-clock `now_ms` above used for the peer's tree position). Read
// once and threaded into classify + the state mutators.
let mono_now_ms = crate::mmp::mono_ms();
let mono_now_ms = crate::time::mono_ms();
// Compute the flap-dampening / hold-down veto at the edge; the classify core
// is clock-free and consumes only this pre-computed verdict.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
match Stp::classify_announce(&self.tree_state, *from, &peer_costs, &skip, mono_now_ms) {
match Stp::classify_announce(
&self.tree_state,
*from,
&peer_costs,
&skip,
switch_suppressed,
) {
TreeDecision::Switch {
new_parent,
new_seq,
@@ -582,9 +591,12 @@ impl Node {
// Monotonic ms for the flap-dampening / hold-down timers, read once and
// threaded into classify + the state mutators.
let mono_now_ms = crate::mmp::mono_ms();
let mono_now_ms = crate::time::mono_ms();
// Compute the flap-dampening / hold-down veto at the edge; the classify core
// is clock-free and consumes only this pre-computed verdict.
let switch_suppressed = self.tree_state.is_switch_suppressed(mono_now_ms);
match Stp::classify_periodic(&self.tree_state, &peer_costs, &skip, mono_now_ms) {
match Stp::classify_periodic(&self.tree_state, &peer_costs, &skip, switch_suppressed) {
TreeDecision::Switch {
new_parent,
new_seq,
@@ -735,7 +747,7 @@ impl Node {
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let mono_now_ms = crate::mmp::mono_ms();
let mono_now_ms = crate::time::mono_ms();
// Removal is not a pure classify: `handle_parent_lost` is a &mut mutator
// whose returned `changed` bool IS the decision. Drive it and map the
+3 -3
View File
@@ -3,7 +3,7 @@
//! Represents a fully authenticated peer after successful Noise handshake.
//! ActivePeer holds tree state, Bloom filter, and routing information.
use crate::mmp::MmpConfig;
use crate::config::MmpConfig;
use crate::node::REKEY_JITTER_SECS;
use crate::noise::{HandshakeState as NoiseHandshakeState, NoiseError, NoiseSession};
use crate::proto::bloom::BloomFilter;
@@ -1116,7 +1116,7 @@ impl ActivePeer {
self.reset_replay_suppressed();
// Reset MMP counters to avoid metric discontinuity
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
if let Some(mmp) = &mut self.mmp {
mmp.reset_for_rekey(now_ms);
}
@@ -1153,7 +1153,7 @@ impl ActivePeer {
self.reset_replay_suppressed();
// Reset MMP counters to avoid metric discontinuity
let now_ms = crate::mmp::mono_ms();
let now_ms = crate::time::mono_ms();
if let Some(mmp) = &mut self.mmp {
mmp.reset_for_rekey(now_ms);
}
+7 -2
View File
@@ -139,6 +139,11 @@ impl BloomFilter {
self.count_ones() as f64 / self.num_bits as f64
}
/// Current false-positive rate: fill ratio raised to the hash count.
pub fn fpr(&self) -> f64 {
crate::proto::math::powi(self.fill_ratio(), self.hash_count as u32)
}
/// Estimate the number of elements in the filter.
///
/// Uses the formula: n = -(m/k) * ln(1 - X/m)
@@ -160,12 +165,12 @@ impl BloomFilter {
}
let fill = x / m;
let fpr = fill.powi(self.hash_count as i32);
let fpr = self.fpr();
if fpr > max_fpr {
return None;
}
Some(-(m / k) * (1.0 - fill).ln())
Some(-(m / k) * libm::log(1.0 - fill))
}
/// Check if the filter is empty.
+29 -7
View File
@@ -26,22 +26,44 @@ mod wire;
#[cfg(test)]
mod tests;
use thiserror::Error;
pub use core::BloomFilter;
pub use limits::{DEFAULT_FILTER_SIZE_BITS, DEFAULT_HASH_COUNT, V1_SIZE_CLASS};
pub use state::BloomState;
pub use wire::FilterAnnounce;
/// Errors related to Bloom filter operations.
#[derive(Debug, Error)]
#[derive(Debug)]
pub enum BloomError {
#[error("invalid filter size: expected {expected} bits, got {got}")]
InvalidSize { expected: usize, got: usize },
/// Filter bit length did not match the expected size.
InvalidSize {
/// Expected number of bits.
expected: usize,
/// Number of bits received.
got: usize,
},
#[error("filter size must be a multiple of 8, got {0}")]
/// Filter size was not a multiple of 8 bits.
SizeNotByteAligned(usize),
#[error("hash count must be positive")]
/// Hash count was zero.
ZeroHashCount,
}
impl ::core::fmt::Display for BloomError {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
match self {
BloomError::InvalidSize { expected, got } => {
write!(
f,
"invalid filter size: expected {expected} bits, got {got}"
)
}
BloomError::SizeNotByteAligned(n) => {
write!(f, "filter size must be a multiple of 8, got {n}")
}
BloomError::ZeroHashCount => write!(f, "hash count must be positive"),
}
}
}
impl ::core::error::Error for BloomError {}
+1 -1
View File
@@ -1,6 +1,6 @@
//! Tests for `BloomState` (announcement state management).
use std::collections::BTreeMap;
use alloc::collections::BTreeMap;
use crate::proto::bloom::{BloomFilter, BloomState};
use crate::testutil::make_node_addr;
+9 -14
View File
@@ -1,5 +1,6 @@
//! Tests for the bloom wire codec (`FilterAnnounce`).
use crate::proto::Error;
use crate::proto::bloom::BloomFilter;
use crate::proto::bloom::FilterAnnounce;
use crate::proto::link::LinkMessageType;
@@ -66,13 +67,10 @@ fn test_filter_announce_decode_rejects_bad_size_class() {
encoded[10] = 5; // invalid size_class > MAX_SIZE_CLASS
let result = FilterAnnounce::decode(&encoded[1..]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("invalid size_class")
);
assert!(matches!(
result,
Err(Error::BadSizeClass { got: 5, max: 3 })
));
}
#[test]
@@ -83,13 +81,10 @@ fn test_filter_announce_decode_rejects_non_v1_size_class() {
let encoded = announce.encode().unwrap();
let result = FilterAnnounce::decode(&encoded[1..]);
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("unsupported size_class")
);
assert!(matches!(
result,
Err(Error::BadSizeClass { got: 0, max: 1 })
));
}
#[test]
+27 -40
View File
@@ -2,6 +2,7 @@
use super::BloomFilter;
use crate::proto::Error;
use crate::proto::codec::{Reader, Writer};
use crate::proto::link::LinkMessageType;
/// Bloom filter announcement for reachability propagation.
@@ -81,77 +82,64 @@ impl FilterAnnounce {
/// ```
pub fn encode(&self) -> Result<Vec<u8>, Error> {
if !self.is_valid() {
return Err(Error::Malformed(
"filter size does not match size_class".into(),
));
return Err(Error::Malformed("filter size does not match size_class"));
}
let filter_bytes = self.filter.as_bytes();
let size = 1 + Self::MIN_PAYLOAD_SIZE + filter_bytes.len();
let mut buf = Vec::with_capacity(size);
let mut w = Writer::with_capacity(size);
// msg_type
buf.push(LinkMessageType::FilterAnnounce.to_byte());
w.write_u8(LinkMessageType::FilterAnnounce.to_byte());
// sequence (8 LE)
buf.extend_from_slice(&self.sequence.to_le_bytes());
w.write_u64_le(self.sequence);
// hash_count
buf.push(self.hash_count);
w.write_u8(self.hash_count);
// size_class
buf.push(self.size_class);
w.write_u8(self.size_class);
// filter_bits
buf.extend_from_slice(filter_bytes);
w.write_bytes(filter_bytes);
Ok(buf)
Ok(w.into_vec())
}
/// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
///
/// The payload starts with the sequence field.
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < Self::MIN_PAYLOAD_SIZE {
return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE,
got: payload.len(),
});
}
let mut pos = 0;
let mut reader = Reader::new(payload);
reader.require(Self::MIN_PAYLOAD_SIZE)?;
// sequence (8 LE)
let sequence = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad sequence".into()))?,
);
pos += 8;
let sequence = reader.read_u64_le()?;
// hash_count
let hash_count = payload[pos];
pos += 1;
let hash_count = reader.read_u8()?;
// size_class
let size_class = payload[pos];
pos += 1;
let size_class = reader.read_u8()?;
// Validate size_class range
if size_class > Self::MAX_SIZE_CLASS {
return Err(Error::Malformed(format!(
"invalid size_class: {size_class} (max {})",
Self::MAX_SIZE_CLASS
)));
return Err(Error::BadSizeClass {
got: size_class,
max: Self::MAX_SIZE_CLASS,
});
}
// v1 compliance check
if size_class != super::V1_SIZE_CLASS {
return Err(Error::Malformed(format!(
"unsupported size_class: {size_class} (v1 requires {})",
super::V1_SIZE_CLASS
)));
return Err(Error::BadSizeClass {
got: size_class,
max: super::V1_SIZE_CLASS,
});
}
// Expected filter size from size_class
// Expected filter size from size_class. The remaining length must match
// exactly (an over-long payload is rejected too), so this stays an
// explicit `!=` check rather than a `Reader::require` lower-bound gate.
let expected_filter_bytes = 512usize << size_class;
let remaining = payload.len() - pos;
let remaining = reader.remaining();
if remaining != expected_filter_bytes {
return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE + expected_filter_bytes,
@@ -160,8 +148,7 @@ impl FilterAnnounce {
}
// Construct BloomFilter from bytes
let filter = BloomFilter::from_slice(&payload[pos..], hash_count)
.map_err(|e| Error::Malformed(format!("invalid bloom filter: {e}")))?;
let filter = BloomFilter::from_slice(reader.rest(), hash_count).map_err(Error::BadBloom)?;
let announce = Self {
filter,
+111
View File
@@ -0,0 +1,111 @@
//! Bounds-checked byte reader/writer shared across the proto wire codecs.
//!
//! `Reader` fails a short read with `Error::MessageTooShort { expected, got }`
//! where `expected` is the cumulative byte offset it needed (`position + n`) and
//! `got` is the total buffer length — reproducing the codecs' existing per-field
//! and up-front length-check values exactly.
use crate::proto::Error;
pub(crate) struct Reader<'a> {
buf: &'a [u8],
pos: usize,
}
impl<'a> Reader<'a> {
pub(crate) fn new(buf: &'a [u8]) -> Self {
Self { buf, pos: 0 }
}
#[allow(dead_code)]
pub(crate) fn position(&self) -> usize {
self.pos
}
pub(crate) fn remaining(&self) -> usize {
self.buf.len() - self.pos
}
pub(crate) fn rest(&self) -> &'a [u8] {
&self.buf[self.pos..]
}
/// Ensure at least `n` more bytes are available; else MessageTooShort.
pub(crate) fn require(&self, n: usize) -> Result<(), Error> {
if self.pos + n > self.buf.len() {
return Err(Error::MessageTooShort {
expected: self.pos + n,
got: self.buf.len(),
});
}
Ok(())
}
/// Advance the cursor by `n` (caller has already validated bounds, e.g. via a
/// sub-decoder that returned a consumed count). Debug-panics if out of range.
pub(crate) fn advance(&mut self, n: usize) {
self.pos += n;
debug_assert!(self.pos <= self.buf.len());
}
pub(crate) fn read_u8(&mut self) -> Result<u8, Error> {
self.require(1)?;
let v = self.buf[self.pos];
self.pos += 1;
Ok(v)
}
pub(crate) fn read_array<const N: usize>(&mut self) -> Result<[u8; N], Error> {
self.require(N)?;
let mut a = [0u8; N];
a.copy_from_slice(&self.buf[self.pos..self.pos + N]);
self.pos += N;
Ok(a)
}
pub(crate) fn read_u16_le(&mut self) -> Result<u16, Error> {
Ok(u16::from_le_bytes(self.read_array::<2>()?))
}
#[allow(dead_code)]
pub(crate) fn read_u32_le(&mut self) -> Result<u32, Error> {
Ok(u32::from_le_bytes(self.read_array::<4>()?))
}
pub(crate) fn read_u64_le(&mut self) -> Result<u64, Error> {
Ok(u64::from_le_bytes(self.read_array::<8>()?))
}
pub(crate) fn read_bytes(&mut self, n: usize) -> Result<&'a [u8], Error> {
self.require(n)?;
let s = &self.buf[self.pos..self.pos + n];
self.pos += n;
Ok(s)
}
}
pub(crate) struct Writer {
buf: alloc::vec::Vec<u8>,
}
impl Writer {
pub(crate) fn new() -> Self {
Self {
buf: alloc::vec::Vec::new(),
}
}
pub(crate) fn with_capacity(n: usize) -> Self {
Self {
buf: alloc::vec::Vec::with_capacity(n),
}
}
pub(crate) fn write_u8(&mut self, v: u8) {
self.buf.push(v);
}
pub(crate) fn write_u16_le(&mut self, v: u16) {
self.buf.extend_from_slice(&v.to_le_bytes());
}
#[allow(dead_code)]
pub(crate) fn write_u32_le(&mut self, v: u32) {
self.buf.extend_from_slice(&v.to_le_bytes());
}
pub(crate) fn write_u64_le(&mut self, v: u64) {
self.buf.extend_from_slice(&v.to_le_bytes());
}
pub(crate) fn write_bytes(&mut self, b: &[u8]) {
self.buf.extend_from_slice(b);
}
pub(crate) fn len(&self) -> usize {
self.buf.len()
}
pub(crate) fn into_vec(self) -> alloc::vec::Vec<u8> {
self.buf
}
}
@@ -1,9 +1,28 @@
//! Tree coordinates and distance calculations.
//! Tree coordinate addressing primitive: the `TreeCoordinate` path type, its
//! `CoordEntry` elements, and their wire codec. A shared `proto` primitive
//! (peer of `link.rs`), re-exported from `proto::stp` for source continuity.
use core::fmt;
use super::TreeError;
use crate::NodeAddr;
use crate::proto::Error;
/// Errors from constructing a `TreeCoordinate`.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoordError {
/// Coordinate path had zero entries.
EmptyCoordinate,
}
impl core::fmt::Display for CoordError {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
CoordError::EmptyCoordinate => write!(f, "invalid tree coordinate: empty path"),
}
}
}
impl core::error::Error for CoordError {}
/// Metadata for a single node in a tree coordinate path.
///
@@ -65,9 +84,9 @@ impl TreeCoordinate {
/// Create a coordinate from a path of entries (self to root).
///
/// The path must be non-empty and ordered from the node to the root.
pub fn new(path: Vec<CoordEntry>) -> Result<Self, TreeError> {
pub fn new(path: Vec<CoordEntry>) -> Result<Self, CoordError> {
if path.is_empty() {
return Err(TreeError::EmptyCoordinate);
return Err(CoordError::EmptyCoordinate);
}
Ok(Self(path))
}
@@ -76,9 +95,9 @@ impl TreeCoordinate {
///
/// Convenience constructor for cases where only routing is needed.
/// Each entry gets sequence=0, timestamp=0.
pub fn from_addrs(addrs: Vec<NodeAddr>) -> Result<Self, TreeError> {
pub fn from_addrs(addrs: Vec<NodeAddr>) -> Result<Self, CoordError> {
if addrs.is_empty() {
return Err(TreeError::EmptyCoordinate);
return Err(CoordError::EmptyCoordinate);
}
Ok(Self(addrs.into_iter().map(CoordEntry::addr_only).collect()))
}
@@ -223,3 +242,96 @@ impl fmt::Debug for TreeCoordinate {
write!(f, "])")
}
}
// ============================================================================
// Coordinate Wire Format Helpers
// ============================================================================
/// Wire size of a TreeCoordinate in address-only format: 2 + entries × 16.
pub(crate) fn coords_wire_size(coords: &TreeCoordinate) -> usize {
2 + coords.entries().len() * 16
}
/// Encode a TreeCoordinate as address-only wire format: count(u16 LE) + addrs(16 × n).
///
/// Session-layer messages serialize coordinates as NodeAddr arrays (16 bytes each),
/// without the sequence/timestamp metadata used by the tree gossip protocol.
pub(crate) fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec<u8>) {
let addrs: Vec<&NodeAddr> = coords.node_addrs().collect();
let count = addrs.len() as u16;
buf.extend_from_slice(&count.to_le_bytes());
for addr in addrs {
buf.extend_from_slice(addr.as_bytes());
}
}
/// Decode a TreeCoordinate from address-only wire format.
///
/// Returns the decoded coordinate and the number of bytes consumed.
pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), Error> {
if data.len() < 2 {
return Err(Error::MessageTooShort {
expected: 2,
got: data.len(),
});
}
let count = u16::from_le_bytes([data[0], data[1]]) as usize;
let needed = 2 + count * 16;
if data.len() < needed {
return Err(Error::MessageTooShort {
expected: needed,
got: data.len(),
});
}
if count == 0 {
return Err(Error::Malformed("coordinate with zero entries"));
}
let mut addrs = Vec::with_capacity(count);
for i in 0..count {
let offset = 2 + i * 16;
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs).map_err(Error::BadCoord)?;
Ok((coord, needed))
}
/// Decode an optional coordinate field (count may be 0).
///
/// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed.
pub(crate) fn decode_optional_coords(
data: &[u8],
) -> Result<(Option<TreeCoordinate>, usize), Error> {
if data.len() < 2 {
return Err(Error::MessageTooShort {
expected: 2,
got: data.len(),
});
}
let count = u16::from_le_bytes([data[0], data[1]]) as usize;
let needed = 2 + count * 16;
if data.len() < needed {
return Err(Error::MessageTooShort {
expected: needed,
got: data.len(),
});
}
if count == 0 {
return Ok((None, 2));
}
let mut addrs = Vec::with_capacity(count);
for i in 0..count {
let offset = 2 + i * 16;
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs).map_err(Error::BadCoord)?;
Ok((Some(coord), needed))
}
/// Encode a count of zero (for empty/absent coordinate fields).
pub(crate) fn encode_empty_coords(buf: &mut Vec<u8>) {
buf.extend_from_slice(&0u16.to_le_bytes());
}
+25 -35
View File
@@ -14,8 +14,17 @@
//! nodes generating fresh request_ids at high rate.
use crate::NodeAddr;
use crate::proto::rate_limit::PerAddrRateLimiter;
use alloc::collections::BTreeMap;
// ============================================================================
// Receive-side: Request dedup cache bound
// ============================================================================
/// Maximum number of recent LookupRequests retained for dedup and
/// reverse-path routing before the cache is treated as full.
pub(crate) const MAX_RECENT_DISCOVERY_REQUESTS: usize = 4096;
// ============================================================================
// Originator-side: Discovery Backoff
// ============================================================================
@@ -26,9 +35,6 @@ const DEFAULT_BACKOFF_BASE_SECS: u64 = 0;
/// Default maximum backoff cap. `0` = disabled.
const DEFAULT_BACKOFF_MAX_SECS: u64 = 0;
/// Backoff multiplier per consecutive failure.
const BACKOFF_MULTIPLIER: u64 = 2;
/// Exponential backoff for failed discovery lookups.
///
/// Tracks targets whose lookups have timed out and suppresses
@@ -83,10 +89,11 @@ impl DiscoveryBackoff {
pub fn record_failure(&mut self, target: &NodeAddr, now_ms: u64) {
let failures = self.entries.get(target).map_or(0, |e| e.failures) + 1;
let backoff_ms = self
.base_ms
.saturating_mul(BACKOFF_MULTIPLIER.saturating_pow(failures.saturating_sub(1)))
.min(self.max_ms);
let backoff_ms = crate::proto::rate_limit::backoff_ms(
failures.saturating_sub(1),
self.base_ms,
self.max_ms,
);
self.entries.insert(
*target,
@@ -152,29 +159,20 @@ const FORWARD_MAX_AGE_MS: u64 = 60_000;
/// Tracks the last time a LookupRequest was forwarded for each target
/// and enforces a minimum interval to prevent floods from misbehaving
/// nodes generating fresh request_ids.
pub struct DiscoveryForwardRateLimiter {
last_forwarded: BTreeMap<NodeAddr, u64>,
min_interval_ms: u64,
max_age_ms: u64,
}
pub struct DiscoveryForwardRateLimiter(PerAddrRateLimiter);
impl DiscoveryForwardRateLimiter {
/// Create with default parameters (2s interval).
pub fn new() -> Self {
Self {
last_forwarded: BTreeMap::new(),
min_interval_ms: DEFAULT_FORWARD_MIN_INTERVAL_MS,
max_age_ms: FORWARD_MAX_AGE_MS,
}
Self(PerAddrRateLimiter::new(
DEFAULT_FORWARD_MIN_INTERVAL_MS,
FORWARD_MAX_AGE_MS,
))
}
/// Create with a custom minimum interval in milliseconds.
pub fn with_interval_ms(min_interval_ms: u64) -> Self {
Self {
last_forwarded: BTreeMap::new(),
min_interval_ms,
max_age_ms: FORWARD_MAX_AGE_MS,
}
Self(PerAddrRateLimiter::new(min_interval_ms, FORWARD_MAX_AGE_MS))
}
/// Check if we should forward a lookup for this target.
@@ -182,32 +180,24 @@ impl DiscoveryForwardRateLimiter {
/// Returns true if enough time has passed since the last forward
/// for this target. Updates internal state when returning true.
pub fn should_forward(&mut self, target: &NodeAddr, now_ms: u64) -> bool {
if let Some(&last) = self.last_forwarded.get(target)
&& now_ms.saturating_sub(last) < self.min_interval_ms
{
return false;
}
self.last_forwarded.insert(*target, now_ms);
self.cleanup(now_ms);
true
self.0.check_and_record(target, now_ms)
}
/// Replace the minimum interval in milliseconds (e.g., set to zero to disable).
#[cfg(test)]
pub fn set_interval_ms(&mut self, interval_ms: u64) {
self.min_interval_ms = interval_ms;
self.0.set_interval_ms(interval_ms);
}
/// Remove entries older than max_age.
#[cfg(test)]
pub(crate) fn cleanup(&mut self, now_ms: u64) {
self.last_forwarded
.retain(|_, &mut last| now_ms.saturating_sub(last) < self.max_age_ms);
self.0.cleanup(now_ms);
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.last_forwarded.len()
self.0.len()
}
}
+3 -1
View File
@@ -26,7 +26,9 @@ pub(crate) use core::{
initiate_gate, on_response_accepted, plan_forward, plan_initiate, plan_response_route,
poll_pending,
};
pub(crate) use limits::{DiscoveryBackoff, DiscoveryForwardRateLimiter};
pub(crate) use limits::{
DiscoveryBackoff, DiscoveryForwardRateLimiter, MAX_RECENT_DISCOVERY_REQUESTS,
};
#[cfg(test)]
pub(crate) use state::RecentRequest;
pub(crate) use state::{Discovery, PendingLookup};
+3 -2
View File
@@ -33,8 +33,9 @@ fn test_lookup_request_generate() {
let target = make_node_addr(1);
let origin = make_node_addr(2);
let req1 = LookupRequest::generate(target, origin, 5, 0);
let req2 = LookupRequest::generate(target, origin, 5, 0);
use rand::RngExt;
let req1 = LookupRequest::new(rand::rng().random(), target, origin, 5, 0);
let req2 = LookupRequest::new(rand::rng().random(), target, origin, 5, 0);
// Random IDs should differ
assert_ne!(req1.request_id, req2.request_id);
+13 -26
View File
@@ -42,13 +42,6 @@ impl LookupRequest {
}
}
/// Generate a new request with a random ID.
pub fn generate(target: NodeAddr, origin: NodeAddr, ttl: u8, min_mtu: u16) -> Self {
use rand::RngExt;
let request_id = rand::rng().random();
Self::new(request_id, target, origin, ttl, min_mtu)
}
/// 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 });
@@ -109,7 +102,7 @@ impl LookupRequest {
let request_id = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad request_id".into()))?,
.map_err(|_| Error::Malformed("bad request_id"))?,
);
pos += 8;
@@ -129,7 +122,7 @@ impl LookupRequest {
let min_mtu = u16::from_le_bytes(
payload[pos..pos + 2]
.try_into()
.map_err(|_| Error::Malformed("bad min_mtu".into()))?,
.map_err(|_| Error::Malformed("bad min_mtu"))?,
);
pos += 2;
@@ -137,18 +130,15 @@ impl LookupRequest {
let mut tlv_entries = Vec::new();
while pos < payload.len() {
if pos + 4 > payload.len() {
return Err(Error::Malformed(
"truncated TLV header in LookupRequest".to_string(),
));
return Err(Error::Malformed("truncated TLV header in LookupRequest"));
}
let field_num = u16::from_le_bytes(payload[pos..pos + 2].try_into().unwrap());
let length = u16::from_le_bytes(payload[pos + 2..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + length > payload.len() {
return Err(Error::Malformed(format!(
"TLV field {field_num}: declared length {length} exceeds remaining data {}",
payload.len() - pos
)));
return Err(Error::Malformed(
"TLV field declared length exceeds remaining data",
));
}
let value = payload[pos..pos + length].to_vec();
pos += length;
@@ -271,7 +261,7 @@ impl LookupResponse {
let request_id = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad request_id".into()))?,
.map_err(|_| Error::Malformed("bad request_id"))?,
);
pos += 8;
@@ -283,7 +273,7 @@ impl LookupResponse {
let path_mtu = u16::from_le_bytes(
payload[pos..pos + 2]
.try_into()
.map_err(|_| Error::Malformed("bad path_mtu".into()))?,
.map_err(|_| Error::Malformed("bad path_mtu"))?,
);
pos += 2;
@@ -297,25 +287,22 @@ impl LookupResponse {
});
}
let proof = Signature::from_slice(&payload[pos..pos + 64])
.map_err(|_| Error::Malformed("bad proof signature".into()))?;
.map_err(|_| Error::Malformed("bad proof signature"))?;
pos += 64;
// Parse TLV entries from remaining bytes after proof
let mut tlv_entries = Vec::new();
while pos < payload.len() {
if pos + 4 > payload.len() {
return Err(Error::Malformed(
"truncated TLV header in LookupResponse".to_string(),
));
return Err(Error::Malformed("truncated TLV header in LookupResponse"));
}
let field_num = u16::from_le_bytes(payload[pos..pos + 2].try_into().unwrap());
let length = u16::from_le_bytes(payload[pos + 2..pos + 4].try_into().unwrap()) as usize;
pos += 4;
if pos + length > payload.len() {
return Err(Error::Malformed(format!(
"TLV field {field_num}: declared length {length} exceeds remaining data {}",
payload.len() - pos
)));
return Err(Error::Malformed(
"TLV field declared length exceeds remaining data",
));
}
let value = payload[pos..pos + length].to_vec();
pos += length;
+83 -14
View File
@@ -1,31 +1,100 @@
//! Protocol error types.
use thiserror::Error;
/// Errors related to protocol message handling.
#[derive(Debug, Error)]
#[derive(Debug)]
pub enum Error {
#[error("invalid message type: 0x{0:02x}")]
/// Message type byte was not recognized.
InvalidMessageType(u8),
#[error("message too short: expected at least {expected}, got {got}")]
MessageTooShort { expected: usize, got: usize },
/// Message was shorter than the minimum expected length.
MessageTooShort {
/// Minimum number of bytes expected.
expected: usize,
/// Number of bytes actually present.
got: usize,
},
#[error("message too long: max {max}, got {got}")]
MessageTooLong { max: usize, got: usize },
/// Message exceeded the maximum allowed length.
MessageTooLong {
/// Maximum number of bytes allowed.
max: usize,
/// Number of bytes actually present.
got: usize,
},
#[error("invalid signature")]
/// Signature failed to parse or verify.
InvalidSignature,
#[error("unsupported protocol version: {0}")]
/// Protocol version byte was not supported.
UnsupportedVersion(u8),
#[error("malformed message: {0}")]
Malformed(String),
/// Message was structurally malformed; the string names the field.
Malformed(&'static str),
#[error("hop limit exceeded")]
/// Size class byte was out of range for this message.
BadSizeClass {
/// The size class value received.
got: u8,
/// The maximum (or required) size class.
max: u8,
},
/// A tree coordinate failed to construct during decode.
BadCoord(crate::proto::coord::CoordError),
/// A bloom filter failed to construct during decode.
BadBloom(crate::proto::bloom::BloomError),
/// Hop limit was exceeded while forwarding.
HopLimitExceeded,
#[error("ttl expired")]
/// Time-to-live expired while forwarding.
TtlExpired,
}
impl core::fmt::Display for Error {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Error::InvalidMessageType(t) => write!(f, "invalid message type: 0x{t:02x}"),
Error::MessageTooShort { expected, got } => {
write!(
f,
"message too short: expected at least {expected}, got {got}"
)
}
Error::MessageTooLong { max, got } => {
write!(f, "message too long: max {max}, got {got}")
}
Error::InvalidSignature => write!(f, "invalid signature"),
Error::UnsupportedVersion(v) => write!(f, "unsupported protocol version: {v}"),
Error::Malformed(m) => write!(f, "malformed message: {m}"),
Error::BadSizeClass { got, max } => write!(f, "bad size class: {got} (max {max})"),
Error::BadCoord(e) => write!(f, "bad coordinate: {e}"),
Error::BadBloom(e) => write!(f, "bad bloom filter: {e}"),
Error::HopLimitExceeded => write!(f, "hop limit exceeded"),
Error::TtlExpired => write!(f, "ttl expired"),
}
}
}
impl core::error::Error for Error {
fn source(&self) -> Option<&(dyn core::error::Error + 'static)> {
match self {
Error::BadCoord(e) => Some(e),
Error::BadBloom(e) => Some(e),
_ => None,
}
}
}
impl From<crate::proto::coord::CoordError> for Error {
fn from(e: crate::proto::coord::CoordError) -> Self {
Error::BadCoord(e)
}
}
impl From<crate::proto::bloom::BloomError> for Error {
fn from(e: crate::proto::bloom::BloomError) -> Self {
Error::BadBloom(e)
}
}
+5 -9
View File
@@ -589,7 +589,7 @@ impl Fmp {
/// arithmetic (the exponent is the resend count *after* this attempt).
fn next_resend_at_ms(now_ms: u64, interval_ms: u64, backoff: f64, prior_count: u32) -> u64 {
let count = prior_count + 1;
now_ms + (interval_ms as f64 * backoff.powi(count as i32)) as u64
now_ms + (interval_ms as f64 * crate::proto::math::powi(backoff, count)) as u64
}
// ============================================================================
@@ -609,10 +609,7 @@ impl NegotiationPayload {
pub fn agree_version(&self, other: &Self) -> Result<u8, Error> {
let agreed = self.version_max.min(other.version_max);
if agreed < self.version_min || agreed < other.version_min {
return Err(Error::Malformed(format!(
"version mismatch: ours [{},{}] theirs [{},{}]",
self.version_min, self.version_max, other.version_min, other.version_max
)));
return Err(Error::Malformed("version mismatch"));
}
Ok(agreed)
}
@@ -675,10 +672,9 @@ impl NegotiationPayload {
/// At least one side must be `Full` or the link is rejected.
pub fn validate_profiles(ours: NodeProfile, theirs: NodeProfile) -> Result<(), Error> {
if ours != NodeProfile::Full && theirs != NodeProfile::Full {
return Err(Error::Malformed(format!(
"invalid profile pairing: {} <-> {} (at least one must be full)",
ours, theirs
)));
return Err(Error::Malformed(
"invalid profile pairing (at least one must be full)",
));
}
Ok(())
}
+1 -4
View File
@@ -9,8 +9,5 @@
/// Uses exponential backoff: `base_interval_ms * 2^retry_count`, capped at
/// `max_backoff_ms`.
pub(crate) fn backoff_ms(retry_count: u32, base_interval_ms: u64, max_backoff_ms: u64) -> u64 {
let multiplier = 1u64.checked_shl(retry_count).unwrap_or(u64::MAX);
base_interval_ms
.saturating_mul(multiplier)
.min(max_backoff_ms)
crate::proto::rate_limit::backoff_ms(retry_count, base_interval_ms, max_backoff_ms)
}
+12 -20
View File
@@ -11,8 +11,9 @@
//! feature helpers) lives in `core.rs`; only the payload codec is here.
use crate::proto::Error;
use crate::proto::codec::Reader;
use crate::proto::link::LinkMessageType;
use std::fmt;
use ::core::fmt;
/// Handshake message type identifiers.
///
@@ -163,13 +164,9 @@ impl Disconnect {
/// Decode from link-layer payload (after msg_type byte has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() {
return Err(Error::MessageTooShort {
expected: 1,
got: 0,
});
}
let reason = DisconnectReason::from_byte(payload[0]).unwrap_or(DisconnectReason::Other);
let mut reader = Reader::new(payload);
let reason =
DisconnectReason::from_byte(reader.read_u8()?).unwrap_or(DisconnectReason::Other);
Ok(Self { reason })
}
}
@@ -246,7 +243,7 @@ impl TryFrom<u8> for NodeProfile {
0 => Ok(Self::Full),
1 => Ok(Self::NonRouting),
2 => Ok(Self::Leaf),
_ => Err(Error::Malformed(format!("unknown node profile: {value}"))),
_ => Err(Error::Malformed("unknown node profile")),
}
}
}
@@ -326,17 +323,13 @@ impl NegotiationPayload {
let format = data[0];
if format != NEGOTIATION_FORMAT_V0 {
return Err(Error::Malformed(format!(
"unknown negotiation format: {format}"
)));
return Err(Error::Malformed("unknown negotiation format"));
}
let version_min = data[1] >> 4;
let version_max = data[1] & 0x0F;
if version_min > version_max {
return Err(Error::Malformed(format!(
"version_min ({version_min}) > version_max ({version_max})"
)));
return Err(Error::Malformed("version_min > version_max"));
}
let features = u64::from_le_bytes(data[2..10].try_into().unwrap());
@@ -346,7 +339,7 @@ impl NegotiationPayload {
while offset < data.len() {
// Need at least 4 bytes for field_num + length
if offset + 4 > data.len() {
return Err(Error::Malformed("truncated TLV header".to_string()));
return Err(Error::Malformed("truncated TLV header"));
}
let field_num = u16::from_le_bytes(data[offset..offset + 2].try_into().unwrap());
@@ -355,10 +348,9 @@ impl NegotiationPayload {
offset += 4;
if offset + length > data.len() {
return Err(Error::Malformed(format!(
"TLV field {field_num}: declared length {length} exceeds remaining data {}",
data.len() - offset
)));
return Err(Error::Malformed(
"TLV field declared length exceeds remaining data",
));
}
let value = data[offset..offset + length].to_vec();
+1 -1
View File
@@ -9,7 +9,7 @@ use crate::proto::fsp::core::{
use crate::proto::fsp::limits::FSP_CUTOVER_DELAY_MS;
use crate::proto::stp::TreeCoordinate;
use crate::testutil::make_node_addr;
use std::collections::VecDeque;
use alloc::collections::VecDeque;
fn coords(byte: u8) -> TreeCoordinate {
TreeCoordinate::from_addrs(vec![make_node_addr(byte)]).unwrap()
+33 -88
View File
@@ -33,8 +33,9 @@
//! | 0x3 | - | Handshake msg3 | SessionMsg3 (Noise XK msg3) |
use crate::proto::Error;
use crate::proto::codec::{Reader, Writer};
use crate::proto::stp::{TreeCoordinate, decode_coords, decode_optional_coords, encode_coords};
use std::fmt;
use ::core::fmt;
// ============================================================================
// Constants
@@ -636,37 +637,18 @@ impl SessionSetup {
/// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() {
return Err(Error::MessageTooShort {
expected: 1,
got: 0,
});
}
let flags = SessionFlags::from_byte(payload[0]);
let mut offset = 1;
let mut reader = Reader::new(payload);
let flags = SessionFlags::from_byte(reader.read_u8()?);
let (src_coords, consumed) = decode_coords(&payload[offset..])?;
offset += consumed;
let (src_coords, consumed) = decode_coords(reader.rest())?;
reader.advance(consumed);
let (dest_coords, consumed) = decode_coords(&payload[offset..])?;
offset += consumed;
let (dest_coords, consumed) = decode_coords(reader.rest())?;
reader.advance(consumed);
if payload.len() < offset + 2 {
return Err(Error::MessageTooShort {
expected: offset + 2,
got: payload.len(),
});
}
let hs_len = u16::from_le_bytes([payload[offset], payload[offset + 1]]) as usize;
offset += 2;
let hs_len = reader.read_u16_le()? as usize;
if payload.len() < offset + hs_len {
return Err(Error::MessageTooShort {
expected: offset + hs_len,
got: payload.len(),
});
}
let handshake_payload = payload[offset..offset + hs_len].to_vec();
let handshake_payload = reader.read_bytes(hs_len)?.to_vec();
Ok(Self {
src_coords,
@@ -756,37 +738,18 @@ impl SessionAck {
/// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() {
return Err(Error::MessageTooShort {
expected: 1,
got: 0,
});
}
let flags = payload[0];
let mut offset = 1;
let mut reader = Reader::new(payload);
let flags = reader.read_u8()?;
let (src_coords, consumed) = decode_coords(&payload[offset..])?;
offset += consumed;
let (src_coords, consumed) = decode_coords(reader.rest())?;
reader.advance(consumed);
let (dest_coords, consumed) = decode_coords(&payload[offset..])?;
offset += consumed;
let (dest_coords, consumed) = decode_coords(reader.rest())?;
reader.advance(consumed);
if payload.len() < offset + 2 {
return Err(Error::MessageTooShort {
expected: offset + 2,
got: payload.len(),
});
}
let hs_len = u16::from_le_bytes([payload[offset], payload[offset + 1]]) as usize;
offset += 2;
let hs_len = reader.read_u16_le()? as usize;
if payload.len() < offset + hs_len {
return Err(Error::MessageTooShort {
expected: offset + hs_len,
got: payload.len(),
});
}
let handshake_payload = payload[offset..offset + hs_len].to_vec();
let handshake_payload = reader.read_bytes(hs_len)?.to_vec();
Ok(Self {
src_coords,
@@ -837,49 +800,31 @@ impl SessionMsg3 {
/// where ver_phase = 0x03 (version 0, phase MSG3).
pub fn encode(&self) -> Vec<u8> {
// Build body first to compute payload_len
let mut body = Vec::new();
body.push(self.flags);
let mut body = Writer::new();
body.write_u8(self.flags);
let hs_len = self.handshake_payload.len() as u16;
body.extend_from_slice(&hs_len.to_le_bytes());
body.extend_from_slice(&self.handshake_payload);
body.write_u16_le(hs_len);
body.write_bytes(&self.handshake_payload);
// Prepend 4-byte FSP common prefix
let payload_len = body.len() as u16;
let mut buf = Vec::with_capacity(4 + body.len());
buf.push(0x03); // version 0, phase 0x3 (MSG3)
buf.push(0x00); // flags (must be zero for handshake)
buf.extend_from_slice(&payload_len.to_le_bytes());
buf.extend_from_slice(&body);
buf
let body = body.into_vec();
let mut w = Writer::with_capacity(4 + body.len());
w.write_u8(0x03); // version 0, phase 0x3 (MSG3)
w.write_u8(0x00); // flags (must be zero for handshake)
w.write_u16_le(payload_len);
w.write_bytes(&body);
w.into_vec()
}
/// Decode from wire format (after 4-byte FSP prefix has been consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.is_empty() {
return Err(Error::MessageTooShort {
expected: 1,
got: 0,
});
}
let flags = payload[0];
let mut offset = 1;
let mut reader = Reader::new(payload);
let flags = reader.read_u8()?;
if payload.len() < offset + 2 {
return Err(Error::MessageTooShort {
expected: offset + 2,
got: payload.len(),
});
}
let hs_len = u16::from_le_bytes([payload[offset], payload[offset + 1]]) as usize;
offset += 2;
let hs_len = reader.read_u16_le()? as usize;
if payload.len() < offset + hs_len {
return Err(Error::MessageTooShort {
expected: offset + hs_len,
got: payload.len(),
});
}
let handshake_payload = payload[offset..offset + hs_len].to_vec();
let handshake_payload = reader.read_bytes(hs_len)?.to_vec();
Ok(Self {
flags,
+1 -1
View File
@@ -2,7 +2,7 @@
use crate::NodeAddr;
use crate::proto::Error;
use std::fmt;
use core::fmt;
// ============================================================================
// Link-Layer Message Types
+55
View File
@@ -0,0 +1,55 @@
//! no_std-shaped numeric helpers for the protocol cores.
//!
//! `f64::powi` and the transcendental methods live in `std`, not `core`. The
//! helpers here keep the small amount of protocol math the codecs need in a
//! `core`-only shape so the cores stay portable, without changing any result.
/// Raise `base` to a non-negative integer power via square-and-multiply.
///
/// Bit-identical to `f64::powi` for the exponents the codecs use: it mirrors the
/// compiler-rt `__powidf2` multiply order (multiply-then-square, skipping the
/// final square), so — floating-point multiplication being non-associative — it
/// reproduces `powi` exactly rather than a naive left-to-right product. The
/// `powi_bit_identical_to_std` test pins this. Keeping it bit-identical matters:
/// the bloom false-positive-rate feeds a reject comparison and the FMP backoff
/// feeds a `u64` timer, so any drift could change a decision.
pub(crate) fn powi(base: f64, exp: u32) -> f64 {
let mut result = 1.0_f64;
let mut b = base;
let mut e = exp;
loop {
if e & 1 == 1 {
result *= b;
}
e >>= 1;
if e == 0 {
break;
}
b *= b;
}
result
}
#[cfg(test)]
mod tests {
use super::powi;
#[test]
fn powi_bit_identical_to_std() {
// The core square-and-multiply must match f64::powi bit-for-bit across
// representative bases and every exponent the protocol math reaches, so
// the FPR reject decision and the backoff timer are unchanged.
let bases = [
0.0, 1.0, 0.5, 0.5469, 0.5493, 0.5508, 0.9999, 1.5, 2.0, 3.7, 0.1, 1e-3,
];
for &base in &bases {
for exp in 0u32..=64 {
assert_eq!(
powi(base, exp).to_bits(),
base.powi(exp as i32).to_bits(),
"powi mismatch at base={base}, exp={exp}"
);
}
}
}
}
+5 -5
View File
@@ -6,7 +6,7 @@
//! (pre-computing every clock read into plain `bool` snapshot fields, and
//! resolving the rekey-suppression predicate shell-side), call the `plan_*`
//! decisions, and drive the returned [`MmpAction`]s — the actual sends,
//! registry mutations, `src/mmp/` primitive calls, metrics, and logging. No
//! registry mutations, `proto/mmp/` primitive calls, metrics, and logging. No
//! I/O, no clock, no metrics, no logging here.
//!
//! Unlike routing/discovery (whose cores do live cross-table reads through a
@@ -50,7 +50,7 @@ pub(crate) struct PeerLivenessSnapshot {
///
/// Every timing read is resolved shell-side into a plain `bool`: `sr_due` /
/// `rr_due` are the pre-evaluated `should_send_report` gates on the peer's
/// `src/mmp/` sender/receiver primitives, and `log_due` is the pre-evaluated
/// `proto/mmp/` sender/receiver primitives, and `log_due` is the pre-evaluated
/// `should_log` gate. `send_sr`/`send_rr` are the profile provide/want flags:
/// on the master (IK) line there is no profile negotiation, so the shell sets
/// them to the behavior-neutral constant `true` (ANDing a runtime `true` is a
@@ -81,7 +81,7 @@ pub(crate) struct LinkReportSnapshot {
///
/// Every timing read is resolved shell-side into a plain `bool`: `sr_due` /
/// `rr_due` are the pre-evaluated `should_send_report` gates on the session's
/// `src/mmp/` sender/receiver primitives, `mtu_due` is the pre-evaluated
/// `proto/mmp/` sender/receiver primitives, `mtu_due` is the pre-evaluated
/// `path_mtu.should_send_notification` gate, and `log_due` is the pre-evaluated
/// `should_log` gate. Unlike the link path there is **no** `send_sr`/`send_rr`
/// profile flag — the session handler has never gated on a profile, so no
@@ -172,7 +172,7 @@ pub(crate) enum MmpAction {
/// Send a heartbeat to `peer`: the shell runs `mark_heartbeat_sent` and the
/// encrypted link send.
Heartbeat { peer: NodeAddr },
/// Build (shell: `src/mmp/` `build_report` + `encode`) and send the given
/// Build (shell: `proto/mmp/` `build_report` + `encode`) and send the given
/// link report over the encrypted link. The interval-advancing
/// `build_report` mutation happens **only** while driving this action, so an
/// ungated report never advances its interval.
@@ -183,7 +183,7 @@ pub(crate) enum MmpAction {
/// Emit the periodic link operator log for `peer` (shell owns the `tracing`
/// call and runs `mark_logged`).
LogLink { peer: NodeAddr },
/// Build (shell: `src/mmp/` `build_report`/`build_notification` + the
/// Build (shell: `proto/mmp/` `build_report`/`build_notification` + the
/// `Session*`/`PathMtuNotification` codec) and send the given session report
/// over the encrypted session. The interval/notification-advancing build
/// mutation happens **only** while driving this action.
+55
View File
@@ -0,0 +1,55 @@
//! MMP tuning constants (EWMA parameters, report-interval bounds, defaults).
//!
//! The plain values the owned sender/receiver/metrics state machines and the
//! shell config clamp against. Re-exported from the module root so external
//! callers keep the `crate::proto::mmp::<CONST>` path.
// --- EWMA parameters ---
/// Dual EWMA short-term: α = 1/4.
pub const EWMA_SHORT_ALPHA: f64 = 0.25;
/// Dual EWMA long-term: α = 1/32.
pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0;
// --- Timing defaults (milliseconds) ---
/// Default report interval before SRTT is available (cold start).
pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200;
/// Minimum report interval (SRTT clamp floor).
///
/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
/// fast initial SRTT convergence before transitioning to this floor.
pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
/// Maximum report interval (SRTT clamp ceiling).
pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000;
/// Number of SRTT samples before transitioning from cold-start to normal floor.
///
/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as
/// the floor to gather SRTT samples quickly. After this many updates, the floor
/// switches to `MIN_REPORT_INTERVAL_MS`.
pub const COLD_START_SAMPLES: u32 = 5;
/// Default OWD ring buffer capacity.
pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
/// Default operator log interval in seconds.
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
// --- Session-layer timing defaults ---
// Session reports are routed end-to-end (bandwidth cost on every transit link),
// so intervals are higher than link-layer.
/// Session-layer minimum report interval.
pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
/// Session-layer maximum report interval.
pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
/// Session-layer cold-start report interval (before SRTT is available).
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
+333
View File
@@ -0,0 +1,333 @@
//! Derived MMP metrics (sans-IO).
//!
//! Sender-side derived metrics (RTT, loss, goodput, ETX, trends) computed from
//! incoming `ReceiverReport`s, plus the [`RrLog`] observability outcome returned
//! to the shell so the operator `trace!` points can fire there rather than
//! mid-decision. All time inputs are injected `u64` milliseconds.
use super::algorithms::{DualEwma, SrttEstimator, compute_etx};
use super::wire::ReceiverReport;
// ============================================================================
// ReceiverReport processing outcome (observability hoisted to the shell)
// ============================================================================
/// The observability outcome of [`MmpMetrics::process_receiver_report`], returned
/// so the async shell can emit the operator `trace!` points that used to live
/// mid-decision (per the sans-IO rule that migrated code carries no `tracing`).
///
/// Exactly one variant applies per call; the shell has the incoming report and
/// our timestamp, so this only carries the values the shell cannot otherwise
/// reconstruct (the previous cumulative snapshot / the derived RTT).
pub enum RrLog {
/// No RTT log point fires (the report carried no timestamp echo).
None,
/// The report was ignored as stale/duplicate. Carries the previous
/// cumulative snapshot for the operator trace.
Stale {
prev_highest: u64,
prev_packets: u64,
prev_bytes: u64,
},
/// A valid RTT sample was taken; carries the derived RTT and the SRTT as it
/// stood **before** this sample was folded in (matching the original log,
/// which read `srtt` before `update`).
RttSample { rtt_ms: u32, srtt_ms: f64 },
/// The report carried an echo but the derived RTT was invalid (wrapped /
/// non-positive).
InvalidRtt,
}
// ============================================================================
// MmpMetrics (derived metrics)
// ============================================================================
/// Derived MMP metrics, updated from incoming ReceiverReports.
///
/// This lives on the sender side: when we receive a ReceiverReport from
/// our peer describing what they observed about our traffic, we process
/// it here to compute RTT, loss, goodput, and trend indicators.
pub struct MmpMetrics {
/// Smoothed RTT from timestamp echo.
pub srtt: SrttEstimator,
/// Dual EWMA trend detectors.
pub rtt_trend: DualEwma,
pub loss_trend: DualEwma,
pub goodput_trend: DualEwma,
pub jitter_trend: DualEwma,
pub etx_trend: DualEwma,
/// Forward delivery ratio (what fraction of our frames the peer received).
pub delivery_ratio_forward: f64,
/// Reverse delivery ratio (set when we compute from our own receiver state).
pub delivery_ratio_reverse: f64,
/// ETX computed from bidirectional delivery ratios.
pub etx: f64,
/// Smoothed goodput in bytes/sec (forward direction: what the peer received from us).
pub goodput_bps: f64,
// --- State for delta computation ---
/// Previous ReceiverReport's cumulative counters (for computing interval deltas).
prev_rr_cum_packets: u64,
prev_rr_cum_bytes: u64,
prev_rr_highest_counter: u64,
prev_rr_ecn_ce: u32,
prev_rr_reorder: u32,
/// Time (injected `u64` ms) of previous ReceiverReport (for goodput rate).
prev_rr_ms: Option<u64>,
/// Whether we have a previous ReceiverReport for delta computation.
has_prev_rr: bool,
// --- State for reverse delivery ratio delta computation ---
/// Previous reverse-side cumulative packets received (our receiver state).
prev_reverse_packets: u64,
/// Previous reverse-side highest counter (our receiver state).
prev_reverse_highest: u64,
/// Whether we have a previous reverse-side snapshot for delta computation.
has_prev_reverse: bool,
}
impl MmpMetrics {
/// Reset state derived from ReceiverReport counters for rekey cutover.
///
/// The new session starts with counter 0, so the prev_rr deltas must
/// be reset to avoid computing bogus loss/goodput from the counter
/// discontinuity. RTT (SRTT) is preserved since it remains valid.
pub fn reset_for_rekey(&mut self) {
self.prev_rr_cum_packets = 0;
self.prev_rr_cum_bytes = 0;
self.prev_rr_highest_counter = 0;
self.prev_rr_ecn_ce = 0;
self.prev_rr_reorder = 0;
self.prev_rr_ms = None;
self.has_prev_rr = false;
self.delivery_ratio_forward = 1.0;
self.prev_reverse_packets = 0;
self.prev_reverse_highest = 0;
self.has_prev_reverse = false;
// Keep srtt, etx, trends, goodput_bps — they'll refresh from data
}
pub fn new() -> Self {
Self {
srtt: SrttEstimator::new(),
rtt_trend: DualEwma::new(),
loss_trend: DualEwma::new(),
goodput_trend: DualEwma::new(),
jitter_trend: DualEwma::new(),
etx_trend: DualEwma::new(),
delivery_ratio_forward: 1.0,
delivery_ratio_reverse: 1.0,
etx: 1.0,
goodput_bps: 0.0,
prev_rr_cum_packets: 0,
prev_rr_cum_bytes: 0,
prev_rr_highest_counter: 0,
prev_rr_ecn_ce: 0,
prev_rr_reorder: 0,
prev_rr_ms: None,
has_prev_rr: false,
prev_reverse_packets: 0,
prev_reverse_highest: 0,
has_prev_reverse: false,
}
}
/// Process an incoming ReceiverReport (from the peer about our traffic).
///
/// `our_timestamp_ms` is the current session-relative time in ms (for RTT).
/// `now_ms` is the injected monotonic time in ms (for goodput rate).
///
/// Returns `(first_srtt, log)`: `first_srtt` is `true` if this report
/// produced the first SRTT measurement (transition from uninitialized to
/// initialized); `log` is the [`RrLog`] observability outcome the shell
/// emits (the `tracing` that used to fire here now lives shell-side).
pub fn process_receiver_report(
&mut self,
rr: &ReceiverReport,
our_timestamp_ms: u32,
now_ms: u64,
) -> (bool, RrLog) {
let had_srtt = self.srtt.initialized();
if self.has_prev_rr {
let counters_regressed = rr.highest_counter < self.prev_rr_highest_counter
|| rr.cumulative_packets_recv < self.prev_rr_cum_packets
|| rr.cumulative_bytes_recv < self.prev_rr_cum_bytes
|| rr.ecn_ce_count < self.prev_rr_ecn_ce
|| rr.cumulative_reorder_count < self.prev_rr_reorder;
let duplicate_counters = rr.highest_counter == self.prev_rr_highest_counter
&& rr.cumulative_packets_recv == self.prev_rr_cum_packets
&& rr.cumulative_bytes_recv == self.prev_rr_cum_bytes
&& rr.ecn_ce_count == self.prev_rr_ecn_ce
&& rr.cumulative_reorder_count == self.prev_rr_reorder;
// Safe to drop: reports are only built after interval data, so
// a fresh report always advances at least one cumulative counter.
if counters_regressed || duplicate_counters {
return (
false,
RrLog::Stale {
prev_highest: self.prev_rr_highest_counter,
prev_packets: self.prev_rr_cum_packets,
prev_bytes: self.prev_rr_cum_bytes,
},
);
}
}
let mut log = RrLog::None;
// --- RTT from timestamp echo ---
// RTT = now - echoed_timestamp - dwell_time
if rr.timestamp_echo > 0 {
let echo_ms = rr.timestamp_echo;
let dwell_ms = u32::from(rr.dwell_time);
let rtt_sample_ms = echo_ms
.checked_add(dwell_ms)
.and_then(|send_done_ms| our_timestamp_ms.checked_sub(send_done_ms));
match rtt_sample_ms {
Some(rtt_ms) if rtt_ms > 0 => {
let rtt_us = (rtt_ms as i64) * 1000;
// Capture SRTT before folding in this sample (the original
// trace read `srtt` before `update`).
let srtt_ms = self.srtt.srtt_us() as f64 / 1000.0;
log = RrLog::RttSample { rtt_ms, srtt_ms };
self.srtt.update(rtt_us);
self.rtt_trend.update(rtt_us as f64);
}
_ => {
log = RrLog::InvalidRtt;
}
}
}
// --- Loss rate from cumulative counters ---
// Delta: frames the peer should have received vs. actually received
if self.has_prev_rr {
let counter_span = rr
.highest_counter
.saturating_sub(self.prev_rr_highest_counter);
let packets_delta = rr
.cumulative_packets_recv
.saturating_sub(self.prev_rr_cum_packets);
if counter_span > 0 {
let delivery = (packets_delta as f64) / (counter_span as f64);
self.delivery_ratio_forward = delivery.clamp(0.0, 1.0);
let loss_rate = 1.0 - self.delivery_ratio_forward;
self.loss_trend.update(loss_rate);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
}
}
// --- Goodput from cumulative bytes + time delta ---
if self.has_prev_rr {
let bytes_delta = rr
.cumulative_bytes_recv
.saturating_sub(self.prev_rr_cum_bytes);
self.goodput_trend.update(bytes_delta as f64);
// Compute bytes/sec if we have a time reference
if let Some(prev_ms) = self.prev_rr_ms {
let secs = (now_ms.saturating_sub(prev_ms) as f64) / 1000.0;
if secs > 0.0 {
let bps = bytes_delta as f64 / secs;
// EWMA smoothing: α = 1/4
if self.goodput_bps == 0.0 {
self.goodput_bps = bps;
} else {
self.goodput_bps += (bps - self.goodput_bps) * 0.25;
}
}
}
}
// --- Jitter trend ---
self.jitter_trend.update(rr.jitter as f64);
// --- Save for next delta ---
self.prev_rr_cum_packets = rr.cumulative_packets_recv;
self.prev_rr_cum_bytes = rr.cumulative_bytes_recv;
self.prev_rr_highest_counter = rr.highest_counter;
self.prev_rr_ecn_ce = rr.ecn_ce_count;
self.prev_rr_reorder = rr.cumulative_reorder_count;
self.prev_rr_ms = Some(now_ms);
self.has_prev_rr = true;
(!had_srtt && self.srtt.initialized(), log)
}
/// Update the reverse delivery ratio from our own receiver state.
///
/// Computes a per-interval delta (same as forward ratio) rather than
/// a lifetime cumulative ratio, so ETX responds to recent conditions.
pub fn update_reverse_delivery(&mut self, our_recv_packets: u64, peer_highest: u64) {
if self.has_prev_reverse {
let counter_span = peer_highest.saturating_sub(self.prev_reverse_highest);
let packets_delta = our_recv_packets.saturating_sub(self.prev_reverse_packets);
if counter_span > 0 {
let delivery = (packets_delta as f64) / (counter_span as f64);
self.delivery_ratio_reverse = delivery.clamp(0.0, 1.0);
self.etx = compute_etx(self.delivery_ratio_forward, self.delivery_ratio_reverse);
self.etx_trend.update(self.etx);
}
}
self.prev_reverse_packets = our_recv_packets;
self.prev_reverse_highest = peer_highest;
self.has_prev_reverse = true;
}
/// Current smoothed RTT in milliseconds, or `None` if not yet measured.
pub fn srtt_ms(&self) -> Option<f64> {
if self.srtt.initialized() {
Some(self.srtt.srtt_us() as f64 / 1000.0)
} else {
None
}
}
/// Current loss rate (0.0 = no loss, 1.0 = total loss).
pub fn loss_rate(&self) -> f64 {
1.0 - self.delivery_ratio_forward
}
/// Smoothed loss rate (long-term EWMA), or `None` if not yet initialized.
pub fn smoothed_loss(&self) -> Option<f64> {
if self.loss_trend.initialized() {
Some(self.loss_trend.long())
} else {
None
}
}
/// Smoothed ETX (long-term EWMA), or `None` if not yet initialized.
pub fn smoothed_etx(&self) -> Option<f64> {
if self.etx_trend.initialized() {
Some(self.etx_trend.long())
} else {
None
}
}
/// Current smoothed goodput in bytes/sec, or 0 if not yet measured.
pub fn goodput_bps(&self) -> f64 {
self.goodput_bps
}
/// Cumulative ECN CE count from the most recent ReceiverReport.
pub fn last_ecn_ce_count(&self) -> u32 {
self.prev_rr_ecn_ce
}
}
impl Default for MmpMetrics {
fn default() -> Self {
Self::new()
}
}
+21 -52
View File
@@ -7,8 +7,12 @@
//! - `core.rs` — the snapshot read-seams, the [`MmpAction`] effect vocabulary,
//! and the pure `plan_*` decisions (line-invariant across the master/next
//! report shapes).
//! - `state.rs` — [`Mmp`] (the reporting anchor) plus the owned sender/receiver/
//! metrics/path-MTU state machines (`u64`-ms time seam, `no_std`+`alloc`).
//! - `state.rs` — [`Mmp`] (the reporting anchor) plus the [`MmpPeerState`]/
//! [`MmpSessionState`] per-entity aggregates.
//! - `sender.rs`/`receiver.rs`/`metrics.rs`/`path_mtu.rs` — the owned sender/
//! receiver/derived-metrics/path-MTU role state machines (`u64`-ms time seam,
//! `no_std`+`alloc`).
//! - `limits.rs` — the module tuning constants.
//! - `algorithms.rs` — the pure estimators (jitter/SRTT/dual-EWMA/OWD-trend/ETX;
//! `no_std`+`alloc`).
//! - `wire.rs` — the link-layer and session-layer report codecs.
@@ -23,6 +27,11 @@ use serde::{Deserialize, Serialize};
mod algorithms;
mod core;
mod limits;
mod metrics;
mod path_mtu;
mod receiver;
mod sender;
mod state;
mod wire;
@@ -34,7 +43,8 @@ pub(crate) use core::{
BackoffUpdate, LinkReportKind, LinkReportSnapshot, MmpAction, PeerLivenessSnapshot, SendResult,
SessionReportKind, SessionReportSnapshot,
};
pub(crate) use state::{Mmp, MmpMetrics, MmpPeerState, MmpSessionState, RrLog};
pub(crate) use metrics::{MmpMetrics, RrLog};
pub(crate) use state::{Mmp, MmpPeerState, MmpSessionState};
pub use wire::{
PathMtuNotification, ReceiverReport, SenderReport, SessionReceiverReport, SessionSenderReport,
};
@@ -70,52 +80,11 @@ impl fmt::Display for MmpMode {
// Constants
// ============================================================================
// --- EWMA parameters ---
/// Dual EWMA short-term: α = 1/4.
pub const EWMA_SHORT_ALPHA: f64 = 0.25;
/// Dual EWMA long-term: α = 1/32.
pub const EWMA_LONG_ALPHA: f64 = 1.0 / 32.0;
// --- Timing defaults (milliseconds) ---
/// Default report interval before SRTT is available (cold start).
pub const DEFAULT_COLD_START_INTERVAL_MS: u64 = 200;
/// Minimum report interval (SRTT clamp floor).
///
/// Raised from 100ms to 1000ms: parent re-evaluation runs every 60s,
/// so 60 samples/cycle is more than sufficient for EWMA convergence (~10).
/// The cold-start phase uses `DEFAULT_COLD_START_INTERVAL_MS` (200ms) for
/// fast initial SRTT convergence before transitioning to this floor.
pub const MIN_REPORT_INTERVAL_MS: u64 = 1_000;
/// Maximum report interval (SRTT clamp ceiling).
pub const MAX_REPORT_INTERVAL_MS: u64 = 5_000;
/// Number of SRTT samples before transitioning from cold-start to normal floor.
///
/// During cold-start, report intervals use `DEFAULT_COLD_START_INTERVAL_MS` as
/// the floor to gather SRTT samples quickly. After this many updates, the floor
/// switches to `MIN_REPORT_INTERVAL_MS`.
pub const COLD_START_SAMPLES: u32 = 5;
/// Default OWD ring buffer capacity.
pub const DEFAULT_OWD_WINDOW_SIZE: usize = 32;
/// Default operator log interval in seconds.
pub const DEFAULT_LOG_INTERVAL_SECS: u64 = 30;
// --- Session-layer timing defaults ---
// Session reports are routed end-to-end (bandwidth cost on every transit link),
// so intervals are higher than link-layer.
/// Session-layer minimum report interval.
pub const MIN_SESSION_REPORT_INTERVAL_MS: u64 = 500;
/// Session-layer maximum report interval.
pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
/// Session-layer cold-start report interval (before SRTT is available).
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
// The module tuning constants live in `limits.rs`; re-exported here so external
// callers keep the `crate::proto::mmp::<CONST>` path.
pub use limits::{
COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_LOG_INTERVAL_SECS,
DEFAULT_OWD_WINDOW_SIZE, EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA, MAX_REPORT_INTERVAL_MS,
MAX_SESSION_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS,
SESSION_COLD_START_INTERVAL_MS,
};
+175
View File
@@ -0,0 +1,175 @@
//! Path MTU tracking for a single session (sans-IO, session-layer only).
//!
//! Destination side observes `path_mtu` from incoming envelopes and emits
//! notifications; source side applies received notifications to bound outbound
//! datagram size. All time inputs are injected `u64` milliseconds.
/// Path MTU tracking for a single session.
///
/// Destination side: observes `path_mtu` from incoming SessionDatagram envelopes
/// and generates PathMtuNotification messages back to the source.
///
/// Source side: applies received PathMtuNotification to limit outbound datagram
/// size. Decrease is immediate; increase requires 3 consecutive notifications.
pub struct PathMtuState {
/// Current effective path MTU (what we use for sending).
current_mtu: u16,
/// Last observed path MTU from incoming datagrams (destination-side).
last_observed_mtu: u16,
/// Whether the observed MTU has changed since the last notification.
observed_changed: bool,
/// Last time (injected `u64` ms) a PathMtuNotification was sent.
last_notification_ms: Option<u64>,
/// Notification interval in ms: max(10s, 5 * SRTT). Default 10s.
notification_interval_ms: u64,
/// For source-side increase tracking: consecutive higher-value notifications.
consecutive_increase_count: u8,
/// Time (injected `u64` ms) of the first notification in the current
/// increase sequence.
first_increase_ms: Option<u64>,
/// The MTU value being proposed for increase.
pending_increase_mtu: u16,
}
impl PathMtuState {
/// Create path MTU state with no initial measurement.
pub fn new() -> Self {
Self {
current_mtu: u16::MAX,
last_observed_mtu: u16::MAX,
observed_changed: false,
last_notification_ms: None,
notification_interval_ms: 10_000,
consecutive_increase_count: 0,
first_increase_ms: None,
pending_increase_mtu: 0,
}
}
/// Current effective path MTU (source-side, for sending).
pub fn current_mtu(&self) -> u16 {
self.current_mtu
}
/// Last observed incoming path MTU (destination-side).
pub fn last_observed_mtu(&self) -> u16 {
self.last_observed_mtu
}
/// Update notification interval from SRTT: max(10s, 5 * SRTT).
pub fn update_interval_from_srtt(&mut self, srtt_ms: f64) {
self.notification_interval_ms = ((srtt_ms * 5.0) as u64).max(10_000);
}
/// Seed source-side current_mtu from outbound transport MTU.
///
/// Called on each send. Only decreases (never increases) the current_mtu
/// so the destination's PathMtuNotification can still raise it later.
/// Ensures current_mtu doesn't stay at u16::MAX before any notification
/// arrives from the destination.
pub fn seed_source_mtu(&mut self, outbound_mtu: u16) {
if outbound_mtu < self.current_mtu {
self.current_mtu = outbound_mtu;
}
}
// --- Destination side ---
/// Observe the path_mtu from an incoming SessionDatagram envelope.
///
/// Called on the destination (receiver) side for every session message.
pub fn observe_incoming_mtu(&mut self, path_mtu: u16) {
if path_mtu != self.last_observed_mtu {
self.observed_changed = true;
self.last_observed_mtu = path_mtu;
}
}
/// Check if a PathMtuNotification should be sent.
///
/// Send on first measurement, on decrease (immediate), or periodic
/// confirmation at the notification interval. `now_ms` is the injected
/// monotonic time in milliseconds.
pub fn should_send_notification(&self, now_ms: u64) -> bool {
if self.last_observed_mtu == u16::MAX {
return false; // No measurement yet
}
match self.last_notification_ms {
None => true, // First measurement
Some(last) => {
// Immediate on decrease
if self.observed_changed && self.last_observed_mtu < self.current_mtu {
return true;
}
// Periodic confirmation
now_ms.saturating_sub(last) >= self.notification_interval_ms
}
}
}
/// Build a PathMtuNotification from current state.
///
/// Returns the path_mtu value to send. Caller handles encoding.
pub fn build_notification(&mut self, now_ms: u64) -> Option<u16> {
if self.last_observed_mtu == u16::MAX {
return None;
}
self.last_notification_ms = Some(now_ms);
self.observed_changed = false;
Some(self.last_observed_mtu)
}
// --- Source side ---
/// Apply a received PathMtuNotification.
///
/// - Decrease: immediate (take the lower value).
/// - Increase: require 3 consecutive notifications with the same higher
/// value, spanning at least 2 * notification_interval.
///
/// `now_ms` is the injected monotonic time in milliseconds. Returns `true`
/// if the effective MTU changed.
pub fn apply_notification(&mut self, reported_mtu: u16, now_ms: u64) -> bool {
if reported_mtu < self.current_mtu {
// Decrease: immediate
self.current_mtu = reported_mtu;
self.consecutive_increase_count = 0;
self.first_increase_ms = None;
return true;
}
if reported_mtu > self.current_mtu {
// Increase: track consecutive notifications
if reported_mtu == self.pending_increase_mtu {
self.consecutive_increase_count += 1;
} else {
// Different value: reset sequence
self.pending_increase_mtu = reported_mtu;
self.consecutive_increase_count = 1;
self.first_increase_ms = Some(now_ms);
}
// Accept increase after 3 consecutive spanning 2 * interval
if self.consecutive_increase_count >= 3
&& let Some(first_ms) = self.first_increase_ms
{
let required_ms = self.notification_interval_ms * 2;
if now_ms.saturating_sub(first_ms) >= required_ms {
self.current_mtu = reported_mtu;
self.consecutive_increase_count = 0;
self.first_increase_ms = None;
return true;
}
}
}
// No change (equal or increase not yet confirmed)
false
}
}
impl Default for PathMtuState {
fn default() -> Self {
Self::new()
}
}
+443
View File
@@ -0,0 +1,443 @@
//! Per-peer receiver-side MMP state (sans-IO).
//!
//! Accumulates per-frame observations (loss bursts, jitter, OWD trend, ECN)
//! and produces `ReceiverReport` snapshots. All time inputs are injected `u64`
//! milliseconds.
use super::algorithms::{JitterEstimator, OwdTrendDetector};
use super::wire::ReceiverReport;
use super::{
COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_OWD_WINDOW_SIZE,
MAX_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS,
};
/// Grace period after rekey before resuming jitter calculation.
///
/// During rekey cutover, frames from the old session may still arrive via the
/// drain window (DRAIN_WINDOW_SECS = 10s). These carry large sender timestamps
/// from the old session, producing enormous transit deltas that spike the EWMA
/// jitter estimator. We suppress jitter updates for drain window + 5s margin.
const REKEY_JITTER_GRACE_SECS: u64 = 15;
// ============================================================================
// Gap Tracker (burst loss detection)
// ============================================================================
/// Tracks counter gaps to detect loss bursts.
///
/// Each gap in the counter sequence is a burst of lost frames.
/// Maintains per-interval statistics that are reset when a report is built.
pub(crate) struct GapTracker {
/// Next expected counter value.
expected_next: Option<u64>,
/// Whether we are currently in a burst (gap).
in_burst: bool,
/// Length of the current burst.
current_burst_len: u16,
// --- Per-interval stats (reset on report) ---
/// Number of distinct burst events this interval.
burst_count: u32,
/// Longest burst in this interval.
max_burst_len: u16,
/// Sum of all burst lengths (for mean computation).
total_burst_len: u64,
}
impl GapTracker {
pub(crate) fn new() -> Self {
Self {
expected_next: None,
in_burst: false,
current_burst_len: 0,
burst_count: 0,
max_burst_len: 0,
total_burst_len: 0,
}
}
/// Process a received counter value. Returns the number of lost frames
/// detected (0 if in order or first frame).
pub(crate) fn observe(&mut self, counter: u64) -> u64 {
let Some(expected) = self.expected_next else {
// First frame: initialize
self.expected_next = Some(counter + 1);
return 0;
};
let lost = if counter > expected {
// Gap detected
let gap = counter - expected;
if self.in_burst {
// Extend current burst
self.current_burst_len = self.current_burst_len.saturating_add(gap as u16);
} else {
// New burst
self.in_burst = true;
self.current_burst_len = gap as u16;
self.burst_count += 1;
}
gap
} else {
// In-order or duplicate (counter <= expected)
if self.in_burst {
// End current burst
self.finish_burst();
}
0
};
// Update expected (always advance to counter+1 or keep expected if
// this was a late/reordered frame)
if counter >= expected {
self.expected_next = Some(counter + 1);
}
lost
}
/// Finish the current burst and record its stats.
fn finish_burst(&mut self) {
if self.in_burst {
self.max_burst_len = self.max_burst_len.max(self.current_burst_len);
self.total_burst_len += self.current_burst_len as u64;
self.in_burst = false;
self.current_burst_len = 0;
}
}
/// Get interval stats and reset for next interval.
pub(crate) fn take_interval_stats(&mut self) -> (u32, u16, u16) {
// Finish any in-progress burst
self.finish_burst();
let count = self.burst_count;
let max_len = self.max_burst_len;
let mean_len = if count > 0 {
// u8.8 fixed-point: (total / count) * 256
let mean_f = (self.total_burst_len as f64) / (count as f64);
(mean_f * 256.0) as u16
} else {
0
};
// Reset interval
self.burst_count = 0;
self.max_burst_len = 0;
self.total_burst_len = 0;
(count, max_len, mean_len)
}
}
// ============================================================================
// ReceiverState
// ============================================================================
/// Per-peer receiver-side MMP state.
///
/// Accumulates per-frame observations and produces `ReceiverReport` snapshots.
pub struct ReceiverState {
// --- Cumulative (lifetime) ---
cumulative_packets_recv: u64,
cumulative_bytes_recv: u64,
cumulative_reorder_count: u64,
/// Highest counter value ever received.
highest_counter: u64,
// --- Current interval ---
interval_packets_recv: u32,
interval_bytes_recv: u32,
// --- Jitter ---
jitter: JitterEstimator,
// --- OWD trend ---
owd_trend: OwdTrendDetector,
/// Monotonic sequence counter for OWD samples.
owd_seq: u32,
// --- Loss tracking ---
gap_tracker: GapTracker,
// --- ECN ---
ecn_ce_count: u32,
// --- Timestamp echo ---
/// Sender timestamp from the most recent frame (for echo).
last_sender_timestamp: u32,
/// Local time (injected `u64` ms) when the most recent frame was received
/// (for dwell / jitter computation).
last_recv_ms: Option<u64>,
// --- Rekey grace ---
/// When set, jitter updates are suppressed until this injected-ms instant
/// passes. Prevents drain-window frames from spiking the jitter estimator.
rekey_jitter_grace_until_ms: Option<u64>,
// --- Report timing (injected `u64` ms) ---
last_report_ms: Option<u64>,
report_interval_ms: u64,
/// Whether any frames have been received since the last report.
interval_has_data: bool,
// --- Cold-start tracking ---
/// Number of SRTT-based interval updates received.
srtt_sample_count: u32,
}
impl ReceiverState {
pub fn new(owd_window_size: usize) -> Self {
Self::new_with_cold_start(owd_window_size, DEFAULT_COLD_START_INTERVAL_MS)
}
/// Create with a custom cold-start interval (ms).
///
/// Used by session-layer MMP which needs a longer initial interval
/// since reports consume bandwidth on every transit link.
pub fn new_with_cold_start(owd_window_size: usize, cold_start_ms: u64) -> Self {
Self {
cumulative_packets_recv: 0,
cumulative_bytes_recv: 0,
cumulative_reorder_count: 0,
highest_counter: 0,
interval_packets_recv: 0,
interval_bytes_recv: 0,
jitter: JitterEstimator::new(),
owd_trend: OwdTrendDetector::new(owd_window_size),
owd_seq: 0,
gap_tracker: GapTracker::new(),
ecn_ce_count: 0,
last_sender_timestamp: 0,
last_recv_ms: None,
rekey_jitter_grace_until_ms: None,
last_report_ms: None,
report_interval_ms: cold_start_ms,
interval_has_data: false,
srtt_sample_count: 0,
}
}
/// Reset counter-dependent state for rekey cutover.
///
/// After cutover, the new session starts with counter 0 and reset
/// timestamps. Without resetting, the old `highest_counter` and
/// `GapTracker.expected_next` cause false reorder/loss detection.
/// `now_ms` is the injected monotonic time in milliseconds.
pub fn reset_for_rekey(&mut self, now_ms: u64) {
self.highest_counter = 0;
self.cumulative_reorder_count = 0;
self.gap_tracker = GapTracker::new();
self.interval_packets_recv = 0;
self.interval_bytes_recv = 0;
self.jitter = JitterEstimator::new();
self.owd_trend.clear();
self.owd_seq = 0;
self.last_sender_timestamp = 0;
self.last_recv_ms = None;
self.rekey_jitter_grace_until_ms = Some(now_ms + REKEY_JITTER_GRACE_SECS * 1000);
self.ecn_ce_count = 0;
self.interval_has_data = false;
// Keep cumulative_packets_recv, cumulative_bytes_recv (lifetime stats)
// Keep last_report_ms, report_interval_ms (report scheduling)
}
/// Record a received frame from this peer.
///
/// Called on the RX path after AEAD decryption, before message dispatch.
///
/// - `counter`: AEAD counter from outer header
/// - `sender_timestamp_ms`: session-relative timestamp from inner header (ms)
/// - `bytes`: wire payload size
/// - `ce_flag`: CE bit from flags byte
/// - `now_ms`: injected monotonic local time in milliseconds
pub fn record_recv(
&mut self,
counter: u64,
sender_timestamp_ms: u32,
bytes: usize,
ce_flag: bool,
now_ms: u64,
) {
self.interval_has_data = true;
self.cumulative_packets_recv += 1;
self.cumulative_bytes_recv += bytes as u64;
self.interval_packets_recv = self.interval_packets_recv.saturating_add(1);
self.interval_bytes_recv = self.interval_bytes_recv.saturating_add(bytes as u32);
// Reordering detection: counter < highest means out-of-order
if counter < self.highest_counter {
self.cumulative_reorder_count += 1;
} else {
self.highest_counter = counter;
}
// Loss/burst detection
let _lost = self.gap_tracker.observe(counter);
// ECN
if ce_flag {
self.ecn_ce_count = self.ecn_ce_count.saturating_add(1);
}
// Jitter: compute transit time delta
// Transit = recv_local - sender_timestamp (in µs for precision)
// We use the injected monotonic ms clock for the local reference.
let sender_us = (sender_timestamp_ms as i64) * 1000;
// We compute the delta between consecutive transits using relative
// millisecond differences (scaled to µs to match the estimator input).
// Skip during post-rekey grace period to avoid drain-window spikes.
let in_grace = self
.rekey_jitter_grace_until_ms
.is_some_and(|deadline| now_ms < deadline);
if !in_grace {
self.rekey_jitter_grace_until_ms = None; // clear expired grace
if let Some(prev_recv) = self.last_recv_ms {
let recv_delta_us = (now_ms.saturating_sub(prev_recv) as i64) * 1000;
let send_delta_us = sender_us - (self.last_sender_timestamp as i64 * 1000);
let transit_delta = (recv_delta_us - send_delta_us) as i32;
self.jitter.update(transit_delta);
}
}
// OWD trend: use sender timestamp as a proxy for send time
// and the injected ms delta from a fixed reference as receive time.
// Since we only need the *trend* (slope), absolute offsets cancel out.
if let Some(first_recv) = self.last_recv_ms.or(Some(now_ms)) {
let recv_offset_us = (now_ms.saturating_sub(first_recv) as i64) * 1000;
let owd_us = recv_offset_us - sender_us;
self.owd_seq = self.owd_seq.wrapping_add(1);
self.owd_trend.push(self.owd_seq, owd_us);
}
// Timestamp echo state
self.last_sender_timestamp = sender_timestamp_ms;
self.last_recv_ms = Some(now_ms);
}
/// Build a ReceiverReport from current state and reset the interval.
///
/// Returns `None` if no frames have been received since the last report.
/// `now_ms` is the injected monotonic time in milliseconds.
pub fn build_report(&mut self, now_ms: u64) -> Option<ReceiverReport> {
if !self.interval_has_data {
return None;
}
// Dwell time: ms between last frame reception and report generation.
// If it no longer fits on the wire, the timestamp echo cannot produce
// a valid RTT sample. Preserve the counters but suppress the echo.
let (timestamp_echo, dwell_time) = self
.last_recv_ms
.map(|t| {
let dwell_ms = now_ms.saturating_sub(t);
if dwell_ms > u64::from(u16::MAX) {
(0, u16::MAX)
} else {
(self.last_sender_timestamp, dwell_ms as u16)
}
})
.unwrap_or((0, 0));
let (burst_count, _max_burst, _mean_burst) = self.gap_tracker.take_interval_stats();
let report = ReceiverReport {
timestamp_echo,
dwell_time,
highest_counter: self.highest_counter,
cumulative_packets_recv: self.cumulative_packets_recv,
cumulative_bytes_recv: self.cumulative_bytes_recv,
jitter: self.jitter.jitter_us(),
ecn_ce_count: self.ecn_ce_count,
owd_trend: self.owd_trend.trend_us_per_sec(),
burst_loss_count: burst_count,
cumulative_reorder_count: self.cumulative_reorder_count as u32,
};
// Reset interval
self.interval_packets_recv = 0;
self.interval_bytes_recv = 0;
self.interval_has_data = false;
self.last_report_ms = Some(now_ms);
Some(report)
}
/// Check if it's time to send a report.
pub fn should_send_report(&self, now_ms: u64) -> bool {
if !self.interval_has_data {
return false;
}
match self.last_report_ms {
None => true,
Some(last) => now_ms.saturating_sub(last) >= self.report_interval_ms,
}
}
/// Update the report interval based on SRTT (link-layer defaults).
///
/// Receiver reports at 1× SRTT clamped to [floor, MAX]. During cold-start
/// (first `COLD_START_SAMPLES` updates), the floor is the cold-start
/// interval (200ms) for fast SRTT convergence. After that, it rises to
/// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency.
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.srtt_sample_count = self.srtt_sample_count.saturating_add(1);
let floor = if self.srtt_sample_count <= COLD_START_SAMPLES {
DEFAULT_COLD_START_INTERVAL_MS
} else {
MIN_REPORT_INTERVAL_MS
};
self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS);
}
/// Update the report interval based on SRTT with custom bounds.
///
/// Used by session-layer MMP which needs higher clamp values since
/// each report consumes bandwidth on every transit link.
pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) {
if srtt_us <= 0 {
return;
}
let interval_ms = ((srtt_us as u64) / 1000).clamp(min_ms, max_ms);
self.report_interval_ms = interval_ms;
}
// --- Accessors ---
pub fn cumulative_packets_recv(&self) -> u64 {
self.cumulative_packets_recv
}
pub fn cumulative_bytes_recv(&self) -> u64 {
self.cumulative_bytes_recv
}
pub fn highest_counter(&self) -> u64 {
self.highest_counter
}
pub fn jitter_us(&self) -> u32 {
self.jitter.jitter_us()
}
pub fn report_interval_ms(&self) -> u64 {
self.report_interval_ms
}
/// Local monotonic time (injected `u64` ms) of the most recent received
/// frame, or `None` if none has been received.
pub fn last_recv_ms(&self) -> Option<u64> {
self.last_recv_ms
}
pub fn ecn_ce_count(&self) -> u32 {
self.ecn_ce_count
}
}
impl Default for ReceiverState {
fn default() -> Self {
Self::new(DEFAULT_OWD_WINDOW_SIZE)
}
}
+190
View File
@@ -0,0 +1,190 @@
//! Per-peer sender-side MMP state (sans-IO).
//!
//! Records cumulative and interval TX counters and produces `SenderReport`
//! snapshots on demand. All time inputs are injected `u64` milliseconds.
use super::wire::SenderReport;
use super::{
COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, MAX_REPORT_INTERVAL_MS,
MIN_REPORT_INTERVAL_MS,
};
/// Per-peer sender-side MMP state.
///
/// Records cumulative and interval counters for every frame transmitted
/// to this peer. Produces `SenderReport` snapshots on demand.
pub struct SenderState {
// --- Cumulative (lifetime) ---
cumulative_packets_sent: u64,
// --- Current interval ---
interval_packets_sent: u32,
interval_bytes_sent: u32,
/// Whether any frames have been sent in the current interval.
interval_has_data: bool,
// --- Report timing (injected `u64` ms) ---
last_report_ms: Option<u64>,
report_interval_ms: u64,
// --- Send failure backoff ---
/// Consecutive send failure count for backoff calculation.
consecutive_send_failures: u32,
// --- Cold-start tracking ---
/// Number of SRTT-based interval updates received.
srtt_sample_count: u32,
}
impl SenderState {
pub fn new() -> Self {
Self::new_with_cold_start(DEFAULT_COLD_START_INTERVAL_MS)
}
/// Create with a custom cold-start interval (ms).
///
/// Used by session-layer MMP which needs a longer initial interval
/// since reports consume bandwidth on every transit link.
pub fn new_with_cold_start(cold_start_ms: u64) -> Self {
Self {
cumulative_packets_sent: 0,
interval_packets_sent: 0,
interval_bytes_sent: 0,
interval_has_data: false,
last_report_ms: None,
report_interval_ms: cold_start_ms,
consecutive_send_failures: 0,
srtt_sample_count: 0,
}
}
/// Record a frame sent to this peer.
///
/// Called on the TX path for every encrypted link message.
/// `counter` is the AEAD nonce/counter, `timestamp` is the inner header
/// session-relative timestamp (ms), `bytes` is the wire payload size.
pub fn record_sent(&mut self, _counter: u64, _timestamp: u32, bytes: usize) {
self.interval_has_data = true;
self.interval_packets_sent = self.interval_packets_sent.saturating_add(1);
self.interval_bytes_sent = self.interval_bytes_sent.saturating_add(bytes as u32);
self.cumulative_packets_sent += 1;
}
/// Build a SenderReport from current state and reset the interval.
///
/// Returns `None` if no frames have been sent since the last report.
/// `now_ms` is the injected monotonic time in milliseconds.
pub fn build_report(&mut self, now_ms: u64) -> Option<SenderReport> {
if !self.interval_has_data {
return None;
}
let report = SenderReport {
interval_packets_sent: self.interval_packets_sent,
interval_bytes_sent: self.interval_bytes_sent,
cumulative_packets_sent: self.cumulative_packets_sent,
};
// Reset interval
self.interval_has_data = false;
self.interval_packets_sent = 0;
self.interval_bytes_sent = 0;
self.last_report_ms = Some(now_ms);
Some(report)
}
/// Check if it's time to send a report.
///
/// When consecutive send failures have occurred, the effective interval
/// is multiplied by an exponential backoff factor (2^failures, capped at 32×).
pub fn should_send_report(&self, now_ms: u64) -> bool {
if !self.interval_has_data {
return false;
}
match self.last_report_ms {
None => true, // Never sent a report — send immediately
Some(last) => {
let effective_ms = (self.report_interval_ms as f64
* self.send_failure_backoff_multiplier())
as u64;
now_ms.saturating_sub(last) >= effective_ms
}
}
}
/// Record a send failure. Returns the new consecutive failure count.
pub fn record_send_failure(&mut self) -> u32 {
self.consecutive_send_failures += 1;
self.consecutive_send_failures
}
/// Record a successful send. Returns the previous failure count (for summary logging).
pub fn record_send_success(&mut self) -> u32 {
let prev = self.consecutive_send_failures;
self.consecutive_send_failures = 0;
prev
}
/// Get the backoff multiplier based on consecutive failures.
///
/// Returns 1.0 for no failures, 2.0 for 1 failure, 4.0 for 2, ...
/// capped at 32.0 (5 failures). Computed as an exact power-of-two bit shift
/// (`2^k == 1 << k`), keeping the module `core`-only (no `libm` for `powi`).
pub fn send_failure_backoff_multiplier(&self) -> f64 {
if self.consecutive_send_failures == 0 {
1.0
} else {
(1u64 << self.consecutive_send_failures.min(5)) as f64
}
}
/// Update the report interval based on SRTT (link-layer defaults).
///
/// Sender reports at 2× SRTT clamped to [floor, MAX]. During cold-start
/// (first `COLD_START_SAMPLES` updates), the floor is the cold-start
/// interval (200ms) for fast SRTT convergence. After that, it rises to
/// `MIN_REPORT_INTERVAL_MS` (1000ms) for steady-state efficiency.
pub fn update_report_interval_from_srtt(&mut self, srtt_us: i64) {
self.srtt_sample_count = self.srtt_sample_count.saturating_add(1);
let floor = if self.srtt_sample_count <= COLD_START_SAMPLES {
DEFAULT_COLD_START_INTERVAL_MS
} else {
MIN_REPORT_INTERVAL_MS
};
self.update_report_interval_with_bounds(srtt_us, floor, MAX_REPORT_INTERVAL_MS);
}
/// Update the report interval based on SRTT with custom bounds.
///
/// Used by session-layer MMP which needs higher clamp values since
/// each report consumes bandwidth on every transit link.
pub fn update_report_interval_with_bounds(&mut self, srtt_us: i64, min_ms: u64, max_ms: u64) {
if srtt_us <= 0 {
return;
}
let interval_us = (srtt_us * 2) as u64;
let interval_ms = (interval_us / 1000).clamp(min_ms, max_ms);
self.report_interval_ms = interval_ms;
}
// --- Accessors ---
pub fn cumulative_packets_sent(&self) -> u64 {
self.cumulative_packets_sent
}
pub fn report_interval_ms(&self) -> u64 {
self.report_interval_ms
}
pub fn consecutive_send_failures(&self) -> u32 {
self.consecutive_send_failures
}
}
impl Default for SenderState {
fn default() -> Self {
Self::new()
}
}
+12 -1127
View File
File diff suppressed because it is too large Load Diff
+2 -1
View File
@@ -1,6 +1,7 @@
//! Tests for the owned MMP protocol state machines.
use crate::proto::mmp::state::{GapTracker, ReceiverState, SenderState};
use crate::proto::mmp::receiver::{GapTracker, ReceiverState};
use crate::proto::mmp::sender::SenderState;
use crate::proto::mmp::{
COLD_START_SAMPLES, DEFAULT_LOG_INTERVAL_SECS, DEFAULT_OWD_WINDOW_SIZE, MAX_REPORT_INTERVAL_MS,
MIN_REPORT_INTERVAL_MS, MmpMetrics, MmpMode, ReceiverReport,
+4
View File
@@ -7,10 +7,14 @@ mod error;
pub use error::Error;
pub(crate) mod bloom;
pub(crate) mod codec;
pub(crate) mod coord;
pub(crate) mod discovery;
pub(crate) mod fmp;
pub(crate) mod fsp;
pub(crate) mod link;
pub(crate) mod math;
pub(crate) mod mmp;
pub(crate) mod rate_limit;
pub(crate) mod routing;
pub(crate) mod stp;
+60
View File
@@ -0,0 +1,60 @@
//! Shared `proto` rate-limiting / backoff primitives: a per-address
//! minimum-interval limiter and an exponential backoff helper, hoisted out of
//! the subsystem `limits.rs` files.
use crate::NodeAddr;
use alloc::collections::BTreeMap;
/// Per-address minimum-interval rate limiter. Tracks the last event time per
/// address and enforces a minimum interval, evicting entries older than a max age.
pub(crate) struct PerAddrRateLimiter {
last: BTreeMap<NodeAddr, u64>,
min_interval_ms: u64,
max_age_ms: u64,
}
impl PerAddrRateLimiter {
pub(crate) fn new(min_interval_ms: u64, max_age_ms: u64) -> Self {
Self {
last: BTreeMap::new(),
min_interval_ms,
max_age_ms,
}
}
/// Returns true (and records `now_ms`) if enough time has elapsed since the
/// last event for `addr`, or this is the first; false if within the interval.
pub(crate) fn check_and_record(&mut self, addr: &NodeAddr, now_ms: u64) -> bool {
if let Some(&last) = self.last.get(addr)
&& now_ms.saturating_sub(last) < self.min_interval_ms
{
return false;
}
self.last.insert(*addr, now_ms);
self.cleanup(now_ms);
true
}
pub(crate) fn cleanup(&mut self, now_ms: u64) {
self.last
.retain(|_, &mut last| now_ms.saturating_sub(last) < self.max_age_ms);
}
#[cfg(test)]
pub(crate) fn set_interval_ms(&mut self, interval_ms: u64) {
self.min_interval_ms = interval_ms;
}
#[cfg(test)]
pub(crate) fn len(&self) -> usize {
self.last.len()
}
}
/// Exponential (base-2) backoff: `base_ms * 2^exponent`, saturating, capped at
/// `cap_ms`. Shared by the discovery originator backoff (exponent = failures-1)
/// and the FMP retry scheduler (exponent = retry_count).
pub(crate) fn backoff_ms(exponent: u32, base_ms: u64, cap_ms: u64) -> u64 {
let multiplier = 1u64.checked_shl(exponent).unwrap_or(u64::MAX);
base_ms.saturating_mul(multiplier).min(cap_ms)
}
+8 -32
View File
@@ -9,7 +9,7 @@
//! portability and deterministic ordering.
use crate::NodeAddr;
use alloc::collections::BTreeMap;
use crate::proto::rate_limit::PerAddrRateLimiter;
/// Default minimum interval between error signals: 100 ms (max 10 errors/sec
/// per destination).
@@ -22,35 +22,19 @@ const MAX_AGE_MS: u64 = 10_000;
///
/// Tracks the last time a routing error was sent for each destination
/// address and enforces a minimum interval to prevent floods.
pub struct RoutingErrorRateLimiter {
/// Maps destination NodeAddr to the last time (Unix ms) we sent an error
/// about it.
last_sent: BTreeMap<NodeAddr, u64>,
/// Minimum interval between error signals for the same destination (ms).
min_interval_ms: u64,
/// Maximum age of entries before cleanup (ms).
max_age_ms: u64,
}
pub struct RoutingErrorRateLimiter(PerAddrRateLimiter);
impl RoutingErrorRateLimiter {
/// Create a new rate limiter.
///
/// Default: max 10 errors/sec per destination (100ms interval).
pub fn new() -> Self {
Self {
last_sent: BTreeMap::new(),
min_interval_ms: DEFAULT_MIN_INTERVAL_MS,
max_age_ms: MAX_AGE_MS,
}
Self(PerAddrRateLimiter::new(DEFAULT_MIN_INTERVAL_MS, MAX_AGE_MS))
}
/// Create a rate limiter with a custom minimum interval in milliseconds.
pub fn with_interval_ms(min_interval_ms: u64) -> Self {
Self {
last_sent: BTreeMap::new(),
min_interval_ms,
max_age_ms: MAX_AGE_MS,
}
Self(PerAddrRateLimiter::new(min_interval_ms, MAX_AGE_MS))
}
/// Check if we should send a routing error for this destination at
@@ -60,26 +44,18 @@ impl RoutingErrorRateLimiter {
/// this destination, or if this is the first error. Updates internal
/// state when returning true.
pub fn should_send(&mut self, dest_addr: &NodeAddr, now_ms: u64) -> bool {
if let Some(&last) = self.last_sent.get(dest_addr)
&& now_ms.saturating_sub(last) < self.min_interval_ms
{
return false;
}
self.last_sent.insert(*dest_addr, now_ms);
self.cleanup(now_ms);
true
self.0.check_and_record(dest_addr, now_ms)
}
/// Remove entries older than max_age.
#[cfg(test)]
pub(crate) fn cleanup(&mut self, now_ms: u64) {
self.last_sent
.retain(|_, &mut last| now_ms.saturating_sub(last) < self.max_age_ms);
self.0.cleanup(now_ms);
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.last_sent.len()
self.0.len()
}
}
+37 -51
View File
@@ -12,6 +12,7 @@
use crate::NodeAddr;
use crate::proto::Error;
use crate::proto::codec::{Reader, Writer};
use crate::proto::stp::{
TreeCoordinate, decode_optional_coords, encode_coords, encode_empty_coords,
};
@@ -109,34 +110,29 @@ impl CoordsRequired {
pub fn encode(&self) -> Vec<u8> {
// Body: msg_type + flags(reserved) + dest_addr + reporter
let body_len = 1 + 1 + 16 + 16; // 34 bytes
let mut buf = Vec::with_capacity(4 + body_len);
let mut w = Writer::with_capacity(4 + body_len);
// FSP prefix: version 0, phase 0x0, U flag set
buf.push(0x00); // version 0, phase 0x0
buf.push(0x04); // U flag
w.write_u8(0x00); // version 0, phase 0x0
w.write_u8(0x04); // U flag
let payload_len = body_len as u16;
buf.extend_from_slice(&payload_len.to_le_bytes());
w.write_u16_le(payload_len);
// msg_type byte (after prefix, before body)
buf.push(RoutingSignalType::CoordsRequired.to_byte());
buf.push(0x00); // reserved flags
buf.extend_from_slice(self.dest_addr.as_bytes());
buf.extend_from_slice(self.reporter.as_bytes());
buf
w.write_u8(RoutingSignalType::CoordsRequired.to_byte());
w.write_u8(0x00); // reserved flags
w.write_bytes(self.dest_addr.as_bytes());
w.write_bytes(self.reporter.as_bytes());
w.into_vec()
}
/// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) = 33
if payload.len() < 33 {
return Err(Error::MessageTooShort {
expected: 33,
got: payload.len(),
});
}
let mut reader = Reader::new(payload);
reader.require(33)?;
// payload[0] is flags (reserved, ignored)
let mut dest_bytes = [0u8; 16];
dest_bytes.copy_from_slice(&payload[1..17]);
let mut reporter_bytes = [0u8; 16];
reporter_bytes.copy_from_slice(&payload[17..33]);
reader.advance(1);
let dest_bytes = reader.read_array::<16>()?;
let reporter_bytes = reader.read_array::<16>()?;
Ok(Self {
dest_addr: NodeAddr::from_bytes(dest_bytes),
@@ -217,19 +213,14 @@ impl PathBroken {
/// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) + coords_count(2) = 35 minimum
if payload.len() < 35 {
return Err(Error::MessageTooShort {
expected: 35,
got: payload.len(),
});
}
let mut reader = Reader::new(payload);
reader.require(35)?;
// payload[0] is flags (reserved, ignored)
let mut dest_bytes = [0u8; 16];
dest_bytes.copy_from_slice(&payload[1..17]);
let mut reporter_bytes = [0u8; 16];
reporter_bytes.copy_from_slice(&payload[17..33]);
reader.advance(1);
let dest_bytes = reader.read_array::<16>()?;
let reporter_bytes = reader.read_array::<16>()?;
let (last_known_coords, _consumed) = decode_optional_coords(&payload[33..])?;
let (last_known_coords, _consumed) = decode_optional_coords(reader.rest())?;
Ok(Self {
dest_addr: NodeAddr::from_bytes(dest_bytes),
@@ -284,36 +275,31 @@ impl MtuExceeded {
/// Error signals use phase=0x0 with U flag set.
pub fn encode(&self) -> Vec<u8> {
let body_len = MTU_EXCEEDED_SIZE; // 36 bytes
let mut buf = Vec::with_capacity(4 + body_len);
let mut w = Writer::with_capacity(4 + body_len);
// FSP prefix: version 0, phase 0x0, U flag set
buf.push(0x00); // version 0, phase 0x0
buf.push(0x04); // U flag
w.write_u8(0x00); // version 0, phase 0x0
w.write_u8(0x04); // U flag
let payload_len = body_len as u16;
buf.extend_from_slice(&payload_len.to_le_bytes());
w.write_u16_le(payload_len);
// msg_type byte
buf.push(RoutingSignalType::MtuExceeded.to_byte());
buf.push(0x00); // reserved flags
buf.extend_from_slice(self.dest_addr.as_bytes());
buf.extend_from_slice(self.reporter.as_bytes());
buf.extend_from_slice(&self.mtu.to_le_bytes());
buf
w.write_u8(RoutingSignalType::MtuExceeded.to_byte());
w.write_u8(0x00); // reserved flags
w.write_bytes(self.dest_addr.as_bytes());
w.write_bytes(self.reporter.as_bytes());
w.write_u16_le(self.mtu);
w.into_vec()
}
/// Decode from wire format (after FSP prefix and msg_type byte consumed).
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
// flags(1) + dest_addr(16) + reporter(16) + mtu(2) = 35
if payload.len() < 35 {
return Err(Error::MessageTooShort {
expected: 35,
got: payload.len(),
});
}
let mut reader = Reader::new(payload);
reader.require(35)?;
// payload[0] is flags (reserved, ignored)
let mut dest_bytes = [0u8; 16];
dest_bytes.copy_from_slice(&payload[1..17]);
let mut reporter_bytes = [0u8; 16];
reporter_bytes.copy_from_slice(&payload[17..33]);
let mtu = u16::from_le_bytes([payload[33], payload[34]]);
reader.advance(1);
let dest_bytes = reader.read_array::<16>()?;
let reporter_bytes = reader.read_array::<16>()?;
let mtu = reader.read_u16_le()?;
Ok(Self {
dest_addr: NodeAddr::from_bytes(dest_bytes),
+62 -19
View File
@@ -3,10 +3,13 @@
//! The post-`update_peer` parent-switch / self-root / loop-drop / ancestry-update
//! ladder that the async `node::tree` handler inlines is extracted here as a pure
//! decision: [`Stp::classify_announce`] reads a consistent `TreeState` plus the
//! shell-passed facts (`peer_costs`, `skip`, and the monotonic `now_ms` the
//! ranking's hold-down / flap-dampening suppression checks read) and returns a
//! [`TreeDecision`] the shell drives. No I/O, no tracing, no keys, no mutation;
//! the injected `now_ms` is the only time input, threaded to `evaluate_parent`.
//! shell-passed facts (`peer_costs`, `skip`, and the pre-computed `switch_suppressed`
//! verdict of the flap-dampening / hold-down veto) and returns a [`TreeDecision`]
//! the shell drives. No I/O, no tracing, no keys, no mutation — and no clock: the
//! classify core is clock-free. The shell reads the monotonic clock at the edge,
//! computes the veto verdict via `TreeState::is_switch_suppressed`, and passes the
//! resulting bool in; `evaluate_parent` distinguishes mandatory from discretionary
//! switches and the ladder applies the veto only to the discretionary arm.
//!
//! [`TreeState`] lives beside this module in `proto/stp/state.rs`.
@@ -76,22 +79,55 @@ pub(crate) enum TreeDecision {
NoChange,
}
/// Outcome of `TreeState::evaluate_parent`: whether a parent switch is warranted
/// and, if so, whether it is mandatory (bypasses the flap/hold-down veto) or
/// discretionary (the shell applies the veto before switching).
pub(crate) enum ParentEval {
/// Path-breaking / root-correcting switch — always taken, veto bypassed.
Mandatory(NodeAddr),
/// Improvement switch — taken only if the shell's veto is not active.
Discretionary(NodeAddr),
/// No switch warranted.
None,
}
impl ParentEval {
/// The switch target if one is warranted IGNORING the veto (i.e. treating a
/// discretionary candidate as taken). Convenience for tests/paths that do not
/// engage the veto. Returns `None` only for `ParentEval::None`.
#[cfg(test)]
pub(crate) fn switch_target(&self) -> Option<NodeAddr> {
match self {
ParentEval::Mandatory(a) | ParentEval::Discretionary(a) => Some(*a),
ParentEval::None => Option::None,
}
}
}
impl Stp {
/// Classify an inbound, already-validated TreeAnnounce into a [`TreeDecision`].
///
/// Pure: reads the post-`update_peer` `tree` plus the shell-passed `peer_costs`
/// (per-peer link cost) and `skip` (non-full/leaf peers excluded from parent
/// candidacy — empty on master, `non_full_peers()` on next). Mirrors the inline
/// ladder in `node::tree::handle_tree_announce`: parent-switch, else self-root,
/// else (same-parent) loop-drop or ancestry-update, else no change.
/// (per-peer link cost), `skip` (non-full/leaf peers excluded from parent
/// candidacy — empty on master, `non_full_peers()` on next), and the
/// pre-computed `switch_suppressed` veto verdict (the shell reads the clock and
/// calls `TreeState::is_switch_suppressed`; a discretionary switch is skipped
/// while it is true, a mandatory switch ignores it). Mirrors the inline ladder
/// in `node::tree::handle_tree_announce`: parent-switch, else self-root, else
/// (same-parent) loop-drop or ancestry-update, else no change.
pub(crate) fn classify_announce(
tree: &TreeState,
from: NodeAddr,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip: &BTreeSet<NodeAddr>,
now_ms: u64,
switch_suppressed: bool,
) -> TreeDecision {
if let Some(new_parent) = tree.evaluate_parent(peer_costs, skip, now_ms) {
let switch = match tree.evaluate_parent(peer_costs, skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = switch {
let new_seq = tree.my_declaration().sequence() + 1;
TreeDecision::Switch {
new_parent,
@@ -120,20 +156,27 @@ impl Stp {
/// Classify a periodic parent re-evaluation into a [`TreeDecision`].
///
/// Pure: reads the current `tree` plus the shell-passed `peer_costs` and `skip`
/// (empty on master, `non_full_peers()` on next). Mirrors the periodic ladder in
/// `node::tree::check_periodic_parent_reeval`: parent-switch, else self-root,
/// else re-broadcast for eventual consistency. Unlike `classify_announce`, the
/// periodic path has no same-parent loop-drop / ancestry-update arms — a periodic
/// tick has no announcing peer, so those cases never arise; the no-change tail is
/// a re-broadcast rather than a true no-op.
/// Pure: reads the current `tree` plus the shell-passed `peer_costs`, `skip`
/// (empty on master, `non_full_peers()` on next), and the pre-computed
/// `switch_suppressed` veto verdict (see `classify_announce`; a discretionary
/// switch is skipped while true, a mandatory switch ignores it). Mirrors the
/// periodic ladder in `node::tree::check_periodic_parent_reeval`: parent-switch,
/// else self-root, else re-broadcast for eventual consistency. Unlike
/// `classify_announce`, the periodic path has no same-parent loop-drop /
/// ancestry-update arms — a periodic tick has no announcing peer, so those cases
/// never arise; the no-change tail is a re-broadcast rather than a true no-op.
pub(crate) fn classify_periodic(
tree: &TreeState,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip: &BTreeSet<NodeAddr>,
now_ms: u64,
switch_suppressed: bool,
) -> TreeDecision {
if let Some(new_parent) = tree.evaluate_parent(peer_costs, skip, now_ms) {
let switch = match tree.evaluate_parent(peer_costs, skip) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !switch_suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = switch {
let new_seq = tree.my_declaration().sequence() + 1;
TreeDecision::Switch {
new_parent,
+144
View File
@@ -0,0 +1,144 @@
//! Parent declaration: a node's signed announcement of its spanning-tree parent.
use core::fmt;
use crate::NodeAddr;
/// A node's declaration of its parent in the spanning tree.
///
/// Each node periodically announces its parent selection. The declaration
/// includes a monotonic sequence number for freshness and a signature
/// for authenticity. When `parent_id == node_addr`, the node declares itself
/// as a root candidate.
#[derive(Clone)]
pub struct ParentDeclaration {
/// The node making this declaration.
node_addr: NodeAddr,
/// The selected parent (equals node_addr if self-declaring as root).
parent_id: NodeAddr,
/// Monotonically increasing sequence number.
sequence: u64,
/// Timestamp when this declaration was created (Unix seconds).
timestamp: u64,
/// Raw 64-byte Schnorr signature over the declaration fields. Stored as
/// opaque bytes so the in-core type carries no signature-crypto dependency;
/// the shell computes/verifies it over `signing_bytes()` (§6).
signature: Option<[u8; 64]>,
}
impl ParentDeclaration {
/// Create a new unsigned parent declaration.
///
/// The declaration must be signed before transmission using `set_signature()`.
pub fn new(node_addr: NodeAddr, parent_id: NodeAddr, sequence: u64, timestamp: u64) -> Self {
Self {
node_addr,
parent_id,
sequence,
timestamp,
signature: None,
}
}
/// Create a self-declaration (node is root candidate).
pub fn self_root(node_addr: NodeAddr, sequence: u64, timestamp: u64) -> Self {
Self::new(node_addr, node_addr, sequence, timestamp)
}
/// Create a declaration with a pre-computed signature.
pub fn with_signature(
node_addr: NodeAddr,
parent_id: NodeAddr,
sequence: u64,
timestamp: u64,
signature: [u8; 64],
) -> Self {
Self {
node_addr,
parent_id,
sequence,
timestamp,
signature: Some(signature),
}
}
/// Get the declaring node's ID.
pub fn node_addr(&self) -> &NodeAddr {
&self.node_addr
}
/// Get the parent node's ID.
pub fn parent_id(&self) -> &NodeAddr {
&self.parent_id
}
/// Get the sequence number.
pub fn sequence(&self) -> u64 {
self.sequence
}
/// Get the timestamp.
pub fn timestamp(&self) -> u64 {
self.timestamp
}
/// Get the raw 64-byte signature, if set.
pub fn signature(&self) -> Option<&[u8; 64]> {
self.signature.as_ref()
}
/// Set the raw 64-byte signature after signing.
pub fn set_signature(&mut self, signature: [u8; 64]) {
self.signature = Some(signature);
}
/// Check if this is a root declaration (parent == self).
pub fn is_root(&self) -> bool {
self.node_addr == self.parent_id
}
/// Check if this declaration is signed.
pub fn is_signed(&self) -> bool {
self.signature.is_some()
}
/// Get the bytes that should be signed.
///
/// Format: node_addr (16) || parent_id (16) || sequence (8) || timestamp (8)
pub fn signing_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(48);
bytes.extend_from_slice(self.node_addr.as_bytes());
bytes.extend_from_slice(self.parent_id.as_bytes());
bytes.extend_from_slice(&self.sequence.to_le_bytes());
bytes.extend_from_slice(&self.timestamp.to_le_bytes());
bytes
}
/// Check if this declaration is fresher than another.
pub fn is_fresher_than(&self, other: &ParentDeclaration) -> bool {
self.sequence > other.sequence
}
}
impl fmt::Debug for ParentDeclaration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ParentDeclaration")
.field("node_addr", &self.node_addr)
.field("parent_id", &self.parent_id)
.field("sequence", &self.sequence)
.field("is_root", &self.is_root())
.field("signed", &self.is_signed())
.finish()
}
}
impl PartialEq for ParentDeclaration {
fn eq(&self, other: &Self) -> bool {
self.node_addr == other.node_addr
&& self.parent_id == other.parent_id
&& self.sequence == other.sequence
&& self.timestamp == other.timestamp
}
}
impl Eq for ParentDeclaration {}
+1 -1
View File
@@ -11,7 +11,7 @@
//! `flap_dampening_duration`.
//!
//! The monotonic clock is injected: every timing method takes a `now_ms: u64`
//! (monotonic milliseconds, read by the shell from `crate::mmp::mono_ms`), and
//! (monotonic milliseconds, read by the shell from `crate::time::mono_ms`), and
//! all timers/durations are stored as plain `u64` milliseconds. Configuration
//! setters accept seconds (matching the node config) and store the value scaled
//! to milliseconds so the comparisons stay in one unit.
+94 -27
View File
@@ -13,13 +13,14 @@
//! - `state.rs` — `TreeState` + `ParentDeclaration` data + the `&self`
//! ranking/election methods (`evaluate_parent`, `should_be_root`,
//! `find_next_hop`).
//! - `coordinate.rs` — `TreeCoordinate` / `CoordEntry`.
//! - `TreeCoordinate` / `CoordEntry` now live in the shared
//! [`crate::proto::coord`] primitive and are re-exported here for continuity.
//! - `limits.rs` — the flap-dampening / hold-down state machine.
//! - `wire.rs` — `TreeAnnounce` + `validate_semantics` (the one std-tethered
//! file).
mod coordinate;
mod core;
mod declaration;
mod limits;
mod state;
mod wire;
@@ -27,64 +28,130 @@ mod wire;
#[cfg(test)]
mod tests;
use thiserror::Error;
use crate::{IdentityError, NodeAddr};
pub use coordinate::{CoordEntry, TreeCoordinate};
pub(crate) use core::{Stp, TreeDecision};
pub use state::{ParentDeclaration, TreeState};
pub use wire::TreeAnnounce;
pub(crate) use wire::{
pub use crate::proto::coord::{CoordEntry, CoordError, TreeCoordinate};
pub(crate) use crate::proto::coord::{
coords_wire_size, decode_coords, decode_optional_coords, encode_coords, encode_empty_coords,
};
pub(crate) use core::{ParentEval, Stp, TreeDecision};
pub use declaration::ParentDeclaration;
pub use state::TreeState;
pub use wire::TreeAnnounce;
/// Errors related to spanning tree operations.
#[derive(Debug, Error)]
#[derive(Debug)]
pub enum TreeError {
#[error("invalid tree coordinate: empty path")]
/// Coordinate path had zero entries.
EmptyCoordinate,
#[error("invalid ancestry: does not reach claimed root")]
/// Ancestry path does not reach the claimed root.
AncestryNotToRoot,
#[error("invalid ancestry: root declaration must contain only the sender")]
/// Root declaration contained hops other than the sender.
RootDeclarationMismatch,
#[error("invalid ancestry: non-root declaration must include a parent hop")]
/// Non-root declaration was missing its parent hop.
AncestryTooShort,
#[error("invalid ancestry: sender {declared} does not match first path entry {ancestry}")]
/// Declared sender does not match the first ancestry entry.
AncestryNodeMismatch {
/// The declared sender address.
declared: NodeAddr,
/// The first ancestry path entry.
ancestry: NodeAddr,
},
#[error(
"invalid ancestry: signed parent {declared} does not match first ancestry hop {ancestry}"
)]
/// Signed parent does not match the first ancestry hop.
AncestryParentMismatch {
/// The signed parent address.
declared: NodeAddr,
/// The first ancestry hop.
ancestry: NodeAddr,
},
#[error(
"invalid ancestry: advertised root {advertised} is not the minimum path entry {minimum}"
)]
/// Advertised root is not the minimum path entry.
AncestryRootNotMinimum {
/// The advertised root address.
advertised: NodeAddr,
/// The minimum path entry.
minimum: NodeAddr,
},
#[error("signature verification failed for node {0:?}")]
/// Signature verification failed for the given node.
InvalidSignature(NodeAddr),
#[error("sequence number regression: got {got}, expected > {expected}")]
SequenceRegression { got: u64, expected: u64 },
/// Sequence number regressed below the expected value.
SequenceRegression {
/// The received sequence number.
got: u64,
/// The value the sequence had to exceed.
expected: u64,
},
#[error("parent not in peers: {0:?}")]
/// Declared parent was not among the known peers.
ParentNotPeer(NodeAddr),
#[error("identity error: {0}")]
Identity(#[from] IdentityError),
/// An identity operation failed.
Identity(IdentityError),
}
impl ::core::fmt::Display for TreeError {
fn fmt(&self, f: &mut ::core::fmt::Formatter<'_>) -> ::core::fmt::Result {
match self {
TreeError::EmptyCoordinate => write!(f, "invalid tree coordinate: empty path"),
TreeError::AncestryNotToRoot => {
write!(f, "invalid ancestry: does not reach claimed root")
}
TreeError::RootDeclarationMismatch => write!(
f,
"invalid ancestry: root declaration must contain only the sender"
),
TreeError::AncestryTooShort => write!(
f,
"invalid ancestry: non-root declaration must include a parent hop"
),
TreeError::AncestryNodeMismatch { declared, ancestry } => write!(
f,
"invalid ancestry: sender {declared} does not match first path entry {ancestry}"
),
TreeError::AncestryParentMismatch { declared, ancestry } => write!(
f,
"invalid ancestry: signed parent {declared} does not match first ancestry hop {ancestry}"
),
TreeError::AncestryRootNotMinimum {
advertised,
minimum,
} => write!(
f,
"invalid ancestry: advertised root {advertised} is not the minimum path entry {minimum}"
),
TreeError::InvalidSignature(node) => {
write!(f, "signature verification failed for node {node:?}")
}
TreeError::SequenceRegression { got, expected } => {
write!(
f,
"sequence number regression: got {got}, expected > {expected}"
)
}
TreeError::ParentNotPeer(node) => write!(f, "parent not in peers: {node:?}"),
TreeError::Identity(e) => write!(f, "identity error: {e}"),
}
}
}
impl ::core::error::Error for TreeError {
fn source(&self) -> Option<&(dyn ::core::error::Error + 'static)> {
match self {
TreeError::Identity(e) => Some(e),
_ => None,
}
}
}
impl From<IdentityError> for TreeError {
fn from(e: IdentityError) -> Self {
TreeError::Identity(e)
}
}
+54 -175
View File
@@ -3,8 +3,9 @@
use alloc::collections::{BTreeMap, BTreeSet};
use core::fmt;
use super::core::ParentEval;
use super::limits::FlapDampener;
use super::{CoordEntry, TreeCoordinate};
use super::{CoordEntry, ParentDeclaration, TreeCoordinate};
use crate::NodeAddr;
/// Local spanning tree state for a node.
@@ -316,28 +317,38 @@ impl TreeState {
self.flap.is_flap_dampened(now_ms)
}
/// Whether a *discretionary* parent switch should currently be suppressed by the
/// flap-dampening / hold-down veto. `now_ms` is the injected monotonic time.
/// Mandatory switches ignore this. Read at the shell edge; the classify core is
/// clock-free and takes the resulting bool.
pub fn is_switch_suppressed(&self, now_ms: u64) -> bool {
self.flap.is_hold_down_active(now_ms) || self.flap.is_flap_dampened(now_ms)
}
/// Evaluate whether to switch parents based on current peer tree state.
///
/// Uses effective_depth (depth + link_cost) for parent comparison.
/// `peer_costs` maps each peer's NodeAddr to its link cost (from local
/// MMP measurements). Missing entries default to 1.0 (optimistic).
///
/// Returns `Some(peer_node_addr)` if a parent switch is recommended,
/// or `None` if the current parent is adequate.
/// Returns a [`ParentEval`] describing whether a parent switch is warranted:
/// `Mandatory` (path-breaking / root-correcting — always taken), `Discretionary`
/// (an improvement the caller applies only if its veto is inactive), or `None`.
///
/// This core is clock-free: it no longer applies the flap-dampening / hold-down
/// veto. The caller reads the clock, computes the veto verdict via
/// [`is_switch_suppressed`](Self::is_switch_suppressed), and suppresses the
/// `Discretionary` arm at the edge; `Mandatory` switches bypass the veto.
///
/// `skip_peers` contains peers that should not be considered as parent
/// candidates (e.g., non-routing and leaf nodes that don't forward transit).
///
/// `now_ms` is the injected monotonic time in milliseconds, used only for
/// the hold-down / flap-dampening suppression checks below.
pub fn evaluate_parent(
pub(crate) fn evaluate_parent(
&self,
peer_costs: &BTreeMap<NodeAddr, f64>,
skip_peers: &BTreeSet<NodeAddr>,
now_ms: u64,
) -> Option<NodeAddr> {
) -> ParentEval {
if self.peer_ancestry.is_empty() {
return None;
return ParentEval::None;
}
// Find the smallest root visible across all peers
@@ -356,7 +367,10 @@ impl TreeState {
});
}
let smallest_root = smallest_root?;
let smallest_root = match smallest_root {
Some(r) => r,
None => return ParentEval::None,
};
// If our own NodeAddr is smaller than (or equal to) the smallest visible
// root, we are the network's smallest node and must be root. Returning
@@ -366,7 +380,7 @@ impl TreeState {
// peer's larger root at the tail — violating "advertised root = min path
// entry" and getting rejected by recipients' `validate_semantics`.
if self.my_node_addr <= smallest_root {
return None;
return ParentEval::None;
}
// Among peers that reach the smallest root, find the lowest effective_depth.
@@ -404,14 +418,17 @@ impl TreeState {
}
}
let (best_peer_id, best_eff_depth) = best_peer?;
let (best_peer_id, best_eff_depth) = match best_peer {
Some(b) => b,
None => return ParentEval::None,
};
// If already using this peer as parent, no switch needed
if *self.my_declaration.parent_id() == best_peer_id && !self.is_root() {
return None;
return ParentEval::None;
}
// --- Mandatory switches (bypass hold-down and hysteresis) ---
// --- Mandatory switches (bypass the shell's hold-down / flap veto) ---
// If our current parent is gone from peer_ancestry, our path is broken — always switch
if !self.is_root()
@@ -419,32 +436,24 @@ impl TreeState {
.peer_ancestry
.contains_key(self.my_declaration.parent_id())
{
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// Switching roots (smaller root found) → always switch
if smallest_root < self.root || (self.is_root() && smallest_root < self.my_node_addr) {
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// We're root but shouldn't be (peers have a smaller root) — always switch
if self.is_root() {
return Some(best_peer_id);
return ParentEval::Mandatory(best_peer_id);
}
// --- Hold-down: suppress non-mandatory re-evaluation after recent switch ---
if self.flap.is_hold_down_active(now_ms) {
return None;
}
// --- Flap dampening: suppress after excessive parent switches ---
if self.flap.is_flap_dampened(now_ms) {
return None;
}
// --- Same root, cost-aware comparison with hysteresis ---
// --- Discretionary switches (the caller applies the veto before taking) ---
//
// Same root, cost-aware comparison with hysteresis. Everything below is
// veto-gated at the edge: the shell suppresses these `Discretionary` results
// while hold-down / flap-dampening is active.
// Current parent's effective_depth.
// If peer_costs is non-empty but current parent has no entry,
@@ -461,15 +470,17 @@ impl TreeState {
let current_parent_coords = self.peer_ancestry.get(self.my_declaration.parent_id());
let current_parent_eff = match current_parent_coords {
Some(coords) => coords.depth() as f64 + current_parent_cost,
None => return Some(best_peer_id), // Parent has no coords — treat as lost
// Parent has no coords — treat as lost. This sat BELOW the veto, so it
// is veto-gated: Discretionary, not Mandatory.
None => return ParentEval::Discretionary(best_peer_id),
};
// Apply hysteresis: only switch if candidate is significantly better
if best_eff_depth < current_parent_eff * (1.0 - self.parent_hysteresis) {
return Some(best_peer_id);
return ParentEval::Discretionary(best_peer_id);
}
None
ParentEval::None
}
/// Handle loss of current parent.
@@ -488,8 +499,15 @@ impl TreeState {
now_secs: u64,
now_ms: u64,
) -> bool {
// Try to find an alternative parent
if let Some(new_parent) = self.evaluate_parent(peer_costs, &BTreeSet::new(), now_ms) {
// Try to find an alternative parent. The veto is computed at the edge and
// applied only to a discretionary result; a mandatory switch bypasses it.
let suppressed = self.is_switch_suppressed(now_ms);
let alt = match self.evaluate_parent(peer_costs, &BTreeSet::new()) {
ParentEval::Mandatory(p) => Some(p),
ParentEval::Discretionary(p) if !suppressed => Some(p),
ParentEval::Discretionary(_) | ParentEval::None => None,
};
if let Some(new_parent) = alt {
let new_seq = self.my_declaration.sequence() + 1;
self.set_parent(new_parent, new_seq, now_secs, now_ms);
self.recompute_coords();
@@ -529,142 +547,3 @@ impl fmt::Debug for TreeState {
.finish()
}
}
/// A node's declaration of its parent in the spanning tree.
///
/// Each node periodically announces its parent selection. The declaration
/// includes a monotonic sequence number for freshness and a signature
/// for authenticity. When `parent_id == node_addr`, the node declares itself
/// as a root candidate.
#[derive(Clone)]
pub struct ParentDeclaration {
/// The node making this declaration.
node_addr: NodeAddr,
/// The selected parent (equals node_addr if self-declaring as root).
parent_id: NodeAddr,
/// Monotonically increasing sequence number.
sequence: u64,
/// Timestamp when this declaration was created (Unix seconds).
timestamp: u64,
/// Raw 64-byte Schnorr signature over the declaration fields. Stored as
/// opaque bytes so the in-core type carries no signature-crypto dependency;
/// the shell computes/verifies it over `signing_bytes()` (§6).
signature: Option<[u8; 64]>,
}
impl ParentDeclaration {
/// Create a new unsigned parent declaration.
///
/// The declaration must be signed before transmission using `set_signature()`.
pub fn new(node_addr: NodeAddr, parent_id: NodeAddr, sequence: u64, timestamp: u64) -> Self {
Self {
node_addr,
parent_id,
sequence,
timestamp,
signature: None,
}
}
/// Create a self-declaration (node is root candidate).
pub fn self_root(node_addr: NodeAddr, sequence: u64, timestamp: u64) -> Self {
Self::new(node_addr, node_addr, sequence, timestamp)
}
/// Create a declaration with a pre-computed signature.
pub fn with_signature(
node_addr: NodeAddr,
parent_id: NodeAddr,
sequence: u64,
timestamp: u64,
signature: [u8; 64],
) -> Self {
Self {
node_addr,
parent_id,
sequence,
timestamp,
signature: Some(signature),
}
}
/// Get the declaring node's ID.
pub fn node_addr(&self) -> &NodeAddr {
&self.node_addr
}
/// Get the parent node's ID.
pub fn parent_id(&self) -> &NodeAddr {
&self.parent_id
}
/// Get the sequence number.
pub fn sequence(&self) -> u64 {
self.sequence
}
/// Get the timestamp.
pub fn timestamp(&self) -> u64 {
self.timestamp
}
/// Get the raw 64-byte signature, if set.
pub fn signature(&self) -> Option<&[u8; 64]> {
self.signature.as_ref()
}
/// Set the raw 64-byte signature after signing.
pub fn set_signature(&mut self, signature: [u8; 64]) {
self.signature = Some(signature);
}
/// Check if this is a root declaration (parent == self).
pub fn is_root(&self) -> bool {
self.node_addr == self.parent_id
}
/// Check if this declaration is signed.
pub fn is_signed(&self) -> bool {
self.signature.is_some()
}
/// Get the bytes that should be signed.
///
/// Format: node_addr (16) || parent_id (16) || sequence (8) || timestamp (8)
pub fn signing_bytes(&self) -> Vec<u8> {
let mut bytes = Vec::with_capacity(48);
bytes.extend_from_slice(self.node_addr.as_bytes());
bytes.extend_from_slice(self.parent_id.as_bytes());
bytes.extend_from_slice(&self.sequence.to_le_bytes());
bytes.extend_from_slice(&self.timestamp.to_le_bytes());
bytes
}
/// Check if this declaration is fresher than another.
pub fn is_fresher_than(&self, other: &ParentDeclaration) -> bool {
self.sequence > other.sequence
}
}
impl fmt::Debug for ParentDeclaration {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ParentDeclaration")
.field("node_addr", &self.node_addr)
.field("parent_id", &self.parent_id)
.field("sequence", &self.sequence)
.field("is_root", &self.is_root())
.field("signed", &self.is_signed())
.finish()
}
}
impl PartialEq for ParentDeclaration {
fn eq(&self, other: &Self) -> bool {
self.node_addr == other.node_addr
&& self.parent_id == other.parent_id
&& self.sequence == other.sequence
&& self.timestamp == other.timestamp
}
}
impl Eq for ParentDeclaration {}
+2 -2
View File
@@ -1,7 +1,7 @@
//! TreeCoordinate unit tests.
use super::util::{make_coords, make_node_addr};
use crate::proto::stp::{CoordEntry, TreeCoordinate, TreeError};
use crate::proto::stp::{CoordEntry, CoordError, TreeCoordinate};
#[test]
fn test_tree_coordinate_root() {
@@ -33,7 +33,7 @@ fn test_tree_coordinate_path() {
#[test]
fn test_tree_coordinate_empty_fails() {
let result = TreeCoordinate::from_addrs(vec![]);
assert!(matches!(result, Err(TreeError::EmptyCoordinate)));
assert!(matches!(result, Err(CoordError::EmptyCoordinate)));
}
#[test]
+30 -12
View File
@@ -1,9 +1,9 @@
//! Flap dampening / hold-down unit tests.
use std::collections::{BTreeMap, BTreeSet};
use alloc::collections::{BTreeMap, BTreeSet};
use super::util::{make_coords, make_costs, make_node_addr};
use crate::proto::stp::{ParentDeclaration, TreeState};
use crate::proto::stp::{ParentDeclaration, ParentEval, TreeState};
#[test]
fn test_flap_dampening_engages_after_threshold() {
@@ -43,11 +43,16 @@ fn test_flap_dampening_engages_after_threshold() {
assert!(dampened);
assert!(state.is_flap_dampened(3000));
// evaluate_parent should return None for non-mandatory switches
// Make peer_b much better than peer_a
// The flap-dampening veto now lives at the shell edge, not inside
// evaluate_parent. The clock-free core still finds peer_b as a discretionary
// candidate (peer_b much better than peer_a); the shell suppresses it because
// dampening is active — same net "no switch" as before.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert_eq!(result, None); // suppressed by flap dampening
assert!(state.is_switch_suppressed(3000)); // veto active at the edge
assert!(matches!(
state.evaluate_parent(&costs, &BTreeSet::new()),
ParentEval::Discretionary(p) if p == peer_b
));
}
#[test]
@@ -82,8 +87,13 @@ fn test_flap_dampening_allows_mandatory_switches() {
// Remove current parent (peer_a) — this is a mandatory switch
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 3000);
assert_eq!(result, Some(peer_b)); // mandatory switch bypasses dampening
// Dampening is still active at the edge, but a parent-lost switch is mandatory
// and bypasses the veto: the core returns Mandatory and the switch is taken.
assert!(state.is_switch_suppressed(3000)); // veto still active
assert!(matches!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new()),
ParentEval::Mandatory(p) if p == peer_b
));
}
#[test]
@@ -119,9 +129,13 @@ fn test_flap_dampening_expires() {
// With 0-second duration, dampening should have already expired
assert!(!state.is_flap_dampened(3000));
// evaluate_parent should work normally now
// Dampening has expired: the veto is no longer active at the edge, so the
// discretionary switch to peer_b resumes and is taken.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert!(!state.is_switch_suppressed(3000)); // veto cleared
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b)); // not suppressed
}
@@ -156,9 +170,13 @@ fn test_flap_dampening_below_threshold() {
assert!(!state.is_flap_dampened(3000));
// evaluate_parent should still work normally
// Dampening never engaged, so the veto is inactive at the edge and the
// discretionary switch to peer_b is taken normally.
let costs = make_costs(&[(1, 10.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 3000);
assert!(!state.is_switch_suppressed(3000)); // veto never engaged
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b)); // not suppressed
}
+1
View File
@@ -4,3 +4,4 @@ mod coordinate;
mod limits;
mod state;
mod util;
mod wire;
+98 -32
View File
@@ -1,9 +1,9 @@
//! ParentDeclaration, TreeState, parent-selection, and find_next_hop unit tests.
use std::collections::{BTreeMap, BTreeSet};
use alloc::collections::{BTreeMap, BTreeSet};
use super::util::{add_peer, make_coords, make_costs, make_node_addr, make_tree_state};
use crate::proto::stp::{ParentDeclaration, TreeCoordinate, TreeState};
use crate::proto::stp::{ParentDeclaration, ParentEval, TreeCoordinate, TreeState};
// ===== ParentDeclaration Tests =====
@@ -214,7 +214,9 @@ fn test_evaluate_parent_picks_smallest_root() {
make_coords(&[7, 2]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer3));
}
@@ -240,7 +242,9 @@ fn test_evaluate_parent_prefers_shallowest_depth() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1));
}
@@ -258,7 +262,9 @@ fn test_evaluate_parent_stays_root_when_smallest() {
);
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -283,7 +289,9 @@ fn test_evaluate_parent_no_switch_when_already_best() {
// Now evaluate — should return None since peer1 is already our parent
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -294,7 +302,9 @@ fn test_evaluate_parent_no_peers() {
let state = TreeState::new(my_node, 1000);
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -329,7 +339,9 @@ fn test_evaluate_parent_depth_threshold() {
make_coords(&[3, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer3));
}
@@ -351,7 +363,9 @@ fn test_evaluate_parent_rejects_loop_candidate() {
// Should return None — the only candidate creates a loop
assert_eq!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000),
state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target(),
None
);
}
@@ -378,7 +392,9 @@ fn test_evaluate_parent_picks_loop_free_over_loopy() {
make_coords(&[2, 3, 4, 0]),
);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer2));
}
@@ -685,7 +701,9 @@ fn test_effective_depth_selects_lower_cost_deeper_peer() {
);
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -711,7 +729,9 @@ fn test_effective_depth_equal_cost_degenerates_to_depth() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1));
}
@@ -736,7 +756,9 @@ fn test_effective_depth_tiebreak_by_node_addr() {
);
let costs = make_costs(&[(1, 1.0), (2, 1.0)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1)); // smaller NodeAddr
}
@@ -769,7 +791,9 @@ fn test_hysteresis_prevents_marginal_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 2.5), (2, 2.2)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None); // marginal improvement blocked by hysteresis
}
@@ -802,7 +826,9 @@ fn test_hysteresis_allows_significant_switch() {
state.recompute_coords();
let costs = make_costs(&[(1, 6.0), (2, 1.01)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -828,7 +854,9 @@ fn test_cold_start_default_cost() {
);
// Empty cost map — all peers get default 1.0
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer1)); // shallowest wins
}
@@ -859,8 +887,14 @@ fn test_hold_down_suppresses_reeval() {
// Peer_b now offers better cost, but hold-down suppresses
let costs = make_costs(&[(1, 5.0), (2, 1.0)]);
state.set_parent_hysteresis(0.0); // no hysteresis, only hold-down
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
assert_eq!(result, None); // suppressed by hold-down
// The hold-down veto now lives at the shell edge, not inside evaluate_parent.
// The clock-free core still finds peer_b as a discretionary candidate; the shell
// suppresses it because hold-down is active — same net "no switch" as before.
assert!(state.is_switch_suppressed(1000)); // hold-down active at the edge
assert!(matches!(
state.evaluate_parent(&costs, &BTreeSet::new()),
ParentEval::Discretionary(p) if p == peer_b
));
}
#[test]
@@ -889,8 +923,13 @@ fn test_mandatory_switch_bypasses_hold_down() {
// Remove peer_a (parent lost) — should bypass hold-down
state.remove_peer(&peer_a);
let result = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
assert_eq!(result, Some(peer_b)); // mandatory switch
// Hold-down is active at the edge, but a parent-lost switch is mandatory and
// bypasses the veto: the core returns Mandatory and the switch is taken anyway.
assert!(state.is_switch_suppressed(1000)); // hold-down active
assert!(matches!(
state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new()),
ParentEval::Mandatory(p) if p == peer_b
));
}
#[test]
@@ -931,12 +970,16 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
);
// Without costs (all 1.0): picks peer 1 (smaller addr) — correct by luck
let result_no_cost = state.evaluate_parent(&BTreeMap::new(), &BTreeSet::new(), 1000);
let result_no_cost = state
.evaluate_parent(&BTreeMap::new(), &BTreeSet::new())
.switch_target();
assert_eq!(result_no_cost, Some(peer1));
// With costs: fiber (1.01) vs LoRa (6.0) — fiber wins definitively
let costs = make_costs(&[(1, 1.01), (2, 6.0)]);
let result_with_cost = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_with_cost = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_with_cost, Some(peer1));
// Now test the critical case: node 5 currently has LoRa parent (peer 2).
@@ -945,14 +988,18 @@ fn test_heterogeneous_7node_avoids_bottleneck() {
state.recompute_coords();
assert_eq!(state.my_coords().depth(), 2); // depth 2 through LoRa peer
let result_switch = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_switch = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_switch, Some(peer1)); // switches away from LoRa bottleneck
// With hysteresis enabled, still switches because the cost difference is large
state.set_parent_hysteresis(0.2);
// current_parent_eff = 1 + 6.0 = 7.0, best_eff = 1 + 1.01 = 2.01
// threshold = 7.0 * 0.8 = 5.6, 2.01 < 5.6 → switch
let result_hyst = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result_hyst = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result_hyst, Some(peer1));
}
@@ -988,14 +1035,18 @@ fn test_cost_degradation_triggers_switch() {
// Initial: both fiber-like costs. Node picks peer_a (smaller addr).
let initial_costs = make_costs(&[(1, 1.05), (2, 1.08)]);
let result = state.evaluate_parent(&initial_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&initial_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000, 1000);
state.recompute_coords();
// Verify stable: no switch with same costs
let result = state.evaluate_parent(&initial_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&initial_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
// Peer A's link degrades significantly (LoRa-like latency + loss)
@@ -1003,7 +1054,9 @@ fn test_cost_degradation_triggers_switch() {
// best_eff = 1 + 1.08 = 2.08
// threshold = 7.0 * 0.8 = 5.6, 2.08 < 5.6 → switch
let degraded_costs = make_costs(&[(1, 6.0), (2, 1.08)]);
let result = state.evaluate_parent(&degraded_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&degraded_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_b));
}
@@ -1036,7 +1089,9 @@ fn test_cost_improvement_within_hysteresis_no_switch() {
// best_eff = 1 + 1.5 = 2.5
// threshold = 3.0 * 0.8 = 2.4, 2.5 > 2.4 → no switch
let costs = make_costs(&[(1, 2.0), (2, 1.5)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
}
@@ -1058,7 +1113,9 @@ fn test_single_peer_no_reeval_benefit() {
// Initial selection: picks the only peer
let costs = make_costs(&[(1, 1.05)]);
let result = state.evaluate_parent(&costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, Some(peer_a));
state.set_parent(peer_a, 1, 1000, 1000);
@@ -1066,7 +1123,9 @@ fn test_single_peer_no_reeval_benefit() {
// Even with terrible cost, no switch (no alternative)
let bad_costs = make_costs(&[(1, 50.0)]);
let result = state.evaluate_parent(&bad_costs, &BTreeSet::new(), 1000);
let result = state
.evaluate_parent(&bad_costs, &BTreeSet::new())
.switch_target();
assert_eq!(result, None);
}
@@ -1096,7 +1155,9 @@ fn test_evaluate_parent_skips_non_full_peer() {
let mut skip = BTreeSet::new();
skip.insert(peer3);
let result = state.evaluate_parent(&BTreeMap::new(), &skip, 1000);
let result = state
.evaluate_parent(&BTreeMap::new(), &skip)
.switch_target();
assert_eq!(result, Some(peer7));
}
@@ -1115,7 +1176,12 @@ fn test_evaluate_parent_all_skipped_returns_none() {
let mut skip = BTreeSet::new();
skip.insert(peer3);
assert_eq!(state.evaluate_parent(&BTreeMap::new(), &skip, 1000), None);
assert_eq!(
state
.evaluate_parent(&BTreeMap::new(), &skip)
.switch_target(),
None
);
}
#[test]
+1 -1
View File
@@ -1,6 +1,6 @@
//! Shared test helpers for the STP primitive unit tests.
use std::collections::BTreeMap;
use alloc::collections::BTreeMap;
use crate::NodeAddr;
use crate::proto::stp::{ParentDeclaration, TreeCoordinate, TreeState};
+336
View File
@@ -0,0 +1,336 @@
//! TreeAnnounce wire codec unit tests.
use super::util::{make_coords, make_node_addr};
use crate::identity::Identity;
use crate::proto::Error;
use crate::proto::stp::wire::TreeAnnounce;
use crate::proto::stp::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError};
/// Sign a declaration in place. In production the shell owns the key-crypto
/// (§6); this test-local helper keeps the sign/verify boundary out of the
/// in-core `state.rs` while letting the codec tests build signed messages.
fn sign_decl(decl: &mut ParentDeclaration, identity: &Identity) {
let sig = identity.sign(&decl.signing_bytes());
decl.set_signature(sig.to_byte_array());
}
#[test]
fn test_tree_announce() {
let node = make_node_addr(1);
let parent = make_node_addr(2);
let decl = ParentDeclaration::new(node, parent, 1, 1000);
let ancestry = make_coords(&[1, 2, 0]);
let announce = TreeAnnounce::new(decl, ancestry);
assert_eq!(announce.declaration.node_addr(), &node);
assert_eq!(announce.ancestry.depth(), 2);
}
#[test]
fn test_tree_announce_encode_decode_root() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
// Root declaration: parent == self
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 5000);
sign_decl(&mut decl, &identity);
// Root ancestry: just the root itself
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 5000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let encoded = announce.encode().unwrap();
// msg_type (1) + version (1) + seq (8) + ts (8) + parent (16) + count (2) + 1 entry (32) + sig (64) = 132
assert_eq!(encoded.len(), 132);
assert_eq!(encoded[0], 0x10); // LinkMessageType::TreeAnnounce
// Decode strips msg_type byte (as dispatcher does)
let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.declaration.node_addr(), &node_addr);
assert_eq!(decoded.declaration.parent_id(), &node_addr);
assert_eq!(decoded.declaration.sequence(), 1);
assert_eq!(decoded.declaration.timestamp(), 5000);
assert!(decoded.declaration.is_root());
assert!(decoded.declaration.is_signed());
assert_eq!(decoded.ancestry.depth(), 0); // root has depth 0
assert_eq!(decoded.ancestry.entries().len(), 1);
assert_eq!(decoded.ancestry.entries()[0].node_addr, node_addr);
assert_eq!(decoded.ancestry.entries()[0].sequence, 1);
assert_eq!(decoded.ancestry.entries()[0].timestamp, 5000);
}
#[test]
fn test_tree_announce_encode_decode_depth3() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let parent = make_node_addr(2);
let grandparent = make_node_addr(3);
let root = make_node_addr(4);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 10000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 10000),
CoordEntry::new(parent, 4, 9000),
CoordEntry::new(grandparent, 3, 8000),
CoordEntry::new(root, 2, 7000),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let encoded = announce.encode().unwrap();
// 1 + 99 + 4*32 = 228
assert_eq!(encoded.len(), 228);
let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.declaration.node_addr(), &node_addr);
assert_eq!(decoded.declaration.parent_id(), &parent);
assert_eq!(decoded.declaration.sequence(), 5);
assert_eq!(decoded.declaration.timestamp(), 10000);
assert!(!decoded.declaration.is_root());
assert_eq!(decoded.ancestry.depth(), 3);
assert_eq!(decoded.ancestry.entries().len(), 4);
// Verify all entries preserved
let entries = decoded.ancestry.entries();
assert_eq!(entries[0].node_addr, node_addr);
assert_eq!(entries[0].sequence, 5);
assert_eq!(entries[1].node_addr, parent);
assert_eq!(entries[1].sequence, 4);
assert_eq!(entries[2].node_addr, grandparent);
assert_eq!(entries[2].timestamp, 8000);
assert_eq!(entries[3].node_addr, root);
assert_eq!(entries[3].timestamp, 7000);
// Root ID is last entry
assert_eq!(decoded.ancestry.root_id(), &root);
}
#[test]
fn test_tree_announce_decode_unsupported_version() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let mut encoded = announce.encode().unwrap();
// Corrupt version byte (byte index 1, after msg_type)
encoded[1] = 0xFF;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(Error::UnsupportedVersion(0xFF))));
}
#[test]
fn test_tree_announce_decode_truncated() {
// Way too short
let result = TreeAnnounce::decode(&[0x01]);
assert!(matches!(
result,
Err(Error::MessageTooShort { expected: 99, .. })
));
// Just under minimum (98 bytes)
let short = vec![0u8; 98];
let result = TreeAnnounce::decode(&short);
assert!(matches!(
result,
Err(Error::MessageTooShort { expected: 99, .. })
));
}
#[test]
fn test_tree_announce_decode_ancestry_count_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let mut encoded = announce.encode().unwrap();
// The ancestry_count is at offset: 1 (msg_type) + 1 (version) + 8 (seq) + 8 (ts) + 16 (parent) = 34
// Set ancestry_count to 5 but we only have 1 entry's worth of data
encoded[34] = 5;
encoded[35] = 0;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(Error::MessageTooShort { .. })));
}
#[test]
fn test_tree_announce_encode_unsigned_fails() {
let node = make_node_addr(1);
let decl = ParentDeclaration::new(node, node, 1, 1000);
let ancestry = make_coords(&[1, 0]);
let announce = TreeAnnounce::new(decl, ancestry);
let result = announce.encode();
assert!(matches!(result, Err(Error::InvalidSignature)));
}
/// Tests that a well-formed non-root ancestry is accepted.
#[test]
fn test_tree_announce_validate_semantics_accepts_valid_non_root() {
use crate::identity::Identity;
// Regenerate until the random identity's node_addr is numerically
// larger than both fixed parent (02:..) and root (01:..), so the
// root-minimum invariant holds deterministically.
let identity = loop {
let id = Identity::generate();
if id.node_addr().as_bytes()[0] > 1 {
break id;
}
};
let node_addr = *identity.node_addr();
let parent = make_node_addr(2);
let root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(announce.validate_semantics().is_ok());
}
/// Tests that an ancestry is rejected if the final node_addr is not the smallest entry in the path.
#[test]
fn test_tree_announce_validate_semantics_rejects_non_minimal_root() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let smaller = make_node_addr(0);
let advertised_root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, smaller, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(smaller, 4, 900),
CoordEntry::new(advertised_root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryRootNotMinimum {
advertised,
minimum,
}) if advertised == advertised_root && minimum == smaller
));
}
/// Tests that an ancestry is rejected if the first ancestry hop does not match the signed parent_id.
#[test]
fn test_tree_announce_validate_semantics_rejects_parent_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let declared_parent = make_node_addr(2);
let ancestry_parent = make_node_addr(3);
let mut decl = ParentDeclaration::new(node_addr, declared_parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(ancestry_parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryParentMismatch {
declared,
ancestry,
}) if declared == declared_parent && ancestry == ancestry_parent
));
}
/// Tests that an ancestry is rejected if the first path entry does not match the signed sender node_addr.
#[test]
fn test_tree_announce_validate_semantics_rejects_sender_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let ancestry_sender = make_node_addr(9);
let parent = make_node_addr(2);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(ancestry_sender, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryNodeMismatch {
declared,
ancestry,
}) if declared == node_addr && ancestry == ancestry_sender
));
}
/// Tests that a self-root declaration is rejected if its ancestry contains extra ancestors.
#[test]
fn test_tree_announce_validate_semantics_rejects_root_with_ancestors() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::self_root(node_addr, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(make_node_addr(0), 4, 900),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::RootDeclarationMismatch)
));
}
+27 -515
View File
@@ -3,6 +3,7 @@
use super::{CoordEntry, ParentDeclaration, TreeCoordinate, TreeError};
use crate::NodeAddr;
use crate::proto::Error;
use crate::proto::codec::{Reader, Writer};
use crate::proto::link::LinkMessageType;
use secp256k1::schnorr::Signature;
@@ -103,121 +104,72 @@ impl TreeAnnounce {
let entries = self.ancestry.entries();
let ancestry_count = entries.len() as u16;
let size = 1 + Self::MIN_PAYLOAD_SIZE + entries.len() * CoordEntry::WIRE_SIZE;
let mut buf = Vec::with_capacity(size);
let mut w = Writer::with_capacity(size);
// msg_type
buf.push(LinkMessageType::TreeAnnounce.to_byte());
w.write_u8(LinkMessageType::TreeAnnounce.to_byte());
// version
buf.push(Self::VERSION_1);
w.write_u8(Self::VERSION_1);
// sequence (8 LE)
buf.extend_from_slice(&self.declaration.sequence().to_le_bytes());
w.write_u64_le(self.declaration.sequence());
// timestamp (8 LE)
buf.extend_from_slice(&self.declaration.timestamp().to_le_bytes());
w.write_u64_le(self.declaration.timestamp());
// parent (16)
buf.extend_from_slice(self.declaration.parent_id().as_bytes());
w.write_bytes(self.declaration.parent_id().as_bytes());
// ancestry_count (2 LE)
buf.extend_from_slice(&ancestry_count.to_le_bytes());
w.write_u16_le(ancestry_count);
// ancestry entries (32 bytes each)
for entry in entries {
buf.extend_from_slice(entry.node_addr.as_bytes()); // 16
buf.extend_from_slice(&entry.sequence.to_le_bytes()); // 8
buf.extend_from_slice(&entry.timestamp.to_le_bytes()); // 8
w.write_bytes(entry.node_addr.as_bytes()); // 16
w.write_u64_le(entry.sequence); // 8
w.write_u64_le(entry.timestamp); // 8
}
// outer signature (64)
buf.extend_from_slice(signature.as_ref());
w.write_bytes(signature.as_ref());
Ok(buf)
Ok(w.into_vec())
}
/// Decode from link-layer payload (after msg_type byte stripped by dispatcher).
///
/// The payload starts with the version byte.
pub fn decode(payload: &[u8]) -> Result<Self, Error> {
if payload.len() < Self::MIN_PAYLOAD_SIZE {
return Err(Error::MessageTooShort {
expected: Self::MIN_PAYLOAD_SIZE,
got: payload.len(),
});
}
let mut pos = 0;
let mut reader = Reader::new(payload);
reader.require(Self::MIN_PAYLOAD_SIZE)?;
// version
let version = payload[pos];
pos += 1;
let version = reader.read_u8()?;
if version != Self::VERSION_1 {
return Err(Error::UnsupportedVersion(version));
}
// sequence (8 LE)
let sequence = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad sequence".into()))?,
);
pos += 8;
let sequence = reader.read_u64_le()?;
// timestamp (8 LE)
let timestamp = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad timestamp".into()))?,
);
pos += 8;
let timestamp = reader.read_u64_le()?;
// parent (16)
let parent = NodeAddr::from_bytes(
payload[pos..pos + 16]
.try_into()
.map_err(|_| Error::Malformed("bad parent".into()))?,
);
pos += 16;
let parent = NodeAddr::from_bytes(reader.read_array::<16>()?);
// ancestry_count (2 LE)
let ancestry_count = u16::from_le_bytes(
payload[pos..pos + 2]
.try_into()
.map_err(|_| Error::Malformed("bad ancestry count".into()))?,
) as usize;
pos += 2;
let ancestry_count = reader.read_u16_le()? as usize;
// Validate remaining length: entries + signature
let expected_remaining = ancestry_count * CoordEntry::WIRE_SIZE + 64;
if payload.len() - pos < expected_remaining {
return Err(Error::MessageTooShort {
expected: pos + expected_remaining,
got: payload.len(),
});
}
reader.require(expected_remaining)?;
// ancestry entries (32 bytes each)
let mut entries = Vec::with_capacity(ancestry_count);
for _ in 0..ancestry_count {
let node_addr = NodeAddr::from_bytes(
payload[pos..pos + 16]
.try_into()
.map_err(|_| Error::Malformed("bad entry node_addr".into()))?,
);
pos += 16;
let entry_seq = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad entry sequence".into()))?,
);
pos += 8;
let entry_ts = u64::from_le_bytes(
payload[pos..pos + 8]
.try_into()
.map_err(|_| Error::Malformed("bad entry timestamp".into()))?,
);
pos += 8;
let node_addr = NodeAddr::from_bytes(reader.read_array::<16>()?);
let entry_seq = reader.read_u64_le()?;
let entry_ts = reader.read_u64_le()?;
entries.push(CoordEntry::new(node_addr, entry_seq, entry_ts));
}
// signature (64)
let sig_bytes: [u8; 64] = payload[pos..pos + 64]
.try_into()
.map_err(|_| Error::Malformed("bad signature".into()))?;
let sig_bytes: [u8; 64] = reader.read_array::<64>()?;
// Validate the signature parses as a well-formed schnorr signature (the
// codec's only crypto touch, §11 w2); store the raw bytes so the in-core
// declaration carries no `secp256k1` dependency. Actual verification is a
@@ -226,17 +178,14 @@ impl TreeAnnounce {
// The first entry's node_addr is the declaring node
if entries.is_empty() {
return Err(Error::Malformed(
"ancestry must have at least one entry".into(),
));
return Err(Error::Malformed("ancestry must have at least one entry"));
}
let node_addr = entries[0].node_addr;
let declaration =
ParentDeclaration::with_signature(node_addr, parent, sequence, timestamp, sig_bytes);
let ancestry = TreeCoordinate::new(entries)
.map_err(|e| Error::Malformed(format!("bad ancestry: {}", e)))?;
let ancestry = TreeCoordinate::new(entries).map_err(Error::BadCoord)?;
Ok(Self {
declaration,
@@ -244,440 +193,3 @@ impl TreeAnnounce {
})
}
}
// ============================================================================
// Coordinate Wire Format Helpers
// ============================================================================
/// Wire size of a TreeCoordinate in address-only format: 2 + entries × 16.
pub(crate) fn coords_wire_size(coords: &TreeCoordinate) -> usize {
2 + coords.entries().len() * 16
}
/// Encode a TreeCoordinate as address-only wire format: count(u16 LE) + addrs(16 × n).
///
/// Session-layer messages serialize coordinates as NodeAddr arrays (16 bytes each),
/// without the sequence/timestamp metadata used by the tree gossip protocol.
pub(crate) fn encode_coords(coords: &TreeCoordinate, buf: &mut Vec<u8>) {
let addrs: Vec<&NodeAddr> = coords.node_addrs().collect();
let count = addrs.len() as u16;
buf.extend_from_slice(&count.to_le_bytes());
for addr in addrs {
buf.extend_from_slice(addr.as_bytes());
}
}
/// Decode a TreeCoordinate from address-only wire format.
///
/// Returns the decoded coordinate and the number of bytes consumed.
pub(crate) fn decode_coords(data: &[u8]) -> Result<(TreeCoordinate, usize), Error> {
if data.len() < 2 {
return Err(Error::MessageTooShort {
expected: 2,
got: data.len(),
});
}
let count = u16::from_le_bytes([data[0], data[1]]) as usize;
let needed = 2 + count * 16;
if data.len() < needed {
return Err(Error::MessageTooShort {
expected: needed,
got: data.len(),
});
}
if count == 0 {
return Err(Error::Malformed("coordinate with zero entries".into()));
}
let mut addrs = Vec::with_capacity(count);
for i in 0..count {
let offset = 2 + i * 16;
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs).map_err(|e| Error::Malformed(e.to_string()))?;
Ok((coord, needed))
}
/// Decode an optional coordinate field (count may be 0).
///
/// Returns None if count is 0, Some(coord) otherwise, plus bytes consumed.
pub(crate) fn decode_optional_coords(
data: &[u8],
) -> Result<(Option<TreeCoordinate>, usize), Error> {
if data.len() < 2 {
return Err(Error::MessageTooShort {
expected: 2,
got: data.len(),
});
}
let count = u16::from_le_bytes([data[0], data[1]]) as usize;
let needed = 2 + count * 16;
if data.len() < needed {
return Err(Error::MessageTooShort {
expected: needed,
got: data.len(),
});
}
if count == 0 {
return Ok((None, 2));
}
let mut addrs = Vec::with_capacity(count);
for i in 0..count {
let offset = 2 + i * 16;
let mut bytes = [0u8; 16];
bytes.copy_from_slice(&data[offset..offset + 16]);
addrs.push(NodeAddr::from_bytes(bytes));
}
let coord = TreeCoordinate::from_addrs(addrs).map_err(|e| Error::Malformed(e.to_string()))?;
Ok((Some(coord), needed))
}
/// Encode a count of zero (for empty/absent coordinate fields).
pub(crate) fn encode_empty_coords(buf: &mut Vec<u8>) {
buf.extend_from_slice(&0u16.to_le_bytes());
}
#[cfg(test)]
mod tests {
use super::*;
use crate::identity::Identity;
fn make_node_addr(val: u8) -> NodeAddr {
let mut bytes = [0u8; 16];
bytes[0] = val;
NodeAddr::from_bytes(bytes)
}
/// Sign a declaration in place. In production the shell owns the key-crypto
/// (§6); this test-local helper keeps the sign/verify boundary out of the
/// in-core `state.rs` while letting the codec tests build signed messages.
fn sign_decl(decl: &mut ParentDeclaration, identity: &Identity) {
let sig = identity.sign(&decl.signing_bytes());
decl.set_signature(sig.to_byte_array());
}
fn make_coords(ids: &[u8]) -> TreeCoordinate {
TreeCoordinate::from_addrs(ids.iter().map(|&v| make_node_addr(v)).collect()).unwrap()
}
#[test]
fn test_tree_announce() {
let node = make_node_addr(1);
let parent = make_node_addr(2);
let decl = ParentDeclaration::new(node, parent, 1, 1000);
let ancestry = make_coords(&[1, 2, 0]);
let announce = TreeAnnounce::new(decl, ancestry);
assert_eq!(announce.declaration.node_addr(), &node);
assert_eq!(announce.ancestry.depth(), 2);
}
#[test]
fn test_tree_announce_encode_decode_root() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
// Root declaration: parent == self
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 5000);
sign_decl(&mut decl, &identity);
// Root ancestry: just the root itself
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 5000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let encoded = announce.encode().unwrap();
// msg_type (1) + version (1) + seq (8) + ts (8) + parent (16) + count (2) + 1 entry (32) + sig (64) = 132
assert_eq!(encoded.len(), 132);
assert_eq!(encoded[0], 0x10); // LinkMessageType::TreeAnnounce
// Decode strips msg_type byte (as dispatcher does)
let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.declaration.node_addr(), &node_addr);
assert_eq!(decoded.declaration.parent_id(), &node_addr);
assert_eq!(decoded.declaration.sequence(), 1);
assert_eq!(decoded.declaration.timestamp(), 5000);
assert!(decoded.declaration.is_root());
assert!(decoded.declaration.is_signed());
assert_eq!(decoded.ancestry.depth(), 0); // root has depth 0
assert_eq!(decoded.ancestry.entries().len(), 1);
assert_eq!(decoded.ancestry.entries()[0].node_addr, node_addr);
assert_eq!(decoded.ancestry.entries()[0].sequence, 1);
assert_eq!(decoded.ancestry.entries()[0].timestamp, 5000);
}
#[test]
fn test_tree_announce_encode_decode_depth3() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let parent = make_node_addr(2);
let grandparent = make_node_addr(3);
let root = make_node_addr(4);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 10000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 10000),
CoordEntry::new(parent, 4, 9000),
CoordEntry::new(grandparent, 3, 8000),
CoordEntry::new(root, 2, 7000),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let encoded = announce.encode().unwrap();
// 1 + 99 + 4*32 = 228
assert_eq!(encoded.len(), 228);
let decoded = TreeAnnounce::decode(&encoded[1..]).unwrap();
assert_eq!(decoded.declaration.node_addr(), &node_addr);
assert_eq!(decoded.declaration.parent_id(), &parent);
assert_eq!(decoded.declaration.sequence(), 5);
assert_eq!(decoded.declaration.timestamp(), 10000);
assert!(!decoded.declaration.is_root());
assert_eq!(decoded.ancestry.depth(), 3);
assert_eq!(decoded.ancestry.entries().len(), 4);
// Verify all entries preserved
let entries = decoded.ancestry.entries();
assert_eq!(entries[0].node_addr, node_addr);
assert_eq!(entries[0].sequence, 5);
assert_eq!(entries[1].node_addr, parent);
assert_eq!(entries[1].sequence, 4);
assert_eq!(entries[2].node_addr, grandparent);
assert_eq!(entries[2].timestamp, 8000);
assert_eq!(entries[3].node_addr, root);
assert_eq!(entries[3].timestamp, 7000);
// Root ID is last entry
assert_eq!(decoded.ancestry.root_id(), &root);
}
#[test]
fn test_tree_announce_decode_unsupported_version() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let mut encoded = announce.encode().unwrap();
// Corrupt version byte (byte index 1, after msg_type)
encoded[1] = 0xFF;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(Error::UnsupportedVersion(0xFF))));
}
#[test]
fn test_tree_announce_decode_truncated() {
// Way too short
let result = TreeAnnounce::decode(&[0x01]);
assert!(matches!(
result,
Err(Error::MessageTooShort { expected: 99, .. })
));
// Just under minimum (98 bytes)
let short = vec![0u8; 98];
let result = TreeAnnounce::decode(&short);
assert!(matches!(
result,
Err(Error::MessageTooShort { expected: 99, .. })
));
}
#[test]
fn test_tree_announce_decode_ancestry_count_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::new(node_addr, node_addr, 1, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![CoordEntry::new(node_addr, 1, 1000)]).unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
let mut encoded = announce.encode().unwrap();
// The ancestry_count is at offset: 1 (msg_type) + 1 (version) + 8 (seq) + 8 (ts) + 16 (parent) = 34
// Set ancestry_count to 5 but we only have 1 entry's worth of data
encoded[34] = 5;
encoded[35] = 0;
let result = TreeAnnounce::decode(&encoded[1..]);
assert!(matches!(result, Err(Error::MessageTooShort { .. })));
}
#[test]
fn test_tree_announce_encode_unsigned_fails() {
let node = make_node_addr(1);
let decl = ParentDeclaration::new(node, node, 1, 1000);
let ancestry = make_coords(&[1, 0]);
let announce = TreeAnnounce::new(decl, ancestry);
let result = announce.encode();
assert!(matches!(result, Err(Error::InvalidSignature)));
}
/// Tests that a well-formed non-root ancestry is accepted.
#[test]
fn test_tree_announce_validate_semantics_accepts_valid_non_root() {
use crate::identity::Identity;
// Regenerate until the random identity's node_addr is numerically
// larger than both fixed parent (02:..) and root (01:..), so the
// root-minimum invariant holds deterministically.
let identity = loop {
let id = Identity::generate();
if id.node_addr().as_bytes()[0] > 1 {
break id;
}
};
let node_addr = *identity.node_addr();
let parent = make_node_addr(2);
let root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(announce.validate_semantics().is_ok());
}
/// Tests that an ancestry is rejected if the final node_addr is not the smallest entry in the path.
#[test]
fn test_tree_announce_validate_semantics_rejects_non_minimal_root() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let smaller = make_node_addr(0);
let advertised_root = make_node_addr(1);
let mut decl = ParentDeclaration::new(node_addr, smaller, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(smaller, 4, 900),
CoordEntry::new(advertised_root, 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryRootNotMinimum {
advertised,
minimum,
}) if advertised == advertised_root && minimum == smaller
));
}
/// Tests that an ancestry is rejected if the first ancestry hop does not match the signed parent_id.
#[test]
fn test_tree_announce_validate_semantics_rejects_parent_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let declared_parent = make_node_addr(2);
let ancestry_parent = make_node_addr(3);
let mut decl = ParentDeclaration::new(node_addr, declared_parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(ancestry_parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryParentMismatch {
declared,
ancestry,
}) if declared == declared_parent && ancestry == ancestry_parent
));
}
/// Tests that an ancestry is rejected if the first path entry does not match the signed sender node_addr.
#[test]
fn test_tree_announce_validate_semantics_rejects_sender_mismatch() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let ancestry_sender = make_node_addr(9);
let parent = make_node_addr(2);
let mut decl = ParentDeclaration::new(node_addr, parent, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(ancestry_sender, 5, 1000),
CoordEntry::new(parent, 4, 900),
CoordEntry::new(make_node_addr(1), 3, 800),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::AncestryNodeMismatch {
declared,
ancestry,
}) if declared == node_addr && ancestry == ancestry_sender
));
}
/// Tests that a self-root declaration is rejected if its ancestry contains extra ancestors.
#[test]
fn test_tree_announce_validate_semantics_rejects_root_with_ancestors() {
use crate::identity::Identity;
let identity = Identity::generate();
let node_addr = *identity.node_addr();
let mut decl = ParentDeclaration::self_root(node_addr, 5, 1000);
sign_decl(&mut decl, &identity);
let ancestry = TreeCoordinate::new(vec![
CoordEntry::new(node_addr, 5, 1000),
CoordEntry::new(make_node_addr(0), 4, 900),
])
.unwrap();
let announce = TreeAnnounce::new(decl, ancestry);
assert!(matches!(
announce.validate_semantics(),
Err(TreeError::RootDeclarationMismatch)
));
}
}
+16
View File
@@ -0,0 +1,16 @@
//! Shell-side time seam: the process-monotonic millisecond clock the sans-IO
//! cores take as an injected `u64`.
use std::sync::LazyLock;
use std::time::Instant;
/// Monotonic millisecond clock for the MMP time seam.
///
/// The owned `proto::mmp` state takes injected `u64` milliseconds rather than
/// reading a clock. The shell reads this process-monotonic clock at each edge
/// and passes the value in; only deltas between two `mono_ms()` reads are ever
/// compared, so the (process-relative) epoch is immaterial.
pub(crate) fn mono_ms() -> u64 {
static START: LazyLock<Instant> = LazyLock::new(Instant::now);
START.elapsed().as_millis() as u64
}