From ec0283f148fc8dcc4699bbffd7f8fae7628fe719 Mon Sep 17 00:00:00 2001 From: Martin Algesten Date: Sun, 5 Jul 2026 11:43:54 +0200 Subject: [PATCH] Add DTLS close state predicates --- CHANGELOG.md | 2 + src/dtls12/client.rs | 10 +++++ src/dtls12/engine.rs | 10 +++++ src/dtls12/server.rs | 10 +++++ src/dtls13/client.rs | 11 ++++++ src/dtls13/engine.rs | 10 +++++ src/dtls13/server.rs | 11 ++++++ src/lib.rs | 57 +++++++++++++++++++++++++++ tests/dtls12/edge.rs | 58 +++++++++++++++++++++++++++ tests/dtls13/edge.rs | 94 ++++++++++++++++++++++++++++++++++++++++++++ 10 files changed, 273 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e652f0b..5877c278 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased + * Add `Dtls::is_closing()` and `Dtls::is_closed()` shutdown predicates #155 + # 0.7.0 * Omit empty DTLS 1.3 CertificateRequest certificate authorities #153 diff --git a/src/dtls12/client.rs b/src/dtls12/client.rs index 71ac7a70..4ec92a60 100644 --- a/src/dtls12/client.rs +++ b/src/dtls12/client.rs @@ -186,6 +186,16 @@ impl Client { self.state.name() } + pub fn is_closing(&self) -> bool { + self.state == State::Closed && !self.is_closed() + } + + pub fn is_closed(&self) -> bool { + self.state == State::Closed + && self.local_events.is_empty() + && !self.engine.has_pending_close_output() + } + pub fn handle_packet(&mut self, packet: &[u8]) -> Result<(), Error> { match self .engine diff --git a/src/dtls12/engine.rs b/src/dtls12/engine.rs index adce4d58..dd4b9c2b 100644 --- a/src/dtls12/engine.rs +++ b/src/dtls12/engine.rs @@ -1018,6 +1018,16 @@ impl Engine { self.close_notify_received } + /// Whether a received close_notify still needs to be surfaced. + pub fn close_notify_pending(&self) -> bool { + self.close_notify_received && !self.close_notify_reported + } + + /// Whether close-related output remains to be polled. + pub fn has_pending_close_output(&self) -> bool { + !self.queue_tx.is_empty() || self.close_notify_pending() + } + /// Discard all pending outgoing data. /// /// RFC 5246 §7.2.1: on receiving close_notify, discard any pending writes. diff --git a/src/dtls12/server.rs b/src/dtls12/server.rs index 48e22f1a..fce3d508 100644 --- a/src/dtls12/server.rs +++ b/src/dtls12/server.rs @@ -188,6 +188,16 @@ impl Server { self.state.name() } + pub fn is_closing(&self) -> bool { + self.state == State::Closed && !self.is_closed() + } + + pub fn is_closed(&self) -> bool { + self.state == State::Closed + && self.local_events.is_empty() + && !self.engine.has_pending_close_output() + } + pub fn handle_packet(&mut self, packet: &[u8]) -> Result<(), Error> { match self .engine diff --git a/src/dtls13/client.rs b/src/dtls13/client.rs index 91636f51..0f5193d5 100644 --- a/src/dtls13/client.rs +++ b/src/dtls13/client.rs @@ -218,6 +218,17 @@ impl Client { self.state.name() } + pub fn is_closing(&self) -> bool { + (matches!(self.state, State::HalfClosedLocal | State::Closed) && !self.is_closed()) + || self.engine.close_notify_pending() + } + + pub fn is_closed(&self) -> bool { + self.state == State::Closed + && self.local_events.is_empty() + && !self.engine.has_pending_close_output() + } + pub fn handle_packet(&mut self, packet: &[u8]) -> Result<(), Error> { match self .engine diff --git a/src/dtls13/engine.rs b/src/dtls13/engine.rs index d2c8e73b..6d822164 100644 --- a/src/dtls13/engine.rs +++ b/src/dtls13/engine.rs @@ -1270,6 +1270,16 @@ impl Engine { self.close_notify_sequence.is_some() } + /// Whether a received close_notify still needs to be surfaced. + pub fn close_notify_pending(&self) -> bool { + self.close_notify_sequence.is_some() && !self.close_notify_reported + } + + /// Whether close-related output remains to be polled. + pub fn has_pending_close_output(&self) -> bool { + !self.queue_tx.is_empty() || self.close_notify_pending() + } + /// Cancel in-flight retransmissions without clearing the transmit queue. /// Used by close() to stop retransmitting control records while still /// allowing the queued close_notify alert to be sent. diff --git a/src/dtls13/server.rs b/src/dtls13/server.rs index 2fd1a4b5..ebe3ed5b 100644 --- a/src/dtls13/server.rs +++ b/src/dtls13/server.rs @@ -236,6 +236,17 @@ impl Server { self.state.name() } + pub fn is_closing(&self) -> bool { + (matches!(self.state, State::HalfClosedLocal | State::Closed) && !self.is_closed()) + || self.engine.close_notify_pending() + } + + pub fn is_closed(&self) -> bool { + self.state == State::Closed + && self.local_events.is_empty() + && !self.engine.has_pending_close_output() + } + pub fn handle_packet(&mut self, packet: &[u8]) -> Result<(), Error> { // In auto-sense mode, buffer raw packets while still waiting for // the ClientHello so they can be replayed to Server12 on fallback. diff --git a/src/lib.rs b/src/lib.rs index 7fa84a3b..8356df11 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -553,6 +553,63 @@ impl Dtls { } } + /// Return true while DTLS shutdown is in progress. + /// + /// This becomes true after local shutdown starts, or while a received + /// `close_notify` still needs to be reported by + /// [`poll_output`](Self::poll_output). Callers should continue polling + /// until this returns false or [`is_closed`](Self::is_closed) returns true. + /// + /// Pending auto-sense handshakes are neither closing nor closed. + pub fn is_closing(&self) -> bool { + let Some(inner) = self.inner.as_ref() else { + return false; + }; + + match inner { + Inner::Client12(client) => client.is_closing(), + Inner::Server12(server) => server.is_closing(), + Inner::Client13(client) => client.is_closing(), + Inner::Server13(server) => { + if server.is_auto_mode() { + false + } else { + server.is_closing() + } + } + Inner::ClientPending(_) => false, + } + } + + /// Return true once DTLS shutdown is terminal for the embedder. + /// + /// This only becomes true after no application data can be sent and no + /// close-related packets or events remain to be drained through + /// [`poll_output`](Self::poll_output). In particular, DTLS 1.2 can enter + /// its internal closed state before the queued `close_notify` packet has + /// been polled out; this method remains false until that output is drained. + /// + /// Pending auto-sense handshakes are neither closing nor closed. + pub fn is_closed(&self) -> bool { + let Some(inner) = self.inner.as_ref() else { + return false; + }; + + match inner { + Inner::Client12(client) => client.is_closed(), + Inner::Server12(server) => server.is_closed(), + Inner::Client13(client) => client.is_closed(), + Inner::Server13(server) => { + if server.is_auto_mode() { + false + } else { + server.is_closed() + } + } + Inner::ClientPending(_) => false, + } + } + /// Return true if the instance is operating in the client role. pub fn is_active(&self) -> bool { matches!( diff --git a/tests/dtls12/edge.rs b/tests/dtls12/edge.rs index 592637b3..279b4ba2 100644 --- a/tests/dtls12/edge.rs +++ b/tests/dtls12/edge.rs @@ -916,8 +916,16 @@ fn dtls12_reciprocal_close_notify_and_no_further_sends() { let (mut client, mut server, now_hs) = setup_connected_12_pair(now); now = now_hs; + assert!(!client.is_closing()); + assert!(!client.is_closed()); + assert!(!server.is_closing()); + assert!(!server.is_closed()); + // Client sends close_notify client.close().unwrap(); + assert!(client.is_closing()); + assert!(!client.is_closed()); + now += Duration::from_millis(10); client.handle_timeout(now).expect("client timeout"); let client_out = drain_outputs(&mut client); @@ -925,10 +933,15 @@ fn dtls12_reciprocal_close_notify_and_no_further_sends() { !client_out.packets.is_empty(), "Client should emit close_notify alert" ); + assert!(!client.is_closing()); + assert!(client.is_closed()); // Deliver to server deliver_packets(&client_out.packets, &mut server); server.handle_timeout(now).expect("server timeout"); + assert!(server.is_closing()); + assert!(!server.is_closed()); + let server_out = drain_outputs(&mut server); // Server should emit CloseNotify event @@ -942,17 +955,24 @@ fn dtls12_reciprocal_close_notify_and_no_further_sends() { !server_out.packets.is_empty(), "Server should emit a reciprocal close_notify packet" ); + assert!(!server.is_closing()); + assert!(server.is_closed()); // Deliver reciprocal back to client and verify it sees CloseNotify. deliver_packets(&server_out.packets, &mut client); client .handle_timeout(now) .expect("client timeout after reciprocal"); + assert!(client.is_closing()); + assert!(!client.is_closed()); + let client_out2 = drain_outputs(&mut client); assert!( client_out2.close_notify, "Client should emit Output::CloseNotify after receiving reciprocal close_notify" ); + assert!(!client.is_closing()); + assert!(client.is_closed()); // No half-close in DTLS 1.2: both sides must reject further sends. assert!( @@ -965,6 +985,44 @@ fn dtls12_reciprocal_close_notify_and_no_further_sends() { ); } +#[test] +#[cfg(feature = "rcgen")] +fn dtls12_close_state_matrix() { + let mut now = Instant::now(); + let (mut client, mut server, now_hs) = setup_connected_12_pair(now); + now = now_hs; + + assert!(!client.is_closing()); + assert!(!client.is_closed()); + assert!(!server.is_closing()); + assert!(!server.is_closed()); + + client.close().unwrap(); + assert!(client.is_closing(), "local close pending"); + assert!(!client.is_closed(), "local close not drained"); + + now += Duration::from_millis(10); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "local close_notify packet"); + assert!(!client.is_closing(), "local close drained"); + assert!(client.is_closed(), "local close terminal"); + + deliver_packets(&client_out.packets, &mut server); + server.handle_timeout(now).expect("server timeout"); + assert!(server.is_closing(), "remote close pending"); + assert!(!server.is_closed(), "remote close not drained"); + + let server_out = drain_outputs(&mut server); + assert!(server_out.close_notify, "remote CloseNotify event"); + assert!( + !server_out.packets.is_empty(), + "remote reciprocal close_notify packet" + ); + assert!(!server.is_closing(), "remote close drained"); + assert!(server.is_closed(), "remote close terminal"); +} + #[test] #[cfg(feature = "rcgen")] fn dtls12_discard_pending_writes_on_close_notify() { diff --git a/tests/dtls13/edge.rs b/tests/dtls13/edge.rs index 644a4f1f..c68ea460 100644 --- a/tests/dtls13/edge.rs +++ b/tests/dtls13/edge.rs @@ -1797,28 +1797,51 @@ fn dtls13_half_closed_local_transitions_to_closed() { let (mut client, mut server, now_hs) = setup_connected_13_pair(now); now = now_hs; + assert!(!client.is_closing()); + assert!(!client.is_closed()); + assert!(!server.is_closing()); + assert!(!server.is_closed()); + // Client calls close() → HalfClosedLocal client.close().unwrap(); + assert!(client.is_closing()); + assert!(!client.is_closed()); + now += Duration::from_millis(10); client.handle_timeout(now).expect("client timeout"); let client_out = drain_outputs(&mut client); + assert!(client.is_closing()); + assert!(!client.is_closed()); // Deliver client's close_notify to server deliver_packets(&client_out.packets, &mut server); server.handle_timeout(now).expect("server timeout"); + assert!(server.is_closing()); + assert!(!server.is_closed()); + let server_out = drain_outputs(&mut server); assert!(server_out.close_notify, "Server should see CloseNotify"); + assert!(!server.is_closing()); + assert!(!server.is_closed()); // Server calls close() → sends its own close_notify server.close().unwrap(); now += Duration::from_millis(10); server.handle_timeout(now).expect("server timeout"); + assert!(server.is_closing()); + assert!(!server.is_closed()); + let server_out = drain_outputs(&mut server); + assert!(!server.is_closing()); + assert!(server.is_closed()); // Deliver server's close_notify to client deliver_packets(&server_out.packets, &mut client); now += Duration::from_millis(10); client.handle_timeout(now).expect("client timeout"); + assert!(client.is_closing()); + assert!(!client.is_closed()); + let client_out = drain_outputs(&mut client); // Client should emit CloseNotify (peer's close_notify received) @@ -1826,6 +1849,77 @@ fn dtls13_half_closed_local_transitions_to_closed() { client_out.close_notify, "Client should emit CloseNotify after receiving peer's close_notify" ); + assert!(!client.is_closing()); + assert!(client.is_closed()); +} + +#[test] +#[cfg(feature = "rcgen")] +fn dtls13_close_state_matrix() { + let mut now = Instant::now(); + let (mut client, mut server, now_hs) = setup_connected_13_pair(now); + now = now_hs; + + assert!(!client.is_closing()); + assert!(!client.is_closed()); + assert!(!server.is_closing()); + assert!(!server.is_closed()); + + client.close().unwrap(); + assert!(client.is_closing(), "local close pending"); + assert!(!client.is_closed(), "local close not terminal"); + + now += Duration::from_millis(10); + client.handle_timeout(now).expect("client timeout"); + let client_out = drain_outputs(&mut client); + assert!(!client_out.packets.is_empty(), "local close_notify packet"); + assert!( + client.is_closing(), + "local close remains pending until peer close_notify" + ); + assert!(!client.is_closed(), "local half-close is not terminal"); + + deliver_packets(&client_out.packets, &mut server); + server.handle_timeout(now).expect("server timeout"); + assert!(server.is_closing(), "remote close pending"); + assert!(!server.is_closed(), "remote close not drained"); + + let server_out = drain_outputs(&mut server); + assert!(server_out.close_notify, "remote CloseNotify event"); + assert!(!server.is_closing(), "remote close event drained"); + assert!( + !server.is_closed(), + "remote half-close leaves write side open" + ); + + server.close().unwrap(); + now += Duration::from_millis(10); + server + .handle_timeout(now) + .expect("server timeout after close"); + assert!(server.is_closing(), "remote reciprocal close pending"); + assert!(!server.is_closed(), "remote reciprocal close not drained"); + + let server_out = drain_outputs(&mut server); + assert!( + !server_out.packets.is_empty(), + "remote reciprocal close_notify packet" + ); + assert!(!server.is_closing(), "remote reciprocal close drained"); + assert!(server.is_closed(), "remote close terminal"); + + deliver_packets(&server_out.packets, &mut client); + now += Duration::from_millis(10); + client + .handle_timeout(now) + .expect("client timeout after reciprocal"); + assert!(client.is_closing(), "local close peer response pending"); + assert!(!client.is_closed(), "local close peer response not drained"); + + let client_out = drain_outputs(&mut client); + assert!(client_out.close_notify, "local CloseNotify event"); + assert!(!client.is_closing(), "local close drained"); + assert!(client.is_closed(), "local close terminal"); } #[test]