matter-controller 0.5.0

High-level Matter controller API: commission, read, write, invoke, subscribe.
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
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
//! The OTA **provider server**: a dedicated task that advertises our
//! operational service, accepts an inbound CASE session as the responder, and
//! dispatches one server-side `InvokeRequest`. Productionizes the responder
//! accept-flow proven in the actor's loopback tests; hosts it in
//! `matter-controller` so it can reuse the persisted operational identity
//! (`crate::credentials::operational_credentials`) and the existing session /
//! transport / discovery machinery without a new crate boundary.
//!
//! This module is `pub(crate)`; its low-level items are re-exported publicly
//! only under the `unstable-provider` feature (the stable path is
//! `MatterController::serve_ota`). The items stay `pub` so that re-export can
//! widen them, so `unreachable_pub` is a false positive in the feature-off
//! build โ€” allow it module-wide.
#![allow(unreachable_pub)]
// With `ota` off, this module is still compiled โ€” `build_operational_service`
// serves the non-OTA check-in listener, and `unstable-provider` re-exports
// `ProviderServer` from here โ€” but most of the OTA-serving machinery
// (`serve_ota_once` and its helpers) has no caller in that configuration,
// so `dead_code`/`unused_imports` would otherwise fire workspace-wide.
#![cfg_attr(not(feature = "ota"), allow(dead_code, unused_imports))]

use std::net::{IpAddr, SocketAddr};
use std::sync::Arc;
use std::time::Instant;

use matter_cert::{MatterTime, TrustedRoots};
use matter_commissioning::driver::{decode_unsecured, encode_unsecured_reply, AsyncDatagram};
use matter_crypto::{CaseCredentials, CaseResponder, ResumptionRecord, Sigma1Outcome};
use matter_interaction::{
    build_invoke_response_command, build_invoke_response_status, parse_invoke_request, CommandPath,
    ImStatus,
};
// Only referenced by the unstable generic-provider `accept_and_dispatch_once`.
#[cfg(any(feature = "unstable-provider", test))]
use matter_interaction::ParsedInvokeRequest;
use matter_transport::{
    DecodeInboundOutput, MatterService, MrpFlags, ProtocolId, ServiceKind, SessionId,
    SessionManager, SessionRole,
};

use crate::error::Error;

// SecureChannel handshake opcodes (Matter Core ยง4.10 / ยง4.13).
const OP_SIGMA1: u8 = 0x30;
const OP_SIGMA2: u8 = 0x31;
const OP_SIGMA3: u8 = 0x32;
const OP_SIGMA2_RESUME: u8 = 0x33;
const OP_STATUS_REPORT: u8 = 0x40;
const OP_MRP_STANDALONE_ACK: u8 = 0x10;
// Interaction Model opcodes.
const OP_INVOKE_REQUEST: u8 = 0x08;

/// Frames discarded while awaiting a Sigma1 before the accept fails. Stray
/// LAN datagrams to the advertised port (undecodable noise, stale acks,
/// leftovers from a discarded session) must not consume pooled credentials โ€”
/// but a flooder should still hit a bound rather than spin the accept
/// forever.
const MAX_AWAIT_SIGMA1_DISCARDS: usize = 64;
const OP_INVOKE_RESPONSE: u8 = 0x09;

// Secure-Channel StatusReport general codes (Matter Core ยง4.11.6).
const STATUS_GENERAL_FAILURE: u16 = 0x0001;

/// Encode the fixed 8-byte Secure-Channel `StatusReport` body (Matter Core
/// ยง4.11.6): `GeneralCode` (u16 LE) || `ProtocolId` (u32 LE, `vendor<<16 |
/// protocol`) || `ProtocolStatus` (u16 LE). Used to abort a BDX transfer
/// so the peer learns the failure instead of timing out.
fn encode_status_report_body(general: u16, proto: ProtocolId, protocol_status: u16) -> Vec<u8> {
    let proto_id: u32 = (u32::from(proto.vendor) << 16) | u32::from(proto.protocol);
    let mut body = Vec::with_capacity(8);
    body.extend_from_slice(&general.to_le_bytes());
    body.extend_from_slice(&proto_id.to_le_bytes());
    body.extend_from_slice(&protocol_status.to_le_bytes());
    body
}

/// Parse the fixed 8-byte Secure-Channel `StatusReport` body into
/// `(general_code, protocol_id, protocol_status)`. Returns `None` if the body
/// is shorter than 8 bytes.
fn parse_status_report_body(payload: &[u8]) -> Option<(u16, u32, u16)> {
    let b: &[u8; 8] = payload.get(..8)?.try_into().ok()?;
    Some((
        u16::from_le_bytes([b[0], b[1]]),
        u32::from_le_bytes([b[2], b[3], b[4], b[5]]),
        u16::from_le_bytes([b[6], b[7]]),
    ))
}

// OtaSoftwareUpdateProvider (0x0029) command ids (Matter Core ยง11.20).
const OTA_PROVIDER_CLUSTER: u32 = 0x0029;
const CMD_QUERY_IMAGE: u32 = 0x00;
const CMD_QUERY_IMAGE_RESPONSE: u32 = 0x01;
const CMD_APPLY_UPDATE_REQUEST: u32 = 0x02;
const CMD_APPLY_UPDATE_RESPONSE: u32 = 0x03;
const CMD_NOTIFY_UPDATE_APPLIED: u32 = 0x04;

/// True when `frame` is an unsecured (session id 0) message โ€” i.e. a new
/// session-establishment attempt arriving while a secured session is being
/// served. Bytes 1..3 are the little-endian session id (Matter Core ยง4.4.1).
fn is_unsecured_frame(frame: &[u8]) -> bool {
    frame.len() >= 3 && frame[1] == 0 && frame[2] == 0
}

/// A raw datagram (frame bytes + sender) handed from one accept to the next,
/// so no handshake bytes are lost across session boundaries.
type CarriedFrame = (Vec<u8>, SocketAddr);

