purecrypto 0.6.12

A pure-Rust cryptography toolkit with no foreign-code dependencies, from constant-time primitives up to keys, X.509 and TLS.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
//! Connection-ID newtype + minimal local/remote tracking.
//!
//! Per RFC 9000 §5.1 a Connection ID is an opaque byte string of length
//! 0..=20. In QUIC v1 each endpoint picks the CIDs the *peer* uses to
//! address it: the server picks the CID that appears as DCID on every
//! client→server packet after the first flight, and the client picks the
//! CID that appears as DCID on every server→client packet (RFC 9000 §7.2).
//!
//! Phase 4 only needs:
//! * `ConnectionId` — fixed-capacity inline byte string (1..=20 bytes).
//! * `CidPair` — the two CIDs that pin one connection during the handshake:
//!   the peer's chosen CID (what we write into DCID on outbound packets)
//!   and our chosen CID (what we expect in DCID on inbound packets, what
//!   the peer wrote into SCID on its first long-header packet).
//!
//! Full multi-CID management — NEW_CONNECTION_ID, RETIRE_CONNECTION_ID,
//! sequence numbers, stateless reset tokens — lands in Phase 7.

#![allow(dead_code)]

use alloc::collections::BTreeMap;

use crate::rng::RngCore;
use crate::tls::Error;

/// Maximum QUIC v1 connection-ID length, RFC 9000 §17.2.
const MAX_CID_LEN: usize = 20;

/// Hard upper bound on the number of sequences queued in
/// [`CidPool::pending_retire`] at once, expressed as a slack added on top
/// of the active-CID `limit`. A well-behaved peer never owes us more
/// outstanding RETIRE_CONNECTION_ID frames than the CIDs it has issued,
/// which is itself bounded by `limit`; the slack absorbs the brief window
/// between queueing a RETIRE and the caller draining it. A malicious peer
/// that floods NEW_CONNECTION_ID frames with large `retire_prior_to` and
/// distinct sequences is capped here, turning the would-be unbounded
/// growth into a connection error instead of memory exhaustion
/// (RFC 9000 §5.1.1 / §19.15). Kept small and constant so the bound is
/// independent of how large a `limit` the peer advertises.
const PENDING_RETIRE_SLACK: u64 = 8;

/// QUIC connection ID — opaque byte string of length 0..=20.
///
/// Stored inline (no heap allocation) to keep `QuicConnection` `Send`
/// without pulling in `Arc`. Cheap to copy and hash.
#[derive(Copy, Clone, PartialEq, Eq, Hash)]
pub(crate) struct ConnectionId {
    /// Raw bytes; only the first `len` are meaningful.
    bytes: [u8; MAX_CID_LEN],
    /// Number of valid bytes in `bytes` (0..=20).
    len: u8,
}

impl ConnectionId {
    /// Constructs a CID from a slice. Returns `None` if `bytes.len() > 20`.
    pub(crate) fn from_slice(bytes: &[u8]) -> Option<Self> {
        if bytes.len() > MAX_CID_LEN {
            return None;
        }
        let mut storage = [0u8; MAX_CID_LEN];
        storage[..bytes.len()].copy_from_slice(bytes);
        Some(Self {
            bytes: storage,
            len: bytes.len() as u8,
        })
    }

    /// The empty CID (0 bytes). RFC 9000 allows endpoints to use a
    /// zero-length CID as long as routing on (src, dst) still uniquely
    /// identifies a connection.
    pub(crate) const fn empty() -> Self {
        Self {
            bytes: [0; MAX_CID_LEN],
            len: 0,
        }
    }

    /// Generates a fresh CID of exactly `len` random bytes. `len` must be
    /// in 1..=20.
    ///
    /// Phase 4 always picks `len = 8` — enough entropy for a loopback
    /// test, short enough to keep Initial headers small. Phase 7 will
    /// expose a longer-CID server option.
    pub(crate) fn random<R: RngCore>(rng: &mut R, len: usize) -> Self {
        debug_assert!((1..=MAX_CID_LEN).contains(&len));
        let mut storage = [0u8; MAX_CID_LEN];
        rng.fill_bytes(&mut storage[..len]);
        Self {
            bytes: storage,
            len: len as u8,
        }
    }

