Skip to main content

io_imap/sasl/
auth_plain.rs

1//! IMAP SASL PLAIN coroutine; supports both the non-IR and SASL-IR
2//! (RFC 4959) flows.
3//!
4//! The mechanism itself lives in io-sasl: this coroutine holds the IMAP
5//! half of the exchange, the `AUTHENTICATE PLAIN` command, the
6//! continuation request, the tagged response and the post-auth
7//! follow-ups, and asks [`SaslPlain`] what to put in each response. So
8//! the NUL-separated triple, and the refusal of a challenge once it has
9//! been sent, are the mechanism's business, and nothing here knows what
10//! PLAIN puts on the wire.
11//!
12//! PLAIN: <https://www.rfc-editor.org/rfc/rfc4616>
13//! SASL-IR: <https://www.rfc-editor.org/rfc/rfc4959>
14//!
15//! # Example
16//!
17//! ```rust,no_run
18//! use std::{
19//!     io::{Read, Write},
20//!     net::TcpStream,
21//! };
22//!
23//! use io_imap::{
24//!     codec::fragmentizer::Fragmentizer,
25//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
26//!     sasl::auth_plain::{ImapAuthPlain, ImapAuthPlainOptions},
27//! };
28//!
29//! // Ready stream needed (TCP-connected, TLS-negotiated)
30//! let mut stream = TcpStream::connect("localhost:143").unwrap();
31//!
32//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
33//! let mut buf = [0u8; 4096];
34//!
35//! let authzid: Option<&str> = None;
36//! let opts = ImapAuthPlainOptions::default();
37//! let mut coroutine = ImapAuthPlain::new(authzid, "alice", "secret", opts);
38//! let mut arg = None;
39//!
40//! let capability = loop {
41//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
42//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
43//!             stream.write_all(&bytes).unwrap();
44//!         }
45//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
46//!             let n = stream.read(&mut buf).unwrap();
47//!             arg = Some(&buf[..n]);
48//!         }
49//!         ImapCoroutineState::Complete(Ok(capability)) => break capability,
50//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
51//!     }
52//! };
53//!
54//! println!("{capability:?}");
55//! ```
56
57use core::{fmt, mem};
58
59use alloc::{
60    string::{String, ToString},
61    vec,
62    vec::Vec,
63};
64
65use imap_codec::{
66    AuthenticateDataCodec, CommandCodec,
67    fragmentizer::Fragmentizer,
68    imap_types::{
69        auth::{AuthMechanism, AuthenticateData},
70        command::{Command, CommandBody},
71        core::{IString, NString, TagGenerator},
72        response::{
73            Capability, Code, CommandContinuationRequest, Data, StatusBody, StatusKind, Tagged,
74        },
75        secret::Secret,
76    },
77};
78use io_sasl::{
79    coroutine::*,
80    rfc4616::plain::{SaslPlain, SaslPlainCreds, SaslPlainError},
81};
82use log::{debug, trace};
83use secrecy::SecretString;
84use thiserror::Error;
85
86use crate::{coroutine::*, imap_try, rfc2971::id::*, rfc3501::capability::*, send::*};
87
88/// Failure causes during the SASL PLAIN flow.
89#[derive(Clone, Debug, Error)]
90pub enum ImapAuthPlainError {
91    /// The server rejected authentication with a tagged NO.
92    #[error("IMAP AUTHENTICATE PLAIN failed: NO {0}")]
93    No(String),
94    /// The server rejected the AUTHENTICATE command with a tagged BAD.
95    #[error("IMAP AUTHENTICATE PLAIN failed: BAD {0}")]
96    Bad(String),
97    /// The server closed the connection with an untagged BYE.
98    #[error("IMAP AUTHENTICATE PLAIN failed: BYE {0}")]
99    Bye(String),
100    /// The server never returned the final tagged response.
101    #[error("IMAP AUTHENTICATE PLAIN failed: server did not return a tagged response")]
102    MissingTagged,
103    /// The server never sent the expected continuation request.
104    #[error(
105        "IMAP AUTHENTICATE PLAIN failed: server did not send the expected continuation request"
106    )]
107    ExpectedContinuationRequest,
108    /// The server returned OK before the mechanism could complete.
109    #[error(
110        "IMAP AUTHENTICATE PLAIN failed: server returned OK before the mechanism could complete"
111    )]
112    UnexpectedOk,
113    /// The mechanism refused the exchange.
114    ///
115    /// A challenge arriving once PLAIN has sent its credentials lands
116    /// here rather than in a framing error of this crate's, only the
117    /// mechanism knowing how many messages it exchanges.
118    #[error("IMAP AUTHENTICATE PLAIN failed: {0}")]
119    Mechanism(#[from] SaslPlainError),
120    /// The underlying send coroutine failed.
121    #[error("IMAP AUTHENTICATE PLAIN failed: {0}")]
122    Send(#[from] ImapSendError),
123    /// The follow-up CAPABILITY command failed.
124    #[error(transparent)]
125    Capability(#[from] ImapCapabilityGetError),
126    /// The follow-up ID command failed.
127    #[error(transparent)]
128    ServerId(#[from] ImapServerIdError),
129}
130
131/// Options for [`ImapAuthPlain::new`].
132#[derive(Clone, Debug, Default, Eq, PartialEq)]
133pub struct ImapAuthPlainOptions {
134    /// `true` selects SASL-IR (RFC 4959, inline credentials);
135    /// `false` selects the non-IR upload-after-challenge flow.
136    pub initial_request: bool,
137    /// Fetch CAPABILITY after authentication when the tagged response
138    /// carries no capability data. Defaults to `false`.
139    pub ensure_capabilities: bool,
140    /// Chain an RFC 2971 ID round-trip right after authentication, as
141    /// required by some providers.
142    ///
143    /// Defaults to `None` (no ID); an empty list sends ID NIL.
144    pub auto_id: Option<Vec<(IString<'static>, NString<'static>)>>,
145}
146
147/// I/O-free SASL PLAIN coroutine.
148pub struct ImapAuthPlain {
149    state: State,
150    mechanism: SaslPlain,
151    observed: Vec<Capability<'static>>,
152    opts: ImapAuthPlainOptions,
153}
154
155impl ImapAuthPlain {
156    /// Builds a SASL PLAIN coroutine authenticating `authcid` with
157    /// `password`.
158    ///
159    /// `authzid` is the optional RFC 4616 authorization identity;
160    /// `authcid` is the authentication identity (typically the
161    /// username). Depending on `opts.initial_request`, the credentials
162    /// go inline with the AUTHENTICATE command (SASL-IR) or are
163    /// uploaded after the server challenge.
164    pub fn new(
165        authzid: Option<impl AsRef<str>>,
166        authcid: impl AsRef<str>,
167        password: impl AsRef<str>,
168        opts: ImapAuthPlainOptions,
169    ) -> Self {
170        let mechanism = SaslPlain::new(SaslPlainCreds {
171            authzid: authzid.map(|zid| zid.as_ref().to_string()),
172            authcid: authcid.as_ref().to_string(),
173            passwd: SecretString::from(password.as_ref().to_string()),
174        });
175
176        Self {
177            state: State::Start,
178            mechanism,
179            observed: Vec::new(),
180            opts,
181        }
182    }
183
184    // helper that tells if the coroutine needs to fetch capability or not (in
185    // case found in data or untagged responses)
186    fn wants_capability(
187        &mut self,
188        code: Option<Code<'static>>,
189        data: Vec<Data<'static>>,
190        untagged: Vec<StatusBody<'static>>,
191    ) -> Option<State> {
192        let mut new_capability = None;
193
194        if let Some(Code::Capability(capability)) = code {
195            new_capability.replace(capability);
196        }
197
198        for data in data {
199            if let Data::Capability(capability) = data {
200                new_capability.replace(capability);
201            }
202        }
203
204        for StatusBody { code, .. } in untagged {
205            if let Some(Code::Capability(capability)) = code {
206                new_capability.replace(capability);
207            }
208        }
209
210        if let Some(capability) = new_capability {
211            self.observed = capability.into_iter().collect();
212        }
213
214        (self.opts.ensure_capabilities && self.observed.is_empty())
215            .then(|| State::Capability(ImapCapabilityGet::new()))
216    }
217
218    // helper that tells if the coroutine needs to exchange ID with server
219    fn wants_id(&mut self) -> Option<State> {
220        let params = self.opts.auto_id.take()?;
221        let wire = (!params.is_empty()).then_some(params);
222        Some(State::Id(ImapServerId::new(ImapServerIdOptions {
223            parameters: wire,
224        })))
225    }
226
227    // helper that tells if the coroutine needs to send continuation auth data
228    fn wants_continue(payload: Vec<u8>) -> State {
229        let auth = AuthenticateData::r#continue(payload);
230        let codec = AuthenticateDataCodec::new();
231        State::Continue(ImapSend::new(codec, auth))
232    }
233
234    // helper that resumes SASL coroutine
235    fn resume_sasl(&mut self, arg: SaslArg<'_>) -> Result<Option<Vec<u8>>, ImapAuthPlainError> {
236        match self.mechanism.resume(arg) {
237            SaslCoroutineState::Yielded(SaslYield::WantsWrite(payload)) => Ok(Some(payload)),
238            SaslCoroutineState::Yielded(SaslYield::WantsRead) => Ok(None),
239            SaslCoroutineState::Complete(result) => result.map(|()| None).map_err(Into::into),
240        }
241    }
242}
243
244impl ImapCoroutine for ImapAuthPlain {
245    type Yield = ImapYield;
246    type Return = Result<Vec<Capability<'static>>, ImapAuthPlainError>;
247
248    fn resume(
249        &mut self,
250        fragmentizer: &mut Fragmentizer,
251        arg: Option<&[u8]>,
252    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
253        loop {
254            match &mut self.state {
255                State::Start => {
256                    let payload = match self.resume_sasl(SaslArg::None) {
257                        Ok(payload) => payload,
258                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
259                    };
260
261                    // NOTE: the initial response travels inline only when the
262                    // server was found to support RFC 4959, which is a decision
263                    // taken before the exchange; otherwise it waits for the
264                    // empty challenge.
265                    let (initial_response, pending) = match payload {
266                        Some(payload) if self.opts.initial_request => {
267                            (Some(Secret::new(payload.into())), None)
268                        }
269                        payload => (None, payload),
270                    };
271
272                    let tag = TagGenerator::new().generate();
273                    let body = CommandBody::Authenticate {
274                        mechanism: AuthMechanism::Plain,
275                        initial_response,
276                    };
277                    let cmd = Command { tag, body };
278                    trace!("send IMAP command {cmd:?}");
279
280                    self.state = State::Send {
281                        send: ImapSend::new(CommandCodec::new(), cmd),
282                        pending,
283                    };
284                    debug!("{}", self.state);
285                }
286                State::Send { send, pending } => {
287                    let out = imap_try!(send, fragmentizer, arg);
288
289                    if let Some(bye) = out.bye {
290                        let err = ImapAuthPlainError::Bye(bye.text.to_string());
291                        return ImapCoroutineState::Complete(Err(err));
292                    }
293
294                    if let Some(cr) = out.continuation_request {
295                        // NOTE: the challenge PLAIN answers is empty, its
296                        // credentials being the initial response RFC 4959
297                        // defines, so it is answered from what the mechanism
298                        // already yielded rather than fed back to it.
299                        let payload = match pending.take() {
300                            Some(payload) => payload,
301                            None => {
302                                match self.resume_sasl(SaslArg::Input(&extract_challenge(cr))) {
303                                    Ok(payload) => payload.unwrap_or_default(),
304                                    Err(err) => return ImapCoroutineState::Complete(Err(err)),
305                                }
306                            }
307                        };
308
309                        self.state = Self::wants_continue(payload);
310                        debug!("{}", self.state);
311                        continue;
312                    }
313
314                    // NOTE: with the credentials inlined there is nothing left
315                    // to send, so the tagged response ends the exchange here
316                    // rather than after a continuation. Without them, a server
317                    // finishing now never asked for what it is authenticating.
318                    let inlined = pending.is_none();
319
320                    let Some(Tagged { body, .. }) = out.tagged else {
321                        let err = ImapAuthPlainError::ExpectedContinuationRequest;
322                        return ImapCoroutineState::Complete(Err(err));
323                    };
324
325                    let code = match body.kind {
326                        StatusKind::Ok if inlined => body.code,
327                        StatusKind::Ok => {
328                            let err = ImapAuthPlainError::UnexpectedOk;
329                            return ImapCoroutineState::Complete(Err(err));
330                        }
331                        StatusKind::No => {
332                            let err = ImapAuthPlainError::No(body.text.to_string());
333                            return ImapCoroutineState::Complete(Err(err));
334                        }
335                        StatusKind::Bad => {
336                            let err = ImapAuthPlainError::Bad(body.text.to_string());
337                            return ImapCoroutineState::Complete(Err(err));
338                        }
339                    };
340
341                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
342                        return ImapCoroutineState::Complete(Err(err));
343                    }
344
345                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
346                        self.state = next;
347                        debug!("{}", self.state);
348                        continue;
349                    }
350
351                    if let Some(next) = self.wants_id() {
352                        self.state = next;
353                        debug!("{}", self.state);
354                        continue;
355                    }
356
357                    let capability = mem::take(&mut self.observed);
358                    return ImapCoroutineState::Complete(Ok(capability));
359                }
360                State::Continue(send) => {
361                    let out = imap_try!(send, fragmentizer, arg);
362
363                    if let Some(bye) = out.bye {
364                        let err = ImapAuthPlainError::Bye(bye.text.to_string());
365                        return ImapCoroutineState::Complete(Err(err));
366                    }
367
368                    if let Some(cr) = out.continuation_request {
369                        let payload = match self.resume_sasl(SaslArg::Input(&extract_challenge(cr)))
370                        {
371                            Ok(payload) => payload.unwrap_or_default(),
372                            Err(err) => return ImapCoroutineState::Complete(Err(err)),
373                        };
374
375                        self.state = Self::wants_continue(payload);
376                        debug!("{}", self.state);
377                        continue;
378                    }
379
380                    let Some(Tagged { body, .. }) = out.tagged else {
381                        let err = ImapAuthPlainError::MissingTagged;
382                        return ImapCoroutineState::Complete(Err(err));
383                    };
384
385                    let code = match body.kind {
386                        StatusKind::Ok => body.code,
387                        StatusKind::No => {
388                            let err = ImapAuthPlainError::No(body.text.to_string());
389                            return ImapCoroutineState::Complete(Err(err));
390                        }
391                        StatusKind::Bad => {
392                            let err = ImapAuthPlainError::Bad(body.text.to_string());
393                            return ImapCoroutineState::Complete(Err(err));
394                        }
395                    };
396
397                    // NOTE: the tagged OK ends the exchange, and the mechanism
398                    // is told so rather than dropped: a mechanism performing
399                    // mutual authentication refuses here when it verified
400                    // nothing, which is what stops a success reply from
401                    // standing in for a proof the server never gave.
402                    if let Err(err) = self.resume_sasl(SaslArg::Done) {
403                        return ImapCoroutineState::Complete(Err(err));
404                    }
405
406                    if let Some(next) = self.wants_capability(code, out.data, out.untagged) {
407                        self.state = next;
408                        debug!("{}", self.state);
409                        continue;
410                    }
411
412                    if let Some(next) = self.wants_id() {
413                        self.state = next;
414                        debug!("{}", self.state);
415                        continue;
416                    }
417
418                    let capability = mem::take(&mut self.observed);
419                    return ImapCoroutineState::Complete(Ok(capability));
420                }
421                State::Capability(capability) => {
422                    self.observed = imap_try!(capability, fragmentizer, arg);
423
424                    if let Some(next) = self.wants_id() {
425                        self.state = next;
426                        debug!("{}", self.state);
427                        continue;
428                    }
429
430                    let capability = mem::take(&mut self.observed);
431                    return ImapCoroutineState::Complete(Ok(capability));
432                }
433                State::Id(id) => {
434                    imap_try!(id, fragmentizer, arg);
435                    let capability = mem::take(&mut self.observed);
436                    return ImapCoroutineState::Complete(Ok(capability));
437                }
438            }
439        }
440    }
441}
442
443enum State {
444    Start,
445    Send {
446        send: ImapSend<CommandCodec>,
447        pending: Option<Vec<u8>>,
448    },
449    Continue(ImapSend<AuthenticateDataCodec>),
450    Capability(ImapCapabilityGet),
451    Id(ImapServerId),
452}
453
454impl fmt::Display for State {
455    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
456        match self {
457            Self::Start => f.write_str("start mechanism"),
458            Self::Send { pending, .. } if pending.is_some() => f.write_str("send auth"),
459            Self::Send { .. } => f.write_str("send auth with ir"),
460            Self::Continue(_) => f.write_str("send credentials"),
461            Self::Capability(_) => f.write_str("fetch capabilities"),
462            Self::Id(_) => f.write_str("send id"),
463        }
464    }
465}
466
467fn extract_challenge(cr: CommandContinuationRequest<'static>) -> Vec<u8> {
468    match cr {
469        CommandContinuationRequest::Base64(data) => data.as_ref().to_vec(),
470        CommandContinuationRequest::Basic(_) => vec![],
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use core::str;
477
478    use alloc::format;
479
480    use crate::sasl::auth_plain::*;
481
482    #[test]
483    fn ir_success_returns_ok() {
484        let opts = ImapAuthPlainOptions {
485            initial_request: true,
486            ..Default::default()
487        };
488
489        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "secret", opts);
490        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
491
492        let bytes = expect_wants_write(&mut auth, &mut frag, None);
493        let line = str::from_utf8(&bytes).expect("utf8 command");
494        let tag = first_word(line);
495        assert!(line.contains("AUTHENTICATE PLAIN "));
496
497        expect_wants_read(&mut auth, &mut frag);
498
499        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
500        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
501    }
502
503    #[test]
504    fn ir_invalid_credentials_returns_no_error() {
505        let opts = ImapAuthPlainOptions {
506            initial_request: true,
507            ..Default::default()
508        };
509
510        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "wrong", opts);
511        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
512
513        let bytes = expect_wants_write(&mut auth, &mut frag, None);
514        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
515
516        expect_wants_read(&mut auth, &mut frag);
517
518        let reply = format!("{tag} NO authentication failed\r\n");
519        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
520        let ImapAuthPlainError::No(text) = err else {
521            panic!("expected ImapAuthPlainError::No, got {err:?}");
522        };
523        assert_eq!(text, "authentication failed");
524    }
525
526    #[test]
527    fn ir_tagged_bad_returns_bad_error() {
528        let opts = ImapAuthPlainOptions {
529            initial_request: true,
530            ..Default::default()
531        };
532
533        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "secret", opts);
534        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
535
536        let bytes = expect_wants_write(&mut auth, &mut frag, None);
537        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
538
539        expect_wants_read(&mut auth, &mut frag);
540
541        let reply = format!("{tag} BAD AUTHENTICATE not enabled\r\n");
542        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
543        let ImapAuthPlainError::Bad(text) = err else {
544            panic!("expected ImapAuthPlainError::Bad, got {err:?}");
545        };
546        assert_eq!(text, "AUTHENTICATE not enabled");
547    }
548
549    #[test]
550    fn ir_extra_challenge_returns_mechanism_error() {
551        let opts = ImapAuthPlainOptions {
552            initial_request: true,
553            ..Default::default()
554        };
555
556        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "secret", opts);
557        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
558
559        expect_wants_write(&mut auth, &mut frag, None);
560        expect_wants_read(&mut auth, &mut frag);
561
562        // NOTE: a challenge, which PLAIN has nothing left to answer once its
563        // credentials went inline. The refusal is the mechanism's, this crate
564        // having no way to know how many messages a mechanism exchanges.
565        let err = expect_complete_err(&mut auth, &mut frag, b"+ \r\n");
566        let ImapAuthPlainError::Mechanism(SaslPlainError::UnexpectedChallenge) = err else {
567            panic!("expected ImapAuthPlainError::Mechanism, got {err:?}");
568        };
569    }
570
571    #[test]
572    fn non_ir_success_returns_ok() {
573        let opts = ImapAuthPlainOptions::default();
574        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "secret", opts);
575        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
576
577        let bytes = expect_wants_write(&mut auth, &mut frag, None);
578        let line = str::from_utf8(&bytes).expect("utf8 command");
579        let tag = first_word(line);
580        assert!(line.trim_end().ends_with("AUTHENTICATE PLAIN"));
581
582        expect_wants_read(&mut auth, &mut frag);
583
584        let creds = expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
585        assert!(creds.ends_with(b"\r\n"));
586
587        expect_wants_read(&mut auth, &mut frag);
588
589        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
590        expect_complete_ok(&mut auth, &mut frag, reply.as_bytes());
591    }
592
593    #[test]
594    fn non_ir_invalid_credentials_returns_no_error() {
595        let opts = ImapAuthPlainOptions::default();
596        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "wrong", opts);
597        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
598
599        let bytes = expect_wants_write(&mut auth, &mut frag, None);
600        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
601
602        expect_wants_read(&mut auth, &mut frag);
603        expect_wants_write(&mut auth, &mut frag, Some(b"+ \r\n"));
604        expect_wants_read(&mut auth, &mut frag);
605
606        let reply = format!("{tag} NO authentication failed\r\n");
607        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
608        let ImapAuthPlainError::No(text) = err else {
609            panic!("expected ImapAuthPlainError::No, got {err:?}");
610        };
611        assert_eq!(text, "authentication failed");
612    }
613
614    #[test]
615    fn non_ir_tagged_ok_before_credentials_returns_unexpected_ok_error() {
616        let opts = ImapAuthPlainOptions::default();
617        let mut auth = ImapAuthPlain::new(None::<&str>, "alice", "secret", opts);
618        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
619
620        let bytes = expect_wants_write(&mut auth, &mut frag, None);
621        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command"));
622
623        expect_wants_read(&mut auth, &mut frag);
624
625        // NOTE: nothing was inlined, so a server finishing here never asked
626        // for what it claims to have authenticated.
627        let reply = format!("{tag} OK AUTHENTICATE completed\r\n");
628        let err = expect_complete_err(&mut auth, &mut frag, reply.as_bytes());
629        let ImapAuthPlainError::UnexpectedOk = err else {
630            panic!("expected ImapAuthPlainError::UnexpectedOk, got {err:?}");
631        };
632    }
633
634    fn expect_wants_write(
635        cor: &mut ImapAuthPlain,
636        frag: &mut Fragmentizer,
637        arg: Option<&[u8]>,
638    ) -> Vec<u8> {
639        match cor.resume(frag, arg) {
640            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
641            state => panic!("expected WantsWrite, got {state:?}"),
642        }
643    }
644
645    fn expect_wants_read(cor: &mut ImapAuthPlain, frag: &mut Fragmentizer) {
646        match cor.resume(frag, None) {
647            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
648            state => panic!("expected WantsRead, got {state:?}"),
649        }
650    }
651
652    fn expect_complete_ok(cor: &mut ImapAuthPlain, frag: &mut Fragmentizer, reply: &[u8]) {
653        match cor.resume(frag, Some(reply)) {
654            ImapCoroutineState::Complete(Ok(_)) => {}
655            state => panic!("expected Complete(Ok), got {state:?}"),
656        }
657    }
658
659    fn expect_complete_err(
660        cor: &mut ImapAuthPlain,
661        frag: &mut Fragmentizer,
662        reply: &[u8],
663    ) -> ImapAuthPlainError {
664        match cor.resume(frag, Some(reply)) {
665            ImapCoroutineState::Complete(Err(err)) => err,
666            state => panic!("expected Complete(Err), got {state:?}"),
667        }
668    }
669
670    fn first_word(line: &str) -> &str {
671        line.split_whitespace()
672            .next()
673            .expect("first whitespace-separated token")
674    }
675}