rvoip-sip 0.2.5

SIP umbrella for RVoIP: api/* (UnifiedCoordinator, StreamPeer, CallbackPeer, Endpoint), server/* (B2BUA helpers), adapter/* (rvoip-core::ConnectionAdapter impl)
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
use crate::state_table::{CallId, DialogId, MediaSessionId, SessionId};
use rvoip_sip_dialog::transaction::TransactionKey;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::net::SocketAddr;
use std::time::{Instant, SystemTime, UNIX_EPOCH};

use super::history::{HistoryConfig, SessionHistory, TransitionRecord};
use crate::api::events::MediaSecurityState;
use crate::state_table::{ConditionUpdates, Role};
use crate::types::{CallState, MediaDirection};

/// Negotiated media configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NegotiatedConfig {
    pub local_addr: SocketAddr,
    pub remote_addr: SocketAddr,
    pub codec: String,
    pub sample_rate: u32,
    pub channels: u8,
}

/// Kind of mid-dialog re-INVITE that was in flight when a 491 Request
/// Pending arrived — captured so `ScheduleReinviteRetry` can re-issue the
/// correct operation after the RFC 3261 §14.1 random backoff.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PendingReinvite {
    Hold,
    Resume,
    /// Generic SDP update with a specific offer (codec change, etc.).
    SdpUpdate(String),
}

/// Transfer state tracking
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransferState {
    None,
    TransferInitiated,
    TransferCompleted,
}

/// Complete state of a session
#[derive(Debug, Clone)]
pub struct SessionState {
    // Identity
    pub session_id: SessionId,
    pub role: Role,

    // Current state
    pub call_state: CallState,
    pub entered_state_at: Instant,

    // Readiness conditions (the 3 flags)
    pub dialog_established: bool,
    pub media_session_ready: bool,
    pub sdp_negotiated: bool,

    // Track if call established was triggered
    pub call_established_triggered: bool,

    // SDP data
    pub local_sdp: Option<String>,
    pub remote_sdp: Option<String>,
    pub negotiated_config: Option<NegotiatedConfig>,
    /// Negotiated media security, populated after SRTP contexts install.
    pub media_security: Option<MediaSecurityState>,
    /// Stable numeric SDP origin session id used in the `o=` line for
    /// every local offer/answer on this session.
    pub sdp_origin_session_id: String,
    /// Monotonic SDP origin version. Incremented for each locally generated
    /// SDP body that can be placed on the wire.
    pub sdp_origin_version: u64,
    /// Current local media direction from our perspective.
    pub local_media_direction: MediaDirection,
    /// Current remote offer direction from the peer's perspective.
    pub remote_media_direction: MediaDirection,

    // Related IDs
    pub dialog_id: Option<DialogId>,
    pub media_session_id: Option<MediaSessionId>,
    pub call_id: Option<CallId>,
    /// Inbound INVITE server transaction captured during UAS setup so the
    /// final 200 OK can avoid rediscovering the pending transaction.
    pub pending_inbound_invite_transaction_id: Option<TransactionKey>,
    /// Session-layer receive timestamp for Config-owned first-response timing.
    pub incoming_invite_received_at: Option<Instant>,

    // SIP URIs
    pub local_uri: Option<String>,  // From URI for UAC, To URI for UAS
    pub remote_uri: Option<String>, // To URI for UAC, From URI for UAS

    // Store last 200 OK response for ACK
    pub last_200_ok: Option<Vec<u8>>, // Serialized response

    // Bridging information (for peer-to-peer conferencing)
    pub bridged_to: Option<SessionId>, // Session this is bridged to

    // Conference information
    pub conference_mixer_id: Option<MediaSessionId>, // Mixer ID if hosting conference
    pub transfer_target: Option<String>,             // Target for transfers
    pub dtmf_digits: Option<String>,                 // DTMF digits to send

    // Rejection details captured from RejectCall event for use by SendRejectResponse
    pub reject_status: Option<u16>,
    pub reject_reason: Option<String>,
    /// SIP_API_DESIGN_2 §3.4 — application headers staged by
    /// [`RejectBuilder`](crate::api::respond::RejectBuilder) /
    /// [`AuthChallengeBuilder`](crate::api::respond::AuthChallengeBuilder)
    /// to ride on the wire 4xx-6xx response. Set by the builder
    /// before calling `reject_call`; consumed (and cleared) by
    /// `Action::SendRejectResponse`.
    pub reject_response_extras: Option<Vec<rvoip_sip_core::types::TypedHeader>>,

    // RFC 3261 §8.1.3.4 / §21.3 — redirect details captured from a local
    // UAS-side RedirectCall event, used by `SendRedirectResponse`. The status
    // must be 3xx; contacts are the URIs we'll advertise in the `Contact:`
    // header so the UAC can pick one to follow.
    pub redirect_response_status: Option<u16>,
    pub redirect_response_contacts: Vec<String>,

    // Caller-supplied SDP for SendEarlyMedia. Consumed by PrepareEarlyMediaSDP
    // on the way to the reliable 183; None means "auto-negotiate from remote
    // offer" (the usual case for a call-flow-driven ringback).
    pub early_media_sdp: Option<String>,

    // RFC 3261 §22.2 — AuthRequired payload stashed here by the executor
    // (mirrors reject_status pattern). Consumed by StoreAuthChallenge and
    // SendINVITEWithAuth to pick `Authorization` vs `Proxy-Authorization`
    // based on status code. Carried as a tuple to keep the field count low.
    pub pending_auth: Option<(u16, String)>,

    // SIP_API_DESIGN_2 R2 — SIP method of the challenged request,
    // extracted from the AuthRequired event (originally from the
    // response's CSeq:). Consumed by `SendRequestWithAuth` to route
    // the retry to the right per-method dispatcher. Empty string means
    // method-agnostic (legacy publish path); the action falls back to
    // inspecting which `pending_*_options` stash is set.
    pub pending_auth_method: Option<String>,

    // Post-send outbound transport context for the challenged request, when
    // dialog-core could report it. Used by UAC auth retry policy so Basic and
    // Bearer decisions are based on the hop that actually carried the first
    // request.
    pub pending_auth_transport: Option<crate::auth::SipTransportSecurityContext>,

    // SIP_API_DESIGN_2 R2 — retry cap counter for the generic
    // `SendRequestWithAuth` action. Mirrors `invite_auth_retry_count`
    // but covers BYE / REFER / NOTIFY / INFO / UPDATE / MESSAGE /
    // OPTIONS / SUBSCRIBE. Capped at 1 (one retry total) to prevent
    // loops when credentials are wrong. The conflict guard ensures
    // only one method has an in-flight request per session so a single
    // counter is sufficient.
    pub request_auth_retry_count: u8,

    // RFC 3261 §22.2 — INVITE auth retry counter, capped at 1 (two attempts
    // total: initial + one authenticated retry). Prevents infinite loops when
    // the server keeps re-challenging with the same nonce.
    pub invite_auth_retry_count: u8,

    // 3xx redirect follow-up state (RFC 3261 §8.1.3.4)
    // Remaining redirect targets to try (first = highest priority); popped
    // from the front by RetryWithContact.
    pub redirect_targets: Vec<String>,
    // Number of redirects followed so far; RFC-recommended cap is 5.
    pub redirect_attempts: u8,

    // 491 Request Pending retry state (RFC 3261 §14.1). Remembers the kind
    // of re-INVITE that was in flight when a 491 was received, so we can
    // re-issue it after the random backoff.
    pub pending_reinvite: Option<PendingReinvite>,
    pub reinvite_retry_attempts: u8,

    // RFC 4028 §6 — 422 Session Interval Too Small retry state. Peer's
    // required `Min-SE` floor is stashed here by the 422 event handler; the
    // retry action reads it to build the bumped `Session-Expires`. Retry
    // counter is capped at 2 to avoid loops when a broken UAS keeps sending
    // 422 regardless of what we pick.
    pub session_timer_min_se: Option<u32>,
    pub session_timer_retry_count: u8,

    // Transfer tracking (blind transfer + REFER-with-Replaces primitive for
    // higher-layer attended-transfer orchestrators). Per-session state only;
    // linking two sessions (consultation + original) is an orchestration
    // concern that lives outside this crate.
    pub transfer_state: TransferState, // Current transfer state
    pub transfer_notify_dialog: Option<DialogId>, // Dialog to send NOTIFY messages to (for blind transfer)

    // Transfer coordination fields
    pub replaces_header: Option<String>, // Replaces header for attended transfer
    pub referred_by: Option<String>,     // Referred-By header from REFER request
    pub refer_transaction_id: Option<String>, // Transaction ID for REFER request (for sending response)
    pub is_transfer_call: bool, // Flag indicating this session is a result of a transfer
    pub transferor_session_id: Option<SessionId>, // Session ID of who sent us the REFER (for NOTIFY messages)
    pub transfer_target_progress_seen: bool, // Whether REFER NOTIFY reported target provisional progress
    pub transfer_target_last_progress: Option<(u16, String)>, // Last provisional target evidence
    pub pending_bye_reason: Option<(String, u16, Option<String>)>, // RFC 3326 Reason for next local BYE

    // ──────────────────────────────────────────────────────────────────
    // SIP_API_DESIGN_2 §7.3 — Pending-options stash lifecycle.
    //
    // Each `pending_<method>_options` slot is set by the matching
    // rvoip-sip builder's `.send()` immediately before the
    // `Action::Send<METHOD>WithOptions` is queued. The state-machine
    // handler reads, dispatches, and clears the slot back to `None`
    // when the transaction reaches a final response (success,
    // terminal failure, or hard timeout). Auth-retry re-reads the
    // same `Arc<XxxRequestOptions>` for the retry transaction; the
    // slot persists across retries until the final response.
    //
    // Set-once / consumed-once: a second `.send()` of the same
    // method on the same session while the slot is occupied returns
    // `Err(SessionError::Conflict { method })`. Different methods on
    // the same session are independent (different slots).
    //
    // On entry to `Terminated`, every `pending_*_options` is set to
    // `None`.
    // ──────────────────────────────────────────────────────────────────
    pub pending_invite_options:
        Option<std::sync::Arc<crate::api::send::outbound_call::OutboundCallOptionsSnapshot>>,
    pub pending_reinvite_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::ReInviteRequestOptions>>,
    pub pending_register_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::RegisterRequestOptions>>,
    pub pending_refer_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::ReferRequestOptions>>,
    pub pending_bye_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::ByeRequestOptions>>,
    pub pending_cancel_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::CancelRequestOptions>>,
    pub pending_notify_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::NotifyRequestOptions>>,
    pub pending_subscribe_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::SubscribeRequestOptions>>,
    pub pending_info_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::InfoRequestOptions>>,
    pub pending_update_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::UpdateRequestOptions>>,
    pub pending_message_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::MessageRequestOptions>>,
    pub pending_options_options:
        Option<std::sync::Arc<rvoip_sip_dialog::api::unified::OptionsRequestOptions>>,

    // Registration fields
    pub registrar_uri: Option<String>, // URI of the registrar server
    pub registration_expires: Option<u32>, // Registration expiry in seconds
    pub registration_contact: Option<String>, // Contact URI for registration
    pub registration_call_id: Option<String>, // Stable Call-ID for this registration lifecycle
    pub registration_cseq: u32,        // Last CSeq used for this registration lifecycle
    pub registration_accepted_expires: Option<u32>, // Registrar-accepted expiry in seconds
    pub registration_registered_at: Option<Instant>, // Time of last successful registration
    pub registration_next_refresh_at: Option<Instant>, // Scheduled automatic refresh time
    pub registration_last_failure: Option<String>, // Last registration failure summary
    pub registration_service_route: Option<Vec<String>>, // Registrar Service-Route URIs
    pub registration_pub_gruu: Option<String>, // Registrar-assigned public GRUU
    pub registration_temp_gruu: Option<String>, // Registrar-assigned temporary GRUU
    pub credentials: Option<crate::types::Credentials>, // User credentials for authentication
    pub auth: Option<crate::auth::SipClientAuth>, // General UAC auth for 401/407 retries
    /// Optional `P-Asserted-Identity` URI (RFC 3325 §9.1) to attach to the
    /// outgoing INVITE for this session. When `Some`, the `SendINVITE` action
    /// routes through `dialog_adapter.send_invite_with_extra_headers` so the
    /// header lands on the very first wire transmission. Carrier trunks
    /// commonly require this for caller-ID assertion.
    pub pai_uri: Option<String>,
    /// Caller-supplied extra typed headers to attach to the very first
    /// outgoing INVITE for this session. Populated by the
    /// `_with_headers` variants on the public API surfaces; consumed by
    /// `Action::SendINVITE`, which appends them to the `extras` vector after
    /// any synthesized `P-Asserted-Identity`. Empty by default — the
    /// outbound-proxy Route prepended inside
    /// `DialogAdapter::send_invite_with_extra_headers` still runs after this,
    /// so a configured outbound proxy stays first on the wire.
    pub extra_headers: Vec<rvoip_sip_core::types::TypedHeader>,
    pub is_registered: bool, // Whether registration is complete
    pub auth_challenge: Option<crate::auth::DigestChallenge>, // Cached authentication challenge from 401
    pub auth_challenge_raw: Option<String>, // Cached raw challenge for non-Digest auth schemes
    /// Whether the latest cached challenge carried `stale=true`.
    pub auth_challenge_stale: bool,
    /// Previous nonce replaced by the latest cached challenge. Used to allow
    /// exactly one stale-nonce recovery retry with a fresh nonce.
    pub auth_challenge_replaces_nonce: Option<String>,
    pub registration_retry_count: u32, // Number of retries attempted (prevent infinite loops)

    // RFC 7616 §3.4.5 — per-(realm, nonce) digest nonce-count cursor.
    // Each successive request reusing the same nonce increments its
    // entry; when a fresh challenge with a new nonce arrives, a new
    // entry is inserted at 1. Carriers reject `nc` repeats as replays,
    // so this map is the difference between working and broken auth on
    // anything beyond the first 401 retry. Sessions are ephemeral —
    // the map is not persisted across process restart on purpose.
    pub digest_nc: HashMap<(String, String), u32>,

    // Timestamps
    pub created_at: Instant,

    // Optional history tracking
    pub history: Option<SessionHistory>,
}

impl SessionState {
    /// Create a new session state
    pub fn new(session_id: SessionId, role: Role) -> Self {
        let now = Instant::now();
        let sdp_origin_session_id = stable_sdp_origin_session_id(&session_id.0);
        Self {
            session_id,
            role,
            call_state: CallState::Idle,
            entered_state_at: now,
            dialog_established: false,
            media_session_ready: false,
            sdp_negotiated: false,
            call_established_triggered: false,
            local_sdp: None,
            remote_sdp: None,
            negotiated_config: None,
            media_security: None,
            sdp_origin_session_id,
            sdp_origin_version: 0,
            local_media_direction: MediaDirection::SendRecv,
            remote_media_direction: MediaDirection::SendRecv,
            dialog_id: None,
            media_session_id: None,
            call_id: None,
            pending_inbound_invite_transaction_id: None,
            incoming_invite_received_at: None,
            local_uri: None,
            remote_uri: None,
            last_200_ok: None,
            bridged_to: None,
            conference_mixer_id: None,
            transfer_target: None,
            dtmf_digits: None,
            reject_status: None,
            reject_reason: None,
            reject_response_extras: None,
            redirect_response_status: None,
            redirect_response_contacts: Vec::new(),
            early_media_sdp: None,
            pending_auth: None,
            pending_auth_method: None,
            pending_auth_transport: None,
            request_auth_retry_count: 0,
            invite_auth_retry_count: 0,
            redirect_targets: Vec::new(),
            redirect_attempts: 0,
            pending_reinvite: None,
            reinvite_retry_attempts: 0,
            session_timer_min_se: None,
            session_timer_retry_count: 0,
            transfer_state: TransferState::None,
            transfer_notify_dialog: None,
            replaces_header: None,
            referred_by: None,
            refer_transaction_id: None,
            is_transfer_call: false,
            transferor_session_id: None,
            transfer_target_progress_seen: false,
            transfer_target_last_progress: None,
            pending_bye_reason: None,
            // SIP_API_DESIGN_2 §7.3 stash slots — none populated at
            // session creation.
            pending_invite_options: None,
            pending_reinvite_options: None,
            pending_register_options: None,
            pending_refer_options: None,
            pending_bye_options: None,
            pending_cancel_options: None,
            pending_notify_options: None,
            pending_subscribe_options: None,
            pending_info_options: None,
            pending_update_options: None,
            pending_message_options: None,
            pending_options_options: None,
            registrar_uri: None,
            registration_expires: None,
            registration_contact: None,
            registration_call_id: None,
            registration_cseq: 0,
            registration_accepted_expires: None,
            registration_registered_at: None,
            registration_next_refresh_at: None,
            registration_last_failure: None,
            registration_service_route: None,
            registration_pub_gruu: None,
            registration_temp_gruu: None,
            credentials: None,
            auth: None,
            pai_uri: None,
            extra_headers: Vec::new(),
            is_registered: false,
            auth_challenge: None,
            auth_challenge_raw: None,
            auth_challenge_stale: false,
            auth_challenge_replaces_nonce: None,
            registration_retry_count: 0,
            digest_nc: HashMap::new(),
            created_at: now,
            history: None,
        }
    }

    /// Create with history tracking enabled
    pub fn with_history(session_id: SessionId, role: Role, config: HistoryConfig) -> Self {
        let mut state = Self::new(session_id, role);
        state.history = Some(SessionHistory::new(config));
        state
    }

    /// Record a transition in history
    pub fn record_transition(&mut self, record: TransitionRecord) {
        if let Some(ref mut history) = self.history {
            history.record_transition(record);
        }
    }

    /// Transition to a new state
    pub fn transition_to(&mut self, new_state: CallState) {
        if let Some(ref mut history) = self.history {
            use crate::session_store::TransitionRecord;
            use crate::state_table::EventType;
            let now = Instant::now();
            let record = TransitionRecord {
                timestamp: now,
                timestamp_ms: SystemTime::now()
                    .duration_since(UNIX_EPOCH)
                    .unwrap_or_default()
                    .as_millis() as u64,
                sequence: 0, // Will be set by history
                from_state: self.call_state,
                event: EventType::MediaEvent("transition_to".to_string()),
                to_state: Some(new_state),
                guards_evaluated: vec![],
                actions_executed: vec![],
                duration_ms: 0,
                errors: vec![],
                events_published: vec![],
            };
            history.record_transition(record);
        }
        self.call_state = new_state;
        self.entered_state_at = Instant::now();
    }

    /// Apply condition updates from a transition
    pub fn apply_condition_updates(&mut self, updates: &ConditionUpdates) {
        if let Some(value) = updates.dialog_established {
            self.dialog_established = value;
        }
        if let Some(value) = updates.media_session_ready {
            self.media_session_ready = value;
        }
        if let Some(value) = updates.sdp_negotiated {
            self.sdp_negotiated = value;
        }
    }

    /// Check if all readiness conditions are met
    pub fn all_conditions_met(&self) -> bool {
        self.dialog_established && self.media_session_ready && self.sdp_negotiated
    }

    /// Get time spent in current state
    pub fn time_in_state(&self) -> std::time::Duration {
        Instant::now() - self.entered_state_at
    }

    /// Get total session duration
    pub fn session_duration(&self) -> std::time::Duration {
        Instant::now() - self.created_at
    }
}

fn stable_sdp_origin_session_id(raw_id: &str) -> String {
    let candidate = raw_id
        .strip_prefix("session-")
        .or_else(|| raw_id.strip_prefix("media-session-"))
        .unwrap_or(raw_id);

    if !candidate.is_empty() && candidate.bytes().all(|b| b.is_ascii_digit()) {
        return candidate.to_string();
    }

    if let Ok(uuid) = uuid::Uuid::parse_str(candidate) {
        let bytes = uuid.as_u128().to_be_bytes();
        let low = u64::from_be_bytes(bytes[8..16].try_into().expect("uuid low bytes"));
        return low.max(1).to_string();
    }

    let mut hash = 14_695_981_039_346_656_037u64;
    for byte in raw_id.bytes() {
        hash ^= byte as u64;
        hash = hash.wrapping_mul(1_099_511_628_211);
    }
    hash.max(1).to_string()
}

#[cfg(test)]
mod digest_nc_tests {
    use super::*;
    use crate::state_table::{Role, SessionId};

    /// RFC 7616 §3.4.5 — repeated requests reusing the same nonce
    /// must carry monotonically incrementing `nc`. The exact idiom
    /// used at both call sites (`SendINVITEWithAuth` and REGISTER
    /// auth) is exercised here to guard against drift.
    #[test]
    fn digest_nc_increments_for_same_realm_nonce() {
        let mut session = SessionState::new(SessionId::new(), Role::UAC);
        let key = ("example.com".to_string(), "shared-nonce".to_string());

        let first = *session
            .digest_nc
            .entry(key.clone())
            .and_modify(|n| *n += 1)
            .or_insert(1);
        let second = *session
            .digest_nc
            .entry(key.clone())
            .and_modify(|n| *n += 1)
            .or_insert(1);
        let third = *session
            .digest_nc
            .entry(key.clone())
            .and_modify(|n| *n += 1)
            .or_insert(1);

        assert_eq!(first, 1);
        assert_eq!(second, 2);
        assert_eq!(third, 3);
    }

    /// A fresh challenge with a new nonce gets its own counter space.
    /// The old entry stays in the map but is never read again — the
    /// session's `auth_challenge` field has been overwritten with the
    /// new nonce, so subsequent compute calls use the new key.
    #[test]
    fn digest_nc_keys_independent_per_nonce() {
        let mut session = SessionState::new(SessionId::new(), Role::UAC);
        let key_a = ("example.com".to_string(), "nonce-A".to_string());
        let key_b = ("example.com".to_string(), "nonce-B".to_string());

        for _ in 0..5 {
            session
                .digest_nc
                .entry(key_a.clone())
                .and_modify(|n| *n += 1)
                .or_insert(1);
        }

        let first_b = *session
            .digest_nc
            .entry(key_b.clone())
            .and_modify(|n| *n += 1)
            .or_insert(1);
        assert_eq!(first_b, 1, "fresh nonce starts a new counter");
        assert_eq!(*session.digest_nc.get(&key_a).unwrap(), 5);
    }
}