/// Build the operational `_matter._tcp` mDNS record to advertise so a requestor
/// can resolve us. Instance name is `<compressed-fabric-id>-<node-id>` in
/// uppercase hex (Matter Core ยง4.3.1), matching what the controller's initiator
/// resolves against via `operational_instance_name`.
#[must_use]
pub fn build_operational_service(
    compressed_fabric_id: [u8; 8],
    node_id: u64,
    addresses: Vec<IpAddr>,
    port: u16,
) -> MatterService {
    let instance_name =
        matter_commissioning::driver::operational_instance_name(compressed_fabric_id, node_id);
    // Operational TXT params (SII/SAI/SAT) are optional hints; F3 advertises
    // none (the requestor resolves us by SRV + A/AAAA). F4/hardening can add
    // session-interval hints if a requestor needs them.
    MatterService::new(
        instance_name,
        ServiceKind::Operational,
        addresses,
        port,
        std::collections::HashMap::new(),
    )
}

/// A multi-session OTA provider server: accepts inbound CASE sessions as the
/// responder (one per pooled credential), then dispatches server-side
/// `InvokeRequest`s. Generic over the datagram transport so it runs over
/// `TokioUdpTransport` in production and `InMemoryDatagram` in tests.
///
/// This productionizes the responder accept-flow proven in the actor's loopback
/// tests (`run_loopback_device`): Sigma1โ†’Sigma2โ†’Sigma3โ†’`SessionManager` register,
/// then secured IM dispatch on the established session.
///
/// The credential pool is consumed one entry per `accept_case` call. When the
/// pool is exhausted, `accept_case` (and any caller such as `serve_ota_once`)
/// returns [`Error::Operational`] with the message
/// `"provider server: credential pool exhausted"`. The pool is sized by the
/// caller โ€” `serve_ota` mints four entries (first session + post-reboot session
/// + retry slack) from the persisted fabric.
pub struct ProviderServer<D> {
    io: D,
    /// Pool of operational identities, one consumed per CASE accept (the
    /// responder state machine takes ownership of its credentials).
    /// `serve_ota` mints these from the persisted fabric โ€” see the spec's
    /// sizing rationale (first session + post-reboot session + retry slack).
    credentials: Vec<CaseCredentials>,
    roots: TrustedRoots,
    /// Base secured session id; accept N advertises `base.wrapping_add(N)` so
    /// consecutive sessions never share a local id.
    base_session_id: u16,
    /// Number of accepts performed so far (also indexes the session id).
    accepts: u16,
    now: MatterTime,
    handshake_counter: u32,
    /// When set, an accepted session whose authenticated peer node id is not
    /// this value fails the accept (its pooled credential is consumed โ€” that
    /// is the point: a fabric member other than the OTA target must not be
    /// able to hijack the serve). `serve_ota` pins its `target_node_id`.
    expected_peer: Option<u64>,
    /// Known CASE resumption records. When an inbound Sigma1 carries
    /// resumption fields whose id matches one of these, the session is
    /// resumed (`Sigma2_Resume`) instead of a full handshake โ€” chip's OTA
    /// requestor always requests resumption of the session the controller
    /// just used to announce, so `serve_ota` seeds this with the announce
    /// connect's persisted record. No match falls back to
    /// `reject_resumption` + full handshake.
    resumption_records: Vec<ResumptionRecord>,
    /// Invoked with the fresh [`ResumptionRecord`] each accept produces
    /// (rotated on the resumed path, brand-new on the full path), so the
    /// caller can persist it IMMEDIATELY โ€” a caller-side timeout that drops
    /// the serve future must not lose the rotation. Best-effort: the sink
    /// must not block (spawn if it needs async work).
    record_sink: Option<Box<dyn Fn(ResumptionRecord) + Send + Sync>>,
}

impl<D: AsyncDatagram> ProviderServer<D> {
    /// Build a provider server bound to `io`, authenticating from the
    /// `credentials` pool (our operational identities). `roots` and `now` are
    /// used to validate the peer's certificate chain on each accept.
    ///
    /// `base_session_id` is the first secured session id advertised in Sigma2;
    /// the Nth accept uses `base_session_id.wrapping_add(N)` so consecutive
    /// sessions never reuse the same local id.
    ///
    /// The pool is consumed one entry per accept. When it is empty, the next
    /// call to `serve_ota_once` (or any method that calls `accept_case`)
    /// returns an [`Error::Operational`] containing
    /// `"provider server: credential pool exhausted"`.
    #[must_use]
    pub fn new(
        io: D,
        credentials: Vec<CaseCredentials>,
        roots: TrustedRoots,
        base_session_id: u16,
        now: MatterTime,
    ) -> Self {
        Self {
            io,
            credentials,
            roots,
            base_session_id,
            accepts: 0,
            now,
            handshake_counter: 1,
            expected_peer: None,
            resumption_records: Vec::new(),
            record_sink: None,
        }
    }

    /// Register a callback that is invoked once per completed accept with the
    /// fresh [`ResumptionRecord`] the handshake produced (rotated on the resumed
    /// path, brand-new on the full path). The caller can use this to persist the
    /// record immediately โ€” a future that is cancelled after `accept_case`
    /// completes but before the caller stores the record would otherwise lose the
    /// rotation. The sink is called synchronously and **must not block**; spawn
    /// an async task if async work is needed.
    #[must_use]
    pub fn with_record_sink(mut self, sink: Box<dyn Fn(ResumptionRecord) + Send + Sync>) -> Self {
        self.record_sink = Some(sink);
        self
    }

    /// Seed the server with known CASE resumption records (see the field
    /// docs). An inbound resumption-requesting Sigma1 matching one of these
    /// by id is accepted via `Sigma2_Resume`; anything else falls back to a
    /// full handshake.
    #[must_use]
    pub fn with_resumption_records(mut self, records: Vec<ResumptionRecord>) -> Self {
        self.resumption_records = records;
        self
    }

