From fe54ebfa52dcde44bd9dfc9d210388a8eee99b2c Mon Sep 17 00:00:00 2001 From: Wez Furlong Date: Sun, 29 Mar 2026 11:57:47 +0100 Subject: [PATCH] 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. --- Cargo.lock | 8 +- crates/integration-tests/src/kumod.rs | 5 +- .../src/test/max_line_length.rs | 1 + .../src/test/no_ports_in_rcpt_domain.rs | 11 +- crates/integration-tests/src/test/xclient.rs | 2 +- crates/kumo-api-types/src/shaping.rs | 2 +- crates/kumo-log-types/src/rfc3464.rs | 2 +- crates/kumo-log-types/src/rfc5965.rs | 2 +- crates/kumod/src/smtp_dispatcher.rs | 24 +- crates/kumod/src/smtp_server.rs | 96 +- crates/message/src/address.rs | 25 +- crates/nom-utils/Cargo.toml | 3 +- crates/nom-utils/src/lib.rs | 261 +- crates/rfc5321/Cargo.toml | 9 +- crates/rfc5321/fuzz/fuzz_targets/parser.rs | 2 +- crates/rfc5321/src/client.rs | 70 +- crates/rfc5321/src/client_types.rs | 6 +- crates/rfc5321/src/parser.rs | 4337 ++++++++++++----- crates/rfc5321/src/rfc5321.pest | 93 - crates/traffic-gen/src/main.rs | 1 + crates/tsa-daemon/src/http_server.rs | 2 +- 21 files changed, 3604 insertions(+), 1358 deletions(-) delete mode 100644 crates/rfc5321/src/rfc5321.pest diff --git a/Cargo.lock b/Cargo.lock index 9844dbed..2cb00d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/crates/integration-tests/src/kumod.rs b/crates/integration-tests/src/kumod.rs index 2a44df75..6cee88d2 100644 --- a/crates/integration-tests/src/kumod.rs +++ b/crates/integration-tests/src/kumod.rs @@ -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; diff --git a/crates/integration-tests/src/test/max_line_length.rs b/crates/integration-tests/src/test/max_line_length.rs index 4f853142..9ab61e1b 100644 --- a/crates/integration-tests/src/test/max_line_length.rs +++ b/crates/integration-tests/src/test/max_line_length.rs @@ -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 diff --git a/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs b/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs index ae52ff56..6d058c2f 100644 --- a/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs +++ b/crates/integration-tests/src/test/no_ports_in_rcpt_domain.rs @@ -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:".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: - | ^--- - | - = expected alpha, digit, or utf8_non_ascii", + content: "Syntax error in command or arguments", command: Some( "RCPT TO:\r ", diff --git a/crates/integration-tests/src/test/xclient.rs b/crates/integration-tests/src/test/xclient.rs index 525a24e6..a8d8046e 100644 --- a/crates/integration-tests/src/test/xclient.rs +++ b/crates/integration-tests/src/test/xclient.rs @@ -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] diff --git a/crates/kumo-api-types/src/shaping.rs b/crates/kumo-api-types/src/shaping.rs index 09426e73..d9dc76cd 100644 --- a/crates/kumo-api-types/src/shaping.rs +++ b/crates/kumo-api-types/src/shaping.rs @@ -342,7 +342,7 @@ impl ShapingInner { } pub async fn match_rules(&self, record: &JsonLogRecord) -> anyhow::Result> { - use rfc5321::ForwardPath; + use rfc5321::parser::ForwardPath; // Extract the domain from the recipient. let recipient = ForwardPath::try_from( record diff --git a/crates/kumo-log-types/src/rfc3464.rs b/crates/kumo-log-types/src/rfc3464.rs index 07d65f39..dac5382f 100644 --- a/crates/kumo-log-types/src/rfc3464.rs +++ b/crates/kumo-log-types/src/rfc3464.rs @@ -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; diff --git a/crates/kumo-log-types/src/rfc5965.rs b/crates/kumo-log-types/src/rfc5965.rs index b0c358d4..ea1c1c5b 100644 --- a/crates/kumo-log-types/src/rfc5965.rs +++ b/crates/kumo-log-types/src/rfc5965.rs @@ -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; diff --git a/crates/kumod/src/smtp_dispatcher.rs b/crates/kumod/src/smtp_dispatcher.rs index 81adbe17..e121c3f6 100644 --- a/crates/kumod/src/smtp_dispatcher.rs +++ b/crates/kumod/src/smtp_dispatcher.rs @@ -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 { 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 = 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()); diff --git a/crates/kumod/src/smtp_server.rs b/crates/kumod/src/smtp_server.rs index 1649069d..0c98075c 100644 --- a/crates/kumod/src/smtp_server.rs +++ b/crates/kumod/src/smtp_server.rs @@ -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(¶ms).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 = 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::() else { + if p.is_name("ADDR") { + let Ok(ip) = p.parse::() 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::() else { + } else if p.is_name("PORT") { + let Ok(v) = p.parse::() 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::() else { + } else if p.is_name("DESTADDR") { + let Ok(ip) = p.parse::() 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::() else { + } else if p.is_name("DESTPORT") { + let Ok(v) = p.parse::() 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, ) diff --git a/crates/message/src/address.rs b/crates/message/src/address.rs index 9d5c005f..4a7af9c4 100644 --- a/crates/message/src/address.rs +++ b/crates/message/src/address.rs @@ -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 for &Address { } impl TryInto for EnvelopeAddress { - type Error = String; + type Error = &'static str; fn try_into(self) -> Result { self.0.try_into() } } impl TryInto for EnvelopeAddress { - type Error = String; + type Error = &'static str; fn try_into(self) -> Result { self.0.try_into() } @@ -116,6 +116,14 @@ impl From for EnvelopeAddress { } } +impl TryFrom for EnvelopeAddress { + type Error = &'static str; + + fn try_from(reverse_path: ReversePath) -> Result { + EnvelopeAddress5321::try_from(reverse_path).map(EnvelopeAddress) + } +} + #[cfg(feature = "impl")] impl UserData for EnvelopeAddress { fn add_fields>(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 + ^____ + + " ); } diff --git a/crates/nom-utils/Cargo.toml b/crates/nom-utils/Cargo.toml index 260691f7..170b6ebf 100644 --- a/crates/nom-utils/Cargo.toml +++ b/crates/nom-utils/Cargo.toml @@ -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} \ No newline at end of file +k9 = {workspace=true} diff --git a/crates/nom-utils/src/lib.rs b/crates/nom-utils/src/lib.rs index 0eea1214..5e339db7 100644 --- a/crates/nom-utils/src/lib.rs +++ b/crates/nom-utils/src/lib.rs @@ -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>>; @@ -18,6 +27,15 @@ pub fn make_span(s: &'_ [u8]) -> Span<'_> { pub fn tag(tag: &'static str) -> TagParser { TagParser { tag, + no_case: false, + e: PhantomData, + } +} + +pub fn tag_no_case(tag: &'static str) -> TagParser { + TagParser { + tag, + no_case: true, e: PhantomData, } } @@ -25,6 +43,7 @@ pub fn tag(tag: &'static str) -> TagParser { /// Struct to support displaying better errors for tag() pub struct TagParser { tag: &'static str, + no_case: bool, e: PhantomData, } @@ -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>>) -> String { _ => format!("{err:#}"), } } + +/// See the following RFCs: +/// * +/// * +/// * +/// 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 { + 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 { + take_while_m_n(1, 3, |c: u8| c.is_ascii_digit()).parse(input) +} + +pub fn ipv4_address(input: Span) -> IResult { + 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 { + 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 { + let name = Name::from_str_relaxed(s)?; + Ok(Self(name.to_ascii())) + } +} + +impl From 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 { + 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 { + 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 { + recognize(pair(let_dig, opt(ldh_str))).parse(input) +} + +/// `domain = sub-domain *( "." sub-domain )` +pub fn domain_name(input: Span) -> IResult { + 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); + } +} diff --git a/crates/rfc5321/Cargo.toml b/crates/rfc5321/Cargo.toml index dd55c022..440972d2 100644 --- a/crates/rfc5321/Cargo.toml +++ b/crates/rfc5321/Cargo.toml @@ -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 diff --git a/crates/rfc5321/fuzz/fuzz_targets/parser.rs b/crates/rfc5321/fuzz/fuzz_targets/parser.rs index 2bd35979..ad131d6d 100644 --- a/crates/rfc5321/fuzz/fuzz_targets/parser.rs +++ b/crates/rfc5321/fuzz/fuzz_targets/parser.rs @@ -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); } }); diff --git a/crates/rfc5321/src/client.rs b/crates/rfc5321/src/client.rs index a4de5fa7..2424cffb 100644 --- a/crates/rfc5321/src/client.rs +++ b/crates/rfc5321/src/client.rs @@ -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 = 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 = vec![]; - let mut all = String::new(); + let mut all: Vec = 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, ClientError> { let response = self - .send_command(&Command::Lhlo(Domain::Name(ehlo_name.to_string()))) + .send_command(&Command::Lhlo(Domain::DomainName( + ehlo_name + .parse::() + .map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?, + ))) .await?; self.ehlo_common(response) } @@ -691,7 +702,11 @@ impl SmtpClient { ehlo_name: &str, ) -> Result<&HashMap, ClientError> { let response = self - .send_command(&Command::Ehlo(Domain::Name(ehlo_name.to_string()))) + .send_command(&Command::Ehlo(Domain::DomainName( + ehlo_name + .parse::() + .map_err(|_| ClientError::InvalidDnsName(ehlo_name.to_string()))?, + ))) .await?; self.ehlo_common(response) } @@ -1189,7 +1204,7 @@ pub fn subject_name(cert: &X509Ref) -> Vec { #[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), diff --git a/crates/rfc5321/src/client_types.rs b/crates/rfc5321/src/client_types.rs index ff2c8c44..62640bb6 100644 --- a/crates/rfc5321/src/client_types.rs +++ b/crates/rfc5321/src/client_types.rs @@ -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, }; } diff --git a/crates/rfc5321/src/parser.rs b/crates/rfc5321/src/parser.rs index 43a06f5a..b618d43b 100644 --- a/crates/rfc5321/src/parser.rs +++ b/crates/rfc5321/src/parser.rs @@ -1,418 +1,133 @@ use crate::client_types::SmtpClientTimeouts; -use pest::iterators::{Pair, Pairs}; -use pest::Parser as _; -use pest_derive::*; +#[cfg(test)] +use bstr::BStr; +use bstr::{BString, ByteSlice}; +use nom::branch::alt; +use nom::bytes::complete::{take_while1, take_while_m_n}; +use nom::combinator::{all_consuming, map, map_res, opt, recognize}; +use nom::error::context; +use nom::multi::{many0, many1}; +use nom::sequence::pair; +use nom::Parser; +use nom_utils::{ + domain_name, explain_nom, ipv4_address, ipv6_address, make_span, tag, tag_no_case, + utf8_non_ascii, DomainString, IResult, Span, +}; +use pastey::paste; use std::borrow::Cow; -use std::str::FromStr; +use std::hash::{Hash, Hasher}; +use std::net::{Ipv4Addr, Ipv6Addr}; use std::time::Duration; -#[derive(Parser)] -#[grammar = "rfc5321.pest"] -struct Parser; - -impl Parser { - pub fn parse_command(text: &str) -> Result { - let result = Parser::parse(Rule::command, text) - .map_err(|err| format!("{err:#}"))? - .next() - .unwrap(); - - match result.as_rule() { - Rule::mail => Self::parse_mail(result.into_inner()), - Rule::rcpt => Self::parse_rcpt(result.into_inner()), - Rule::ehlo => Self::parse_ehlo(result.into_inner()), - Rule::helo => Self::parse_helo(result.into_inner()), - Rule::data => Ok(Command::Data), - Rule::rset => Ok(Command::Rset), - Rule::quit => Ok(Command::Quit), - Rule::starttls => Ok(Command::StartTls), - Rule::vrfy => Self::parse_vrfy(result.into_inner()), - Rule::expn => Self::parse_expn(result.into_inner()), - Rule::help => Self::parse_help(result.into_inner()), - Rule::noop => Self::parse_noop(result.into_inner()), - Rule::auth => Self::parse_auth(result.into_inner()), - Rule::xclient => Self::parse_xclient(result.into_inner()), - _ => Err(format!("unexpected {result:?}")), - } - } - - pub fn parse_envelope_address(text: &str) -> Result { - let result = Parser::parse(Rule::parse_envelope_address, text) - .map_err(|err| format!("{err:#}"))? - .next() - .unwrap(); - match result.as_rule() { - Rule::path | Rule::path_no_angles => { - let path = Self::parse_path(result)?; - Ok(EnvelopeAddress::Path(path)) - } - Rule::postmaster_no_angles | Rule::postmaster => Ok(EnvelopeAddress::Postmaster), - Rule::null_sender_no_angles | Rule::null_sender => Ok(EnvelopeAddress::Null), - _ => Err(format!("unexpected {result:?}")), - } - } - - fn parse_ehlo(mut pairs: Pairs) -> Result { - let domain = pairs.next().unwrap(); - Ok(Command::Ehlo(Self::parse_domain(domain)?)) - } - - fn parse_helo(mut pairs: Pairs) -> Result { - let domain = pairs.next().unwrap(); - Ok(Command::Helo(Self::parse_domain(domain)?)) - } - - fn parse_vrfy(mut pairs: Pairs) -> Result { - let param = pairs.next().unwrap().as_str().to_string(); - Ok(Command::Vrfy(param)) - } - - fn parse_expn(mut pairs: Pairs) -> Result { - let param = pairs.next().unwrap().as_str().to_string(); - Ok(Command::Expn(param)) - } - - fn parse_help(mut pairs: Pairs) -> Result { - let param = pairs.next().map(|s| s.as_str().to_string()); - Ok(Command::Help(param)) - } - - fn parse_noop(mut pairs: Pairs) -> Result { - let param = pairs.next().map(|s| s.as_str().to_string()); - Ok(Command::Noop(param)) - } - - fn parse_auth(mut pairs: Pairs) -> Result { - let sasl_mech = pairs.next().map(|s| s.as_str().to_string()).unwrap(); - let initial_response = pairs.next().map(|s| s.as_str().to_string()); - - Ok(Command::Auth { - sasl_mech, - initial_response, - }) - } - - fn parse_xclient(mut pairs: Pairs) -> Result { - let mut params = vec![]; - - while let Some(param_name) = pairs.next() { - let name = param_name.as_str().to_string(); - let value = xtext_decode(pairs.next().unwrap().as_str())?; - params.push(XClientParameter { name, value }); - } - - Ok(Command::XClient(params)) - } - - fn parse_rcpt(mut pairs: Pairs) -> Result { - let forward_path = pairs.next().unwrap().into_inner().next().unwrap(); - let mut no_angles = false; - let address = match forward_path.as_rule() { - Rule::path_no_angles => { - no_angles = true; - ForwardPath::Path(Self::parse_path(forward_path)?) - } - Rule::path => ForwardPath::Path(Self::parse_path(forward_path)?), - Rule::postmaster => ForwardPath::Postmaster, - wat => return Err(format!("unexpected {wat:?}")), - }; - - let mut parameters = vec![]; - - if let Some(params) = pairs.next() { - if no_angles { - return Err( - "must enclose address in <> if you want to use ESMTP parameters".to_string(), - ); - } - for param in params.into_inner() { - let mut iter = param.into_inner(); - let name = iter.next().unwrap().as_str().to_string(); - let value = iter.next().map(|p| p.as_str().to_string()); - parameters.push(EsmtpParameter { name, value }); - } - } - - Ok(Command::RcptTo { - address, - parameters, - }) - } - - fn parse_mail(mut pairs: Pairs) -> Result { - let reverse_path = pairs.next().unwrap().into_inner().next().unwrap(); - let mut no_angles = false; - let address = match reverse_path.as_rule() { - Rule::path_no_angles => { - no_angles = true; - ReversePath::Path(Self::parse_path(reverse_path)?) - } - Rule::path => ReversePath::Path(Self::parse_path(reverse_path)?), - Rule::null_sender => ReversePath::NullSender, - wat => return Err(format!("unexpected {wat:?}")), - }; - - let mut parameters = vec![]; - - if let Some(params) = pairs.next() { - if no_angles { - return Err( - "must enclose address in <> if you want to use ESMTP parameters".to_string(), - ); - } - for param in params.into_inner() { - let mut iter = param.into_inner(); - let name = iter.next().unwrap().as_str().to_string(); - let value = iter.next().map(|p| p.as_str().to_string()); - parameters.push(EsmtpParameter { name, value }); - } - } - - Ok(Command::MailFrom { - address, - parameters, - }) - } - - fn parse_path(path: Pair) -> Result { - let mut at_domain_list: Vec = vec![]; - for p in path.into_inner() { - match p.as_rule() { - Rule::adl => { - for pair in p.into_inner() { - if let Some(dom) = pair.into_inner().next() { - at_domain_list.push(dom.as_str().to_string()); - } - } - } - Rule::mailbox => { - let mailbox = Self::parse_mailbox(p.into_inner())?; - return Ok(MailPath { - at_domain_list, - mailbox, - }); - } - _ => unreachable!(), - } - } - unreachable!() - } - - fn parse_domain(domain: Pair) -> Result { - Ok(match domain.as_rule() { - Rule::domain => Domain::name(domain.as_str())?, - Rule::address_literal => { - let literal = domain.into_inner().next().unwrap(); - match literal.as_rule() { - Rule::ipv4_address_literal => Domain::V4(literal.as_str().to_string()), - Rule::ipv6_address_literal => { - Domain::V6(literal.into_inner().next().unwrap().as_str().to_string()) - } - Rule::general_address_literal => { - let mut literal = literal.into_inner(); - let tag = literal.next().unwrap().as_str().to_string(); - let literal = literal.next().unwrap().as_str().to_string(); - Domain::Tagged { tag, literal } - } - - _ => unreachable!(), - } - } - _ => unreachable!(), - }) - } - - fn parse_mailbox(mut mailbox: Pairs) -> Result { - let local_part = mailbox.next().unwrap().as_str().to_string(); - let domain = Self::parse_domain(mailbox.next().unwrap())?; - Ok(Mailbox { local_part, domain }) - } -} - -pub fn parse_envelope_address(text: &str) -> Result { - Parser::parse_envelope_address(text) -} - #[derive(Debug, Clone, PartialEq, Eq)] -pub enum ReversePath { - Path(MailPath), - NullSender, +pub enum CommandVerb { + Ehlo, + Helo, + Lhlo, + Mail, + Rcpt, + Data, + Rset, + Quit, + Vrfy, + Expn, + Help, + Noop, + StartTls, + Auth, + XClient, + Unknown(BString), } -impl ReversePath { +/// Domain part of a mailbox in a MAIL FROM address. +#[derive(Clone, PartialEq, Eq, Hash)] +pub enum Domain { + /// A valid DNS domain name + DomainName(DomainString), + /// An IPv4 address literal, e.g. from `[10.0.0.1]` + V4(Ipv4Addr), + /// An IPv6 address literal, e.g. from `[IPv6:::1]` + V6(Ipv6Addr), + /// A general/tagged address literal, e.g. from `[future:something]`. + /// Stores the original `"tag:literal"` string; split on the first `:` + /// when the tag or literal parts are needed individually. + Tagged(String), +} + +impl std::fmt::Debug for Domain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Domain::DomainName(s) => write!(f, "{}", s), + Domain::V4(ip) => write!(f, "[{}]", ip), + Domain::V6(ip) => write!(f, "[IPv6:{}]", ip), + Domain::Tagged(s) => write!(f, "[{}]", s), + } + } +} + +impl std::fmt::Display for Domain { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Domain::DomainName(s) => write!(f, "{}", s), + Domain::V4(ip) => write!(f, "[{}]", ip), + Domain::V6(ip) => write!(f, "[IPv6:{}]", ip), + Domain::Tagged(s) => write!(f, "[{}]", s), + } + } +} + +impl Domain { + /// Returns true if the wire representation of this domain is pure ASCII. + /// + /// `DomainName` is always normalized to ASCII punycode on the wire, so it + /// is always considered ASCII here. IP address literals are inherently + /// ASCII. Tagged literals are checked character-by-character. pub fn is_ascii(&self) -> bool { match self { - Self::Path(path) => path.is_ascii(), - Self::NullSender => true, + Domain::DomainName(_) | Domain::V4(_) | Domain::V6(_) => true, + Domain::Tagged(s) => s.is_ascii(), } } + } -impl TryFrom<&str> for ReversePath { - type Error = String; - fn try_from(s: &str) -> Result { - match s.parse::()? { - EnvelopeAddress::Postmaster => Err("cannot send from postmaster".to_string()), - EnvelopeAddress::Null => Ok(Self::NullSender), - EnvelopeAddress::Path(p) => Ok(Self::Path(p)), - } - } -} - -impl ToString for ReversePath { - fn to_string(&self) -> String { - match self { - Self::Path(p) => p.to_string(), - Self::NullSender => "".to_string(), - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum ForwardPath { - Path(MailPath), - Postmaster, -} - -impl ForwardPath { - pub fn is_ascii(&self) -> bool { - match self { - Self::Path(p) => p.is_ascii(), - Self::Postmaster => true, - } - } -} - -impl TryFrom<&str> for ForwardPath { - type Error = String; - fn try_from(s: &str) -> Result { - match s.parse::()? { - EnvelopeAddress::Postmaster => Ok(Self::Postmaster), - EnvelopeAddress::Null => Err("cannot send to null sender".to_string()), - EnvelopeAddress::Path(p) => Ok(Self::Path(p)), - } - } -} - -impl ToString for ForwardPath { - fn to_string(&self) -> String { - match self { - Self::Path(p) => p.to_string(), - Self::Postmaster => "postmaster".to_string(), - } - } -} - -impl TryInto for EnvelopeAddress { - type Error = String; - fn try_into(self) -> Result { - match self { - EnvelopeAddress::Null => Err("cannot send to null sender".to_string()), - EnvelopeAddress::Postmaster => Ok(ForwardPath::Postmaster), - EnvelopeAddress::Path(p) => Ok(ForwardPath::Path(p)), - } - } -} - -impl TryInto for EnvelopeAddress { - type Error = String; - fn try_into(self) -> Result { - match self { - EnvelopeAddress::Null => Ok(ReversePath::NullSender), - EnvelopeAddress::Postmaster => Err("cannot send from postmaster".to_string()), - EnvelopeAddress::Path(p) => Ok(ReversePath::Path(p)), - } - } -} - -impl Into for ForwardPath { - fn into(self) -> EnvelopeAddress { - match self { - ForwardPath::Postmaster => EnvelopeAddress::Postmaster, - ForwardPath::Path(p) => EnvelopeAddress::Path(p), - } - } -} - -impl Into for &Mailbox { - fn into(self) -> EnvelopeAddress { - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: self.clone(), - }) - } -} - -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub struct MailPath { - pub at_domain_list: Vec, - pub mailbox: Mailbox, -} - -impl MailPath { - pub fn is_ascii(&self) -> bool { - // Note: ignoring at_domain_list here per the to_string() - // implementation - self.mailbox.is_ascii() - } -} - -impl ToString for MailPath { - fn to_string(&self) -> String { - // Note: RFC5321 says about at_domain_list: - // Note that this form, the so-called "source - // route", MUST BE accepted, SHOULD NOT be - // generated, and SHOULD be ignored. - // So we don't include it in the stringified - // version of MailPath - self.mailbox.to_string() - } -} - -#[derive(Debug, Clone, Hash)] +/// An email mailbox: `local-part "@" domain` +#[derive(Clone, Debug)] pub struct Mailbox { - /// Note that the local_part may be a quoted_string. - /// The convention here is to preserve the quoted string as-is, - /// so that we can simply concatenate it with domain when - /// producing the full mailbox string - // FIXME: inconsistent with AddrSpec! - // TODO: Should integrate/unify both of these types where possible. - // Probably should migrate this parser from pest to nom to facilitate that. pub(crate) local_part: String, pub domain: Domain, } impl PartialEq for Mailbox { fn eq(&self, other: &Self) -> bool { - self.domain == other.domain && self.local_part() == other.local_part() + self.local_part() == other.local_part() && self.domain == other.domain } } impl Eq for Mailbox {} impl Mailbox { + /// Returns true if both the local part and domain are pure ASCII. pub fn is_ascii(&self) -> bool { - if !self.local_part.is_ascii() { - return false; - } - match &self.domain { - Domain::V4(s) | Domain::V6(s) | Domain::Name(s) => s.is_ascii(), - Domain::Tagged { tag, literal } => tag.is_ascii() && literal.is_ascii(), - } + self.local_part.is_ascii() && self.domain.is_ascii() } /// Returns the normalized local part. /// Normalization removes any quoting from the local part, /// so that `"\f\o\o"` and `"foo"` will both be returned - /// as `foo` and will compare as equal - pub fn local_part<'a>(&'a self) -> Cow<'a, str> { + /// as `foo` and will compare as equal. + /// Any byte sequences that are invalid UTF-8 will be + /// replaced with the unicode replacement character. + pub fn local_part(&self) -> Cow<'_, str> { + // Check if the local_part is a quoted string if self.local_part.starts_with('"') { + // Quoted string - need to unquote let mut result = String::new(); - let mut iter = self.local_part.chars(); - iter.next(); // skip initial quote - while let Some(c) = iter.next() { + let mut chars = self.local_part.chars(); + chars.next(); // skip initial quote + while let Some(c) = chars.next() { match c { - '\\' => match iter.next() { + '\\' => match chars.next() { Some(c) => { result.push(c); } @@ -432,195 +147,565 @@ impl Mailbox { } Cow::Owned(result) } else { - Cow::Borrowed(&self.local_part) + Cow::Borrowed(self.local_part.as_str()) } } } -impl ToString for Mailbox { - fn to_string(&self) -> String { - let domain = self.domain.to_string(); - format!("{}@{domain}", self.local_part) +#[cfg(test)] +mod mailbox_tests { + use super::*; + + #[test] + fn test_mailbox_local_part_normalized() { + // Test that local_part() normalizes quoted strings + let mb1 = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb2 = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb3 = Mailbox { + local_part: String::from("\"f\\oo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + // All three should normalize to "foo" + k9::assert_equal!(mb1.local_part(), "foo"); + k9::assert_equal!(mb2.local_part(), "foo"); + k9::assert_equal!(mb3.local_part(), "foo"); } -} -#[derive(Debug, Clone, PartialEq, Eq, Hash)] -pub enum Domain { - Name(String), - V4(String), - V6(String), - Tagged { tag: String, literal: String }, -} + #[test] + fn test_mailbox_local_part_eq_normalized() { + // Test that Mailbox equality uses normalized local_part + let mb1 = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb2 = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let mb3 = Mailbox { + local_part: String::from("\"f\\oo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; -impl Domain { - pub fn name(name: &str) -> Result { - match idna::domain_to_ascii(name) { - Ok(name) => Ok(Self::Name(name)), - Err(_empty_error_type) => Err(format!("invalid IDNA domain {name}")), + // All three should be equal due to normalized local_part + k9::assert_equal!(mb1, mb2); + k9::assert_equal!(mb2, mb3); + k9::assert_equal!(mb1, mb3); + } + + #[test] + fn test_mailbox_local_part_unquoted_borrowed() { + // Test that unquoted valid UTF-8 returns Cow::Borrowed + let mb = Mailbox { + local_part: String::from("foo"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + let local_part = mb.local_part(); + match local_part { + Cow::Borrowed(_) => {} + Cow::Owned(_) => panic!("Expected Cow::Borrowed for unquoted valid UTF-8"), + } + } + + #[test] + fn test_mailbox_local_part_quoted_unquoted_borrowed() { + // Test that quoted valid UTF-8 returns Cow::Owned (needs unquoting) + let mb = Mailbox { + local_part: String::from("\"foo\""), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + + let local_part = mb.local_part(); + match local_part { + Cow::Borrowed(_) => panic!("Expected Cow::Owned for quoted string"), + Cow::Owned(s) => { + k9::assert_equal!(s, "foo"); + } } } } -impl ToString for Domain { - fn to_string(&self) -> String { +impl Hash for Mailbox { + fn hash(&self, state: &mut H) { + self.local_part().hash(state); + self.domain.hash(state); + } +} + +/// A parsed email path: optional source route (at-domain-list) plus mailbox. +/// +/// Per RFC 5321 §4.1.2, the source route (at-domain-list) MUST be accepted +/// when parsing, SHOULD NOT be generated when encoding, and SHOULD be ignored. +#[derive(Clone, PartialEq, Eq, Hash)] +pub struct MailPath { + /// Optional source route: list of domains (without the `@` prefix). + pub at_domain_list: Vec, + /// The final mailbox (local-part@domain). + pub mailbox: Mailbox, +} + +impl std::fmt::Debug for MailPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut local_part = Vec::new(); + + // Add source route if present + if !self.at_domain_list.is_empty() { + for (i, domain) in self.at_domain_list.iter().enumerate() { + if i > 0 { + local_part.push(b','); + } + local_part.push(b'@'); // RFC 5321 source route has @ prefix + local_part.extend_from_slice(domain.as_bytes()); + } + local_part.push(b':'); + } + + // Add local-part + local_part.extend_from_slice(self.mailbox.local_part.as_bytes()); + + // Format as MailPath("local_part@domain") with proper escaping using escape_bytes + write!( + f, + "MailPath(\"{}@{:?}\")", + local_part.escape_bytes(), + self.mailbox.domain + ) + } +} + +impl std::fmt::Display for MailPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if !self.at_domain_list.is_empty() { + for (i, domain) in self.at_domain_list.iter().enumerate() { + if i > 0 { + f.write_str(",")?; + } + write!(f, "@{domain}")?; + } + f.write_str(":")?; + } + write!(f, "{}@{}", self.mailbox.local_part, self.mailbox.domain) + } +} + +impl MailPath { + /// Returns true if the mailbox address is pure ASCII. + /// + /// The source route (`at_domain_list`) is intentionally ignored, matching + /// the old parser behaviour (RFC 5321 says source routes SHOULD be ignored). + pub fn is_ascii(&self) -> bool { + self.mailbox.is_ascii() + } +} + +/// The reverse path (sender) for a MAIL FROM command +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ReversePath { + /// A mailbox path + Path(MailPath), + /// Null sender: `MAIL FROM:<>` + NullSender, +} + +impl ReversePath { + /// Returns true if the address is pure ASCII. + pub fn is_ascii(&self) -> bool { match self { - Self::Name(name) => name.to_string(), - Self::V4(addr) => format!("[{addr}]"), - Self::V6(addr) => format!("[IPv6:{addr}]"), - Self::Tagged { tag, literal } => format!("[{tag}:{literal}]"), + Self::NullSender => true, + Self::Path(p) => p.is_ascii(), } } } -#[derive(Debug, PartialEq, Eq, Clone, serde::Deserialize, serde::Serialize)] +impl TryFrom<&str> for ReversePath { + type Error = String; + fn try_from(s: &str) -> Result { + EnvelopeAddress::parse(s)? + .try_into() + .map_err(|e: &'static str| e.to_string()) + } +} + +impl std::fmt::Display for ReversePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NullSender => Ok(()), + Self::Path(p) => p.fmt(f), + } + } +} + +/// The forward path (recipient) for a RCPT TO command +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum ForwardPath { + /// A mailbox path + Path(MailPath), + /// Postmaster: `RCPT TO:` (RFC 5321 §4.1.1.3) + Postmaster, +} + +impl ForwardPath { + /// Returns true if the address is pure ASCII. + pub fn is_ascii(&self) -> bool { + match self { + Self::Postmaster => true, + Self::Path(p) => p.is_ascii(), + } + } +} + +impl TryFrom<&str> for ForwardPath { + type Error = String; + fn try_from(s: &str) -> Result { + EnvelopeAddress::parse(s)? + .try_into() + .map_err(|e: &'static str| e.to_string()) + } +} + +impl std::fmt::Display for ForwardPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Postmaster => write!(f, "Postmaster"), + Self::Path(p) => p.fmt(f), + } + } +} + +/// An envelope address: either a path, null sender, or postmaster. +#[derive(Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[serde(try_from = "String", into = "String")] pub enum EnvelopeAddress { + /// Null sender: `<>` Null, + /// Postmaster: `` or `Postmaster` Postmaster, + /// A path: `` or bare path Path(MailPath), } +impl std::fmt::Display for EnvelopeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EnvelopeAddress::Null => write!(f, ""), + EnvelopeAddress::Postmaster => write!(f, "Postmaster"), + EnvelopeAddress::Path(path) => path.fmt(f), + } + } +} + +impl std::fmt::Debug for EnvelopeAddress { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "<{}>", self) + } +} + +impl From for EnvelopeAddress { + fn from(path: MailPath) -> Self { + EnvelopeAddress::Path(path) + } +} + impl TryFrom for EnvelopeAddress { type Error = String; - fn try_from(s: String) -> Result { - crate::parser::parse_envelope_address(&s) + fn try_from(s: String) -> Result { + EnvelopeAddress::parse(&s) } } -impl FromStr for EnvelopeAddress { +impl From for String { + fn from(addr: EnvelopeAddress) -> Self { + addr.to_string() + } +} + +impl std::str::FromStr for EnvelopeAddress { type Err = String; - fn from_str(input: &str) -> Result { - crate::parser::parse_envelope_address(input) + fn from_str(s: &str) -> Result { + EnvelopeAddress::parse(s) } } -impl Into for EnvelopeAddress { - fn into(self) -> String { - self.to_string() - } -} - -impl ToString for EnvelopeAddress { - fn to_string(&self) -> String { - match self { - Self::Null => String::new(), - Self::Postmaster => "postmaster".to_string(), - Self::Path(path) => path.to_string(), +impl From for EnvelopeAddress { + fn from(fp: ForwardPath) -> Self { + match fp { + ForwardPath::Postmaster => EnvelopeAddress::Postmaster, + ForwardPath::Path(p) => EnvelopeAddress::Path(p), } } } -fn xtext_encode(s: &str) -> Result { - let mut result = String::new(); - - for c in s.chars() { - let ival = c as u32; - if (ival >= 33 && ival <= 126) && c != '+' && c != '=' { - result.push(c); - continue; - } - - if ival > 0xff { - return Err(format!("xtext_encode: char {c} cannot be xtext encoded")); - } - - result.push_str(&format!("+{ival:02x}")); - } - Ok(result) -} - -fn xtext_decode(s: &str) -> Result { - let mut bytes = vec![]; - - let mut iter = s.chars(); - while let Some(c) = iter.next() { - if c == '+' { - // Decode a hex char - let hi = iter - .next() - .ok_or_else(|| "xtext_decode: missing high nybble of hexchar".to_string())?; - let lo = iter - .next() - .ok_or_else(|| "xtext_decode: missing low nybble of hexchar".to_string())?; - - let hi = hi - .to_digit(16) - .ok_or_else(|| "xtext_decode: high nybble is not a valid hexchar".to_string())?; - let lo = lo - .to_digit(16) - .ok_or_else(|| "xtext_decode: low nybble is not a valid hexchar".to_string())?; - - let byte = ((hi << 4) | lo) as u8; - - bytes.push(byte); - } else { - let mut utf8 = [0u8; 4]; - bytes.extend_from_slice(c.encode_utf8(&mut utf8).as_bytes()); +impl TryFrom for EnvelopeAddress { + type Error = &'static str; + fn try_from(rp: ReversePath) -> Result { + match rp { + ReversePath::NullSender => Ok(EnvelopeAddress::Null), + ReversePath::Path(p) => Ok(EnvelopeAddress::Path(p)), } } - - String::from_utf8(bytes) - .map_err(|err| format!("xtext_decode: decoded bytes are not valid utf8: {err:#}")) } -#[cfg(test)] -#[test] -fn test_xtext() { - for (input, expect) in [ - ("hello", "hello"), - ("extra+", "extra+2b"), - ("1+1=2", "1+2b1+3d2"), - ] { - let encoded = xtext_encode(input).unwrap(); - assert_eq!(encoded, expect, "encode error input={input}"); - - let decoded = xtext_decode(&encoded).unwrap(); - assert_eq!(decoded, input, "decode error input={input}"); +impl EnvelopeAddress { + /// Parse an envelope address from a string. + /// + /// Accepts either forward or reverse path syntax, with or without angle brackets. + pub fn parse(input: &str) -> Result { + let input = make_span(input.as_bytes()); + let (_, result) = all_consuming(alt(( + map(tag_no_case("<>"), |_| EnvelopeAddress::Null), + map(tag_no_case(""), |_| EnvelopeAddress::Postmaster), + map(tag_no_case("Postmaster"), |_| EnvelopeAddress::Postmaster), + map(path, EnvelopeAddress::Path), + map(mailbox, EnvelopeAddress::from), + ))) + .parse(input) + .map_err(|e| explain_nom(input, e))?; + Ok(result) } - - assert_eq!( - xtext_encode("space👾").unwrap_err(), - "xtext_encode: char 👾 cannot be xtext encoded" - ); } -#[derive(Debug, Clone, PartialEq, Eq)] +impl From for ReversePath { + fn from(path: MailPath) -> Self { + ReversePath::Path(path) + } +} + +impl From for ForwardPath { + fn from(path: MailPath) -> Self { + ForwardPath::Path(path) + } +} + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(mailpath) => Ok(mailpath), + ReversePath::NullSender => Err("Cannot convert NullSender to MailPath"), + } + } +} + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(mailpath) => Ok(mailpath), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to MailPath"), + } + } +} + +// ============================================================================ +// Conversions from Mailbox +// ============================================================================ + +/// Infallible conversion: wraps a Mailbox in a MailPath with no source route. +impl From for MailPath { + fn from(mailbox: Mailbox) -> Self { + MailPath { + at_domain_list: vec![], + mailbox, + } + } +} + +/// Infallible conversion: wraps a Mailbox in an EnvelopeAddress::Path. +impl From for EnvelopeAddress { + fn from(mailbox: Mailbox) -> Self { + EnvelopeAddress::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +/// Infallible conversion: wraps a Mailbox in a ReversePath::Path. +impl From for ReversePath { + fn from(mailbox: Mailbox) -> Self { + ReversePath::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +/// Infallible conversion: wraps a Mailbox in a ForwardPath::Path. +impl From for ForwardPath { + fn from(mailbox: Mailbox) -> Self { + ForwardPath::Path(MailPath { + at_domain_list: vec![], + mailbox, + }) + } +} + +// ============================================================================ +// Fallible conversions to Mailbox +// ============================================================================ + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(path.mailbox), + EnvelopeAddress::Null => Err("Cannot convert Null to Mailbox"), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to Mailbox"), + } + } +} + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(path) => Ok(path.mailbox), + ReversePath::NullSender => Err("Cannot convert NullSender to Mailbox"), + } + } +} + +impl TryFrom for Mailbox { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(path) => Ok(path.mailbox), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to Mailbox"), + } + } +} + +// ============================================================================ +// Fallible conversions to MailPath +// ============================================================================ + +impl TryFrom for MailPath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(path), + EnvelopeAddress::Null => Err("Cannot convert Null to MailPath"), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to MailPath"), + } + } +} + +impl TryFrom for ForwardPath { + type Error = &'static str; + + fn try_from(path: ReversePath) -> Result { + match path { + ReversePath::Path(mailpath) => Ok(ForwardPath::Path(mailpath)), + ReversePath::NullSender => Err("Cannot convert NullSender to ForwardPath"), + } + } +} + +impl TryFrom for ReversePath { + type Error = &'static str; + + fn try_from(path: ForwardPath) -> Result { + match path { + ForwardPath::Path(mailpath) => Ok(ReversePath::Path(mailpath)), + ForwardPath::Postmaster => Err("Cannot convert Postmaster to ReversePath"), + } + } +} + +impl TryFrom for ReversePath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(ReversePath::Path(path)), + EnvelopeAddress::Null => Ok(ReversePath::NullSender), + EnvelopeAddress::Postmaster => Err("Cannot convert Postmaster to ReversePath"), + } + } +} + +impl TryFrom for ForwardPath { + type Error = &'static str; + + fn try_from(addr: EnvelopeAddress) -> Result { + match addr { + EnvelopeAddress::Path(path) => Ok(ForwardPath::Path(path)), + EnvelopeAddress::Null => Err("Cannot convert Null to ForwardPath"), + EnvelopeAddress::Postmaster => Ok(ForwardPath::Postmaster), + } + } +} + +/// An ESMTP parameter: `name["=" value]` +#[derive(Clone, Debug, PartialEq, Eq)] pub struct EsmtpParameter { pub name: String, pub value: Option, } -impl ToString for EsmtpParameter { - fn to_string(&self) -> String { - match &self.value { - Some(value) => format!("{}={}", self.name, value), - None => self.name.to_string(), - } - } -} - +/// A single XCLIENT parameter: `name=xtext-value`. +/// The `value` field stores the **xtext-decoded** string; the wire form +/// uses xtext encoding where non-printable bytes appear as `+XX` hex pairs. #[derive(Debug, Clone, PartialEq, Eq)] pub struct XClientParameter { pub name: String, pub value: String, } -impl ToString for XClientParameter { - fn to_string(&self) -> String { - let value = match xtext_encode(&self.value) { - Ok(s) => s, - Err(s) => s, - }; - format!("{}={value}", self.name) +impl XClientParameter { + /// Returns true if the parameter name matches the given name (case-insensitive). + pub fn is_name(&self, name: impl AsRef) -> bool { + self.name.eq_ignore_ascii_case(name.as_ref()) + } + + /// Parse the parameter value as type T. + /// + /// Converts the value to a string and then parses it as T. + pub fn parse(&self) -> Result + where + T: std::str::FromStr, + T::Err: std::fmt::Display, + { + let parsed: Result = self.value.parse(); + parsed.map_err(|e| e.to_string()) } } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Clone, PartialEq, Eq)] pub enum Command { Ehlo(Domain), Helo(Domain), Lhlo(Domain), + Noop(Option), + Help(Option), + Vrfy(Option), + Expn(Option), + Data, + /// The end-of-data terminator sent after the message body: `".\r\n"`. + /// + /// This variant is never produced by the parser — it is constructed + /// programmatically by SMTP client code and serialized via + /// [`Command::encode`] when the client needs to signal the end of the + /// DATA content stream (RFC 5321 §4.5.2). + DataDot, + Rset, + Quit, + StartTls, MailFrom { address: ReversePath, parameters: Vec, @@ -629,98 +714,327 @@ pub enum Command { address: ForwardPath, parameters: Vec, }, - Data, - DataDot, - Rset, - Quit, - Vrfy(String), - Expn(String), - Help(Option), - Noop(Option), - StartTls, Auth { sasl_mech: String, initial_response: Option, }, XClient(Vec), - /// For testing non-conforming inputs - RawLine(String), + Unknown(BString), +} + +impl std::fmt::Debug for Command { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Use Command::encode to get the wire format, then escape_bytes for Debug output + let encoded = self.encode(); + write!(f, "Command(\"{}\")", encoded.escape_bytes()) + } +} + +#[derive(Clone, PartialEq, Eq)] +pub enum MaybePartialCommand { + Full(Command), + Partial { + verb: CommandVerb, + remainder: BString, + }, +} + +impl std::fmt::Debug for MaybePartialCommand { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MaybePartialCommand::Full(cmd) => write!(f, "Full({cmd:?})"), + MaybePartialCommand::Partial { verb, remainder } => { + write!( + f, + "Partial {{ verb: {verb:?}, remainder: {:?} }}", + remainder.escape_bytes() + ) + } + } + } +} + +macro_rules! parse_single { + ($func_name:ident, $token:literal, $verb:ident) => { + fn $func_name(input: Span) -> IResult { + context( + $token, + alt(( + map( + all_consuming((tag_no_case($token), wsp, anything)), + |(_cmd, _space, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case($token)), |_| { + MaybePartialCommand::Full(Command::$verb) + }), + )), + ) + .parse(input) + } + + paste! { + #[cfg(test)] + #[test] + fn []() { + k9::assert_equal!( + unwrapper(Command::parse($token)), + MaybePartialCommand::Full(Command::$verb) + ); + k9::assert_equal!( + unwrapper(Command::parse($token.to_lowercase())), + MaybePartialCommand::Full(Command::$verb) + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} trailing garbage", $token))), + MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder:"trailing garbage".into() + } + ); + } + } + }; +} + +macro_rules! parse_opt_arg { + ($func_name:ident, $token:literal, $verb:ident) => { + fn $func_name(input: Span) -> IResult { + context( + $token, + alt(( + map( + all_consuming((tag_no_case($token), wsp, string)), + |(_cmd, _space, param)| match String::from_utf8(param.fragment().to_vec()) { + Ok(s) => MaybePartialCommand::Full(Command::$verb(Some(s))), + Err(_) => MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: BString::default(), + }, + }, + ), + map( + all_consuming((tag_no_case($token), wsp, anything)), + |(_cmd, _space, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case($token)), |_| { + MaybePartialCommand::Full(Command::$verb(None)) + }), + )), + ) + .parse(input) + } + + paste! { + #[cfg(test)] + #[test] + fn []() { + k9::assert_equal!( + unwrapper(Command::parse($token)), + MaybePartialCommand::Full(Command::$verb(None)), + "full no param" + ); + k9::assert_equal!( + unwrapper(Command::parse($token.to_lowercase())), + MaybePartialCommand::Full(Command::$verb(None)), + "full no param, different case" + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} parameter", $token))), + MaybePartialCommand::Full(Command::$verb(Some("parameter".into()))), + "full with param" + ); + k9::assert_equal!( + unwrapper(Command::parse(format!("{} trailing garbage", $token))), + MaybePartialCommand::Partial { + verb: CommandVerb::$verb, + remainder:"trailing garbage".into() + }, + "should have partial" + ); + } + } + }; +} + +/// Helper that does an unwrap, but rather than print the error string +/// with escapes, uses its Display impl. This makes it easier to see +/// what the error message is, because the error in this context is +/// typically a nom_utils error which is multi-line and uses a caret +/// to point to the appropriate column in the input. +#[cfg(test)] +fn unwrapper(result: Result) -> T { + match result { + Ok(r) => r, + Err(err) => panic!("{err}"), + } +} + +parse_opt_arg!(parse_noop, "NOOP", Noop); +parse_opt_arg!(parse_help, "HELP", Help); +parse_opt_arg!(parse_vrfy, "VRFY", Vrfy); +parse_opt_arg!(parse_expn, "EXPN", Expn); +parse_single!(parse_data, "DATA", Data); +parse_single!(parse_rset, "RSET", Rset); +parse_single!(parse_quit, "QUIT", Quit); +parse_single!(parse_starttls, "STARTTLS", StartTls); + +fn parse_with<'a, R, F>(text: &'a [u8], parser: F) -> Result +where + F: Fn(Span<'a>) -> IResult<'a, Span<'a>, R>, +{ + let input = make_span(text); + let (_, result) = all_consuming(parser) + .parse(input) + .map_err(|err| explain_nom(input, err))?; + Ok(result) } impl Command { - pub fn parse(line: &str) -> Result { - Parser::parse_command(line) + pub fn parse(input: impl AsRef<[u8]>) -> Result { + // Strip a trailing CRLF (or bare LF) so that both wire-format input + // (with CRLF terminator, as produced by encode()) and bare command + // strings (as used in tests and interactive contexts) are accepted. + let bytes = input.as_ref(); + let bytes = bytes + .strip_suffix(b"\r\n") + .or_else(|| bytes.strip_suffix(b"\n")) + .unwrap_or(bytes); + parse_with(bytes, Self::parse_span) } - pub fn encode(&self) -> String { + fn parse_span(input: Span) -> IResult { + context( + "command-verb", + alt(( + parse_ehlo, + parse_helo, + parse_lhlo, + parse_help, + parse_noop, + parse_vrfy, + parse_expn, + parse_data, + parse_rset, + parse_quit, + parse_starttls, + parse_mail_from, + parse_rcpt_to, + parse_auth, + parse_xclient, + Self::parse_unknown, + )), + ) + .parse(input) + } + + /// Re-encode the command as a single line of text ready to send on the + /// wire, including the trailing `\r\n`. + /// + /// The returned `BString` can be fed back to [`Command::parse`] to + /// recover the original `Command` value (round-trip stable), with the + /// one intentional exception that `at_domain_list` source routes are not + /// re-emitted (RFC 5321 says they SHOULD NOT be generated). + pub fn encode(&self) -> BString { + let mut buf: Vec = Vec::new(); match self { - Self::Ehlo(domain) => format!("EHLO {}\r\n", domain.to_string()), - Self::Helo(domain) => format!("HELO {}\r\n", domain.to_string()), - Self::Lhlo(domain) => format!("LHLO {}\r\n", domain.to_string()), + Self::Ehlo(domain) => { + buf.extend_from_slice(b"EHLO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Helo(domain) => { + buf.extend_from_slice(b"HELO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Lhlo(domain) => { + buf.extend_from_slice(b"LHLO "); + buf.extend_from_slice(encode_domain(domain).as_ref()); + } + Self::Noop(None) => buf.extend_from_slice(b"NOOP"), + Self::Noop(Some(s)) => { + buf.extend_from_slice(b"NOOP "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Help(None) => buf.extend_from_slice(b"HELP"), + Self::Help(Some(s)) => { + buf.extend_from_slice(b"HELP "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Vrfy(None) => buf.extend_from_slice(b"VRFY"), + Self::Vrfy(Some(s)) => { + buf.extend_from_slice(b"VRFY "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Expn(None) => buf.extend_from_slice(b"EXPN"), + Self::Expn(Some(s)) => { + buf.extend_from_slice(b"EXPN "); + buf.extend_from_slice(s.as_bytes()); + } + Self::Data => buf.extend_from_slice(b"DATA"), + // DataDot encodes as exactly ".\r\n" — return before the + // unconditional CRLF that all other arms rely on below. + Self::DataDot => return BString::from(".\r\n"), + Self::Rset => buf.extend_from_slice(b"RSET"), + Self::Quit => buf.extend_from_slice(b"QUIT"), + Self::StartTls => buf.extend_from_slice(b"STARTTLS"), Self::MailFrom { address, parameters, } => { - let mut params = String::new(); - for p in parameters { - params.push(' '); - params.push_str(&p.to_string()); - } - - format!("MAIL FROM:<{}>{params}\r\n", address.to_string()) + buf.extend_from_slice(b"MAIL FROM:<"); + buf.extend(encode_reverse_path(address)); + buf.push(b'>'); + buf.extend(encode_esmtp_params(parameters)); } Self::RcptTo { address, parameters, } => { - let mut params = String::new(); - for p in parameters { - params.push(' '); - params.push_str(&p.to_string()); - } - - format!("RCPT TO:<{}>{params}\r\n", address.to_string()) + buf.extend_from_slice(b"RCPT TO:<"); + buf.extend(encode_forward_path(address)); + buf.push(b'>'); + buf.extend(encode_esmtp_params(parameters)); } - Self::Data => "DATA\r\n".to_string(), - Self::DataDot => ".\r\n".to_string(), - Self::Rset => "RSET\r\n".to_string(), - Self::Quit => "QUIT\r\n".to_string(), - Self::StartTls => "STARTTLS\r\n".to_string(), - Self::Vrfy(param) => format!("VRFY {param}\r\n"), - Self::Expn(param) => format!("EXPN {param}\r\n"), - Self::Help(Some(param)) => format!("HELP {param}\r\n"), - Self::Help(None) => "HELP\r\n".to_string(), - Self::Noop(Some(param)) => format!("NOOP {param}\r\n"), - Self::Noop(None) => "NOOP\r\n".to_string(), Self::Auth { sasl_mech, initial_response: None, - } => format!("AUTH {sasl_mech}\r\n"), + } => { + buf.extend_from_slice(b"AUTH "); + buf.extend_from_slice(sasl_mech.as_bytes()); + } Self::Auth { sasl_mech, initial_response: Some(resp), - } => format!("AUTH {sasl_mech} {resp}\r\n"), - Self::XClient(params) => { - let mut s = String::new(); - for p in params { - s.push(' '); - s.push_str(&p.to_string()); - } - format!("XCLIENT{s}\r\n") + } => { + buf.extend_from_slice(b"AUTH "); + buf.extend_from_slice(sasl_mech.as_bytes()); + buf.push(b' '); + buf.extend_from_slice(resp.as_bytes()); } - Self::RawLine(line) => { - format!("{line}\r\n") + Self::XClient(params) => { + buf.extend_from_slice(b"XCLIENT"); + buf.extend(encode_xclient_params(params)); + } + Self::Unknown(s) => { + buf.extend_from_slice(s); } } + buf.extend_from_slice(b"\r\n"); + BString::from(buf) } - /// Timeouts for reading the response + /// Timeout for reading the response to this command. pub fn client_timeout(&self, timeouts: &SmtpClientTimeouts) -> Duration { match self { Self::Helo(_) | Self::Ehlo(_) | Self::Lhlo(_) => timeouts.ehlo_timeout, Self::MailFrom { .. } => timeouts.mail_from_timeout, Self::RcptTo { .. } => timeouts.rcpt_to_timeout, - Self::Data { .. } => timeouts.data_timeout, + Self::Data => timeouts.data_timeout, Self::DataDot => timeouts.data_dot_timeout, Self::Rset => timeouts.rset_timeout, Self::StartTls => timeouts.starttls_timeout, @@ -728,20 +1042,869 @@ impl Command { timeouts.idle_timeout } Self::Auth { .. } => timeouts.auth_timeout, - Self::XClient { .. } => timeouts.auth_timeout, // FIXME: xclient specific timeout - Self::RawLine(_) => timeouts.mail_from_timeout, // No good option for this TBH. + Self::XClient(_) => timeouts.auth_timeout, // FIXME: xclient specific timeout + Self::Unknown(_) => timeouts.mail_from_timeout, // No good option for this TBH. } } - /// Timeouts for writing the request + /// Timeout for writing the request. pub fn client_timeout_request(&self, timeouts: &SmtpClientTimeouts) -> Duration { - let one_minute = Duration::from_secs(60); - self.client_timeout(timeouts).min(one_minute) + self.client_timeout(timeouts).min(Duration::from_secs(60)) + } + + fn parse_unknown(input: Span) -> IResult { + context( + "unknown-command", + alt(( + map( + all_consuming(recognize((command_word, wsp, anything))), + |command| MaybePartialCommand::Full(Command::Unknown((*command).into())), + ), + map(all_consuming(command_word), |command| { + MaybePartialCommand::Full(Command::Unknown((*command).into())) + }), + )), + ) + .parse(input) } } -pub fn is_valid_domain(text: &str) -> bool { - Parser::parse(Rule::complete_domain, text).is_ok() +fn command_word(input: Span) -> IResult { + context( + "command-word", + take_while1(|c: u8| c.is_ascii_alphanumeric()), + ) + .parse(input) +} + +fn wsp(input: Span) -> IResult { + context("wsp", take_while1(|c| c == b' ' || c == b'\t')).parse(input) +} + +fn anything(input: Span) -> IResult { + context("anything", take_while1(|_| true)).parse(input) +} + +fn atext(input: Span) -> IResult { + recognize(alt(( + take_while_m_n(1, 1, |c: u8| { + matches!( + c, + b'!' | b'#' | b'$' | b'%' | b'&' | b'\'' | b'*' | b'+' | b'-' | b'/' | b'=' | b'?' + | b'^' | b'_' | b'`' | b'{' | b'|' | b'}' | b'~' + | b'A'..=b'Z' + | b'a'..=b'z' + | b'0'..=b'9' + ) + }), + utf8_non_ascii, + ))) + .parse(input) +} + +fn atom(input: Span) -> IResult { + context("atom", recognize(many1(atext))).parse(input) +} + +fn quoted_string(input: Span) -> IResult { + context( + "quoted-string", + recognize(( + tag("\""), + many0(alt(( + recognize(pair( + tag("\\"), + take_while_m_n(1, 1, |c: u8| c >= 0x20 && c <= 0x7e), + )), + take_while_m_n(1, 1, |c: u8| { + (c >= 0x20 && c <= 0x21) || (c >= 0x23 && c <= 0x5b) || (c >= 0x5d && c <= 0x7e) + }), + utf8_non_ascii, + ))), + tag("\""), + )), + ) + .parse(input) +} + +fn string(input: Span) -> IResult { + context("string", alt((atom, quoted_string))).parse(input) +} + +// --------------------------------------------------------------------------- +// MAIL FROM helpers +// --------------------------------------------------------------------------- + +/// `dot-string = atom *("." atom)` +fn dot_string(input: Span) -> IResult { + context("dot-string", recognize((atom, many0(pair(tag("."), atom))))).parse(input) +} + +/// `local-part = dot-string / quoted-string` +fn local_part(input: Span) -> IResult { + context("local-part", alt((dot_string, quoted_string))).parse(input) +} + +/// `dcontent = %d33-90 / %d94-126` — printable US-ASCII excluding `[`, `\`, `]` +fn dcontent(input: Span) -> IResult { + take_while1(|c: u8| (c >= 33 && c <= 90) || (c >= 94 && c <= 126)).parse(input) +} + +/// The content inside an address literal `[...]`. +/// +/// The IPv6 prefix is checked first so that `[IPv6:bad]` produces a +/// recoverable parse error rather than falling through to the general +/// address-literal branch (which would silently accept `IPv6` as a tag). +fn address_literal_content(input: Span) -> IResult { + let is_ipv6 = input + .fragment() + .get(..5) + .map(|b| b.eq_ignore_ascii_case(b"IPv6:")) + .unwrap_or(false); + + if is_ipv6 { + // Strictly parse as IPv6; no fallthrough to general literal on failure + context( + "ipv6-address-literal", + map((tag_no_case("IPv6:"), ipv6_address), |(_, ip)| { + Domain::V6(ip) + }), + ) + .parse(input) + } else { + alt(( + map(ipv4_address, Domain::V4), + map_res( + ( + recognize(take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-')), + tag(":"), + recognize(many1(dcontent)), + ), + |(tag_s, _colon, lit): (Span, _, Span)| -> Result { + // Store the original "tag:literal" string as-is + let mut s = String::from_utf8(tag_s.fragment().to_vec()) + .map_err(|_| "address_literal: invalid UTF-8 in tag".to_string())?; + s.push(':'); + let lit_str = std::str::from_utf8(lit.fragment()) + .map_err(|_| "address_literal: invalid UTF-8 in literal".to_string())?; + s.push_str(lit_str); + Ok(Domain::Tagged(s)) + }, + ), + )) + .parse(input) + } +} + +/// `address-literal = "[" ( IPv4 / "IPv6:" IPv6 / tag ":" dcontent ) "]"` +fn address_literal(input: Span) -> IResult { + context( + "address-literal", + map((tag("["), address_literal_content, tag("]")), |(_, d, _)| d), + ) + .parse(input) +} + +/// `mailbox-domain = address-literal / domain-name` +fn mailbox_domain(input: Span) -> IResult { + context( + "mailbox-domain", + alt((address_literal, map(domain_name, Domain::DomainName))), + ) + .parse(input) +} + +/// `mailbox = local-part "@" mailbox-domain` +fn mailbox(input: Span) -> IResult { + context( + "mailbox", + map_res( + (local_part, tag("@"), mailbox_domain), + |(lp, _, dom): (Span, _, Domain)| -> Result { + // Convert the local_part bytes to a String + // If the bytes are not valid UTF-8, return an error + let local_part = String::from_utf8(lp.fragment().to_vec()) + .map_err(|_| "invalid UTF-8 in local-part".to_string())?; + Ok(Mailbox { + local_part, + domain: dom, + }) + }, + ), + ) + .parse(input) +} + +/// `at-domain = "@" domain-name` — returns the domain string (without the `@`) +fn at_domain(input: Span) -> IResult { + map_res( + (tag("@"), recognize(domain_name)), + |(_, d): (Span, Span)| -> Result { + String::from_utf8(d.fragment().to_vec()) + .map_err(|_| "at_domain: invalid UTF-8 in domain".to_string()) + }, + ) + .parse(input) +} + +/// `at-domain-list = at-domain *("," at-domain) ":"` +fn at_domain_list(input: Span) -> IResult> { + context( + "at-domain-list", + map( + (at_domain, many0((tag(","), at_domain)), tag(":")), + |(first, rest, _)| { + let mut v = vec![first]; + v.extend(rest.into_iter().map(|(_, d)| d)); + v + }, + ), + ) + .parse(input) +} + +/// `null-sender = "<>"` +fn null_sender(input: Span) -> IResult { + context("null-sender", map(tag("<>"), |_| ReversePath::NullSender)).parse(input) +} + +/// `path = "<" [ at-domain-list ] mailbox ">"` +/// +/// This is the core grammar element shared by both reverse-path and +/// forward-path. It parses the content between angle brackets and returns +/// a MailPath (optional source route + mailbox). +fn path(input: Span) -> IResult { + context( + "path", + map( + (tag("<"), opt(at_domain_list), mailbox, tag(">")), + |(_, domains, mb, _)| MailPath { + at_domain_list: domains.unwrap_or_default(), + mailbox: mb, + }, + ), + ) + .parse(input) +} + +/// `reverse-path = null-sender / path / bare-mailbox` +/// +/// Null sender is tried first so that `<>` is not consumed as the opening +/// `<` of a path. Bare mailbox (no angle brackets) is accepted as a +/// leniency for non-conforming senders. +fn reverse_path(input: Span) -> IResult { + context( + "reverse-path", + alt(( + null_sender, + map(path, ReversePath::Path), + map(mailbox, ReversePath::from), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// RCPT TO helpers +// --------------------------------------------------------------------------- + +/// `` — the special no-domain postmaster address (RFC 5321 §4.1.1.3) +fn postmaster_path(input: Span) -> IResult { + context( + "postmaster", + map(tag_no_case(""), |_| ForwardPath::Postmaster), + ) + .parse(input) +} + +/// `forward-path = "" / "<" path-content ">" / bare-mailbox` +/// +/// `` is tried first so the literal string is not consumed as +/// the opening `<` of a regular path. Bare mailbox (no angle brackets) is +/// accepted as a leniency for non-conforming senders. +fn forward_path(input: Span) -> IResult { + context( + "forward-path", + alt(( + postmaster_path, + map(path, ForwardPath::Path), + map(mailbox, ForwardPath::from), + )), + ) + .parse(input) +} + +/// `esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-")` +fn esmtp_keyword(input: Span) -> IResult { + context( + "esmtp-keyword", + recognize(( + take_while_m_n(1, 1, |c: u8| c.is_ascii_alphanumeric()), + many0(take_while_m_n(1, 1, |c: u8| { + c.is_ascii_alphanumeric() || c == b'-' + })), + )), + ) + .parse(input) +} + +/// `esmtp-value = 1*(%d33-60 / %d62-126 / UTF8-non-ASCII)` +/// +/// RFC 5321 defines the base character range (printable ASCII excluding `=`, +/// SP, and controls). RFC 6531 §3.3 extends this with `UTF8-non-ASCII` to +/// support internationalized ESMTP parameter values. +/// +/// `many1(alt(ascii_run, utf8_non_ascii))` lets the `take_while1` arm greedily +/// consume consecutive ASCII bytes while `utf8_non_ascii` handles each +/// multi-byte codepoint. +fn esmtp_value(input: Span) -> IResult { + context( + "esmtp-value", + recognize(many1(alt(( + take_while1(|c: u8| (c >= 33 && c <= 60) || (c >= 62 && c <= 126)), + utf8_non_ascii, + )))), + ) + .parse(input) +} + +/// `esmtp-param = esmtp-keyword ["=" esmtp-value]` +fn esmtp_param(input: Span) -> IResult { + context( + "esmtp-param", + map_res( + (esmtp_keyword, opt((tag("="), esmtp_value))), + |(name, value): (Span, Option<(Span, Span)>)| -> Result { + let name = String::from_utf8(name.fragment().to_vec()) + .map_err(|_| "esmtp_param: invalid UTF-8 in name".to_string())?; + let value = value + .map(|(_, v)| { + String::from_utf8(v.fragment().to_vec()) + .map_err(|_| "esmtp_param: invalid UTF-8 in value".to_string()) + }) + .transpose()?; + Ok(EsmtpParameter { name, value }) + }, + ), + ) + .parse(input) +} + +/// `mail-parameters = esmtp-param *(SP esmtp-param)` +fn mail_parameters(input: Span) -> IResult> { + context( + "mail-parameters", + map((esmtp_param, many0((wsp, esmtp_param))), |(first, rest)| { + let mut params = vec![first]; + params.extend(rest.into_iter().map(|(_, p)| p)); + params + }), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// EHLO / HELO / LHLO parsers +// --------------------------------------------------------------------------- + +/// `ehlo = "EHLO" SP ( Domain / address-literal ) CRLF` +/// +/// Both HELO and LHLO reuse the same domain parser (permissive: address +/// literals are accepted for all three greeting commands). +/// Any failure in `mailbox_domain` (e.g. bad IP) falls through to Partial. +fn parse_ehlo(input: Span) -> IResult { + context( + "ehlo", + alt(( + map( + all_consuming((tag_no_case("EHLO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Ehlo(domain)), + ), + map( + all_consuming((tag_no_case("EHLO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("EHLO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `helo = "HELO" SP Domain CRLF` +/// +/// Permissive: also accepts address literals (matches parser.rs behaviour). +fn parse_helo(input: Span) -> IResult { + context( + "helo", + alt(( + map( + all_consuming((tag_no_case("HELO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Helo(domain)), + ), + map( + all_consuming((tag_no_case("HELO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("HELO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `lhlo = "LHLO" SP ( Domain / address-literal ) CRLF` (RFC 2033 LMTP) +fn parse_lhlo(input: Span) -> IResult { + context( + "lhlo", + alt(( + map( + all_consuming((tag_no_case("LHLO"), wsp, mailbox_domain)), + |(_, _, domain)| MaybePartialCommand::Full(Command::Lhlo(domain)), + ), + map( + all_consuming((tag_no_case("LHLO"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: (*remainder).into(), + }, + ), + map(all_consuming(tag_no_case("LHLO")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// AUTH parser +// --------------------------------------------------------------------------- + +/// `sasl-mechanism = 1*(ALPHA / DIGIT / "-")` +fn sasl_mechanism(input: Span) -> IResult { + context( + "sasl-mechanism", + map( + take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'-'), + |s: Span| { + // sasl_mechanism only accepts ASCII bytes, so UTF-8 conversion always succeeds + String::from_utf8(s.fragment().to_vec()).expect("sasl_mechanism guaranteed ASCII") + }, + ), + ) + .parse(input) +} + +/// `auth-initial-response = base64 / "="` +/// +/// Matches base64 characters `[A-Za-z0-9+/=]+`. The single `"="` (empty +/// initial response) is a subset of this pattern, so no special case is +/// needed. +fn auth_initial_response(input: Span) -> IResult { + context( + "auth-initial-response", + map( + take_while1(|c: u8| c.is_ascii_alphanumeric() || c == b'+' || c == b'/' || c == b'='), + |s: Span| { + // auth_initial_response only accepts ASCII bytes, so UTF-8 conversion always succeeds + String::from_utf8(s.fragment().to_vec()) + .expect("auth_initial_response guaranteed ASCII") + }, + ), + ) + .parse(input) +} + +/// `auth = "AUTH" SP mechanism [SP initial-response]` +fn parse_auth(input: Span) -> IResult { + context( + "auth", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("AUTH"), + wsp, + sasl_mechanism, + opt((wsp, auth_initial_response)), + )), + |(_, _, sasl_mech, resp)| match resp { + Some((_, r)) => MaybePartialCommand::Full(Command::Auth { + sasl_mech, + initial_response: Some(r), + }), + None => MaybePartialCommand::Full(Command::Auth { + sasl_mech, + initial_response: None, + }), + }, + ), + // Arm 2: "AUTH" + whitespace + anything → Partial + map( + all_consuming((tag_no_case("AUTH"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "AUTH" alone → Partial + map(all_consuming(tag_no_case("AUTH")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// XCLIENT parser +// --------------------------------------------------------------------------- + +/// Decode an xtext-encoded byte slice (RFC 3461 §4). +/// +/// xtext characters are printable ASCII in `\x21`–`\x7e` where `+XX` +/// introduces a hex-encoded byte. Returns an error on a truncated or +/// invalid hex escape. +fn xtext_decode(encoded: &[u8]) -> Result { + let mut result: Vec = Vec::with_capacity(encoded.len()); + let mut i = 0; + while i < encoded.len() { + if encoded[i] == b'+' { + if i + 2 >= encoded.len() { + return Err(format!("xtext_decode: truncated hex escape at byte {i}")); + } + let hi = hex_nibble(encoded[i + 1]).map_err(|e| format!("xtext_decode: {e}"))?; + let lo = hex_nibble(encoded[i + 2]).map_err(|e| format!("xtext_decode: {e}"))?; + result.push((hi << 4) | lo); + i += 3; + } else { + result.push(encoded[i]); + i += 1; + } + } + String::from_utf8(result) + .map_err(|_| "xtext_decode: invalid UTF-8 in decoded value".to_string()) +} + +fn hex_nibble(b: u8) -> Result { + match b { + b'0'..=b'9' => Ok(b - b'0'), + b'a'..=b'f' => Ok(b - b'a' + 10), + b'A'..=b'F' => Ok(b - b'A' + 10), + _ => Err(format!("invalid hex digit '{}'", b as char)), + } +} + +/// Raw xtext value: printable non-space ASCII (`\x21`–`\x7e`), which +/// includes the `+` that introduces a hex escape. Space terminates the +/// value in the XCLIENT parameter list. +fn xclient_xtext_value(input: Span) -> IResult { + context( + "xclient-xtext-value", + take_while1(|c: u8| c >= 33 && c <= 126), + ) + .parse(input) +} + +/// One XCLIENT parameter: `name "=" xtext-value` +/// +/// The value is xtext-decoded via `map_res`; a malformed escape sequence +/// produces a recoverable nom error so `alt` can fall through to Partial. +fn xclient_param(input: Span) -> IResult { + context( + "xclient-param", + map_res( + (esmtp_keyword, tag("="), xclient_xtext_value), + |(name, _, value): (Span, _, Span)| -> Result { + let name = String::from_utf8(name.fragment().to_vec()) + .map_err(|_| "xclient_param: invalid UTF-8 in name".to_string())?; + let value = xtext_decode(value.fragment())?; + Ok(XClientParameter { name, value }) + }, + ), + ) + .parse(input) +} + +/// `xclient-params = xclient-param *(SP xclient-param)` +fn xclient_params(input: Span) -> IResult> { + context( + "xclient-params", + map( + (xclient_param, many0((wsp, xclient_param))), + |(first, rest)| { + let mut params = vec![first]; + params.extend(rest.into_iter().map(|(_, p)| p)); + params + }, + ), + ) + .parse(input) +} + +/// `xclient = "XCLIENT" SP xclient-params` +fn parse_xclient(input: Span) -> IResult { + context( + "xclient", + alt(( + // Arm 1: complete successful parse + map( + all_consuming((tag_no_case("XCLIENT"), wsp, xclient_params)), + |(_, _, params)| MaybePartialCommand::Full(Command::XClient(params)), + ), + // Arm 2: "XCLIENT" + whitespace + anything → Partial + map( + all_consuming((tag_no_case("XCLIENT"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "XCLIENT" alone → Partial + map(all_consuming(tag_no_case("XCLIENT")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `mail = "MAIL FROM:" reverse-path [SP mail-parameters]` +/// +/// Returns `Full(Command::MailFrom { … })` on a complete successful parse. +/// +/// Any failure after the `MAIL` keyword — including an invalid IPv4/IPv6 +/// address or domain name in the sender address — falls through to a +/// `Partial { verb: CommandVerb::Mail, … }` result instead of a hard error. +/// No `cut` is used anywhere in the full-parse arm so that all sub-parser +/// failures remain recoverable and `alt` can try the fallback arms. +fn parse_mail_from(input: Span) -> IResult { + context( + "mail-from", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("MAIL"), + wsp, + tag_no_case("FROM:"), + reverse_path, + opt(map((wsp, mail_parameters), |(_, p)| p)), + )), + |(_, _, _, address, parameters)| { + MaybePartialCommand::Full(Command::MailFrom { + address, + parameters: parameters.unwrap_or_default(), + }) + }, + ), + // Arm 2: "MAIL" + whitespace + anything → Partial + // This catches bad addresses (invalid IP, domain, syntax) + map( + all_consuming((tag_no_case("MAIL"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "MAIL" alone → Partial (incomplete command) + map(all_consuming(tag_no_case("MAIL")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +/// `rcpt = "RCPT TO:" forward-path [SP mail-parameters]` +/// +/// Returns `Full(Command::RcptTo { … })` on a complete successful parse. +/// +/// Any failure after the `RCPT` keyword — including an invalid IPv4/IPv6 +/// address or domain name in the recipient address — falls through to a +/// `Partial { verb: CommandVerb::Rcpt, … }` result instead of a hard error. +/// No `cut` is used anywhere in the full-parse arm so that all sub-parser +/// failures remain recoverable and `alt` can try the fallback arms. +fn parse_rcpt_to(input: Span) -> IResult { + context( + "rcpt-to", + alt(( + // Arm 1: complete successful parse + map( + all_consuming(( + tag_no_case("RCPT"), + wsp, + tag_no_case("TO:"), + forward_path, + opt(map((wsp, mail_parameters), |(_, p)| p)), + )), + |(_, _, _, address, parameters)| { + MaybePartialCommand::Full(Command::RcptTo { + address, + parameters: parameters.unwrap_or_default(), + }) + }, + ), + // Arm 2: "RCPT" + whitespace + anything → Partial + // This catches bad addresses (invalid IP, domain, syntax) + map( + all_consuming((tag_no_case("RCPT"), wsp, anything)), + |(_, _, remainder)| MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: (*remainder).into(), + }, + ), + // Arm 3: "RCPT" alone → Partial (incomplete command) + map(all_consuming(tag_no_case("RCPT")), |_| { + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: BString::default(), + } + }), + )), + ) + .parse(input) +} + +// --------------------------------------------------------------------------- +// Encoding helpers +// --------------------------------------------------------------------------- + +/// Return the lowercase hex digit character for a nibble value (0–15). +fn hex_nibble_lower(n: u8) -> u8 { + if n < 10 { + b'0' + n + } else { + b'a' + n - 10 + } +} + +/// Xtext-encode a byte slice (RFC 3461 §4). +/// +/// Bytes in the xchar range (`\x21`–`\x7e` except `+` and `=`) are copied +/// unchanged. All other byte values are encoded as `+XX` where `XX` is two +/// lowercase hex digits. +fn xtext_encode_bytes(value: &[u8]) -> Vec { + let mut result = Vec::with_capacity(value.len()); + for &b in value { + if b >= 33 && b <= 126 && b != b'+' && b != b'=' { + result.push(b); + } else { + result.push(b'+'); + result.push(hex_nibble_lower(b >> 4)); + result.push(hex_nibble_lower(b & 0x0f)); + } + } + result +} + +/// Encode a `Domain` as the ASCII text that appears in a command line. +/// +/// - `DomainName` → ASCII/punycode-normalised domain string +/// - `V4` → `[{ip}]` +/// - `V6` → `[IPv6:{ip}]` +/// - `Tagged` → `[{tag:literal}]` +fn encode_domain(domain: &Domain) -> BString { + match domain { + Domain::DomainName(s) => BString::from(s.to_string()), + Domain::V4(ip) => BString::from(format!("[{}]", ip)), + Domain::V6(ip) => BString::from(format!("[IPv6:{}]", ip)), + Domain::Tagged(s) => BString::from(format!("[{}]", s)), + } +} + +/// Encode a `MailPath` as `local-part "@" domain` bytes. +/// +/// The `at_domain_list` (source route) is intentionally **not** re-encoded: +/// RFC 5321 §4.1.2 says source routes MUST be accepted, SHOULD NOT be +/// generated, and SHOULD be ignored. +fn encode_mail_path(path: &MailPath) -> Vec { + let mut buf = path.mailbox.local_part.as_bytes().to_vec(); + buf.push(b'@'); + buf.extend_from_slice(encode_domain(&path.mailbox.domain).as_ref()); + buf +} + +/// Encode the content that goes **between** the angle brackets of +/// `MAIL FROM:<…>`. +/// +/// `NullSender` → empty (produces `MAIL FROM:<>`). +/// `Path` → `encode_mail_path` result. +fn encode_reverse_path(rp: &ReversePath) -> Vec { + match rp { + ReversePath::NullSender => vec![], + ReversePath::Path(path) => encode_mail_path(path), + } +} + +/// Encode the content that goes **between** the angle brackets of +/// `RCPT TO:<…>`. +/// +/// `Postmaster` → `b"Postmaster"`. +/// `Path` → `encode_mail_path` result. +fn encode_forward_path(fp: &ForwardPath) -> Vec { + match fp { + ForwardPath::Postmaster => b"Postmaster".to_vec(), + ForwardPath::Path(path) => encode_mail_path(path), + } +} + +/// Encode a slice of `EsmtpParameter` as the optional suffix of a +/// `MAIL FROM` or `RCPT TO` command: `*(SP keyword ["=" value])`. +/// +/// Returns an empty `Vec` when `params` is empty. +fn encode_esmtp_params(params: &[EsmtpParameter]) -> Vec { + let mut buf = Vec::new(); + for p in params { + buf.push(b' '); + buf.extend_from_slice(p.name.as_bytes()); + if let Some(v) = &p.value { + buf.push(b'='); + buf.extend_from_slice(v.as_bytes()); + } + } + buf +} + +/// Encode a slice of `XClientParameter` as `*(SP name "=" xtext-value)`. +/// +/// Each parameter value is xtext-encoded before writing. +fn encode_xclient_params(params: &[XClientParameter]) -> Vec { + let mut buf = Vec::new(); + for p in params { + buf.push(b' '); + buf.extend_from_slice(p.name.as_bytes()); + buf.push(b'='); + buf.extend(xtext_encode_bytes(p.value.as_bytes())); + } + buf } #[cfg(test)] @@ -749,674 +1912,1570 @@ mod test { use super::*; #[test] - fn parse_single_verbs() { - assert_eq!(Parser::parse_command("data").unwrap(), Command::Data,); - assert_eq!(Parser::parse_command("Quit").unwrap(), Command::Quit,); - assert_eq!(Parser::parse_command("rset").unwrap(), Command::Rset,); - } + fn test_string() { + k9::snapshot!( + BStr::new(&parse_with("hello".as_bytes(), string).unwrap()), + "hello" + ); + k9::snapshot!( + BStr::new(&parse_with("\"hello\"".as_bytes(), string).unwrap()), + "\"hello\"" + ); + k9::snapshot!( + BStr::new(&parse_with("\"hello world\"".as_bytes(), string).unwrap()), + "\"hello world\"" + ); + k9::snapshot!( + parse_with("hello world".as_bytes(), string), + r#" +Err( + "Error at line 1, in Eof: +hello world + ^_____ - #[test] - fn parse_vrfy() { - assert_eq!( - Parser::parse_command("VRFY someone").unwrap(), - Command::Vrfy("someone".to_string()) +", +) +"# ); } #[test] - fn parse_expn() { - assert_eq!( - Parser::parse_command("expn someone").unwrap(), - Command::Expn("someone".to_string()) + fn test_bogus() { + k9::snapshot!( + Command::parse("bogus"), + r#" +Ok( + Full(Command("bogus\r +")), +) +"# + ); + } + + // ------------------------------------------------------------------ + // EHLO tests + // ------------------------------------------------------------------ + + #[test] + fn test_ehlo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO example.com")), + MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) ); } #[test] - fn parse_help() { - assert_eq!(Parser::parse_command("help").unwrap(), Command::Help(None),); - assert_eq!( - Parser::parse_command("help me").unwrap(), - Command::Help(Some("me".to_string())), + fn test_ehlo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("ehlo example.com")), + MaybePartialCommand::Full(Command::Ehlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) ); } #[test] - fn parse_noop() { - assert_eq!(Parser::parse_command("noop").unwrap(), Command::Noop(None),); - assert_eq!( - Parser::parse_command("noop param").unwrap(), - Command::Noop(Some("param".to_string())), + fn test_ehlo_ipv4_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::V4("10.0.0.1".parse().unwrap()))) ); } #[test] - fn parse_ehlo() { - assert_eq!( - Parser::parse_command("EHLO there").unwrap(), - Command::Ehlo(Domain::Name("there".to_string())) - ); - assert_eq!( - Parser::parse_command("EHLO [127.0.0.1]").unwrap(), - Command::Ehlo(Domain::V4("127.0.0.1".to_string())) + fn test_ehlo_ipv6_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [IPv6:::1]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::V6("::1".parse().unwrap()))) ); } #[test] - fn parse_helo() { - assert_eq!( - Parser::parse_command("HELO there").unwrap(), - Command::Helo(Domain::Name("there".to_string())) - ); - // The spec says that we cannot use address literals with, - // HELO, but some tools will still submit it and some MTAs - // will accept it, so we do too. - assert_eq!( - Parser::parse_command("EHLO [127.0.0.1]").unwrap(), - Command::Ehlo(Domain::V4("127.0.0.1".to_string())) + fn test_ehlo_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [future:something]")), + MaybePartialCommand::Full(Command::Ehlo(Domain::Tagged("future:something".into(),))) ); } #[test] - fn parse_auth() { - assert_eq!( - Parser::parse_command("AUTH PLAIN dGVzdAB0ZXN0ADEyMzQ=").unwrap(), - Command::Auth { - sasl_mech: "PLAIN".to_string(), - initial_response: Some("dGVzdAB0ZXN0ADEyMzQ=".to_string()), + fn test_ehlo_invalid_ipv4_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [999.999.999.999]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "[999.999.999.999]".into(), } ); - assert_eq!( - Parser::parse_command("AUTH PLAIN").unwrap(), - Command::Auth { - sasl_mech: "PLAIN".to_string(), + } + + #[test] + fn test_ehlo_invalid_ipv6_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO [IPv6:not-an-ipv6]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "[IPv6:not-an-ipv6]".into(), + } + ); + } + + #[test] + fn test_ehlo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "".into(), + } + ); + } + + #[test] + fn test_ehlo_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("EHLO !!invalid!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Ehlo, + remainder: "!!invalid!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // HELO tests + // ------------------------------------------------------------------ + + #[test] + fn test_helo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("HELO example.com")), + MaybePartialCommand::Full(Command::Helo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_helo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("helo example.com")), + MaybePartialCommand::Full(Command::Helo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_helo_ipv4_literal() { + // Permissive: address literals accepted for HELO + k9::assert_equal!( + unwrapper(Command::parse("HELO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Helo(Domain::V4("10.0.0.1".parse().unwrap()))) + ); + } + + #[test] + fn test_helo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("HELO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: "".into(), + } + ); + } + + #[test] + fn test_helo_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("HELO !!invalid!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Helo, + remainder: "!!invalid!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // LHLO tests + // ------------------------------------------------------------------ + + #[test] + fn test_lhlo_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO example.com")), + MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_lhlo_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("lhlo example.com")), + MaybePartialCommand::Full(Command::Lhlo(Domain::DomainName( + "example.com".parse().unwrap() + ))) + ); + } + + #[test] + fn test_lhlo_ipv4_literal() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [10.0.0.1]")), + MaybePartialCommand::Full(Command::Lhlo(Domain::V4("10.0.0.1".parse().unwrap()))) + ); + } + + #[test] + fn test_lhlo_ipv6_literal() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [IPv6:::1]")), + MaybePartialCommand::Full(Command::Lhlo(Domain::V6("::1".parse().unwrap()))) + ); + } + + #[test] + fn test_lhlo_invalid_ipv4_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO [999.999.999.999]")), + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: "[999.999.999.999]".into(), + } + ); + } + + #[test] + fn test_lhlo_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("LHLO")), + MaybePartialCommand::Partial { + verb: CommandVerb::Lhlo, + remainder: "".into(), + } + ); + } + + // ------------------------------------------------------------------ + // AUTH tests + // ------------------------------------------------------------------ + + #[test] + fn test_auth_mechanism_only() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), initial_response: None, + }) + ); + } + + #[test] + fn test_auth_with_initial_response() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN dXNlcjpwYXNz")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), + initial_response: Some("dXNlcjpwYXNz".into()), + }) + ); + } + + #[test] + fn test_auth_empty_initial_response() { + // "=" signals an empty initial response (RFC 4954) + k9::assert_equal!( + unwrapper(Command::parse("AUTH PLAIN =")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "PLAIN".into(), + initial_response: Some("=".into()), + }) + ); + } + + #[test] + fn test_auth_hyphenated_mechanism() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH CRAM-MD5")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "CRAM-MD5".into(), + initial_response: None, + }) + ); + } + + #[test] + fn test_auth_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("auth plain")), + MaybePartialCommand::Full(Command::Auth { + sasl_mech: "plain".into(), + initial_response: None, + }) + ); + } + + #[test] + fn test_auth_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("AUTH")), + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: "".into(), } ); } #[test] - fn parse_rcpt_to_punycode() { - assert_eq!( - Parser::parse_command("Rcpt To:") - .unwrap_err() - .to_string(), - "invalid IDNA domain 4bed.xn--5dbhlacyps5bf4a.com" + fn test_auth_with_garbage_is_partial() { + // Mechanism contains invalid characters → Partial + k9::assert_equal!( + unwrapper(Command::parse("AUTH !!bad!!")), + MaybePartialCommand::Partial { + verb: CommandVerb::Auth, + remainder: "!!bad!!".into(), + } + ); + } + + // ------------------------------------------------------------------ + // XCLIENT tests + // ------------------------------------------------------------------ + + #[test] + fn test_xclient_single_param() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=foo.example.com")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "foo.example.com".into(), + }])) ); } #[test] - fn parse_rcpt_to() { - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("xn--xample-9ua.com".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "rené".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - assert_eq!( - Parser::parse_command("Rcpt To:user@host").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To: user@host").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "admin".to_string(), - domain: Domain::Tagged { - tag: "2001".to_string(), - literal: "aaaa:bbbbb".to_string() - } - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Domain::Tagged { - tag: "2001".to_string(), - literal: "aaaa:bbbbb".to_string() - } - .to_string(), - "[2001:aaaa:bbbbb]".to_string() - ); - - assert_eq!( - Parser::parse_command("Rcpt To:<\"asking for trouble\"@host.name>").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "\"asking for trouble\"".to_string(), - domain: Domain::Name("host.name".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:").unwrap(), - Command::RcptTo { - address: ForwardPath::Postmaster, - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To: woot").unwrap(), - Command::RcptTo { - address: ForwardPath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![EsmtpParameter { - name: "woot".to_string(), - value: None - }], - } - ); - - assert_eq!( - Parser::parse_command("Rcpt To:user@host woot").unwrap_err(), - "must enclose address in <> if you want to use ESMTP parameters".to_string() + fn test_xclient_multiple_params() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=foo.example.com ADDR=10.0.0.1")), + MaybePartialCommand::Full(Command::XClient(vec![ + XClientParameter { + name: "NAME".into(), + value: "foo.example.com".into(), + }, + XClientParameter { + name: "ADDR".into(), + value: "10.0.0.1".into(), + }, + ])) ); } #[test] - fn parse_mail_from() { - assert_eq!( - Parser::parse_command("Mail FROM:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], + fn test_xclient_xtext_hex_escape() { + // '+40' decodes to '@' + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=user+40example.com")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "user@example.com".into(), + }])) + ); + } + + #[test] + fn test_xclient_case_insensitive() { + k9::assert_equal!( + unwrapper(Command::parse("xclient NAME=host")), + MaybePartialCommand::Full(Command::XClient(vec![XClientParameter { + name: "NAME".into(), + value: "host".into(), + }])) + ); + } + + #[test] + fn test_xclient_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "".into(), } ); + } - assert_eq!( - Parser::parse_command("Mail FROM:user@host").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), - parameters: vec![], + #[test] + fn test_xclient_invalid_xtext_is_partial() { + // '+' not followed by two hex digits → xtext_decode fails → Partial + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT NAME=bad+ZZ")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "NAME=bad+ZZ".into(), } ); + } - assert_eq!( - Parser::parse_command("Mail FROM:user@host foo bar=baz").unwrap_err(), - "must enclose address in <> if you want to use ESMTP parameters".to_string() + #[test] + fn test_xclient_garbage_is_partial() { + // No '=' separator → xclient_param fails → Partial + k9::assert_equal!( + unwrapper(Command::parse("XCLIENT noequals")), + MaybePartialCommand::Partial { + verb: CommandVerb::XClient, + remainder: "noequals".into(), + } ); + } - assert_eq!( - Parser::parse_command("Mail FROM: foo bar=baz").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("host".to_string()) - } - }), + #[test] + fn test_xclient_parameter_methods() { + // Parse an XCLIENT command with an IP address + let cmd = unwrapper(Command::parse("XCLIENT ADDR=192.168.1.1")); + let params = match cmd { + MaybePartialCommand::Full(Command::XClient(params)) => params, + _ => panic!("Expected XCLIENT command"), + }; + + // Test is_name method + k9::assert_equal!(params[0].is_name("ADDR"), true); + k9::assert_equal!(params[0].is_name("addr"), true); + k9::assert_equal!(params[0].is_name("NAME"), false); + + // Test parse method with IpAddr + let ip: std::net::IpAddr = params[0].parse().expect("Failed to parse IP address"); + k9::assert_equal!( + ip, + std::net::IpAddr::V4(std::net::Ipv4Addr::new(192, 168, 1, 1)) + ); + } + + #[test] + fn test_xclient_parameter_parse_invalid_ip() { + // Test parsing with an invalid IP address string + let cmd = unwrapper(Command::parse("XCLIENT ADDR=not-an-ip")); + let params = match cmd { + MaybePartialCommand::Full(Command::XClient(params)) => params, + _ => panic!("Expected XCLIENT command"), + }; + + let result: Result = params[0].parse(); + k9::assert_equal!(result.unwrap_err(), "invalid IP address syntax"); + } + + // ------------------------------------------------------------------ + // MAIL FROM tests + // ------------------------------------------------------------------ + + fn mail_path(local: &str, domain: Domain) -> ReversePath { + Mailbox { + local_part: local.into(), + domain, + } + .into() + } + + #[test] + fn test_mail_from_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + // Case-insensitive verb and keyword + k9::assert_equal!( + unwrapper(Command::parse("mail from:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_null_sender() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:<>")), + MaybePartialCommand::Full(Command::MailFrom { + address: ReversePath::NullSender, + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_ipv4() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::V4("10.0.0.1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_ipv6() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::V6("::1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::Tagged("future:something".into())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_esmtp_params() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM: foo bar=baz")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), parameters: vec![ EsmtpParameter { - name: "foo".to_string(), + name: "foo".into(), value: None, }, EsmtpParameter { - name: "bar".to_string(), - value: Some("baz".to_string()), - } + name: "bar".into(), + value: Some("baz".into()), + }, ], - } + }) ); + } - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { + #[test] + fn test_mail_from_bare_address() { + // No angle brackets — accepted as a leniency + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:user@host")), + MaybePartialCommand::Full(Command::MailFrom { + address: mail_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_mail_from_at_domain_list() { + k9::assert_equal!( + unwrapper(Command::parse( + "MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>" + )), + MaybePartialCommand::Full(Command::MailFrom { address: ReversePath::Path(MailPath { - at_domain_list: vec![], + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::V4("10.0.0.1".to_string()) - } + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, }), parameters: vec![], - } + }) ); + } - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::V6("::1".to_string()) - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Mailbox { - local_part: "user".to_string(), - domain: Domain::V6("::1".to_string()) - } - .to_string(), - "user@[IPv6:::1]".to_string() - ); - - assert_eq!( - Parser::parse_command("mail from:").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Tagged { - tag: "future".to_string(), - literal: "something".to_string() - } - } - }), - parameters: vec![], - } - ); - - assert_eq!( - Parser::parse_command("MAIL FROM:<@hosta.int,@jkl.org:userc@d.bar.org>").unwrap(), - Command::MailFrom { - address: ReversePath::Path(MailPath { - at_domain_list: vec!["hosta.int".to_string(), "jkl.org".to_string()], - mailbox: Mailbox { - local_part: "userc".to_string(), - domain: Domain::Name("d.bar.org".to_string()) - } - }), - parameters: vec![], + #[test] + fn test_mail_from_invalid_ipv4_is_partial() { + // Bad IPv4 inside brackets → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "FROM:".into(), } ); } #[test] - fn parse_domain() { - assert!(is_valid_domain("hello")); - assert!(is_valid_domain("he-llo")); - assert!(is_valid_domain("he.llo")); - assert!(is_valid_domain("he.llo-")); - } - - #[test] - fn parse_xclient() { - assert_eq!( - Parser::parse_command("XCLIENT NAME=spike.porcupine.org ADDR=10.0.0.1").unwrap(), - Command::XClient(vec![ - XClientParameter { - name: "NAME".to_string(), - value: "spike.porcupine.org".to_string() - }, - XClientParameter { - name: "ADDR".to_string(), - value: "10.0.0.1".to_string() - }, - ]) - ); - } - - #[test] - fn mailbox() { - assert_eq!( - Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) + fn test_mail_from_invalid_ipv6_is_partial() { + // Bad IPv6 after "IPv6:" prefix → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("MAIL FROM:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "FROM:".into(), } - .to_string(), - "user@example.com" ); + } - let info_at = MailPath { - at_domain_list: vec![], + #[test] + fn test_mail_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "".into(), + } + ); + } + + #[test] + fn test_mail_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("MAIL garbage")), + MaybePartialCommand::Partial { + verb: CommandVerb::Mail, + remainder: "garbage".into(), + } + ); + } + + // ------------------------------------------------------------------ + // RCPT TO tests + // ------------------------------------------------------------------ + + fn rcpt_path(local: &str, domain: Domain) -> ForwardPath { + Mailbox { + local_part: local.into(), + domain, + } + .into() + } + + #[test] + fn test_rcpt_to_domain_name() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_case_insensitive() { + // Verb and keyword are case-insensitive + k9::assert_equal!( + unwrapper(Command::parse("rcpt to:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_postmaster() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_postmaster_lowercase() { + // is case-insensitive per RFC 5321 + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_ipv4() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::V4("10.0.0.1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_ipv6() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::V6("::1".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_tagged_literal() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::Tagged("future:something".into())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_esmtp_params() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO: foo bar=baz")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![ + EsmtpParameter { + name: "foo".into(), + value: None, + }, + EsmtpParameter { + name: "bar".into(), + value: Some("baz".into()), + }, + ], + }) + ); + } + + #[test] + fn test_rcpt_to_bare_address() { + // No angle brackets — accepted as a leniency + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:user@host")), + MaybePartialCommand::Full(Command::RcptTo { + address: rcpt_path("user", Domain::DomainName("host".parse().unwrap())), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_at_domain_list() { + k9::assert_equal!( + unwrapper(Command::parse( + "RCPT TO:<@hosta.int,@jkl.org:userc@d.bar.org>" + )), + MaybePartialCommand::Full(Command::RcptTo { + address: ForwardPath::Path(MailPath { + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], + mailbox: Mailbox { + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, + }), + parameters: vec![], + }) + ); + } + + #[test] + fn test_rcpt_to_invalid_ipv4_is_partial() { + // Bad IPv4 inside brackets → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "TO:".into(), + } + ); + } + + #[test] + fn test_rcpt_to_invalid_ipv6_is_partial() { + // Bad IPv6 after "IPv6:" prefix → Partial, not a hard error + k9::assert_equal!( + unwrapper(Command::parse("RCPT TO:")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "TO:".into(), + } + ); + } + + #[test] + fn test_rcpt_alone_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "".into(), + } + ); + } + + #[test] + fn test_rcpt_with_garbage_is_partial() { + k9::assert_equal!( + unwrapper(Command::parse("RCPT garbage")), + MaybePartialCommand::Partial { + verb: CommandVerb::Rcpt, + remainder: "garbage".into(), + } + ); + } + + // ------------------------------------------------------------------ + // encode / encode_str tests + // ------------------------------------------------------------------ + + /// Parse a command from a string, encode it, and assert the encoded + /// output equals `expected`. Then parse the encoded output and assert + /// the result equals the original parsed command (round-trip). + fn assert_encode(input: &str, expected: &str) { + let cmd = match unwrapper(Command::parse(input)) { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + }; + let encoded = cmd.encode(); + k9::assert_equal!(encoded, BString::from(expected)); + // Round-trip: parsing the encoded form must reproduce the command + k9::assert_equal!( + unwrapper(Command::parse(encoded.clone())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_encode_ehlo_domain() { + assert_encode("EHLO example.com", "EHLO example.com\r\n"); + } + + #[test] + fn test_encode_ehlo_ipv4() { + assert_encode("EHLO [10.0.0.1]", "EHLO [10.0.0.1]\r\n"); + } + + #[test] + fn test_encode_ehlo_ipv6() { + assert_encode("EHLO [IPv6:::1]", "EHLO [IPv6:::1]\r\n"); + } + + #[test] + fn test_encode_ehlo_tagged() { + assert_encode("EHLO [future:something]", "EHLO [future:something]\r\n"); + } + + #[test] + fn test_encode_helo() { + assert_encode("HELO mail.example.com", "HELO mail.example.com\r\n"); + } + + #[test] + fn test_encode_lhlo() { + assert_encode("LHLO mail.example.com", "LHLO mail.example.com\r\n"); + } + + #[test] + fn test_encode_noop_none() { + assert_encode("NOOP", "NOOP\r\n"); + } + + #[test] + fn test_encode_noop_some() { + assert_encode("NOOP something", "NOOP something\r\n"); + } + + #[test] + fn test_encode_help_none() { + assert_encode("HELP", "HELP\r\n"); + } + + #[test] + fn test_encode_help_some() { + assert_encode("HELP MAIL", "HELP MAIL\r\n"); + } + + #[test] + fn test_encode_vrfy_none() { + assert_encode("VRFY", "VRFY\r\n"); + } + + #[test] + fn test_encode_vrfy_some() { + assert_encode("VRFY user", "VRFY user\r\n"); + } + + #[test] + fn test_encode_expn_none() { + assert_encode("EXPN", "EXPN\r\n"); + } + + #[test] + fn test_encode_expn_some() { + assert_encode("EXPN list", "EXPN list\r\n"); + } + + #[test] + fn test_encode_data() { + assert_encode("DATA", "DATA\r\n"); + } + + #[test] + fn test_encode_data_dot() { + // DataDot is never parsed — it is constructed programmatically. + // Verify it encodes to exactly ".\r\n" (not ".\r\n\r\n"). + k9::assert_equal!(Command::DataDot.encode(), BString::from(".\r\n")); + } + + #[test] + fn test_encode_rset() { + assert_encode("RSET", "RSET\r\n"); + } + + #[test] + fn test_encode_quit() { + assert_encode("QUIT", "QUIT\r\n"); + } + + #[test] + fn test_encode_starttls() { + assert_encode("STARTTLS", "STARTTLS\r\n"); + } + + #[test] + fn test_encode_mail_from_path() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_mail_from_null_sender() { + assert_encode("MAIL FROM:<>", "MAIL FROM:<>\r\n"); + } + + #[test] + fn test_encode_mail_from_params() { + assert_encode( + "MAIL FROM: SIZE=1000 BODY=8BITMIME", + "MAIL FROM: SIZE=1000 BODY=8BITMIME\r\n", + ); + } + + #[test] + fn test_encode_mail_from_ipv4() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_mail_from_ipv6() { + assert_encode( + "MAIL FROM:", + "MAIL FROM:\r\n", + ); + } + + #[test] + fn test_encode_rcpt_to_path() { + assert_encode( + "RCPT TO:", + "RCPT TO:\r\n", + ); + } + + #[test] + fn test_encode_rcpt_to_postmaster() { + // Canonical encoding is capital-P Postmaster; parsing is case-insensitive + let cmd = Command::RcptTo { + address: ForwardPath::Postmaster, + parameters: vec![], + }; + k9::assert_equal!(cmd.encode(), BString::from("RCPT TO:\r\n")); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_encode_rcpt_to_params() { + assert_encode( + "RCPT TO: NOTIFY=SUCCESS", + "RCPT TO: NOTIFY=SUCCESS\r\n", + ); + } + + #[test] + fn test_encode_auth_no_response() { + assert_encode("AUTH PLAIN", "AUTH PLAIN\r\n"); + } + + #[test] + fn test_encode_auth_with_response() { + assert_encode("AUTH PLAIN dXNlcjpwYXNz", "AUTH PLAIN dXNlcjpwYXNz\r\n"); + } + + #[test] + fn test_encode_xclient_single() { + assert_encode( + "XCLIENT NAME=foo.example.com", + "XCLIENT NAME=foo.example.com\r\n", + ); + } + + #[test] + fn test_encode_xclient_multiple() { + assert_encode( + "XCLIENT NAME=foo.example.com ADDR=10.0.0.1", + "XCLIENT NAME=foo.example.com ADDR=10.0.0.1\r\n", + ); + } + + #[test] + fn test_encode_xclient_xtext_roundtrip() { + // '+40' in wire form decodes to '@'. + // '@' (ASCII 64) is a valid xchar (range 33-126, excl. '+' and '='), + // so it is NOT re-encoded as '+40' — it passes through unchanged. + assert_encode( + "XCLIENT NAME=user+40example.com", + "XCLIENT NAME=user@example.com\r\n", + ); + } + + #[test] + fn test_encode_unknown() { + assert_encode("FOOBAR some args", "FOOBAR some args\r\n"); + } + + #[test] + fn test_encode_unknown_bare_verb() { + assert_encode("FOOBAR", "FOOBAR\r\n"); + } + + #[test] + fn test_encode_mail_from_source_route_dropped() { + // A command parsed with a non-empty at_domain_list encodes without the + // source route (RFC 5321 says SHOULD NOT generate). The re-parsed + // result therefore has an empty at_domain_list. + let parsed = unwrapper(Command::parse("MAIL FROM:<@route.example.com:user@host>")); + let cmd = match parsed { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + }; + let encoded = cmd.encode(); + k9::assert_equal!(encoded, BString::from("MAIL FROM:\r\n")); + // Re-parsing gives back the same mailbox but with an empty source route + let expected = Mailbox { + local_part: "user".into(), + domain: Domain::DomainName("host".parse().unwrap()), + }; + k9::assert_equal!( + unwrapper(Command::parse(encoded)), + MaybePartialCommand::Full(Command::MailFrom { + address: expected.into(), + parameters: vec![], + }) + ); + } + + // ------------------------------------------------------------------ + // RFC 6531 / non-ASCII tests + // ------------------------------------------------------------------ + + /// Helper: parse a Full command from a known-good string. + fn parse_full(input: &str) -> Command { + match unwrapper(Command::parse(input)) { + MaybePartialCommand::Full(c) => c, + other => panic!("expected Full, got {other:?}"), + } + } + + // --- Non-ASCII local parts --- + + #[test] + fn test_mail_from_utf8_local_part() { + // ü = U+00FC, UTF-8 encoding [0xc3, 0xbc] + let cmd = parse_full("MAIL FROM:<ü@example.com>"); + let mailbox = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(mailbox.local_part, String::from("ü")); + k9::assert_equal!( + mailbox.domain, + Domain::DomainName("example.com".parse().unwrap()) + ); + } + + #[test] + fn test_mail_from_utf8_local_part_roundtrip() { + // Non-ASCII local parts survive encode → parse unchanged because + // encode_mailbox copies the raw bytes and parse stores them verbatim. + let input = "MAIL FROM:<ü@example.com>"; + let cmd = parse_full(input); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(cmd) + ); + } + + #[test] + fn test_mail_from_quoted_utf8_local_part() { + // UTF-8 characters are valid inside a quoted-string local part (RFC 6532). + let cmd = parse_full("MAIL FROM:<\"ü\"@example.com>"); + let mailbox = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + // local_part stores the string including the surrounding quotes + k9::assert_equal!(mailbox.local_part, String::from("\"ü\"")); + } + + #[test] + fn test_rcpt_to_utf8_local_part() { + // CJK characters in the local part (RFC 6531 EAI) + let cmd = parse_full("RCPT TO:<用户@example.com>"); + let mailbox = match cmd { + Command::RcptTo { + address: ForwardPath::Path(path), + .. + } => path.mailbox, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(mailbox.local_part, String::from("用户")); + } + + // --- U-label (non-ASCII) domain names --- + + #[test] + fn test_mail_from_u_label_domain() { + // münchen.de — stored as normalized ASCII/punycode form + let cmd = parse_full("MAIL FROM:"); + let domain = match cmd { + Command::MailFrom { + address: ReversePath::Path(path), + .. + } => path.mailbox.domain, + other => panic!("unexpected {other:?}"), + }; + match domain { + Domain::DomainName(ref s) => { + k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de"); + } + other => panic!("unexpected domain variant {other:?}"), + } + } + + #[test] + fn test_mail_from_u_label_domain_encode() { + // encode_domain normalises U-labels to their ASCII/punycode form. + // This is intentional: punycode is always safe for wire transmission. + let cmd = parse_full("MAIL FROM:"); + k9::assert_equal!( + cmd.encode(), + BString::from("MAIL FROM:\r\n") + ); + } + + #[test] + fn test_ehlo_u_label_domain() { + // DomainString::as_str() returns the normalized ASCII/punycode form + let cmd = parse_full("EHLO münchen.de"); + match cmd { + Command::Ehlo(Domain::DomainName(ref s)) => { + k9::assert_equal!(s.as_str(), "xn--mnchen-3ya.de"); + } + other => panic!("unexpected {other:?}"), + } + } + + #[test] + fn test_ehlo_u_label_domain_encode() { + // encode_domain normalises to punycode for EHLO as well + let cmd = parse_full("EHLO münchen.de"); + k9::assert_equal!(cmd.encode(), BString::from("EHLO xn--mnchen-3ya.de\r\n")); + } + + // --- Non-ASCII ESMTP values (RFC 6531 §3.3: esmtp-value =/ UTF8-non-ASCII) --- + + #[test] + fn test_esmtp_value_utf8() { + // A parameter value containing a non-ASCII UTF-8 character (ü = U+00FC) + let cmd = parse_full("MAIL FROM: PARAM=valüe"); + let params = match cmd { + Command::MailFrom { parameters, .. } => parameters, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(params.len(), 1); + k9::assert_equal!(params[0].name, "PARAM"); + k9::assert_equal!(params[0].value, Some("valüe".to_string())); + } + + #[test] + fn test_esmtp_value_utf8_roundtrip() { + // Non-ASCII ESMTP values are stored and re-encoded verbatim (raw bytes). + let input = "MAIL FROM: PARAM=valüe"; + let cmd = parse_full(input); + k9::assert_equal!( + cmd.encode(), + BString::from("MAIL FROM: PARAM=valüe\r\n") + ); + k9::assert_equal!( + unwrapper(Command::parse(cmd.encode())), + MaybePartialCommand::Full(parse_full(input)) + ); + } + + #[test] + fn test_esmtp_value_utf8_only() { + // A value consisting entirely of non-ASCII UTF-8 characters parses successfully + let cmd = parse_full("MAIL FROM: X=ünïcödé"); + let params = match cmd { + Command::MailFrom { parameters, .. } => parameters, + other => panic!("unexpected {other:?}"), + }; + k9::assert_equal!(params[0].name, "X"); + k9::assert_equal!(params[0].value, Some("ünïcödé".to_string())); + } + + // --- Fallible conversion tests for MailPath --- + + #[test] + fn test_reverse_path_null_sender_try_into_mailpath_err() { + use core::convert::TryInto; + let null_sender = ReversePath::NullSender; + let err: &'static str = + >::try_into(null_sender).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to MailPath"); + } + + #[test] + fn test_forward_path_postmaster_try_into_mailpath_err() { + use core::convert::TryInto; + let postmaster = ForwardPath::Postmaster; + let err: &'static str = + >::try_into(postmaster).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + #[test] + fn test_reverse_path_try_from_null_sender_err() { + let null_sender = ReversePath::NullSender; + let err = MailPath::try_from(null_sender).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to MailPath"); + } + + #[test] + fn test_forward_path_try_from_postmaster_err() { + let postmaster = ForwardPath::Postmaster; + let err = MailPath::try_from(postmaster).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + // --- Fallible conversion tests for EnvelopeAddress --- + + #[test] + fn test_envelope_address_try_from_mailbox() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let addr = EnvelopeAddress::from(mailbox); + match addr { + EnvelopeAddress::Path(path) => { + k9::assert_equal!(path.mailbox.local_part(), "user"); + k9::assert_equal!( + path.mailbox.domain, + Domain::DomainName("example.com".parse().unwrap()) + ); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_envelope_address_try_from_null_err() { + let addr = EnvelopeAddress::Null; + let err = Mailbox::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Null to Mailbox"); + } + + #[test] + fn test_envelope_address_try_from_postmaster_err() { + let addr = EnvelopeAddress::Postmaster; + let err = Mailbox::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to Mailbox"); + } + + #[test] + fn test_envelope_address_try_from_null_to_reverse_path() { + let addr = EnvelopeAddress::Null; + let result = ReversePath::try_from(addr).unwrap(); + k9::assert_equal!(result, ReversePath::NullSender); + } + + #[test] + fn test_envelope_address_try_from_postmaster_to_forward_path() { + let addr = EnvelopeAddress::Postmaster; + let result = ForwardPath::try_from(addr).unwrap(); + k9::assert_equal!(result, ForwardPath::Postmaster); + } + + // --- Fallible conversion tests for MailPath --- + + #[test] + fn test_envelope_address_try_into_mailpath_null_err() { + let addr = EnvelopeAddress::Null; + let err = MailPath::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Null to MailPath"); + } + + #[test] + fn test_envelope_address_try_into_mailpath_postmaster_err() { + let addr = EnvelopeAddress::Postmaster; + let err = MailPath::try_from(addr).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to MailPath"); + } + + #[test] + fn test_reverse_path_try_into_forward_path_null_sender_err() { + let rp = ReversePath::NullSender; + let err = ForwardPath::try_from(rp).unwrap_err(); + k9::assert_equal!(err, "Cannot convert NullSender to ForwardPath"); + } + + #[test] + fn test_forward_path_try_into_reverse_path_postmaster_err() { + let fp = ForwardPath::Postmaster; + let err = ReversePath::try_from(fp).unwrap_err(); + k9::assert_equal!(err, "Cannot convert Postmaster to ReversePath"); + } + + // --- Infallible conversion tests for Mailbox --- + + #[test] + fn test_mailbox_into_mailpath() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + assert!(path.at_domain_list.is_empty()); + k9::assert_equal!(path.mailbox.local_part(), "user"); + } + + #[test] + fn test_mailbox_into_envelope_address() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let addr: EnvelopeAddress = mailbox.into(); + match addr { + EnvelopeAddress::Path(path) => { + k9::assert_equal!(path.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_mailbox_into_reverse_path() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: ReversePath = mailbox.into(); + match path { + ReversePath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + #[test] + fn test_mailbox_into_forward_path() { + let mailbox = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: ForwardPath = mailbox.into(); + match path { + ForwardPath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } + + // --- Round-trip conversion tests --- + + #[test] + fn test_mailbox_roundtrip() { + let original = Mailbox { + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = original.clone().into(); + let mailbox: Mailbox = path.mailbox.into(); + k9::assert_equal!(original.local_part(), mailbox.local_part()); + k9::assert_equal!(original.domain, mailbox.domain); + } + + #[test] + fn test_reverse_path_to_forward_path() { + let path = MailPath { + at_domain_list: vec!["route.com".into()], mailbox: Mailbox { - local_part: "\"info@\"".to_string(), - domain: Domain::Name("example.com".to_string()), + local_part: String::from("user"), + domain: Domain::DomainName("example.com".parse().unwrap()), }, }; - assert_eq!( - EnvelopeAddress::from_str("\"info@\"@example.com").unwrap(), - EnvelopeAddress::Path(info_at.clone()) - ); - assert_eq!(info_at.to_string(), "\"info@\"@example.com"); + let rp = ReversePath::Path(path.clone()); + let fp: ForwardPath = rp.try_into().unwrap(); + match fp { + ForwardPath::Path(p) => { + k9::assert_equal!(p.mailbox.local_part(), "user"); + } + _ => panic!("Expected Path variant"), + } + } - assert_eq!( - EnvelopeAddress::from_str("postmaster").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - EnvelopeAddress::from_str("").unwrap(), - EnvelopeAddress::Null - ); + // --- Debug tests for MailPath --- - assert_eq!(EnvelopeAddress::Null.to_string(), ""); - assert_eq!(EnvelopeAddress::Postmaster.to_string(), "postmaster"); - assert_eq!( - EnvelopeAddress::Path(info_at).to_string(), - "\"info@\"@example.com" - ); - - // Equality is based on normalized local part - let foo1 = EnvelopeAddress::from_str("foo@example.com").unwrap(); - let foo2 = EnvelopeAddress::from_str("\"foo\"@example.com").unwrap(); - let foo3 = EnvelopeAddress::from_str("\"f\\oo\"@example.com").unwrap(); - - assert_eq!(foo1, foo2); - assert_eq!(foo2, foo3); - assert_eq!(foo1, foo3); - - let EnvelopeAddress::Path(foo3) = foo3 else { - panic!("Should be a Path"); + #[test] + fn test_mailpath_debug_simple() { + let mailbox = Mailbox { + local_part: "someone".into(), + domain: Domain::DomainName("example.com".parse().unwrap()), }; - - assert_eq!(foo3.mailbox.local_part(), "foo"); - - // Unbalanced quotes are illegal and don't parse - EnvelopeAddress::from_str("\"foo@example.com").unwrap_err(); + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + k9::assert_equal!(debug_str, r#"MailPath("someone@example.com")"#); } #[test] - fn parse_envelope_address_with_angles() { - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) + fn test_mailpath_debug_with_at_domain_list() { + let path = MailPath { + at_domain_list: vec!["route.example.com".into()], + mailbox: Mailbox { + local_part: "user".into(), + domain: Domain::DomainName("host".parse().unwrap()), + }, + }; + let debug_str = format!("{:?}", path); + k9::assert_equal!(debug_str, r#"MailPath("@route.example.com:user@host")"#); + } + + #[test] + fn test_mailpath_debug_multiple_at_domains() { + let path = MailPath { + at_domain_list: vec!["hosta.int".into(), "jkl.org".into()], + mailbox: Mailbox { + local_part: "userc".into(), + domain: Domain::DomainName("d.bar.org".parse().unwrap()), + }, + }; + let debug_str = format!("{:?}", path); + k9::assert_equal!( + debug_str, + r#"MailPath("@hosta.int,@jkl.org:userc@d.bar.org")"# ); } #[test] - fn parse_envelope_address_without_angles() { - assert_eq!( - parse_envelope_address("user@example.com").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "user".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) - ); + fn test_mailpath_debug_non_ascii_utf8() { + // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8 + // should be preserved as-is in debug output + let mailbox = Mailbox { + local_part: "üser".into(), + domain: Domain::DomainName("example.com".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // ü is valid UTF-8, should appear as-is + k9::assert_equal!(debug_str, r#"MailPath("üser@example.com")"#); } #[test] - fn parse_envelope_address_postmaster_with_angles() { - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - parse_envelope_address("").unwrap(), - EnvelopeAddress::Postmaster - ); + fn test_mailpath_debug_with_ipv4() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::V4("10.0.0.1".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // IPv4 literals must be in brackets per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[10.0.0.1]")"#); } #[test] - fn parse_envelope_address_postmaster_no_angles() { - assert_eq!( - parse_envelope_address("postmaster").unwrap(), - EnvelopeAddress::Postmaster - ); - assert_eq!( - parse_envelope_address("PostMaster").unwrap(), - EnvelopeAddress::Postmaster - ); + fn test_mailpath_debug_with_ipv6() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::V6("::1".parse().unwrap()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // IPv6 literals must be in brackets with IPv6: prefix per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[IPv6:::1]")"#); } #[test] - fn parse_envelope_address_postmaster_with_domain() { - assert_eq!( - parse_envelope_address("postmaster@example.com").unwrap(), - EnvelopeAddress::Path(MailPath { - at_domain_list: vec![], - mailbox: Mailbox { - local_part: "postmaster".to_string(), - domain: Domain::Name("example.com".to_string()) - } - }) - ); + fn test_mailpath_debug_with_tagged_literal() { + let mailbox = Mailbox { + local_part: "user".into(), + domain: Domain::Tagged("future:something".into()), + }; + let path: MailPath = mailbox.into(); + let debug_str = format!("{:?}", path); + // Tagged literals must be in brackets per RFC 5321 + k9::assert_equal!(debug_str, r#"MailPath("user@[future:something]")"#); + } + #[test] + fn test_envelope_address_debug_null() { + let addr = EnvelopeAddress::Null; + let debug_str = format!("{:?}", addr); + k9::assert_equal!(debug_str, "<>"); } #[test] - fn parse_envelope_address_null_sender() { - assert_eq!(parse_envelope_address("<>").unwrap(), EnvelopeAddress::Null); + fn test_envelope_address_debug_postmaster() { + let addr = EnvelopeAddress::Postmaster; + let debug_str = format!("{:?}", addr); + k9::assert_equal!(debug_str, ""); } #[test] - fn parse_envelope_address_null_sender_no_angles() { - assert_eq!(parse_envelope_address("").unwrap(), EnvelopeAddress::Null); + fn test_envelope_address_debug_path_with_non_ascii_utf8() { + // UTF-8 character ü (U+00FC) which is non-ASCII but valid UTF-8 + // should be preserved as-is in debug output + let path = MailPath { + at_domain_list: vec!["example.com".into()], + mailbox: Mailbox { + local_part: String::from("üser"), + domain: Domain::DomainName("example.com".parse().unwrap()), + }, + }; + let addr = EnvelopeAddress::Path(path); + let debug_str = format!("{:?}", addr); + // ü is valid UTF-8, should appear as-is + k9::assert_equal!(debug_str, r#"<@example.com:üser@example.com>"#); } } - -/* ABNF from RFC 5321 - -mail = "MAIL FROM:" Reverse-path [SP Mail-parameters] CRLF - -rcpt = "RCPT TO:" ( "" / "" / - Forward-path ) [SP Rcpt-parameters] CRLF - - Note that, in a departure from the usual rules for - local-parts, the "Postmaster" string shown above is - treated as case-insensitive. - -Reverse-path = Path / "<>" -Forward-path = Path -Path = "<" [ A-d-l ":" ] Mailbox ">" -A-d-l = At-domain *( "," At-domain ) - ; Note that this form, the so-called "source - ; route", MUST BE accepted, SHOULD NOT be - ; generated, and SHOULD be ignored. -At-domain = "@" Domain -Mail-parameters = esmtp-param *(SP esmtp-param) - - Rcpt-parameters = esmtp-param *(SP esmtp-param) - - esmtp-param = esmtp-keyword ["=" esmtp-value] - - esmtp-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") - - esmtp-value = 1*(%d33-60 / %d62-126) - ; any CHAR excluding "=", SP, and control - ; characters. If this string is an email address, - ; i.e., a Mailbox, then the "xtext" syntax [32] - ; SHOULD be used. - - Keyword = Ldh-str - - Argument = Atom - - Domain = sub-domain *("." sub-domain) - sub-domain = Let-dig [Ldh-str] - - Let-dig = ALPHA / DIGIT - - Ldh-str = *( ALPHA / DIGIT / "-" ) Let-dig - - address-literal = "[" ( IPv4-address-literal / - IPv6-address-literal / - General-address-literal ) "]" - ; See Section 4.1.3 - - Mailbox = Local-part "@" ( Domain / address-literal ) - - Local-part = Dot-string / Quoted-string - ; MAY be case-sensitive - - - Dot-string = Atom *("." Atom) - - Atom = 1*atext - - Quoted-string = DQUOTE *QcontentSMTP DQUOTE - - QcontentSMTP = qtextSMTP / quoted-pairSMTP - - quoted-pairSMTP = %d92 %d32-126 - ; i.e., backslash followed by any ASCII - ; graphic (including itself) or SPace - - qtextSMTP = %d32-33 / %d35-91 / %d93-126 - ; i.e., within a quoted string, any - ; ASCII graphic or space is permitted - ; without blackslash-quoting except - ; double-quote and the backslash itself. - - String = Atom / Quoted-string - - - IPv4-address-literal = Snum 3("." Snum) - - IPv6-address-literal = "IPv6:" IPv6-addr - - General-address-literal = Standardized-tag ":" 1*dcontent - - Standardized-tag = Ldh-str - ; Standardized-tag MUST be specified in a - ; Standards-Track RFC and registered with IANA - - - dcontent = %d33-90 / ; Printable US-ASCII - %d94-126 ; excl. "[", "\", "]" - - Snum = 1*3DIGIT - ; representing a decimal integer - ; value in the range 0 through 255 - - IPv6-addr = IPv6-full / IPv6-comp / IPv6v4-full / IPv6v4-comp - - IPv6-hex = 1*4HEXDIG - - IPv6-full = IPv6-hex 7(":" IPv6-hex) - - IPv6-comp = [IPv6-hex *5(":" IPv6-hex)] "::" - [IPv6-hex *5(":" IPv6-hex)] - ; The "::" represents at least 2 16-bit groups of - ; zeros. No more than 6 groups in addition to the - ; "::" may be present. - - IPv6v4-full = IPv6-hex 5(":" IPv6-hex) ":" IPv4-address-literal - - IPv6v4-comp = [IPv6-hex *3(":" IPv6-hex)] "::" - [IPv6-hex *3(":" IPv6-hex) ":"] - IPv4-address-literal - ; The "::" represents at least 2 16-bit groups of - ; zeros. No more than 4 groups in addition to the - ; "::" and IPv4-address-literal may be present. - - - ehlo = "EHLO" SP ( Domain / address-literal ) CRLF - helo = "HELO" SP Domain CRLF - - ehlo-ok-rsp = ( "250" SP Domain [ SP ehlo-greet ] CRLF ) - / ( "250-" Domain [ SP ehlo-greet ] CRLF - *( "250-" ehlo-line CRLF ) - "250" SP ehlo-line CRLF ) - - ehlo-greet = 1*(%d0-9 / %d11-12 / %d14-127) - ; string of any characters other than CR or LF - - ehlo-line = ehlo-keyword *( SP ehlo-param ) - - ehlo-keyword = (ALPHA / DIGIT) *(ALPHA / DIGIT / "-") - ; additional syntax of ehlo-params depends on - ; ehlo-keyword - - ehlo-param = 1*(%d33-126) - ; any CHAR excluding and all - ; control characters (US-ASCII 0-31 and 127 - ; inclusive) - - data = "DATA" CRLF - rset = "RSET" CRLF - vrfy = "VRFY" SP String CRLF - expn = "EXPN" SP String CRLF - help = "HELP" [ SP String ] CRLF - noop = "NOOP" [ SP String ] CRLF -quit = "QUIT" CRLF - - -*/ diff --git a/crates/rfc5321/src/rfc5321.pest b/crates/rfc5321/src/rfc5321.pest deleted file mode 100644 index 68ca0089..00000000 --- a/crates/rfc5321/src/rfc5321.pest +++ /dev/null @@ -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_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 } diff --git a/crates/traffic-gen/src/main.rs b/crates/traffic-gen/src/main.rs index 1a87d9de..e03f6249 100644 --- a/crates/traffic-gen/src/main.rs +++ b/crates/traffic-gen/src/main.rs @@ -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; diff --git a/crates/tsa-daemon/src/http_server.rs b/crates/tsa-daemon/src/http_server.rs index 32098155..0d6b3499 100644 --- a/crates/tsa-daemon/src/http_server.rs +++ b/crates/tsa-daemon/src/http_server.rs @@ -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;