    /// Borrowed bytes view.
    #[inline]
    pub(crate) fn as_slice(&self) -> &[u8] {
        &self.bytes[..self.len as usize]
    }

    /// Number of bytes (0..=20).
    #[inline]
    pub(crate) fn len(&self) -> usize {
        self.len as usize
    }

    /// True iff the CID is the zero-length CID.
    #[inline]
    pub(crate) fn is_empty(&self) -> bool {
        self.len == 0
    }
}

impl core::fmt::Debug for ConnectionId {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        write!(f, "ConnectionId(")?;
        for b in self.as_slice() {
            write!(f, "{b:02x}")?;
        }
        write!(f, ")")
    }
}

/// The two CIDs that pin a connection during the handshake.
///
/// * `peer` — the CID we write into DCID on outbound packets. For a client
///   this is initially the random 8-byte value the client chose (which
///   keys the Initial secrets per RFC 9001 §5.2); the server replaces it
///   with its own chosen SCID on receipt of the first server flight. For
///   a server it is the SCID the client wrote on its first Initial.
/// * `local` — the CID we expect in DCID on inbound packets. The peer
///   reads this from a SCID we sent earlier (in the long-header SCID
///   slot for handshake-level packets).
#[derive(Clone, Debug)]
pub(crate) struct CidPair {
    pub peer: ConnectionId,
    pub local: ConnectionId,
}

impl CidPair {
    pub(crate) fn new(peer: ConnectionId, local: ConnectionId) -> Self {
        Self { peer, local }
    }
}

// =========================================================================
// Phase 7 — CidPool: NEW_CONNECTION_ID / RETIRE_CONNECTION_ID housekeeping
// =========================================================================

/// One CID in a [`CidPool`]: the connection-ID bytes, its sequence number
/// (RFC 9000 §5.1.1), and the stateless-reset token bound to it (16 bytes
/// per RFC 9000 §10.3). Phase 7 *stores* the reset token but does not
/// yet act on it — stateless reset emission lands in Phase 8.
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) struct CidEntry {
    /// The actual connection-ID bytes (≤ 20 in QUIC v1).
    pub(crate) cid: ConnectionId,
    /// Sequence number assigned by the issuing endpoint (RFC 9000 §5.1.1).
    /// Sequence 0 is the CID established during the handshake; subsequent
    /// CIDs come via NEW_CONNECTION_ID frames.
    pub(crate) sequence: u64,
    /// Stateless-reset token: 16 bytes (RFC 9000 §10.3). `None` for CIDs
    /// where no token was supplied (e.g. a zero-length CID, or a
    /// pre-handshake placeholder).
    pub(crate) reset_token: Option<[u8; 16]>,
}

/// A bounded pool of connection-IDs in one direction. The local pool
/// tracks CIDs *we* issued for the peer to use as DCIDs on inbound
/// packets; the remote pool tracks CIDs the peer issued for *us* to use
/// as DCIDs on outbound packets.
///
/// RFC 9000 §5.1.1: an endpoint MUST limit the number of unretired CIDs
/// it accepts from a peer to `active_connection_id_limit` (default 2,
/// minimum 2). Phase 7 enforces this on insertion.
///
/// RFC 9000 §5.1.2: when the peer's `retire_prior_to` advances, all
/// sequences strictly below it are retired automatically; the retiring
/// side emits a RETIRE_CONNECTION_ID frame per dropped sequence.
pub(crate) struct CidPool {
    /// All CIDs in this pool, keyed by sequence. Always non-empty after
    /// construction (the handshake CID is at sequence 0).
    pub(crate) entries: BTreeMap<u64, CidEntry>,
    /// Sequence of the CID currently in use. Phase 7 keeps this fixed at
    /// 0 (CID migration is a Phase 8+ concern); future phases will
    /// advance it when migrating to a new CID.
    pub(crate) active_seq: u64,
    /// The largest `retire_prior_to` value the peer has signalled. Any
    /// stored entry with `sequence < retire_prior_to` should be removed
    /// from this pool and a RETIRE_CONNECTION_ID emitted (per §5.1.2).
    pub(crate) retire_prior_to: u64,
    /// Bound from the peer's `active_connection_id_limit` transport
    /// parameter. We refuse to store more than `limit` non-retired entries
    /// per §5.1.1.
    pub(crate) limit: u64,
    /// Sequences this side has been asked to RETIRE but hasn't yet
    /// emitted a RETIRE_CONNECTION_ID frame for. (Remote pool only:
    /// when the peer tells us `retire_prior_to = N`, we owe the peer
    /// a RETIRE_CONNECTION_ID for every sequence we previously stored
    /// below N.)
    pub(crate) pending_retire: alloc::vec::Vec<u64>,
}

