iridium_stomp/
parser.rs

1// Slice-based STOMP frame parser (produces owned Vecs from input slices)
2
3/// Unescape a STOMP 1.2 header value.
4///
5/// Per STOMP 1.2 spec, the following escape sequences are supported:
6/// - `\r` → carriage return (0x0d)
7/// - `\n` → line feed (0x0a)
8/// - `\c` → colon (0x3a)
9/// - `\\` → backslash (0x5c)
10///
11/// Returns an error if an invalid escape sequence is encountered.
12pub fn unescape_header_value(input: &[u8]) -> Result<Vec<u8>, String> {
13    let mut result = Vec::with_capacity(input.len());
14    let mut i = 0;
15    while i < input.len() {
16        if input[i] == b'\\' {
17            if i + 1 >= input.len() {
18                return Err("incomplete escape sequence at end of header value".to_string());
19            }
20            match input[i + 1] {
21                b'\\' => result.push(b'\\'),
22                b'n' => result.push(b'\n'),
23                b'r' => result.push(b'\r'),
24                b'c' => result.push(b':'),
25                other => {
26                    return Err(format!(
27                        "invalid escape sequence '\\{}' in header value",
28                        other as char
29                    ));
30                }
31            }
32            i += 2;
33        } else {
34            result.push(input[i]);
35            i += 1;
36        }
37    }
38    Ok(result)
39}
40
41/// Minimal helper: extract optional content-length header value from a header list.
42///
43/// Returns:
44/// - Ok(Some(n)) when a valid Content-Length header is present and parsed.
45/// - Ok(None) when no Content-Length header is present.
46/// - Err(String) when Content-Length is present but not a valid unsigned integer.
47type ParseResult =
48    Result<Option<(Vec<u8>, Vec<(Vec<u8>, Vec<u8>)>, Option<Vec<u8>>, usize)>, String>;
49
50fn get_content_length(headers: &[(Vec<u8>, Vec<u8>)]) -> Result<Option<usize>, String> {
51    for (k, v) in headers {
52        if k.eq_ignore_ascii_case(&b"content-length"[..]) {
53            let s =
54                std::str::from_utf8(v).map_err(|e| format!("content-length not utf8: {}", e))?;
55            let trimmed = s.trim();
56            if trimmed.is_empty() {
57                return Err("empty content-length".to_string());
58            }
59            match trimmed.parse::<usize>() {
60                Ok(n) => return Ok(Some(n)),
61                Err(e) => return Err(format!("invalid content-length '{}': {}", trimmed, e)),
62            }
63        }
64    }
65    Ok(None)
66}
67
68/// Parse a single STOMP frame from a raw byte slice.
69///
70/// Returns Ok(Some((command, headers, body, consumed_bytes))) when a full frame
71/// was parsed and how many bytes were consumed. Returns Ok(None) when more
72/// bytes are required. Returns Err on protocol errors.
73pub fn parse_frame_slice(input: &[u8]) -> ParseResult {
74    let mut pos = 0usize;
75    let len = input.len();
76
77    // skip any leading LF heartbeats
78    while pos < len && input[pos] == b'\n' {
79        // treat a single LF as a heartbeat frame (handled by caller if desired)
80        // but we skip leading LFs here; the codec will detect heartbeat earlier
81        pos += 1;
82    }
83
84    // parse command line: find next LF; if no LF, fall back to NUL-only frame
85    let cmd_end_opt = input[pos..].iter().position(|&b| b == b'\n');
86    let mut command: Vec<u8>;
87    if let Some(cmd_end_rel) = cmd_end_opt {
88        command = input[pos..pos + cmd_end_rel].to_vec();
89        // strip trailing CR if present
90        if command.last() == Some(&b'\r') {
91            // remove trailing CR
92            command.pop();
93        }
94        pos += cmd_end_rel + 1;
95    } else {
96        // No newline found: if there's a NUL in the remaining bytes, treat
97        // this as a bare NUL-terminated body with empty command/headers.
98        if let Some(nul_rel) = input[pos..].iter().position(|&b| b == 0) {
99            let body = input[pos..pos + nul_rel].to_vec();
100            pos += nul_rel + 1;
101            if pos < len && input[pos] == b'\n' {
102                pos += 1;
103            }
104            let body_opt = if body.is_empty() { None } else { Some(body) };
105            return Ok(Some((Vec::new(), Vec::new(), body_opt, pos)));
106        }
107        return Ok(None);
108    }
109
110    // parse headers until an empty line (LF) is found
111    let mut headers: Vec<(Vec<u8>, Vec<u8>)> = Vec::new();
112    loop {
113        if pos >= len {
114            return Ok(None);
115        }
116        if input[pos] == b'\n' {
117            pos += 1; // consume blank line
118            break;
119        }
120        // find end of header line
121        let line_end_rel = match input[pos..].iter().position(|&b| b == b'\n') {
122            Some(i) => i,
123            None => return Ok(None),
124        };
125        let mut line = &input[pos..pos + line_end_rel];
126        // strip trailing CR
127        if !line.is_empty() && line[line.len() - 1] == b'\r' {
128            line = &line[..line.len() - 1];
129        }
130        // find ':' separator
131        if let Some(colon) = line.iter().position(|&b| b == b':') {
132            let key = line[..colon].to_vec();
133            let val = line[colon + 1..].to_vec();
134            headers.push((key, val));
135        } else {
136            return Err(format!(
137                "malformed header line: {:?}",
138                String::from_utf8_lossy(line)
139            ));
140        }
141        pos += line_end_rel + 1;
142    }
143
144    // determine body strategy
145    match get_content_length(&headers) {
146        Ok(Some(content_len)) => {
147            // need content_len bytes, plus terminating NUL
148            if pos + content_len + 1 > len {
149                Ok(None)
150            } else {
151                let body = input[pos..pos + content_len].to_vec();
152                pos += content_len;
153                // next must be NUL
154                if pos >= len || input[pos] != 0 {
155                    Err("missing NUL terminator after content-length body".to_string())
156                } else {
157                    pos += 1;
158                    // optional trailing LF
159                    if pos < len && input[pos] == b'\n' {
160                        pos += 1;
161                    }
162                    Ok(Some((command, headers, Some(body), pos)))
163                }
164            }
165        }
166        Ok(None) => {
167            // NUL-terminated body: find NUL
168            match input[pos..].iter().position(|&b| b == 0) {
169                Some(nul_rel) => {
170                    let body = input[pos..pos + nul_rel].to_vec();
171                    pos += nul_rel + 1;
172                    // optional trailing LF
173                    if pos < len && input[pos] == b'\n' {
174                        pos += 1;
175                    }
176                    let body_opt = if body.is_empty() { None } else { Some(body) };
177                    Ok(Some((command, headers, body_opt, pos)))
178                }
179                None => Ok(None),
180            }
181        }
182        Err(e) => Err(e),
183    }
184}