Skip to main content

matter_controller/
provider_server.rs

1//! The OTA **provider server**: a dedicated task that advertises our
2//! operational service, accepts an inbound CASE session as the responder, and
3//! dispatches one server-side `InvokeRequest`. Productionizes the responder
4//! accept-flow proven in the actor's loopback tests; hosts it in
5//! `matter-controller` so it can reuse the persisted operational identity
6//! (`crate::credentials::operational_credentials`) and the existing session /
7//! transport / discovery machinery without a new crate boundary.
8//!
9//! This module is `pub(crate)`; its low-level items are re-exported publicly
10//! only under the `unstable-provider` feature (the stable path is
11//! `MatterController::serve_ota`). The items stay `pub` so that re-export can
12//! widen them, so `unreachable_pub` is a false positive in the feature-off
13//! build — allow it module-wide.
14#![allow(unreachable_pub)]
15// With `ota` off, this module is still compiled — `build_operational_service`
16// serves the non-OTA check-in listener, and `unstable-provider` re-exports
17// `ProviderServer` from here — but most of the OTA-serving machinery
18// (`serve_ota_once` and its helpers) has no caller in that configuration,
19// so `dead_code`/`unused_imports` would otherwise fire workspace-wide.
20#![cfg_attr(not(feature = "ota"), allow(dead_code, unused_imports))]
21
22use std::net::{IpAddr, SocketAddr};
23use std::sync::Arc;
24use std::time::Instant;
25
26use matter_cert::{MatterTime, TrustedRoots};
27use matter_commissioning::driver::{decode_unsecured, encode_unsecured_reply, AsyncDatagram};
28use matter_crypto::{CaseCredentials, CaseResponder, ResumptionRecord, Sigma1Outcome};
29use matter_interaction::{
30    build_invoke_response_command, build_invoke_response_status, parse_invoke_request, CommandPath,
31    ImStatus,
32};
33// Only referenced by the unstable generic-provider `accept_and_dispatch_once`.
34#[cfg(any(feature = "unstable-provider", test))]
35use matter_interaction::ParsedInvokeRequest;
36use matter_transport::{
37    DecodeInboundOutput, MatterService, MrpFlags, ProtocolId, ServiceKind, SessionId,
38    SessionManager, SessionRole,
39};
40
41use crate::error::Error;
42
43// SecureChannel handshake opcodes (Matter Core §4.10 / §4.13).
44const OP_SIGMA1: u8 = 0x30;
45const OP_SIGMA2: u8 = 0x31;
46const OP_SIGMA3: u8 = 0x32;
47const OP_SIGMA2_RESUME: u8 = 0x33;
48const OP_STATUS_REPORT: u8 = 0x40;
49const OP_MRP_STANDALONE_ACK: u8 = 0x10;
50// Interaction Model opcodes.
51const OP_INVOKE_REQUEST: u8 = 0x08;
52
53/// Frames discarded while awaiting a Sigma1 before the accept fails. Stray
54/// LAN datagrams to the advertised port (undecodable noise, stale acks,
55/// leftovers from a discarded session) must not consume pooled credentials —
56/// but a flooder should still hit a bound rather than spin the accept
57/// forever.
58const MAX_AWAIT_SIGMA1_DISCARDS: usize = 64;
59const OP_INVOKE_RESPONSE: u8 = 0x09;
60
61// Secure-Channel StatusReport general codes (Matter Core §4.11.6).
62const STATUS_GENERAL_FAILURE: u16 = 0x0001;
63
64/// Encode the fixed 8-byte Secure-Channel `StatusReport` body (Matter Core
65/// §4.11.6): `GeneralCode` (u16 LE) || `ProtocolId` (u32 LE, `vendor<<16 |
66/// protocol`) || `ProtocolStatus` (u16 LE). Used to abort a BDX transfer
67/// so the peer learns the failure instead of timing out.
68fn encode_status_report_body(general: u16, proto: ProtocolId, protocol_status: u16) -> Vec<u8> {
69    let proto_id: u32 = (u32::from(proto.vendor) << 16) | u32::from(proto.protocol);
70    let mut body = Vec::with_capacity(8);
71    body.extend_from_slice(&general.to_le_bytes());
72    body.extend_from_slice(&proto_id.to_le_bytes());
73    body.extend_from_slice(&protocol_status.to_le_bytes());
74    body
75}
76
77/// Parse the fixed 8-byte Secure-Channel `StatusReport` body into
78/// `(general_code, protocol_id, protocol_status)`. Returns `None` if the body
79/// is shorter than 8 bytes.
80fn parse_status_report_body(payload: &[u8]) -> Option<(u16, u32, u16)> {
81    let b: &[u8; 8] = payload.get(..8)?.try_into().ok()?;
82    Some((
83        u16::from_le_bytes([b[0], b[1]]),
84        u32::from_le_bytes([b[2], b[3], b[4], b[5]]),
85        u16::from_le_bytes([b[6], b[7]]),
86    ))
87}
88
89// OtaSoftwareUpdateProvider (0x0029) command ids (Matter Core §11.20).
90const OTA_PROVIDER_CLUSTER: u32 = 0x0029;
91const CMD_QUERY_IMAGE: u32 = 0x00;
92const CMD_QUERY_IMAGE_RESPONSE: u32 = 0x01;
93const CMD_APPLY_UPDATE_REQUEST: u32 = 0x02;
94const CMD_APPLY_UPDATE_RESPONSE: u32 = 0x03;
95const CMD_NOTIFY_UPDATE_APPLIED: u32 = 0x04;
96
97/// True when `frame` is an unsecured (session id 0) message — i.e. a new
98/// session-establishment attempt arriving while a secured session is being
99/// served. Bytes 1..3 are the little-endian session id (Matter Core §4.4.1).
100fn is_unsecured_frame(frame: &[u8]) -> bool {
101    frame.len() >= 3 && frame[1] == 0 && frame[2] == 0
102}
103
104/// A raw datagram (frame bytes + sender) handed from one accept to the next,
105/// so no handshake bytes are lost across session boundaries.
106type CarriedFrame = (Vec<u8>, SocketAddr);
107
108/// Build the operational `_matter._tcp` mDNS record to advertise so a requestor
109/// can resolve us. Instance name is `<compressed-fabric-id>-<node-id>` in
110/// uppercase hex (Matter Core §4.3.1), matching what the controller's initiator
111/// resolves against via `operational_instance_name`.
112#[must_use]
113pub fn build_operational_service(
114    compressed_fabric_id: [u8; 8],
115    node_id: u64,
116    addresses: Vec<IpAddr>,
117    port: u16,
118) -> MatterService {
119    let instance_name =
120        matter_commissioning::driver::operational_instance_name(compressed_fabric_id, node_id);
121    // Operational TXT params (SII/SAI/SAT) are optional hints; F3 advertises
122    // none (the requestor resolves us by SRV + A/AAAA). F4/hardening can add
123    // session-interval hints if a requestor needs them.
124    MatterService::new(
125        instance_name,
126        ServiceKind::Operational,
127        addresses,
128        port,
129        std::collections::HashMap::new(),
130    )
131}
132
133/// A multi-session OTA provider server: accepts inbound CASE sessions as the
134/// responder (one per pooled credential), then dispatches server-side
135/// `InvokeRequest`s. Generic over the datagram transport so it runs over
136/// `TokioUdpTransport` in production and `InMemoryDatagram` in tests.
137///
138/// This productionizes the responder accept-flow proven in the actor's loopback
139/// tests (`run_loopback_device`): Sigma1→Sigma2→Sigma3→`SessionManager` register,
140/// then secured IM dispatch on the established session.
141///
142/// The credential pool is consumed one entry per `accept_case` call. When the
143/// pool is exhausted, `accept_case` (and any caller such as `serve_ota_once`)
144/// returns [`Error::Operational`] with the message
145/// `"provider server: credential pool exhausted"`. The pool is sized by the
146/// caller — `serve_ota` mints four entries (first session + post-reboot session
147/// + retry slack) from the persisted fabric.
148pub struct ProviderServer<D> {
149    io: D,
150    /// Pool of operational identities, one consumed per CASE accept (the
151    /// responder state machine takes ownership of its credentials).
152    /// `serve_ota` mints these from the persisted fabric — see the spec's
153    /// sizing rationale (first session + post-reboot session + retry slack).
154    credentials: Vec<CaseCredentials>,
155    roots: TrustedRoots,
156    /// Base secured session id; accept N advertises `base.wrapping_add(N)` so
157    /// consecutive sessions never share a local id.
158    base_session_id: u16,
159    /// Number of accepts performed so far (also indexes the session id).
160    accepts: u16,
161    now: MatterTime,
162    handshake_counter: u32,
163    /// When set, an accepted session whose authenticated peer node id is not
164    /// this value fails the accept (its pooled credential is consumed — that
165    /// is the point: a fabric member other than the OTA target must not be
166    /// able to hijack the serve). `serve_ota` pins its `target_node_id`.
167    expected_peer: Option<u64>,
168    /// Known CASE resumption records. When an inbound Sigma1 carries
169    /// resumption fields whose id matches one of these, the session is
170    /// resumed (`Sigma2_Resume`) instead of a full handshake — chip's OTA
171    /// requestor always requests resumption of the session the controller
172    /// just used to announce, so `serve_ota` seeds this with the announce
173    /// connect's persisted record. No match falls back to
174    /// `reject_resumption` + full handshake.
175    resumption_records: Vec<ResumptionRecord>,
176    /// Invoked with the fresh [`ResumptionRecord`] each accept produces
177    /// (rotated on the resumed path, brand-new on the full path), so the
178    /// caller can persist it IMMEDIATELY — a caller-side timeout that drops
179    /// the serve future must not lose the rotation. Best-effort: the sink
180    /// must not block (spawn if it needs async work).
181    record_sink: Option<Box<dyn Fn(ResumptionRecord) + Send + Sync>>,
182}
183
184impl<D: AsyncDatagram> ProviderServer<D> {
185    /// Build a provider server bound to `io`, authenticating from the
186    /// `credentials` pool (our operational identities). `roots` and `now` are
187    /// used to validate the peer's certificate chain on each accept.
188    ///
189    /// `base_session_id` is the first secured session id advertised in Sigma2;
190    /// the Nth accept uses `base_session_id.wrapping_add(N)` so consecutive
191    /// sessions never reuse the same local id.
192    ///
193    /// The pool is consumed one entry per accept. When it is empty, the next
194    /// call to `serve_ota_once` (or any method that calls `accept_case`)
195    /// returns an [`Error::Operational`] containing
196    /// `"provider server: credential pool exhausted"`.
197    #[must_use]
198    pub fn new(
199        io: D,
200        credentials: Vec<CaseCredentials>,
201        roots: TrustedRoots,
202        base_session_id: u16,
203        now: MatterTime,
204    ) -> Self {
205        Self {
206            io,
207            credentials,
208            roots,
209            base_session_id,
210            accepts: 0,
211            now,
212            handshake_counter: 1,
213            expected_peer: None,
214            resumption_records: Vec::new(),
215            record_sink: None,
216        }
217    }
218
219    /// Register a callback that is invoked once per completed accept with the
220    /// fresh [`ResumptionRecord`] the handshake produced (rotated on the resumed
221    /// path, brand-new on the full path). The caller can use this to persist the
222    /// record immediately — a future that is cancelled after `accept_case`
223    /// completes but before the caller stores the record would otherwise lose the
224    /// rotation. The sink is called synchronously and **must not block**; spawn
225    /// an async task if async work is needed.
226    #[must_use]
227    pub fn with_record_sink(mut self, sink: Box<dyn Fn(ResumptionRecord) + Send + Sync>) -> Self {
228        self.record_sink = Some(sink);
229        self
230    }
231
232    /// Seed the server with known CASE resumption records (see the field
233    /// docs). An inbound resumption-requesting Sigma1 matching one of these
234    /// by id is accepted via `Sigma2_Resume`; anything else falls back to a
235    /// full handshake.
236    #[must_use]
237    pub fn with_resumption_records(mut self, records: Vec<ResumptionRecord>) -> Self {
238        self.resumption_records = records;
239        self
240    }
241
242    /// Pin the peer: an accepted session must authenticate as `node_id` or
243    /// the accept fails (consuming its pooled credential). Without this, any
244    /// member of the fabric could consume the serve.
245    #[must_use]
246    pub fn with_expected_peer(mut self, node_id: u64) -> Self {
247        self.expected_peer = Some(node_id);
248        self
249    }
250
251    fn next_handshake_counter(&mut self) -> u32 {
252        let c = self.handshake_counter;
253        self.handshake_counter = self.handshake_counter.wrapping_add(1);
254        c
255    }
256
257    async fn recv(&self) -> Result<(Vec<u8>, SocketAddr), Error> {
258        self.io
259            .recv_from()
260            .await
261            .map_err(|e| Error::Operational(format!("provider recv: {e}")))
262    }
263
264    async fn send(&self, bytes: &[u8], peer: SocketAddr) -> Result<(), Error> {
265        self.io
266            .send_to(bytes, peer)
267            .await
268            .map_err(|e| Error::Operational(format!("provider send: {e}")))
269    }
270
271    /// Receive the next datagram while driving the session's MRP timers, so
272    /// scheduled standalone acks (and retransmits) fire even while we sit in
273    /// `recv`. Load-bearing for the OTA flow: the requestor's `BlockAckEOF`
274    /// is MRP-reliable and we reply with nothing — without the pumped
275    /// standalone ack, chip retransmits it, marks the session defunct, and
276    /// abandons the update before `ApplyUpdateRequest` (observed live).
277    async fn recv_secured(
278        &self,
279        sessions: &mut SessionManager,
280        peer: SocketAddr,
281    ) -> Result<(Vec<u8>, SocketAddr), Error> {
282        use matter_transport::MrpEvent;
283        loop {
284            let Some(deadline) = sessions.poll_timeout() else {
285                return self.recv().await;
286            };
287            let wait = deadline.saturating_duration_since(Instant::now());
288            match tokio::time::timeout(wait, self.recv()).await {
289                Ok(result) => return result,
290                Err(_deadline_hit) => {
291                    for event in sessions.handle_timeout(Instant::now()) {
292                        match event {
293                            MrpEvent::Retransmit { packet, .. }
294                            | MrpEvent::SendStandaloneAck { packet, .. } => {
295                                self.send(&packet, peer).await?;
296                            }
297                            // Single-session server: nothing to resolve on
298                            // expiry; `MrpEvent` is non_exhaustive.
299                            _ => {}
300                        }
301                    }
302                }
303            }
304        }
305    }
306
307    /// Accept ONE inbound CASE session as the responder, returning an
308    /// established [`SessionManager`] + the secured `SessionId` + the peer's
309    /// address. Mirrors the proven `run_loopback_device` accept-flow on the full
310    /// path; a Sigma1 carrying resumption fields that match a seeded record (see
311    /// [`Self::with_resumption_records`]) takes the `Sigma2_Resume` fast path
312    /// instead.
313    ///
314    /// The fresh [`ResumptionRecord`] the handshake produces is handled
315    /// internally: it is re-seeded into `self.resumption_records` (so the NEXT
316    /// accept can match it) and passed to the `record_sink` (if set) before this
317    /// method returns.
318    ///
319    /// If `first_frame` is `Some`, that datagram is used as the Sigma1 instead
320    /// of calling `recv` — useful for callers that have already peeked the first
321    /// packet (e.g., a multi-session loop that demuxes by session id).
322    ///
323    /// The returned [`CarriedFrame`] is `Some` when the full-handshake close
324    /// saw a NEW Sigma1 in place of the initiator's standalone ack (see
325    /// [`Self::complete_full`]); the caller must feed it into its next accept
326    /// or the handshake attempt it opens is lost.
327    async fn accept_case(
328        &mut self,
329        first_frame: Option<CarriedFrame>,
330    ) -> Result<(SessionManager, SessionId, SocketAddr, Option<CarriedFrame>), Error> {
331        // Fast-fail an exhausted pool before any IO. The check does NOT pop:
332        // a credential is consumed only once a valid Sigma1 is in hand, so
333        // stray datagrams to the advertised port cannot burn the pool.
334        if self.credentials.is_empty() {
335            return Err(Error::Operational(
336                "provider server: credential pool exhausted".into(),
337            ));
338        }
339
340        // Await a valid Sigma1, discarding anything else (undecodable noise,
341        // stray acks, stale secured frames) within a bounded budget.
342        let mut carried = first_frame;
343        let mut discarded = 0usize;
344        let (m1, peer) = loop {
345            let (bytes, from) = match carried.take() {
346                Some(f) => f,
347                None => self.recv().await?,
348            };
349            match decode_unsecured(&bytes) {
350                Ok(m) if m.opcode == OP_SIGMA1 => break (m, from),
351                _ => {
352                    discarded += 1;
353                    if discarded >= MAX_AWAIT_SIGMA1_DISCARDS {
354                        return Err(Error::Operational(format!(
355                            "no Sigma1 within {MAX_AWAIT_SIGMA1_DISCARDS} frames"
356                        )));
357                    }
358                }
359            }
360        };
361
362        // A real handshake attempt is starting: consume one pooled identity.
363        let credentials = self.credentials.remove(0);
364        let responder_session_id = self.base_session_id.wrapping_add(self.accepts);
365        self.accepts = self.accepts.wrapping_add(1);
366        let mut responder = CaseResponder::new(
367            credentials,
368            self.roots.clone(),
369            responder_session_id,
370            self.now,
371        )
372        .map_err(|e| Error::Operational(format!("CASE responder init: {e}")))?;
373
374        let outcome = responder
375            .handle_sigma1(&m1.payload)
376            .map_err(|e| Error::Operational(format!("handle_sigma1: {e}")))?;
377
378        let resumed = match outcome {
379            Sigma1Outcome::NewSession => false,
380            Sigma1Outcome::ResumptionRequested { id } => {
381                if let Some(pos) = self.resumption_records.iter().position(|r| r.id == id) {
382                    let record = self.resumption_records.swap_remove(pos);
383                    responder
384                        .accept_resumption(record)
385                        .map_err(|e| Error::Operational(format!("accept_resumption: {e}")))?;
386                    true
387                } else {
388                    // Unknown id — decline and fall back to a full handshake.
389                    responder
390                        .reject_resumption()
391                        .map_err(|e| Error::Operational(format!("reject_resumption: {e}")))?;
392                    false
393                }
394            }
395        };
396
397        let carry = if resumed {
398            self.complete_resumed(&mut responder, &m1, peer).await?;
399            None
400        } else {
401            self.complete_full(&mut responder, &m1, peer).await?
402        };
403
404        let output = responder
405            .finish()
406            .map_err(|e| Error::Operational(format!("CASE finish: {e}")))?;
407        // Enforce the pin BEFORE re-seeding/sinking the record: a rejected
408        // peer must leave no resumption state behind.
409        if let Some(expected) = self.expected_peer {
410            if output.peer.node_id != expected {
411                return Err(Error::Operational(format!(
412                    "provider server: accepted peer node {:#x} is not the expected {expected:#x}",
413                    output.peer.node_id
414                )));
415            }
416        }
417        if let Some(record) = output.resumption_record.clone() {
418            // Re-seed so the NEXT accept (the post-reboot requestor resumes
419            // with the id rotated during THIS handshake) can match it.
420            self.resumption_records.push(record.clone());
421            if let Some(sink) = &self.record_sink {
422                sink(record);
423            }
424        }
425        let mut sessions = SessionManager::new();
426        let sid = sessions.register_case(&output, SessionRole::Responder);
427        Ok((sessions, sid, peer, carry))
428    }
429
430    /// Resumed path: send `Sigma2_Resume` on Sigma1's exchange, then await the
431    /// initiator's success `StatusReport` and standalone-ack it (the report is
432    /// MRP-reliable; without our ack chip retransmits it and eventually tears
433    /// the exchange down). Tolerates interleaved Sigma1 retransmits (re-sends
434    /// `Sigma2_Resume`) and stray standalone acks.
435    async fn complete_resumed(
436        &mut self,
437        responder: &mut CaseResponder,
438        m1: &matter_commissioning::driver::UnsecuredMessage,
439        peer: SocketAddr,
440    ) -> Result<(), Error> {
441        let sigma2_resume = responder
442            .next_message()
443            .map_err(|e| Error::Operational(format!("sigma2_resume: {e}")))?;
444        let c = self.next_handshake_counter();
445        let wire = encode_unsecured_reply(
446            c,
447            m1.exchange_id,
448            OP_SIGMA2_RESUME,
449            ProtocolId::SECURE_CHANNEL,
450            true,
451            Some(m1.message_counter),
452            m1.source_node_id,
453            &sigma2_resume,
454        );
455        self.send(&wire, peer).await?;
456
457        // Await the initiator's SigmaFinished success StatusReport, within a
458        // bounded frame budget.
459        for _ in 0..8 {
460            let (bytes, _) = self.recv().await?;
461            let m = decode_unsecured(&bytes)
462                .map_err(|e| Error::Operational(format!("post-resume frame: {e}")))?;
463            match m.opcode {
464                OP_STATUS_REPORT => {
465                    // StatusReport body: GeneralCode(u16 LE) || ProtocolId(u32) || ProtocolCode(u16).
466                    let general_code = m
467                        .payload
468                        .get(0..2)
469                        .map(|b| u16::from_le_bytes([b[0], b[1]]))
470                        .ok_or_else(|| {
471                            Error::Operational("truncated resumption StatusReport".into())
472                        })?;
473                    if general_code != 0 {
474                        return Err(Error::Operational(format!(
475                            "initiator rejected resumption: StatusReport general code {general_code}"
476                        )));
477                    }
478                    // Ack the reliable report so the initiator's MRP settles.
479                    let c = self.next_handshake_counter();
480                    let ack = encode_unsecured_reply(
481                        c,
482                        m.exchange_id,
483                        OP_MRP_STANDALONE_ACK,
484                        ProtocolId::SECURE_CHANNEL,
485                        false,
486                        Some(m.message_counter),
487                        m.source_node_id.or(m1.source_node_id),
488                        &[],
489                    );
490                    self.send(&ack, peer).await?;
491                    return Ok(());
492                }
493                // Sigma1 retransmit: our Sigma2_Resume (or its ack) was lost —
494                // re-send it on the same exchange.
495                OP_SIGMA1 => {
496                    let c = self.next_handshake_counter();
497                    let wire = encode_unsecured_reply(
498                        c,
499                        m.exchange_id,
500                        OP_SIGMA2_RESUME,
501                        ProtocolId::SECURE_CHANNEL,
502                        true,
503                        Some(m.message_counter),
504                        m.source_node_id.or(m1.source_node_id),
505                        &sigma2_resume,
506                    );
507                    self.send(&wire, peer).await?;
508                }
509                // A standalone ack of our Sigma2_Resume — fine, keep waiting.
510                OP_MRP_STANDALONE_ACK => {}
511                other => {
512                    return Err(Error::Operational(format!(
513                        "expected resumption StatusReport (0x40), got {other:#04x}"
514                    )))
515                }
516            }
517        }
518        Err(Error::Operational(
519            "no StatusReport after Sigma2_Resume within frame budget".into(),
520        ))
521    }
522
523    /// Full-handshake path (Sigma2 → Sigma3 → our success `StatusReport`), used
524    /// for a plain Sigma1 and as the fallback after `reject_resumption`.
525    ///
526    /// Returns the frame to carry into the next accept when the closing
527    /// ack-absorb `recv` saw a NEW Sigma1 instead of the initiator's
528    /// standalone ack: a requestor that applies and reboots fast can have its
529    /// next handshake's Sigma1 in flight before the ack — eating it would
530    /// force the peer through an MRP retransmit round AND burn one pooled
531    /// retry credential on this side. Everything else (the ack, noise, a
532    /// same-exchange Sigma1 — a stale duplicate of `m1`, provably already
533    /// answered because Sigma3 arrived) is absorbed as before.
534    async fn complete_full(
535        &mut self,
536        responder: &mut CaseResponder,
537        m1: &matter_commissioning::driver::UnsecuredMessage,
538        peer: SocketAddr,
539    ) -> Result<Option<CarriedFrame>, Error> {
540        let sigma2 = responder
541            .next_message()
542            .map_err(|e| Error::Operational(format!("sigma2: {e}")))?;
543        let c = self.next_handshake_counter();
544        let wire = encode_unsecured_reply(
545            c,
546            m1.exchange_id,
547            OP_SIGMA2,
548            ProtocolId::SECURE_CHANNEL,
549            true,
550            Some(m1.message_counter),
551            m1.source_node_id,
552            &sigma2,
553        );
554        self.send(&wire, peer).await?;
555
556        // Sigma3 → success StatusReport.
557        let (s3, _) = self.recv().await?;
558        let m3 = decode_unsecured(&s3).map_err(|e| Error::Operational(format!("sigma3: {e}")))?;
559        if m3.opcode != OP_SIGMA3 {
560            return Err(Error::Operational(format!(
561                "expected Sigma3 (0x32), got {:#04x}",
562                m3.opcode
563            )));
564        }
565        responder
566            .handle_sigma3(&m3.payload)
567            .map_err(|e| Error::Operational(format!("handle_sigma3: {e}")))?;
568        let mut body = Vec::with_capacity(8);
569        body.extend_from_slice(&0u16.to_le_bytes()); // GeneralCode: success
570        body.extend_from_slice(&0u32.to_le_bytes()); // ProtocolId: SecureChannel
571        body.extend_from_slice(&0u16.to_le_bytes()); // ProtocolCode: 0
572        let c = self.next_handshake_counter();
573        let report = encode_unsecured_reply(
574            c,
575            m3.exchange_id,
576            OP_STATUS_REPORT,
577            ProtocolId::SECURE_CHANNEL,
578            true,
579            Some(m3.message_counter),
580            m3.source_node_id.or(m1.source_node_id),
581            &body,
582        );
583        self.send(&report, peer).await?;
584
585        // Absorb the initiator's standalone ack of our StatusReport — but hand
586        // a fresh Sigma1 (new handshake, new exchange) back to the caller
587        // instead of eating it (see the method docs).
588        let (bytes, from) = self.recv().await?;
589        if let Ok(m) = decode_unsecured(&bytes) {
590            if m.opcode == OP_SIGMA1 && m.exchange_id != m1.exchange_id {
591                return Ok(Some((bytes, from)));
592            }
593        }
594        Ok(None)
595    }
596
597    /// Accept ONE inbound CASE session, then dispatch up to `max_invokes`
598    /// server-side `InvokeRequest`s through `handler`, replying to each on its
599    /// exchange. Returns the number of invokes dispatched.
600    ///
601    /// `handler` maps a parsed `InvokeRequest` to the encoded `InvokeResponse`
602    /// message bytes (e.g. via `matter_interaction::build_invoke_response_*`).
603    ///
604    /// # Errors
605    ///
606    /// Returns [`Error::Operational`] on a transport, CASE-handshake, or framing
607    /// failure (including a non-`NewSession` Sigma1 or an unexpected opcode), or
608    /// [`Error::Transport`] / [`Error::InteractionModel`] from the session / IM
609    /// layers.
610    ///
611    /// Part of the unstable generic-provider surface (see the module docs); the
612    /// stable OTA path uses `serve_ota_once`. Compiled only under
613    /// `unstable-provider` (its sole caller, `serve_provider_once`) or in tests.
614    #[cfg(any(feature = "unstable-provider", test))]
615    pub async fn accept_and_dispatch_once<H>(
616        mut self,
617        mut handler: H,
618        max_invokes: usize,
619    ) -> Result<usize, Error>
620    where
621        H: FnMut(&ParsedInvokeRequest) -> Vec<u8>,
622    {
623        // Single-session API: there is no next accept to feed a carried
624        // Sigma1 into, so it is dropped (the peer's MRP retransmit covers it)
625        // — the pre-multi-session behavior.
626        let (mut sessions, sid, peer, _fast_sigma1) = self.accept_case(None).await?;
627
628        let mut dispatched = 0usize;
629        while dispatched < max_invokes {
630            let (wire, _) = self.recv_secured(&mut sessions, peer).await?;
631            if let DecodeInboundOutput::AppMessage {
632                exchange_id,
633                opcode,
634                payload,
635                ..
636            } = sessions.decode_inbound(&wire, Instant::now())?
637            {
638                if opcode != OP_INVOKE_REQUEST {
639                    // Ignore non-invoke app messages in F3 (e.g. reads).
640                    continue;
641                }
642                let parsed = parse_invoke_request(&payload)?;
643                let response = handler(&parsed);
644                let out = sessions.encode_outbound(
645                    sid,
646                    Some(exchange_id),
647                    OP_INVOKE_RESPONSE,
648                    ProtocolId::INTERACTION_MODEL,
649                    &response,
650                    MrpFlags { reliable: false },
651                    Instant::now(),
652                )?;
653                self.send(&out.wire_bytes, peer).await?;
654                dispatched += 1;
655            }
656        }
657        Ok(dispatched)
658    }
659
660    /// Accept CASE sessions in sequence, serving `image` to the requestor over the
661    /// full OTA flow — `QueryImage` → `QueryImageResponse`, a BDX transfer, then
662    /// `ApplyUpdateRequest` → `ApplyUpdateResponse` (Proceed) — and completing once
663    /// `NotifyUpdateApplied` is received on ANY session. A real requestor downloads
664    /// and applies on its first session, reboots into the new image, and sends
665    /// `NotifyUpdateApplied` on a fresh session; this method spans that reboot by
666    /// running an outer loop over `accept_case` calls.
667    ///
668    /// Unsecured frames (session id 0) arriving while a secured session is being
669    /// served are recognised as new-session-establishment attempts; they are
670    /// carried into the next outer iteration as the `first_frame` for the next
671    /// `accept_case` call, so no handshake bytes are lost.
672    ///
673    /// The caller owns the deadline: wrap `serve_ota_once` in
674    /// `tokio::time::timeout` (or similar) to bound a requestor that never
675    /// returns. Pool exhaustion (all credentials consumed) and a per-session step
676    /// budget are the two error paths.
677    ///
678    /// The fresh [`ResumptionRecord`] the accept handshake produced is re-seeded
679    /// and forwarded to the `record_sink` (if set via
680    /// [`Self::with_record_sink`]) before the OTA dispatch loop begins — the
681    /// caller need not wait for the full OTA flow to persist the rotation.
682    ///
683    /// `offer` shapes the `QueryImageResponse` (its `ImageURI`/`UpdateToken`);
684    /// `max_block_size` caps each BDX block. All replies are unreliable
685    /// (piggyback ack) — happy-path, localhost-validated. Messages route by
686    /// [`ProtocolId`]: Interaction-Model invokes go to the `matter-ota` handlers,
687    /// `ProtocolId::BDX` messages drive a [`matter_bdx::BlockSender`].
688    ///
689    /// # Errors
690    ///
691    /// [`Error::Operational`] on a CASE/transport/codec failure, a BDX abort, an
692    /// unexpected OTA command, or if a session exhausts its step budget without
693    /// an unsecured carry-frame; [`Error::Transport`] / [`Error::InteractionModel`]
694    /// from the session / IM layers.
695    #[cfg(feature = "ota")]
696    #[allow(clippy::too_many_lines)] // Linear OTA protocol-dispatch loop; splitting hurts clarity.
697    pub async fn serve_ota_once(
698        mut self,
699        offer: matter_ota::ImageOffer,
700        image: Vec<u8>,
701        max_block_size: u16,
702    ) -> Result<(), Error> {
703        use matter_bdx::{BdxMessage, BlockSender, MessageType, SenderOutcome};
704
705        // Shared once so every `BlockSender` (the initial QueryImage arm and
706        // any cross-session ReceiveInit re-arm below) clones the `Arc`
707        // handle rather than the image bytes.
708        let image: Arc<[u8]> = Arc::from(image);
709
710        // Flow state spans sessions: the requestor downloads + applies on its
711        // first session, REBOOTS into the image, and sends NotifyUpdateApplied
712        // on a fresh session (usually resuming the record rotated during the
713        // first accept — re-seeded by accept_case).
714        let mut bdx: Option<BlockSender> = None;
715        let mut carried: Option<(Vec<u8>, SocketAddr)> = None;
716
717        // Outer: one iteration per CASE session; bounded by the credential
718        // pool (accept_case errors when it is exhausted). A failed mid-flow
719        // handshake poisons only that accept (spec: Error handling) — it
720        // consumed one pooled credential, and the loop waits for the peer's
721        // next attempt; only pool exhaustion (or the caller's deadline) ends
722        // the serve.
723        loop {
724            let (mut sessions, sid, peer, fast_sigma1) =
725                match self.accept_case(carried.take()).await {
726                    Ok(accepted) => accepted,
727                    Err(e) => {
728                        if self.credentials.is_empty() {
729                            return Err(e); // exhausted (or the last credential's failure)
730                        }
731                        continue; // retry with the next pooled credential
732                    }
733                };
734            if let Some(frame) = fast_sigma1 {
735                // The peer opened a NEW handshake instead of acking this one's
736                // close (fast post-reboot Sigma1 in place of the standalone
737                // ack): the session just established is already abandoned —
738                // roll the Sigma1 straight into the next accept rather than
739                // blocking the inner loop on a dead session.
740                carried = Some(frame);
741                continue;
742            }
743
744            // BDX-4: bound progress and iteration SEPARATELY. `max_progress`
745            // caps how many transfer-ADVANCING messages (OTA commands + blocks)
746            // we serve; a larger `max_iterations` backstop bounds frames that do
747            // NOT advance the transfer (stale prior-session retransmits, the
748            // duplicate-reliable ack resends BDX-2 handles, a peer StatusReport).
749            // Counting every frame against one budget — as the old `steps`
750            // did — let a lossy mesh's retransmits exhaust it before the last
751            // block arrived, turning a recoverable loss into a spurious failure.
752            let max_progress = image.len() / usize::from(max_block_size.max(1)) + 64;
753            let max_iterations = max_progress.saturating_mul(8).max(1024);
754            let mut progress = 0usize;
755            let mut iterations = 0usize;
756
757            // Inner: serve this session until Notify (done), a new handshake
758            // frame (roll into the next accept), or a bound.
759            while progress < max_progress && iterations < max_iterations {
760                iterations += 1;
761                let (wire, from) = self.recv_secured(&mut sessions, peer).await?;
762                if is_unsecured_frame(&wire) {
763                    carried = Some((wire, from));
764                    break;
765                }
766                // A frame that fails secured decode is a stale leftover — e.g.
767                // a late retransmit keyed to a PRIOR session's id after the
768                // requestor re-established (the reboot window) — not a fault
769                // of the live session. Skip it; the step budget bounds a
770                // pathological stream of them.
771                let Ok(decoded) = sessions.decode_inbound(&wire, Instant::now()) else {
772                    continue;
773                };
774                let DecodeInboundOutput::AppMessage {
775                    exchange_id,
776                    protocol_id,
777                    opcode,
778                    payload,
779                    ..
780                } = decoded
781                else {
782                    // BDX-2: the requestor retransmitted a reliable message
783                    // (e.g. a BlockQuery) whose ack was lost. decode_inbound has
784                    // pre-built the standalone ack to re-send — send it and do
785                    // NOT advance BDX state (the block counter already moved).
786                    // Dropping it here (the old `continue`) left the requestor
787                    // retransmitting forever, stalling the transfer. Other
788                    // non-app outcomes (AckOnly) need no response.
789                    if let DecodeInboundOutput::DuplicateReliableAckResent { ack_packet, .. } =
790                        decoded
791                    {
792                        self.send(&ack_packet, peer).await?;
793                    }
794                    continue;
795                };
796
797                // BDX-4: only a message that ADVANCES the transfer (an OTA
798                // invoke or a BDX message) counts against `max_progress`. A
799                // stale/duplicate frame `continue`s above without reaching here,
800                // so it burns only an `iterations` slot, never the progress
801                // budget.
802                let advanced = (protocol_id == ProtocolId::INTERACTION_MODEL
803                    && opcode == OP_INVOKE_REQUEST)
804                    || protocol_id == ProtocolId::BDX;
805
806                if protocol_id == ProtocolId::INTERACTION_MODEL && opcode == OP_INVOKE_REQUEST {
807                    let parsed = parse_invoke_request(&payload)?;
808                    let cmd = parsed
809                        .commands
810                        .first()
811                        .ok_or_else(|| Error::Operational("OTA invoke had no command".into()))?;
812                    let response = if cmd.path.command == CMD_QUERY_IMAGE {
813                        bdx = Some(BlockSender::from_shared(Arc::clone(&image), max_block_size));
814                        let fields = matter_ota::handle_query_image(&cmd.fields_tlv, Some(&offer))
815                            .map_err(|e| Error::Operational(format!("QueryImage: {e}")))?;
816                        build_invoke_response_command(
817                            CommandPath {
818                                endpoint: 0,
819                                cluster: OTA_PROVIDER_CLUSTER,
820                                command: CMD_QUERY_IMAGE_RESPONSE,
821                            },
822                            &fields,
823                        )
824                    } else if cmd.path.command == CMD_APPLY_UPDATE_REQUEST {
825                        let fields = matter_ota::handle_apply_update_request(&cmd.fields_tlv)
826                            .map_err(|e| Error::Operational(format!("ApplyUpdateRequest: {e}")))?;
827                        build_invoke_response_command(
828                            CommandPath {
829                                endpoint: 0,
830                                cluster: OTA_PROVIDER_CLUSTER,
831                                command: CMD_APPLY_UPDATE_RESPONSE,
832                            },
833                            &fields,
834                        )
835                    } else if cmd.path.command == CMD_NOTIFY_UPDATE_APPLIED {
836                        matter_ota::parse_notify_update_applied(&cmd.fields_tlv)
837                            .map_err(|e| Error::Operational(format!("NotifyUpdateApplied: {e}")))?;
838                        let r = build_invoke_response_status(
839                            CommandPath {
840                                endpoint: 0,
841                                cluster: OTA_PROVIDER_CLUSTER,
842                                command: CMD_NOTIFY_UPDATE_APPLIED,
843                            },
844                            ImStatus::Success,
845                        );
846                        let out = sessions.encode_outbound(
847                            sid,
848                            Some(exchange_id),
849                            OP_INVOKE_RESPONSE,
850                            ProtocolId::INTERACTION_MODEL,
851                            &r,
852                            MrpFlags { reliable: false },
853                            Instant::now(),
854                        )?;
855                        self.send(&out.wire_bytes, peer).await?;
856                        return Ok(());
857                    } else {
858                        return Err(Error::Operational(format!(
859                            "unexpected OTA command {:#04x}",
860                            cmd.path.command
861                        )));
862                    };
863                    let out = sessions.encode_outbound(
864                        sid,
865                        Some(exchange_id),
866                        OP_INVOKE_RESPONSE,
867                        ProtocolId::INTERACTION_MODEL,
868                        &response,
869                        MrpFlags { reliable: false },
870                        Instant::now(),
871                    )?;
872                    self.send(&out.wire_bytes, peer).await?;
873                } else if protocol_id == ProtocolId::SECURE_CHANNEL && opcode == OP_STATUS_REPORT {
874                    // BDX-3 (receive): the requestor aborted via a Secure-Channel
875                    // StatusReport — e.g. a device-side flash-write failure. End
876                    // the transfer with a descriptive error naming the peer's
877                    // status, instead of ignoring it and spinning to the "step
878                    // budget exceeded" error (chip surfaces the peer status;
879                    // TestBdxTransferSession.cpp:629).
880                    let (general, proto, code) =
881                        parse_status_report_body(&payload).ok_or_else(|| {
882                            Error::Operational("BDX StatusReport body truncated".into())
883                        })?;
884                    return Err(Error::Operational(format!(
885                        "BDX transfer aborted by peer: StatusReport general={general:#06x} \
886                         protocol={proto:#010x} status={code:#06x}"
887                    )));
888                } else if protocol_id == ProtocolId::BDX {
889                    let mt = MessageType::from_u8(opcode).ok_or_else(|| {
890                        Error::Operational(format!("unknown BDX opcode {opcode:#04x}"))
891                    })?;
892                    let msg = BdxMessage::decode(mt, &payload)
893                        .map_err(|e| Error::Operational(format!("BDX decode: {e}")))?;
894                    // A `ReceiveInit` is a request to START a transfer. When a
895                    // sender is already armed but mid-transfer, the requestor
896                    // reconnected mid-download (reboot, link loss) and is
897                    // re-initiating BDX from its cached `QueryImageResponse`
898                    // URI without re-querying — re-arm and serve from the
899                    // start rather than aborting the serve (tolerant choice:
900                    // the image is static and the session authenticated — and
901                    // peer-pinned under `with_expected_peer` — so re-serving
902                    // the same bytes discloses nothing new). The DoS bound is
903                    // preserved: BDX still NEVER starts before this serve's
904                    // first `QueryImage` (`bdx` stays `None` until then), and
905                    // the per-session step budget bounds a requestor that
906                    // loops `ReceiveInit`.
907                    if matches!(msg, BdxMessage::ReceiveInit(_)) && bdx.is_some() {
908                        bdx = Some(BlockSender::from_shared(Arc::clone(&image), max_block_size));
909                    }
910                    let sender = bdx.as_mut().ok_or_else(|| {
911                        Error::Operational("BDX message before QueryImage".into())
912                    })?;
913                    let outcome = match msg {
914                        BdxMessage::ReceiveInit(init) => sender.accept_receive_init(&init),
915                        BdxMessage::BlockQuery(q) => sender.handle_block_query(&q),
916                        BdxMessage::BlockAckEof(a) => sender.handle_block_ack_eof(&a),
917                        _ => {
918                            return Err(Error::Operational("unexpected inbound BDX message".into()))
919                        }
920                    };
921                    match outcome {
922                        SenderOutcome::Send(out) => {
923                            // BDX-1: send every BDX message MRP-reliable so a
924                            // lost block/ReceiveAccept is retransmitted (the
925                            // recv_secured loop pumps the MRP retransmit timer).
926                            // Over a lossy mesh (Thread) the first lost block
927                            // otherwise stalls the transfer forever. chip sends
928                            // every BDX message with kExpectResponse / never
929                            // kNoAutoRequestAck (AsyncTransferFacilitator.cpp:127).
930                            let w = sessions.encode_outbound(
931                                sid,
932                                Some(exchange_id),
933                                out.message_type.to_u8(),
934                                ProtocolId::BDX,
935                                &out.payload,
936                                MrpFlags { reliable: true },
937                                Instant::now(),
938                            )?;
939                            self.send(&w.wire_bytes, peer).await?;
940                        }
941                        SenderOutcome::Done => {}
942                        SenderOutcome::Abort(code) => {
943                            // BDX-3 (send): notify the peer with a Secure-Channel
944                            // StatusReport before bailing, so the requestor learns
945                            // the transfer failed instead of timing out. Best
946                            // effort — the abort error is returned regardless.
947                            let body = encode_status_report_body(
948                                STATUS_GENERAL_FAILURE,
949                                ProtocolId::BDX,
950                                code.to_u16(),
951                            );
952                            if let Ok(w) = sessions.encode_outbound(
953                                sid,
954                                Some(exchange_id),
955                                OP_STATUS_REPORT,
956                                ProtocolId::SECURE_CHANNEL,
957                                &body,
958                                MrpFlags { reliable: true },
959                                Instant::now(),
960                            ) {
961                                let _ = self.send(&w.wire_bytes, peer).await;
962                            }
963                            return Err(Error::Operational(format!(
964                                "BDX transfer aborted: status {:#06x}",
965                                code.to_u16()
966                            )));
967                        }
968                    }
969                }
970
971                if advanced {
972                    progress += 1;
973                }
974            }
975            if carried.is_none() {
976                return Err(Error::Operational(format!(
977                    "OTA session ended without completing: served {progress}/{max_progress} \
978                     transfer-advancing messages in {iterations}/{max_iterations} iterations"
979                )));
980            }
981        }
982    }
983}
984
985#[cfg(test)]
986mod tests {
987    #![allow(clippy::unwrap_used, clippy::expect_used)] // Test code: CLAUDE.md carve-out.
988    use super::*;
989    use std::net::Ipv6Addr;
990
991    #[test]
992    fn operational_service_has_expected_name_kind_and_port() {
993        let compressed = [0xDE, 0xAD, 0xBE, 0xEF, 0xCA, 0xFE, 0xBA, 0xBE];
994        let node_id = 0x0000_0000_0000_0001;
995        let addr = IpAddr::V6(Ipv6Addr::LOCALHOST);
996        let svc = build_operational_service(compressed, node_id, vec![addr], 5540);
997
998        assert_eq!(svc.kind, ServiceKind::Operational);
999        assert_eq!(svc.port, 5540);
1000        // <16-hex compressed>-<16-hex node>, uppercase.
1001        assert_eq!(svc.instance_name, "DEADBEEFCAFEBABE-0000000000000001");
1002        assert_eq!(svc.addresses, vec![addr]);
1003    }
1004
1005    #[cfg(feature = "ota")]
1006    #[test]
1007    fn status_report_body_byte_layout_and_roundtrip() {
1008        // BDX-3: a BDX abort StatusReport = Failure || BDX proto id (0x00000002)
1009        // || the 16-bit BDX status. Byte layout is little-endian per field
1010        // (Matter Core §4.11.6).
1011        let code = matter_bdx::BdxStatusCode::BadBlockCounter.to_u16(); // 0x0017
1012        let body = encode_status_report_body(STATUS_GENERAL_FAILURE, ProtocolId::BDX, code);
1013        assert_eq!(
1014            body,
1015            vec![0x01, 0x00, 0x02, 0x00, 0x00, 0x00, 0x17, 0x00],
1016            "GeneralCode(LE) || ProtocolId(LE u32) || ProtocolStatus(LE)"
1017        );
1018        assert_eq!(
1019            parse_status_report_body(&body),
1020            Some((STATUS_GENERAL_FAILURE, 0x0000_0002, code))
1021        );
1022    }
1023
1024    #[test]
1025    fn status_report_body_rejects_truncated() {
1026        assert_eq!(parse_status_report_body(&[0x01, 0x00, 0x02]), None);
1027        assert_eq!(parse_status_report_body(&[]), None);
1028    }
1029
1030    /// `is_unsecured_frame` returns true for session id 0 (unsecured), false
1031    /// for a non-zero session id (secured), and false for a short slice.
1032    #[test]
1033    fn is_unsecured_frame_classifies_correctly() {
1034        // True: encode_unsecured_reply always sets session id to 0.
1035        let unsecured = encode_unsecured_reply(
1036            1,
1037            1,
1038            0x30,
1039            ProtocolId::SECURE_CHANNEL,
1040            false,
1041            None,
1042            None,
1043            &[],
1044        );
1045        assert!(
1046            is_unsecured_frame(&unsecured),
1047            "unsecured reply must have session id 0"
1048        );
1049
1050        // False: hand-built frame with session id 0x1234 (LE at bytes[1..3]).
1051        let secured = vec![0x00u8, 0x34, 0x12, 0x00, 0x00, 0x00];
1052        assert!(
1053            !is_unsecured_frame(&secured),
1054            "non-zero session id must not be classified as unsecured"
1055        );
1056
1057        // False: slice shorter than 3 bytes.
1058        assert!(
1059            !is_unsecured_frame(&[0x00u8, 0x00]),
1060            "2-byte slice must return false"
1061        );
1062    }
1063
1064    /// An empty credential pool must fail fast (before any IO) with the
1065    /// canonical error message. This exercises the pool-exhaustion guard in
1066    /// `accept_case` without requiring a real CASE peer.
1067    #[cfg(feature = "ota")]
1068    #[tokio::test]
1069    async fn empty_credential_pool_errors_before_any_io() {
1070        let (io, _peer) = matter_commissioning::driver::InMemoryDatagram::pair();
1071        let server = ProviderServer::new(
1072            io,
1073            Vec::new(),
1074            TrustedRoots::new(),
1075            0x10,
1076            MatterTime::from_unix_secs(2_000_000_000),
1077        );
1078        let offer = matter_ota::ImageOffer {
1079            software_version: 2,
1080            software_version_string: "2.0".into(),
1081            image_uri: "bdx://0/fw.ota".into(),
1082            update_token: vec![0xAB; 16],
1083        };
1084        let err = server
1085            .serve_ota_once(offer, vec![0u8; 16], 960)
1086            .await
1087            .expect_err("empty pool must fail fast");
1088        assert!(
1089            err.to_string().contains("credential pool exhausted"),
1090            "unexpected error: {err}"
1091        );
1092    }
1093}