impl CidPool {
    /// Constructs a pool seeded with a single entry at sequence 0 — the
    /// handshake CID. The `active_connection_id_limit` defaults to 2
    /// (RFC 9000 §18.2 default); the caller updates `limit` once it
    /// learns the peer's actual value.
    pub(crate) fn new(initial: ConnectionId, initial_reset_token: Option<[u8; 16]>) -> Self {
        let mut entries = BTreeMap::new();
        entries.insert(
            0,
            CidEntry {
                cid: initial,
                sequence: 0,
                reset_token: initial_reset_token,
            },
        );
        Self {
            entries,
            active_seq: 0,
            retire_prior_to: 0,
            limit: 2,
            pending_retire: alloc::vec::Vec::new(),
        }
    }

    /// Sets the peer-advertised `active_connection_id_limit` (RFC 9000
    /// §18.2). Per §5.1.1, the value MUST be at least 2; we clamp here
    /// for robustness rather than rejecting the peer outright.
    pub(crate) fn set_limit(&mut self, limit: u64) {
        self.limit = limit.max(2);
    }

    /// Maximum number of sequences we will hold in `pending_retire`
    /// before treating further growth as a connection error. Derived from
    /// the active-CID `limit` plus a small constant slack so legitimate
    /// peers (which never owe more than `limit` outstanding RETIREs) are
    /// unaffected, while a flood is bounded (RFC 9000 §5.1.1).
    pub(crate) fn pending_retire_cap(&self) -> usize {
        self.limit.saturating_add(PENDING_RETIRE_SLACK) as usize
    }

    /// Queues `sequence` for a RETIRE_CONNECTION_ID emission, deduping
    /// against already-queued sequences and enforcing the
    /// `pending_retire` cap. Returns [`Error::IllegalParameter`] when the
    /// cap would be exceeded, so the connection closes rather than letting
    /// a peer grow `pending_retire` without bound (F2).
    fn queue_pending_retire(&mut self, sequence: u64) -> Result<(), Error> {
        if self.pending_retire.contains(&sequence) {
            // Already owed; dedup so a peer can't inflate the queue by
            // re-announcing the same retired sequence.
            return Ok(());
        }
        if self.pending_retire.len() >= self.pending_retire_cap() {
            return Err(Error::IllegalParameter);
        }
        self.pending_retire.push(sequence);
        Ok(())
    }

    /// Inserts `entry`. Returns [`Error::IllegalParameter`] if the
    /// sequence already exists with different content (RFC 9000 §19.15:
    /// "the same sequence number MAY appear in multiple frames, but the
    /// content MUST be identical"). Returns [`Error::IllegalParameter`]
    /// if accepting this entry would exceed `limit` non-retired entries.
    pub(crate) fn add(&mut self, entry: CidEntry) -> Result<(), Error> {
        if let Some(existing) = self.entries.get(&entry.sequence) {
            if existing != &entry {
                return Err(Error::IllegalParameter);
            }
            return Ok(());
        }
        if entry.sequence < self.retire_prior_to {
            // RFC 9000 §5.1.2: a newly-received CID with sequence below
            // the retire_prior_to we already announced is immediately
            // retired. We emit the RETIRE for it but don't keep it.
            // `queue_pending_retire` dedups and caps growth so a peer
            // cannot flood distinct low sequences to exhaust memory (F2).
            return self.queue_pending_retire(entry.sequence);
        }
        let live = self
            .entries
            .iter()
            .filter(|(seq, _)| **seq >= self.retire_prior_to)
            .count() as u64;
        if live >= self.limit {
            // Exceeded active_connection_id_limit.
            return Err(Error::IllegalParameter);
        }
        self.entries.insert(entry.sequence, entry);
        Ok(())
    }

