Initial commit
This commit is contained in:
@@ -0,0 +1,384 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
pub struct CommandBuilder {
|
||||
next_tag: u32,
|
||||
}
|
||||
|
||||
impl Default for CommandBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CommandBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self { next_tag: 1 }
|
||||
}
|
||||
|
||||
pub fn next_tag(&mut self) -> String {
|
||||
let tag = format!("A{:04}", self.next_tag);
|
||||
self.next_tag = self.next_tag.wrapping_add(1).max(1);
|
||||
tag
|
||||
}
|
||||
|
||||
pub fn build(&mut self, command: &str) -> (String, Vec<u8>) {
|
||||
let tag = self.next_tag();
|
||||
let mut out = String::with_capacity(tag.len() + command.len() + 4);
|
||||
out.push_str(&tag);
|
||||
out.push(' ');
|
||||
out.push_str(command);
|
||||
out.push_str("\r\n");
|
||||
(tag, out.into_bytes())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quote_astring(s: &str) -> String {
|
||||
let needs_literal = s.bytes().any(|b| !(0x20..=0x7e).contains(&b));
|
||||
if needs_literal {
|
||||
format!("{{{}+}}\r\n{}", s.len(), s)
|
||||
} else {
|
||||
let mut out = String::with_capacity(s.len() + 2);
|
||||
out.push('"');
|
||||
for c in s.chars() {
|
||||
if c == '"' || c == '\\' {
|
||||
out.push('\\');
|
||||
}
|
||||
out.push(c);
|
||||
}
|
||||
out.push('"');
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
pub fn contains_literal(bytes: &[u8]) -> bool {
|
||||
let mut i = 0;
|
||||
while i + 2 < bytes.len() {
|
||||
if bytes[i] == b'{' {
|
||||
let mut j = i + 1;
|
||||
while j < bytes.len() && bytes[j].is_ascii_digit() {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() && bytes[j] == b'+' {
|
||||
j += 1;
|
||||
}
|
||||
if j < bytes.len() && bytes[j] == b'}' {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub fn login(user: &str, password: &str) -> String {
|
||||
format!("LOGIN {} {}", quote_astring(user), quote_astring(password))
|
||||
}
|
||||
|
||||
pub fn authenticate(mechanism: &str) -> String {
|
||||
format!("AUTHENTICATE {mechanism}")
|
||||
}
|
||||
|
||||
pub fn authenticate_with_ir(mechanism: &str, initial_response: &str) -> String {
|
||||
format!("AUTHENTICATE {mechanism} {initial_response}")
|
||||
}
|
||||
|
||||
pub fn capability() -> &'static str {
|
||||
"CAPABILITY"
|
||||
}
|
||||
|
||||
pub fn starttls() -> &'static str {
|
||||
"STARTTLS"
|
||||
}
|
||||
|
||||
pub fn compress_deflate() -> &'static str {
|
||||
"COMPRESS DEFLATE"
|
||||
}
|
||||
|
||||
pub fn noop() -> &'static str {
|
||||
"NOOP"
|
||||
}
|
||||
|
||||
pub fn logout() -> &'static str {
|
||||
"LOGOUT"
|
||||
}
|
||||
|
||||
pub fn namespace() -> &'static str {
|
||||
"NAMESPACE"
|
||||
}
|
||||
|
||||
pub fn enable(extensions: &[&str]) -> String {
|
||||
let mut out = String::from("ENABLE");
|
||||
for ext in extensions {
|
||||
out.push(' ');
|
||||
out.push_str(ext);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn list(reference: &str, pattern: &str) -> String {
|
||||
format!(
|
||||
"LIST {} {}",
|
||||
quote_astring(reference),
|
||||
quote_astring(pattern)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn list_extended(
|
||||
reference: &str,
|
||||
patterns: &[&str],
|
||||
selection: &[&str],
|
||||
return_options: &[&str],
|
||||
) -> String {
|
||||
let mut out = String::from("LIST");
|
||||
if !selection.is_empty() {
|
||||
out.push_str(" (");
|
||||
for (i, s) in selection.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
out.push(' ');
|
||||
out.push_str("e_astring(reference));
|
||||
if patterns.len() == 1 {
|
||||
out.push(' ');
|
||||
out.push_str("e_astring(patterns[0]));
|
||||
} else {
|
||||
out.push_str(" (");
|
||||
for (i, p) in patterns.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str("e_astring(p));
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
if !return_options.is_empty() {
|
||||
out.push_str(" RETURN (");
|
||||
for (i, r) in return_options.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(r);
|
||||
}
|
||||
out.push(')');
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn lsub(reference: &str, pattern: &str) -> String {
|
||||
format!(
|
||||
"LSUB {} {}",
|
||||
quote_astring(reference),
|
||||
quote_astring(pattern)
|
||||
)
|
||||
}
|
||||
|
||||
pub fn select(mailbox: &str) -> String {
|
||||
format!("SELECT {}", quote_astring(mailbox))
|
||||
}
|
||||
|
||||
pub fn examine(mailbox: &str) -> String {
|
||||
format!("EXAMINE {}", quote_astring(mailbox))
|
||||
}
|
||||
|
||||
pub fn status(mailbox: &str, items: &[&str]) -> String {
|
||||
let mut out = String::from("STATUS ");
|
||||
out.push_str("e_astring(mailbox));
|
||||
out.push_str(" (");
|
||||
for (i, s) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn uid_search_esearch_all() -> &'static str {
|
||||
"UID SEARCH RETURN (ALL) ALL"
|
||||
}
|
||||
|
||||
pub fn uid_search_all() -> &'static str {
|
||||
"UID SEARCH ALL"
|
||||
}
|
||||
|
||||
pub fn uid_fetch_all_uids() -> &'static str {
|
||||
"UID FETCH 1:* (UID)"
|
||||
}
|
||||
|
||||
pub fn uid_fetch(set: &str, items: &[&str]) -> String {
|
||||
let mut out = String::from("UID FETCH ");
|
||||
out.push_str(set);
|
||||
out.push_str(" (");
|
||||
for (i, s) in items.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push(' ');
|
||||
}
|
||||
out.push_str(s);
|
||||
}
|
||||
out.push(')');
|
||||
out
|
||||
}
|
||||
|
||||
pub fn format_uid_set(uids: &[u32], is_sorted: bool) -> String {
|
||||
if uids.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let owned_sorted: Vec<u32>;
|
||||
let slice: &[u32] = if is_sorted {
|
||||
uids
|
||||
} else {
|
||||
let mut s = uids.to_vec();
|
||||
s.sort_unstable();
|
||||
s.dedup();
|
||||
owned_sorted = s;
|
||||
&owned_sorted[..]
|
||||
};
|
||||
|
||||
let mut out = String::with_capacity(slice.len() * 8);
|
||||
let mut i = 0;
|
||||
let mut first = true;
|
||||
while i < slice.len() {
|
||||
let start = slice[i];
|
||||
let mut end = start;
|
||||
let mut j = i + 1;
|
||||
while j < slice.len() && slice[j] == end + 1 {
|
||||
end = slice[j];
|
||||
j += 1;
|
||||
}
|
||||
if !first {
|
||||
out.push(',');
|
||||
}
|
||||
if start == end {
|
||||
let _ = write!(&mut out, "{start}");
|
||||
} else {
|
||||
let _ = write!(&mut out, "{start}:{end}");
|
||||
}
|
||||
first = false;
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn next_tag_increments() {
|
||||
let mut b = CommandBuilder::new();
|
||||
assert_eq!(b.next_tag(), "A0001");
|
||||
assert_eq!(b.next_tag(), "A0002");
|
||||
assert_eq!(b.next_tag(), "A0003");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_appends_crlf() {
|
||||
let mut b = CommandBuilder::new();
|
||||
let (tag, bytes) = b.build("NOOP");
|
||||
assert_eq!(tag, "A0001");
|
||||
assert_eq!(bytes, b"A0001 NOOP\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_astring_quotes_safe_text() {
|
||||
assert_eq!(quote_astring("hello"), "\"hello\"");
|
||||
assert_eq!(quote_astring("INBOX"), "\"INBOX\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_astring_escapes_quote_and_backslash() {
|
||||
assert_eq!(quote_astring("a\"b"), "\"a\\\"b\"");
|
||||
assert_eq!(quote_astring("a\\b"), "\"a\\\\b\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_astring_switches_to_literal_for_non_ascii() {
|
||||
let q = quote_astring("éclair");
|
||||
assert!(q.starts_with("{"));
|
||||
assert!(q.contains("\r\n"));
|
||||
assert!(q.ends_with("éclair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_command_format() {
|
||||
let s = login("alice", "p@ss");
|
||||
assert_eq!(s, "LOGIN \"alice\" \"p@ss\"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_extended_with_return_clause() {
|
||||
let s = list_extended("", &["*"], &[], &["SPECIAL-USE", "SUBSCRIBED"]);
|
||||
assert_eq!(s, "LIST \"\" \"*\" RETURN (SPECIAL-USE SUBSCRIBED)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_extended_with_selection_and_multi_pattern() {
|
||||
let s = list_extended("", &["INBOX", "Sent"], &["SUBSCRIBED"], &[]);
|
||||
assert_eq!(s, "LIST (SUBSCRIBED) \"\" (\"INBOX\" \"Sent\")");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_items_serialise_in_order() {
|
||||
let s = status("INBOX", &["UIDVALIDITY", "UIDNEXT", "MESSAGES"]);
|
||||
assert_eq!(s, "STATUS \"INBOX\" (UIDVALIDITY UIDNEXT MESSAGES)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn uid_fetch_with_items() {
|
||||
let s = uid_fetch("1:100", &["UID", "FLAGS", "INTERNALDATE", "RFC822.SIZE"]);
|
||||
assert_eq!(s, "UID FETCH 1:100 (UID FLAGS INTERNALDATE RFC822.SIZE)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_uid_set_collapses_consecutive_sorted_input() {
|
||||
assert_eq!(format_uid_set(&[], true), "");
|
||||
assert_eq!(format_uid_set(&[5], true), "5");
|
||||
assert_eq!(format_uid_set(&[1, 2, 3, 5, 7, 8], true), "1:3,5,7:8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_uid_set_sorts_when_caller_unsure() {
|
||||
assert_eq!(format_uid_set(&[3, 1, 2], false), "1:3");
|
||||
assert_eq!(format_uid_set(&[1, 1, 1, 2, 2, 3], false), "1:3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn enable_with_multiple_extensions() {
|
||||
assert_eq!(
|
||||
enable(&["UTF8=ACCEPT", "CONDSTORE"]),
|
||||
"ENABLE UTF8=ACCEPT CONDSTORE"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quote_astring_uses_literal_plus_for_non_ascii() {
|
||||
let q = quote_astring("éclair");
|
||||
assert!(q.starts_with("{"));
|
||||
assert!(q.contains("+}\r\n"));
|
||||
assert!(q.ends_with("éclair"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn contains_literal_detects_literal_plus_and_sync() {
|
||||
assert!(contains_literal(b"LOGIN \"u\" {6+}\r\nsecret"));
|
||||
assert!(contains_literal(b"LOGIN \"u\" {6}\r\nsecret"));
|
||||
assert!(!contains_literal(b"LOGIN \"u\" \"p\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn format_uid_set_trusts_sorted_flag() {
|
||||
let s = format_uid_set(&[1, 2, 3, 7, 9], true);
|
||||
assert_eq!(s, "1:3,7,9");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user