    /// Pin the peer: an accepted session must authenticate as `node_id` or
    /// the accept fails (consuming its pooled credential). Without this, any
    /// member of the fabric could consume the serve.
    #[must_use]
    pub fn with_expected_peer(mut self, node_id: u64) -> Self {
        self.expected_peer = Some(node_id);
        self
    }

    fn next_handshake_counter(&mut self) -> u32 {
        let c = self.handshake_counter;
        self.handshake_counter = self.handshake_counter.wrapping_add(1);
        c
    }

    async fn recv(&self) -> Result<(Vec<u8>, SocketAddr), Error> {
        self.io
            .recv_from()
            .await
            .map_err(|e| Error::Operational(format!("provider recv: {e}")))
    }

    async fn send(&self, bytes: &[u8], peer: SocketAddr) -> Result<(), Error> {
        self.io
            .send_to(bytes, peer)
            .await
            .map_err(|e| Error::Operational(format!("provider send: {e}")))
    }

    /// Receive the next datagram while driving the session's MRP timers, so
    /// scheduled standalone acks (and retransmits) fire even while we sit in
    /// `recv`. Load-bearing for the OTA flow: the requestor's `BlockAckEOF`
    /// is MRP-reliable and we reply with nothing โ€” without the pumped
    /// standalone ack, chip retransmits it, marks the session defunct, and
    /// abandons the update before `ApplyUpdateRequest` (observed live).
    async fn recv_secured(
        &self,
        sessions: &mut SessionManager,
        peer: SocketAddr,
    ) -> Result<(Vec<u8>, SocketAddr), Error> {
        use matter_transport::MrpEvent;
        loop {
            let Some(deadline) = sessions.poll_timeout() else {
                return self.recv().await;
            };
            let wait = deadline.saturating_duration_since(Instant::now());
            match tokio::time::timeout(wait, self.recv()).await {
                Ok(result) => return result,
                Err(_deadline_hit) => {
                    for event in sessions.handle_timeout(Instant::now()) {
                        match event {
                            MrpEvent::Retransmit { packet, .. }
                            | MrpEvent::SendStandaloneAck { packet, .. } => {
                                self.send(&packet, peer).await?;
                            }
                            // Single-session server: nothing to resolve on
                            // expiry; `MrpEvent` is non_exhaustive.
                            _ => {}
                        }
                    }
                }
            }
        }
    }

    /// Accept ONE inbound CASE session as the responder, returning an
    /// established [`SessionManager`] + the secured `SessionId` + the peer's
    /// address. Mirrors the proven `run_loopback_device` accept-flow on the full
    /// path; a Sigma1 carrying resumption fields that match a seeded record (see
    /// [`Self::with_resumption_records`]) takes the `Sigma2_Resume` fast path
    /// instead.
    ///
    /// The fresh [`ResumptionRecord`] the handshake produces is handled
    /// internally: it is re-seeded into `self.resumption_records` (so the NEXT
    /// accept can match it) and passed to the `record_sink` (if set) before this
    /// method returns.
    ///
    /// If `first_frame` is `Some`, that datagram is used as the Sigma1 instead
    /// of calling `recv` โ€” useful for callers that have already peeked the first
    /// packet (e.g., a multi-session loop that demuxes by session id).
    ///
    /// The returned [`CarriedFrame`] is `Some` when the full-handshake close
    /// saw a NEW Sigma1 in place of the initiator's standalone ack (see
    /// [`Self::complete_full`]); the caller must feed it into its next accept
    /// or the handshake attempt it opens is lost.
    async fn accept_case(
        &mut self,
        first_frame: Option<CarriedFrame>,
    ) -> Result<(SessionManager, SessionId, SocketAddr, Option<CarriedFrame>), Error> {
        // Fast-fail an exhausted pool before any IO. The check does NOT pop:
        // a credential is consumed only once a valid Sigma1 is in hand, so
        // stray datagrams to the advertised port cannot burn the pool.
        if self.credentials.is_empty() {
            return Err(Error::Operational(
                "provider server: credential pool exhausted".into(),
            ));
        }

        // Await a valid Sigma1, discarding anything else (undecodable noise,
        // stray acks, stale secured frames) within a bounded budget.
        let mut carried = first_frame;
        let mut discarded = 0usize;
        let (m1, peer) = loop {
            let (bytes, from) = match carried.take() {
                Some(f) => f,
                None => self.recv().await?,
            };
            match decode_unsecured(&bytes) {
                Ok(m) if m.opcode == OP_SIGMA1 => break (m, from),
                _ => {
                    discarded += 1;
                    if discarded >= MAX_AWAIT_SIGMA1_DISCARDS {
                        return Err(Error::Operational(format!(
                            "no Sigma1 within {MAX_AWAIT_SIGMA1_DISCARDS} frames"
                        )));
                    }
                }
            }
        };

        // A real handshake attempt is starting: consume one pooled identity.
        let credentials = self.credentials.remove(0);
        let responder_session_id = self.base_session_id.wrapping_add(self.accepts);
        self.accepts = self.accepts.wrapping_add(1);
        let mut responder = CaseResponder::new(
            credentials,
            self.roots.clone(),
            responder_session_id,
            self.now,
        )
        .map_err(|e| Error::Operational(format!("CASE responder init: {e}")))?;

        let outcome = responder
            .handle_sigma1(&m1.payload)
            .map_err(|e| Error::Operational(format!("handle_sigma1: {e}")))?;

        let resumed = match outcome {
            Sigma1Outcome::NewSession => false,
            Sigma1Outcome::ResumptionRequested { id } => {
                if let Some(pos) = self.resumption_records.iter().position(|r| r.id == id) {
                    let record = self.resumption_records.swap_remove(pos);
                    responder
                        .accept_resumption(record)
                        .map_err(|e| Error::Operational(format!("accept_resumption: {e}")))?;
                    true
                } else {
                    // Unknown id โ€” decline and fall back to a full handshake.
                    responder
                        .reject_resumption()
                        .map_err(|e| Error::Operational(format!("reject_resumption: {e}")))?;
                    false
                }
            }
        };

        let carry = if resumed {
            self.complete_resumed(&mut responder, &m1, peer).await?;
            None
        } else {
            self.complete_full(&mut responder, &m1, peer).await?
        };

        let output = responder
            .finish()
            .map_err(|e| Error::Operational(format!("CASE finish: {e}")))?;
        // Enforce the pin BEFORE re-seeding/sinking the record: a rejected
        // peer must leave no resumption state behind.
        if let Some(expected) = self.expected_peer {
            if output.peer.node_id != expected {
                return Err(Error::Operational(format!(
                    "provider server: accepted peer node {:#x} is not the expected {expected:#x}",
                    output.peer.node_id
                )));
            }
        }
        if let Some(record) = output.resumption_record.clone() {
            // Re-seed so the NEXT accept (the post-reboot requestor resumes
            // with the id rotated during THIS handshake) can match it.
            self.resumption_records.push(record.clone());
            if let Some(sink) = &self.record_sink {
                sink(record);
            }
        }
        let mut sessions = SessionManager::new();
        let sid = sessions.register_case(&output, SessionRole::Responder);
        Ok((sessions, sid, peer, carry))
    }