    /// Retires the CID at `sequence`. Returns the removed entry, or
    /// `Ok(None)` if no such sequence was present. Returns
    /// [`Error::IllegalParameter`] if the caller is trying to retire the
    /// CID that is currently in use (RFC 9000 §19.16: "Receipt of a
    /// RETIRE_CONNECTION_ID frame that retires the same connection ID
    /// the endpoint used to send the frame ... MUST be treated as a
    /// connection error"). Phase 7 conservatively checks this whether
    /// this side or the peer is retiring.
    pub(crate) fn retire(&mut self, sequence: u64) -> Result<Option<CidEntry>, Error> {
        if sequence == self.active_seq && self.entries.contains_key(&sequence) {
            // Phase 7 doesn't migrate; if asked to retire the active CID
            // we treat it as a protocol violation.
            return Err(Error::IllegalParameter);
        }
        Ok(self.entries.remove(&sequence))
    }

    /// Records a peer-advertised `retire_prior_to` value (from a
    /// NEW_CONNECTION_ID frame, RFC 9000 §19.15). All entries with
    /// `sequence < new` are removed; their sequences are added to
    /// `pending_retire` so the caller can emit RETIRE_CONNECTION_ID
    /// frames in the next outbound packet.
    ///
    /// L-2: if the currently-active CID (`active_seq`) is among the
    /// sequences being retired, the active sequence is first rotated to
    /// the lowest surviving entry whose sequence is `>= new` (a CID the
    /// peer wants us to keep using). If *no* such replacement exists yet
    /// — the peer advanced `retire_prior_to` past every CID it has so far
    /// issued — the active entry is RETAINED (not removed) so the
    /// connection keeps a usable outbound DCID until a replacement
    /// NEW_CONNECTION_ID arrives. This is RFC 9000 §5.1.2-consistent:
    /// retire_prior_to obligates us to retire old CIDs, but a peer that
    /// leaves us with no usable CID would be breaking the connection on
    /// itself; we degrade gracefully rather than discard our only DCID.
    ///
    /// Returns [`Error::IllegalParameter`] if queueing the dropped
    /// sequences would exceed the `pending_retire` cap — only reachable
    /// when a peer drives growth far beyond the active-CID `limit`, which
    /// is itself a protocol violation (F2 / RFC 9000 §5.1.1). Sequences
    /// are deduped against what is already queued.
    pub(crate) fn note_retire_prior_to(&mut self, new: u64) -> Result<(), Error> {
        if new <= self.retire_prior_to {
            return Ok(());
        }
        self.retire_prior_to = new;

        // Determine whether the active CID is being retired, and if so,
        // find a surviving replacement (lowest sequence >= new). The
        // entries map is sorted, so the first key >= new is the
        // replacement candidate.
        let active_retired = self.active_seq < new;
        let replacement = self.entries.range(new..).next().map(|(s, _)| *s);
        let keep_active = if active_retired {
            match replacement {
                Some(repl) => {
                    // Rotate to the surviving CID before removing the old
                    // active one.
                    self.active_seq = repl;
                    None
                }
                // No replacement available yet: keep the active entry so
                // we still have a usable outbound DCID. It will be
                // retired by a later note_retire_prior_to once the peer
                // supplies a higher-sequence CID.
                None => Some(self.active_seq),
            }
        } else {
            None
        };

        let dropped: alloc::vec::Vec<u64> = self
            .entries
            .keys()
            .copied()
            .filter(|s| *s < new && Some(*s) != keep_active)
            .collect();
        for s in dropped {
            self.entries.remove(&s);
            self.queue_pending_retire(s)?;
        }
        Ok(())
    }

    /// Currently-active CID entry (the one whose CID we write into the
    /// DCID of every outbound packet, for the remote pool; the one we
    /// expect in DCID on inbound packets, for the local pool). `None`
    /// only in pathological cases — the active entry should always be
    /// present.
    pub(crate) fn active(&self) -> Option<&CidEntry> {
        self.entries.get(&self.active_seq)
    }

