Skip to main content

io_imap/
session.rs

1//! Composite session-opening coroutine: everything between a bare
2//! address and an authenticated IMAP session.
3//!
4//! The handshake is where the protocol knowledge accumulates: which
5//! transport a scheme implies, that the greeting precedes STARTTLS, that
6//! CAPABILITY must be re-issued after the upgrade, that a PREAUTH
7//! greeting means authentication is already done, whether the RFC 4959
8//! initial response may be inlined, and which providers lie about it.
9//! Holding all of that in a std client would put it out of reach of
10//! every other runtime, so it lives here as a coroutine instead.
11//!
12//! Unlike a command coroutine, [`ImapSessionOpen`] yields transport
13//! requests as well as reads and writes: connect this socket, upgrade
14//! that one to TLS. The caller answers them with whatever sockets its
15//! runtime has, and inherits the ordering and the provider quirks for
16//! free. A caller that skips a step cannot advance, because the state
17//! machine never asks for the next one.
18//!
19//! # Example
20//!
21//! ```rust,no_run
22//! use std::{
23//!     io::{Read, Write},
24//!     net::TcpStream,
25//! };
26//!
27//! use io_imap::{
28//!     codec::fragmentizer::Fragmentizer,
29//!     coroutine::{ImapCoroutine, ImapCoroutineState},
30//!     session::{ImapSessionOpen, ImapSessionOpenOptions, ImapSessionOpenYield, ImapSessionTransport},
31//! };
32//! use io_sasl::rfc4616::plain::SaslPlainCreds;
33//!
34//! let transport = ImapSessionTransport::Tcp {
35//!     host: String::from("localhost"),
36//!     port: 143,
37//! };
38//!
39//! let sasl = SaslPlainCreds {
40//!     authzid: None,
41//!     authcid: String::from("alice"),
42//!     passwd: String::from("secret").into(),
43//! };
44//!
45//! let mut coroutine = ImapSessionOpen::new(transport, Some(sasl), ImapSessionOpenOptions::default());
46//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
47//! let mut stream: Option<TcpStream> = None;
48//! let mut buf = [0u8; 4096];
49//! let mut arg = None;
50//!
51//! let session = loop {
52//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
53//!         ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { host, port }) => {
54//!             stream = Some(TcpStream::connect((host.as_str(), port)).unwrap());
55//!         }
56//!         ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => {
57//!             stream.as_mut().unwrap().write_all(&bytes).unwrap();
58//!         }
59//!         ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {
60//!             let n = stream.as_mut().unwrap().read(&mut buf).unwrap();
61//!             arg = Some(&buf[..n]);
62//!         }
63//!         ImapCoroutineState::Yielded(yielded) => panic!("unexpected {yielded:?} over plain TCP"),
64//!         ImapCoroutineState::Complete(Ok(session)) => break session,
65//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
66//!     }
67//! };
68//!
69//! println!("{:?}", session.capability);
70//! ```
71
72use core::fmt;
73
74use alloc::{string::String, vec, vec::Vec};
75
76use imap_codec::{
77    fragmentizer::Fragmentizer,
78    imap_types::{
79        core::{IString, NString},
80        error::ValidationError,
81        response::Capability,
82    },
83};
84use io_sasl::{
85    login::SaslLoginCreds,
86    mechanism::{Sasl, SaslMechanism},
87    rfc4505::anonymous::SaslAnonymousCreds,
88    rfc4616::plain::SaslPlainCreds,
89    rfc7628::oauthbearer::SaslOauthbearerCreds,
90    xoauth2::SaslXoauth2Creds,
91};
92use log::debug;
93use secrecy::ExposeSecret;
94use thiserror::Error;
95#[cfg(feature = "url")]
96use url::Url;
97
98#[cfg(feature = "scram")]
99use crate::rfc7677::auth_scram_sha_256::*;
100use crate::{
101    coroutine::*,
102    imap_try,
103    rfc3501::{capability::*, greeting::*, login::*, starttls::*},
104    rfc7628::auth_oauthbearer::*,
105    sasl::{auth_anonymous::*, auth_login::*, auth_plain::*, auth_xoauth2::*},
106};
107
108/// Failure causes while opening an IMAP session.
109#[derive(Debug, Error)]
110pub enum ImapSessionOpenError {
111    /// STARTTLS was requested on a transport that is already TLS.
112    #[error("STARTTLS requested on an already-encrypted transport: TLS is active")]
113    StartTlsOverTls,
114    /// The server sent bytes past the STARTTLS tagged response.
115    ///
116    /// RFC 3501 §6.2.1 forbids them, so their presence means an attacker
117    /// injected plaintext commands the server will replay inside the TLS
118    /// session. The upgrade is refused rather than performed.
119    #[error("IMAP STARTTLS response carried trailing bytes: refusing the TLS upgrade")]
120    StartTlsInjection,
121    /// Credentials were given for a mechanism this crate does not
122    /// frame.
123    ///
124    /// io-sasl computes more mechanisms than IMAP wires up here; the
125    /// ones left out are named rather than silently skipped, so a
126    /// caller learns which of its credentials this crate cannot use.
127    #[error("{} SASL mechanism is not supported by this crate", .0.as_str())]
128    UnsupportedMechanism(SaslMechanism),
129    /// The URL carries no host to connect to.
130    #[cfg(feature = "url")]
131    #[error("IMAP URL `{0}` has no host")]
132    UrlMissingHost(String),
133    /// The URL scheme is none of imap, imaps and unix.
134    #[cfg(feature = "url")]
135    #[error("IMAP URL `{0}` has unsupported scheme `{1}` (expected `imap`, `imaps` or `unix`)")]
136    UrlUnsupportedScheme(String, String),
137    /// The LOGIN user or password failed imap-types validation.
138    #[error("Invalid IMAP LOGIN credentials")]
139    InvalidLoginCredentials(#[from] ValidationError),
140    /// The STARTTLS coroutine failed.
141    #[error(transparent)]
142    StartTls(#[from] ImapStartTlsError),
143    /// The greeting coroutine failed.
144    #[error(transparent)]
145    Greeting(#[from] ImapGreetingGetError),
146    /// The CAPABILITY coroutine failed.
147    #[error(transparent)]
148    Capability(#[from] ImapCapabilityGetError),
149    /// The LOGIN coroutine failed.
150    #[error(transparent)]
151    Login(#[from] ImapLoginError),
152    /// The SASL ANONYMOUS coroutine failed.
153    #[error(transparent)]
154    AuthAnonymous(#[from] ImapAuthAnonymousError),
155    /// The SASL LOGIN coroutine failed.
156    #[error(transparent)]
157    AuthLogin(#[from] ImapAuthLoginError),
158    /// The SASL PLAIN coroutine failed.
159    #[error(transparent)]
160    AuthPlain(#[from] ImapAuthPlainError),
161    /// The SASL OAUTHBEARER coroutine failed.
162    #[error(transparent)]
163    AuthOauthbearer(#[from] ImapAuthOauthbearerError),
164    /// The SASL XOAUTH2 coroutine failed.
165    #[error(transparent)]
166    AuthXoauth2(#[from] ImapAuthXoauth2Error),
167    /// The SASL SCRAM-SHA-256 coroutine failed.
168    #[cfg(feature = "scram")]
169    #[error(transparent)]
170    AuthScramSha256(#[from] ImapAuthScramSha256Error),
171}
172
173/// Where and how the connection is opened.
174///
175/// The scheme table that maps an IMAP URL onto one of these variants is
176/// protocol knowledge, so it lives here rather than in the transport
177/// layer; see [`ImapSessionTransport::from_url`].
178#[derive(Clone, Debug, Eq, PartialEq)]
179pub enum ImapSessionTransport {
180    /// Plain TCP, the `imap://` scheme. Pair it with
181    /// [`ImapSessionOpenOptions::starttls`] to reach a TLS session.
182    Tcp {
183        /// The server host name.
184        host: String,
185        /// The server port, conventionally 143.
186        port: u16,
187    },
188    /// Implicit TLS, the `imaps://` scheme.
189    Tls {
190        /// The server host name.
191        host: String,
192        /// The server port, conventionally 993.
193        port: u16,
194    },
195    /// A local unix domain socket, the `unix://` scheme, typically a
196    /// pre-authenticated socket proxy answering with a PREAUTH greeting.
197    Unix(String),
198}
199
200#[cfg(feature = "url")]
201impl ImapSessionTransport {
202    /// Reads the transport out of an IMAP URL.
203    ///
204    /// `imap://` is plain TCP on port 143, `imaps://` is implicit TLS on
205    /// port 993 and `unix://` is a local socket path; an explicit port in
206    /// the URL wins over the default.
207    pub fn from_url(url: &Url) -> Result<Self, ImapSessionOpenError> {
208        let scheme = url.scheme();
209
210        if scheme.eq_ignore_ascii_case("unix") {
211            return Ok(Self::Unix(String::from(url.path())));
212        }
213
214        let Some(host) = url.host_str() else {
215            let url = String::from(url.as_str());
216            return Err(ImapSessionOpenError::UrlMissingHost(url));
217        };
218
219        let host = String::from(host);
220        let port = url.port().unwrap_or_else(|| default_port(scheme));
221
222        if scheme.eq_ignore_ascii_case("imap") {
223            Ok(Self::Tcp { host, port })
224        } else if scheme.eq_ignore_ascii_case("imaps") {
225            Ok(Self::Tls { host, port })
226        } else {
227            let scheme = String::from(scheme);
228            let url = String::from(url.as_str());
229            Err(ImapSessionOpenError::UrlUnsupportedScheme(url, scheme))
230        }
231    }
232}
233
234/// Provider-quirk and policy options for [`ImapSessionOpen::new`].
235///
236/// The default upgrades nothing, sends no `ID` and follows the server's
237/// advertised capabilities.
238#[derive(Clone, Debug, Default)]
239pub struct ImapSessionOpenOptions {
240    /// Whether to upgrade the connection with `STARTTLS` after the
241    /// greeting. Only valid on a cleartext transport, since
242    /// [`ImapSessionTransport::Tls`] is already encrypted.
243    pub starttls: bool,
244    /// ID parameters consumed by the authentication step, required by a
245    /// few providers (mail.qq.com, Fastmail).
246    ///
247    /// `None` skips, `Some(empty)` sends `ID NIL`, `Some(params)` sends
248    /// `ID (k v ...)`.
249    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
250    /// Forces the RFC 4959 SASL-IR initial response on or off.
251    ///
252    /// `Some(true)` inlines it with the `AUTHENTICATE` command,
253    /// `Some(false)` waits for the server's continuation request, and
254    /// `None` follows the advertised `SASL-IR` capability. Coremail
255    /// (126.com, 163.com) advertises it falsely, and no capability
256    /// inspection can predict that.
257    pub sasl_ir: Option<bool>,
258}
259
260/// An opened IMAP session.
261#[derive(Clone, Debug)]
262pub struct ImapSessionOpenData {
263    /// The capabilities advertised once the session reached its final
264    /// state, after authentication when one took place.
265    pub capability: Vec<Capability<'static>>,
266    /// Whether the greeting was `PREAUTH`: the session opened already
267    /// authenticated, so the SASL step was skipped.
268    pub pre_authenticated: bool,
269}
270
271/// Requests emitted while opening a session.
272///
273/// The three connect variants and the upgrade are what set this
274/// coroutine apart from a command coroutine: the caller answers them
275/// with its own sockets, whatever the runtime.
276#[derive(Debug)]
277pub enum ImapSessionOpenYield {
278    /// The caller opens a plain TCP connection and resumes.
279    WantsTcpConnect {
280        /// The server host name.
281        host: String,
282        /// The server port.
283        port: u16,
284    },
285    /// The caller opens a TLS connection and resumes.
286    WantsTlsConnect {
287        /// The server host name, also the certificate name to verify.
288        host: String,
289        /// The server port.
290        port: u16,
291    },
292    /// The caller connects to the unix socket at this path and resumes.
293    WantsUnixConnect(String),
294    /// The caller upgrades the open connection to TLS and resumes.
295    ///
296    /// Emitted only once the STARTTLS exchange completed cleanly; the
297    /// coroutine refuses the upgrade itself when the server appended
298    /// bytes to its tagged response.
299    WantsTlsUpgrade,
300    /// The caller reads from its stream and resumes with the bytes.
301    WantsRead,
302    /// The caller writes the given bytes to its stream and resumes.
303    WantsWrite(Vec<u8>),
304}
305
306impl From<ImapYield> for ImapSessionOpenYield {
307    fn from(y: ImapYield) -> Self {
308        match y {
309            ImapYield::WantsRead => Self::WantsRead,
310            ImapYield::WantsWrite(bytes) => Self::WantsWrite(bytes),
311        }
312    }
313}
314
315/// I/O-free IMAP session-opening coroutine.
316pub struct ImapSessionOpen {
317    state: State,
318    transport: ImapSessionTransport,
319    sasl: Option<Sasl>,
320    capability: Vec<Capability<'static>>,
321    pre_authenticated: bool,
322    opts: ImapSessionOpenOptions,
323}
324
325impl ImapSessionOpen {
326    /// Builds a session-opening coroutine reaching `transport` and
327    /// authenticating with `sasl`.
328    ///
329    /// `sasl` of `None` stops after the greeting, which is what a
330    /// pre-authenticated socket proxy wants; a `Some` mechanism is
331    /// skipped anyway when the greeting turns out to be PREAUTH.
332    pub fn new(
333        transport: ImapSessionTransport,
334        sasl: Option<impl Into<Sasl>>,
335        opts: ImapSessionOpenOptions,
336    ) -> Self {
337        Self {
338            state: State::Connect,
339            transport,
340            sasl: sasl.map(Into::into),
341            capability: Vec::new(),
342            pre_authenticated: false,
343            opts,
344        }
345    }
346
347    /// Picks the SASL mechanism, resolves the SASL-IR policy and hands
348    /// the observed capabilities to the auth coroutine.
349    ///
350    /// Returns `None` when there is nothing to authenticate: no
351    /// mechanism was given, or the greeting was PREAUTH.
352    fn wants_auth(&mut self) -> Result<Option<State>, ImapSessionOpenError> {
353        if self.pre_authenticated {
354            return Ok(None);
355        }
356
357        let Some(sasl) = self.sasl.take() else {
358            return Ok(None);
359        };
360
361        let initial_request = self
362            .opts
363            .sasl_ir
364            .unwrap_or_else(|| self.capability.contains(&Capability::SaslIr));
365        let auto_id = self.opts.auto_id.take();
366        let ensure_capabilities = true;
367
368        let auth = match sasl {
369            Sasl::Anonymous(SaslAnonymousCreds { message }) => {
370                let opts = ImapAuthAnonymousOptions {
371                    initial_request,
372                    ensure_capabilities,
373                    auto_id,
374                };
375
376                Auth::Anonymous(ImapAuthAnonymous::new(message, opts))
377            }
378            Sasl::Login(SaslLoginCreds { username, password }) => {
379                let opts = ImapLoginOptions {
380                    ensure_capabilities,
381                    auto_id,
382                };
383
384                Auth::Login(ImapLogin::new(username, password.expose_secret(), opts)?)
385            }
386            Sasl::Plain(SaslPlainCreds {
387                authzid,
388                authcid,
389                passwd,
390            }) => {
391                let opts = ImapAuthPlainOptions {
392                    initial_request,
393                    ensure_capabilities,
394                    auto_id,
395                };
396
397                Auth::Plain(ImapAuthPlain::new(
398                    authzid,
399                    authcid,
400                    passwd.expose_secret(),
401                    opts,
402                ))
403            }
404            Sasl::Oauthbearer(SaslOauthbearerCreds {
405                username,
406                host,
407                port,
408                token,
409            }) => {
410                let opts = ImapAuthOauthbearerOptions {
411                    initial_request,
412                    ensure_capabilities,
413                    auto_id,
414                };
415
416                Auth::Oauthbearer(ImapAuthOauthbearer::new(
417                    username,
418                    host,
419                    port,
420                    token.expose_secret(),
421                    opts,
422                ))
423            }
424            Sasl::Xoauth2(SaslXoauth2Creds { username, token }) => {
425                let opts = ImapAuthXoauth2Options {
426                    initial_request,
427                    ensure_capabilities,
428                    auto_id,
429                };
430
431                Auth::Xoauth2(ImapAuthXoauth2::new(username, token.expose_secret(), opts))
432            }
433            #[cfg(feature = "scram")]
434            Sasl::ScramSha256(creds) => {
435                let opts = ImapAuthScramSha256Options {
436                    initial_request,
437                    ensure_capabilities,
438                    auto_id,
439                };
440
441                Auth::ScramSha256(ImapAuthScramSha256::new(creds, opts))
442            }
443            // NOTE: RFC 4422 frames any mechanism, but each one still
444            // needs its own coroutine here, and these have none. The arm
445            // also catches whatever io-sasl gains under a feature this
446            // crate does not enable but another crate in the build does.
447            sasl => {
448                let mechanism = sasl.mechanism();
449                return Err(ImapSessionOpenError::UnsupportedMechanism(mechanism));
450            }
451        };
452
453        Ok(Some(State::Auth(auth)))
454    }
455
456    /// Terminal value, taking the capabilities observed along the way.
457    fn complete(
458        &mut self,
459    ) -> ImapCoroutineState<ImapSessionOpenYield, <Self as ImapCoroutine>::Return> {
460        let data = ImapSessionOpenData {
461            capability: core::mem::take(&mut self.capability),
462            pre_authenticated: self.pre_authenticated,
463        };
464
465        ImapCoroutineState::Complete(Ok(data))
466    }
467}
468
469impl ImapCoroutine for ImapSessionOpen {
470    type Yield = ImapSessionOpenYield;
471    type Return = Result<ImapSessionOpenData, ImapSessionOpenError>;
472
473    fn resume(
474        &mut self,
475        fragmentizer: &mut Fragmentizer,
476        arg: Option<&[u8]>,
477    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
478        loop {
479            match &mut self.state {
480                State::Connect => {
481                    let is_tls = matches!(self.transport, ImapSessionTransport::Tls { .. });
482
483                    if self.opts.starttls && is_tls {
484                        let err = ImapSessionOpenError::StartTlsOverTls;
485                        return ImapCoroutineState::Complete(Err(err));
486                    }
487
488                    // NOTE: the transport is small and read once per
489                    // session, so cloning it out beats threading an
490                    // Option through the whole state machine.
491                    let yielded = match &self.transport {
492                        ImapSessionTransport::Tcp { host, port } => {
493                            ImapSessionOpenYield::WantsTcpConnect {
494                                host: host.clone(),
495                                port: *port,
496                            }
497                        }
498                        ImapSessionTransport::Tls { host, port } => {
499                            ImapSessionOpenYield::WantsTlsConnect {
500                                host: host.clone(),
501                                port: *port,
502                            }
503                        }
504                        ImapSessionTransport::Unix(path) => {
505                            ImapSessionOpenYield::WantsUnixConnect(path.clone())
506                        }
507                    };
508
509                    self.state = State::Connected;
510                    debug!("{}", self.state);
511
512                    return ImapCoroutineState::Yielded(yielded);
513                }
514                State::Connected => {
515                    self.state = if self.opts.starttls {
516                        State::StartTls(ImapStartTls::new())
517                    } else {
518                        State::Greeting(ImapGreetingGet::new(ImapGreetingGetOptions {
519                            ensure_capabilities: true,
520                        }))
521                    };
522
523                    debug!("{}", self.state);
524                }
525                State::StartTls(starttls) => {
526                    let leftover = imap_try!(starttls, fragmentizer, arg);
527
528                    if !leftover.is_empty() {
529                        let err = ImapSessionOpenError::StartTlsInjection;
530                        return ImapCoroutineState::Complete(Err(err));
531                    }
532
533                    self.state = State::Upgraded;
534                    debug!("{}", self.state);
535
536                    return ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade);
537                }
538                State::Upgraded => {
539                    // NOTE: RFC 3501 §6.2.1 invalidates the pre-upgrade
540                    // capability list, so it is re-read over TLS rather
541                    // than carried across. A STARTTLS session never
542                    // reaches PREAUTH: the greeting was consumed by the
543                    // STARTTLS coroutine before the upgrade.
544                    self.state = State::Capability(ImapCapabilityGet::new());
545                    debug!("{}", self.state);
546                }
547                State::Capability(capability) => {
548                    self.capability = imap_try!(capability, fragmentizer, arg);
549
550                    match self.wants_auth() {
551                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
552                        Ok(None) => return self.complete(),
553                        Ok(Some(next)) => {
554                            self.state = next;
555                            debug!("{}", self.state);
556                        }
557                    }
558                }
559                State::Greeting(greeting) => {
560                    let greeting = imap_try!(greeting, fragmentizer, arg);
561
562                    self.capability = greeting.capability;
563                    self.pre_authenticated = greeting.pre_authenticated;
564
565                    match self.wants_auth() {
566                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
567                        Ok(None) => return self.complete(),
568                        Ok(Some(next)) => {
569                            self.state = next;
570                            debug!("{}", self.state);
571                        }
572                    }
573                }
574                State::Auth(auth) => {
575                    self.capability = match auth {
576                        Auth::Anonymous(auth) => imap_try!(auth, fragmentizer, arg),
577                        Auth::Login(auth) => imap_try!(auth, fragmentizer, arg),
578                        Auth::Plain(auth) => imap_try!(auth, fragmentizer, arg),
579                        Auth::Oauthbearer(auth) => imap_try!(auth, fragmentizer, arg),
580                        Auth::Xoauth2(auth) => imap_try!(auth, fragmentizer, arg),
581                        #[cfg(feature = "scram")]
582                        Auth::ScramSha256(auth) => imap_try!(auth, fragmentizer, arg),
583                    };
584
585                    return self.complete();
586                }
587            }
588        }
589    }
590}
591
592/// Default ALPN identifier for IMAP TLS ([RFC 7595]).
593///
594/// [RFC 7595]: https://www.rfc-editor.org/rfc/rfc7595
595pub fn default_alpn() -> Vec<String> {
596    vec![String::from("imap")]
597}
598
599/// Default IMAP port for `scheme`: 993 for `imaps`, 143 otherwise.
600pub fn default_port(scheme: &str) -> u16 {
601    if scheme.eq_ignore_ascii_case("imaps") {
602        993
603    } else {
604        143
605    }
606}
607
608// NOTE: the variants differ by some 700 bytes, since every coroutine
609// sending a command carries an ImapSend of its own, and the lint would
610// have each state boxed. One allocation per transition buys less than
611// the copy it saves: a session moves through four of these.
612#[allow(clippy::large_enum_variant)]
613enum State {
614    Connect,
615    Connected,
616    StartTls(ImapStartTls),
617    Upgraded,
618    Capability(ImapCapabilityGet),
619    Greeting(ImapGreetingGet),
620    Auth(Auth),
621}
622
623impl fmt::Display for State {
624    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
625        match self {
626            Self::Connect => f.write_str("open connection"),
627            Self::Connected => f.write_str("connection opened"),
628            Self::StartTls(_) => f.write_str("send starttls"),
629            Self::Upgraded => f.write_str("connection upgraded to tls"),
630            Self::Capability(_) => f.write_str("fetch capabilities"),
631            Self::Greeting(_) => f.write_str("read greeting"),
632            Self::Auth(auth) => write!(f, "authenticate with {auth}"),
633        }
634    }
635}
636
637enum Auth {
638    Anonymous(ImapAuthAnonymous),
639    Login(ImapLogin),
640    Plain(ImapAuthPlain),
641    Oauthbearer(ImapAuthOauthbearer),
642    Xoauth2(ImapAuthXoauth2),
643    #[cfg(feature = "scram")]
644    ScramSha256(ImapAuthScramSha256),
645}
646
647impl fmt::Display for Auth {
648    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
649        match self {
650            Self::Anonymous(_) => f.write_str("anonymous"),
651            Self::Login(_) => f.write_str("login"),
652            Self::Plain(_) => f.write_str("plain"),
653            Self::Oauthbearer(_) => f.write_str("oauthbearer"),
654            Self::Xoauth2(_) => f.write_str("xoauth2"),
655            #[cfg(feature = "scram")]
656            Self::ScramSha256(_) => f.write_str("scram-sha-256"),
657        }
658    }
659}
660
661#[cfg(test)]
662mod tests {
663    use alloc::string::ToString;
664
665    use crate::session::*;
666
667    #[test]
668    fn tcp_transport_yields_tcp_connect_then_greeting() {
669        let transport = ImapSessionTransport::Tcp {
670            host: "localhost".to_string(),
671            port: 143,
672        };
673
674        let mut session = ImapSessionOpen::new(transport, None::<Sasl>, Default::default());
675        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
676
677        match session.resume(&mut frag, None) {
678            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { host, port }) => {
679                assert_eq!(host, "localhost");
680                assert_eq!(port, 143);
681            }
682            state => panic!("expected WantsTcpConnect, got {state:?}"),
683        }
684
685        match session.resume(&mut frag, None) {
686            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
687            state => panic!("expected WantsRead, got {state:?}"),
688        }
689
690        let greeting = b"* OK [CAPABILITY IMAP4REV1] server ready\r\n";
691        match session.resume(&mut frag, Some(greeting)) {
692            ImapCoroutineState::Complete(Ok(data)) => {
693                assert!(!data.pre_authenticated);
694                assert_eq!(data.capability, vec![Capability::Imap4Rev1]);
695            }
696            state => panic!("expected Complete(Ok), got {state:?}"),
697        }
698    }
699
700    #[test]
701    fn preauth_greeting_skips_the_sasl_step() {
702        let transport = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
703        let sasl = SaslPlainCreds {
704            authzid: None,
705            authcid: "alice".to_string(),
706            passwd: "secret".to_string().into(),
707        };
708
709        let mut session = ImapSessionOpen::new(transport, Some(sasl), Default::default());
710        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
711
712        match session.resume(&mut frag, None) {
713            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsUnixConnect(path)) => {
714                assert_eq!(path, "/run/sirup.sock");
715            }
716            state => panic!("expected WantsUnixConnect, got {state:?}"),
717        }
718
719        match session.resume(&mut frag, None) {
720            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead) => {}
721            state => panic!("expected WantsRead, got {state:?}"),
722        }
723
724        let greeting = b"* PREAUTH [CAPABILITY IMAP4REV1] already authenticated\r\n";
725        match session.resume(&mut frag, Some(greeting)) {
726            ImapCoroutineState::Complete(Ok(data)) => assert!(data.pre_authenticated),
727            state => panic!("expected Complete(Ok), got {state:?}"),
728        }
729    }
730
731    #[test]
732    fn starttls_over_tls_fails_before_opening_a_socket() {
733        let transport = ImapSessionTransport::Tls {
734            host: "localhost".to_string(),
735            port: 993,
736        };
737
738        let opts = ImapSessionOpenOptions {
739            starttls: true,
740            ..Default::default()
741        };
742
743        let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
744        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
745
746        match session.resume(&mut frag, None) {
747            ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsOverTls)) => {}
748            state => panic!("expected StartTlsOverTls, got {state:?}"),
749        }
750    }
751
752    #[test]
753    fn starttls_reaches_the_upgrade_then_refetches_capabilities() {
754        let transport = ImapSessionTransport::Tcp {
755            host: "localhost".to_string(),
756            port: 143,
757        };
758
759        let opts = ImapSessionOpenOptions {
760            starttls: true,
761            ..Default::default()
762        };
763
764        let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
765        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
766
767        assert!(matches!(
768            session.resume(&mut frag, None),
769            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTcpConnect { .. })
770        ));
771        assert!(matches!(
772            session.resume(&mut frag, None),
773            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
774        ));
775
776        // NOTE: the STARTTLS coroutine discards the plaintext greeting
777        // line before issuing its own command.
778        let greeting = b"* OK server ready\r\n";
779        let command = match session.resume(&mut frag, Some(greeting)) {
780            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
781            state => panic!("expected WantsWrite, got {state:?}"),
782        };
783
784        let command = String::from_utf8(command).expect("utf8 command");
785        assert!(command.contains("STARTTLS"));
786        let tag = command
787            .split_whitespace()
788            .next()
789            .expect("first whitespace-separated token")
790            .to_string();
791
792        assert!(matches!(
793            session.resume(&mut frag, None),
794            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsRead)
795        ));
796
797        let reply = alloc::format!("{tag} OK begin TLS negotiation\r\n");
798        assert!(matches!(
799            session.resume(&mut frag, Some(reply.as_bytes())),
800            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsTlsUpgrade)
801        ));
802
803        // NOTE: the caller has swapped in the TLS stream; the coroutine
804        // must now re-issue CAPABILITY rather than trust the pre-upgrade
805        // list.
806        let command = match session.resume(&mut frag, None) {
807            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
808            state => panic!("expected WantsWrite, got {state:?}"),
809        };
810
811        let command = String::from_utf8(command).expect("utf8 command");
812        assert!(command.contains("CAPABILITY"));
813    }
814
815    #[test]
816    fn starttls_trailing_bytes_refuse_the_upgrade() {
817        let transport = ImapSessionTransport::Tcp {
818            host: "localhost".to_string(),
819            port: 143,
820        };
821
822        let opts = ImapSessionOpenOptions {
823            starttls: true,
824            ..Default::default()
825        };
826
827        let mut session = ImapSessionOpen::new(transport, None::<Sasl>, opts);
828        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
829
830        session.resume(&mut frag, None);
831        session.resume(&mut frag, None);
832
833        let command = match session.resume(&mut frag, Some(b"* OK server ready\r\n")) {
834            ImapCoroutineState::Yielded(ImapSessionOpenYield::WantsWrite(bytes)) => bytes,
835            state => panic!("expected WantsWrite, got {state:?}"),
836        };
837
838        let command = String::from_utf8(command).expect("utf8 command");
839        let tag = command
840            .split_whitespace()
841            .next()
842            .expect("first whitespace-separated token")
843            .to_string();
844
845        session.resume(&mut frag, None);
846
847        // NOTE: the injected NOOP rides in the same TCP segment as the
848        // tagged OK, so the server would replay it inside the TLS
849        // session.
850        let reply = alloc::format!("{tag} OK begin TLS negotiation\r\na1 NOOP\r\n");
851        match session.resume(&mut frag, Some(reply.as_bytes())) {
852            ImapCoroutineState::Complete(Err(ImapSessionOpenError::StartTlsInjection)) => {}
853            state => panic!("expected StartTlsInjection, got {state:?}"),
854        }
855    }
856
857    #[cfg(feature = "url")]
858    #[test]
859    fn urls_map_onto_transports() {
860        let url = Url::parse("imap://example.org").unwrap();
861        let expected = ImapSessionTransport::Tcp {
862            host: "example.org".to_string(),
863            port: 143,
864        };
865        assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
866
867        let url = Url::parse("imaps://example.org:1993").unwrap();
868        let expected = ImapSessionTransport::Tls {
869            host: "example.org".to_string(),
870            port: 1993,
871        };
872        assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
873
874        let url = Url::parse("unix:///run/sirup.sock").unwrap();
875        let expected = ImapSessionTransport::Unix("/run/sirup.sock".to_string());
876        assert_eq!(ImapSessionTransport::from_url(&url).unwrap(), expected);
877
878        let url = Url::parse("http://example.org").unwrap();
879        let err = ImapSessionTransport::from_url(&url).unwrap_err();
880        assert!(matches!(
881            err,
882            ImapSessionOpenError::UrlUnsupportedScheme(_, _)
883        ));
884    }
885}