1use parking_lot::Mutex;
12use sha2::{Digest, Sha256};
13use std::collections::{HashMap, HashSet};
14use std::sync::Arc;
15use std::time::Instant;
16
17const MAX_PAIR_ATTEMPTS: u32 = 5;
19const PAIR_LOCKOUT_SECS: u64 = 300; const MAX_TRACKED_CLIENTS: usize = 10_000;
23const FAILED_ATTEMPT_RETENTION_SECS: u64 = 900; const FAILED_ATTEMPT_SWEEP_INTERVAL_SECS: u64 = 300; #[derive(Debug, Clone, Copy)]
30struct FailedAttemptState {
31 count: u32,
32 lockout_until: Option<Instant>,
33 last_attempt: Instant,
34}
35
36#[derive(Debug, Clone, PartialEq, Eq)]
38pub enum GeneratePairingCodeError {
39 Pending,
41 PairingDisabled,
43}
44
45#[derive(Debug, Clone)]
52pub struct PairingGuard {
53 require_pairing: bool,
55 pairing_code: Arc<Mutex<Option<String>>>,
57 paired_tokens: Arc<Mutex<HashSet<String>>>,
59 failed_attempts: Arc<Mutex<(HashMap<String, FailedAttemptState>, Instant)>>,
61}
62
63impl PairingGuard {
64 pub fn new(require_pairing: bool, existing_tokens: &[String]) -> Self {
75 let tokens: HashSet<String> = existing_tokens
76 .iter()
77 .map(|t| {
78 if is_token_hash(t) {
79 t.clone()
80 } else {
81 hash_token(t)
82 }
83 })
84 .collect();
85 let code = if require_pairing && tokens.is_empty() {
86 Some(generate_code())
87 } else {
88 None
89 };
90 Self {
91 require_pairing,
92 pairing_code: Arc::new(Mutex::new(code)),
93 paired_tokens: Arc::new(Mutex::new(tokens)),
94 failed_attempts: Arc::new(Mutex::new((HashMap::new(), Instant::now()))),
95 }
96 }
97
98 pub fn pairing_code(&self) -> Option<String> {
100 self.pairing_code.lock().clone()
101 }
102
103 pub fn require_pairing(&self) -> bool {
105 self.require_pairing
106 }
107
108 fn try_pair_blocking(&self, code: &str, client_id: &str) -> Result<Option<String>, u64> {
109 let client_id = normalize_client_key(client_id);
110 let now = Instant::now();
111
112 {
114 let mut guard = self.failed_attempts.lock();
115 let (ref mut map, ref mut last_sweep) = *guard;
116
117 if now.duration_since(*last_sweep).as_secs() >= FAILED_ATTEMPT_SWEEP_INTERVAL_SECS {
119 prune_failed_attempts(map, now);
120 *last_sweep = now;
121 }
122
123 if let Some(state) = map.get(&client_id)
125 && let Some(until) = state.lockout_until
126 {
127 if now < until {
128 let remaining = (until - now).as_secs();
129 return Err(remaining.max(1));
130 }
131 map.remove(&client_id);
133 }
134 }
135
136 {
137 let mut pairing_code = self.pairing_code.lock();
138 if let Some(ref expected) = *pairing_code
139 && constant_time_eq(code.trim(), expected.trim())
140 {
141 {
143 let mut guard = self.failed_attempts.lock();
144 guard.0.remove(&client_id);
145 }
146 let token = generate_token();
147 let mut tokens = self.paired_tokens.lock();
148 tokens.insert(hash_token(&token));
149
150 *pairing_code = None;
152
153 return Ok(Some(token));
154 }
155 }
156
157 {
159 let mut guard = self.failed_attempts.lock();
160 let (ref mut map, _) = *guard;
161
162 if map.len() >= MAX_TRACKED_CLIENTS {
164 prune_failed_attempts(map, now);
165 }
166 if map.len() >= MAX_TRACKED_CLIENTS {
167 if let Some(lru_key) = map
169 .iter()
170 .min_by_key(|(_, s)| s.last_attempt)
171 .map(|(k, _)| k.clone())
172 {
173 map.remove(&lru_key);
174 }
175 }
176
177 let entry = map.entry(client_id).or_insert(FailedAttemptState {
178 count: 0,
179 lockout_until: None,
180 last_attempt: now,
181 });
182
183 entry.last_attempt = now;
184 entry.count += 1;
185
186 if entry.count >= MAX_PAIR_ATTEMPTS {
187 entry.lockout_until = Some(now + std::time::Duration::from_secs(PAIR_LOCKOUT_SECS));
188 }
189 }
190
191 Ok(None)
192 }
193
194 pub async fn try_pair(&self, code: &str, client_id: &str) -> Result<Option<String>, u64> {
198 let this = self.clone();
199 let code = code.to_string();
200 let client_id = client_id.to_string();
201 let handle = tokio::task::spawn_blocking(move || this.try_pair_blocking(&code, &client_id));
203
204 handle
205 .await
206 .expect("failed to spawn blocking task this should not happen")
207 }
208
209 pub fn is_authenticated(&self, token: &str) -> bool {
211 if !self.require_pairing {
212 return true;
213 }
214 let hashed = hash_token(token);
215 let tokens = self.paired_tokens.lock();
216 tokens.contains(&hashed)
217 }
218
219 pub fn is_paired(&self) -> bool {
221 let tokens = self.paired_tokens.lock();
222 !tokens.is_empty()
223 }
224
225 pub fn tokens(&self) -> Vec<String> {
227 let tokens = self.paired_tokens.lock();
228 tokens.iter().cloned().collect()
229 }
230
231 pub fn revoke_token(&self, token: &str) -> bool {
241 let hashed = hash_token(token);
242 let mut tokens = self.paired_tokens.lock();
243 tokens.remove(&hashed)
244 }
245
246 pub fn revoke_token_hash(&self, token_hash: &str) -> bool {
248 let mut tokens = self.paired_tokens.lock();
249 tokens.remove(token_hash)
250 }
251
252 pub fn revoke_all_tokens(&self) -> usize {
259 let mut tokens = self.paired_tokens.lock();
260 let count = tokens.len();
261 tokens.clear();
262 count
263 }
264
265 pub fn generate_new_pairing_code(&self) -> Option<String> {
270 if !self.require_pairing {
271 return None;
272 }
273 let new_code = generate_code();
274 *self.pairing_code.lock() = Some(new_code.clone());
275 Some(new_code)
276 }
277
278 pub fn generate_pairing_code_if_vacant(&self) -> Result<String, GeneratePairingCodeError> {
286 if !self.require_pairing {
287 return Err(GeneratePairingCodeError::PairingDisabled);
288 }
289 let mut slot = self.pairing_code.lock();
290 if slot.is_some() {
291 return Err(GeneratePairingCodeError::Pending);
292 }
293 let new_code = generate_code();
294 *slot = Some(new_code.clone());
295 Ok(new_code)
296 }
297
298 pub fn token_hash(token: &str) -> String {
300 use sha2::{Digest, Sha256};
301 hex::encode(Sha256::digest(token.as_bytes()))
302 }
303
304 pub fn authenticate_and_hash(&self, token: &str) -> Option<String> {
306 if self.is_authenticated(token) {
307 Some(Self::token_hash(token))
308 } else {
309 None
310 }
311 }
312}
313
314fn normalize_client_key(key: &str) -> String {
316 let trimmed = key.trim();
317 if trimmed.is_empty() {
318 "unknown".to_string()
319 } else {
320 trimmed.to_string()
321 }
322}
323
324fn prune_failed_attempts(map: &mut HashMap<String, FailedAttemptState>, now: Instant) {
326 map.retain(|_, state| {
327 now.duration_since(state.last_attempt).as_secs() < FAILED_ATTEMPT_RETENTION_SECS
328 });
329}
330
331fn generate_code() -> String {
333 const UPPER_BOUND: u32 = 1_000_000;
342 const REJECT_THRESHOLD: u32 = (u32::MAX / UPPER_BOUND) * UPPER_BOUND;
343
344 loop {
345 let uuid = uuid::Uuid::new_v4();
346 let bytes = uuid.as_bytes();
347 let raw = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
348
349 if raw < REJECT_THRESHOLD {
350 return format!("{:06}", raw % UPPER_BOUND);
351 }
352 }
353}
354
355fn generate_token() -> String {
362 let bytes: [u8; 32] = rand::random();
363 format!("zc_{}", hex::encode(bytes))
364}
365
366fn hash_token(token: &str) -> String {
368 format!("{:x}", Sha256::digest(token.as_bytes()))
369}
370
371fn is_token_hash(value: &str) -> bool {
374 value.len() == 64 && value.chars().all(|c| c.is_ascii_hexdigit())
375}
376
377#[allow(clippy::needless_bitwise_bool)]
394pub fn constant_time_eq(a: &str, b: &str) -> bool {
395 let a = a.as_bytes();
396 let b = b.as_bytes();
397
398 let len_diff = a.len() ^ b.len();
400
401 let max_len = a.len().max(b.len());
404 let mut byte_diff = 0u8;
405 for i in 0..max_len {
406 let x = *a.get(i).unwrap_or(&0);
407 let y = *b.get(i).unwrap_or(&0);
408 byte_diff |= x ^ y;
409 }
410 (len_diff == 0) & (byte_diff == 0)
413}
414
415pub fn is_public_bind(host: &str) -> bool {
417 !matches!(
418 host,
419 "127.0.0.1" | "localhost" | "::1" | "[::1]" | "0:0:0:0:0:0:0:1"
420 )
421}
422
423#[cfg(test)]
424mod tests {
425 use super::*;
426 use tokio::test;
427
428 #[test]
431 async fn new_guard_generates_code_when_no_tokens() {
432 let guard = PairingGuard::new(true, &[]);
433 assert!(guard.pairing_code().is_some());
434 assert!(!guard.is_paired());
435 }
436
437 #[test]
438 async fn new_guard_no_code_when_tokens_exist() {
439 let guard = PairingGuard::new(true, &["zc_existing".into()]);
440 assert!(guard.pairing_code().is_none());
441 assert!(guard.is_paired());
442 }
443
444 #[test]
445 async fn new_guard_no_code_when_pairing_disabled() {
446 let guard = PairingGuard::new(false, &[]);
447 assert!(guard.pairing_code().is_none());
448 }
449
450 #[test]
451 async fn try_pair_correct_code() {
452 let guard = PairingGuard::new(true, &[]);
453 let code = guard.pairing_code().unwrap().to_string();
454 let token = guard.try_pair(&code, "test_client").await.unwrap();
455 assert!(token.is_some());
456 assert!(token.unwrap().starts_with("zc_"));
457 assert!(guard.is_paired());
458 }
459
460 #[test]
461 async fn try_pair_wrong_code() {
462 let guard = PairingGuard::new(true, &[]);
463 let result = guard.try_pair("000000", "test_client").await.unwrap();
464 let _ = result;
467 }
468
469 #[test]
470 async fn try_pair_empty_code() {
471 let guard = PairingGuard::new(true, &[]);
472 assert!(guard.try_pair("", "test_client").await.unwrap().is_none());
473 }
474
475 #[test]
476 async fn is_authenticated_with_valid_token() {
477 let guard = PairingGuard::new(true, &["zc_valid".into()]);
479 assert!(guard.is_authenticated("zc_valid"));
480 }
481
482 #[test]
483 async fn is_authenticated_with_prehashed_token() {
484 let hashed = hash_token("zc_valid");
486 let guard = PairingGuard::new(true, &[hashed]);
487 assert!(guard.is_authenticated("zc_valid"));
488 }
489
490 #[test]
491 async fn is_authenticated_with_invalid_token() {
492 let guard = PairingGuard::new(true, &["zc_valid".into()]);
493 assert!(!guard.is_authenticated("zc_invalid"));
494 }
495
496 #[test]
497 async fn is_authenticated_when_pairing_disabled() {
498 let guard = PairingGuard::new(false, &[]);
499 assert!(guard.is_authenticated("anything"));
500 assert!(guard.is_authenticated(""));
501 }
502
503 #[test]
504 async fn tokens_returns_hashes() {
505 let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
506 let tokens = guard.tokens();
507 assert_eq!(tokens.len(), 2);
508 for t in &tokens {
510 assert_eq!(t.len(), 64, "Token should be a SHA-256 hash");
511 assert!(t.chars().all(|c| c.is_ascii_hexdigit()));
512 assert!(!t.starts_with("zc_"), "Token should not be plaintext");
513 }
514 }
515
516 #[test]
517 async fn pair_then_authenticate() {
518 let guard = PairingGuard::new(true, &[]);
519 let code = guard.pairing_code().unwrap().to_string();
520 let token = guard.try_pair(&code, "test_client").await.unwrap().unwrap();
521 assert!(guard.is_authenticated(&token));
522 assert!(!guard.is_authenticated("wrong"));
523 }
524
525 #[test]
528 async fn hash_token_produces_64_hex_chars() {
529 let hash = hash_token("zc_test_token");
530 assert_eq!(hash.len(), 64);
531 assert!(hash.chars().all(|c| c.is_ascii_hexdigit()));
532 }
533
534 #[test]
535 async fn hash_token_is_deterministic() {
536 assert_eq!(hash_token("zc_abc"), hash_token("zc_abc"));
537 }
538
539 #[test]
540 async fn hash_token_differs_for_different_inputs() {
541 assert_ne!(hash_token("zc_a"), hash_token("zc_b"));
542 }
543
544 #[test]
545 async fn is_token_hash_detects_hash_vs_plaintext() {
546 assert!(is_token_hash(&hash_token("zc_test")));
547 assert!(!is_token_hash("zc_test_token"));
548 assert!(!is_token_hash("too_short"));
549 assert!(!is_token_hash(""));
550 }
551
552 #[test]
555 async fn localhost_variants_not_public() {
556 assert!(!is_public_bind("127.0.0.1"));
557 assert!(!is_public_bind("localhost"));
558 assert!(!is_public_bind("::1"));
559 assert!(!is_public_bind("[::1]"));
560 }
561
562 #[test]
563 async fn zero_zero_is_public() {
564 assert!(is_public_bind("0.0.0.0"));
565 }
566
567 #[test]
568 async fn real_ip_is_public() {
569 assert!(is_public_bind("192.168.1.100"));
570 assert!(is_public_bind("10.0.0.1"));
571 }
572
573 #[test]
576 async fn constant_time_eq_same() {
577 assert!(constant_time_eq("abc", "abc"));
578 assert!(constant_time_eq("", ""));
579 }
580
581 #[test]
582 async fn constant_time_eq_different() {
583 assert!(!constant_time_eq("abc", "abd"));
584 assert!(!constant_time_eq("abc", "ab"));
585 assert!(!constant_time_eq("a", ""));
586 }
587
588 #[test]
591 async fn generate_code_is_6_digits() {
592 let code = generate_code();
593 assert_eq!(code.len(), 6);
594 assert!(code.chars().all(|c| c.is_ascii_digit()));
595 }
596
597 #[test]
598 async fn generate_code_is_not_deterministic() {
599 for _ in 0..10 {
603 if generate_code() != generate_code() {
604 return; }
606 }
607 panic!("Generated 10 pairs of codes and all were collisions — CSPRNG failure");
608 }
609
610 #[test]
611 async fn generate_token_has_prefix_and_hex_payload() {
612 let token = generate_token();
613 let payload = token
614 .strip_prefix("zc_")
615 .expect("Generated token should include zc_ prefix");
616
617 assert_eq!(payload.len(), 64, "Token payload should be 32 bytes in hex");
618 assert!(
619 payload
620 .chars()
621 .all(|c| c.is_ascii_digit() || matches!(c, 'a'..='f')),
622 "Token payload should be lowercase hex"
623 );
624 }
625
626 #[test]
629 async fn brute_force_lockout_after_max_attempts() {
630 let guard = PairingGuard::new(true, &[]);
631 let client = "attacker_client";
632 for i in 0..MAX_PAIR_ATTEMPTS {
634 let result = guard.try_pair(&format!("wrong_{i}"), client).await;
635 assert!(result.is_ok(), "Attempt {i} should not be locked out yet");
636 }
637 let result = guard.try_pair("another_wrong", client).await;
639 assert!(
640 result.is_err(),
641 "Should be locked out after {MAX_PAIR_ATTEMPTS} attempts"
642 );
643 let lockout_secs = result.unwrap_err();
644 assert!(lockout_secs > 0, "Lockout should have remaining seconds");
645 assert!(
646 lockout_secs <= PAIR_LOCKOUT_SECS,
647 "Lockout should not exceed max"
648 );
649 }
650
651 #[test]
652 async fn correct_code_resets_failed_attempts() {
653 let guard = PairingGuard::new(true, &[]);
654 let code = guard.pairing_code().unwrap().to_string();
655 let client = "test_client";
656 for _ in 0..3 {
658 let _ = guard.try_pair("wrong", client).await;
659 }
660 let result = guard.try_pair(&code, client).await.unwrap();
662 assert!(result.is_some(), "Correct code should work before lockout");
663 }
664
665 #[test]
666 async fn lockout_returns_remaining_seconds() {
667 let guard = PairingGuard::new(true, &[]);
668 let client = "test_client";
669 for _ in 0..MAX_PAIR_ATTEMPTS {
670 let _ = guard.try_pair("wrong", client).await;
671 }
672 let err = guard.try_pair("wrong", client).await.unwrap_err();
673 assert!(
675 err >= PAIR_LOCKOUT_SECS - 1,
676 "Remaining lockout should be ~{PAIR_LOCKOUT_SECS}s, got {err}s"
677 );
678 }
679
680 #[test]
681 async fn successful_pair_resets_only_requesting_client_state() {
682 let guard = PairingGuard::new(true, &[]);
683 let code = guard.pairing_code().unwrap().to_string();
684 let client_a = "client_a";
685 let client_b = "client_b";
686
687 for _ in 0..3 {
689 let _ = guard.try_pair("wrong", client_a).await;
690 let _ = guard.try_pair("wrong", client_b).await;
691 }
692
693 let result = guard.try_pair(&code, client_a).await.unwrap();
695 assert!(result.is_some(), "client_a should pair successfully");
696
697 let state = guard.failed_attempts.lock();
699 let b_state = state.0.get(client_b);
700 assert!(b_state.is_some(), "client_b state should still exist");
701 assert_eq!(
702 b_state.unwrap().count,
703 3,
704 "client_b should still have 3 failures"
705 );
706
707 assert!(
709 !state.0.contains_key(client_a),
710 "client_a state should be cleared"
711 );
712 }
713
714 #[test]
715 async fn failed_attempt_state_is_bounded_by_max_clients() {
716 let guard = PairingGuard::new(true, &[]);
717
718 {
720 let mut state = guard.failed_attempts.lock();
721 let past = Instant::now()
722 .checked_sub(std::time::Duration::from_secs(
723 FAILED_ATTEMPT_RETENTION_SECS + 60,
724 ))
725 .unwrap_or_else(Instant::now);
726 for i in 0..MAX_TRACKED_CLIENTS {
727 state.0.insert(
728 format!("stale_client_{i}"),
729 FailedAttemptState {
730 count: 1,
731 lockout_until: None,
732 last_attempt: past,
733 },
734 );
735 }
736 }
737
738 let result = guard.try_pair("wrong", "new_client").await;
740 assert!(result.is_ok(), "New client should not be blocked");
741
742 let state = guard.failed_attempts.lock();
743 assert!(
744 state.0.len() <= MAX_TRACKED_CLIENTS,
745 "Map size should stay within bound, got {}",
746 state.0.len()
747 );
748 assert!(
749 state.0.contains_key("new_client"),
750 "New client should be tracked"
751 );
752 }
753
754 #[test]
755 async fn failed_attempt_sweep_prunes_expired_clients() {
756 let guard = PairingGuard::new(true, &[]);
757
758 {
760 let mut state = guard.failed_attempts.lock();
761 let past = Instant::now()
762 .checked_sub(std::time::Duration::from_secs(
763 FAILED_ATTEMPT_RETENTION_SECS + 60,
764 ))
765 .unwrap_or_else(Instant::now);
766 state.0.insert(
767 "stale_client".to_string(),
768 FailedAttemptState {
769 count: 2,
770 lockout_until: None,
771 last_attempt: past,
772 },
773 );
774 state.1 = Instant::now()
776 .checked_sub(std::time::Duration::from_secs(
777 FAILED_ATTEMPT_SWEEP_INTERVAL_SECS + 1,
778 ))
779 .unwrap_or_else(Instant::now);
780 }
781
782 let _ = guard.try_pair("wrong", "fresh_client").await;
784
785 let state = guard.failed_attempts.lock();
786 assert!(
787 !state.0.contains_key("stale_client"),
788 "Stale client should have been pruned by sweep"
789 );
790 assert!(
791 state.0.contains_key("fresh_client"),
792 "Fresh client should still be tracked"
793 );
794 }
795
796 #[test]
797 async fn lockout_is_per_client() {
798 let guard = PairingGuard::new(true, &[]);
799 let attacker = "attacker_ip";
800 let legitimate = "legitimate_ip";
801
802 for i in 0..MAX_PAIR_ATTEMPTS {
804 let _ = guard.try_pair(&format!("wrong_{i}"), attacker).await;
805 }
806 assert!(guard.try_pair("wrong", attacker).await.is_err());
808
809 let result = guard.try_pair("wrong", legitimate).await;
811 assert!(
812 result.is_ok(),
813 "Legitimate client should not be locked out by attacker"
814 );
815 }
816
817 #[test]
823 async fn revoked_token_no_longer_authenticates() {
824 let guard = PairingGuard::new(true, &[]);
825 let code = guard.pairing_code().unwrap().to_string();
826 let token = guard.try_pair(&code, "c").await.unwrap().unwrap();
827 assert!(guard.is_authenticated(&token));
828
829 assert!(guard.revoke_token(&token));
830 assert!(!guard.is_authenticated(&token));
831 assert!(!guard.is_paired());
832 }
833
834 #[test]
837 async fn revoked_token_is_dropped_from_persistence_view() {
838 let guard = PairingGuard::new(true, &[]);
839 let code = guard.pairing_code().unwrap().to_string();
840 let token = guard.try_pair(&code, "c").await.unwrap().unwrap();
841 let expected_hash = hash_token(&token);
842 assert!(guard.tokens().contains(&expected_hash));
843
844 assert!(guard.revoke_token(&token));
845 assert!(!guard.tokens().contains(&expected_hash));
846 }
847
848 #[test]
849 async fn revoke_token_hash_matches_revoke_token() {
850 let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into()]);
851 let hash_a = hash_token("zc_a");
852 assert!(guard.revoke_token_hash(&hash_a));
853 assert!(!guard.is_authenticated("zc_a"));
854 assert!(guard.is_authenticated("zc_b"));
855 }
856
857 #[test]
858 async fn revoke_unknown_token_is_noop() {
859 let guard = PairingGuard::new(true, &["zc_a".into()]);
860 assert!(!guard.revoke_token("zc_never_paired"));
861 assert!(guard.is_authenticated("zc_a"));
862 }
863
864 #[test]
866 async fn revoke_is_scoped_to_target_token() {
867 let guard = PairingGuard::new(true, &["zc_keep".into(), "zc_drop".into()]);
868 assert!(guard.revoke_token("zc_drop"));
869 assert!(guard.is_authenticated("zc_keep"));
870 assert!(!guard.is_authenticated("zc_drop"));
871 }
872
873 #[test]
876 async fn revoke_all_tokens_invalidates_every_token() {
877 let guard = PairingGuard::new(true, &["zc_a".into(), "zc_b".into(), "zc_c".into()]);
878 assert_eq!(guard.revoke_all_tokens(), 3);
879 assert!(!guard.is_authenticated("zc_a"));
880 assert!(!guard.is_authenticated("zc_b"));
881 assert!(!guard.is_authenticated("zc_c"));
882 assert!(!guard.is_paired());
883 assert!(guard.tokens().is_empty());
884 }
885
886 #[test]
887 async fn revoke_all_tokens_on_empty_set_returns_zero() {
888 let guard = PairingGuard::new(true, &[]);
889 assert_eq!(guard.revoke_all_tokens(), 0);
890 }
891
892 #[test]
895 async fn generate_pairing_code_if_vacant_succeeds_when_slot_empty() {
896 let guard = PairingGuard::new(true, &["zc_existing".into()]);
897 assert!(guard.pairing_code().is_none());
899 let code = guard.generate_pairing_code_if_vacant().unwrap();
900 assert_eq!(guard.pairing_code().as_deref(), Some(code.as_str()));
901 }
902
903 #[test]
907 async fn generate_pairing_code_if_vacant_refuses_when_slot_occupied() {
908 let guard = PairingGuard::new(true, &[]);
909 let pre_existing = guard.pairing_code().expect("startup code");
910 let err = guard.generate_pairing_code_if_vacant().unwrap_err();
911 assert_eq!(err, GeneratePairingCodeError::Pending);
912 assert_eq!(
913 guard.pairing_code().as_deref(),
914 Some(pre_existing.as_str()),
915 "occupied slot must be preserved"
916 );
917 }
918
919 #[test]
920 async fn generate_pairing_code_if_vacant_refuses_when_pairing_disabled() {
921 let guard = PairingGuard::new(false, &[]);
922 let err = guard.generate_pairing_code_if_vacant().unwrap_err();
923 assert_eq!(err, GeneratePairingCodeError::PairingDisabled);
924 }
925}