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}