rfc5321: change protocol parser from pest -> nom

This gives us more flexibility in how we can build our parser,
and is significantly easier to maintain.

Part of this change is allowing the command parser to recognize
the starting command verb in an otherwise failed command line
parse; the intent is to provide slightly better error codes
where SMTP defines them when we encounter such a thing.

This commit doesn't do that; it's already pretty huge.
This commit is contained in:
Wez Furlong
2026-04-08 14:12:58 +01:00
parent 41e67a4762
commit fe54ebfa52
21 changed files with 3604 additions and 1358 deletions
Generated
+6 -2
View File
@@ -5289,6 +5289,7 @@ name = "nom-utils"
version = "0.1.0"
dependencies = [
"bstr",
"hickory-resolver",
"k9",
"nom 8.0.0",
"nom_locate",
@@ -6844,18 +6845,21 @@ name = "rfc5321"
version = "0.1.0"
dependencies = [
"anyhow",
"bstr",
"data-encoding",
"duration-serde",
"hickory-proto",
"idna",
"k9",
"kumo-tls-helper",
"libc",
"linkme",
"lruttl",
"memchr",
"nom 8.0.0",
"nom-utils",
"openssl",
"pest",
"pest_derive",
"pastey",
"rustls",
"rustls-pemfile",
"rustls-platform-verifier",
+2 -3
View File
@@ -12,9 +12,8 @@ use maildir::{MailEntry, Maildir};
use mailparsing::MessageBuilder;
use nix::unistd::{Uid, User};
use parking_lot::Mutex;
use rfc5321::{
BatchSendSuccess, ForwardPath, Response, ReversePath, SmtpClient, SmtpClientTimeouts,
};
use rfc5321::parser::{ForwardPath, ReversePath};
use rfc5321::{BatchSendSuccess, Response, SmtpClient, SmtpClientTimeouts};
use sqlite::{Connection, State};
use std::collections::BTreeMap;
use std::net::SocketAddr;
@@ -1,5 +1,6 @@
use crate::kumod::{DaemonWithMaildir, MailGenParams};
use k9::assert_equal;
use rfc5321::parser::Command;
use rfc5321::*;
/// test maximum line length for a single SMTP command
@@ -1,6 +1,6 @@
use crate::kumod::DaemonWithMaildir;
use anyhow::Context;
use rfc5321::Command;
use rfc5321::parser::Command;
#[tokio::test]
async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> {
@@ -18,7 +18,7 @@ async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> {
})
.await?;
let resp = client
.send_command(&Command::RawLine(
.send_command(&Command::Unknown(
"RCPT TO:<sender@example.com:2025>".into(),
))
.await?;
@@ -29,12 +29,7 @@ async fn no_ports_in_rcpt_domain() -> anyhow::Result<()> {
Response {
code: 501,
enhanced_code: None,
content: "Syntax error in command or arguments: --> 1:28
|
1 | RCPT TO:<sender@example.com:2025>
| ^---
|
= expected alpha, digit, or utf8_non_ascii",
content: "Syntax error in command or arguments",
command: Some(
"RCPT TO:<sender@example.com:2025>\r
",
+1 -1
View File
@@ -1,7 +1,7 @@
use crate::kumod::DaemonWithMaildirOptions;
use anyhow::Context;
use kumo_api_types::TraceSmtpV1Payload::Callback;
use rfc5321::{Command, XClientParameter};
use rfc5321::parser::{Command, XClientParameter};
use std::time::Duration;
#[tokio::test]
+1 -1
View File
@@ -342,7 +342,7 @@ impl ShapingInner {
}
pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result<Vec<Rule>> {
use rfc5321::ForwardPath;
use rfc5321::parser::ForwardPath;
// Extract the domain from the recipient.
let recipient = ForwardPath::try_from(
record
+1 -1
View File
@@ -8,7 +8,7 @@ use anyhow::{anyhow, Context};
use bstr::{BStr, BString, ByteSlice};
use chrono::{DateTime, Utc};
use mailparsing::MimePart;
use rfc5321::EnvelopeAddress;
use rfc5321::parser::EnvelopeAddress;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::str::FromStr;
+1 -1
View File
@@ -4,7 +4,7 @@ use anyhow::anyhow;
use bstr::{BStr, BString, ByteSlice};
use chrono::{DateTime, Utc};
use mailparsing::{Header, HeaderParseResult, MimePart};
use rfc5321::EnvelopeAddress;
use rfc5321::parser::EnvelopeAddress;
use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::str::FromStr;
+15 -9
View File
@@ -20,9 +20,10 @@ use kumo_server_runtime::spawn;
use message::message::QueueNameComponents;
use message::Message;
use mta_sts::policy::PolicyMode;
use rfc5321::parser::{ForwardPath, ReversePath};
use rfc5321::{
ClientError, EnhancedStatusCode, ForwardPath, IsTooManyRecipients, Response, ReversePath,
SmtpClient, TlsInformation, TlsOptions, TlsStatus,
ClientError, EnhancedStatusCode, IsTooManyRecipients, Response, SmtpClient, TlsInformation,
TlsOptions, TlsStatus,
};
use serde::{Deserialize, Serialize};
use spool::SpoolId;
@@ -793,7 +794,7 @@ impl SmtpDispatcher {
// be busted by the failed handshake and never succeed
tokio::time::timeout(
tokio::time::Duration::from_secs(2),
client.send_command(&rfc5321::Command::Quit),
client.send_command(&rfc5321::parser::Command::Quit),
)
.await
.ok();
@@ -990,7 +991,10 @@ impl SmtpDispatcher {
impl QueueDispatcher for SmtpDispatcher {
async fn close_connection(&mut self, _dispatcher: &mut Dispatcher) -> anyhow::Result<bool> {
if let Some(mut client) = self.client.take() {
client.send_command(&rfc5321::Command::Quit).await.ok();
client
.send_command(&rfc5321::parser::Command::Quit)
.await
.ok();
// Close out this dispatcher and let the maintainer spawn
// a new connection
Ok(true)
@@ -1042,10 +1046,12 @@ impl QueueDispatcher for SmtpDispatcher {
.sender()
.await?
.try_into()
.map_err(|err| anyhow::anyhow!("{err}"))?;
.map_err(|err: &str| anyhow::anyhow!("{err}"))?;
let mut recipients: Vec<ForwardPath> = vec![];
for recip in msg.recipient_list().await? {
let recip: ForwardPath = recip.try_into().map_err(|err| anyhow::anyhow!("{err:#}"))?;
let recip: ForwardPath = recip
.try_into()
.map_err(|err: &str| anyhow::anyhow!("{err:#}"))?;
recips_this_txn.insert(
(spool_id, recip.clone()),
1 + self
@@ -1108,7 +1114,7 @@ impl QueueDispatcher for SmtpDispatcher {
if recipients_this_batch.len() < path_config.max_recipients_per_batch {
recipients_this_batch.push(recip);
} else {
revised_recipient_list.push(recip.into());
revised_recipient_list.push(message::EnvelopeAddress::from(recip));
// The excess is ready to go immediately
retry_immediately = true;
}
@@ -1183,7 +1189,7 @@ impl QueueDispatcher for SmtpDispatcher {
detail: 2,
}),
content: reason.clone(),
command: command.as_ref().map(|c| c.encode()),
command: command.as_ref().map(|c| c.encode().to_string()),
});
}
}
@@ -1300,7 +1306,7 @@ impl QueueDispatcher for SmtpDispatcher {
};
if record_type == RecordType::TransientFailure {
revised_recipient_list.push(recipient.clone().into());
revised_recipient_list.push(message::EnvelopeAddress::from(recipient.clone()));
}
if record_type != RecordType::Delivery && overall_response.is_none() {
overall_response.replace(response.clone());
+44 -52
View File
@@ -35,10 +35,8 @@ use mlua::{FromLuaMulti, IntoLuaMulti, LuaSerdeExt, UserData, UserDataMethods};
use openssl::x509::X509;
use parking_lot::FairMutex as Mutex;
use ppp::{HeaderResult, PartialResult};
use rfc5321::{
subject_name, AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Response, TlsInformation,
XClientParameter,
};
use rfc5321::parser::{Command, MaybePartialCommand, XClientParameter};
use rfc5321::{subject_name, AsyncReadAndWrite, BoxedAsyncReadAndWrite, Response, TlsInformation};
use rustls::ServerConfig;
use serde::{Deserialize, Serialize};
use serde_json::json;
@@ -1693,7 +1691,16 @@ impl SmtpServerSession {
)
.await?;
}
Ok(Command::Quit) => {
Ok(MaybePartialCommand::Partial { .. }) => {
self.write_response(
501,
"Syntax error in command or arguments",
Some(line),
RejectDisconnect::If421,
)
.await?;
}
Ok(MaybePartialCommand::Full(Command::Quit)) => {
self.write_response(
221,
"So long, and thanks for all the fish!",
@@ -1703,7 +1710,7 @@ impl SmtpServerSession {
.await?;
return Ok(());
}
Ok(Command::StartTls) => {
Ok(MaybePartialCommand::Full(Command::StartTls)) => {
if self.tls_active.is_some() {
self.write_response(
501,
@@ -1772,17 +1779,17 @@ impl SmtpServerSession {
};
self.socket.replace(socket);
}
Ok(Command::Auth {
Ok(MaybePartialCommand::Full(Command::Auth {
sasl_mech,
initial_response,
}) => {
})) => {
if self.process_auth(line, sasl_mech, initial_response).await?
== CommandDisposition::Terminate
{
return Ok(());
}
}
Ok(Command::Ehlo(domain)) => {
Ok(MaybePartialCommand::Full(Command::Ehlo(domain))) => {
let domain = domain.to_string();
let mut extensions =
@@ -1823,7 +1830,7 @@ impl SmtpServerSession {
self.meta.set_meta("ehlo_domain", domain.clone());
self.said_hello.replace(domain);
}
Ok(Command::Helo(domain)) => {
Ok(MaybePartialCommand::Full(Command::Helo(domain))) => {
let domain = domain.to_string();
if let Err(rej) = self
@@ -1847,10 +1854,10 @@ impl SmtpServerSession {
self.meta.set_meta("ehlo_domain", domain.clone());
self.said_hello.replace(domain);
}
Ok(Command::MailFrom {
Ok(MaybePartialCommand::Full(Command::MailFrom {
address,
parameters: _,
}) => {
})) => {
if self.state.is_some() {
self.write_response(
503,
@@ -1861,7 +1868,7 @@ impl SmtpServerSession {
.await?;
continue;
}
let address = match EnvelopeAddress::parse(&address.to_string()) {
let address = match EnvelopeAddress::try_from(address) {
Ok(address) => address,
Err(err) => {
self.write_response(
@@ -1899,10 +1906,10 @@ impl SmtpServerSession {
)
.await?;
}
Ok(Command::RcptTo {
Ok(MaybePartialCommand::Full(Command::RcptTo {
address,
parameters: _,
}) => {
})) => {
if self.state.is_none() {
self.write_response(
503,
@@ -1913,19 +1920,7 @@ impl SmtpServerSession {
.await?;
continue;
}
let address = match EnvelopeAddress::parse(&address.to_string()) {
Ok(address) => address,
Err(err) => {
self.write_response(
501,
format!("5.1.3 Invalid recipient address syntax: {err}"),
Some(line),
RejectDisconnect::If421,
)
.await?;
continue;
}
};
let address = EnvelopeAddress::from(address);
let sender = self.state.as_ref().unwrap().sender.clone();
let relay_disposition = self.check_relaying(&sender, &address).await?;
@@ -2012,7 +2007,7 @@ impl SmtpServerSession {
.recipients
.push(address);
}
Ok(Command::Data) => {
Ok(MaybePartialCommand::Full(Command::Data)) => {
if self.state.is_none() {
self.write_response(
503,
@@ -2097,12 +2092,12 @@ impl SmtpServerSession {
let _process_data_timer = PROCESS_DATA_LATENCY.start_timer();
Box::pin(self.process_data(data, &activity)).await?;
}
Ok(Command::Rset) => {
Ok(MaybePartialCommand::Full(Command::Rset)) => {
self.state.take();
self.write_response(250, "Reset state", None, RejectDisconnect::If421)
.await?;
}
Ok(Command::Noop(_)) => {
Ok(MaybePartialCommand::Full(Command::Noop(_))) => {
self.write_response(
250,
"the goggles do nothing",
@@ -2111,16 +2106,16 @@ impl SmtpServerSession {
)
.await?;
}
Ok(Command::XClient(params)) => {
Ok(MaybePartialCommand::Full(Command::XClient(params))) => {
self.process_xclient(&params).await?;
}
Ok(
Ok(MaybePartialCommand::Full(
Command::Vrfy(_)
| Command::Expn(_)
| Command::Help(_)
| Command::Lhlo(_)
| Command::RawLine(_),
) => {
| Command::Unknown(_),
)) => {
self.write_response(
502,
format!("5.5.1 Command unimplemented"),
@@ -2129,7 +2124,7 @@ impl SmtpServerSession {
)
.await?;
}
Ok(Command::DataDot) => unreachable!(),
Ok(MaybePartialCommand::Full(Command::DataDot)) => unreachable!(),
}
}
}
@@ -2318,14 +2313,11 @@ impl SmtpServerSession {
let mut dest_port: Option<u16> = None;
for p in params {
let name = &p.name;
let value = &p.value;
if name.eq_ignore_ascii_case("ADDR") {
let Ok(ip) = value.parse::<IpAddr>() else {
if p.is_name("ADDR") {
let Ok(ip) = p.parse::<IpAddr>() else {
self.write_response(
501,
format!("ADDR {value} is invalid"),
format!("ADDR {} is invalid", p.value),
None,
RejectDisconnect::If421,
)
@@ -2333,11 +2325,11 @@ impl SmtpServerSession {
return Ok(());
};
addr.replace(ip);
} else if name.eq_ignore_ascii_case("PORT") {
let Ok(v) = value.parse::<u16>() else {
} else if p.is_name("PORT") {
let Ok(v) = p.parse::<u16>() else {
self.write_response(
501,
format!("PORT {value} is invalid"),
format!("PORT {} is invalid", p.value),
None,
RejectDisconnect::If421,
)
@@ -2345,11 +2337,11 @@ impl SmtpServerSession {
return Ok(());
};
port.replace(v);
} else if name.eq_ignore_ascii_case("DESTADDR") {
let Ok(ip) = value.parse::<IpAddr>() else {
} else if p.is_name("DESTADDR") {
let Ok(ip) = p.parse::<IpAddr>() else {
self.write_response(
501,
format!("ADDR {value} is invalid"),
format!("DESTADDR {} is invalid", p.value),
None,
RejectDisconnect::If421,
)
@@ -2357,11 +2349,11 @@ impl SmtpServerSession {
return Ok(());
};
dest_addr.replace(ip);
} else if name.eq_ignore_ascii_case("DESTPORT") {
let Ok(v) = value.parse::<u16>() else {
} else if p.is_name("DESTPORT") {
let Ok(v) = p.parse::<u16>() else {
self.write_response(
501,
format!("PORT {value} is invalid"),
format!("DESTPORT {} is invalid", p.value),
None,
RejectDisconnect::If421,
)
@@ -2372,7 +2364,7 @@ impl SmtpServerSession {
} else {
self.write_response(
501,
format!("parameter {name} is not supported"),
format!("parameter {} is not supported", p.name),
None,
RejectDisconnect::If421,
)
+16 -9
View File
@@ -4,7 +4,7 @@ use config::any_err;
use mailparsing::{Address, AddressList, EncodeHeaderValue, Mailbox};
#[cfg(feature = "impl")]
use mlua::{FromLua, MetaMethod, UserData, UserDataFields, UserDataMethods};
use rfc5321::{EnvelopeAddress as EnvelopeAddress5321, ForwardPath, ReversePath};
use rfc5321::parser::{EnvelopeAddress as EnvelopeAddress5321, ForwardPath, ReversePath};
use serde::{Deserialize, Serialize};
#[derive(Clone, PartialEq, Serialize, Deserialize, Eq)]
@@ -97,14 +97,14 @@ impl TryInto<EnvelopeAddress> for &Address {
}
impl TryInto<ForwardPath> for EnvelopeAddress {
type Error = String;
type Error = &'static str;
fn try_into(self) -> Result<ForwardPath, Self::Error> {
self.0.try_into()
}
}
impl TryInto<ReversePath> for EnvelopeAddress {
type Error = String;
type Error = &'static str;
fn try_into(self) -> Result<ReversePath, Self::Error> {
self.0.try_into()
}
@@ -116,6 +116,14 @@ impl From<ForwardPath> for EnvelopeAddress {
}
}
impl TryFrom<ReversePath> for EnvelopeAddress {
type Error = &'static str;
fn try_from(reverse_path: ReversePath) -> Result<Self, Self::Error> {
EnvelopeAddress5321::try_from(reverse_path).map(EnvelopeAddress)
}
}
#[cfg(feature = "impl")]
impl UserData for EnvelopeAddress {
fn add_fields<F: UserDataFields<Self>>(fields: &mut F) {
@@ -348,12 +356,11 @@ mod test {
k9::snapshot!(
EnvelopeAddress::parse("user@example.com:2025").unwrap_err(),
"
--> 1:17
|
1 | user@example.com:2025
| ^---
|
= expected EOI, alpha, digit, or utf8_non_ascii
Error at line 1, in Eof:
user@example.com:2025
^____
"
);
}
+2 -1
View File
@@ -5,8 +5,9 @@ edition = "2021"
[dependencies]
bstr.workspace = true
hickory-resolver.workspace = true
nom = {workspace=true}
nom_locate = {workspace=true}
[dev-dependencies]
k9 = {workspace=true}
k9 = {workspace=true}
+257 -4
View File
@@ -1,9 +1,18 @@
use bstr::{BStr, ByteSlice};
use nom::error::{ContextError, ErrorKind};
use nom::Input;
use hickory_resolver::Name;
use nom::branch::alt;
use nom::bytes::complete::{take_while1, take_while_m_n};
use nom::combinator::{map_res, opt, recognize};
use nom::error::{context, ContextError, ErrorKind, FromExternalError, ParseError as _};
use nom::multi::{many0, many1};
use nom::sequence::pair;
use nom::{Input, Parser as _};
use nom_locate::LocatedSpan;
use std::fmt::{Debug, Write};
use std::fmt::{self, Debug, Write};
use std::hash::Hash;
use std::marker::PhantomData;
use std::net::{Ipv4Addr, Ipv6Addr};
use std::str::FromStr;
pub type Span<'a> = LocatedSpan<&'a [u8]>;
pub type IResult<'a, A, B> = nom::IResult<A, B, ParseError<Span<'a>>>;
@@ -18,6 +27,15 @@ pub fn make_span(s: &'_ [u8]) -> Span<'_> {
pub fn tag<E>(tag: &'static str) -> TagParser<E> {
TagParser {
tag,
no_case: false,
e: PhantomData,
}
}
pub fn tag_no_case<E>(tag: &'static str) -> TagParser<E> {
TagParser {
tag,
no_case: true,
e: PhantomData,
}
}
@@ -25,6 +43,7 @@ pub fn tag<E>(tag: &'static str) -> TagParser<E> {
/// Struct to support displaying better errors for tag()
pub struct TagParser<E> {
tag: &'static str,
no_case: bool,
e: PhantomData<E>,
}
@@ -46,7 +65,13 @@ where
let tag_len = self.tag.input_len();
match i.compare(self.tag) {
let compare_result = if self.no_case {
i.compare_no_case(self.tag)
} else {
i.compare(self.tag)
};
match compare_result {
CompareResult::Ok => Ok((i.take_from(tag_len), OM::Output::bind(|| i.take(tag_len)))),
CompareResult::Incomplete => Err(Err::Error(OM::Error::bind(|| {
Error::from_external_error(
@@ -252,3 +277,231 @@ pub fn explain_nom(input: Span, err: nom::Err<ParseError<Span<'_>>>) -> String {
_ => format!("{err:#}"),
}
}
/// See the following RFCs:
/// * <https://datatracker.ietf.org/doc/html/rfc6531#section-3.3>
/// * <https://datatracker.ietf.org/doc/html/rfc6532#section-3.1>
/// * <https://datatracker.ietf.org/doc/html/rfc3629#section-4>
/// which define a bunch of ABNF, but then caps it off with:
/// > The authoritative definition of UTF-8 is in [UNICODE]. This
/// > grammar is believed to describe the same thing Unicode describes, but
/// > does not claim to be authoritative. Implementors are urged to rely
/// > on the authoritative source, rather than on this ABNF.
pub fn utf8_non_ascii(input: Span) -> IResult<Span, Span> {
use nom::Err;
match input.char_indices().next() {
Some((start, end, c)) => {
let len = end - start;
if c as u32 <= 0x7f {
// It's ASCII, therefore doesn't match as utf8_non_ascii
return Err(Err::Error(ParseError::from_error_kind(
input,
ErrorKind::Fail,
)));
}
let slice = &input[start..end];
if c == std::char::REPLACEMENT_CHARACTER {
let mut verify = [0u8; 4];
if slice != c.encode_utf8(&mut verify).as_bytes() {
// The original sequence wasn't REPLACEMENT_CHARACTER,
// therefore the input is not valid UTF-8
return Err(Err::Error(ParseError::from_error_kind(
input,
ErrorKind::Fail,
)));
}
}
// slice is the first UTF-8 character in the input
Ok((input.take_from(len), input.take(len)))
}
None => {
// There's no input, therefore we cannot match
Err(Err::Error(ParseError::from_error_kind(
input,
ErrorKind::Eof,
)))
}
}
}
fn snum(input: Span) -> IResult<Span, Span> {
take_while_m_n(1, 3, |c: u8| c.is_ascii_digit()).parse(input)
}
pub fn ipv4_address(input: Span) -> IResult<Span, Ipv4Addr> {
context(
"ipv4_address",
map_res(
recognize((snum, tag("."), snum, tag("."), snum, tag("."), snum)),
|matched| {
let v4str = std::str::from_utf8(&matched).expect("can only be ascii");
v4str.parse().map_err(|err| {
nom::Err::Error(ParseError::from_external_error(
input,
ErrorKind::Fail,
format!("invalid ipv4_address: {err}"),
))
})
},
),
)
.parse(input)
}
pub fn ipv6_address(input: Span) -> IResult<Span, Ipv6Addr> {
context(
"ipv6_address",
map_res(
take_while1(|c: u8| c.is_ascii_hexdigit() || c == b':' || c == b'.'),
|matched: Span| {
let v6str = std::str::from_utf8(&matched).expect("can only be ascii");
v6str.parse().map_err(|err| {
nom::Err::Error(ParseError::from_external_error(
input,
ErrorKind::Fail,
format!("invalid ipv6_address: {err}"),
))
})
},
),
)
.parse(input)
}
/// A validated DNS domain name, stored in normalized (ASCII/punycode) form.
/// The original wire-format string (which may have been a UTF-8 U-label)
/// is not preserved; only the IDNA-normalized A-label form is kept.
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct DomainString(String);
impl fmt::Display for DomainString {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl DomainString {
pub fn name(&self) -> Name {
Name::from_str_relaxed(&self.0)
.expect("cannot construct DomainString with an invalid domain name")
}
/// Returns a reference to the normalized (ASCII/punycode) domain string.
pub fn as_str(&self) -> &str {
&self.0
}
}
impl FromStr for DomainString {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let name = Name::from_str_relaxed(s)?;
Ok(Self(name.to_ascii()))
}
}
impl From<DomainString> for Name {
fn from(val: DomainString) -> Self {
val.name()
}
}
impl From<&DomainString> for Name {
fn from(val: &DomainString) -> Self {
val.name()
}
}
/// `let-dig = ALPHA / DIGIT / UTF8-non-ASCII`
fn let_dig(input: Span) -> IResult<Span, Span> {
recognize(alt((
take_while_m_n(1, 1, |c: u8| c.is_ascii_alphanumeric()),
utf8_non_ascii,
)))
.parse(input)
}
/// `ldh-str = *( ALPHA / DIGIT / "-" / UTF8-non-ASCII )` (one or more)
///
/// As an extension to the mail RFCs, we allow for underscore
/// in domain names, as those are a commonly deployed name, despite it
/// being in violation of the DNS RFCs.
fn ldh_str(input: Span) -> IResult<Span, Span> {
recognize(many1(alt((
take_while_m_n(1, 1, |c: u8| {
c.is_ascii_alphanumeric() || c == b'-' || c == b'_'
}),
utf8_non_ascii,
))))
.parse(input)
}
/// `sub-domain = let-dig [ ldh-str ]`
fn sub_domain(input: Span) -> IResult<Span, Span> {
recognize(pair(let_dig, opt(ldh_str))).parse(input)
}
/// `domain = sub-domain *( "." sub-domain )`
pub fn domain_name(input: Span) -> IResult<Span, DomainString> {
context(
"domain-name",
map_res(
recognize(pair(sub_domain, many0(pair(tag("."), sub_domain)))),
|matched: Span| match std::str::from_utf8(&matched) {
Ok(s) => s.parse().map_err(|err| {
nom::Err::Error(ParseError::from_external_error(
input,
ErrorKind::Fail,
format!("invalid domain name: {err}"),
))
}),
Err(err) => Err(nom::Err::Error(ParseError::from_external_error(
input,
ErrorKind::Fail,
format!("invalid domain name: {err}"),
))),
},
),
)
.parse(input)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_ipv4_parse() {
// ipv4_address should parse valid IPv4 addresses
let (_, addr) = ipv4_address(make_span(b"192.168.1.1")).unwrap();
k9::assert_equal!(addr, Ipv4Addr::new(192, 168, 1, 1));
}
#[test]
fn test_ipv6_parse() {
// ipv6_address should parse valid IPv6 addresses,
// and different representations of the same address should be equal
let (_, v6a) = ipv6_address(make_span(b"2001:0db8:0000:0000:0000:0000:0000:0001")).unwrap();
let (_, v6b) = ipv6_address(make_span(b"2001:db8::1")).unwrap();
k9::assert_equal!(v6a, v6b);
}
#[test]
fn test_domain_string_partial_eq() {
// DomainString should compare equal if they normalize to the same domain
let d1 = DomainString::from_str("EXAMPLE.COM").unwrap();
let d2 = DomainString::from_str("example.com").unwrap();
assert_eq!(d1, d2);
}
#[test]
fn test_domain_string_partial_eq_idna() {
// DomainString should compare equal after IDNA normalization
let d1 = DomainString::from_str("münchen.de").unwrap();
let d2 = DomainString::from_str("xn--mnchen-3ya.de").unwrap();
assert_eq!(d1, d2);
}
}
+7 -2
View File
@@ -19,6 +19,7 @@ client = [
[dependencies]
anyhow = {workspace=true}
bstr.workspace = true
data-encoding = {workspace=true}
duration-serde = {path="../duration-serde"}
hickory-proto = {workspace=true, optional=true}
@@ -28,9 +29,10 @@ libc = {workspace=true}
linkme.workspace = true
lruttl = {path="../lruttl", optional=true}
memchr = {workspace=true}
nom.workspace = true
nom-utils = {path="../nom-utils"}
openssl = {workspace=true, optional=true}
pest = {workspace=true}
pest_derive = {workspace=true}
pastey.workspace = true
rustls = {workspace=true}
rustls-pemfile = { workspace = true }
rustls-platform-verifier = {workspace=true,optional=true}
@@ -40,3 +42,6 @@ tokio = {workspace=true, features=["full"], optional=true}
tokio-rustls = {workspace=true, optional=true}
tokio-openssl = {workspace=true, optional=true}
tracing = {workspace=true, optional=true}
[dev-dependencies]
k9.workspace = true
+1 -1
View File
@@ -3,6 +3,6 @@ use libfuzzer_sys::fuzz_target;
fuzz_target!(|data: &[u8]| {
if let Ok(s) = std::str::from_utf8(data) {
let _ = rfc5321::Command::parse(s);
let _ = rfc5321::parser::Command::parse(s);
}
});
+43 -27
View File
@@ -1,11 +1,11 @@
#![allow(clippy::result_large_err)]
use crate::client_types::*;
use crate::{
AsyncReadAndWrite, BoxedAsyncReadAndWrite, Command, Domain, EsmtpParameter, ForwardPath,
ReversePath,
};
use crate::parser::{Command, Domain, EsmtpParameter, ForwardPath, ReversePath};
use crate::{AsyncReadAndWrite, BoxedAsyncReadAndWrite};
use bstr::ByteSlice;
use hickory_proto::rr::rdata::TLSA;
use memchr::memmem::Finder;
use nom_utils::DomainString;
use openssl::x509::{X509Ref, X509};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
@@ -86,12 +86,15 @@ impl ClientError {
| Self::ReadError {
command: Some(command),
..
} => Some(command.encode()),
} => Some(command.encode().to_string()),
Self::TimeOutRequest { commands, .. } | Self::WriteError { commands, .. }
if !commands.is_empty() =>
{
let commands: Vec<String> = commands.into_iter().map(|cmd| cmd.encode()).collect();
Some(commands.join(""))
let s: String = commands
.iter()
.map(|cmd| cmd.encode().to_string())
.collect();
Some(s)
}
_ => None,
}
@@ -460,7 +463,7 @@ impl SmtpClient {
.map_err(ClientError::MalformedResponseLine)?;
}
let response = response_builder.build(command.map(|cmd| cmd.encode()));
let response = response_builder.build(command.map(|cmd| cmd.encode().to_string()));
tracing::trace!("{}: {response:?}", self.hostname);
@@ -526,25 +529,29 @@ impl SmtpClient {
.sum();
let mut lines: Vec<String> = vec![];
let mut all = String::new();
let mut all: Vec<u8> = vec![];
for cmd in commands {
let line = cmd.encode();
all.push_str(&line);
lines.push(line);
all.extend_from_slice(&line);
lines.push(line.to_string());
}
tracing::trace!("send->{}: (PIPELINE) {all}", self.hostname);
tracing::trace!(
"send->{}: (PIPELINE) {}",
self.hostname,
all.as_bstr().escape_bytes()
);
if self.socket.is_some() {
if let Some(tracer) = &self.tracer {
// Send the lines individually to the tracer, so that we
// don't break --terse mode
for line in lines {
WriteTracer::trace(tracer, &line);
for line in &lines {
WriteTracer::trace(tracer, line);
}
}
}
self.write_all_with_timeout(
total_timeout,
all.as_bytes(),
&all,
|| ClientError::TimeOutRequest {
duration: total_timeout,
commands: commands.to_vec(),
@@ -562,14 +569,14 @@ impl SmtpClient {
tracing::trace!("send->{}: {line}", self.hostname);
if self.socket.is_some() {
if let Some(tracer) = &self.tracer {
WriteTracer::trace(tracer, &line);
WriteTracer::trace(tracer, &line.to_string());
}
}
let timeout_duration = command.client_timeout_request(&self.timeouts);
self.write_all_with_timeout(
timeout_duration,
line.as_bytes(),
&line,
|| ClientError::TimeOutRequest {
duration: timeout_duration,
commands: vec![command.clone()],
@@ -681,7 +688,11 @@ impl SmtpClient {
ehlo_name: &str,
) -> Result<&HashMap<String, EsmtpCapability>, ClientError> {
let response = self
.send_command(&Command::Lhlo(Domain::Name(ehlo_name.to_string())))
.send_command(&Command::Lhlo(Domain::DomainName(
ehlo_name
.parse::<DomainString>()
.map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?,
)))
.await?;
self.ehlo_common(response)
}
@@ -691,7 +702,11 @@ impl SmtpClient {
ehlo_name: &str,
) -> Result<&HashMap<String, EsmtpCapability>, ClientError> {
let response = self
.send_command(&Command::Ehlo(Domain::Name(ehlo_name.to_string())))
.send_command(&Command::Ehlo(Domain::DomainName(
ehlo_name
.parse::<DomainString>()
.map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?,
)))
.await?;
self.ehlo_common(response)
}
@@ -1189,7 +1204,7 @@ pub fn subject_name(cert: &X509Ref) -> Vec<String> {
#[cfg(test)]
mod test {
use super::*;
use crate::{MailPath, Mailbox};
use crate::parser::{EnvelopeAddress, MailPath, Mailbox, ReversePath};
#[test]
fn test_stuffing() {
@@ -1370,13 +1385,14 @@ mod test {
"{:#}",
ClientError::TimeOutResponse {
command: Some(Command::MailFrom {
address: ReversePath::Path(MailPath {
at_domain_list: vec![],
mailbox: Mailbox {
local_part: "user".to_string(),
domain: Domain::Name("host".to_string())
}
}),
address: {
let EnvelopeAddress::Path(p) =
EnvelopeAddress::parse("user@host").unwrap()
else {
panic!("expected Path")
};
ReversePath::Path(p)
},
parameters: vec![],
}),
duration: Duration::from_secs(10),
+3 -3
View File
@@ -1,4 +1,4 @@
use crate::Command;
use crate::parser::{Command, MaybePartialCommand};
use serde::{Deserialize, Serialize};
use std::time::Duration;
@@ -268,7 +268,7 @@ impl Response {
/// a separate connection
pub fn was_due_to_message(&self) -> bool {
if let Some(command) = &self.command {
if let Ok(cmd) = Command::parse(command) {
if let Ok(MaybePartialCommand::Full(cmd)) = Command::parse(command) {
return match cmd {
Command::MailFrom { .. }
| Command::RcptTo { .. }
@@ -285,7 +285,7 @@ impl Response {
| Command::Noop(_)
| Command::Help(_)
| Command::Auth { .. }
| Command::RawLine(_)
| Command::Unknown(_)
| Command::XClient(_) => false,
};
}
+3198 -1139
View File
File diff suppressed because it is too large Load Diff
-93
View File
@@ -1,93 +0,0 @@
alpha = { 'a'..'z' | 'A'..'Z' }
digit = { '0'..'9' }
hexdig = { 'a'..'f' | 'A'..'F' | '0'..'9' }
utf8_non_ascii = { '\u{80}'..'\u{10FFFF}' }
atext = { "!" | "#" | "$" | "%" | "&" | "'" | "*" | "+" | "-" | "/" | "=" |
"?" | "^" | "_" | "`" | "{" | "|" | "}" | "~" | alpha | digit | utf8_non_ascii }
atom = { atext+ }
let_dig = { alpha | digit | utf8_non_ascii }
ldh_str = { (alpha | digit | "-" | utf8_non_ascii )+ } // FIXME: validate that it doesn't end with -
sub_domain = { let_dig ~ ldh_str? }
domain = { sub_domain ~ ("." ~ sub_domain)* }
dot_string = { atom ~ ("." ~ atom)* }
quoted_string = { "\"" ~ q_content_smtp* ~ "\"" }
q_content_smtp = { q_text_smtp | quoted_pair_smtp }
quoted_pair_smtp = { "\\" ~ '\u{20}'..'\u{7e}' }
q_text_smtp = { '\u{20}'..'\u{21}' | '\u{23}'..'\u{5b}' | '\u{5d}'..'\u{7e}' | utf8_non_ascii }
string = { atom | quoted_string }
local_part = { dot_string | quoted_string }
mailbox = { local_part ~ "@" ~ ( domain | address_literal ) }
address_literal = { "[" ~ ( ipv4_address_literal | ipv6_address_literal | general_address_literal ) ~ "]" }
ipv4_address_literal = { snum ~ "." ~ snum ~ "." ~ snum ~ "." ~ snum }
snum = { digit{1,3} }
ipv6_address_literal = { ^"IPv6:" ~ ipv6_address }
ipv6_address = { (hexdig | ":" | ".")+ }
general_address_literal = { standardized_tag ~ ":" ~ tag_content }
tag_content = { dcontent+ }
dcontent = { '\u{21}'..'\u{5a}' | '\u{5e}'..'\u{7e}' }
standardized_tag = { ldh_str }
path = { "<" ~ (adl ~ ":" )? ~ mailbox ~ ">" }
path_no_angles = { (adl ~ ":" )? ~ mailbox }
adl = { at_domain ~ ( "," ~ at_domain )* }
at_domain = { "@" ~ domain }
// Helper for use outside of the client when parsing addresses
envelope_address = { path | postmaster | null_sender | path_no_angles }
parse_envelope_address = _{ SOI ~ (path | postmaster | null_sender | path_no_angles | postmaster_no_angles | null_sender_no_angles) ~ EOI }
forward_path = { path | postmaster | path_no_angles }
reverse_path = { path | null_sender | path_no_angles }
null_sender = { "<>" }
null_sender_no_angles = { "" }
mail = { ^"MAIL FROM:" ~ " "* ~ reverse_path ~ (" " ~ smtp_parameters )? }
rcpt = { ^"RCPT TO:" ~ " "* ~ forward_path ~ ( " " ~ smtp_parameters )? }
postmaster = { ^"<postmaster>" }
postmaster_no_angles = { ^"postmaster" }
smtp_parameters = { esmtp_param ~ ( " " ~ esmtp_param )* }
esmtp_param = { esmtp_keyword ~ ("=" ~ esmtp_value )? }
esmtp_keyword = { (alpha | digit | "-")+ }
esmtp_value = { ( '\u{21}' .. '\u{3c}' | '\u{3e}'..'\u{7e}' | utf8_non_ascii )+ }
complete_domain = _{ SOI ~ domain ~ EOI }
hexchar = { "+" ~ hexdig{2} }
xchar = { '\u{21}'..'\u{2a}' | '\u{2c}'..'\u{3c}' | '\u{3e}'..'\u{7e}' }
xtext = { ( xchar | hexchar )* }
mech_char = { 'A'..'Z' | '0'..'9' | "-" | "_" }
sasl_mech = { mech_char{1,20} }
initial_response = { base64+ }
base64 = { 'A'..'Z' | 'a'..'z' | '0'..'9' | "+" | "/" | "=" }
xclient_attr_name = { ^"NAME" | ^"ADDR" | ^"PORT" | ^"HELO" | ^"LOGIN" | ^"DESTADDR" | ^"DESTPORT" }
xclient_attr_value = { xtext }
ehlo = { ^"EHLO " ~ ( domain | address_literal ) }
helo = { ^"HELO " ~ ( domain | address_literal ) }
data = { ^"DATA" }
rset = { ^"RSET" }
quit = { ^"QUIT" }
vrfy = { ^"VRFY " ~ string }
expn = { ^"EXPN " ~ string }
help = { ^"HELP" ~ (" " ~ string)? }
noop = { ^"NOOP" ~ (" " ~ string)? }
starttls = { ^"STARTTLS" }
auth = { ^"AUTH " ~ sasl_mech ~ (" " ~ initial_response)? }
xclient = { ^"XCLIENT" ~ (" " ~ xclient_attr_name ~ "=" ~ xclient_attr_value )+ }
command = _{ SOI ~ mail | rcpt | ehlo | helo | data | rset | vrfy | expn | help | noop | quit | starttls | auth | xclient ~ EOI }
+1
View File
@@ -12,6 +12,7 @@ use num_format::{Locale, ToFormattedString};
use rand::distributions::WeightedIndex;
use rand::prelude::*;
use reqwest::{Client as HttpClient, Url};
use rfc5321::parser::{Command, ForwardPath, ReversePath};
use rfc5321::*;
use serde::Serialize;
use std::io::Write;
+1 -1
View File
@@ -22,7 +22,7 @@ use kumo_server_common::http_server::{AppError, RouterAndDocs};
use kumo_server_common::router_with_docs;
use message::message::QueueNameComponents;
use parking_lot::Mutex;
use rfc5321::ForwardPath;
use rfc5321::parser::ForwardPath;
use serde_json::Value as JsonValue;
use sha2::{Digest, Sha256};
use std::hash::Hash;