    /// Resumed path: send `Sigma2_Resume` on Sigma1's exchange, then await the
    /// initiator's success `StatusReport` and standalone-ack it (the report is
    /// MRP-reliable; without our ack chip retransmits it and eventually tears
    /// the exchange down). Tolerates interleaved Sigma1 retransmits (re-sends
    /// `Sigma2_Resume`) and stray standalone acks.
    async fn complete_resumed(
        &mut self,
        responder: &mut CaseResponder,
        m1: &matter_commissioning::driver::UnsecuredMessage,
        peer: SocketAddr,
    ) -> Result<(), Error> {
        let sigma2_resume = responder
            .next_message()
            .map_err(|e| Error::Operational(format!("sigma2_resume: {e}")))?;
        let c = self.next_handshake_counter();
        let wire = encode_unsecured_reply(
            c,
            m1.exchange_id,
            OP_SIGMA2_RESUME,
            ProtocolId::SECURE_CHANNEL,
            true,
            Some(m1.message_counter),
            m1.source_node_id,
            &sigma2_resume,
        );
        self.send(&wire, peer).await?;

        // Await the initiator's SigmaFinished success StatusReport, within a
        // bounded frame budget.
        for _ in 0..8 {
            let (bytes, _) = self.recv().await?;
            let m = decode_unsecured(&bytes)
                .map_err(|e| Error::Operational(format!("post-resume frame: {e}")))?;
            match m.opcode {
                OP_STATUS_REPORT => {
                    // StatusReport body: GeneralCode(u16 LE) || ProtocolId(u32) || ProtocolCode(u16).
                    let general_code = m
                        .payload
                        .get(0..2)
                        .map(|b| u16::from_le_bytes([b[0], b[1]]))
                        .ok_or_else(|| {
                            Error::Operational("truncated resumption StatusReport".into())
                        })?;
                    if general_code != 0 {
                        return Err(Error::Operational(format!(
                            "initiator rejected resumption: StatusReport general code {general_code}"
                        )));
                    }
                    // Ack the reliable report so the initiator's MRP settles.
                    let c = self.next_handshake_counter();
                    let ack = encode_unsecured_reply(
                        c,
                        m.exchange_id,
                        OP_MRP_STANDALONE_ACK,
                        ProtocolId::SECURE_CHANNEL,
                        false,
                        Some(m.message_counter),
                        m.source_node_id.or(m1.source_node_id),
                        &[],
                    );
                    self.send(&ack, peer).await?;
                    return Ok(());
                }
                // Sigma1 retransmit: our Sigma2_Resume (or its ack) was lost โ€”
                // re-send it on the same exchange.
                OP_SIGMA1 => {
                    let c = self.next_handshake_counter();
                    let wire = encode_unsecured_reply(
                        c,
                        m.exchange_id,
                        OP_SIGMA2_RESUME,
                        ProtocolId::SECURE_CHANNEL,
                        true,
                        Some(m.message_counter),
                        m.source_node_id.or(m1.source_node_id),
                        &sigma2_resume,
                    );
                    self.send(&wire, peer).await?;
                }
                // A standalone ack of our Sigma2_Resume โ€” fine, keep waiting.
                OP_MRP_STANDALONE_ACK => {}
                other => {
                    return Err(Error::Operational(format!(
                        "expected resumption StatusReport (0x40), got {other:#04x}"
                    )))
                }
            }
        }
        Err(Error::Operational(
            "no StatusReport after Sigma2_Resume within frame budget".into(),
        ))
    }

