Skip to main content

imap_client/
credentials.rs

1use std::fmt;
2use zeroize::{Zeroize, ZeroizeOnDrop};
3
4use crate::error::ClientError;
5
6/// Secret string memory-wiped on drop and obfuscated in `Debug` output.
7#[derive(Clone, Zeroize, ZeroizeOnDrop)]
8pub struct Password(String);
9
10impl Password {
11    pub fn new<S: Into<String>>(pass: S) -> Self {
12        Self(pass.into())
13    }
14
15    pub fn as_str(&self) -> &str {
16        &self.0
17    }
18
19    /// Render the password as an IMAP `quoted` string (RFC 9051 §4.3),
20    /// escaping the two quoted-specials (`\`, `"`).
21    ///
22    /// Returns [`ClientError::CommandFailed`] if the secret contains a
23    /// character that cannot appear inside a quoted string (`CR`, `LF`,
24    /// `NUL`, or any 8-bit byte). Callers facing an 8-bit secret should
25    /// use [`Session::authenticate_plain`](crate::session::Session) which
26    /// transports the secret as base64 over a SASL exchange.
27    pub fn as_imap_quoted(&self) -> Result<String, ClientError> {
28        imap_quoted(&self.0)
29    }
30}
31
32impl fmt::Debug for Password {
33    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34        f.write_str("\"***\"")
35    }
36}
37
38/// Secure wrapper for OAuth bearer tokens.
39#[derive(Clone, Zeroize, ZeroizeOnDrop)]
40pub struct OAuthToken(String);
41
42impl OAuthToken {
43    pub fn new<S: Into<String>>(token: S) -> Self {
44        Self(token.into())
45    }
46
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50}
51
52impl fmt::Debug for OAuthToken {
53    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54        f.write_str("\"***\"")
55    }
56}
57
58/// Render `s` as an IMAP quoted string with proper escaping. Errors out
59/// when `s` contains a character not permitted inside a quoted string.
60pub(crate) fn imap_quoted(s: &str) -> Result<String, ClientError> {
61    if s.bytes()
62        .any(|b| b == b'\r' || b == b'\n' || b == 0 || b > 0x7F)
63    {
64        return Err(ClientError::CommandFailed(
65            "value contains characters disallowed in IMAP quoted string; \
66             use AUTHENTICATE for 8-bit or control-byte values"
67                .to_string(),
68        ));
69    }
70    let mut out = String::with_capacity(s.len() + 2);
71    out.push('"');
72    for c in s.chars() {
73        if c == '"' || c == '\\' {
74            out.push('\\');
75        }
76        out.push(c);
77    }
78    out.push('"');
79    Ok(out)
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_password_obfuscation() {
88        let pass = Password::new("secret_pass");
89        let debug_str = format!("{:?}", pass);
90        assert_eq!(debug_str, "\"***\"");
91        assert!(!debug_str.contains("secret_pass"));
92        assert_eq!(pass.as_str(), "secret_pass");
93    }
94
95    #[test]
96    fn test_oauth_obfuscation() {
97        let token = OAuthToken::new("ya29.token");
98        let debug_str = format!("{:?}", token);
99        assert_eq!(debug_str, "\"***\"");
100        assert!(!debug_str.contains("ya29.token"));
101        assert_eq!(token.as_str(), "ya29.token");
102    }
103
104    #[test]
105    fn test_imap_quoted_basic() {
106        assert_eq!(imap_quoted("hello").unwrap(), "\"hello\"");
107        assert_eq!(imap_quoted("").unwrap(), "\"\"");
108    }
109
110    #[test]
111    fn test_imap_quoted_escapes() {
112        assert_eq!(imap_quoted("a\"b").unwrap(), "\"a\\\"b\"");
113        assert_eq!(imap_quoted("a\\b").unwrap(), "\"a\\\\b\"");
114        assert_eq!(imap_quoted("a\\\"b").unwrap(), "\"a\\\\\\\"b\"");
115    }
116
117    #[test]
118    fn test_imap_quoted_rejects_cr_lf() {
119        assert!(imap_quoted("with\rCR").is_err());
120        assert!(imap_quoted("with\nLF").is_err());
121    }
122
123    #[test]
124    fn test_imap_quoted_rejects_nul_and_8bit() {
125        assert!(imap_quoted("a\0b").is_err());
126        assert!(imap_quoted("café").is_err());
127    }
128
129    #[test]
130    fn test_password_as_imap_quoted_roundtrip() {
131        let p = Password::new("p@ss\"with\\specials");
132        assert_eq!(p.as_imap_quoted().unwrap(), "\"p@ss\\\"with\\\\specials\"");
133    }
134}