Skip to main content

io_imap/sasl/
auth_anonymous.rs

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