Skip to main content

netget/server/stomp/
frame.rs

1//! STOMP 1.2 frame codec.
2//!
3//! A STOMP frame is
4//!
5//! ```text
6//! COMMAND\n
7//! header:value\n
8//! ...\n
9//! \n
10//! <body>\0
11//! ```
12//!
13//! The framing is done here, in Rust, rather than handed to the model: frame boundaries,
14//! header escaping and the NUL terminator are mechanical, and a model that gets any of them
15//! wrong produces a stream a real client cannot resynchronise from. What the model decides is
16//! the *content* — which is what the events and actions in `actions.rs` carry.
17//!
18//! Three details of the spec that are easy to get wrong and are implemented here:
19//!
20//! - **Header escaping is per-frame, not global.** `\r` `\n` `:` `\` are escaped as `\\r`
21//!   `\\n` `\\c` `\\\\` in every frame *except* `CONNECT`, `STOMP` and `CONNECTED`, which are
22//!   exempt for backwards compatibility with STOMP 1.0/1.1 clients. [`should_escape`] is the
23//!   single place that decision is made, so encode and decode cannot disagree.
24//! - **An undefined escape sequence is a fatal protocol error**, not something to pass
25//!   through. [`unescape_header`] returns [`FrameError::InvalidEscape`].
26//! - **`content-length`, when present, is authoritative** — the body may then contain NUL
27//!   bytes, and the terminator is the byte *after* the counted body. Without it the body runs
28//!   to the first NUL.
29//!
30//! Repeated headers: the first occurrence wins ([`StompFrame::header`]), which is what the
31//! spec requires. Order is preserved so a frame can be logged as it arrived.
32
33/// Largest frame this server will buffer before giving up on a peer.
34///
35/// Without a bound, a peer that opens a frame and never terminates it makes the server grow a
36/// buffer forever. One megabyte is far above anything the actions here can produce and far
37/// below anything that threatens the process.
38pub const MAX_FRAME_BYTES: usize = 1024 * 1024;
39
40/// Largest number of header lines accepted in one frame.
41///
42/// `MAX_FRAME_BYTES` bounds the *memory*, but not the work: the connection loop re-parses the
43/// whole pending buffer after every read, allocating two `String`s per header line each time.
44/// One megabyte of three-byte header lines (`a:\n`) is ~350k headers re-parsed ~128 times as
45/// the 8 KB reads arrive - tens of millions of allocations from a megabyte of input, per
46/// connection, with no connection limit. No real STOMP frame has more than a dozen headers.
47pub const MAX_HEADERS: usize = 1024;
48
49/// Longest peer-supplied fragment quoted back in a `FrameError`.
50///
51/// A `FrameError` reaches three places: the `ERROR` frame sent to the peer, `netget.log`, and
52/// the TUI status stream, which is an unbounded channel with no backpressure. Quoting a
53/// megabyte header line verbatim - which `{:?}` escaping can roughly double - into all three
54/// is a peer's choice of how much memory to spend on NetGet's behalf.
55const MAX_QUOTED_FRAGMENT: usize = 120;
56
57/// A parsed STOMP frame.
58#[derive(Debug, Clone, PartialEq, Eq)]
59pub struct StompFrame {
60    /// The command line (`CONNECT`, `SEND`, `MESSAGE`, …), verbatim.
61    pub command: String,
62    /// Headers in arrival order, already unescaped where the command calls for it.
63    pub headers: Vec<(String, String)>,
64    /// The frame body, exactly as it appeared on the wire.
65    pub body: Vec<u8>,
66}
67
68impl StompFrame {
69    /// Build a frame from borrowed parts.
70    pub fn new(command: impl Into<String>, headers: Vec<(String, String)>, body: Vec<u8>) -> Self {
71        Self {
72            command: command.into(),
73            headers,
74            body,
75        }
76    }
77
78    /// First value for `name`, which is the one the spec says applies.
79    pub fn header(&self, name: &str) -> Option<&str> {
80        self.headers
81            .iter()
82            .find(|(k, _)| k == name)
83            .map(|(_, v)| v.as_str())
84    }
85
86    /// Every header except the ones listed, as a JSON object.
87    ///
88    /// Used to hand a model the headers it has not already been given as named event fields,
89    /// without ever handing it raw bytes.
90    pub fn headers_except(&self, exclude: &[&str]) -> serde_json::Map<String, serde_json::Value> {
91        let mut out = serde_json::Map::new();
92        for (k, v) in &self.headers {
93            if exclude.contains(&k.as_str()) {
94                continue;
95            }
96            out.entry(k.clone())
97                .or_insert_with(|| serde_json::Value::String(v.clone()));
98        }
99        out
100    }
101
102    /// Serialise to wire bytes.
103    ///
104    /// `content-length` is added automatically for a non-empty body when the caller did not
105    /// supply one: without it a body containing a NUL byte would truncate the frame, and the
106    /// caller cannot always know whether the model's text is binary-safe.
107    pub fn encode(&self) -> Vec<u8> {
108        let escape = should_escape(&self.command);
109        let mut out = Vec::with_capacity(64 + self.body.len());
110        out.extend_from_slice(self.command.as_bytes());
111        out.push(b'\n');
112
113        let mut has_content_length = false;
114        for (k, v) in &self.headers {
115            if k == "content-length" {
116                has_content_length = true;
117            }
118            let (k, v) = if escape {
119                (escape_header(k), escape_header(v))
120            } else {
121                (k.clone(), v.clone())
122            };
123            out.extend_from_slice(k.as_bytes());
124            out.push(b':');
125            out.extend_from_slice(v.as_bytes());
126            out.push(b'\n');
127        }
128
129        if !has_content_length && !self.body.is_empty() {
130            out.extend_from_slice(format!("content-length:{}\n", self.body.len()).as_bytes());
131        }
132
133        out.push(b'\n');
134        out.extend_from_slice(&self.body);
135        out.push(0);
136        out
137    }
138}
139
140/// What a parse attempt found in the buffer.
141#[derive(Debug, Clone, PartialEq, Eq)]
142pub enum ParseOutcome {
143    /// A complete frame, and how many bytes of the buffer it occupied.
144    Frame {
145        /// The frame.
146        frame: StompFrame,
147        /// Bytes to drain from the front of the buffer.
148        consumed: usize,
149    },
150    /// Only inter-frame EOLs were present. STOMP uses a bare newline as a heart-beat, and a
151    /// client is also allowed to leave trailing EOLs after a frame's NUL, so these are drained
152    /// and ignored rather than treated as an empty command.
153    Heartbeat {
154        /// Bytes to drain from the front of the buffer.
155        consumed: usize,
156    },
157    /// Not enough bytes yet; read more and try again.
158    Incomplete,
159}
160
161/// A frame the peer sent that cannot be interpreted. Every variant is fatal for the
162/// connection: STOMP has no way to resynchronise a stream once framing is lost.
163#[derive(Debug, Clone, PartialEq, Eq)]
164pub enum FrameError {
165    /// A header line with no `:` separator.
166    MalformedHeader(String),
167    /// A `\` followed by something other than `r`, `n`, `c` or `\`.
168    InvalidEscape(String),
169    /// `content-length` was present but not a non-negative integer.
170    BadContentLength(String),
171    /// `content-length` bytes were followed by something other than NUL.
172    MissingNulTerminator,
173    /// The command line or a header was not valid UTF-8.
174    NotUtf8,
175    /// The peer exceeded [`MAX_FRAME_BYTES`] without completing a frame.
176    FrameTooLarge(usize),
177    /// The frame declared more than [`MAX_HEADERS`] header lines.
178    TooManyHeaders,
179}
180
181impl std::fmt::Display for FrameError {
182    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
183        match self {
184            FrameError::MalformedHeader(line) => {
185                write!(f, "header line has no ':' separator: {line:?}")
186            }
187            FrameError::InvalidEscape(seq) => write!(f, "undefined header escape sequence {seq:?}"),
188            FrameError::BadContentLength(v) => {
189                write!(f, "content-length is not a non-negative integer: {v:?}")
190            }
191            FrameError::MissingNulTerminator => write!(
192                f,
193                "content-length bytes were not followed by a NUL terminator"
194            ),
195            FrameError::NotUtf8 => write!(f, "command line or header was not valid UTF-8"),
196            FrameError::FrameTooLarge(n) => write!(
197                f,
198                "frame exceeded {MAX_FRAME_BYTES} bytes without terminating ({n} buffered)"
199            ),
200            FrameError::TooManyHeaders => {
201                write!(f, "frame declared more than {MAX_HEADERS} headers")
202            }
203        }
204    }
205}
206
207impl std::error::Error for FrameError {}
208
209/// Trim a peer-supplied fragment to something safe to quote back at it and into the logs.
210///
211/// `crate::utils::truncate_for_log` rather than `&s[..N]`, because a header value is arbitrary
212/// UTF-8 and a byte-index cut lands mid-character often enough to matter.
213fn quote_fragment(s: &str) -> String {
214    crate::utils::truncate_for_log(s, MAX_QUOTED_FRAGMENT)
215}
216
217/// Whether a header name or value can be written into a frame whose command is exempt from
218/// escaping, without forging a header.
219///
220/// `CONNECT`, `STOMP` and `CONNECTED` carry their headers raw (see [`should_escape`]), so for
221/// those three commands nothing downstream can neutralise a `\n` or a `:`. A value containing
222/// one would inject an extra header, and `\n\n` would terminate the header block and start a
223/// body. Rejecting is right rather than escaping: a 1.0/1.1 peer would read an escape sequence
224/// literally, which is the whole reason those commands are exempt.
225pub fn is_safe_unescaped_header(s: &str) -> bool {
226    !s.contains(['\r', '\n', ':', '\0'])
227}
228
229/// Whether headers in `command` are escaped.
230///
231/// `CONNECT`, `STOMP` and `CONNECTED` are exempt in STOMP 1.2 so that a 1.2 endpoint can still
232/// read the handshake of a 1.0/1.1 peer, which knew nothing about escaping.
233pub fn should_escape(command: &str) -> bool {
234    !matches!(command, "CONNECT" | "STOMP" | "CONNECTED")
235}
236
237/// Encode the four sequences STOMP 1.2 defines. Nothing else is touched.
238pub fn escape_header(s: &str) -> String {
239    let mut out = String::with_capacity(s.len());
240    for c in s.chars() {
241        match c {
242            '\r' => out.push_str("\\r"),
243            '\n' => out.push_str("\\n"),
244            ':' => out.push_str("\\c"),
245            '\\' => out.push_str("\\\\"),
246            other => out.push(other),
247        }
248    }
249    out
250}
251
252/// Decode the four sequences STOMP 1.2 defines.
253///
254/// Any other sequence is a fatal error by the spec — passing it through would let a peer smuggle
255/// a `:` or a newline past the parser and forge a header.
256pub fn unescape_header(s: &str) -> Result<String, FrameError> {
257    let mut out = String::with_capacity(s.len());
258    let mut chars = s.chars();
259    while let Some(c) = chars.next() {
260        if c != '\\' {
261            out.push(c);
262            continue;
263        }
264        match chars.next() {
265            Some('r') => out.push('\r'),
266            Some('n') => out.push('\n'),
267            Some('c') => out.push(':'),
268            Some('\\') => out.push('\\'),
269            Some(other) => return Err(FrameError::InvalidEscape(format!("\\{other}"))),
270            None => return Err(FrameError::InvalidEscape("\\<end of value>".to_string())),
271        }
272    }
273    Ok(out)
274}
275
276/// Try to take one frame off the front of `buf`.
277///
278/// Never consumes a partial frame: on [`ParseOutcome::Incomplete`] the caller keeps the buffer
279/// as it is and reads more.
280pub fn parse_frame(buf: &[u8]) -> Result<ParseOutcome, FrameError> {
281    match parse_inner(buf) {
282        Ok(ParseOutcome::Incomplete) if buf.len() > MAX_FRAME_BYTES => {
283            Err(FrameError::FrameTooLarge(buf.len()))
284        }
285        other => other,
286    }
287}
288
289fn parse_inner(buf: &[u8]) -> Result<ParseOutcome, FrameError> {
290    // Drain inter-frame EOLs (heart-beats, and the trailing newlines some clients append
291    // after a frame's NUL). A lone trailing '\r' is ambiguous until the next byte arrives.
292    let mut pos = 0usize;
293    loop {
294        match buf.get(pos) {
295            Some(b'\n') => pos += 1,
296            Some(b'\r') => match buf.get(pos + 1) {
297                Some(b'\n') => pos += 2,
298                Some(_) => break,
299                None => return Ok(ParseOutcome::Incomplete),
300            },
301            _ => break,
302        }
303    }
304    if pos > 0 && pos >= buf.len() {
305        return Ok(ParseOutcome::Heartbeat { consumed: pos });
306    }
307    if pos >= buf.len() {
308        return Ok(ParseOutcome::Incomplete);
309    }
310
311    let Some((command, next)) = read_line(buf, pos)? else {
312        return Ok(ParseOutcome::Incomplete);
313    };
314    pos = next;
315
316    let escape = should_escape(&command);
317    let mut headers: Vec<(String, String)> = Vec::new();
318    loop {
319        let Some((line, next)) = read_line(buf, pos)? else {
320            return Ok(ParseOutcome::Incomplete);
321        };
322        pos = next;
323        if line.is_empty() {
324            break;
325        }
326        if headers.len() >= MAX_HEADERS {
327            return Err(FrameError::TooManyHeaders);
328        }
329        let Some((raw_name, raw_value)) = line.split_once(':') else {
330            return Err(FrameError::MalformedHeader(quote_fragment(&line)));
331        };
332        let (name, value) = if escape {
333            (unescape_header(raw_name)?, unescape_header(raw_value)?)
334        } else {
335            (raw_name.to_string(), raw_value.to_string())
336        };
337        headers.push((name, value));
338    }
339
340    // `content-length` is authoritative when present, so a body may contain NUL bytes.
341    let declared_len = headers
342        .iter()
343        .find(|(k, _)| k == "content-length")
344        .map(|(_, v)| {
345            v.trim()
346                .parse::<usize>()
347                .map_err(|_| FrameError::BadContentLength(quote_fragment(v)))
348        })
349        .transpose()?;
350
351    let (body, consumed) = match declared_len {
352        Some(len) => {
353            // `content-length: 18446744073709551615` parses cleanly into a `usize`, and
354            // `pos + len` then overflowed: a panic in debug and test builds (there is no
355            // `[profile.dev]` override, so `overflow-checks` is on), and in release a wrap to
356            // `pos - 1` that made the bug invisible in the shipped profile while it was live
357            // in every test run. The panic happened inside the connection's `tokio::spawn`,
358            // so it was swallowed, the connection's cleanup never ran, and the peer handle
359            // and `AppState` row leaked with the connection stuck `Active`. Reachable before
360            // the CONNECT gate, by any peer, in one frame.
361            if len > MAX_FRAME_BYTES {
362                return Err(FrameError::FrameTooLarge(len));
363            }
364            let end = pos + len;
365            if end >= buf.len() {
366                return Ok(ParseOutcome::Incomplete);
367            }
368            if buf[end] != 0 {
369                return Err(FrameError::MissingNulTerminator);
370            }
371            (buf[pos..end].to_vec(), end + 1)
372        }
373        None => {
374            let Some(offset) = buf[pos..].iter().position(|&b| b == 0) else {
375                return Ok(ParseOutcome::Incomplete);
376            };
377            (buf[pos..pos + offset].to_vec(), pos + offset + 1)
378        }
379    };
380
381    Ok(ParseOutcome::Frame {
382        frame: StompFrame {
383            command,
384            headers,
385            body,
386        },
387        consumed,
388    })
389}
390
391/// Read one `\n`- or `\r\n`-terminated line starting at `from`.
392///
393/// `Ok(None)` means the line is not complete yet.
394fn read_line(buf: &[u8], from: usize) -> Result<Option<(String, usize)>, FrameError> {
395    let Some(offset) = buf[from..].iter().position(|&b| b == b'\n') else {
396        return Ok(None);
397    };
398    let mut end = from + offset;
399    let next = end + 1;
400    if end > from && buf[end - 1] == b'\r' {
401        end -= 1;
402    }
403    let line = std::str::from_utf8(&buf[from..end])
404        .map_err(|_| FrameError::NotUtf8)?
405        .to_string();
406    Ok(Some((line, next)))
407}
408
409/// A `RECEIPT` frame for `receipt_id`.
410pub fn receipt_frame(receipt_id: &str) -> Vec<u8> {
411    StompFrame::new(
412        "RECEIPT",
413        vec![("receipt-id".to_string(), receipt_id.to_string())],
414        Vec::new(),
415    )
416    .encode()
417}
418
419/// An `ERROR` frame.
420///
421/// `message` becomes the `message` header (the short reason) and `body` the explanatory text.
422/// Both are caller-chosen, and the callers in this protocol only ever pass literals or a
423/// [`crate::utils::WireFailure`] category — an internal error string must never reach here.
424pub fn error_frame(message: &str, body: &str) -> Vec<u8> {
425    StompFrame::new(
426        "ERROR",
427        vec![
428            ("message".to_string(), message.to_string()),
429            ("content-type".to_string(), "text/plain".to_string()),
430        ],
431        body.as_bytes().to_vec(),
432    )
433    .encode()
434}