1use serde::{Deserialize, Serialize};
48use std::fmt;
49use std::time::{SystemTime, UNIX_EPOCH};
50
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
56#[repr(u8)]
57pub enum RejectionReason {
58 KeyspaceSaturation = 0x01,
61
62 Subnet64Limit = 0x02,
65
66 Subnet48Limit = 0x03,
69
70 Subnet32Limit = 0x04,
73
74 AsnLimit = 0x05,
77
78 RegionLimit = 0x06,
81
82 CloseGroupFull = 0x07,
85
86 NodeIdCollision = 0x08,
89
90 RateLimited = 0x09,
93
94 Blocklisted = 0x0B,
97
98 Other = 0xFF,
101
102 GeoIpPolicy = 0x0C,
105}
106
107impl RejectionReason {
108 #[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 #[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 #[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 #[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 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
201pub struct SaturationInfo {
202 pub current_saturation: f64,
205
206 pub average_saturation: f64,
208
209 pub sparse_regions: Vec<KeyspaceRegion>,
212
213 pub local_node_count: u32,
215
216 pub region_capacity: u32,
218}
219
220impl SaturationInfo {
221 #[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 pub fn add_sparse_region(&mut self, region: KeyspaceRegion) {
235 self.sparse_regions.push(region);
236 }
237
238 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#[derive(Debug, Clone, Serialize, Deserialize)]
248pub struct KeyspaceRegion {
249 pub prefix: Vec<u8>,
252
253 pub prefix_len: u8,
255
256 pub saturation: f64,
258
259 pub estimated_nodes: u32,
261}
262
263impl KeyspaceRegion {
264 #[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 pub fn with_estimated_nodes(mut self, count: u32) -> Self {
277 self.estimated_nodes = count;
278 self
279 }
280
281 #[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 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
314pub struct TargetRegion {
315 pub region: KeyspaceRegion,
317
318 pub confidence: f64,
321
322 pub reason: String,
324}
325
326impl TargetRegion {
327 #[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#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct RejectionInfo {
344 pub reason: RejectionReason,
346
347 pub timestamp: u64,
349
350 pub saturation_info: Option<SaturationInfo>,
352
353 pub suggested_target: Option<TargetRegion>,
355
356 pub retry_after_secs: u32,
358
359 pub regeneration_recommended: bool,
361
362 pub message: Option<String>,
364
365 pub rejecting_node: Option<String>,
367}
368
369impl RejectionInfo {
370 #[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 #[must_use]
392 pub fn with_saturation_info(mut self, info: SaturationInfo) -> Self {
393 self.saturation_info = Some(info);
394 self
395 }
396
397 #[must_use]
399 pub fn with_suggested_target(mut self, target: TargetRegion) -> Self {
400 self.suggested_target = Some(target);
401 self
402 }
403
404 #[must_use]
406 pub fn with_retry_after(mut self, secs: u32) -> Self {
407 self.retry_after_secs = secs;
408 self
409 }
410
411 #[must_use]
413 pub fn with_regeneration_recommended(mut self, recommended: bool) -> Self {
414 self.regeneration_recommended = recommended;
415 self
416 }
417
418 #[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 #[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 #[must_use]
437 pub fn should_regenerate(&self) -> bool {
438 self.regeneration_recommended && self.reason.regeneration_may_help()
439 }
440
441 #[must_use]
443 pub fn is_blocking(&self) -> bool {
444 self.reason.is_blocking()
445 }
446
447 #[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#[derive(Debug, Clone, Default, Serialize, Deserialize)]
472pub struct RejectionHistory {
473 rejections: Vec<RejectionInfo>,
475
476 max_entries: usize,
478}
479
480impl RejectionHistory {
481 #[must_use]
483 pub fn new() -> Self {
484 Self {
485 rejections: Vec::new(),
486 max_entries: 100,
487 }
488 }
489
490 #[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 pub fn record(&mut self, info: RejectionInfo) {
501 self.rejections.push(info);
502
503 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 #[must_use]
512 pub fn all(&self) -> &[RejectionInfo] {
513 &self.rejections
514 }
515
516 #[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 #[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 #[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 #[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 pub fn clear(&mut self) {
563 self.rejections.clear();
564 }
565
566 #[must_use]
568 pub fn len(&self) -> usize {
569 self.rejections.len()
570 }
571
572 #[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 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 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 let region = KeyspaceRegion::new(vec![0x80], 1, 0.5);
654
655 let in_region = super::super::NodeId([0xFF; 32]);
657 assert!(region.contains(&in_region));
658
659 let not_in_region = super::super::NodeId([0x00; 32]);
661 assert!(!region.contains(¬_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 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 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}