Skip to main content

moirai_http/websocket/
close.rs

1//! Validated peer-close observations and WebSocket protocol errors.
2
3use std::fmt;
4use std::io;
5
6/// A complete, validated RFC 6455 close frame received from the peer.
7///
8/// [`super::WebSocketStream::recv_message`] retains `UnexpectedEof` as its I/O
9/// category and stores this typed cause in [`io::Error::get_ref`]. Truncated
10/// frames never carry this cause. An empty close payload has no status code.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub struct WebSocketPeerClosed {
13    code: Option<u16>,
14    reason: String,
15}
16
17impl WebSocketPeerClosed {
18    /// Validated wire status, absent when the close payload is empty.
19    pub const fn code(&self) -> Option<u16> {
20        self.code
21    }
22
23    /// Validated UTF-8 close reason from the peer.
24    pub fn reason(&self) -> &str {
25        &self.reason
26    }
27}
28
29impl fmt::Display for WebSocketPeerClosed {
30    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
31        // Retain the established diagnostic; structured fields carry context.
32        formatter.write_str("WebSocket peer closed the connection")
33    }
34}
35
36impl std::error::Error for WebSocketPeerClosed {}
37
38// A borrowed validated payload avoids allocation for locally sent close frames.
39// Only this leaf constructs it, so conversion cannot fabricate validated fields.
40pub(super) struct ClosePayload<'a> {
41    code: Option<u16>,
42    reason: &'a str,
43}
44
45impl From<ClosePayload<'_>> for WebSocketPeerClosed {
46    fn from(payload: ClosePayload<'_>) -> Self {
47        Self {
48            code: payload.code,
49            // io::Error owns a 'static cause after its frame buffer is released.
50            reason: payload.reason.to_owned(),
51        }
52    }
53}
54
55pub(super) fn parse_close_payload(payload: &[u8]) -> io::Result<ClosePayload<'_>> {
56    match payload {
57        [] => Ok(ClosePayload {
58            code: None,
59            reason: "",
60        }),
61        [_] => Err(protocol_error("WebSocket close payload has one byte")),
62        [high, low, reason @ ..] => {
63            let code = u16::from_be_bytes([*high, *low]);
64            let valid_range = (1000..=2999).contains(&code) || (3000..=4999).contains(&code);
65            if !valid_range || matches!(code, 1004 | 1005 | 1006 | 1015) {
66                return Err(protocol_error("WebSocket close code is reserved"));
67            }
68            let reason = std::str::from_utf8(reason)
69                .map_err(|_| protocol_error("WebSocket close reason is not UTF-8"))?;
70            Ok(ClosePayload {
71                code: Some(code),
72                reason,
73            })
74        }
75    }
76}
77
78pub(super) fn protocol_error(message: &str) -> io::Error {
79    io::Error::new(io::ErrorKind::InvalidData, message)
80}