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 e2f4e47..1007413 100644 --- a/src/client.rs +++ b/src/client.rs @@ -374,10 +374,10 @@ impl Client { /// } /// # } /// ``` - pub fn login, P: AsRef>( + pub fn login( mut self, - username: U, - password: P, + username: impl AsRef, + password: impl AsRef, ) -> ::std::result::Result, (Error, Client)> { let synopsis = "LOGIN"; let u = @@ -432,9 +432,9 @@ impl Client { /// }; /// } /// ``` - pub fn authenticate>( + pub fn authenticate( mut self, - auth_type: S, + auth_type: impl AsRef, authenticator: &A, ) -> ::std::result::Result, (Error, Client)> { ok_or_unauth_client_err!( @@ -528,7 +528,7 @@ impl Session { /// [`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>(&mut self, mailbox_name: S) -> Result { + pub fn select(&mut self, mailbox_name: impl AsRef) -> Result { self.run(&format!( "SELECT {}", validate_str("SELECT", "mailbox", mailbox_name.as_ref())? @@ -540,7 +540,7 @@ impl Session { /// 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>(&mut self, mailbox_name: S) -> Result { + pub fn examine(&mut self, mailbox_name: impl AsRef) -> Result { self.run(&format!( "EXAMINE {}", validate_str("EXAMINE", "mailbox", mailbox_name.as_ref())? @@ -606,11 +606,11 @@ impl Session { /// - `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(&mut self, sequence_set: S1, query: S2) -> Result - where - S1: AsRef, - S2: AsRef, - { + pub fn fetch( + &mut self, + sequence_set: impl AsRef, + query: impl AsRef, + ) -> Result { if sequence_set.as_ref().is_empty() { Fetches::parse(vec![], &mut self.unsolicited_responses_tx) } else { @@ -626,11 +626,11 @@ impl Session { /// 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(&mut self, uid_set: S1, query: S2) -> Result - where - S1: AsRef, - S2: AsRef, - { + pub fn uid_fetch( + &mut self, + uid_set: impl AsRef, + query: impl AsRef, + ) -> Result { if uid_set.as_ref().is_empty() { Fetches::parse(vec![], &mut self.unsolicited_responses_tx) } else { @@ -688,7 +688,7 @@ impl Session { /// 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>(&mut self, mailbox_name: S) -> Result<()> { + pub fn create(&mut self, mailbox_name: impl AsRef) -> Result<()> { self.run_command_and_check_ok(&format!( "CREATE {}", validate_str("CREATE", "mailbox", mailbox_name.as_ref())? @@ -714,7 +714,7 @@ impl Session { /// 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>(&mut self, mailbox_name: S) -> Result<()> { + pub fn delete(&mut self, mailbox_name: impl AsRef) -> Result<()> { self.run_command_and_check_ok(&format!( "DELETE {}", validate_str("DELETE", "mailbox", mailbox_name.as_ref())? @@ -746,7 +746,7 @@ impl Session { /// 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, S2: AsRef>(&mut self, from: S1, to: S2) -> Result<()> { + pub fn rename(&mut self, from: impl AsRef, to: impl AsRef) -> Result<()> { self.run_command_and_check_ok(&format!( "RENAME {} {}", quote!(from.as_ref()), @@ -762,7 +762,7 @@ impl Session { /// 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>(&mut self, mailbox: S) -> Result<()> { + pub fn subscribe(&mut self, mailbox: impl AsRef) -> Result<()> { self.run_command_and_check_ok(&format!("SUBSCRIBE {}", quote!(mailbox.as_ref()))) } @@ -770,7 +770,7 @@ impl Session { /// 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>(&mut self, mailbox: S) -> Result<()> { + pub fn unsubscribe(&mut self, mailbox: impl AsRef) -> Result<()> { self.run_command_and_check_ok(&format!("UNSUBSCRIBE {}", quote!(mailbox.as_ref()))) } @@ -813,7 +813,7 @@ impl Session { /// /// Alternatively, the client may fall back to using just [`Session::expunge`], risking the /// unintended removal of some messages. - pub fn uid_expunge>(&mut self, uid_set: S) -> Result { + pub fn uid_expunge(&mut self, uid_set: impl AsRef) -> Result { 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 Session { /// Ok(()) /// } /// ``` - pub fn store(&mut self, sequence_set: S1, query: S2) -> Result - where - S1: AsRef, - S2: AsRef, - { + pub fn store( + &mut self, + sequence_set: impl AsRef, + query: impl AsRef, + ) -> Result { self.run_command_and_read_response(&format!( "STORE {} {}", sequence_set.as_ref(), @@ -911,11 +911,11 @@ impl Session { /// 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(&mut self, uid_set: S1, query: S2) -> Result - where - S1: AsRef, - S2: AsRef, - { + pub fn uid_store( + &mut self, + uid_set: impl AsRef, + query: impl AsRef, + ) -> Result { self.run_command_and_read_response(&format!( "UID STORE {} {}", uid_set.as_ref(), @@ -931,10 +931,10 @@ impl Session { /// /// 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, S2: AsRef>( + pub fn copy( &mut self, - sequence_set: S1, - mailbox_name: S2, + sequence_set: impl AsRef, + mailbox_name: impl AsRef, ) -> Result<()> { self.run_command_and_check_ok(&format!( "COPY {} {}", @@ -945,10 +945,10 @@ impl Session { /// 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, S2: AsRef>( + pub fn uid_copy( &mut self, - uid_set: S1, - mailbox_name: S2, + uid_set: impl AsRef, + mailbox_name: impl AsRef, ) -> Result<()> { self.run_command_and_check_ok(&format!( "UID COPY {} {}", @@ -987,10 +987,10 @@ impl Session { /// 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, S2: AsRef>( + pub fn mv( &mut self, - sequence_set: S1, - mailbox_name: S2, + sequence_set: impl AsRef, + mailbox_name: impl AsRef, ) -> Result<()> { self.run_command_and_check_ok(&format!( "MOVE {} {}", @@ -1003,10 +1003,10 @@ impl Session { /// [`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, S2: AsRef>( + pub fn uid_mv( &mut self, - uid_set: S1, - mailbox_name: S2, + uid_set: impl AsRef, + mailbox_name: impl AsRef, ) -> Result<()> { self.run_command_and_check_ok(&format!( "UID MOVE {} {}", @@ -1121,10 +1121,10 @@ impl Session { /// - `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, S2: AsRef>( + pub fn status( &mut self, - mailbox_name: S1, - data_items: S2, + mailbox_name: impl AsRef, + data_items: impl AsRef, ) -> Result { let mailbox_name = mailbox_name.as_ref(); self.run_command_and_read_response(&format!( @@ -1233,7 +1233,7 @@ impl Session { /// /// - `BEFORE `: Messages whose internal date (disregarding time and timezone) is earlier than the specified date. /// - `SINCE `: Messages whose internal date (disregarding time and timezone) is within or later than the specified date. - pub fn search>(&mut self, query: S) -> Result> { + pub fn search(&mut self, query: impl AsRef) -> Result> { 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 Session { /// 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>(&mut self, query: S) -> Result> { + pub fn uid_search(&mut self, query: impl AsRef) -> Result> { 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 Session { /// /// 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>( + pub fn sort( &mut self, criteria: &[extensions::sort::SortCriterion<'_>], charset: extensions::sort::SortCharset<'_>, - query: S, + query: impl AsRef, ) -> Result> { self.run_command_and_read_response(&format!( "SORT {} {} {}", @@ -1269,11 +1269,11 @@ impl Session { /// Equivalent to [`Session::sort`], except that it returns [`Uid`]s. /// /// See also [`Session::uid_search`]. - pub fn uid_sort>( + pub fn uid_sort( &mut self, criteria: &[extensions::sort::SortCriterion<'_>], charset: extensions::sort::SortCharset<'_>, - query: S, + query: impl AsRef, ) -> Result> { self.run_command_and_read_response(&format!( "UID SORT {} {} {}", @@ -1284,14 +1284,112 @@ 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: S) -> Result<()> { + pub fn run_command_and_check_ok(&mut self, command: impl AsRef) -> Result<()> { self.run_command_and_read_response(command).map(|_| ()) } /// Runs any command passed to it. - pub fn run_command>(&mut self, untagged_command: S) -> Result<()> { + pub fn run_command(&mut self, untagged_command: impl AsRef) -> Result<()> { self.conn.run_command(untagged_command.as_ref()) } @@ -1303,7 +1401,7 @@ impl Session { /// additional untagged `RECENT`, `EXISTS`, `FETCH`, and `EXPUNGE` responses! /// /// The response includes the final [`Response::Done`], which starts at the returned index. - pub fn run>(&mut self, untagged_command: S) -> Result<(Vec, usize)> { + pub fn run(&mut self, untagged_command: impl AsRef) -> Result<(Vec, usize)> { self.conn.run(untagged_command.as_ref()) } @@ -1315,9 +1413,9 @@ impl Session { /// additional untagged `RECENT`, `EXISTS`, `FETCH`, and `EXPUNGE` responses! /// /// The response does not include the final [`Response::Done`]. - pub fn run_command_and_read_response>( + pub fn run_command_and_read_response( &mut self, - untagged_command: S, + untagged_command: impl AsRef, ) -> Result> { 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::>()); } + #[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");