mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-06 11:38:24 +00:00
802 lines
32 KiB
Rust
802 lines
32 KiB
Rust
//! Sans-IO mesh lookup decision core.
|
|
//!
|
|
//! Pure, runtime-agnostic decision logic for the mesh lookup protocol. The
|
|
//! async I/O adapter in `node::handlers::lookup` decodes wire bytes,
|
|
//! calls into this core, and drives the returned actions (the actual
|
|
//! encrypted sends). No I/O, no clock, no metrics, no logging here.
|
|
|
|
use alloc::sync::Arc;
|
|
|
|
use super::state::{Lookup, PendingLookup};
|
|
use super::wire::LookupRequest;
|
|
use crate::NodeAddr;
|
|
|
|
/// Read-only view of routing state the lookup core needs.
|
|
///
|
|
/// The core defines this interface; the async shell (`node`) implements it
|
|
/// over the live peer/tree tables. Keeping it a trait keeps `proto` free of
|
|
/// any dependency on `node` and lets the core be unit-tested with a mock.
|
|
pub(crate) trait RoutingView {
|
|
/// Is `addr` a spanning-tree peer (parent or child)?
|
|
fn is_tree_peer(&self, addr: &NodeAddr) -> bool;
|
|
/// Peers whose bloom filter may reach `target` (i.e. `may_reach(target)`).
|
|
fn peers_reaching(&self, target: &NodeAddr) -> Vec<NodeAddr>;
|
|
/// Is this node a Leaf? Leaves do not transit-forward lookup requests.
|
|
fn node_is_leaf(&self) -> bool;
|
|
/// Does `addr` advertise the Full profile? Only Full peers are forwarded to.
|
|
fn peer_is_full(&self, addr: &NodeAddr) -> bool;
|
|
/// Does `addr`'s outgoing link MTU satisfy `min_mtu`? (`min_mtu == 0` means
|
|
/// no requirement; a peer with no known transport MTU is not pruned.)
|
|
fn peer_meets_mtu(&self, addr: &NodeAddr, min_mtu: u16) -> bool;
|
|
}
|
|
|
|
/// An I/O action the async shell performs on the core's behalf.
|
|
pub(crate) enum LookupAction {
|
|
/// Send an encoded lookup PDU to a peer as an encrypted link message.
|
|
/// `bytes` is `Arc`-shared so a fan-out encodes once.
|
|
SendLink { peer: NodeAddr, bytes: Arc<[u8]> },
|
|
/// Cache the verified destination coordinates + path MTU (coord_cache).
|
|
///
|
|
/// `request_id` is the correlator of the response this write came from. The
|
|
/// shell logs a warning here when `path_mtu` is below the actionable floor,
|
|
/// and that line is only correlatable with the rest of the exchange if the
|
|
/// action carries the id — by then the response itself is out of scope.
|
|
CacheCoords {
|
|
request_id: u64,
|
|
target: NodeAddr,
|
|
coords: crate::TreeCoordinate,
|
|
now_ms: u64,
|
|
path_mtu: u16,
|
|
},
|
|
/// Mirror path_mtu into the FipsAddress-keyed TUN-shared lookup map.
|
|
///
|
|
/// `now_ms` stamps the entry's expiry deadline. It is the same instant
|
|
/// [`Self::CacheCoords`] carries, because the clamp this write feeds must
|
|
/// not outlive the route it was learned with.
|
|
WritePathMtu {
|
|
target: NodeAddr,
|
|
now_ms: u64,
|
|
path_mtu: u16,
|
|
},
|
|
/// Reset the coords-warmup counter if an established session exists.
|
|
ResetWarmupIfEstablished { target: NodeAddr },
|
|
/// Retry queued TUN packets or native datagrams for the target if any are
|
|
/// pending.
|
|
RetryQueuedPackets { target: NodeAddr },
|
|
}
|
|
|
|
/// Outcome of planning a LookupRequest forward.
|
|
pub(crate) enum ForwardOutcome {
|
|
/// TTL was exhausted — nothing to do.
|
|
TtlExhausted,
|
|
/// This node is a Leaf; leaves do not transit-forward. Silent no-op.
|
|
LeafNoForward,
|
|
/// No eligible peer had the target in its bloom filter.
|
|
NoPeers,
|
|
/// Forward: one SendLink per selected peer. `used_fallback` is true when
|
|
/// the non-tree bloom-match fallback set was used (no tree peer matched).
|
|
Forward {
|
|
actions: Vec<LookupAction>,
|
|
used_fallback: bool,
|
|
},
|
|
}
|
|
|
|
/// Plan the transit forward of an inbound LookupRequest.
|
|
///
|
|
/// Decrements TTL; suppresses forwarding on a Leaf node; restricts candidates to
|
|
/// Full peers whose link satisfies the request's `min_mtu`; selects tree peers
|
|
/// whose bloom matches the target, else a non-tree bloom-match fallback; encodes
|
|
/// the (decremented) request once and emits one SendLink per selected peer. Pure
|
|
/// — no I/O, metrics, or logs.
|
|
pub(crate) fn plan_forward(request: &mut LookupRequest, rv: &impl RoutingView) -> ForwardOutcome {
|
|
if !request.forward() {
|
|
return ForwardOutcome::TtlExhausted;
|
|
}
|
|
// Leaf nodes do not transit-forward lookup requests.
|
|
if rv.node_is_leaf() {
|
|
return ForwardOutcome::LeafNoForward;
|
|
}
|
|
let target = request.target;
|
|
let min_mtu = request.min_mtu;
|
|
// Only Full peers whose outgoing link satisfies min_mtu are eligible.
|
|
let eligible: Vec<NodeAddr> = rv
|
|
.peers_reaching(&target)
|
|
.into_iter()
|
|
.filter(|a| rv.peer_is_full(a) && rv.peer_meets_mtu(a, min_mtu))
|
|
.collect();
|
|
let tree: Vec<NodeAddr> = eligible
|
|
.iter()
|
|
.copied()
|
|
.filter(|a| rv.is_tree_peer(a))
|
|
.collect();
|
|
let (targets, used_fallback) = if tree.is_empty() {
|
|
let fallback: Vec<NodeAddr> = eligible
|
|
.into_iter()
|
|
.filter(|a| !rv.is_tree_peer(a))
|
|
.collect();
|
|
if fallback.is_empty() {
|
|
return ForwardOutcome::NoPeers;
|
|
}
|
|
(fallback, true)
|
|
} else {
|
|
(tree, false)
|
|
};
|
|
let bytes: Arc<[u8]> = Arc::from(request.encode());
|
|
let actions = targets
|
|
.into_iter()
|
|
.map(|peer| LookupAction::SendLink {
|
|
peer,
|
|
bytes: bytes.clone(),
|
|
})
|
|
.collect();
|
|
ForwardOutcome::Forward {
|
|
actions,
|
|
used_fallback,
|
|
}
|
|
}
|
|
|
|
/// Plan the origination of a freshly-generated LookupRequest.
|
|
///
|
|
/// Selects tree peers whose bloom matches the target and emits one SendLink per
|
|
/// selected peer, encoding the request once (Arc-shared). Returns an empty Vec
|
|
/// when no tree peer matches; the shell treats that as an immediate failure.
|
|
/// Pure — no I/O, metrics, or logs; the shell generates and signs the request.
|
|
///
|
|
/// NOTE: unlike [`plan_forward`], this does NOT fall back to non-tree
|
|
/// (cross-link) bloom-matching peers. That asymmetry is preserved verbatim from
|
|
/// the pre-sans-IO `initiate_lookup` to keep this extraction behavior-neutral;
|
|
/// it is a known origination gap whose fix adds the fallback branch as a
|
|
/// separate, behavior-changing change.
|
|
pub(crate) fn plan_initiate(request: &LookupRequest, rv: &impl RoutingView) -> Vec<LookupAction> {
|
|
let min_mtu = request.min_mtu;
|
|
let targets: Vec<NodeAddr> = rv
|
|
.peers_reaching(&request.target)
|
|
.into_iter()
|
|
.filter(|addr| {
|
|
rv.is_tree_peer(addr) && rv.peer_is_full(addr) && rv.peer_meets_mtu(addr, min_mtu)
|
|
})
|
|
.collect();
|
|
if targets.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let bytes: Arc<[u8]> = Arc::from(request.encode());
|
|
targets
|
|
.into_iter()
|
|
.map(|peer| LookupAction::SendLink {
|
|
peer,
|
|
bytes: bytes.clone(),
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
/// Classification of an inbound LookupRequest, decided from Lookup state.
|
|
pub(crate) enum RequestOutcome {
|
|
/// request_id already in the dedup cache — drop. The test is on the id
|
|
/// alone, so one flood reaching this node through two neighbours lands
|
|
/// here just as a request sent twice does; it does not identify the peer
|
|
/// that delivered the copy.
|
|
Duplicate,
|
|
/// A request this node originated, looped back to it — drop without
|
|
/// recording. Kept apart from `Duplicate` by cause: this is the node's
|
|
/// own fan-out returning, while `Duplicate` is a request id the node has
|
|
/// already recorded, seen again. Neither identifies the delivering peer.
|
|
/// Recognised only while the lookup is outstanding and the id is among
|
|
/// the last `MAX_RECORDED_IDS` it issued; an own copy outside that reach
|
|
/// is recorded and forwarded as transit, and a later copy of it is
|
|
/// dropped as a duplicate.
|
|
OwnRequestLooped,
|
|
/// We are the lookup target — the shell generates + sends the response.
|
|
RespondAsTarget,
|
|
/// Forward the request onward (the shell calls the forward planner).
|
|
Forward,
|
|
/// Transit forward suppressed by the per-target forward rate limiter.
|
|
ForwardRateLimited,
|
|
/// TTL exhausted, not the target — drop.
|
|
TtlExhausted,
|
|
}
|
|
|
|
/// One dedup-cache entry dropped to make room for an arriving request.
|
|
///
|
|
/// Returned to the shell so it can count and log the eviction; the core does
|
|
/// no metrics and no logging itself.
|
|
pub(crate) struct Eviction {
|
|
/// The `request_id` that was dropped. Its reverse path is gone: a
|
|
/// response still in flight for it will be treated as unsolicited.
|
|
pub request_id: u64,
|
|
/// The link peer charged for the eviction — the one whose oldest entry
|
|
/// this was, which is not necessarily the peer being admitted.
|
|
pub peer: NodeAddr,
|
|
/// The per-peer share in force at the time, for the log line.
|
|
pub share: usize,
|
|
}
|
|
|
|
/// The result of classifying an inbound LookupRequest: the route decision,
|
|
/// plus any entry that was evicted to make room for it.
|
|
pub(crate) struct Classification {
|
|
/// What the shell should do with the request.
|
|
pub outcome: RequestOutcome,
|
|
/// The entry dropped to admit this request, if one was.
|
|
pub evicted: Option<Eviction>,
|
|
}
|
|
|
|
/// Evict from the dedup cache if admitting one more request would put this
|
|
/// peer over its share, or the cache over its capacity.
|
|
///
|
|
/// Who pays is the whole point. Over its own share a peer pays for itself,
|
|
/// and at global capacity the peer holding the most entries pays, so a light
|
|
/// peer's reverse path is never taken to admit a heavy one and extra
|
|
/// identities buy a flooder proportionally less. Nothing is evicted while
|
|
/// the peer is under its share and the cache is under capacity.
|
|
fn make_room(
|
|
lookup: &mut Lookup,
|
|
from: &NodeAddr,
|
|
max_recent: usize,
|
|
peer_count: usize,
|
|
) -> Option<Eviction> {
|
|
let share = Lookup::peer_share(max_recent, peer_count);
|
|
let victim = if lookup.peer_entries(from) >= share {
|
|
*from
|
|
} else if lookup.recent_requests.len() >= max_recent {
|
|
// Never charge the arriving peer when it is under its share: charge
|
|
// whoever is holding the most.
|
|
lookup.heaviest_peer()?
|
|
} else {
|
|
return None;
|
|
};
|
|
let request_id = lookup.evict_oldest_from(&victim)?;
|
|
Some(Eviction {
|
|
request_id,
|
|
peer: victim,
|
|
share,
|
|
})
|
|
}
|
|
|
|
/// Classify an inbound LookupRequest against the recent-request dedup cache and
|
|
/// the transit forward rate limiter. Purges expired dedup entries, records the
|
|
/// request for reverse-path forwarding on the non-drop paths, and decides the
|
|
/// route. Pure over Lookup state + node addr + injected clock; no I/O, no view.
|
|
///
|
|
/// A full cache evicts rather than refuses. Refusing put the capacity check
|
|
/// ahead of the check for whether the request names this node, so one link
|
|
/// peer emitting fresh `request_id`s could stop the node answering lookups
|
|
/// for itself and stop it carrying anyone else's for as long as it kept the
|
|
/// cache full — a denial of exactly the service the cache exists to protect.
|
|
/// [`make_room`] charges the eviction to the peer that filled the cache.
|
|
///
|
|
/// The loosening this accepts: an evicted `request_id` arriving again inside
|
|
/// the dedup window is forwarded a second time rather than recognised as a
|
|
/// duplicate, and a response still in flight for it has lost its reverse
|
|
/// path. The per-target forward limiter and the request TTL already bound
|
|
/// what that second forward can cost, and the alternative — refusing the
|
|
/// arrival — is the availability defect above.
|
|
///
|
|
/// `peer_count` is the current link-peer count, supplied by the caller: the
|
|
/// core is sans-IO and clockless and has no view of the live peer table.
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub(crate) fn classify_request(
|
|
lookup: &mut Lookup,
|
|
request: &LookupRequest,
|
|
from: &NodeAddr,
|
|
my_addr: &NodeAddr,
|
|
now_ms: u64,
|
|
recent_expiry_ms: u64,
|
|
max_recent: usize,
|
|
peer_count: usize,
|
|
) -> Classification {
|
|
// Purge expired dedup entries (was purge_expired_requests). Cache and
|
|
// per-peer index are purged together, or the eviction policy below reads
|
|
// a stale index and charges the wrong peer.
|
|
lookup.purge_recent(now_ms, recent_expiry_ms);
|
|
|
|
if lookup.recent_requests.contains_key(&request.request_id) {
|
|
return Classification {
|
|
outcome: RequestOutcome::Duplicate,
|
|
evicted: None,
|
|
};
|
|
}
|
|
|
|
// A request this node originated, flooded to every bloom-matching tree
|
|
// peer and circulated back to us by one of them. The only identity test
|
|
// below is `request.target == *my_addr`, and for a lookup we originated
|
|
// the target is someone else, so without this the copy is filed as an
|
|
// ordinary transit entry keyed on our own `request_id` — and the answer,
|
|
// when it comes, is reverse-path forwarded to the peer that looped the
|
|
// request instead of being accepted here. Dropping it as the duplicate it
|
|
// is also stops the copy being forwarded a second time: our flood already
|
|
// reached the peers that could carry it.
|
|
//
|
|
// `request.origin` looks like the cheaper identity test and cannot be
|
|
// used. It is unsigned and set by whoever sends the frame, so a peer
|
|
// could put this node's address on any request and make it refuse to
|
|
// transit that request. An id has to have been issued here to match,
|
|
// which is what makes this test safe to drop on.
|
|
//
|
|
// The test reaches only as far as `PendingLookup::ids`, which keeps the
|
|
// last `MAX_RECORDED_IDS` that a target's ladder issued. A ladder
|
|
// configured with more rungs than that loses its earliest ids, and a
|
|
// returning copy of one of those attempts is recorded as transit again.
|
|
// The bound is inherited from the originator test in `classify_response`
|
|
// rather than introduced here, and a reply to such an attempt was already
|
|
// being dropped as unsolicited.
|
|
if lookup
|
|
.pending_lookups
|
|
.get(&request.target)
|
|
.is_some_and(|pending| pending.matches(request.request_id))
|
|
{
|
|
return Classification {
|
|
outcome: RequestOutcome::OwnRequestLooped,
|
|
evicted: None,
|
|
};
|
|
}
|
|
|
|
let evicted = make_room(lookup, from, max_recent, peer_count);
|
|
lookup.record_recent(request.request_id, *from, now_ms);
|
|
|
|
let outcome = if request.target == *my_addr {
|
|
RequestOutcome::RespondAsTarget
|
|
} else if request.can_forward() {
|
|
if lookup
|
|
.forward_limiter
|
|
.should_forward(&request.target, now_ms)
|
|
{
|
|
RequestOutcome::Forward
|
|
} else {
|
|
RequestOutcome::ForwardRateLimited
|
|
}
|
|
} else {
|
|
RequestOutcome::TtlExhausted
|
|
};
|
|
Classification { outcome, evicted }
|
|
}
|
|
|
|
/// How an inbound LookupResponse should be routed, decided from the pending
|
|
/// lookups this node has outstanding and, failing that, the recent-request
|
|
/// dedup state.
|
|
pub(crate) enum ResponseRoute {
|
|
/// A response for a request we forwarded, but we already reverse-forwarded
|
|
/// one for this request_id — drop to prevent response routing loops.
|
|
AlreadyForwarded,
|
|
/// Transit node: reverse-path forward toward `from_peer`.
|
|
Transit { from_peer: NodeAddr },
|
|
/// We originated this request — the shell verifies the proof and caches.
|
|
Originator,
|
|
/// Nobody asked for this: it is neither a request we transited nor an
|
|
/// answer to a lookup we have outstanding for its target. Dropped before
|
|
/// the identity resolve and the signature verify, so it costs nothing.
|
|
Unsolicited,
|
|
}
|
|
|
|
/// Classify an inbound LookupResponse against the pending lookups and the
|
|
/// recent-request dedup cache, in that order.
|
|
///
|
|
/// Pure decision over `Lookup` state: sets `response_forwarded` when this is
|
|
/// the first response we transit for the request. No I/O, no view, no metrics.
|
|
pub(crate) fn classify_response(
|
|
lookup: &mut Lookup,
|
|
request_id: u64,
|
|
target: &NodeAddr,
|
|
) -> ResponseRoute {
|
|
// Originator-ness is decided first, ahead of the transit dedup record.
|
|
// The response names a target this node has a lookup outstanding for and
|
|
// carries an id that lookup issued, so it answers us whatever else the
|
|
// dedup cache happens to hold for that id. Testing `recent_requests`
|
|
// first instead made this arm unreachable whenever a copy of our own
|
|
// flooded request found its way back to us and was filed as transit: the
|
|
// reply was relayed away rather than accepted, the pending lookup was
|
|
// never satisfied, and discovery failed while the answers were arriving.
|
|
//
|
|
// The id is fresh 64-bit randomness drawn per attempt and the target
|
|
// signs over it, so a harvested response is bound to the request it
|
|
// answered and cannot be redirected or replayed: an id we never issued
|
|
// still cannot match, and preferring this arm takes nothing away from
|
|
// what the `Unsolicited` arm protects. Replies to earlier attempts of a
|
|
// still-outstanding lookup match too, which is the common case on a link
|
|
// whose round trip exceeds the first rung of the retry ladder.
|
|
if lookup
|
|
.pending_lookups
|
|
.get(target)
|
|
.is_some_and(|pending| pending.matches(request_id))
|
|
{
|
|
return ResponseRoute::Originator;
|
|
}
|
|
|
|
match lookup.recent_requests.get_mut(&request_id) {
|
|
Some(recent) => {
|
|
if recent.response_forwarded {
|
|
ResponseRoute::AlreadyForwarded
|
|
} else {
|
|
recent.response_forwarded = true;
|
|
ResponseRoute::Transit {
|
|
from_peer: recent.from_peer,
|
|
}
|
|
}
|
|
}
|
|
// Neither a request we transited nor an answer to a lookup we have
|
|
// outstanding for its target. Nobody asked for it.
|
|
None => ResponseRoute::Unsolicited,
|
|
}
|
|
}
|
|
|
|
/// Where a LookupResponse we originate as the target should be sent first.
|
|
pub(crate) enum ResponseRouteDecision {
|
|
/// Send toward the peer the matching request arrived from — the reverse path
|
|
/// recorded in `recent_requests` by [`classify_request`].
|
|
ReversePath(NodeAddr),
|
|
/// No recorded reverse path: the shell must route greedily toward the origin.
|
|
NeedsTreeRoute,
|
|
}
|
|
|
|
/// Decide the first hop for a LookupResponse we originate as the target, from
|
|
/// the recent-request reverse-path record. Pure over `Lookup` state.
|
|
///
|
|
/// Only the reverse-path decision is pure. The `NeedsTreeRoute` fallback (greedy
|
|
/// tree routing toward the origin) is a `&mut Node` coord-cache operation with a
|
|
/// TTL-touch side effect, so it stays in the shell rather than moving here.
|
|
pub(crate) fn plan_response_route(lookup: &Lookup, request_id: u64) -> ResponseRouteDecision {
|
|
match lookup.recent_requests.get(&request_id) {
|
|
Some(recent) => ResponseRouteDecision::ReversePath(recent.from_peer),
|
|
None => ResponseRouteDecision::NeedsTreeRoute,
|
|
}
|
|
}
|
|
|
|
/// Apply the accept-side effects of a verified LookupResponse we originated.
|
|
///
|
|
/// Mutates the Lookup success state (clears backoff, drops the pending
|
|
/// lookup) and returns the cross-subsystem effects for the shell to drive.
|
|
/// Verification is the shell's job — this runs only after the proof checked out.
|
|
pub(crate) fn on_response_accepted(
|
|
lookup: &mut Lookup,
|
|
request_id: u64,
|
|
target: &NodeAddr,
|
|
coords: crate::TreeCoordinate,
|
|
now_ms: u64,
|
|
path_mtu: u16,
|
|
) -> Vec<LookupAction> {
|
|
lookup.backoff.record_success(target);
|
|
lookup.pending_lookups.remove(target);
|
|
vec![
|
|
LookupAction::CacheCoords {
|
|
request_id,
|
|
target: *target,
|
|
coords,
|
|
now_ms,
|
|
path_mtu,
|
|
},
|
|
LookupAction::WritePathMtu {
|
|
target: *target,
|
|
now_ms,
|
|
path_mtu,
|
|
},
|
|
LookupAction::ResetWarmupIfEstablished { target: *target },
|
|
LookupAction::RetryQueuedPackets { target: *target },
|
|
]
|
|
}
|
|
|
|
/// Result of polling the pending-lookup retry ladder at `now_ms`.
|
|
///
|
|
/// The core has already applied the state mutations: retried entries have had
|
|
/// their attempt bumped and last_sent updated; timed-out entries have been
|
|
/// removed and a backoff failure recorded. The shell drives the effects.
|
|
pub(crate) struct PollOutcome {
|
|
/// (target, new attempt number) — shell re-sends via initiate_lookup.
|
|
pub retries: Vec<(NodeAddr, u8)>,
|
|
/// (target, failure_count after recording) — shell emits unreachable.
|
|
pub timeouts: Vec<(NodeAddr, u32)>,
|
|
}
|
|
|
|
/// Advance the pending-lookup retry ladder. Pure over `Lookup` state +
|
|
/// injected clock: partitions due entries into retries (attempt bumped) and
|
|
/// final timeouts (removed + backoff failure recorded). No I/O, no view.
|
|
pub(crate) fn poll_pending(
|
|
lookup: &mut Lookup,
|
|
now_ms: u64,
|
|
attempt_timeouts_secs: &[u64],
|
|
) -> PollOutcome {
|
|
let max_attempts = attempt_timeouts_secs.len() as u8;
|
|
|
|
// Collect targets needing action (can't mutate while iterating).
|
|
let mut retry_targets: Vec<NodeAddr> = Vec::new();
|
|
let mut timeout_targets: Vec<NodeAddr> = Vec::new();
|
|
|
|
for (&target, entry) in &lookup.pending_lookups {
|
|
let idx = (entry.attempt as usize).saturating_sub(1);
|
|
let to_ms = attempt_timeouts_secs.get(idx).copied().unwrap_or(0) * 1000;
|
|
if now_ms.saturating_sub(entry.last_sent_ms) >= to_ms {
|
|
if entry.attempt >= max_attempts {
|
|
timeout_targets.push(target);
|
|
} else {
|
|
retry_targets.push(target);
|
|
}
|
|
}
|
|
}
|
|
|
|
let mut retries: Vec<(NodeAddr, u8)> = Vec::new();
|
|
for target in retry_targets {
|
|
if let Some(entry) = lookup.pending_lookups.get_mut(&target) {
|
|
entry.attempt += 1;
|
|
entry.last_sent_ms = now_ms;
|
|
retries.push((target, entry.attempt));
|
|
}
|
|
}
|
|
|
|
let mut timeouts: Vec<(NodeAddr, u32)> = Vec::new();
|
|
for target in timeout_targets {
|
|
lookup.pending_lookups.remove(&target);
|
|
lookup.backoff.record_failure(&target, now_ms);
|
|
let failures = lookup.backoff.failure_count(&target);
|
|
timeouts.push((target, failures));
|
|
}
|
|
|
|
PollOutcome { retries, timeouts }
|
|
}
|
|
|
|
/// Decision for whether/how to initiate a lookup for a target.
|
|
pub(crate) enum InitiateDecision {
|
|
/// A lookup is already pending for this target — skip.
|
|
Deduplicated,
|
|
/// Suppressed by post-failure backoff. `failures` is the current count (for the log).
|
|
Suppressed { failures: u32 },
|
|
/// No peer's bloom filter reaches the target — skip (a failure was recorded).
|
|
BloomMiss,
|
|
/// Proceed: a PendingLookup was inserted; the shell sends the first attempt.
|
|
Proceed,
|
|
}
|
|
|
|
/// Gate a lookup initiation against pending-dedup, backoff
|
|
/// suppression, and bloom reachability (passed in — the shell reads the peer
|
|
/// filters). On BloomMiss records a failure; on Proceed inserts the pending
|
|
/// lookup. Pure over Lookup state + injected clock; no I/O, no view.
|
|
pub(crate) fn initiate_gate(
|
|
lookup: &mut Lookup,
|
|
dest: &NodeAddr,
|
|
now_ms: u64,
|
|
reachable: bool,
|
|
) -> InitiateDecision {
|
|
if lookup.pending_lookups.contains_key(dest) {
|
|
return InitiateDecision::Deduplicated;
|
|
}
|
|
if lookup.backoff.is_suppressed(dest, now_ms) {
|
|
return InitiateDecision::Suppressed {
|
|
failures: lookup.backoff.failure_count(dest),
|
|
};
|
|
}
|
|
if !reachable {
|
|
lookup.backoff.record_failure(dest, now_ms);
|
|
return InitiateDecision::BloomMiss;
|
|
}
|
|
lookup
|
|
.pending_lookups
|
|
.insert(*dest, PendingLookup::new(now_ms));
|
|
InitiateDecision::Proceed
|
|
}
|
|
|
|
/// Roll back a lookup whose first attempt reached no tree peers (sent == 0):
|
|
/// drop the pending entry and record a backoff failure.
|
|
pub(crate) fn initiate_failed(lookup: &mut Lookup, dest: &NodeAddr, now_ms: u64) {
|
|
lookup.pending_lookups.remove(dest);
|
|
lookup.backoff.record_failure(dest, now_ms);
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod dedup_eviction_tests {
|
|
//! Capacity policy for the dedup cache.
|
|
//!
|
|
//! These live beside the policy rather than in `lookup/tests/core.rs`
|
|
//! because they are the regression tests for a security finding and read
|
|
//! directly against `make_room`'s two branches.
|
|
|
|
use super::super::limits::{LookupBackoff, LookupForwardRateLimiter};
|
|
use super::super::state::MIN_RECENT_PER_PEER;
|
|
use super::*;
|
|
use crate::testutil::make_node_addr;
|
|
|
|
/// The cache bound used by the behavioural tests. Smaller than the
|
|
/// production 4096 so a saturation test stays cheap; the policy is a
|
|
/// function of the bound, not of its value.
|
|
const CACHE: usize = 128;
|
|
|
|
fn empty() -> Lookup {
|
|
Lookup::new(
|
|
LookupBackoff::default(),
|
|
LookupForwardRateLimiter::default(),
|
|
)
|
|
}
|
|
|
|
fn request(request_id: u64, target: NodeAddr) -> LookupRequest {
|
|
let origin = make_node_addr(0xCC);
|
|
// `next`'s LookupRequest carries no coordinates.
|
|
LookupRequest::new(request_id, target, origin, 5, 0)
|
|
}
|
|
|
|
/// Deliver one transit request from `from`, discarding the route
|
|
/// decision: these tests are about which entries survive, and the
|
|
/// forward limiter's verdict does not affect what is cached.
|
|
fn deliver(
|
|
lookup: &mut Lookup,
|
|
request_id: u64,
|
|
from: &NodeAddr,
|
|
my_addr: &NodeAddr,
|
|
peer_count: usize,
|
|
) -> Classification {
|
|
let target = make_node_addr(0xBB);
|
|
classify_request(
|
|
lookup,
|
|
&request(request_id, target),
|
|
from,
|
|
my_addr,
|
|
1_000,
|
|
60_000,
|
|
CACHE,
|
|
peer_count,
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn a_peer_over_its_share_evicts_its_own_oldest_and_not_a_light_peers() {
|
|
let mut lookup = empty();
|
|
let heavy = make_node_addr(0x01);
|
|
let light = make_node_addr(0x02);
|
|
let me = make_node_addr(0x99);
|
|
// 64 peers over a 128-entry cache: 128/64 = 2, floored to 64.
|
|
let peer_count = 64;
|
|
let share = Lookup::peer_share(CACHE, peer_count);
|
|
|
|
deliver(&mut lookup, 7, &light, &me, peer_count);
|
|
|
|
// One request past the share, so the heavy peer pays for its own
|
|
// admission rather than the cache paying for it.
|
|
for i in 0..=share as u64 {
|
|
deliver(&mut lookup, 1_000 + i, &heavy, &me, peer_count);
|
|
}
|
|
|
|
assert!(
|
|
lookup.recent_requests.contains_key(&7),
|
|
"a light peer's reverse-path entry must survive a neighbour's flood"
|
|
);
|
|
assert!(
|
|
!lookup.recent_requests.contains_key(&1_000),
|
|
"the flooder's own oldest entry is what pays for its newest"
|
|
);
|
|
assert!(
|
|
lookup.recent_requests.contains_key(&(1_000 + share as u64)),
|
|
"and its newest is admitted rather than dropped"
|
|
);
|
|
assert_eq!(
|
|
lookup.peer_entries(&heavy),
|
|
share,
|
|
"the flooder is held at its share"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn the_per_peer_share_never_falls_below_the_floor_however_many_peers() {
|
|
// A node with as many peers as cache entries would otherwise give
|
|
// each peer a share of one, which no genuine transit burst survives.
|
|
assert_eq!(Lookup::peer_share(CACHE, CACHE), MIN_RECENT_PER_PEER);
|
|
assert_eq!(Lookup::peer_share(CACHE, usize::MAX), MIN_RECENT_PER_PEER);
|
|
// Above the floor the share still tracks the peer count.
|
|
assert_eq!(Lookup::peer_share(4096, 8), 512);
|
|
// And a zero peer count (no links up yet) must not divide by zero.
|
|
assert_eq!(Lookup::peer_share(CACHE, 0), CACHE);
|
|
|
|
// Behaviourally: at the floor, entry number 64 costs the peer
|
|
// nothing and entry number 65 costs it its oldest.
|
|
let mut lookup = empty();
|
|
let peer = make_node_addr(0x01);
|
|
let me = make_node_addr(0x99);
|
|
let peer_count = usize::MAX;
|
|
for i in 0..MIN_RECENT_PER_PEER as u64 {
|
|
let evicted = deliver(&mut lookup, i, &peer, &me, peer_count).evicted;
|
|
assert!(evicted.is_none(), "nothing is evicted below the floor");
|
|
}
|
|
let evicted = deliver(&mut lookup, 999, &peer, &me, peer_count)
|
|
.evicted
|
|
.expect("the entry past the floor must evict");
|
|
assert_eq!(evicted.request_id, 0, "the peer's oldest entry pays");
|
|
assert_eq!(evicted.peer, peer);
|
|
assert_eq!(evicted.share, MIN_RECENT_PER_PEER);
|
|
}
|
|
|
|
#[test]
|
|
fn a_node_whose_dedup_cache_is_saturated_still_answers_a_lookup_for_itself() {
|
|
// The availability claim, and the actual finding: the capacity check
|
|
// used to sit ahead of the check for whether the request names us,
|
|
// so a peer holding the cache full made this node unresolvable.
|
|
let mut lookup = empty();
|
|
let flooder = make_node_addr(0x01);
|
|
let other = make_node_addr(0x02);
|
|
let me = make_node_addr(0x99);
|
|
// A single link peer, so its share is the whole cache and it can
|
|
// saturate without evicting itself.
|
|
let peer_count = 1;
|
|
|
|
for i in 0..CACHE as u64 {
|
|
deliver(&mut lookup, i, &flooder, &me, peer_count);
|
|
}
|
|
assert_eq!(
|
|
lookup.recent_requests.len(),
|
|
CACHE,
|
|
"precondition: the cache is full, or the rest observes nothing"
|
|
);
|
|
|
|
let classification = classify_request(
|
|
&mut lookup,
|
|
&request(u64::MAX, me),
|
|
&other,
|
|
&me,
|
|
1_000,
|
|
60_000,
|
|
CACHE,
|
|
peer_count,
|
|
);
|
|
|
|
assert!(
|
|
matches!(classification.outcome, RequestOutcome::RespondAsTarget),
|
|
"a saturated cache must not stop the node answering lookups for itself"
|
|
);
|
|
let evicted = classification
|
|
.evicted
|
|
.expect("room must have been made at capacity");
|
|
assert_eq!(
|
|
evicted.peer, flooder,
|
|
"the peer holding the most entries pays, not the arriving one"
|
|
);
|
|
assert_eq!(evicted.request_id, 0, "and it pays with its oldest");
|
|
assert!(
|
|
lookup.recent_requests.contains_key(&u64::MAX),
|
|
"the arriving request is recorded, so its response can be routed back"
|
|
);
|
|
assert_eq!(
|
|
lookup.recent_requests.len(),
|
|
CACHE,
|
|
"the cache stays at its bound"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_duplicate_is_not_indexed_twice_and_evicts_nothing() {
|
|
// The index is a second container over the same entries, so the
|
|
// maintenance risk is drift: everything the policy decides reads it.
|
|
let mut lookup = empty();
|
|
let peer = make_node_addr(0x01);
|
|
let me = make_node_addr(0x99);
|
|
|
|
deliver(&mut lookup, 42, &peer, &me, 1);
|
|
let repeat = deliver(&mut lookup, 42, &peer, &me, 1);
|
|
|
|
assert!(matches!(repeat.outcome, RequestOutcome::Duplicate));
|
|
assert!(repeat.evicted.is_none(), "a duplicate makes no room");
|
|
assert_eq!(lookup.peer_entries(&peer), 1);
|
|
assert_eq!(lookup.recent_requests.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn purging_expired_entries_leaves_the_index_level_with_the_cache() {
|
|
let mut lookup = empty();
|
|
let a = make_node_addr(0x01);
|
|
let b = make_node_addr(0x02);
|
|
let me = make_node_addr(0x99);
|
|
|
|
for i in 0..5u64 {
|
|
deliver(&mut lookup, i, &a, &me, 2);
|
|
}
|
|
for i in 100..103u64 {
|
|
deliver(&mut lookup, i, &b, &me, 2);
|
|
}
|
|
let indexed: usize = lookup.recent_by_peer.values().map(|ids| ids.len()).sum();
|
|
assert_eq!(
|
|
indexed,
|
|
lookup.recent_requests.len(),
|
|
"every cached request is indexed exactly once"
|
|
);
|
|
|
|
// Entries were stamped at 1_000 with a 60s window; age them out.
|
|
lookup.purge_recent(1_000 + 60_000 + 1, 60_000);
|
|
assert!(lookup.recent_requests.is_empty());
|
|
assert!(
|
|
lookup.recent_by_peer.is_empty(),
|
|
"the index must not keep entries the cache no longer holds"
|
|
);
|
|
}
|
|
}
|