use nom::IResult;
#[cfg(any(test, fuzzing))]
use nom::{
bytes::streaming::{tag, take_while},
combinator::opt,
};
use crate::future_release::parse_rfc3339_to_utc_key;
use crate::types::{
AuthMechanism, DomainOrLiteral, EnhancedStatusCode, ServerCapabilities, SmtpExtension,
SmtpResponse,
};
#[cfg(any(test, fuzzing))]
pub(crate) fn parse_response(input: &[u8]) -> IResult<&[u8], SmtpResponse> {
let mut remaining = input;
let mut lines: Vec<String> = Vec::new();
let mut first_enhanced: Option<EnhancedStatusCode> = None;
let mut first_code: Option<u16> = None;
let mut final_code: u16;
loop {
let (rest, code) = reply_code(remaining)?;
if let Some(expected) = first_code {
if code != expected {
return Err(nom::Err::Error(nom::error::Error::new(
remaining,
nom::error::ErrorKind::Verify,
)));
}
} else {
first_code = Some(code);
}
if rest.is_empty() {
return Err(nom::Err::Incomplete(nom::Needed::Unknown));
}
let separator = rest[0];
let is_continuation = separator == b'-';
let has_space = separator == b' ';
if !is_continuation && !has_space && separator != b'\r' && separator != b'\n' {
return Err(nom::Err::Error(nom::error::Error::new(
rest,
nom::error::ErrorKind::Char,
)));
}
let rest = if separator == b'\r' || separator == b'\n' {
rest
} else {
&rest[1..]
};
let pre_esc = rest;
let (rest_after_esc, enhanced) = opt(enhanced_status_code_with_trailing_space)(rest)?;
let (rest_after_esc, enhanced) = if enhanced.is_none() {
match enhanced_status_code(rest) {
Ok((remaining, esc))
if remaining.first().is_some_and(|&b| b == b'\r' || b == b'\n') =>
{
(remaining, Some(esc))
}
_ => (rest_after_esc, None),
}
} else {
(rest_after_esc, enhanced)
};
let text_start = if let Some(ref esc) = enhanced {
let reply_class = code / 100;
if u16::from(esc.class) == reply_class {
if first_enhanced.is_none() {
first_enhanced = Some(*esc);
}
rest_after_esc
} else {
pre_esc
}
} else {
rest_after_esc
};
let (rest, text_bytes) = take_while(|b: u8| b != b'\r' && b != b'\n')(text_start)?;
let rest = if rest.starts_with(b"\r\n") {
&rest[2..]
} else if rest.starts_with(b"\n") {
&rest[1..]
} else {
return Err(nom::Err::Incomplete(nom::Needed::Unknown));
};
let text = String::from_utf8_lossy(text_bytes).into_owned();
lines.push(text);
final_code = code;
remaining = rest;
if !is_continuation {
break;
}
}
Ok((
remaining,
SmtpResponse {
code: final_code,
enhanced_code: first_enhanced,
lines,
},
))
}
#[cfg(any(test, fuzzing))]
pub(crate) fn reply_code(input: &[u8]) -> IResult<&[u8], u16> {
if input.len() < 3 {
return Err(nom::Err::Incomplete(nom::Needed::new(3 - input.len())));
}
let d0 = input[0];
let d1 = input[1];
let d2 = input[2];
if !d0.is_ascii_digit() || !d1.is_ascii_digit() || !d2.is_ascii_digit() {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Digit,
)));
}
if !(b'2'..=b'5').contains(&d0) || !d1.is_ascii_digit() {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::Verify,
)));
}
let code = u16::from(d0 - b'0') * 100 + u16::from(d1 - b'0') * 10 + u16::from(d2 - b'0');
Ok((&input[3..], code))
}
macro_rules! define_enhanced_status_code_parser {
($name:ident, $(#[$meta:meta])*, $one_of:path, $tag:path, $take_while1:path) => {
$(#[$meta])*
fn $name(input: &[u8]) -> IResult<&[u8], EnhancedStatusCode> {
let (rest, class_char) = $one_of("245")(input)?;
#[allow(clippy::cast_possible_truncation)]
let class = (class_char as u32 - '0' as u32) as u8;
let (rest, _) = $tag(b".")(rest)?;
let (rest, subject_bytes) = $take_while1(|b: u8| b.is_ascii_digit())(rest)?;
if subject_bytes.len() > 3 {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::TooLarge,
)));
}
let subject = parse_digits(subject_bytes);
let (rest, _) = $tag(b".")(rest)?;
let (rest, detail_bytes) = $take_while1(|b: u8| b.is_ascii_digit())(rest)?;
if detail_bytes.len() > 3 {
return Err(nom::Err::Error(nom::error::Error::new(
input,
nom::error::ErrorKind::TooLarge,
)));
}
let detail = parse_digits(detail_bytes);
Ok((
rest,
EnhancedStatusCode {
class,
subject,
detail,
},
))
}
};
}
define_enhanced_status_code_parser!(
enhanced_status_code,
#[cfg(any(test, fuzzing))],
nom::character::streaming::one_of,
nom::bytes::streaming::tag,
nom::bytes::streaming::take_while1
);
#[cfg(any(test, fuzzing))]
fn enhanced_status_code_with_trailing_space(input: &[u8]) -> IResult<&[u8], EnhancedStatusCode> {
let (rest, esc) = enhanced_status_code(input)?;
let (rest, _) = tag(b" ")(rest)?;
Ok((rest, esc))
}
fn parse_digits(bytes: &[u8]) -> u16 {
let mut val: u16 = 0;
for &b in bytes {
val = val * 10 + u16::from(b - b'0');
}
val
}
fn merge_or_push_auth(extensions: &mut Vec<SmtpExtension>, new_mechs: Vec<AuthMechanism>) {
for ext in extensions.iter_mut() {
if let SmtpExtension::Auth(existing) = ext {
for mech in new_mechs {
if !existing.iter().any(|m| m.eq_mechanism(&mech)) {
existing.push(mech);
}
}
return;
}
}
extensions.push(SmtpExtension::Auth(new_mechs));
}
enum ParsedEhloLine {
Extension(SmtpExtension),
Auth(Vec<AuthMechanism>),
Unknown,
}
enum DeliverByEhloMinimum {
Unspecified,
Minimum(u64),
}
fn parse_ehlo_decimal_1_to_9_digits(s: &str, allow_zero: bool) -> Option<u64> {
if s.is_empty() || s.len() > 9 || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
let value = s.parse::<u64>().ok()?;
if !allow_zero && value == 0 {
return None;
}
Some(value)
}
fn parse_deliverby_minimum(params: &str) -> Option<DeliverByEhloMinimum> {
let mut segments = params.split(',');
let first = segments.next().unwrap_or_default().trim();
let minimum = if first.is_empty() {
DeliverByEhloMinimum::Unspecified
} else {
DeliverByEhloMinimum::Minimum(parse_ehlo_decimal_1_to_9_digits(first, true)?)
};
if segments.any(|segment| !is_valid_deliverby_extension_token(segment.trim())) {
return None;
}
Some(minimum)
}
fn is_valid_deliverby_extension_token(token: &str) -> bool {
!token.is_empty()
&& token.is_ascii()
&& token.bytes().all(|b| b != b' ' && b != b',' && b > 0x1F)
}
fn parse_future_release_limits(params: &str) -> Option<(u64, String)> {
let mut parts = params.split_whitespace();
let interval = parse_ehlo_decimal_1_to_9_digits(parts.next()?, false)?;
let datetime = parts.next()?;
parse_rfc3339_to_utc_key(datetime)?;
if parts.next().is_some() {
return None;
}
Some((interval, datetime.to_owned()))
}
#[allow(clippy::too_many_lines)]
fn parse_ehlo_extension_line(line: &str) -> ParsedEhloLine {
let (keyword, params) = match line.find(|c: char| c.is_ascii_whitespace()) {
Some(pos) => (&line[..pos], Some(line[pos + 1..].trim())),
None => (line, None),
};
let keyword_upper = keyword.to_ascii_uppercase();
if keyword_upper.starts_with("AUTH=") && keyword_upper.len() > 5 {
let first_mech = &keyword[5..];
let all_mechs = match params {
Some(p) if !p.is_empty() => format!("{first_mech} {p}"),
_ => first_mech.to_owned(),
};
return match parse_auth_mechanism_list(&all_mechs) {
Some(mechanisms) => ParsedEhloLine::Auth(mechanisms),
None => ParsedEhloLine::Unknown,
};
}
let has_params = params.is_some_and(|p| !p.is_empty());
let extension = match keyword_upper.as_str() {
"8BITMIME" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::EightBitMime
}
"PIPELINING" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::Pipelining
}
"SIZE" => {
let size_limit = match params.and_then(|p| if p.is_empty() { None } else { Some(p) }) {
None => None,
Some(param) => match param
.bytes()
.all(|b| b.is_ascii_digit())
.then(|| param.parse::<u64>())
{
Some(Ok(0)) => None,
Some(Ok(n)) => Some(n),
Some(Err(_)) | None => return ParsedEhloLine::Unknown,
},
};
SmtpExtension::Size(size_limit)
}
"STARTTLS" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::StartTls
}
"AUTH" => {
let Some(raw_mechanisms) = params.filter(|p| !p.is_empty()) else {
return ParsedEhloLine::Unknown;
};
let Some(mechanisms) = parse_auth_mechanism_list(raw_mechanisms) else {
return ParsedEhloLine::Unknown;
};
return ParsedEhloLine::Auth(mechanisms);
}
"CHUNKING" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::Chunking
}
"BINARYMIME" | "BINARY" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::BinaryMime
}
"SMTPUTF8" => SmtpExtension::SmtpUtf8,
"ENHANCEDSTATUSCODES" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::EnhancedStatusCodes
}
"SASL-IR" => SmtpExtension::SaslIr,
"DSN" => SmtpExtension::Dsn,
"REQUIRETLS" => SmtpExtension::RequireTls,
"FUTURERELEASE" => {
let Some(raw_params) = params.and_then(|p| if p.is_empty() { None } else { Some(p) })
else {
return ParsedEhloLine::Unknown;
};
let Some((max_interval, max_datetime)) = parse_future_release_limits(raw_params) else {
return ParsedEhloLine::Unknown;
};
SmtpExtension::FutureRelease {
max_interval: Some(max_interval),
max_datetime: Some(max_datetime),
}
}
"DELIVERBY" => {
let max_seconds = match params.and_then(|p| if p.is_empty() { None } else { Some(p) }) {
None => None,
Some(p) => match parse_deliverby_minimum(p) {
Some(DeliverByEhloMinimum::Unspecified) => None,
Some(DeliverByEhloMinimum::Minimum(value)) => Some(value),
None => return ParsedEhloLine::Unknown,
},
};
SmtpExtension::DeliverBy(max_seconds)
}
"MT-PRIORITY" => SmtpExtension::MtPriority,
"VRFY" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::Vrfy
}
"EXPN" => {
if has_params {
return ParsedEhloLine::Unknown;
}
SmtpExtension::Expn
}
"NO-SOLICITING" => {
let keyword = params
.and_then(|p| if p.is_empty() { None } else { Some(p) })
.map(str::to_owned);
SmtpExtension::NoSoliciting(keyword)
}
_ => return ParsedEhloLine::Unknown,
};
ParsedEhloLine::Extension(extension)
}
fn apply_ehlo_extension(caps: &mut ServerCapabilities, parsed: ParsedEhloLine) {
match parsed {
ParsedEhloLine::Extension(extension) => caps.extensions.push(extension),
ParsedEhloLine::Auth(mechanisms) => merge_or_push_auth(&mut caps.extensions, mechanisms),
ParsedEhloLine::Unknown => {}
}
}
pub(crate) fn parse_ehlo_capabilities(response: &SmtpResponse) -> ServerCapabilities {
let mut caps = ServerCapabilities::default();
let single_line_reply = response.lines.len() == 1;
for (i, line) in response.lines.iter().enumerate() {
if i == 0 {
let greeting_token = match line.find(|c: char| c.is_ascii_whitespace()) {
Some(pos) => &line[..pos],
None => line,
};
if single_line_reply && DomainOrLiteral::new(greeting_token).is_err() {
match parse_ehlo_extension_line(line) {
ParsedEhloLine::Unknown => {}
parsed => {
apply_ehlo_extension(&mut caps, parsed);
continue;
}
}
}
greeting_token.clone_into(&mut caps.greeting_name);
continue;
}
match parse_ehlo_extension_line(line) {
ParsedEhloLine::Unknown => caps.extensions.push(SmtpExtension::Other(line.clone())),
parsed => apply_ehlo_extension(&mut caps, parsed),
}
}
caps
}
fn parse_auth_mechanism_list(list: &str) -> Option<Vec<AuthMechanism>> {
let mechanisms = list
.split_whitespace()
.map(parse_auth_mechanism)
.collect::<Option<Vec<_>>>()?;
(!mechanisms.is_empty()).then_some(mechanisms)
}
fn parse_auth_mechanism(name: &str) -> Option<AuthMechanism> {
if !is_valid_sasl_mechanism_name(name) {
return None;
}
Some(match name.to_ascii_uppercase().as_str() {
"PLAIN" => AuthMechanism::Plain,
"LOGIN" => AuthMechanism::Login,
"OAUTHBEARER" => AuthMechanism::OAuthBearer,
"XOAUTH2" => AuthMechanism::XOAuth2,
_ => AuthMechanism::Other(name.to_owned()),
})
}
fn is_valid_sasl_mechanism_name(name: &str) -> bool {
let len = name.len();
(1..=20).contains(&len)
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
}
pub(crate) fn strip_enhanced_code(
text: &str,
reply_code: u16,
) -> Option<(EnhancedStatusCode, &str)> {
use nom::bytes::complete::tag as tag_complete;
let bytes = text.as_bytes();
match enhanced_status_code_complete(bytes) {
Ok((rest, esc)) => {
#[allow(clippy::cast_possible_truncation)]
let reply_class = (reply_code / 100) as u8;
if esc.class != reply_class {
return None;
}
match tag_complete::<_, _, nom::error::Error<&[u8]>>(b" ")(rest) {
Ok((after_space, _)) => {
let consumed = bytes.len() - after_space.len();
Some((esc, &text[consumed..]))
}
_ => {
if rest.is_empty() {
Some((esc, ""))
} else {
None
}
}
}
}
_ => None,
}
}
#[cfg(any(test, fuzzing))]
pub(crate) fn parse_enhanced_code_from_str(s: &str) -> Option<EnhancedStatusCode> {
match enhanced_status_code_complete(s.as_bytes()) {
Ok(([], esc)) => Some(esc),
_ => None,
}
}
define_enhanced_status_code_parser!(
enhanced_status_code_complete,
,
nom::character::complete::one_of,
nom::bytes::complete::tag,
nom::bytes::complete::take_while1
);
#[cfg(test)]
#[path = "decode_tests.rs"]
mod tests;