1use bytes::{Buf, BytesMut};
7use std::collections::HashMap;
8use tokio_util::codec::{Decoder, Encoder};
9
10use crate::error::AmiError;
11
12const MAX_MESSAGE_SIZE: usize = 64 * 1024;
14
15const MAX_HEADERS: usize = 512;
20
21#[derive(Clone, PartialEq)]
23pub struct RawAmiMessage {
24 pub headers: Vec<(String, String)>,
26 pub output: Vec<String>,
28 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 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 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 pub fn is_response(&self) -> bool {
129 self.get("Response").is_some()
130 }
131
132 pub fn is_event(&self) -> bool {
134 self.get("Event").is_some()
135 }
136
137 pub fn get_variable(&self, name: &str) -> Option<&str> {
139 self.channel_variables.get(name).map(|s| s.as_str())
140 }
141
142 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 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#[derive(Debug)]
167pub struct AmiCodec {
168 banner_consumed: bool,
170}
171
172impl AmiCodec {
173 pub fn new() -> Self {
174 Self {
175 banner_consumed: false,
176 }
177 }
178
179 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 if !self.banner_consumed {
252 if let Some(pos) = find_crlf(src) {
253 let line = &src[..pos];
254 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); self.banner_consumed = true;
265 } else {
266 reject_oversized_incomplete(src)?;
267 return Ok(None); }
269 }
270
271 const END_MARKER: &[u8] = b"--END COMMAND--";
277 const END_SEQUENCE: &[u8] = b"\r\n--END COMMAND--\r\n\r\n";
278
279 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 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 first_blank + 4
302 };
303
304 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 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"); 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
480fn find_crlf(buf: &[u8]) -> Option<usize> {
482 buf.windows(2).position(|w| w == b"\r\n")
483}
484
485fn find_double_crlf(buf: &[u8]) -> Option<usize> {
487 buf.windows(4).position(|w| w == b"\r\n\r\n")
488}
489
490fn 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
506fn find_subsequence(haystack: &[u8], needle: &[u8]) -> Option<usize> {
508 haystack.windows(needle.len()).position(|w| w == needle)
509}