From b84dcca0ab4d9952019f6b122aeb11fee0a42c65 Mon Sep 17 00:00:00 2001 From: Damian Poddebniak Date: Mon, 4 Dec 2023 17:45:08 +0100 Subject: [PATCH] feat: Implement `ID` (RFC 2971) --- imap-codec/Cargo.toml | 1 + imap-codec/fuzz/Cargo.toml | 2 + imap-codec/src/codec/encode.rs | 40 ++++++++++ imap-codec/src/command.rs | 12 ++- imap-codec/src/extensions.rs | 2 + imap-codec/src/extensions/id.rs | 135 ++++++++++++++++++++++++++++++++ imap-codec/src/response.rs | 15 +++- imap-types/Cargo.toml | 1 + imap-types/src/command.rs | 11 +++ imap-types/src/response.rs | 9 +++ 10 files changed, 224 insertions(+), 4 deletions(-) create mode 100644 imap-codec/src/extensions/id.rs diff --git a/imap-codec/Cargo.toml b/imap-codec/Cargo.toml index 385ca977..c4eebe49 100644 --- a/imap-codec/Cargo.toml +++ b/imap-codec/Cargo.toml @@ -24,6 +24,7 @@ starttls = ["imap-types/starttls"] ext_condstore_qresync = ["imap-types/ext_condstore_qresync"] ext_login_referrals = ["imap-types/ext_login_referrals"] ext_mailbox_referrals = ["imap-types/ext_mailbox_referrals"] +ext_id = ["imap-types/ext_id"] # # IMAP quirks diff --git a/imap-codec/fuzz/Cargo.toml b/imap-codec/fuzz/Cargo.toml index b351df12..6459a58e 100644 --- a/imap-codec/fuzz/Cargo.toml +++ b/imap-codec/fuzz/Cargo.toml @@ -18,6 +18,7 @@ starttls = ["imap-codec/starttls"] ext_condstore_qresync = ["imap-codec/ext_condstore_qresync"] ext_login_referrals = ["imap-codec/ext_login_referrals"] ext_mailbox_referrals = ["imap-codec/ext_mailbox_referrals"] +ext_id = ["imap-codec/ext_id"] # IMAP quirks quirk_crlf_relaxed = ["imap-codec/quirk_crlf_relaxed"] @@ -29,6 +30,7 @@ ext = [ "ext_condstore_qresync", #"ext_login_referrals", #"ext_mailbox_referrals", + "ext_id", ] # Enable `Debug`-printing during parsing. This is useful to analyze crashes. debug = [] diff --git a/imap-codec/src/codec/encode.rs b/imap-codec/src/codec/encode.rs index b459713a..a70efd53 100644 --- a/imap-codec/src/codec/encode.rs +++ b/imap-codec/src/codec/encode.rs @@ -543,6 +543,26 @@ impl<'a> EncodeIntoContext for CommandBody<'a> { ctx.write_all(b" ")?; mailbox.encode_ctx(ctx) } + #[cfg(feature = "ext_id")] + CommandBody::Id { parameters } => { + if let Some((first, tail)) = parameters.split_first() { + ctx.write_all(b"ID (")?; + first.0.encode_ctx(ctx)?; + ctx.write_all(b" ")?; + first.1.encode_ctx(ctx)?; + + for parameter in tail { + ctx.write_all(b" ")?; + parameter.0.encode_ctx(ctx)?; + ctx.write_all(b" ")?; + parameter.1.encode_ctx(ctx)?; + } + + ctx.write_all(b")") + } else { + ctx.write_all(b"ID NIL") + } + } } } } @@ -1201,6 +1221,26 @@ impl<'a> EncodeIntoContext for Data<'a> { root.encode_ctx(ctx)?; } } + #[cfg(feature = "ext_id")] + Data::Id { parameters } => { + if let Some((first, tail)) = parameters.split_first() { + ctx.write_all(b"* ID (")?; + first.0.encode_ctx(ctx)?; + ctx.write_all(b" ")?; + first.1.encode_ctx(ctx)?; + + for parameter in tail { + ctx.write_all(b" ")?; + parameter.0.encode_ctx(ctx)?; + ctx.write_all(b" ")?; + parameter.1.encode_ctx(ctx)?; + } + + ctx.write_all(b")")?; + } else { + ctx.write_all(b"* ID NIL")?; + } + } } ctx.write_all(b"\r\n") diff --git a/imap-codec/src/command.rs b/imap-codec/src/command.rs index 2207d348..8f6435b5 100644 --- a/imap-codec/src/command.rs +++ b/imap-codec/src/command.rs @@ -21,6 +21,8 @@ use nom::{ sequence::{delimited, preceded, terminated, tuple}, }; +#[cfg(feature = "ext_id")] +use crate::extensions::id::id; use crate::{ auth::auth_type, core::{astring, base64, literal, tag_imap}, @@ -79,7 +81,13 @@ pub(crate) fn command(input: &[u8]) -> IMAPResult<&[u8], Command> { // # Command Any -/// `command-any = "CAPABILITY" / "LOGOUT" / "NOOP" / x-command` +/// ```abnf +/// command-any = "CAPABILITY" / +/// "LOGOUT" / +/// "NOOP" / +/// x-command / +/// id ; adds id command to command_any (See RFC 2971) +/// ``` /// /// Note: Valid in all states pub(crate) fn command_any(input: &[u8]) -> IMAPResult<&[u8], CommandBody> { @@ -88,6 +96,8 @@ pub(crate) fn command_any(input: &[u8]) -> IMAPResult<&[u8], CommandBody> { value(CommandBody::Logout, tag_no_case(b"LOGOUT")), value(CommandBody::Noop, tag_no_case(b"NOOP")), // x-command = "X" atom + #[cfg(feature = "ext_id")] + map(id, |parameters| CommandBody::Id { parameters }), ))(input) } diff --git a/imap-codec/src/extensions.rs b/imap-codec/src/extensions.rs index e7792ff0..c8ba3ea8 100644 --- a/imap-codec/src/extensions.rs +++ b/imap-codec/src/extensions.rs @@ -1,5 +1,7 @@ pub mod compress; pub mod enable; +#[cfg(feature = "ext_id")] +pub mod id; pub mod idle; pub mod literal; pub mod r#move; diff --git a/imap-codec/src/extensions/id.rs b/imap-codec/src/extensions/id.rs new file mode 100644 index 00000000..7f5785c5 --- /dev/null +++ b/imap-codec/src/extensions/id.rs @@ -0,0 +1,135 @@ +//! IMAP4 ID extension + +// Additional changes: +// +// command_any ::= "CAPABILITY" / "LOGOUT" / "NOOP" / x_command / id +// response_data ::= "*" SPACE (resp_cond_state / resp_cond_bye / mailbox_data / message_data / capability_data / id_response) + +use abnf_core::streaming::sp; +use imap_types::core::{IString, NString}; +use nom::{ + branch::alt, + bytes::streaming::{tag, tag_no_case}, + combinator::value, + multi::separated_list0, + sequence::{delimited, preceded, separated_pair}, +}; + +use crate::{ + core::{nil, nstring, string}, + decode::IMAPResult, +}; + +/// ```abnf_old +/// id ::= "ID" SPACE id_params_list +/// ``` +pub(crate) fn id(input: &[u8]) -> IMAPResult<&[u8], Vec<(IString, NString)>> { + preceded(tag_no_case("ID "), id_params_list)(input) +} + +/// ```abnf_olf +/// id_response ::= "ID" SPACE id_params_list +/// ``` +#[inline] +pub(crate) fn id_response(input: &[u8]) -> IMAPResult<&[u8], Vec<(IString, NString)>> { + id(input) +} + +/// ```abnf_old +/// id_params_list = "(" #(string SPACE nstring) ")" / nil +/// ``` +/// +/// Note: The ABNF above is non-standard. According to the examples, `#` likely means "separated by whitespace". +/// I'm not sure if `#` means `*` or `1*`, though. However, we can bypass this uncertainty by accepting both. +pub(crate) fn id_params_list(input: &[u8]) -> IMAPResult<&[u8], Vec<(IString, NString)>> { + alt(( + delimited( + tag("("), + separated_list0(sp, separated_pair(string, sp, nstring)), + tag(")"), + ), + value(vec![], nil), + ))(input) +} + +#[cfg(test)] +mod tests { + use imap_types::{ + command::{Command, CommandBody}, + core::{IString, NString}, + response::{Data, Response}, + }; + + use super::*; + use crate::testing::{kat_inverse_command, kat_inverse_response}; + + #[test] + fn test_parse_id() { + let got = id(b"id (\"name\" \"imap-codec\")\r\n").unwrap().1; + assert_eq!( + vec![( + IString::try_from("name").unwrap(), + NString::try_from("imap-codec").unwrap() + )], + got + ); + } + + #[test] + fn test_kat_inverse_command_id() { + kat_inverse_command(&[ + ( + b"A ID nil\r\n".as_ref(), + b"".as_ref(), + Command::new("A", CommandBody::Id { parameters: vec![] }).unwrap(), + ), + ( + b"A ID NIL\r\n".as_ref(), + b"".as_ref(), + Command::new("A", CommandBody::Id { parameters: vec![] }).unwrap(), + ), + ( + b"A ID ()\r\n".as_ref(), + b"".as_ref(), + Command::new("A", CommandBody::Id { parameters: vec![] }).unwrap(), + ), + ( + b"A ID (\"\" \"\")\r\n".as_ref(), + b"".as_ref(), + Command::new( + "A", + CommandBody::Id { + parameters: vec![( + IString::try_from("").unwrap(), + NString::try_from("").unwrap(), + )], + }, + ) + .unwrap(), + ), + ( + b"A ID (\"name\" \"imap-codec\")\r\n".as_ref(), + b"".as_ref(), + Command::new( + "A", + CommandBody::Id { + parameters: vec![( + IString::try_from("name").unwrap(), + NString::try_from("imap-codec").unwrap(), + )], + }, + ) + .unwrap(), + ), + ]); + } + + #[test] + fn test_kat_inverse_response_id() { + kat_inverse_response(&[( + b"* ID nil\r\n".as_ref(), + b"".as_ref(), + Response::Data(Data::Id { parameters: vec![] }), + )]); + } +} diff --git a/imap-codec/src/response.rs b/imap-codec/src/response.rs index f15efcf2..6ee79c84 100644 --- a/imap-codec/src/response.rs +++ b/imap-codec/src/response.rs @@ -23,6 +23,8 @@ use nom::{ sequence::{delimited, preceded, terminated, tuple}, }; +#[cfg(feature = "ext_id")] +use crate::extensions::id::id_response; use crate::{ core::{atom, charset, nz_number, tag_imap, text}, decode::IMAPResult, @@ -274,13 +276,16 @@ pub(crate) fn continue_req(input: &[u8]) -> IMAPResult<&[u8], CommandContinuatio Ok((remaining, continue_request)) } -/// `response-data = "*" SP ( +/// ```abnf +/// response-data = "*" SP ( /// resp-cond-state / /// resp-cond-bye / /// mailbox-data / /// message-data / -/// capability-data -/// ) CRLF` +/// capability-data / +/// id_response ; (See RFC 2971) +/// ) CRLF +/// ``` pub(crate) fn response_data(input: &[u8]) -> IMAPResult<&[u8], Response> { let mut parser = tuple(( tag(b"*"), @@ -317,6 +322,10 @@ pub(crate) fn response_data(input: &[u8]) -> IMAPResult<&[u8], Response> { Response::Data(Data::Capability(caps)) }), map(enable_data, Response::Data), + #[cfg(feature = "ext_id")] + map(id_response, |parameters| { + Response::Data(Data::Id { parameters }) + }), )), crlf, )); diff --git a/imap-types/Cargo.toml b/imap-types/Cargo.toml index 326ca1f7..b0db925f 100644 --- a/imap-types/Cargo.toml +++ b/imap-types/Cargo.toml @@ -21,6 +21,7 @@ starttls = [] ext_condstore_qresync = [] ext_login_referrals = [] ext_mailbox_referrals = [] +ext_id = [] # Unlock `unvalidated` constructors. unvalidated = [] diff --git a/imap-types/src/command.rs b/imap-types/src/command.rs index e0e18d4d..925fbe60 100644 --- a/imap-types/src/command.rs +++ b/imap-types/src/command.rs @@ -11,6 +11,8 @@ use bounded_static::ToStatic; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +#[cfg(feature = "ext_id")] +use crate::core::{IString, NString}; use crate::{ auth::AuthMechanism, command::error::{AppendError, CopyError, ListError, LoginError, RenameError}, @@ -1362,6 +1364,13 @@ pub enum CommandBody<'a> { /// Use UID variant. uid: bool, }, + + #[cfg(feature = "ext_id")] + /// ID command. + Id { + /// Parameters. + parameters: Vec<(IString<'a>, NString<'a>)>, + }, } impl<'a> CommandBody<'a> { @@ -1645,6 +1654,8 @@ impl<'a> CommandBody<'a> { Self::GetQuotaRoot { .. } => "GETQUOTAROOT", Self::SetQuota { .. } => "SETQUOTA", Self::Move { .. } => "MOVE", + #[cfg(feature = "ext_id")] + Self::Id { .. } => "ID", } } } diff --git a/imap-types/src/response.rs b/imap-types/src/response.rs index b90c2841..aa884c56 100644 --- a/imap-types/src/response.rs +++ b/imap-types/src/response.rs @@ -14,6 +14,8 @@ use bounded_static::ToStatic; #[cfg(feature = "serde")] use serde::{Deserialize, Serialize}; +#[cfg(feature = "ext_id")] +use crate::core::{IString, NString}; use crate::{ auth::AuthMechanism, core::{impl_try_from, AString, Atom, Charset, NonEmptyVec, QuotedChar, Tag, Text}, @@ -550,6 +552,13 @@ pub enum Data<'a> { /// List of quota roots. roots: Vec>, }, + + #[cfg(feature = "ext_id")] + /// ID Response + Id { + /// Parameters + parameters: Vec<(IString<'a>, NString<'a>)>, + }, } impl<'a> Data<'a> {