    /// How many more fresh CIDs we should issue to the peer so the peer
    /// has `limit` unretired CIDs available. Returns 0 if we already
    /// have at least `limit` live entries.
    pub(crate) fn how_many_to_issue(&self) -> u64 {
        let live = self
            .entries
            .iter()
            .filter(|(seq, _)| **seq >= self.retire_prior_to)
            .count() as u64;
        self.limit.saturating_sub(live)
    }

    /// Pops the next pending RETIRE_CONNECTION_ID sequence to emit, or
    /// `None` if none are queued.
    pub(crate) fn pop_pending_retire(&mut self) -> Option<u64> {
        if self.pending_retire.is_empty() {
            None
        } else {
            Some(self.pending_retire.remove(0))
        }
    }

    /// Highest sequence number currently stored. Used by the issuing
    /// side to pick the next sequence number for a NEW_CONNECTION_ID
    /// emission.
    pub(crate) fn max_sequence(&self) -> u64 {
        self.entries.keys().next_back().copied().unwrap_or(0)
    }

    /// Installs / replaces the `reset_token` on the entry at the given
    /// sequence. Returns `true` if an entry was found and updated.
    ///
    /// G-3: the peer's `stateless_reset_token` transport parameter is
    /// bound to the *handshake* CID (sequence 0), which is constructed
    /// at pool-seed time with `initial_reset_token = None` because the
    /// token only arrives later in the TLS handshake's transport params.
    /// Without this hook, [`super::connection::QuicConnection::detect_stateless_reset`]
    /// would never recognize a stateless reset targeted at the
    /// handshake CID.
    pub(crate) fn set_token(&mut self, sequence: u64, token: [u8; 16]) -> bool {
        if let Some(e) = self.entries.get_mut(&sequence) {
            e.reset_token = Some(token);
            true
        } else {
            false
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hash::Sha256;
    use crate::rng::HmacDrbg;

    #[test]
    fn from_slice_caps_at_20() {
        assert!(ConnectionId::from_slice(&[0u8; 21]).is_none());
        let cid = ConnectionId::from_slice(&[1, 2, 3, 4]).unwrap();
        assert_eq!(cid.as_slice(), &[1, 2, 3, 4]);
        assert_eq!(cid.len(), 4);
        assert!(!cid.is_empty());
    }

    #[test]
    fn empty_is_empty() {
        let e = ConnectionId::empty();
        assert!(e.is_empty());
        assert_eq!(e.len(), 0);
        assert_eq!(e.as_slice(), &[] as &[u8]);
    }

    #[test]
    fn random_has_right_length() {
        let mut rng = HmacDrbg::<Sha256>::new(b"cid-test", b"nonce", &[]);
        let cid = ConnectionId::random(&mut rng, 8);
        assert_eq!(cid.len(), 8);
        // Random 8 bytes are overwhelmingly unlikely to be all zero.
        assert_ne!(cid.as_slice(), &[0u8; 8]);
    }

    #[test]
    fn debug_is_hex() {
        let cid = ConnectionId::from_slice(&[0x83, 0x94]).unwrap();
        let s = alloc::format!("{cid:?}");
        assert!(s.contains("8394"));
    }

    // ============================================================
    // Phase 7 — CidPool tests
    // ============================================================

    fn cid_n(n: u8) -> ConnectionId {
        ConnectionId::from_slice(&[n; 8]).expect("8-byte cid")
    }

    #[test]
    fn cidpool_seeded_with_handshake_entry() {
        let pool = CidPool::new(cid_n(0), Some([0u8; 16]));
        assert_eq!(pool.active_seq, 0);
        assert!(pool.active().is_some());
        assert_eq!(pool.active().unwrap().cid, cid_n(0));
        assert_eq!(pool.limit, 2);
        // Default limit is 2 active CIDs; we already have 1 → can issue 1 more.
        assert_eq!(pool.how_many_to_issue(), 1);
    }

    #[test]
    fn cidpool_add_respects_limit() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(2);
        let e1 = CidEntry {
            cid: cid_n(1),
            sequence: 1,
            reset_token: Some([1u8; 16]),
        };
        assert!(pool.add(e1).is_ok());
        // Now 2 live entries → can't add another while limit = 2.
        let e2 = CidEntry {
            cid: cid_n(2),
            sequence: 2,
            reset_token: Some([2u8; 16]),
        };
        assert!(matches!(pool.add(e2), Err(Error::IllegalParameter)));
        // Lift the limit; add now succeeds.
        pool.set_limit(3);
        let e2 = CidEntry {
            cid: cid_n(2),
            sequence: 2,
            reset_token: Some([2u8; 16]),
        };
        assert!(pool.add(e2).is_ok());
        assert_eq!(pool.max_sequence(), 2);
    }

    #[test]
    fn cidpool_add_rejects_inconsistent_duplicate() {
        let mut pool = CidPool::new(cid_n(0), None);
        let e1 = CidEntry {
            cid: cid_n(1),
            sequence: 1,
            reset_token: Some([7u8; 16]),
        };
        assert!(pool.add(e1.clone()).is_ok());
        // Identical re-add: fine.
        assert!(pool.add(e1.clone()).is_ok());
        // Mismatched re-add: error per RFC 9000 §19.15.
        let e1_bad = CidEntry {
            cid: cid_n(0xff),
            sequence: 1,
            reset_token: Some([7u8; 16]),
        };
        assert!(matches!(pool.add(e1_bad), Err(Error::IllegalParameter)));
    }

    #[test]
    fn cidpool_retire_prior_to_pulls_retires_and_queues() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(4);
        for s in 1..=3 {
            pool.add(CidEntry {
                cid: cid_n(s as u8),
                sequence: s,
                reset_token: Some([s as u8; 16]),
            })
            .unwrap();
        }
        // Now there are 4 entries (sequences 0..=3). Move active to 2 so
        // retire of 0/1 doesn't trip the "active retired" check.
        pool.active_seq = 2;
        // The peer says "retire prior to 2" → 0 and 1 drop.
        pool.note_retire_prior_to(2).expect("retire ok");
        assert_eq!(pool.retire_prior_to, 2);
        assert!(!pool.entries.contains_key(&0));
        assert!(!pool.entries.contains_key(&1));
        // Pending-retire frames are queued for 0 and 1.
        let mut got = alloc::vec::Vec::new();
        while let Some(s) = pool.pop_pending_retire() {
            got.push(s);
        }
        got.sort();
        assert_eq!(got, alloc::vec![0u64, 1]);
    }

