Files
fips/src/node/reject.rs
T
Johnathan Corgan 989ae65fc5 Align the datagram hop-limit helpers with the forwarder's semantics
The forwarding path was corrected to full IP semantics: local delivery is not
hop-limit gated, and forwarding decrements first and drops at zero. Two helpers
on SessionDatagram were left implementing the old rule. decrement_ttl still
checked before decrementing, and can_forward still answered true at a hop limit
of one, where the decrement leaves zero and the datagram is dropped. Neither is
called anywhere today, which is precisely why they are worth correcting rather
than leaving as an invitation to reintroduce the defect.

The TtlExhausted reject doc described behaviour that no longer exists. The
reject is charged for a transit arrival at one as well as at zero, and is never
charged for a datagram addressed to this node, whose delivery is decided ahead
of the test.

Both helpers are pinned by tests at the boundary, which fail against the
previous implementations.
2026-07-25 17:05:20 +00:00

365 lines
15 KiB
Rust

//! Typed rejection reasons for silent-rejection sites across the node.
//!
//! Every rejection-and-return path in the node should classify its
//! reason via [`RejectReason`] and pass the result to
//! [`NodeStats::record_reject`](crate::node::stats::NodeStats::record_reject)
//! so operators can see *what* is being rejected via stats counters
//! rather than via log scraping.
//!
//! The top-level variant set mirrors the protocol-layer / subsystem
//! split that the [`NodeStats`](crate::node::stats::NodeStats)
//! sub-structures already follow, with additional categories
//! (`Handshake`/`Session`/`Mmp`/`Forwarding`/`Transport`) for known
//! silent-rejection clusters that don't yet have dedicated stats
//! sub-structures.
//!
//! The second-level enums are marked `#[non_exhaustive]` to keep the
//! door open for additions without semver concerns from any future
//! external crates.
/// Typed rejection reason for any silent-rejection site in the node.
///
/// Each top-level variant maps to a protocol layer or major subsystem;
/// the nested second-level enum classifies the specific reason within
/// that layer. The whole type is `Copy` so it can be passed through
/// match arms cheaply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[must_use = "RejectReason values must be passed to NodeStats::record_reject"]
pub enum RejectReason {
/// Spanning-tree TreeAnnounce processing rejection.
Tree(TreeReject),
/// Bloom-filter FilterAnnounce processing rejection.
Bloom(BloomReject),
/// Discovery request / response processing rejection.
Discovery(DiscoveryReject),
/// Noise handshake state-machine rejection.
Handshake(HandshakeReject),
/// FSP session state-machine rejection.
Session(SessionReject),
/// MMP link-layer rejection.
Mmp(MmpReject),
/// Forwarding-path rejection (no-route, TTL, MTU).
Forwarding(ForwardingReject),
/// Transport-layer rejection (admission caps, framing, etc.).
Transport(TransportReject),
}
/// Spanning-tree rejection reasons.
///
/// `AncestryInvalid` covers the `validate_semantics` ancestry-structure
/// rejection; `OutboundSignFailed` covers the Tree and MMP
/// sign-failure cluster.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TreeReject {
/// `TreeAnnounce::validate_semantics` returned an error — the
/// advertised ancestry is structurally invalid (advertised root
/// must equal min path entry, parent-link consistency along the
/// chain). Tracked via
/// [`TreeStats::ancestry_invalid`](crate::node::stats::TreeStats).
AncestryInvalid,
/// Local outbound `TreeDeclaration` signing failed — the node's
/// identity returned an error from `sign_declaration`. Tracked via
/// [`TreeStats::outbound_sign_failed`](crate::node::stats::TreeStats).
/// Fires on parent switch, self-root promotion, loop-detection
/// recovery, parent update from inbound TreeAnnounce, periodic
/// re-eval, parent-loss recovery, and first-RTT MMP parent eval.
OutboundSignFailed,
}
/// Bloom-filter rejection reasons.
///
/// Each variant corresponds to a silent-rejection path in
/// `src/node/bloom.rs::handle_filter_announce`. The matching counters
/// already exist as direct fields on `BloomStats`; `record_reject`
/// dispatches into them so the typed enum stays the canonical entry
/// point for new rejection paths.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum BloomReject {
/// `FilterAnnounce::decode` returned an error. Tracked via
/// [`BloomStats::decode_error`](crate::node::stats::BloomStats).
DecodeError,
/// Announce passed decode but the filter/size_class pair is
/// internally inconsistent (`is_valid()` returned false). Tracked
/// via [`BloomStats::invalid`](crate::node::stats::BloomStats).
Invalid,
/// Announce advertises a non-v1-compliant size class. Tracked via
/// [`BloomStats::non_v1`](crate::node::stats::BloomStats).
NonV1,
/// Announce arrived from a peer with no `ActivePeer` record on
/// this node. Tracked via
/// [`BloomStats::unknown_peer`](crate::node::stats::BloomStats).
UnknownPeer,
/// Announce sequence number is not strictly greater than the
/// peer's current stored sequence (replay or stale). Tracked via
/// [`BloomStats::stale`](crate::node::stats::BloomStats).
Stale,
/// Announce filter's false-positive rate exceeds the configured
/// `max_inbound_fpr` antipoison cap. Tracked via
/// [`BloomStats::fill_exceeded`](crate::node::stats::BloomStats).
FillExceeded,
}
/// Discovery rejection reasons.
///
/// Each variant corresponds to a silent-rejection path in
/// `src/node/handlers/discovery.rs` across request and response
/// processing. Matching counters already exist on `DiscoveryStats`;
/// `record_reject` dispatches into them.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum DiscoveryReject {
/// `LookupRequest::decode` returned an error. Tracked via
/// [`DiscoveryStats::req_decode_error`](crate::node::stats::DiscoveryStats).
ReqDecodeError,
/// Request `request_id` already seen — dedup / loop protection.
/// Tracked via
/// [`DiscoveryStats::req_duplicate`](crate::node::stats::DiscoveryStats).
ReqDuplicate,
/// Request dedup cache (`recent_requests`) is at capacity, so the
/// `LookupRequest` is dropped without being forwarded. Tracked via
/// [`DiscoveryStats::req_dedup_cache_full`](crate::node::stats::DiscoveryStats).
ReqDedupCacheFull,
/// Request arrived with TTL=0 — no more forwarding hops allowed.
/// Tracked via
/// [`DiscoveryStats::req_ttl_exhausted`](crate::node::stats::DiscoveryStats).
ReqTtlExhausted,
/// `LookupResponse::decode` returned an error. Tracked via
/// [`DiscoveryStats::resp_decode_error`](crate::node::stats::DiscoveryStats).
RespDecodeError,
/// Response arrived for an originated request but the target's
/// public key was not in the identity cache, so the proof cannot
/// be verified. Tracked via
/// [`DiscoveryStats::resp_identity_miss`](crate::node::stats::DiscoveryStats).
RespIdentityMiss,
/// Response proof signature failed verification. Tracked via
/// [`DiscoveryStats::resp_proof_failed`](crate::node::stats::DiscoveryStats).
RespProofFailed,
/// Response could not be routed toward the origin: no reverse-path
/// entry for the `request_id` and no greedy tree route to the
/// origin. Tracked via
/// [`DiscoveryStats::resp_no_route`](crate::node::stats::DiscoveryStats).
RespNoRoute,
}
/// Noise-handshake rejection reasons.
///
/// Variants cover the state-machine cluster in
/// `handlers/handshake.rs` (msg1, msg2, and, on the next-side XX
/// handshake, msg3). `BadState` covers the bulk of the cluster: header
/// parse failures, crypto-step failures, identity not learned, index
/// allocator exhaustion, wire send failures, promotion failures, ACL
/// rejections, and admission-gate drops at max_peers / accept_connections.
/// `UnknownConnection` covers lookup-miss sites where an inbound message
/// arrived for a connection identifier we don't recognise (no pending
/// outbound for the receiver_idx in msg2; duplicate msg1 with no stored
/// msg2 to resend).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum HandshakeReject {
/// Handshake state-machine rejection: header parse failed, crypto step
/// failed, identity could not be learned, index allocator returned an
/// error, msg2/msg3 send failed, promote_connection returned an error,
/// ACL gate rejected the peer, or the admission gate fired
/// (max_peers / accept_connections). Tracked via
/// [`HandshakeStats::bad_state`](crate::node::stats::HandshakeStats).
BadState,
/// Inbound handshake message arrived but the connection identifier
/// has no matching entry: msg2 for an unknown receiver_idx (no
/// pending outbound handshake), duplicate msg1 with no stored msg2
/// to resend, msg3 for an unknown receiver_idx (no pending inbound,
/// no rekey-responder state). Tracked via
/// [`HandshakeStats::unknown_connection`](crate::node::stats::HandshakeStats).
UnknownConnection,
}
/// FSP session rejection reasons.
///
/// `UnknownSession` and `BadState` cover the session unknown-session
/// and state-machine cluster.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum SessionReject {
/// Inbound session-layer message arrived for a remote address that
/// has no corresponding `SessionEntry` — the session was never
/// established, was torn down, or the peer is talking to a stale
/// destination. Tracked via
/// [`SessionStats::unknown_session`](crate::node::stats::SessionStats).
/// Fires on encrypted data, SessionAck, SessionMsg3, SessionReceiverReport,
/// and PathMtuNotification when the lookup returns `None`.
UnknownSession,
/// Inbound session-layer message arrived for a `SessionEntry` whose
/// state is incompatible with the message type: encrypted data while
/// the session is not yet `Established`, a SessionAck when the
/// session is not `Initiating`, or a SessionMsg3 when the session
/// is not `AwaitingMsg3`. Tracked via
/// [`SessionStats::bad_state`](crate::node::stats::SessionStats).
BadState,
}
/// MMP rejection reasons.
///
/// The outbound sign-failure sites in `handlers/mmp.rs` use
/// `RejectReason::Tree(TreeReject::OutboundSignFailed)` rather than
/// `RejectReason::Mmp(...)` because the outcome they represent
/// (tree-state side effect failed) is tree-classified. This enum
/// covers the receive-path silent-rejection sites in the same file:
/// `SenderReport` / `ReceiverReport` decode and unknown-peer drops.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum MmpReject {
/// `SenderReport::decode` or `ReceiverReport::decode` returned an
/// error. Tracked via
/// [`MmpStats::decode_error`](crate::node::stats::MmpStats).
DecodeError,
/// Report arrived from a peer with no `ActivePeer` record on this
/// node. Tracked via
/// [`MmpStats::unknown_peer`](crate::node::stats::MmpStats).
UnknownPeer,
}
/// Forwarding-path rejection reasons.
///
/// Each variant corresponds to a silent-rejection path in
/// `src/node/handlers/forwarding.rs::handle_session_datagram`. Matching
/// `ForwardingStats` counters already track packets and bytes for each
/// outcome; `record_reject` mirrors the packet-count side of the bump
/// for parity with the other rejection clusters.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum ForwardingReject {
/// `SessionDatagramRef::decode` returned an error. Tracked via
/// [`ForwardingStats::decode_error_packets`](crate::node::stats::ForwardingStats).
DecodeError,
/// Transit datagram whose TTL would reach zero on this hop, so it is
/// dropped rather than forwarded. Charged for an arrival at TTL 1 as
/// well as an already-exhausted arrival at TTL 0. Never charged for a
/// datagram addressed to this node, whose delivery is not TTL-gated and
/// is decided ahead of this test.
/// Tracked via
/// [`ForwardingStats::ttl_exhausted_packets`](crate::node::stats::ForwardingStats).
TtlExhausted,
/// `find_next_hop` returned None for the destination — no route.
/// Tracked via
/// [`ForwardingStats::drop_no_route_packets`](crate::node::stats::ForwardingStats).
NoRoute,
/// Outgoing link rejected the encoded datagram as larger than the
/// link MTU. Tracked via
/// [`ForwardingStats::drop_mtu_exceeded_packets`](crate::node::stats::ForwardingStats).
MtuExceeded,
/// Send call returned a non-MTU error (transport send failure,
/// channel closed, etc.). Tracked via
/// [`ForwardingStats::drop_send_error_packets`](crate::node::stats::ForwardingStats).
SendError,
}
/// Transport-layer rejection reasons.
///
/// Currently covers the admission cap-hit path at the TCP and Tor
/// accept loops. Additional transport-side rejection variants
/// (framing errors, connection failures wired through to the node
/// stats path) can be added incrementally.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum TransportReject {
/// Inbound TCP or Tor onion connection rejected because the
/// per-transport inbound connection cap
/// (`max_inbound_connections`) was already reached. Tracked via
/// [`TransportStats::inbound_cap_exceeded`](crate::node::stats::TransportStats).
InboundCapExceeded,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reject_reason_is_copy_and_eq() {
fn requires_copy_eq_hash<T: Copy + Eq + std::hash::Hash>() {}
requires_copy_eq_hash::<RejectReason>();
requires_copy_eq_hash::<TreeReject>();
}
#[test]
fn tree_ancestry_invalid_round_trips_through_match() {
let r = RejectReason::Tree(TreeReject::AncestryInvalid);
let matched = matches!(r, RejectReason::Tree(TreeReject::AncestryInvalid));
assert!(matched);
}
#[test]
fn reject_reason_equality_is_structural() {
assert_eq!(
RejectReason::Tree(TreeReject::AncestryInvalid),
RejectReason::Tree(TreeReject::AncestryInvalid),
);
}
#[test]
fn bloom_reject_variants_round_trip() {
let variants = [
BloomReject::DecodeError,
BloomReject::Invalid,
BloomReject::NonV1,
BloomReject::UnknownPeer,
BloomReject::Stale,
BloomReject::FillExceeded,
];
for v in variants {
let r = RejectReason::Bloom(v);
assert!(matches!(r, RejectReason::Bloom(_)));
}
}
#[test]
fn discovery_reject_variants_round_trip() {
let variants = [
DiscoveryReject::ReqDecodeError,
DiscoveryReject::ReqDuplicate,
DiscoveryReject::ReqDedupCacheFull,
DiscoveryReject::ReqTtlExhausted,
DiscoveryReject::RespDecodeError,
DiscoveryReject::RespIdentityMiss,
DiscoveryReject::RespProofFailed,
];
for v in variants {
let r = RejectReason::Discovery(v);
assert!(matches!(r, RejectReason::Discovery(_)));
}
}
#[test]
fn forwarding_reject_variants_round_trip() {
let variants = [
ForwardingReject::DecodeError,
ForwardingReject::TtlExhausted,
ForwardingReject::NoRoute,
ForwardingReject::MtuExceeded,
ForwardingReject::SendError,
];
for v in variants {
let r = RejectReason::Forwarding(v);
assert!(matches!(r, RejectReason::Forwarding(_)));
}
}
#[test]
fn mmp_reject_variants_round_trip() {
let variants = [MmpReject::DecodeError, MmpReject::UnknownPeer];
for v in variants {
let r = RejectReason::Mmp(v);
assert!(matches!(r, RejectReason::Mmp(_)));
}
}
#[test]
fn transport_reject_inbound_cap_exceeded_round_trips() {
let r = RejectReason::Transport(TransportReject::InboundCapExceeded);
assert!(matches!(
r,
RejectReason::Transport(TransportReject::InboundCapExceeded)
));
}
}