Skip to main content

saorsa_core/identity/
rejection.rs

1// Copyright (c) 2025 Saorsa Labs Limited
2
3// This file is part of the Saorsa P2P network.
4
5// Licensed under the AGPL-3.0 license:
6// <https://www.gnu.org/licenses/agpl-3.0.html>
7
8// This program is distributed in the hope that it will be useful,
9// but WITHOUT ANY WARRANTY; without even the implied warranty of
10// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11// GNU Affero General Public License for more details.
12
13// You should have received a copy of the GNU Affero General Public License
14// along with this program. If not, see <https://www.gnu.org/licenses/>.
15
16// Copyright 2024 P2P Foundation
17// SPDX-License-Identifier: AGPL-3.0-or-later
18
19//! Network rejection handling for identity restart system.
20//!
21//! This module provides structured rejection reasons and information that enables
22//! nodes to understand why they were rejected from joining the DHT network and
23//! make intelligent decisions about identity regeneration.
24//!
25//! # Rejection Reasons
26//!
27//! Nodes can be rejected for various reasons:
28//! - **Keyspace Saturation**: The XOR region is too crowded
29//! - **Diversity Limits**: Subnet (/64, /48, /32) or ASN limits exceeded
30//! - **Close Group Full**: The target close group is at capacity
31//! - **Node ID Collision**: The generated ID conflicts with an existing node
32//!
33//! # Example
34//!
35//! ```ignore
36//! use saorsa_core::identity::rejection::{RejectionInfo, RejectionReason};
37//!
38//! let rejection = RejectionInfo::new(RejectionReason::KeyspaceSaturation)
39//!     .with_regeneration_recommended(true)
40//!     .with_retry_after(60);
41//!
42//! if rejection.regeneration_recommended {
43//!     // Trigger identity regeneration
44//! }
45//! ```
46
47use serde::{Deserialize, Serialize};
48use std::fmt;
49use std::time::{SystemTime, UNIX_EPOCH};
50
51/// Reason why a node was rejected from joining the network.
52///
53/// These reason codes are wire-format stable and represented as a single byte
54/// for efficient network transmission.
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[repr(u8)]
57pub enum RejectionReason {
58    /// XOR keyspace region is oversaturated with nodes.
59    /// Regenerating identity may help by placing node in less crowded region.
60    KeyspaceSaturation = 0x01,
61
62    /// Too many nodes from the same /64 IPv6 subnet.
63    /// This is a diversity constraint - regeneration won't help unless IP changes.
64    Subnet64Limit = 0x02,
65
66    /// Too many nodes from the same /48 IPv6 subnet.
67    /// This is a diversity constraint - regeneration won't help unless IP changes.
68    Subnet48Limit = 0x03,
69
70    /// Too many nodes from the same /32 IPv4 subnet.
71    /// This is a diversity constraint - regeneration won't help unless IP changes.
72    Subnet32Limit = 0x04,
73
74    /// Too many nodes from the same Autonomous System Number (ASN).
75    /// This is a diversity constraint - regeneration won't help.
76    AsnLimit = 0x05,
77
78    /// Too many nodes from the same geographic region.
79    /// This is a diversity constraint - regeneration won't help.
80    RegionLimit = 0x06,
81
82    /// The target close group (K=8) is full.
83    /// Regenerating to target a different keyspace region may help.
84    CloseGroupFull = 0x07,
85
86    /// The generated NodeId collides with an existing node.
87    /// Regenerating identity will resolve this (extremely rare).
88    NodeIdCollision = 0x08,
89
90    /// Join rate limit exceeded - too many join attempts.
91    /// Should wait before retrying, regeneration not needed.
92    RateLimited = 0x09,
93
94    /// Node has been blocklisted.
95    /// Regeneration will not help - the underlying cause must be addressed.
96    Blocklisted = 0x0B,
97
98    /// Generic rejection for unspecified reasons.
99    /// May or may not benefit from regeneration.
100    Other = 0xFF,
101
102    /// Rejected due to GeoIP policy (e.g., hosting provider, VPN, or restricted region).
103    /// Regeneration won't help unless IP changes.
104    GeoIpPolicy = 0x0C,
105}
106
107impl RejectionReason {
108    /// Returns whether identity regeneration could potentially resolve this rejection.
109    ///
110    /// Some rejections (like diversity limits based on IP/ASN) cannot be resolved
111    /// by regenerating identity since the constraints are based on network properties
112    /// that remain constant.
113    #[must_use]
114    pub fn regeneration_may_help(&self) -> bool {
115        matches!(
116            self,
117            Self::KeyspaceSaturation | Self::CloseGroupFull | Self::NodeIdCollision | Self::Other
118        )
119    }
120
121    /// Returns whether this is a diversity-based constraint.
122    ///
123    /// Diversity constraints are based on network properties (IP, ASN, region)
124    /// that cannot be changed by regenerating identity.
125    #[must_use]
126    pub fn is_diversity_constraint(&self) -> bool {
127        matches!(
128            self,
129            Self::Subnet64Limit
130                | Self::Subnet48Limit
131                | Self::Subnet32Limit
132                | Self::AsnLimit
133                | Self::RegionLimit
134        )
135    }
136
137    /// Returns whether this rejection should block further regeneration attempts.
138    #[must_use]
139    pub fn is_blocking(&self) -> bool {
140        matches!(
141            self,
142            Self::Blocklisted
143                | Self::Subnet64Limit
144                | Self::Subnet48Limit
145                | Self::Subnet32Limit
146                | Self::AsnLimit
147                | Self::GeoIpPolicy
148        )
149    }
150
151    /// Convert from wire format byte.
152    #[must_use]
153    pub fn from_byte(byte: u8) -> Self {
154        match byte {
155            0x01 => Self::KeyspaceSaturation,
156            0x02 => Self::Subnet64Limit,
157            0x03 => Self::Subnet48Limit,
158            0x04 => Self::Subnet32Limit,
159            0x05 => Self::AsnLimit,
160            0x06 => Self::RegionLimit,
161            0x07 => Self::CloseGroupFull,
162            0x08 => Self::NodeIdCollision,
163            0x09 => Self::RateLimited,
164            0x0B => Self::Blocklisted,
165            0x0C => Self::GeoIpPolicy,
166            _ => Self::Other,
167        }
168    }
169
170    /// Convert to wire format byte.
171    #[must_use]
172    pub fn to_byte(&self) -> u8 {
173        *self as u8
174    }
175}
176
177impl fmt::Display for RejectionReason {
178    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
179        match self {
180            Self::KeyspaceSaturation => write!(f, "keyspace region is oversaturated"),
181            Self::Subnet64Limit => write!(f, "too many nodes from /64 subnet"),
182            Self::Subnet48Limit => write!(f, "too many nodes from /48 subnet"),
183            Self::Subnet32Limit => write!(f, "too many nodes from /32 subnet"),
184            Self::AsnLimit => write!(f, "too many nodes from ASN"),
185            Self::RegionLimit => write!(f, "too many nodes from geographic region"),
186            Self::CloseGroupFull => write!(f, "close group at capacity"),
187            Self::NodeIdCollision => write!(f, "node ID collision"),
188            Self::RateLimited => write!(f, "join rate limit exceeded"),
189            Self::Blocklisted => write!(f, "node is blocklisted"),
190            Self::GeoIpPolicy => write!(f, "rejected by GeoIP policy"),
191            Self::Other => write!(f, "unspecified rejection"),
192        }
193    }
194}
195
196/// Information about keyspace saturation levels.
197///
198/// This provides detail about how saturated different regions of the
199/// XOR keyspace are, helping the node target less crowded areas.
200#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct SaturationInfo {
202    /// Current region's saturation level (0.0 to 1.0).
203    /// 1.0 means completely full.
204    pub current_saturation: f64,
205
206    /// Average saturation across all regions.
207    pub average_saturation: f64,
208
209    /// Least saturated regions (XOR prefix bits).
210    /// These are suggested targets for regeneration.
211    pub sparse_regions: Vec<KeyspaceRegion>,
212
213    /// Number of nodes in the current close group vicinity.
214    pub local_node_count: u32,
215
216    /// Maximum nodes allowed in region before rejection.
217    pub region_capacity: u32,
218}
219
220impl SaturationInfo {
221    /// Create new saturation info.
222    #[must_use]
223    pub fn new(current: f64, average: f64) -> Self {
224        Self {
225            current_saturation: current.clamp(0.0, 1.0),
226            average_saturation: average.clamp(0.0, 1.0),
227            sparse_regions: Vec::new(),
228            local_node_count: 0,
229            region_capacity: 0,
230        }
231    }
232
233    /// Add a sparse region suggestion.
234    pub fn add_sparse_region(&mut self, region: KeyspaceRegion) {
235        self.sparse_regions.push(region);
236    }
237
238    /// Set local node count.
239    pub fn with_local_count(mut self, count: u32, capacity: u32) -> Self {
240        self.local_node_count = count;
241        self.region_capacity = capacity;
242        self
243    }
244}
245
246/// A region in the XOR keyspace identified by prefix bits.
247#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct KeyspaceRegion {
249    /// The prefix bits that identify this region.
250    /// Stored as big-endian bytes.
251    pub prefix: Vec<u8>,
252
253    /// Number of significant bits in the prefix.
254    pub prefix_len: u8,
255
256    /// Saturation level of this region (0.0 to 1.0).
257    pub saturation: f64,
258
259    /// Estimated node count in this region.
260    pub estimated_nodes: u32,
261}
262
263impl KeyspaceRegion {
264    /// Create a new keyspace region.
265    #[must_use]
266    pub fn new(prefix: Vec<u8>, prefix_len: u8, saturation: f64) -> Self {
267        Self {
268            prefix,
269            prefix_len,
270            saturation: saturation.clamp(0.0, 1.0),
271            estimated_nodes: 0,
272        }
273    }
274
275    /// Set estimated node count.
276    pub fn with_estimated_nodes(mut self, count: u32) -> Self {
277        self.estimated_nodes = count;
278        self
279    }
280
281    /// Check if a NodeId falls within this region.
282    #[must_use]
283    pub fn contains(&self, node_id: &super::NodeId) -> bool {
284        let node_bytes = node_id.to_bytes();
285        let full_bytes = self.prefix_len as usize / 8;
286        let remaining_bits = self.prefix_len as usize % 8;
287
288        // Check full bytes using zip to iterate both slices together
289        let prefix_slice = &self.prefix[..full_bytes.min(self.prefix.len())];
290        let node_slice = &node_bytes[..full_bytes.min(node_bytes.len())];
291        for (p, n) in prefix_slice.iter().zip(node_slice.iter()) {
292            if p != n {
293                return false;
294            }
295        }
296
297        // Check remaining bits
298        if remaining_bits > 0 && full_bytes < self.prefix.len() && full_bytes < node_bytes.len() {
299            let mask = 0xFF << (8 - remaining_bits);
300            if (self.prefix[full_bytes] & mask) != (node_bytes[full_bytes] & mask) {
301                return false;
302            }
303        }
304
305        true
306    }
307}
308
309/// Suggested target region for identity regeneration.
310///
311/// When a node is rejected, the network may suggest alternative regions
312/// where the node would be more likely to be accepted.
313#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TargetRegion {
315    /// The suggested keyspace region.
316    pub region: KeyspaceRegion,
317
318    /// How strongly this region is recommended (0.0 to 1.0).
319    /// Higher values indicate better fit.
320    pub confidence: f64,
321
322    /// Human-readable reason for this suggestion.
323    pub reason: String,
324}
325
326impl TargetRegion {
327    /// Create a new target region suggestion.
328    #[must_use]
329    pub fn new(region: KeyspaceRegion, confidence: f64, reason: impl Into<String>) -> Self {
330        Self {
331            region,
332            confidence: confidence.clamp(0.0, 1.0),
333            reason: reason.into(),
334        }
335    }
336}
337
338/// Complete rejection information returned by the network.
339///
340/// This structure contains all information needed for a node to understand
341/// why it was rejected and make an informed decision about regeneration.
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RejectionInfo {
344    /// The primary reason for rejection.
345    pub reason: RejectionReason,
346
347    /// Unix timestamp when rejection occurred.
348    pub timestamp: u64,
349
350    /// Detailed saturation information (if available).
351    pub saturation_info: Option<SaturationInfo>,
352
353    /// Suggested target region for regeneration (if available).
354    pub suggested_target: Option<TargetRegion>,
355
356    /// Recommended wait time before retrying (seconds).
357    pub retry_after_secs: u32,
358
359    /// Whether the network recommends identity regeneration.
360    pub regeneration_recommended: bool,
361
362    /// Additional context message.
363    pub message: Option<String>,
364
365    /// ID of the rejecting node (for debugging).
366    pub rejecting_node: Option<String>,
367}
368
369impl RejectionInfo {
370    /// Create new rejection info with the given reason.
371    #[must_use]
372    pub fn new(reason: RejectionReason) -> Self {
373        let timestamp = SystemTime::now()
374            .duration_since(UNIX_EPOCH)
375            .map(|d| d.as_secs())
376            .unwrap_or(0);
377
378        Self {
379            reason,
380            timestamp,
381            saturation_info: None,
382            suggested_target: None,
383            retry_after_secs: 0,
384            regeneration_recommended: reason.regeneration_may_help(),
385            message: None,
386            rejecting_node: None,
387        }
388    }
389
390    /// Set saturation information.
391    #[must_use]
392    pub fn with_saturation_info(mut self, info: SaturationInfo) -> Self {
393        self.saturation_info = Some(info);
394        self
395    }
396
397    /// Set suggested target region.
398    #[must_use]
399    pub fn with_suggested_target(mut self, target: TargetRegion) -> Self {
400        self.suggested_target = Some(target);
401        self
402    }
403
404    /// Set retry delay.
405    #[must_use]
406    pub fn with_retry_after(mut self, secs: u32) -> Self {
407        self.retry_after_secs = secs;
408        self
409    }
410
411    /// Override regeneration recommendation.
412    #[must_use]
413    pub fn with_regeneration_recommended(mut self, recommended: bool) -> Self {
414        self.regeneration_recommended = recommended;
415        self
416    }
417
418    /// Set additional context message.
419    #[must_use]
420    pub fn with_message(mut self, message: impl Into<String>) -> Self {
421        self.message = Some(message.into());
422        self
423    }
424
425    /// Set rejecting node ID.
426    #[must_use]
427    pub fn with_rejecting_node(mut self, node_id: impl Into<String>) -> Self {
428        self.rejecting_node = Some(node_id.into());
429        self
430    }
431
432    /// Check if this rejection should trigger regeneration.
433    ///
434    /// Returns true if regeneration is recommended AND the reason
435    /// indicates regeneration could help.
436    #[must_use]
437    pub fn should_regenerate(&self) -> bool {
438        self.regeneration_recommended && self.reason.regeneration_may_help()
439    }
440
441    /// Check if this rejection should block further attempts.
442    #[must_use]
443    pub fn is_blocking(&self) -> bool {
444        self.reason.is_blocking()
445    }
446
447    /// Get the suggested wait duration before retry.
448    #[must_use]
449    pub fn retry_delay(&self) -> std::time::Duration {
450        std::time::Duration::from_secs(u64::from(self.retry_after_secs))
451    }
452}
453
454impl fmt::Display for RejectionInfo {
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        write!(f, "Rejected: {}", self.reason)?;
457        if let Some(msg) = &self.message {
458            write!(f, " ({})", msg)?;
459        }
460        if self.regeneration_recommended {
461            write!(f, " [regeneration recommended]")?;
462        }
463        if self.retry_after_secs > 0 {
464            write!(f, " [retry after {}s]", self.retry_after_secs)?;
465        }
466        Ok(())
467    }
468}
469
470/// History of rejections for a node, used to inform regeneration decisions.
471#[derive(Debug, Clone, Default, Serialize, Deserialize)]
472pub struct RejectionHistory {
473    /// List of past rejections with timestamps.
474    rejections: Vec<RejectionInfo>,
475
476    /// Maximum number of rejections to keep.
477    max_entries: usize,
478}
479
480impl RejectionHistory {
481    /// Create a new rejection history with default capacity.
482    #[must_use]
483    pub fn new() -> Self {
484        Self {
485            rejections: Vec::new(),
486            max_entries: 100,
487        }
488    }
489
490    /// Create with custom capacity.
491    #[must_use]
492    pub fn with_capacity(max_entries: usize) -> Self {
493        Self {
494            rejections: Vec::with_capacity(max_entries.min(1000)),
495            max_entries: max_entries.min(1000),
496        }
497    }
498
499    /// Record a new rejection.
500    pub fn record(&mut self, info: RejectionInfo) {
501        self.rejections.push(info);
502
503        // Trim old entries if over capacity
504        if self.rejections.len() > self.max_entries {
505            let excess = self.rejections.len() - self.max_entries;
506            self.rejections.drain(0..excess);
507        }
508    }
509
510    /// Get all rejections.
511    #[must_use]
512    pub fn all(&self) -> &[RejectionInfo] {
513        &self.rejections
514    }
515
516    /// Get recent rejections within the given duration.
517    #[must_use]
518    pub fn recent(&self, duration: std::time::Duration) -> Vec<&RejectionInfo> {
519        let cutoff = SystemTime::now()
520            .duration_since(UNIX_EPOCH)
521            .map(|d| d.as_secs().saturating_sub(duration.as_secs()))
522            .unwrap_or(0);
523
524        self.rejections
525            .iter()
526            .filter(|r| r.timestamp >= cutoff)
527            .collect()
528    }
529
530    /// Count rejections by reason.
531    #[must_use]
532    pub fn count_by_reason(&self, reason: RejectionReason) -> usize {
533        self.rejections
534            .iter()
535            .filter(|r| r.reason == reason)
536            .count()
537    }
538
539    /// Check if there are too many recent rejections (potential loop detection).
540    #[must_use]
541    pub fn is_in_rejection_loop(&self, threshold: usize, window: std::time::Duration) -> bool {
542        self.recent(window).len() >= threshold
543    }
544
545    /// Get the most common rejection reason.
546    #[must_use]
547    pub fn most_common_reason(&self) -> Option<RejectionReason> {
548        use std::collections::HashMap;
549
550        let mut counts: HashMap<RejectionReason, usize> = HashMap::new();
551        for rejection in &self.rejections {
552            *counts.entry(rejection.reason).or_insert(0) += 1;
553        }
554
555        counts
556            .into_iter()
557            .max_by_key(|(_, count)| *count)
558            .map(|(reason, _)| reason)
559    }
560
561    /// Clear all rejection history.
562    pub fn clear(&mut self) {
563        self.rejections.clear();
564    }
565
566    /// Get number of recorded rejections.
567    #[must_use]
568    pub fn len(&self) -> usize {
569        self.rejections.len()
570    }
571
572    /// Check if history is empty.
573    #[must_use]
574    pub fn is_empty(&self) -> bool {
575        self.rejections.is_empty()
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    #[test]
584    fn test_rejection_reason_regeneration_help() {
585        assert!(RejectionReason::KeyspaceSaturation.regeneration_may_help());
586        assert!(RejectionReason::CloseGroupFull.regeneration_may_help());
587        assert!(RejectionReason::NodeIdCollision.regeneration_may_help());
588
589        assert!(!RejectionReason::Subnet64Limit.regeneration_may_help());
590        assert!(!RejectionReason::AsnLimit.regeneration_may_help());
591        assert!(!RejectionReason::Blocklisted.regeneration_may_help());
592    }
593
594    #[test]
595    fn test_rejection_reason_byte_conversion() {
596        for reason in [
597            RejectionReason::KeyspaceSaturation,
598            RejectionReason::Subnet64Limit,
599            RejectionReason::CloseGroupFull,
600            RejectionReason::Blocklisted,
601        ] {
602            let byte = reason.to_byte();
603            let recovered = RejectionReason::from_byte(byte);
604            assert_eq!(reason, recovered);
605        }
606
607        // Unknown bytes should become Other
608        assert_eq!(RejectionReason::from_byte(0xFE), RejectionReason::Other);
609    }
610
611    #[test]
612    fn test_rejection_info_builder() {
613        let info = RejectionInfo::new(RejectionReason::KeyspaceSaturation)
614            .with_retry_after(60)
615            .with_regeneration_recommended(true)
616            .with_message("Test rejection");
617
618        assert_eq!(info.reason, RejectionReason::KeyspaceSaturation);
619        assert_eq!(info.retry_after_secs, 60);
620        assert!(info.regeneration_recommended);
621        assert_eq!(info.message, Some("Test rejection".to_string()));
622    }
623
624    #[test]
625    fn test_rejection_info_should_regenerate() {
626        let info1 = RejectionInfo::new(RejectionReason::KeyspaceSaturation)
627            .with_regeneration_recommended(true);
628        assert!(info1.should_regenerate());
629
630        let info2 =
631            RejectionInfo::new(RejectionReason::Subnet64Limit).with_regeneration_recommended(true);
632        // Subnet limit can't be helped by regeneration
633        assert!(!info2.should_regenerate());
634
635        let info3 = RejectionInfo::new(RejectionReason::KeyspaceSaturation)
636            .with_regeneration_recommended(false);
637        assert!(!info3.should_regenerate());
638    }
639
640    #[test]
641    fn test_saturation_info() {
642        let mut saturation = SaturationInfo::new(0.85, 0.60);
643        saturation.add_sparse_region(KeyspaceRegion::new(vec![0x00], 4, 0.20));
644
645        assert!((saturation.current_saturation - 0.85).abs() < f64::EPSILON);
646        assert!((saturation.average_saturation - 0.60).abs() < f64::EPSILON);
647        assert_eq!(saturation.sparse_regions.len(), 1);
648    }
649
650    #[test]
651    fn test_keyspace_region_contains() {
652        // Region with prefix 0x80 (first bit = 1) and 1 bit length
653        let region = KeyspaceRegion::new(vec![0x80], 1, 0.5);
654
655        // NodeId starting with 1... should be in region
656        let in_region = super::super::NodeId([0xFF; 32]);
657        assert!(region.contains(&in_region));
658
659        // NodeId starting with 0... should NOT be in region
660        let not_in_region = super::super::NodeId([0x00; 32]);
661        assert!(!region.contains(&not_in_region));
662    }
663
664    #[test]
665    fn test_rejection_history() {
666        let mut history = RejectionHistory::with_capacity(10);
667
668        for _ in 0..5 {
669            history.record(RejectionInfo::new(RejectionReason::KeyspaceSaturation));
670        }
671        for _ in 0..3 {
672            history.record(RejectionInfo::new(RejectionReason::CloseGroupFull));
673        }
674
675        assert_eq!(history.len(), 8);
676        assert_eq!(
677            history.count_by_reason(RejectionReason::KeyspaceSaturation),
678            5
679        );
680        assert_eq!(history.count_by_reason(RejectionReason::CloseGroupFull), 3);
681        assert_eq!(
682            history.most_common_reason(),
683            Some(RejectionReason::KeyspaceSaturation)
684        );
685    }
686
687    #[test]
688    fn test_rejection_history_capacity() {
689        let mut history = RejectionHistory::with_capacity(5);
690
691        for _ in 0..10 {
692            history.record(RejectionInfo::new(RejectionReason::KeyspaceSaturation));
693        }
694
695        // Should only keep 5 entries
696        assert_eq!(history.len(), 5);
697    }
698
699    #[test]
700    fn test_rejection_loop_detection() {
701        let mut history = RejectionHistory::new();
702
703        for _ in 0..5 {
704            history.record(RejectionInfo::new(RejectionReason::KeyspaceSaturation));
705        }
706
707        // 5 rejections within any window should trigger loop detection at threshold 5
708        assert!(history.is_in_rejection_loop(5, std::time::Duration::from_secs(3600)));
709        assert!(!history.is_in_rejection_loop(10, std::time::Duration::from_secs(3600)));
710    }
711}