Skip to main content

asterisk_rs_ami/
codec.rs

1//! AMI wire protocol codec.
2//!
3//! Handles framing of AMI's `Key: Value\r\n` line protocol with
4//! `\r\n\r\n` message termination.
5
6use bytes::{Buf, BytesMut};
7use std::collections::HashMap;
8use tokio_util::codec::{Decoder, Encoder};
9
10use crate::error::AmiError;
11
12/// maximum size of a single AMI message (64 KiB)
13const MAX_MESSAGE_SIZE: usize = 64 * 1024;
14
15/// maximum number of headers in a single AMI message
16///
17/// prevents excessive allocation from a malicious or misbehaving server
18/// sending many tiny headers within the byte limit
19const MAX_HEADERS: usize = 512;
20
21/// raw AMI message as parsed from the wire
22#[derive(Clone, PartialEq)]
23pub struct RawAmiMessage {
24    /// ordered key-value headers
25    pub headers: Vec<(String, String)>,
26    /// command output lines (for Response: Follows)
27    pub output: Vec<String>,
28    /// channel variables extracted from ChanVariable(name) headers
29    pub channel_variables: HashMap<String, String>,
30}
31
32impl std::fmt::Debug for RawAmiMessage {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        f.debug_struct("RawAmiMessage")
35            .field("headers", &RedactedHeaderPairs(&self.headers))
36            .field("output_lines", &self.output.len())
37            .field(
38                "output_bytes",
39                &self.output.iter().map(String::len).sum::<usize>(),
40            )
41            .field(
42                "channel_variables",
43                &RedactedHeaderMap(&self.channel_variables),
44            )
45            .finish()
46    }
47}
48
49pub(crate) const REDACTED_HEADER_VALUE: &str = "[REDACTED]";
50
51pub(crate) fn is_sensitive_header(key: &str) -> bool {
52    let normalized: String = key
53        .chars()
54        .filter(|character| character.is_ascii_alphanumeric())
55        .flat_map(char::to_lowercase)
56        .collect();
57    normalized.contains("password")
58        || normalized.contains("passwd")
59        || normalized.contains("secret")
60        || normalized == "md5cred"
61        || normalized.contains("credential")
62        || normalized.contains("token")
63        || normalized.contains("authorization")
64        || normalized.contains("apikey")
65        || normalized.contains("privatekey")
66        || normalized.contains("accesskey")
67        || normalized.contains("cookie")
68        || normalized == "pin"
69        || normalized.ends_with("pin")
70        || normalized.contains("pincode")
71}
72
73pub(crate) fn redacted_header_value<'a>(key: &str, value: &'a str) -> &'a str {
74    if is_sensitive_header(key) {
75        REDACTED_HEADER_VALUE
76    } else {
77        value
78    }
79}
80
81pub(crate) struct RedactedHeaderPairs<'a>(pub(crate) &'a [(String, String)]);
82
83impl std::fmt::Debug for RedactedHeaderPairs<'_> {
84    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
85        f.debug_list()
86            .entries(
87                self.0
88                    .iter()
89                    .map(|(key, value)| (key, redacted_header_value(key, value))),
90            )
91            .finish()
92    }
93}
94
95pub(crate) struct RedactedHeaderMap<'a>(pub(crate) &'a HashMap<String, String>);
96
97impl std::fmt::Debug for RedactedHeaderMap<'_> {
98    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
99        f.debug_map()
100            .entries(
101                self.0
102                    .iter()
103                    .map(|(key, value)| (key, redacted_header_value(key, value))),
104            )
105            .finish()
106    }
107}
108
109impl RawAmiMessage {
110    /// get the first value for a given key (case-insensitive)
111    pub fn get(&self, key: &str) -> Option<&str> {
112        self.headers
113            .iter()
114            .find(|(k, _)| k.eq_ignore_ascii_case(key))
115            .map(|(_, v)| v.as_str())
116    }
117
118    /// get all values for a given key (case-insensitive)
119    pub fn get_all(&self, key: &str) -> Vec<&str> {
120        self.headers
121            .iter()
122            .filter(|(k, _)| k.eq_ignore_ascii_case(key))
123            .map(|(_, v)| v.as_str())
124            .collect()
125    }
126
127    /// check if this is a response message
128    pub fn is_response(&self) -> bool {
129        self.get("Response").is_some()
130    }
131
132    /// check if this is an event message
133    pub fn is_event(&self) -> bool {
134        self.get("Event").is_some()
135    }
136
137    /// get a channel variable by name
138    pub fn get_variable(&self, name: &str) -> Option<&str> {
139        self.channel_variables.get(name).map(|s| s.as_str())
140    }
141
142    /// convert headers to a HashMap (last value wins for duplicates)
143    pub fn to_map(&self) -> HashMap<String, String> {
144        self.headers
145            .iter()
146            .map(|(k, v)| (k.clone(), v.clone()))
147            .collect()
148    }
149
150    /// approximate retained bytes for bounded multi-message aggregation
151    pub(crate) fn retained_size(&self) -> usize {
152        self.headers
153            .iter()
154            .map(|(key, value)| key.len() + value.len())
155            .sum::<usize>()
156            + self.output.iter().map(String::len).sum::<usize>()
157            + self
158                .channel_variables
159                .iter()
160                .map(|(name, value)| name.len() + value.len())
161                .sum::<usize>()
162    }
163}
164
165/// codec for AMI's line-based protocol
166#[derive(Debug)]
167pub struct AmiCodec {
168    /// tracks whether we've consumed the initial banner line
169    banner_consumed: bool,
170}
171
172impl AmiCodec {
173    pub fn new() -> Self {
174        Self {
175            banner_consumed: false,
176        }
177    }
178
179    /// validate a complete outbound frame before any socket write begins
180    pub(crate) fn validate_outbound(item: &RawAmiMessage) -> Result<(), AmiError> {
181        let contains_line_terminator = |s: &str| s.bytes().any(|b| b == b'\r' || b == b'\n');
182        if item.headers.len() + item.channel_variables.len() > MAX_HEADERS {
183            return Err(AmiError::Protocol(
184                asterisk_rs_core::error::ProtocolError::MalformedMessage {
185                    details: format!("message exceeds {} header limit", MAX_HEADERS),
186                },
187            ));
188        }
189
190        let mut frame_len = 2usize;
191        for (key, value) in &item.headers {
192            if contains_line_terminator(key) {
193                return Err(AmiError::Protocol(
194                    asterisk_rs_core::error::ProtocolError::MalformedMessage {
195                        details: format!("header key contains illegal line terminator: {key:?}"),
196                    },
197                ));
198            }
199            if contains_line_terminator(value) {
200                return Err(AmiError::Protocol(
201                    asterisk_rs_core::error::ProtocolError::MalformedMessage {
202                        details: "header value contains illegal line terminator".to_owned(),
203                    },
204                ));
205            }
206            frame_len = frame_len
207                .checked_add(key.len() + value.len() + 4)
208                .ok_or_else(message_too_large)?;
209        }
210        for (name, value) in &item.channel_variables {
211            if contains_line_terminator(name) {
212                return Err(AmiError::Protocol(
213                    asterisk_rs_core::error::ProtocolError::MalformedMessage {
214                        details: format!(
215                            "channel variable name contains illegal line terminator: {name:?}"
216                        ),
217                    },
218                ));
219            }
220            if contains_line_terminator(value) {
221                return Err(AmiError::Protocol(
222                    asterisk_rs_core::error::ProtocolError::MalformedMessage {
223                        details: "channel variable value contains illegal line terminator"
224                            .to_owned(),
225                    },
226                ));
227            }
228            frame_len = frame_len
229                .checked_add(name.len() + value.len() + 18)
230                .ok_or_else(message_too_large)?;
231        }
232        if frame_len > MAX_MESSAGE_SIZE {
233            return Err(message_too_large());
234        }
235        Ok(())
236    }
237}
238
239impl Default for AmiCodec {
240    fn default() -> Self {
241        Self::new()
242    }
243}
244
245impl Decoder for AmiCodec {
246    type Item = RawAmiMessage;
247    type Error = AmiError;
248
249    fn decode(&mut self, src: &mut BytesMut) -> Result<Option<Self::Item>, Self::Error> {
250        // consume the banner line on first message
251        if !self.banner_consumed {
252            if let Some(pos) = find_crlf(src) {
253                let line = &src[..pos];
254                // validate it looks like an AMI banner
255                if !line.starts_with(b"Asterisk Call Manager") {
256                    let preview = String::from_utf8_lossy(&line[..line.len().min(64)]);
257                    return Err(AmiError::Protocol(
258                        asterisk_rs_core::error::ProtocolError::MalformedMessage {
259                            details: format!("expected AMI banner, got: {}", preview),
260                        },
261                    ));
262                }
263                src.advance(pos + 2); // skip line + \r\n
264                self.banner_consumed = true;
265            } else {
266                reject_oversized_incomplete(src)?;
267                return Ok(None); // need more data
268            }
269        }
270
271        // AMI Response: Follows frames embed output lines between the header
272        // lines and a --END COMMAND-- marker, all terminated by \r\n\r\n.
273        // output may use repeated Output headers or raw lines, including
274        // blank and colon-bearing lines. do not accept a \r\n\r\n that
275        // appears before the line-delimited end marker.
276        const END_MARKER: &[u8] = b"--END COMMAND--";
277        const END_SEQUENCE: &[u8] = b"\r\n--END COMMAND--\r\n\r\n";
278
279        // loop to skip empty frames instead of recursing
280        loop {
281            let first_blank = match find_double_crlf(src) {
282                Some(pos) => pos,
283                None => {
284                    reject_oversized_incomplete(src)?;
285                    return Ok(None);
286                }
287            };
288
289            // peek: does this frame contain a Follows header?
290            // if so, the real terminator is \r\n\r\n *after* --END COMMAND--
291            let frame_end = if is_follows_response(&src[..first_blank]) {
292                match find_subsequence(src, END_SEQUENCE) {
293                    Some(marker_pos) => marker_pos + END_SEQUENCE.len(),
294                    None => {
295                        reject_oversized_incomplete(src)?;
296                        return Ok(None);
297                    }
298                }
299            } else {
300                // regular message: frame ends at first \r\n\r\n + 4
301                first_blank + 4
302            };
303
304            // size check on the individual message, not the whole buffer
305            if frame_end > MAX_MESSAGE_SIZE {
306                return Err(AmiError::Protocol(
307                    asterisk_rs_core::error::ProtocolError::MalformedMessage {
308                        details: format!("message exceeds {} byte limit", MAX_MESSAGE_SIZE),
309                    },
310                ));
311            }
312
313            let message_bytes = &src[..frame_end];
314            let message = parse_message(
315                message_bytes,
316                is_follows_response(&src[..first_blank]),
317                END_MARKER,
318            )?;
319
320            src.advance(frame_end);
321
322            if message.headers.is_empty() {
323                // empty frame, skip and try next
324                continue;
325            }
326
327            return Ok(Some(message));
328        }
329    }
330}
331
332impl Encoder<RawAmiMessage> for AmiCodec {
333    type Error = AmiError;
334
335    fn encode(&mut self, item: RawAmiMessage, dst: &mut BytesMut) -> Result<(), Self::Error> {
336        Self::validate_outbound(&item)?;
337
338        let mut frame = BytesMut::new();
339        for (key, value) in &item.headers {
340            frame.extend_from_slice(key.as_bytes());
341            frame.extend_from_slice(b": ");
342            frame.extend_from_slice(value.as_bytes());
343            frame.extend_from_slice(b"\r\n");
344        }
345        for (name, value) in &item.channel_variables {
346            frame.extend_from_slice(b"ChanVariable(");
347            frame.extend_from_slice(name.as_bytes());
348            frame.extend_from_slice(b"): ");
349            frame.extend_from_slice(value.as_bytes());
350            frame.extend_from_slice(b"\r\n");
351        }
352        frame.extend_from_slice(b"\r\n"); // message terminator
353        dst.extend_from_slice(&frame);
354        Ok(())
355    }
356}
357
358fn parse_message(
359    message_bytes: &[u8],
360    follows: bool,
361    end_marker: &[u8],
362) -> Result<RawAmiMessage, AmiError> {
363    let mut headers = Vec::new();
364    let mut output = Vec::new();
365    let mut channel_variables = HashMap::new();
366    let mut output_started = false;
367
368    for line in message_bytes.split(|&byte| byte == b'\n') {
369        let line = line.strip_suffix(b"\r").unwrap_or(line);
370        if follows && line == end_marker {
371            break;
372        }
373        if follows {
374            if let Some(value) = output_header_value(line) {
375                output_started = true;
376                output.push(String::from_utf8_lossy(value).into_owned());
377                continue;
378            }
379        }
380        if follows && output_started {
381            output.push(String::from_utf8_lossy(line).into_owned());
382            continue;
383        }
384        if line.is_empty() {
385            if follows && !headers.is_empty() {
386                output_started = true;
387                output.push(String::new());
388            }
389            continue;
390        }
391
392        let Some(colon_pos) = line.iter().position(|&byte| byte == b':') else {
393            if follows {
394                output_started = true;
395            }
396            output.push(String::from_utf8_lossy(line).into_owned());
397            continue;
398        };
399        let key_bytes = trim_ascii(&line[..colon_pos]);
400        if follows && !is_follows_envelope_header(key_bytes) {
401            output_started = true;
402            output.push(String::from_utf8_lossy(line).into_owned());
403            continue;
404        }
405        if headers.len() + channel_variables.len() >= MAX_HEADERS {
406            return Err(AmiError::Protocol(
407                asterisk_rs_core::error::ProtocolError::MalformedMessage {
408                    details: format!("message exceeds {} header limit", MAX_HEADERS),
409                },
410            ));
411        }
412        let key = String::from_utf8_lossy(key_bytes).into_owned();
413        let value = String::from_utf8_lossy(trim_ascii(&line[colon_pos + 1..])).into_owned();
414        if let Some(var_name) = key
415            .strip_prefix("ChanVariable(")
416            .and_then(|name| name.strip_suffix(')'))
417        {
418            channel_variables.insert(var_name.to_owned(), value);
419        } else {
420            headers.push((key, value));
421        }
422    }
423
424    Ok(RawAmiMessage {
425        headers,
426        output,
427        channel_variables,
428    })
429}
430
431fn output_header_value(line: &[u8]) -> Option<&[u8]> {
432    let colon_pos = line.iter().position(|&byte| byte == b':')?;
433    if !trim_ascii(&line[..colon_pos]).eq_ignore_ascii_case(b"Output") {
434        return None;
435    }
436    Some(
437        line[colon_pos + 1..]
438            .strip_prefix(b" ")
439            .unwrap_or(&line[colon_pos + 1..]),
440    )
441}
442
443fn is_follows_envelope_header(key: &[u8]) -> bool {
444    [
445        b"Response".as_slice(),
446        b"ActionID".as_slice(),
447        b"Privilege".as_slice(),
448        b"Message".as_slice(),
449        b"EventList".as_slice(),
450        b"Timestamp".as_slice(),
451        b"Server".as_slice(),
452    ]
453    .iter()
454    .any(|candidate| key.eq_ignore_ascii_case(candidate))
455}
456
457fn trim_ascii(mut value: &[u8]) -> &[u8] {
458    while value.first().is_some_and(u8::is_ascii_whitespace) {
459        value = &value[1..];
460    }
461    while value.last().is_some_and(u8::is_ascii_whitespace) {
462        value = &value[..value.len() - 1];
463    }
464    value
465}
466
467fn reject_oversized_incomplete(src: &BytesMut) -> Result<(), AmiError> {
468    if src.len() > MAX_MESSAGE_SIZE {
469        return Err(message_too_large());
470    }
471    Ok(())
472}
473
474fn message_too_large() -> AmiError {
475    AmiError::Protocol(asterisk_rs_core::error::ProtocolError::MalformedMessage {
476        details: format!("message exceeds {} byte limit", MAX_MESSAGE_SIZE),
477    })
478}
479
480/// find the position of the first \r\n in the buffer
481fn find_crlf(buf: &[u8]) -> Option<usize> {
482    buf.windows(2).position(|w| w == b"\r\n")
483}
484
485/// find the position of \r\n\r\n (returns position of first \r)
486fn find_double_crlf(buf: &[u8]) -> Option<usize> {
487    buf.windows(4).position(|w| w == b"\r\n\r\n")
488}
489
490/// returns true if the header block contains a `Response: Follows` header,
491/// tolerating optional whitespace after the colon (e.g. `Response:Follows`)
492fn is_follows_response(header_bytes: &[u8]) -> bool {
493    header_bytes.split(|&b| b == b'\n').any(|line| {
494        let line = line.strip_suffix(b"\r").unwrap_or(line);
495        if let Some(colon_pos) = line.iter().position(|&b| b == b':') {
496            let key = &line[..colon_pos];
497            let value = &line[colon_pos + 1..];
498            let value_trimmed = value.strip_prefix(b" ").unwrap_or(value);
499            key.eq_ignore_ascii_case(b"response") && value_trimmed.eq_ignore_ascii_case(b"follows")
500        } else {
501            false
502        }
503    })
504}
505
506/// find the starting position of `needle` in `haystack`
507fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
508    haystack.windows(needle.len()).position(|w| w == needle)
509}