    /// Full-handshake path (Sigma2 โ†’ Sigma3 โ†’ our success `StatusReport`), used
    /// for a plain Sigma1 and as the fallback after `reject_resumption`.
    ///
    /// Returns the frame to carry into the next accept when the closing
    /// ack-absorb `recv` saw a NEW Sigma1 instead of the initiator's
    /// standalone ack: a requestor that applies and reboots fast can have its
    /// next handshake's Sigma1 in flight before the ack โ€” eating it would
    /// force the peer through an MRP retransmit round AND burn one pooled
    /// retry credential on this side. Everything else (the ack, noise, a
    /// same-exchange Sigma1 โ€” a stale duplicate of `m1`, provably already
    /// answered because Sigma3 arrived) is absorbed as before.
    async fn complete_full(
        &mut self,
        responder: &mut CaseResponder,
        m1: &matter_commissioning::driver::UnsecuredMessage,
        peer: SocketAddr,
    ) -> Result<Option<CarriedFrame>, Error> {
        let sigma2 = responder
            .next_message()
            .map_err(|e| Error::Operational(format!("sigma2: {e}")))?;
        let c = self.next_handshake_counter();
        let wire = encode_unsecured_reply(
            c,
            m1.exchange_id,
            OP_SIGMA2,
            ProtocolId::SECURE_CHANNEL,
            true,
            Some(m1.message_counter),
            m1.source_node_id,
            &sigma2,
        );
        self.send(&wire, peer).await?;

        // Sigma3 โ†’ success StatusReport.
        let (s3, _) = self.recv().await?;
        let m3 = decode_unsecured(&s3).map_err(|e| Error::Operational(format!("sigma3: {e}")))?;
        if m3.opcode != OP_SIGMA3 {
            return Err(Error::Operational(format!(
                "expected Sigma3 (0x32), got {:#04x}",
                m3.opcode
            )));
        }
        responder
            .handle_sigma3(&m3.payload)
            .map_err(|e| Error::Operational(format!("handle_sigma3: {e}")))?;
        let mut body = Vec::with_capacity(8);
        body.extend_from_slice(&0u16.to_le_bytes()); // GeneralCode: success
        body.extend_from_slice(&0u32.to_le_bytes()); // ProtocolId: SecureChannel
        body.extend_from_slice(&0u16.to_le_bytes()); // ProtocolCode: 0
        let c = self.next_handshake_counter();
        let report = encode_unsecured_reply(
            c,
            m3.exchange_id,
            OP_STATUS_REPORT,
            ProtocolId::SECURE_CHANNEL,
            true,
            Some(m3.message_counter),
            m3.source_node_id.or(m1.source_node_id),
            &body,
        );
        self.send(&report, peer).await?;

        // Absorb the initiator's standalone ack of our StatusReport โ€” but hand
        // a fresh Sigma1 (new handshake, new exchange) back to the caller
        // instead of eating it (see the method docs).
        let (bytes, from) = self.recv().await?;
        if let Ok(m) = decode_unsecured(&bytes) {
            if m.opcode == OP_SIGMA1 && m.exchange_id != m1.exchange_id {
                return Ok(Some((bytes, from)));
            }
        }
        Ok(None)
    }

    /// Accept ONE inbound CASE session, then dispatch up to `max_invokes`
    /// server-side `InvokeRequest`s through `handler`, replying to each on its
    /// exchange. Returns the number of invokes dispatched.
    ///
    /// `handler` maps a parsed `InvokeRequest` to the encoded `InvokeResponse`
    /// message bytes (e.g. via `matter_interaction::build_invoke_response_*`).
    ///
    /// # Errors
    ///
    /// Returns [`Error::Operational`] on a transport, CASE-handshake, or framing
    /// failure (including a non-`NewSession` Sigma1 or an unexpected opcode), or
    /// [`Error::Transport`] / [`Error::InteractionModel`] from the session / IM
    /// layers.
    ///
    /// Part of the unstable generic-provider surface (see the module docs); the
    /// stable OTA path uses `serve_ota_once`. Compiled only under
    /// `unstable-provider` (its sole caller, `serve_provider_once`) or in tests.
    #[cfg(any(feature = "unstable-provider", test))]
    pub async fn accept_and_dispatch_once<H>(
        mut self,
        mut handler: H,
        max_invokes: usize,
    ) -> Result<usize, Error>
    where
        H: FnMut(&ParsedInvokeRequest) -> Vec<u8>,
    {
        // Single-session API: there is no next accept to feed a carried
        // Sigma1 into, so it is dropped (the peer's MRP retransmit covers it)
        // โ€” the pre-multi-session behavior.
        let (mut sessions, sid, peer, _fast_sigma1) = self.accept_case(None).await?;

        let mut dispatched = 0usize;
        while dispatched < max_invokes {
            let (wire, _) = self.recv_secured(&mut sessions, peer).await?;
            if let DecodeInboundOutput::AppMessage {
                exchange_id,
                opcode,
                payload,
                ..
            } = sessions.decode_inbound(&wire, Instant::now())?
            {
                if opcode != OP_INVOKE_REQUEST {
                    // Ignore non-invoke app messages in F3 (e.g. reads).
                    continue;
                }
                let parsed = parse_invoke_request(&payload)?;
                let response = handler(&parsed);
                let out = sessions.encode_outbound(
                    sid,
                    Some(exchange_id),
                    OP_INVOKE_RESPONSE,
                    ProtocolId::INTERACTION_MODEL,
                    &response,
                    MrpFlags { reliable: false },
                    Instant::now(),
                )?;
                self.send(&out.wire_bytes, peer).await?;
                dispatched += 1;
            }
        }
        Ok(dispatched)
    }

