Skip to main content

imap_client/
credentials.rs

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