From b12eda492495f272ebb612cf3cdd61cc85ba0097 Mon Sep 17 00:00:00 2001 From: Edward Rudd Date: Sat, 23 Apr 2022 15:25:45 -0400 Subject: [PATCH] implement ACL extension --- .github/workflows/test.yml | 2 +- Cargo.toml | 4 +- src/client.rs | 358 ++++++++++++++++++++++++++++++++- src/parse.rs | 22 +++ src/types/acls.rs | 391 +++++++++++++++++++++++++++++++++++++ src/types/mod.rs | 3 + tests/imap_integration.rs | 96 ++++++++- 7 files changed, 869 insertions(+), 7 deletions(-) create mode 100644 src/types/acls.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ccf4a5..c96579b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Cargo.toml b/Cargo.toml index 748b27e..49030ae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"]} diff --git a/src/client.rs b/src/client.rs index be8798d..1007413 100644 --- a/src/client.rs +++ b/src/client.rs @@ -432,10 +432,10 @@ impl Client { /// }; /// } /// ``` - pub fn authenticate( + pub fn authenticate( mut self, auth_type: impl AsRef, - authenticator: &impl Authenticator, + authenticator: &A, ) -> ::std::result::Result, (Error, Client)> { ok_or_unauth_client_err!( self.run_command(&format!("AUTHENTICATE {}", auth_type.as_ref())), @@ -1284,6 +1284,104 @@ impl Session { .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, + identifier: impl AsRef, + 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, + identifier: impl AsRef, + ) -> 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) -> Result { + 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, + identifier: impl AsRef, + ) -> Result { + 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) -> Result { + 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(&mut self, command: impl AsRef) -> Result<()> { @@ -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::>()); } + #[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\ diff --git a/src/parse.rs b/src/parse.rs index c622a9d..afc5719 100644 --- a/src/parse.rs +++ b/src/parse.rs @@ -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, + map: F, +) -> Result +where + F: FnMut(Response<'input>) -> Result>, +{ + let mut temp_output = Vec::::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, unsolicited: &mut mpsc::Sender, diff --git a/src/types/acls.rs b/src/types/acls.rs new file mode 100644 index 0000000..b67076b --- /dev/null +++ b/src/types/acls.rs @@ -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, +} + +impl AclRights { + /// Returns true if the AclRights has the provided ACL (either as a char or an AclRight enum) + pub fn contains>(&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 = self.data.iter().map(|c| char::from(*c)).collect(); + + v.sort_unstable(); + + write!(f, "{}", v.into_iter().collect::()) + } +} + +impl From> for AclRights { + fn from(hash: HashSet) -> Self { + Self { data: hash } + } +} + +impl From> for AclRights { + fn from(vec: Vec) -> Self { + AclRights { + data: vec.into_iter().collect(), + } + } +} + +impl TryFrom<&str> for AclRights { + type Error = AclRightError; + + fn try_from(input: &str) -> Result { + 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::>() + .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, + #[borrows(data)] + #[covariant] + pub(crate) acl: Acl<'this>, +} + +impl AclResponse { + /// Parse the given input into a [`Acl`] response. + pub fn parse( + owned: Vec, + unsolicited: &mut mpsc::Sender, + ) -> Result { + 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>, +} + +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, + #[borrows(data)] + #[covariant] + pub(crate) rights: ListRights<'this>, +} + +impl ListRightsResponse { + /// Parse the given input into a [`ListRights`] response. + pub fn parse( + owned: Vec, + unsolicited: &mut mpsc::Sender, + ) -> Result { + 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, + #[borrows(data)] + #[covariant] + pub(crate) rights: MyRights<'this>, +} + +impl MyRightsResponse { + /// Parse the given input into a [`MyRights`] response. + pub fn parse( + owned: Vec, + unsolicited: &mut mpsc::Sender, + ) -> Result { + 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 = 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 = 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'))); + } +} diff --git a/src/types/mod.rs b/src/types/mod.rs index fd3b462..b458245 100644 --- a/src/types/mod.rs +++ b/src/types/mod.rs @@ -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}; diff --git a/tests/imap_integration.rs b/tests/imap_integration.rs index 09fa274..d60e363 100644 --- a/tests/imap_integration.rs +++ b/tests/imap_integration.rs @@ -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");