    /// Accept CASE sessions in sequence, serving `image` to the requestor over the
    /// full OTA flow โ€” `QueryImage` โ†’ `QueryImageResponse`, a BDX transfer, then
    /// `ApplyUpdateRequest` โ†’ `ApplyUpdateResponse` (Proceed) โ€” and completing once
    /// `NotifyUpdateApplied` is received on ANY session. A real requestor downloads
    /// and applies on its first session, reboots into the new image, and sends
    /// `NotifyUpdateApplied` on a fresh session; this method spans that reboot by
    /// running an outer loop over `accept_case` calls.
    ///
    /// Unsecured frames (session id 0) arriving while a secured session is being
    /// served are recognised as new-session-establishment attempts; they are
    /// carried into the next outer iteration as the `first_frame` for the next
    /// `accept_case` call, so no handshake bytes are lost.
    ///
    /// The caller owns the deadline: wrap `serve_ota_once` in
    /// `tokio::time::timeout` (or similar) to bound a requestor that never
    /// returns. Pool exhaustion (all credentials consumed) and a per-session step
    /// budget are the two error paths.
    ///
    /// The fresh [`ResumptionRecord`] the accept handshake produced is re-seeded
    /// and forwarded to the `record_sink` (if set via
    /// [`Self::with_record_sink`]) before the OTA dispatch loop begins โ€” the
    /// caller need not wait for the full OTA flow to persist the rotation.
    ///
    /// `offer` shapes the `QueryImageResponse` (its `ImageURI`/`UpdateToken`);
    /// `max_block_size` caps each BDX block. All replies are unreliable
    /// (piggyback ack) โ€” happy-path, localhost-validated. Messages route by
    /// [`ProtocolId`]: Interaction-Model invokes go to the `matter-ota` handlers,
    /// `ProtocolId::BDX` messages drive a [`matter_bdx::BlockSender`].
    ///
    /// # Errors
    ///
    /// [`Error::Operational`] on a CASE/transport/codec failure, a BDX abort, an
    /// unexpected OTA command, or if a session exhausts its step budget without
    /// an unsecured carry-frame; [`Error::Transport`] / [`Error::InteractionModel`]
    /// from the session / IM layers.
    #[cfg(feature = "ota")]
    #[allow(clippy::too_many_lines)] // Linear OTA protocol-dispatch loop; splitting hurts clarity.
    pub async fn serve_ota_once(
        mut self,
        offer: matter_ota::ImageOffer,
        image: Vec<u8>,
        max_block_size: u16,
    ) -> Result<(), Error> {
        use matter_bdx::{BdxMessage, BlockSender, MessageType, SenderOutcome};

        // Shared once so every `BlockSender` (the initial QueryImage arm and
        // any cross-session ReceiveInit re-arm below) clones the `Arc`
        // handle rather than the image bytes.
        let image: Arc<[u8]> = Arc::from(image);

        // Flow state spans sessions: the requestor downloads + applies on its
        // first session, REBOOTS into the image, and sends NotifyUpdateApplied
        // on a fresh session (usually resuming the record rotated during the
        // first accept โ€” re-seeded by accept_case).
        let mut bdx: Option<BlockSender> = None;
        let mut carried: Option<(Vec<u8>, SocketAddr)> = None;

        // Outer: one iteration per CASE session; bounded by the credential
        // pool (accept_case errors when it is exhausted). A failed mid-flow
        // handshake poisons only that accept (spec: Error handling) โ€” it
        // consumed one pooled credential, and the loop waits for the peer's
        // next attempt; only pool exhaustion (or the caller's deadline) ends
        // the serve.
        loop {
            let (mut sessions, sid, peer, fast_sigma1) =
                match self.accept_case(carried.take()).await {
                    Ok(accepted) => accepted,
                    Err(e) => {
                        if self.credentials.is_empty() {
                            return Err(e); // exhausted (or the last credential's failure)
                        }
                        continue; // retry with the next pooled credential
                    }
                };
            if let Some(frame) = fast_sigma1 {
                // The peer opened a NEW handshake instead of acking this one's
                // close (fast post-reboot Sigma1 in place of the standalone
                // ack): the session just established is already abandoned โ€”
                // roll the Sigma1 straight into the next accept rather than
                // blocking the inner loop on a dead session.
                carried = Some(frame);
                continue;
            }

            // BDX-4: bound progress and iteration SEPARATELY. `max_progress`
            // caps how many transfer-ADVANCING messages (OTA commands + blocks)
            // we serve; a larger `max_iterations` backstop bounds frames that do
            // NOT advance the transfer (stale prior-session retransmits, the
            // duplicate-reliable ack resends BDX-2 handles, a peer StatusReport).
            // Counting every frame against one budget โ€” as the old `steps`
            // did โ€” let a lossy mesh's retransmits exhaust it before the last
            // block arrived, turning a recoverable loss into a spurious failure.
            let max_progress = image.len() / usize::from(max_block_size.max(1)) + 64;
            let max_iterations = max_progress.saturating_mul(8).max(1024);
            let mut progress = 0usize;
            let mut iterations = 0usize;

            // Inner: serve this session until Notify (done), a new handshake
            // frame (roll into the next accept), or a bound.
            while progress < max_progress && iterations < max_iterations {
                iterations += 1;
                let (wire, from) = self.recv_secured(&mut sessions, peer).await?;
                if is_unsecured_frame(&wire) {
                    carried = Some((wire, from));
                    break;
                }
                // A frame that fails secured decode is a stale leftover โ€” e.g.
                // a late retransmit keyed to a PRIOR session's id after the
                // requestor re-established (the reboot window) โ€” not a fault
                // of the live session. Skip it; the step budget bounds a
                // pathological stream of them.
                let Ok(decoded) = sessions.decode_inbound(&wire, Instant::now()) else {
                    continue;
                };
                let DecodeInboundOutput::AppMessage {
                    exchange_id,
                    protocol_id,
                    opcode,
                    payload,
                    ..
                } = decoded
                else {
                    // BDX-2: the requestor retransmitted a reliable message
                    // (e.g. a BlockQuery) whose ack was lost. decode_inbound has
                    // pre-built the standalone ack to re-send โ€” send it and do
                    // NOT advance BDX state (the block counter already moved).
                    // Dropping it here (the old `continue`) left the requestor
                    // retransmitting forever, stalling the transfer. Other
                    // non-app outcomes (AckOnly) need no response.
                    if let DecodeInboundOutput::DuplicateReliableAckResent { ack_packet, .. } =
                        decoded
                    {
                        self.send(&ack_packet, peer).await?;
                    }
                    continue;
                };

                // BDX-4: only a message that ADVANCES the transfer (an OTA
                // invoke or a BDX message) counts against `max_progress`. A
                // stale/duplicate frame `continue`s above without reaching here,
                // so it burns only an `iterations` slot, never the progress
                // budget.
                let advanced = (protocol_id == ProtocolId::INTERACTION_MODEL
                    && opcode == OP_INVOKE_REQUEST)
                    || protocol_id == ProtocolId::BDX;

                if protocol_id == ProtocolId::INTERACTION_MODEL && opcode == OP_INVOKE_REQUEST {
                    let parsed = parse_invoke_request(&payload)?;
                    let cmd = parsed
                        .commands
                        .first()
                        .ok_or_else(|| Error::Operational("OTA invoke had no command".into()))?;
                    let response = if cmd.path.command == CMD_QUERY_IMAGE {
                        bdx = Some(BlockSender::from_shared(Arc::clone(&image), max_block_size));
                        let fields = matter_ota::handle_query_image(&cmd.fields_tlv, Some(&offer))
                            .map_err(|e| Error::Operational(format!("QueryImage: {e}")))?;
                        build_invoke_response_command(
                            CommandPath {
                                endpoint: 0,
                                cluster: OTA_PROVIDER_CLUSTER,
                                command: CMD_QUERY_IMAGE_RESPONSE,
                            },
                            &fields,
                        )
                    } else if cmd.path.command == CMD_APPLY_UPDATE_REQUEST {
                        let fields = matter_ota::handle_apply_update_request(&cmd.fields_tlv)
                            .map_err(|e| Error::Operational(format!("ApplyUpdateRequest: {e}")))?;
                        build_invoke_response_command(
                            CommandPath {
                                endpoint: 0,
                                cluster: OTA_PROVIDER_CLUSTER,
                                command: CMD_APPLY_UPDATE_RESPONSE,
                            },
                            &fields,
                        )
                    } else if cmd.path.command == CMD_NOTIFY_UPDATE_APPLIED {
                        matter_ota::parse_notify_update_applied(&cmd.fields_tlv)
                            .map_err(|e| Error::Operational(format!("NotifyUpdateApplied: {e}")))?;
                        let r = build_invoke_response_status(
                            CommandPath {
                                endpoint: 0,
                                cluster: OTA_PROVIDER_CLUSTER,
                                command: CMD_NOTIFY_UPDATE_APPLIED,
                            },
                            ImStatus::Success,
                        );
                        let out = sessions.encode_outbound(
                            sid,
                            Some(exchange_id),
                            OP_INVOKE_RESPONSE,
                            ProtocolId::INTERACTION_MODEL,
                            &r,
                            MrpFlags { reliable: false },
                            Instant::now(),
                        )?;
                        self.send(&out.wire_bytes, peer).await?;
                        return Ok(());
                    } else {
                        return Err(Error::Operational(format!(
                            "unexpected OTA command {:#04x}",
                            cmd.path.command
                        )));
                    };
                    let out = sessions.encode_outbound(
                        sid,
                        Some(exchange_id),
                        OP_INVOKE_RESPONSE,
                        ProtocolId::INTERACTION_MODEL,
                        &response,
                        MrpFlags { reliable: false },
                        Instant::now(),
                    )?;
                    self.send(&out.wire_bytes, peer).await?;
                } else if protocol_id == ProtocolId::SECURE_CHANNEL && opcode == OP_STATUS_REPORT {
                    // BDX-3 (receive): the requestor aborted via a Secure-Channel
                    // StatusReport โ€” e.g. a device-side flash-write failure. End
                    // the transfer with a descriptive error naming the peer's
                    // status, instead of ignoring it and spinning to the "step
                    // budget exceeded" error (chip surfaces the peer status;
                    // TestBdxTransferSession.cpp:629).
                    let (general, proto, code) =
                        parse_status_report_body(&payload).ok_or_else(|| {
                            Error::Operational("BDX StatusReport body truncated".into())
                        })?;
                    return Err(Error::Operational(format!(
                        "BDX transfer aborted by peer: StatusReport general={general:#06x} \
                         protocol={proto:#010x} status={code:#06x}"
                    )));
                } else if protocol_id == ProtocolId::BDX {
                    let mt = MessageType::from_u8(opcode).ok_or_else(|| {
                        Error::Operational(format!("unknown BDX opcode {opcode:#04x}"))
                    })?;
                    let msg = BdxMessage::decode(mt, &payload)
                        .map_err(|e| Error::Operational(format!("BDX decode: {e}")))?;
                    // A `ReceiveInit` is a request to START a transfer. When a
                    // sender is already armed but mid-transfer, the requestor
                    // reconnected mid-download (reboot, link loss) and is
                    // re-initiating BDX from its cached `QueryImageResponse`
                    // URI without re-querying โ€” re-arm and serve from the
                    // start rather than aborting the serve (tolerant choice:
                    // the image is static and the session authenticated โ€” and
                    // peer-pinned under `with_expected_peer` โ€” so re-serving
                    // the same bytes discloses nothing new). The DoS bound is
                    // preserved: BDX still NEVER starts before this serve's
                    // first `QueryImage` (`bdx` stays `None` until then), and
                    // the per-session step budget bounds a requestor that
                    // loops `ReceiveInit`.
                    if matches!(msg, BdxMessage::ReceiveInit(_)) && bdx.is_some() {
                        bdx = Some(BlockSender::from_shared(Arc::clone(&image), max_block_size));
                    }
                    let sender = bdx.as_mut().ok_or_else(|| {
                        Error::Operational("BDX message before QueryImage".into())
                    })?;
                    let outcome = match msg {
                        BdxMessage::ReceiveInit(init) => sender.accept_receive_init(&init),
                        BdxMessage::BlockQuery(q) => sender.handle_block_query(&q),
                        BdxMessage::BlockAckEof(a) => sender.handle_block_ack_eof(&a),
                        _ => {
                            return Err(Error::Operational("unexpected inbound BDX message".into()))
                        }
                    };
                    match outcome {
                        SenderOutcome::Send(out) => {
                            // BDX-1: send every BDX message MRP-reliable so a
                            // lost block/ReceiveAccept is retransmitted (the
                            // recv_secured loop pumps the MRP retransmit timer).
                            // Over a lossy mesh (Thread) the first lost block
                            // otherwise stalls the transfer forever. chip sends
                            // every BDX message with kExpectResponse / never
                            // kNoAutoRequestAck (AsyncTransferFacilitator.cpp:127).
                            let w = sessions.encode_outbound(
                                sid,
                                Some(exchange_id),
                                out.message_type.to_u8(),
                                ProtocolId::BDX,
                                &out.payload,
                                MrpFlags { reliable: true },
                                Instant::now(),
                            )?;
                            self.send(&w.wire_bytes, peer).await?;
                        }
                        SenderOutcome::Done => {}
                        SenderOutcome::Abort(code) => {
                            // BDX-3 (send): notify the peer with a Secure-Channel
                            // StatusReport before bailing, so the requestor learns
                            // the transfer failed instead of timing out. Best
                            // effort โ€” the abort error is returned regardless.
                            let body = encode_status_report_body(
                                STATUS_GENERAL_FAILURE,
                                ProtocolId::BDX,
                                code.to_u16(),
                            );
                            if let Ok(w) = sessions.encode_outbound(
                                sid,
                                Some(exchange_id),
                                OP_STATUS_REPORT,
                                ProtocolId::SECURE_CHANNEL,
                                &body,
                                MrpFlags { reliable: true },
                                Instant::now(),
                            ) {
                                let _ = self.send(&w.wire_bytes, peer).await;
                            }
                            return Err(Error::Operational(format!(
                                "BDX transfer aborted: status {:#06x}",
                                code.to_u16()
                            )));
                        }
                    }
                }

                if advanced {
                    progress += 1;
                }
            }
            if carried.is_none() {
                return Err(Error::Operational(format!(
                    "OTA session ended without completing: served {progress}/{max_progress} \
                     transfer-advancing messages in {iterations}/{max_iterations} iterations"
                )));
            }
        }
    }
}

