Merge pull request #227 from urkle/feat-acl-protocol

add in ACL extension support
This commit is contained in:
Jon Gjengset 2022-09-17 16:12:02 -04:00 committed by GitHub
commit 1a81abbdb8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 926 additions and 64 deletions

View file

@ -48,7 +48,7 @@ jobs:
uses: actions-rs/cargo@v1
with:
command: test
args: -- --ignored
args: --features test-full-imap
services:
cyrus_imapd:
image: outoforder/cyrus-imapd-tester:latest

View file

@ -16,13 +16,15 @@ categories = ["email", "network-programming"]
[features]
rustls-tls = ["rustls-connector"]
default = ["native-tls"]
# Used to activate full integration tests when running against a more complete IMAP server
test-full-imap = []
[dependencies]
native-tls = { version = "0.2.2", optional = true }
rustls-connector = { version = "0.16.1", optional = true }
regex = "1.0"
bufstream = "0.1.3"
imap-proto = "0.16.0"
imap-proto = "0.16.1"
nom = { version = "7.1.0", default-features = false }
base64 = "0.13"
chrono = { version = "0.4", default-features = false, features = ["std"]}

View file

@ -374,10 +374,10 @@ impl<T: Read + Write> Client<T> {
/// }
/// # }
/// ```
pub fn login<U: AsRef<str>, P: AsRef<str>>(
pub fn login(
mut self,
username: U,
password: P,
username: impl AsRef<str>,
password: impl AsRef<str>,
) -> ::std::result::Result<Session<T>, (Error, Client<T>)> {
let synopsis = "LOGIN";
let u =
@ -432,9 +432,9 @@ impl<T: Read + Write> Client<T> {
/// };
/// }
/// ```
pub fn authenticate<A: Authenticator, S: AsRef<str>>(
pub fn authenticate<A: Authenticator>(
mut self,
auth_type: S,
auth_type: impl AsRef<str>,
authenticator: &A,
) -> ::std::result::Result<Session<T>, (Error, Client<T>)> {
ok_or_unauth_client_err!(
@ -528,7 +528,7 @@ impl<T: Read + Write> Session<T> {
/// [`Connection::run_command_and_read_response`], you *may* see additional untagged `RECENT`,
/// `EXISTS`, `FETCH`, and `EXPUNGE` responses. You can get them from the
/// `unsolicited_responses` channel of the [`Session`](struct.Session.html).
pub fn select<S: AsRef<str>>(&mut self, mailbox_name: S) -> Result<Mailbox> {
pub fn select(&mut self, mailbox_name: impl AsRef<str>) -> Result<Mailbox> {
self.run(&format!(
"SELECT {}",
validate_str("SELECT", "mailbox", mailbox_name.as_ref())?
@ -540,7 +540,7 @@ impl<T: Read + Write> Session<T> {
/// however, the selected mailbox is identified as read-only. No changes to the permanent state
/// of the mailbox, including per-user state, will happen in a mailbox opened with `examine`;
/// in particular, messagess cannot lose [`Flag::Recent`] in an examined mailbox.
pub fn examine<S: AsRef<str>>(&mut self, mailbox_name: S) -> Result<Mailbox> {
pub fn examine(&mut self, mailbox_name: impl AsRef<str>) -> Result<Mailbox> {
self.run(&format!(
"EXAMINE {}",
validate_str("EXAMINE", "mailbox", mailbox_name.as_ref())?
@ -606,11 +606,11 @@ impl<T: Read + Write> Session<T> {
/// - `RFC822.HEADER`: Functionally equivalent to `BODY.PEEK[HEADER]`.
/// - `RFC822.SIZE`: The [RFC-2822](https://tools.ietf.org/html/rfc2822) size of the message.
/// - `UID`: The unique identifier for the message.
pub fn fetch<S1, S2>(&mut self, sequence_set: S1, query: S2) -> Result<Fetches>
where
S1: AsRef<str>,
S2: AsRef<str>,
{
pub fn fetch(
&mut self,
sequence_set: impl AsRef<str>,
query: impl AsRef<str>,
) -> Result<Fetches> {
if sequence_set.as_ref().is_empty() {
Fetches::parse(vec![], &mut self.unsolicited_responses_tx)
} else {
@ -626,11 +626,11 @@ impl<T: Read + Write> Session<T> {
/// Equivalent to [`Session::fetch`], except that all identifiers in `uid_set` are
/// [`Uid`]s. See also the [`UID` command](https://tools.ietf.org/html/rfc3501#section-6.4.8).
pub fn uid_fetch<S1, S2>(&mut self, uid_set: S1, query: S2) -> Result<Fetches>
where
S1: AsRef<str>,
S2: AsRef<str>,
{
pub fn uid_fetch(
&mut self,
uid_set: impl AsRef<str>,
query: impl AsRef<str>,
) -> Result<Fetches> {
if uid_set.as_ref().is_empty() {
Fetches::parse(vec![], &mut self.unsolicited_responses_tx)
} else {
@ -688,7 +688,7 @@ impl<T: Read + Write> Session<T> {
/// the mailbox UNLESS the new incarnation has a different unique identifier validity value.
/// See the description of the [`UID`
/// command](https://tools.ietf.org/html/rfc3501#section-6.4.8) for more detail.
pub fn create<S: AsRef<str>>(&mut self, mailbox_name: S) -> Result<()> {
pub fn create(&mut self, mailbox_name: impl AsRef<str>) -> Result<()> {
self.run_command_and_check_ok(&format!(
"CREATE {}",
validate_str("CREATE", "mailbox", mailbox_name.as_ref())?
@ -714,7 +714,7 @@ impl<T: Read + Write> Session<T> {
/// incarnation, UNLESS the new incarnation has a different unique identifier validity value.
/// See the description of the [`UID`
/// command](https://tools.ietf.org/html/rfc3501#section-6.4.8) for more detail.
pub fn delete<S: AsRef<str>>(&mut self, mailbox_name: S) -> Result<()> {
pub fn delete(&mut self, mailbox_name: impl AsRef<str>) -> Result<()> {
self.run_command_and_check_ok(&format!(
"DELETE {}",
validate_str("DELETE", "mailbox", mailbox_name.as_ref())?
@ -746,7 +746,7 @@ impl<T: Read + Write> Session<T> {
/// to a new mailbox with the given name, leaving `INBOX` empty. If the server implementation
/// supports inferior hierarchical names of `INBOX`, these are unaffected by a rename of
/// `INBOX`.
pub fn rename<S1: AsRef<str>, S2: AsRef<str>>(&mut self, from: S1, to: S2) -> Result<()> {
pub fn rename(&mut self, from: impl AsRef<str>, to: impl AsRef<str>) -> Result<()> {
self.run_command_and_check_ok(&format!(
"RENAME {} {}",
quote!(from.as_ref()),
@ -762,7 +762,7 @@ impl<T: Read + Write> Session<T> {
/// The server may validate the mailbox argument to `SUBSCRIBE` to verify that it exists.
/// However, it will not unilaterally remove an existing mailbox name from the subscription
/// list even if a mailbox by that name no longer exists.
pub fn subscribe<S: AsRef<str>>(&mut self, mailbox: S) -> Result<()> {
pub fn subscribe(&mut self, mailbox: impl AsRef<str>) -> Result<()> {
self.run_command_and_check_ok(&format!("SUBSCRIBE {}", quote!(mailbox.as_ref())))
}
@ -770,7 +770,7 @@ impl<T: Read + Write> Session<T> {
/// specified mailbox name from the server's set of "active" or "subscribed" mailboxes as
/// returned by [`Session::lsub`]. This command returns `Ok` only if the unsubscription is
/// successful.
pub fn unsubscribe<S: AsRef<str>>(&mut self, mailbox: S) -> Result<()> {
pub fn unsubscribe(&mut self, mailbox: impl AsRef<str>) -> Result<()> {
self.run_command_and_check_ok(&format!("UNSUBSCRIBE {}", quote!(mailbox.as_ref())))
}
@ -813,7 +813,7 @@ impl<T: Read + Write> Session<T> {
///
/// Alternatively, the client may fall back to using just [`Session::expunge`], risking the
/// unintended removal of some messages.
pub fn uid_expunge<S: AsRef<str>>(&mut self, uid_set: S) -> Result<Deleted> {
pub fn uid_expunge(&mut self, uid_set: impl AsRef<str>) -> Result<Deleted> {
self.run_command(&format!("UID EXPUNGE {}", uid_set.as_ref()))?;
self.read_response()
.and_then(|(lines, _)| parse_expunge(lines, &mut self.unsolicited_responses_tx))
@ -896,11 +896,11 @@ impl<T: Read + Write> Session<T> {
/// Ok(())
/// }
/// ```
pub fn store<S1, S2>(&mut self, sequence_set: S1, query: S2) -> Result<Fetches>
where
S1: AsRef<str>,
S2: AsRef<str>,
{
pub fn store(
&mut self,
sequence_set: impl AsRef<str>,
query: impl AsRef<str>,
) -> Result<Fetches> {
self.run_command_and_read_response(&format!(
"STORE {} {}",
sequence_set.as_ref(),
@ -911,11 +911,11 @@ impl<T: Read + Write> Session<T> {
/// Equivalent to [`Session::store`], except that all identifiers in `sequence_set` are
/// [`Uid`]s. See also the [`UID` command](https://tools.ietf.org/html/rfc3501#section-6.4.8).
pub fn uid_store<S1, S2>(&mut self, uid_set: S1, query: S2) -> Result<Fetches>
where
S1: AsRef<str>,
S2: AsRef<str>,
{
pub fn uid_store(
&mut self,
uid_set: impl AsRef<str>,
query: impl AsRef<str>,
) -> Result<Fetches> {
self.run_command_and_read_response(&format!(
"UID STORE {} {}",
uid_set.as_ref(),
@ -931,10 +931,10 @@ impl<T: Read + Write> Session<T> {
///
/// If the `COPY` command is unsuccessful for any reason, the server restores the destination
/// mailbox to its state before the `COPY` attempt.
pub fn copy<S1: AsRef<str>, S2: AsRef<str>>(
pub fn copy(
&mut self,
sequence_set: S1,
mailbox_name: S2,
sequence_set: impl AsRef<str>,
mailbox_name: impl AsRef<str>,
) -> Result<()> {
self.run_command_and_check_ok(&format!(
"COPY {} {}",
@ -945,10 +945,10 @@ impl<T: Read + Write> Session<T> {
/// Equivalent to [`Session::copy`], except that all identifiers in `sequence_set` are
/// [`Uid`]s. See also the [`UID` command](https://tools.ietf.org/html/rfc3501#section-6.4.8).
pub fn uid_copy<S1: AsRef<str>, S2: AsRef<str>>(
pub fn uid_copy(
&mut self,
uid_set: S1,
mailbox_name: S2,
uid_set: impl AsRef<str>,
mailbox_name: impl AsRef<str>,
) -> Result<()> {
self.run_command_and_check_ok(&format!(
"UID COPY {} {}",
@ -987,10 +987,10 @@ impl<T: Read + Write> Session<T> {
/// orphaned). The server will generally not leave any message in both mailboxes (it would be
/// bad for a partial failure to result in a bunch of duplicate messages). This is true even
/// if the server returns with [`Error::No`].
pub fn mv<S1: AsRef<str>, S2: AsRef<str>>(
pub fn mv(
&mut self,
sequence_set: S1,
mailbox_name: S2,
sequence_set: impl AsRef<str>,
mailbox_name: impl AsRef<str>,
) -> Result<()> {
self.run_command_and_check_ok(&format!(
"MOVE {} {}",
@ -1003,10 +1003,10 @@ impl<T: Read + Write> Session<T> {
/// [`Uid`]s. See also the [`UID` command](https://tools.ietf.org/html/rfc3501#section-6.4.8)
/// and the [semantics of `MOVE` and `UID
/// MOVE`](https://tools.ietf.org/html/rfc6851#section-3.3).
pub fn uid_mv<S1: AsRef<str>, S2: AsRef<str>>(
pub fn uid_mv(
&mut self,
uid_set: S1,
mailbox_name: S2,
uid_set: impl AsRef<str>,
mailbox_name: impl AsRef<str>,
) -> Result<()> {
self.run_command_and_check_ok(&format!(
"UID MOVE {} {}",
@ -1121,10 +1121,10 @@ impl<T: Read + Write> Session<T> {
/// - `UNSEEN`: The number of messages which do not have [`Flag::Seen`] set.
///
/// `data_items` is a space-separated list enclosed in parentheses.
pub fn status<S1: AsRef<str>, S2: AsRef<str>>(
pub fn status(
&mut self,
mailbox_name: S1,
data_items: S2,
mailbox_name: impl AsRef<str>,
data_items: impl AsRef<str>,
) -> Result<Mailbox> {
let mailbox_name = mailbox_name.as_ref();
self.run_command_and_read_response(&format!(
@ -1233,7 +1233,7 @@ impl<T: Read + Write> Session<T> {
///
/// - `BEFORE <date>`: Messages whose internal date (disregarding time and timezone) is earlier than the specified date.
/// - `SINCE <date>`: Messages whose internal date (disregarding time and timezone) is within or later than the specified date.
pub fn search<S: AsRef<str>>(&mut self, query: S) -> Result<HashSet<Seq>> {
pub fn search(&mut self, query: impl AsRef<str>) -> Result<HashSet<Seq>> {
self.run_command_and_read_response(&format!("SEARCH {}", query.as_ref()))
.and_then(|lines| parse_id_set(&lines, &mut self.unsolicited_responses_tx))
}
@ -1241,7 +1241,7 @@ impl<T: Read + Write> Session<T> {
/// Equivalent to [`Session::search`], except that the returned identifiers
/// are [`Uid`] instead of [`Seq`]. See also the [`UID`
/// command](https://tools.ietf.org/html/rfc3501#section-6.4.8).
pub fn uid_search<S: AsRef<str>>(&mut self, query: S) -> Result<HashSet<Uid>> {
pub fn uid_search(&mut self, query: impl AsRef<str>) -> Result<HashSet<Uid>> {
self.run_command_and_read_response(&format!("UID SEARCH {}", query.as_ref()))
.and_then(|lines| parse_id_set(&lines, &mut self.unsolicited_responses_tx))
}
@ -1251,11 +1251,11 @@ impl<T: Read + Write> Session<T> {
///
/// This command is like [`Session::search`], except that
/// the results are also sorted according to the supplied criteria (subject to the given charset).
pub fn sort<S: AsRef<str>>(
pub fn sort(
&mut self,
criteria: &[extensions::sort::SortCriterion<'_>],
charset: extensions::sort::SortCharset<'_>,
query: S,
query: impl AsRef<str>,
) -> Result<Vec<Seq>> {
self.run_command_and_read_response(&format!(
"SORT {} {} {}",
@ -1269,11 +1269,11 @@ impl<T: Read + Write> Session<T> {
/// Equivalent to [`Session::sort`], except that it returns [`Uid`]s.
///
/// See also [`Session::uid_search`].
pub fn uid_sort<S: AsRef<str>>(
pub fn uid_sort(
&mut self,
criteria: &[extensions::sort::SortCriterion<'_>],
charset: extensions::sort::SortCharset<'_>,
query: S,
query: impl AsRef<str>,
) -> Result<Vec<Uid>> {
self.run_command_and_read_response(&format!(
"UID SORT {} {} {}",
@ -1284,14 +1284,112 @@ impl<T: Read + Write> Session<T> {
.and_then(|lines| parse_id_seq(&lines, &mut self.unsolicited_responses_tx))
}
/// The [`SETACL` command](https://datatracker.ietf.org/doc/html/rfc4314#section-3.1)
///
/// Modifies the ACLs on the given mailbox for the specified identifier.
/// Return [`Error::No`] if the logged in user does not have `a` rights on the mailbox.
///
/// This method only works against a server with the ACL capability. Otherwise [`Error::Bad`]
/// will be returned
pub fn set_acl(
&mut self,
mailbox_name: impl AsRef<str>,
identifier: impl AsRef<str>,
rights: &AclRights,
modification: AclModifyMode,
) -> Result<()> {
let mod_str = match modification {
AclModifyMode::Replace => "",
AclModifyMode::Add => "+",
AclModifyMode::Remove => "-",
};
self.run_command_and_check_ok(&format!(
"SETACL {} {} {}{}",
validate_str("SETACL", "mailbox", mailbox_name.as_ref())?,
validate_str("SETACL", "identifier", identifier.as_ref())?,
mod_str,
rights,
))
}
/// The [`DELETEACL` command](https://datatracker.ietf.org/doc/html/rfc4314#section-3.2)
///
/// Removes the ACL for the given identifier from the given mailbox.
/// Return [`Error::No`] if the logged in user does not have `a` rights on the mailbox.
///
/// This method only works against a server with the ACL capability. Otherwise [`Error::Bad`]
/// will be returned
pub fn delete_acl(
&mut self,
mailbox_name: impl AsRef<str>,
identifier: impl AsRef<str>,
) -> Result<()> {
self.run_command_and_check_ok(&format!(
"DELETEACL {} {}",
validate_str("DELETEACL", "mailbox", mailbox_name.as_ref())?,
validate_str("DELETEACL", "identifier", identifier.as_ref())?,
))
}
/// The [`GETACL` command](https://datatracker.ietf.org/doc/html/rfc4314#section-3.3)
///
/// Returns the ACLs on the given mailbox. A set ot `ACL` responses are returned if the
/// logged in user has `a` rights on the mailbox. Otherwise, will return [`Error::No`].
///
/// This method only works against a server with the ACL capability. Otherwise [`Error::Bad`]
/// will be returned
pub fn get_acl(&mut self, mailbox_name: impl AsRef<str>) -> Result<AclResponse> {
self.run_command_and_read_response(&format!(
"GETACL {}",
validate_str("GETACL", "mailbox", mailbox_name.as_ref())?
))
.and_then(|lines| AclResponse::parse(lines, &mut self.unsolicited_responses_tx))
}
/// The [`LISTRIGHTS` command](https://datatracker.ietf.org/doc/html/rfc4314#section-3.4)
///
/// Returns the always granted and optionally granted rights on the given mailbox for the
/// specified identifier (login). A set ot `LISTRIGHTS` responses are returned if the
/// logged in user has `a` rights on the mailbox. Otherwise, will return [`Error::No`].
///
/// This method only works against a server with the ACL capability. Otherwise [`Error::Bad`]
/// will be returned
pub fn list_rights(
&mut self,
mailbox_name: impl AsRef<str>,
identifier: impl AsRef<str>,
) -> Result<ListRightsResponse> {
self.run_command_and_read_response(&format!(
"LISTRIGHTS {} {}",
validate_str("LISTRIGHTS", "mailbox", mailbox_name.as_ref())?,
validate_str("LISTRIGHTS", "identifier", identifier.as_ref())?
))
.and_then(|lines| ListRightsResponse::parse(lines, &mut self.unsolicited_responses_tx))
}
/// The [`MYRIGHTS` command](https://datatracker.ietf.org/doc/html/rfc4314#section-3.5)
///
/// Returns the list of rights the logged in user has on the given mailbox.
///
/// This method only works against a server with the ACL capability. Otherwise [`Error::Bad`]
/// will be returned
pub fn my_rights(&mut self, mailbox_name: impl AsRef<str>) -> Result<MyRightsResponse> {
self.run_command_and_read_response(&format!(
"MYRIGHTS {}",
validate_str("MYRIGHTS", "mailbox", mailbox_name.as_ref())?,
))
.and_then(|lines| MyRightsResponse::parse(lines, &mut self.unsolicited_responses_tx))
}
// these are only here because they are public interface, the rest is in `Connection`
/// Runs a command and checks if it returns OK.
pub fn run_command_and_check_ok<S: AsRef<str>>(&mut self, command: S) -> Result<()> {
pub fn run_command_and_check_ok(&mut self, command: impl AsRef<str>) -> Result<()> {
self.run_command_and_read_response(command).map(|_| ())
}
/// Runs any command passed to it.
pub fn run_command<S: AsRef<str>>(&mut self, untagged_command: S) -> Result<()> {
pub fn run_command(&mut self, untagged_command: impl AsRef<str>) -> Result<()> {
self.conn.run_command(untagged_command.as_ref())
}
@ -1303,7 +1401,7 @@ impl<T: Read + Write> Session<T> {
/// additional untagged `RECENT`, `EXISTS`, `FETCH`, and `EXPUNGE` responses!
///
/// The response includes the final [`Response::Done`], which starts at the returned index.
pub fn run<S: AsRef<str>>(&mut self, untagged_command: S) -> Result<(Vec<u8>, usize)> {
pub fn run(&mut self, untagged_command: impl AsRef<str>) -> Result<(Vec<u8>, usize)> {
self.conn.run(untagged_command.as_ref())
}
@ -1315,9 +1413,9 @@ impl<T: Read + Write> Session<T> {
/// additional untagged `RECENT`, `EXISTS`, `FETCH`, and `EXPUNGE` responses!
///
/// The response does not include the final [`Response::Done`].
pub fn run_command_and_read_response<S: AsRef<str>>(
pub fn run_command_and_read_response(
&mut self,
untagged_command: S,
untagged_command: impl AsRef<str>,
) -> Result<Vec<u8>> {
let (mut data, ok) = self.run(untagged_command)?;
data.truncate(ok);
@ -1550,7 +1648,7 @@ mod tests {
use super::super::error::Result;
use super::super::mock_stream::MockStream;
use super::*;
use imap_proto::types::*;
use imap_proto::types::Capability;
use std::borrow::Cow;
use super::testutils::*;
@ -2024,6 +2122,260 @@ mod tests {
assert_eq!(ids, [1, 2, 3, 4, 5].iter().cloned().collect::<Vec<_>>());
}
#[test]
fn set_acl_replace() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
session
.set_acl(
"INBOX",
"myuser",
&"x".try_into().unwrap(),
AclModifyMode::Replace,
)
.unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 SETACL \"INBOX\" \"myuser\" x\r\n".to_vec(),
"Invalid setacl command"
);
}
#[test]
fn set_acl_add() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
session
.set_acl(
"INBOX",
"myuser",
&"x".try_into().unwrap(),
AclModifyMode::Add,
)
.unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 SETACL \"INBOX\" \"myuser\" +x\r\n".to_vec(),
"Invalid setacl command"
);
}
#[test]
fn set_acl_remove() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
session
.set_acl(
"INBOX",
"myuser",
&"x".try_into().unwrap(),
AclModifyMode::Remove,
)
.unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 SETACL \"INBOX\" \"myuser\" -x\r\n".to_vec(),
"Invalid setacl command"
);
}
#[test]
fn delete_acl() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
session.delete_acl("INBOX", "myuser").unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 DELETEACL \"INBOX\" \"myuser\"\r\n".to_vec(),
"Invalid deleteacl command"
);
}
#[test]
fn get_acl() {
let response = b"* ACL INBOX myuser lr\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.get_acl("INBOX").unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 GETACL \"INBOX\"\r\n".to_vec(),
"Invalid getacl command"
);
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert_eq!(
acl.parsed().acls(),
vec![AclEntry {
identifier: "myuser".into(),
rights: "lr".try_into().unwrap(),
}]
);
}
#[test]
fn get_acl_invalid_no_acl_lines() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.get_acl("INBOX");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 GETACL \"INBOX\"\r\n".to_vec(),
"Invalid getacl command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn get_acl_invalid_too_many_acl_lines() {
let response = b"* ACL INBOX myuser lr\r\n\
* ACL INBOX myuser lr\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.get_acl("INBOX");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 GETACL \"INBOX\"\r\n".to_vec(),
"Invalid getacl command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn get_acl_multiple_users() {
let response = b"* ACL INBOX myuser lr other_user lr\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.get_acl("INBOX").unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 GETACL \"INBOX\"\r\n".to_vec(),
"Invalid getacl command"
);
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert_eq!(
acl.parsed().acls(),
vec![
AclEntry {
identifier: "myuser".into(),
rights: "lr".try_into().unwrap(),
},
AclEntry {
identifier: "other_user".into(),
rights: "lr".try_into().unwrap(),
},
]
);
}
#[test]
fn list_rights() {
let response = b"* LISTRIGHTS INBOX myuser lr x k\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.list_rights("INBOX", "myuser").unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 LISTRIGHTS \"INBOX\" \"myuser\"\r\n".to_vec(),
"Invalid listrights command"
);
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert_eq!(acl.parsed().identifier(), "myuser");
assert_eq!(*acl.parsed().required(), "lr".try_into().unwrap());
assert_eq!(*acl.parsed().optional(), "kx".try_into().unwrap());
}
#[test]
fn list_rights_invalid_no_rights_lines() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.list_rights("INBOX", "myuser");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 LISTRIGHTS \"INBOX\" \"myuser\"\r\n".to_vec(),
"Invalid listrights command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn list_rights_invalid_too_many_rights_lines() {
let response = b"* LISTRIGHTS INBOX myuser lr x k\r\n\
* LISTRIGHTS INBOX myuser lr x k\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.list_rights("INBOX", "myuser");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 LISTRIGHTS \"INBOX\" \"myuser\"\r\n".to_vec(),
"Invalid listrights command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn my_rights() {
let response = b"* MYRIGHTS INBOX lrxk\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.my_rights("INBOX").unwrap();
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 MYRIGHTS \"INBOX\"\r\n".to_vec(),
"Invalid myrights command"
);
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert_eq!(*acl.parsed().rights(), "lrkx".try_into().unwrap())
}
#[test]
fn my_rights_invalid_no_rights_lines() {
let response = b"a1 OK completed\r\n".to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.my_rights("INBOX");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 MYRIGHTS \"INBOX\"\r\n".to_vec(),
"Invalid myrights command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn my_rights_invalid_too_many_rights_lines() {
let response = b"* MYRIGHTS INBOX lrxk\r\n\
* MYRIGHTS INBOX lrxk\r\n\
a1 OK completed\r\n"
.to_vec();
let mock_stream = MockStream::new(response);
let mut session = mock_session!(mock_stream);
let acl = session.my_rights("INBOX");
assert_eq!(
session.stream.get_ref().written_buf,
b"a1 MYRIGHTS \"INBOX\"\r\n".to_vec(),
"Invalid myrights command"
);
assert!(matches!(acl, Err(Error::Parse(_))));
}
#[test]
fn capability() {
let response = b"* CAPABILITY IMAP4rev1 STARTTLS AUTH=GSSAPI LOGINDISABLED\r\n\

View file

@ -71,6 +71,28 @@ where
}
}
/// Parse and return an expected single `T` Response with `F`.
/// Responses other than `T` go into the `unsolicited` channel.
///
/// If zero or more than one `T` is found then [`Error::Parse`] is returned
pub(crate) fn parse_until_done<'input, T, F>(
input: &'input [u8],
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
map: F,
) -> Result<T>
where
F: FnMut(Response<'input>) -> Result<MapOrNot<'input, T>>,
{
let mut temp_output = Vec::<T>::new();
parse_many_into(input, &mut temp_output, unsolicited, map)?;
match temp_output.len() {
1 => Ok(temp_output.remove(0)),
_ => Err(Error::Parse(ParseError::Invalid(input.to_vec()))),
}
}
pub fn parse_expunge(
lines: Vec<u8>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,

391
src/types/acls.rs Normal file
View file

@ -0,0 +1,391 @@
use crate::error::Error;
use crate::parse::{parse_until_done, MapOrNot};
use crate::types::UnsolicitedResponse;
#[cfg(doc)]
use crate::Session;
use imap_proto::types::AclRight;
use imap_proto::Response;
use ouroboros::self_referencing;
use std::borrow::Cow;
use std::collections::HashSet;
use std::fmt::{Display, Formatter};
use std::sync::mpsc;
/// Specifies how [`Session::set_acl`] should modify an existing permission set.
#[derive(Debug, Clone, Copy)]
pub enum AclModifyMode {
/// Replace all ACLs on the identifier for the mailbox
Replace,
/// Add the given ACLs to the identifier for the mailbox
Add,
/// Remove the given ACLs from the identifier for the mailbox
Remove,
}
/// A set of [`imap_proto::AclRight`]s.
///
/// Used as input for [`Session::set_acl`] as output in [`ListRights`], [`MyRights`], and [`AclEntry`]
#[derive(Debug, Eq, PartialEq, Clone)]
pub struct AclRights {
pub(crate) data: HashSet<AclRight>,
}
impl AclRights {
/// Returns true if the AclRights has the provided ACL (either as a char or an AclRight enum)
pub fn contains<T: Into<AclRight>>(&self, right: T) -> bool {
self.data.contains(&right.into())
}
}
impl Display for AclRights {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
let mut v: Vec<char> = self.data.iter().map(|c| char::from(*c)).collect();
v.sort_unstable();
write!(f, "{}", v.into_iter().collect::<String>())
}
}
impl From<HashSet<AclRight>> for AclRights {
fn from(hash: HashSet<AclRight>) -> Self {
Self { data: hash }
}
}
impl From<Vec<AclRight>> for AclRights {
fn from(vec: Vec<AclRight>) -> Self {
AclRights {
data: vec.into_iter().collect(),
}
}
}
impl TryFrom<&str> for AclRights {
type Error = AclRightError;
fn try_from(input: &str) -> Result<Self, Self::Error> {
if !input
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
{
return Err(AclRightError::InvalidRight);
}
Ok(input
.chars()
.map(|c| c.into())
.collect::<HashSet<AclRight>>()
.into())
}
}
/// Error from parsing AclRight strings
#[derive(Debug, Eq, PartialEq)]
#[non_exhaustive]
pub enum AclRightError {
/// Returned when a non-lower-case alpha numeric is provided in the rights list string.
InvalidRight,
}
impl Display for AclRightError {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
match *self {
AclRightError::InvalidRight => {
write!(f, "Rights may only be lowercase alpha numeric characters")
}
}
}
}
impl std::error::Error for AclRightError {}
/// From [section 3.6 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.6).
///
/// This is a wrapper around a single [`Acl`].
///
/// The ACL response from the [`Session::get_acl`] IMAP command
#[self_referencing]
pub struct AclResponse {
data: Vec<u8>,
#[borrows(data)]
#[covariant]
pub(crate) acl: Acl<'this>,
}
impl AclResponse {
/// Parse the given input into a [`Acl`] response.
pub fn parse(
owned: Vec<u8>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
) -> Result<Self, Error> {
AclResponseTryBuilder {
data: owned,
acl_builder: |input| {
// There should only be ONE single ACL response
parse_until_done(input, unsolicited, |response| match response {
Response::Acl(a) => Ok(MapOrNot::Map(Acl {
mailbox: a.mailbox,
acls: a
.acls
.into_iter()
.map(|e| AclEntry {
identifier: e.identifier,
rights: e.rights.into(),
})
.collect(),
})),
resp => Ok(MapOrNot::Not(resp)),
})
},
}
.try_build()
}
/// Access to the wrapped [`ListRights`] struct
pub fn parsed(&self) -> &Acl<'_> {
self.borrow_acl()
}
}
/// From [section 3.6 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.6).
///
/// Used by [`AclResponse`].
#[derive(Debug, Eq, PartialEq)]
pub struct Acl<'a> {
/// The mailbox the ACL Entries belong to
pub(crate) mailbox: Cow<'a, str>,
/// The list of identifier/rights pairs for the mailbox
pub(crate) acls: Vec<AclEntry<'a>>,
}
impl<'a> Acl<'a> {
/// Return the mailbox the ACL entries belong to
pub fn mailbox(&self) -> &str {
&*self.mailbox
}
/// Returns a list of identifier/rights pairs for the mailbox
pub fn acls(&self) -> &[AclEntry<'_>] {
&*self.acls
}
}
/// From [section 3.6 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.6).
///
/// The list of identifiers and rights for the [`Acl`] response
#[derive(Debug, Eq, PartialEq, Clone)]
#[non_exhaustive]
pub struct AclEntry<'a> {
/// The user identifier the rights are for
pub identifier: Cow<'a, str>,
/// the rights for the provided identifier
pub rights: AclRights,
}
/// From [section 3.7 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.7).
///
/// This is a wrapper around a single [`ListRights`].
///
/// The LISTRIGHTS response from the [`Session::list_rights`] IMAP command
#[self_referencing]
pub struct ListRightsResponse {
data: Vec<u8>,
#[borrows(data)]
#[covariant]
pub(crate) rights: ListRights<'this>,
}
impl ListRightsResponse {
/// Parse the given input into a [`ListRights`] response.
pub fn parse(
owned: Vec<u8>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
) -> Result<Self, Error> {
ListRightsResponseTryBuilder {
data: owned,
rights_builder: |input| {
// There should only be ONE single LISTRIGHTS response
parse_until_done(input, unsolicited, |response| match response {
Response::ListRights(a) => Ok(MapOrNot::Map(ListRights {
mailbox: a.mailbox,
identifier: a.identifier,
required: a.required.into(),
optional: a.optional.into(),
})),
resp => Ok(MapOrNot::Not(resp)),
})
},
}
.try_build()
}
/// Access to the wrapped [`ListRights`] struct
pub fn parsed(&self) -> &ListRights<'_> {
self.borrow_rights()
}
}
/// From [section 3.7 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.7).
///
/// Used by [`ListRightsResponse`].
#[derive(Debug, Eq, PartialEq)]
pub struct ListRights<'a> {
/// The mailbox for the rights
pub(crate) mailbox: Cow<'a, str>,
/// The user identifier for the rights
pub(crate) identifier: Cow<'a, str>,
/// The set of rights that are always provided for this identifier
pub(crate) required: AclRights,
/// The set of rights that can be granted to the identifier
pub(crate) optional: AclRights,
}
impl ListRights<'_> {
/// Returns the mailbox for the rights
pub fn mailbox(&self) -> &str {
&*self.mailbox
}
/// Returns the user identifier for the rights
pub fn identifier(&self) -> &str {
&*self.identifier
}
/// Returns the set of rights that are always provided for this identifier
pub fn required(&self) -> &AclRights {
&self.required
}
/// Returns the set of rights that can be granted to the identifier
pub fn optional(&self) -> &AclRights {
&self.optional
}
}
/// From [section 3.8 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.8).
///
/// This is a wrapper around a single [`MyRights`].
///
/// The MYRIGHTS response from the [`Session::my_rights`] IMAP command
#[self_referencing]
pub struct MyRightsResponse {
data: Vec<u8>,
#[borrows(data)]
#[covariant]
pub(crate) rights: MyRights<'this>,
}
impl MyRightsResponse {
/// Parse the given input into a [`MyRights`] response.
pub fn parse(
owned: Vec<u8>,
unsolicited: &mut mpsc::Sender<UnsolicitedResponse>,
) -> Result<Self, Error> {
MyRightsResponseTryBuilder {
data: owned,
rights_builder: |input| {
// There should only be ONE single MYRIGHTS response
parse_until_done(input, unsolicited, |response| match response {
Response::MyRights(a) => Ok(MapOrNot::Map(MyRights {
mailbox: a.mailbox,
rights: a.rights.into(),
})),
resp => Ok(MapOrNot::Not(resp)),
})
},
}
.try_build()
}
/// Access to the wrapped [`MyRights`] struct
pub fn parsed(&self) -> &MyRights<'_> {
self.borrow_rights()
}
}
/// From [section 3.8 of RFC 4313](https://datatracker.ietf.org/doc/html/rfc4314#section-3.8).
///
/// Used by [`MyRightsResponse`].
#[derive(Debug, Eq, PartialEq)]
pub struct MyRights<'a> {
/// The mailbox for the rights
pub(crate) mailbox: Cow<'a, str>,
/// The rights for the mailbox
pub(crate) rights: AclRights,
}
impl MyRights<'_> {
/// Returns the mailbox for the rights
pub fn mailbox(&self) -> &str {
&*self.mailbox
}
/// Returns the rights for the mailbox
pub fn rights(&self) -> &AclRights {
&self.rights
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_acl_rights_to_string() {
let rights: AclRights = vec![
AclRight::Lookup,
AclRight::Read,
AclRight::Seen,
AclRight::Custom('0'),
]
.into();
let expected = "0lrs";
assert_eq!(rights.to_string(), expected);
}
#[test]
fn test_str_to_acl_rights() {
let right_string = "lrskx0";
let rights: Result<AclRights, _> = right_string.try_into();
assert_eq!(
rights,
Ok(vec![
AclRight::Lookup,
AclRight::Read,
AclRight::Seen,
AclRight::CreateMailbox,
AclRight::DeleteMailbox,
AclRight::Custom('0'),
]
.into())
);
}
#[test]
fn test_str_to_acl_rights_invalid_right_character() {
let right_string = "l_";
let rights: Result<AclRights, _> = right_string.try_into();
assert_eq!(rights, Err(AclRightError::InvalidRight));
assert_eq!(
format!("{}", rights.unwrap_err()),
"Rights may only be lowercase alpha numeric characters"
);
}
#[test]
fn test_acl_rights_contains() {
let rights: AclRights = "lrskx".try_into().unwrap();
assert!(rights.contains('l'));
assert!(rights.contains(AclRight::Lookup));
assert!(!rights.contains('0'));
assert!(!rights.contains(AclRight::Custom('0')));
}
}

View file

@ -121,6 +121,9 @@ pub use self::capabilities::Capabilities;
mod deleted;
pub use self::deleted::Deleted;
mod acls;
pub use self::acls::*;
mod unsolicited_response;
pub use self::unsolicited_response::{AttributeValue, UnsolicitedResponse};

View file

@ -27,6 +27,7 @@ fn test_smtp_host() -> String {
.unwrap_or_else(|_| std::env::var("TEST_HOST").unwrap_or("127.0.0.1".to_string()))
}
#[cfg(feature = "test-full-imap")]
fn test_imap_port() -> u16 {
std::env::var("TEST_IMAP_PORT")
.unwrap_or("3143".to_string())
@ -109,10 +110,10 @@ fn smtp(user: &str) -> lettre::SmtpTransport {
}
#[test]
#[ignore]
#[cfg(feature = "test-full-imap")]
fn connect_insecure_then_secure() {
let host = test_host();
// ignored because of https://github.com/greenmail-mail-test/greenmail/issues/135
// Not supported on greenmail because of https://github.com/greenmail-mail-test/greenmail/issues/135
imap::ClientBuilder::new(&host, test_imap_port())
.starttls()
.connect(|domain, tcp| {
@ -509,6 +510,97 @@ fn append_with_flags_and_date() {
assert_eq!(inbox.len(), 0);
}
#[test]
#[cfg(feature = "test-full-imap")]
fn acl_tests() {
use imap::types::AclModifyMode;
let user_friend = "inbox-acl-friend@localhost";
let user_me = "inbox-acl@localhost";
// ensure we have this user by logging in once
session(user_friend);
let mut s_me = session(user_me);
let acl = s_me.get_acl("INBOX").unwrap();
// one ACL
// assert_eq!(acl.acls().len(), 1);
// ACL is for me
assert_eq!(acl.parsed().acls()[0].identifier, user_me);
// ACL has administration rights
assert!(acl.parsed().acls()[0].rights.contains('a'));
// Grant read to friend
let ret = s_me.set_acl(
"INBOX",
user_friend,
&"lr".try_into().unwrap(),
AclModifyMode::Replace,
);
assert!(ret.is_ok());
// Check rights again
let acl = s_me.get_acl("INBOX").unwrap();
assert_eq!(acl.parsed().acls().len(), 2);
let idx = acl
.parsed()
.acls()
.binary_search_by(|e| (*e.identifier).cmp(user_friend))
.unwrap();
assert_eq!(acl.parsed().acls()[idx].rights, "lr".try_into().unwrap());
// Add "p" right (post)
let ret = s_me.set_acl(
"INBOX",
user_friend,
&"p".try_into().unwrap(),
AclModifyMode::Add,
);
assert!(ret.is_ok());
// Check rights again
let acl = s_me.get_acl("INBOX").unwrap();
assert_eq!(acl.parsed().acls().len(), 2);
let idx = acl
.parsed()
.acls()
.binary_search_by(|e| (*e.identifier).cmp(user_friend))
.unwrap();
assert_eq!(acl.parsed().acls()[idx].rights, "lrp".try_into().unwrap());
// remove "p" right (post)
let ret = s_me.set_acl(
"INBOX",
user_friend,
&"p".try_into().unwrap(),
AclModifyMode::Remove,
);
assert!(ret.is_ok());
// Check rights again
let acl = s_me.get_acl("INBOX").unwrap();
assert_eq!(acl.parsed().acls().len(), 2);
let idx = acl
.parsed()
.acls()
.binary_search_by(|e| (*e.identifier).cmp(user_friend))
.unwrap();
assert_eq!(acl.parsed().acls()[idx].rights, "lr".try_into().unwrap());
// Delete rights for friend
let ret = s_me.delete_acl("INBOX", user_friend);
assert!(ret.is_ok());
// Check rights again
let acl = s_me.get_acl("INBOX").unwrap();
assert_eq!(acl.parsed().acls().len(), 1);
assert_eq!(acl.parsed().acls()[0].identifier, user_me);
// List rights
let acl = s_me.list_rights("INBOX", user_friend).unwrap();
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert_eq!(acl.parsed().identifier(), user_friend);
assert!(acl.parsed().optional().contains('0'));
assert!(!acl.parsed().required().contains('0'));
// My Rights
let acl = s_me.my_rights("INBOX").unwrap();
assert_eq!(acl.parsed().mailbox(), "INBOX");
assert!(acl.parsed().rights().contains('a'));
}
#[test]
fn status() {
let mut s = session("readonly-test@localhost");