    #[test]
    fn cidpool_retire_active_is_protocol_error() {
        let mut pool = CidPool::new(cid_n(0), None);
        assert!(matches!(pool.retire(0), Err(Error::IllegalParameter)));
    }

    // L-2: retire_prior_to that covers the active CID must rotate the
    // active sequence to a surviving higher-sequence CID, never leaving
    // `active()` pointing at a removed entry.
    #[test]
    fn cidpool_retire_prior_to_rotates_active() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(4);
        for s in 1..=3 {
            pool.add(CidEntry {
                cid: cid_n(s as u8),
                sequence: s,
                reset_token: Some([s as u8; 16]),
            })
            .unwrap();
        }
        // active is still the handshake CID (seq 0).
        assert_eq!(pool.active_seq, 0);
        // Peer retires everything below 2 — the active CID (0) goes too.
        pool.note_retire_prior_to(2).expect("retire ok");
        // Active must have rotated to the lowest survivor (seq 2), and
        // its entry must still be present.
        assert_eq!(pool.active_seq, 2, "active rotated to surviving CID");
        let active = pool.active().expect("active entry present");
        assert_eq!(active.sequence, 2);
        assert_eq!(active.cid, cid_n(2));
        // The retired sequences (0, 1) are gone and queued.
        assert!(!pool.entries.contains_key(&0));
        assert!(!pool.entries.contains_key(&1));
    }

    // L-2: if retire_prior_to advances past every CID the peer has so far
    // issued, the active entry is RETAINED (not removed) so a usable
    // outbound DCID survives until a replacement arrives.
    #[test]
    fn cidpool_retire_prior_to_keeps_active_when_no_replacement() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(4);
        // Only the handshake CID (seq 0) exists. Peer retires prior to 5.
        pool.note_retire_prior_to(5).expect("retire ok");
        // No survivor >= 5, so the active CID (seq 0) is kept.
        assert_eq!(pool.active_seq, 0, "active unchanged with no replacement");
        let active = pool.active().expect("active entry retained");
        assert_eq!(active.cid, cid_n(0));
        // It was NOT queued for retire (we still use it).
        assert!(pool.pop_pending_retire().is_none());
        assert_eq!(pool.retire_prior_to, 5);

        // When a higher-sequence CID finally arrives, the next
        // retire_prior_to bump (or this one, re-evaluated) lets us rotate
        // and drop the stale active CID.
        pool.add(CidEntry {
            cid: cid_n(7),
            sequence: 7,
            reset_token: None,
        })
        .unwrap();
        // Re-announce a retire_prior_to that now has a survivor (>5).
        pool.note_retire_prior_to(6).expect("retire ok");
        assert_eq!(pool.active_seq, 7, "active rotates once a survivor exists");
        assert!(!pool.entries.contains_key(&0), "stale active CID dropped");
    }

    #[test]
    fn cidpool_retire_unknown_sequence_returns_none() {
        let mut pool = CidPool::new(cid_n(0), None);
        // Sequence 42 was never added; retire returns Ok(None).
        let r = pool.retire(42).expect("ok");
        assert!(r.is_none());
    }

    #[test]
    fn cidpool_add_below_retire_prior_to_immediately_retires() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(4);
        pool.active_seq = 5;
        pool.note_retire_prior_to(3).expect("retire ok");
        // Now an entry at sequence 2 should be auto-retired.
        let e = CidEntry {
            cid: cid_n(0xab),
            sequence: 2,
            reset_token: None,
        };
        assert!(pool.add(e).is_ok());
        assert!(!pool.entries.contains_key(&2));
        let mut got = alloc::vec::Vec::new();
        while let Some(s) = pool.pop_pending_retire() {
            got.push(s);
        }
        assert!(got.contains(&2));
    }

    // F2: a peer that floods NEW_CONNECTION_ID-style entries with a large
    // retire_prior_to and distinct small sequences must not be able to
    // grow `pending_retire` without bound. The pool either dedups the
    // sequence away or errors once the cap is hit; it never grows
    // unboundedly.
    #[test]
    fn cidpool_pending_retire_is_bounded_under_flood() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(2);
        // Announce a high retire_prior_to so every fresh low sequence is
        // immediately auto-retired (the unbounded-growth branch in `add`).
        pool.note_retire_prior_to(1_000_000).expect("retire ok");
        let cap = pool.pending_retire_cap();

        let mut hit_error = false;
        for s in 0..100_000u64 {
            let e = CidEntry {
                cid: cid_n((s % 256) as u8),
                sequence: s,
                reset_token: None,
            };
            // Every distinct sequence is below retire_prior_to → routed
            // through `queue_pending_retire`. Once the cap is reached the
            // pool returns an error rather than appending forever.
            if pool.add(e).is_err() {
                hit_error = true;
                break;
            }
        }
        assert!(hit_error, "flood must eventually be rejected");
        assert!(
            pool.pending_retire.len() <= cap,
            "pending_retire ({}) must stay within cap ({cap})",
            pool.pending_retire.len(),
        );
    }

    // F2: re-announcing an already-queued retire sequence is deduped and
    // does not consume cap budget, so a peer cannot inflate the queue by
    // repeating the same sequence.
    #[test]
    fn cidpool_pending_retire_dedups() {
        let mut pool = CidPool::new(cid_n(0), None);
        pool.set_limit(2);
        pool.note_retire_prior_to(10).expect("retire ok");
        // Add the same below-threshold sequence many times.
        for _ in 0..1000 {
            let e = CidEntry {
                cid: cid_n(7),
                sequence: 5,
                reset_token: None,
            };
            pool.add(e).expect("dedup keeps this within cap");
        }
        assert_eq!(
            pool.pending_retire.iter().filter(|&&s| s == 5).count(),
            1,
            "sequence 5 must be queued at most once",
        );
        assert!(pool.pending_retire.len() <= pool.pending_retire_cap());
    }
}