#[cfg(test)]
mod tests {
    #![allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md carve-out.
    use super::*;
    use std::net::Ipv6Addr;

    #[test]
    fn operational_service_has_expected_name_kind_and_port() {
        let compressed = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE];
        let node_id = 0x0000_0000_0000_0001;
        let addr = IpAddr::V6(Ipv6Addr::LOCALHOST);
        let svc = build_operational_service(compressed, node_id, vec![addr], 5540);

        assert_eq!(svc.kind, ServiceKind::Operational);
        assert_eq!(svc.port, 5540);
        // <16-hex compressed>-<16-hex node>, uppercase.
        assert_eq!(svc.instance_name, "DEADBEEFCAFEBABE-0000000000000001");
        assert_eq!(svc.addresses, vec![addr]);
    }

    #[cfg(feature = "ota")]
    #[test]
    fn status_report_body_byte_layout_and_roundtrip() {
        // BDX-3: a BDX abort StatusReport = Failure || BDX proto id (0x00000002)
        // || the 16-bit BDX status. Byte layout is little-endian per field
        // (Matter Core ยง4.11.6).
        let code = matter_bdx::BdxStatusCode::BadBlockCounter.to_u16(); // 0x0017
        let body = encode_status_report_body(STATUS_GENERAL_FAILURE, ProtocolId::BDX, code);
        assert_eq!(
            body,
            vec![0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x17, 0x00],
            "GeneralCode(LE) || ProtocolId(LE u32) || ProtocolStatus(LE)"
        );
        assert_eq!(
            parse_status_report_body(&body),
            Some((STATUS_GENERAL_FAILURE, 0x0000_0002, code))
        );
    }

    #[test]
    fn status_report_body_rejects_truncated() {
        assert_eq!(parse_status_report_body(&[0x01, 0x00, 0x02]), None);
        assert_eq!(parse_status_report_body(&[]), None);
    }

    /// `is_unsecured_frame` returns true for session id 0 (unsecured), false
    /// for a non-zero session id (secured), and false for a short slice.
    #[test]
    fn is_unsecured_frame_classifies_correctly() {
        // True: encode_unsecured_reply always sets session id to 0.
        let unsecured = encode_unsecured_reply(
            1,
            1,
            0x30,
            ProtocolId::SECURE_CHANNEL,
            false,
            None,
            None,
            &[],
        );
        assert!(
            is_unsecured_frame(&unsecured),
            "unsecured reply must have session id 0"
        );

        // False: hand-built frame with session id 0x1234 (LE at bytes[1..3]).
        let secured = vec![0x00u8, 0x34, 0x12, 0x00, 0x00, 0x00];
        assert!(
            !is_unsecured_frame(&secured),
            "non-zero session id must not be classified as unsecured"
        );

        // False: slice shorter than 3 bytes.
        assert!(
            !is_unsecured_frame(&[0x00u8, 0x00]),
            "2-byte slice must return false"
        );
    }

    /// An empty credential pool must fail fast (before any IO) with the
    /// canonical error message. This exercises the pool-exhaustion guard in
    /// `accept_case` without requiring a real CASE peer.
    #[cfg(feature = "ota")]
    #[tokio::test]
    async fn empty_credential_pool_errors_before_any_io() {
        let (io, _peer) = matter_commissioning::driver::InMemoryDatagram::pair();
        let server = ProviderServer::new(
            io,
            Vec::new(),
            TrustedRoots::new(),
            0x10,
            MatterTime::from_unix_secs(2_000_000_000),
        );
        let offer = matter_ota::ImageOffer {
            software_version: 2,
            software_version_string: "2.0".into(),
            image_uri: "bdx://0/fw.ota".into(),
            update_token: vec![0xAB; 16],
        };
        let err = server
            .serve_ota_once(offer, vec![0u8; 16], 960)
            .await
            .expect_err("empty pool must fail fast");
        assert!(
            err.to_string().contains("credential pool exhausted"),
            "unexpected error: {err}"
        );
    }
}