Migrate to encodify
This commit is contained in:
+3
-11
@@ -4,6 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use encodify::hex::decode_pair;
|
||||
use url::Url;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
@@ -111,9 +112,9 @@ fn percent_decode(s: &str) -> String {
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'%'
|
||||
&& i + 2 < bytes.len()
|
||||
&& let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2]))
|
||||
&& let Some(byte) = decode_pair(bytes[i + 1], bytes[i + 2])
|
||||
{
|
||||
buf.push((h << 4) | l);
|
||||
buf.push(byte);
|
||||
i += 3;
|
||||
continue;
|
||||
}
|
||||
@@ -126,15 +127,6 @@ fn percent_decode(s: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
fn hex_val(b: u8) -> Option<u8> {
|
||||
match b {
|
||||
b'0'..=b'9' => Some(b - b'0'),
|
||||
b'a'..=b'f' => Some(b - b'a' + 10),
|
||||
b'A'..=b'F' => Some(b - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn percent_encode_safe(s: &str) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
for byte in s.bytes() {
|
||||
|
||||
@@ -9,8 +9,6 @@ use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use ureq::Agent;
|
||||
use ureq::config::{Config, RedirectAuthHeaders};
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
@@ -168,14 +166,11 @@ impl EwsClient {
|
||||
}
|
||||
|
||||
fn auth_header(&self) -> String {
|
||||
let auth = self.inner.auth.lock().ok().map(|g| g.clone());
|
||||
match auth {
|
||||
Some(Auth::Basic { user, password }) => {
|
||||
format!("Basic {}", STANDARD.encode(format!("{user}:{password}")))
|
||||
}
|
||||
Some(Auth::Bearer { token }) => format!("Bearer {token}"),
|
||||
None => String::new(),
|
||||
}
|
||||
self.inner
|
||||
.auth
|
||||
.lock()
|
||||
.map(|auth| auth.header_value())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn anchor_header(&self) -> Option<String> {
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
use std::io::{self, Write};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use encodify::base64::{Base64, Padding, URL_SAFE};
|
||||
use serde_json::Value;
|
||||
use ureq::config::Config;
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
@@ -19,6 +18,8 @@ pub const SCOPE_APP_ONLY: &str = "https://outlook.office365.com/.default";
|
||||
pub const SCOPE_DELEGATED: &str =
|
||||
"https://outlook.office365.com/EWS.AccessAsUser.All offline_access";
|
||||
|
||||
const JWT_SEGMENT: Base64 = URL_SAFE.with_padding(Padding::Optional).any_alphabet();
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AcquiredToken {
|
||||
pub access_token: String,
|
||||
@@ -81,11 +82,7 @@ pub fn decode_jwt_claims(token: &str) -> Option<JwtClaims> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload))
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
||||
.ok()?;
|
||||
let bytes = JWT_SEGMENT.decode(payload).ok()?;
|
||||
let value: Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(JwtClaims {
|
||||
tenant_id: value.get("tid").and_then(Value::as_str).map(str::to_owned),
|
||||
@@ -329,7 +326,7 @@ fn urlencode(s: &str) -> String {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use encodify::base64::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
|
||||
|
||||
fn make_jwt(tid: &str, upn: &str, exp: u64) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
|
||||
@@ -347,6 +344,19 @@ mod tests {
|
||||
assert_eq!(claims.exp, Some(9999999999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_payload_decodes_in_either_alphabet_with_or_without_padding() {
|
||||
let claims = r#"{"tid":"t-3","upn":"??>>@xy","exp":1}"#;
|
||||
for engine in [URL_SAFE_NO_PAD, URL_SAFE, STANDARD_NO_PAD, STANDARD] {
|
||||
let token = format!("h.{}.s", engine.encode(claims));
|
||||
let decoded = decode_jwt_claims(&token).unwrap();
|
||||
assert_eq!(decoded.upn.as_deref(), Some("??>>@xy"), "{token}");
|
||||
}
|
||||
assert!(decode_jwt_claims("h.eyJ0aWQiOiJ0LTMifQ.s").is_some());
|
||||
assert!(decode_jwt_claims("h.eyJ0aWQiOiJ0LTMifQ=.s").is_none());
|
||||
assert!(decode_jwt_claims("h.eyJ0 aWQiOiJ0LTMifQ.s").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_token_returns_none() {
|
||||
assert!(decode_jwt_claims("garbage").is_none());
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
use std::io::{self, Write};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use encodify::base64::{Base64, Padding, URL_SAFE};
|
||||
use serde_json::Value;
|
||||
use ureq::config::Config;
|
||||
use ureq::tls::{RootCerts, TlsConfig};
|
||||
@@ -18,6 +17,8 @@ use crate::exchange_graph::error::GraphError;
|
||||
pub const SCOPES: &str =
|
||||
"offline_access User.Read Mail.Read MailboxSettings.Read Calendars.Read Contacts.Read";
|
||||
|
||||
const JWT_SEGMENT: Base64 = URL_SAFE.with_padding(Padding::Optional).any_alphabet();
|
||||
|
||||
pub fn default_authority(tenant: &str) -> String {
|
||||
format!("https://login.microsoftonline.com/{tenant}")
|
||||
}
|
||||
@@ -53,11 +54,7 @@ pub fn decode_jwt_claims(token: &str) -> Option<JwtClaims> {
|
||||
let mut parts = token.split('.');
|
||||
let _header = parts.next()?;
|
||||
let payload = parts.next()?;
|
||||
let bytes = URL_SAFE_NO_PAD
|
||||
.decode(payload)
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD_NO_PAD.decode(payload))
|
||||
.or_else(|_| base64::engine::general_purpose::STANDARD.decode(payload))
|
||||
.ok()?;
|
||||
let bytes = JWT_SEGMENT.decode(payload).ok()?;
|
||||
let value: Value = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(JwtClaims {
|
||||
tenant_id: value.get("tid").and_then(Value::as_str).map(str::to_owned),
|
||||
@@ -420,6 +417,7 @@ where
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use encodify::base64::{STANDARD, STANDARD_NO_PAD, URL_SAFE_NO_PAD};
|
||||
|
||||
fn make_jwt(tid: &str, upn: &str, exp: u64) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(b"{\"alg\":\"none\"}");
|
||||
@@ -437,6 +435,19 @@ mod tests {
|
||||
assert_eq!(claims.exp, Some(9999999999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn jwt_payload_decodes_in_either_alphabet_with_or_without_padding() {
|
||||
let claims = r#"{"tid":"t-3","upn":"??>>@xy","exp":1}"#;
|
||||
for engine in [URL_SAFE_NO_PAD, URL_SAFE, STANDARD_NO_PAD, STANDARD] {
|
||||
let token = format!("h.{}.s", engine.encode(claims));
|
||||
let decoded = decode_jwt_claims(&token).unwrap();
|
||||
assert_eq!(decoded.upn.as_deref(), Some("??>>@xy"), "{token}");
|
||||
}
|
||||
assert!(decode_jwt_claims("h.eyJ0aWQiOiJ0LTMifQ.s").is_some());
|
||||
assert!(decode_jwt_claims("h.eyJ0aWQiOiJ0LTMifQ=.s").is_none());
|
||||
assert!(decode_jwt_claims("h.eyJ0 aWQiOiJ0LTMifQ.s").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_token_returns_none() {
|
||||
assert!(decode_jwt_claims("garbage").is_none());
|
||||
|
||||
+4
-13
@@ -8,9 +8,6 @@ use std::collections::BTreeSet;
|
||||
use std::io::{BufReader, Read, Write};
|
||||
use std::time::Instant;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
|
||||
use super::command::{self, CommandBuilder};
|
||||
use super::error::{ImapError, NoError};
|
||||
use super::response::{Response, Status, StatusLine, Untagged, parse_response};
|
||||
@@ -201,9 +198,8 @@ impl ImapClient {
|
||||
payload.extend_from_slice(authcid.as_bytes());
|
||||
payload.push(0);
|
||||
payload.extend_from_slice(password.as_bytes());
|
||||
let encoded = BASE64.encode(&payload);
|
||||
if self.has_capability("SASL-IR") {
|
||||
let cmd = command::authenticate_with_ir("PLAIN", &encoded);
|
||||
let cmd = command::authenticate_with_ir("PLAIN", &payload);
|
||||
return match self.run_collect(&cmd) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ImapError::No(no)) if no.is_auth_failed() => {
|
||||
@@ -218,9 +214,7 @@ impl ImapClient {
|
||||
let resp = parse_response(&mut self.reader)?;
|
||||
match resp {
|
||||
Response::Continuation(_) => {
|
||||
let mut line = encoded.clone().into_bytes();
|
||||
line.extend_from_slice(b"\r\n");
|
||||
self.write_all(&line)?;
|
||||
self.write_all(&command::sasl_response(&payload))?;
|
||||
}
|
||||
Response::Tagged { tag: t, line } if t == tag => {
|
||||
self.update_capabilities_from_code(&line);
|
||||
@@ -260,9 +254,8 @@ impl ImapClient {
|
||||
}
|
||||
|
||||
fn drive_bearer_sasl(&mut self, mechanism: &str, payload: &[u8]) -> Result<(), ImapError> {
|
||||
let encoded = BASE64.encode(payload);
|
||||
if self.has_capability("SASL-IR") {
|
||||
let cmd = command::authenticate_with_ir(mechanism, &encoded);
|
||||
let cmd = command::authenticate_with_ir(mechanism, payload);
|
||||
return match self.run_collect(&cmd) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(ImapError::No(no)) if no.is_auth_failed() => {
|
||||
@@ -281,9 +274,7 @@ impl ImapClient {
|
||||
if sent {
|
||||
self.write_all(b"\r\n")?;
|
||||
} else {
|
||||
let mut line = encoded.clone().into_bytes();
|
||||
line.extend_from_slice(b"\r\n");
|
||||
self.write_all(&line)?;
|
||||
self.write_all(&command::sasl_response(payload))?;
|
||||
sent = true;
|
||||
}
|
||||
}
|
||||
|
||||
+39
-2
@@ -6,6 +6,11 @@
|
||||
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use encodify::base64::STANDARD;
|
||||
|
||||
const AUTHENTICATE: &str = "AUTHENTICATE ";
|
||||
const CRLF: &[u8] = b"\r\n";
|
||||
|
||||
pub struct CommandBuilder {
|
||||
next_tag: u32,
|
||||
}
|
||||
@@ -84,8 +89,22 @@ pub fn authenticate(mechanism: &str) -> String {
|
||||
format!("AUTHENTICATE {mechanism}")
|
||||
}
|
||||
|
||||
pub fn authenticate_with_ir(mechanism: &str, initial_response: &str) -> String {
|
||||
format!("AUTHENTICATE {mechanism} {initial_response}")
|
||||
pub fn authenticate_with_ir(mechanism: &str, initial_response: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(
|
||||
AUTHENTICATE.len() + mechanism.len() + 1 + STANDARD.encoded_len(initial_response.len()),
|
||||
);
|
||||
out.push_str(AUTHENTICATE);
|
||||
out.push_str(mechanism);
|
||||
out.push(' ');
|
||||
STANDARD.encode_append(initial_response, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn sasl_response(response: &[u8]) -> Vec<u8> {
|
||||
let mut line = Vec::with_capacity(STANDARD.encoded_len(response.len()) + CRLF.len());
|
||||
STANDARD.encode_append(response, &mut line);
|
||||
line.extend_from_slice(CRLF);
|
||||
line
|
||||
}
|
||||
|
||||
pub fn capability() -> &'static str {
|
||||
@@ -282,6 +301,24 @@ mod tests {
|
||||
assert_eq!(b.next_tag(), "A0003");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authenticate_with_ir_appends_the_base64_initial_response() {
|
||||
assert_eq!(
|
||||
authenticate_with_ir("PLAIN", b"\0foo\0bar"),
|
||||
"AUTHENTICATE PLAIN AGZvbwBiYXI="
|
||||
);
|
||||
assert_eq!(
|
||||
authenticate_with_ir("XOAUTH2", b""),
|
||||
"AUTHENTICATE XOAUTH2 "
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sasl_response_is_base64_with_crlf() {
|
||||
assert_eq!(sasl_response(b"\0foo\0bar"), b"AGZvbwBiYXI=\r\n");
|
||||
assert_eq!(sasl_response(b""), b"\r\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_appends_crlf() {
|
||||
let mut b = CommandBuilder::new();
|
||||
|
||||
+54
-176
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use encodify::utf7::{self, Utf7};
|
||||
|
||||
use super::error::ImapError;
|
||||
|
||||
const MODIFIED_UTF7_ALPHABET: &[u8] =
|
||||
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+,";
|
||||
const RECEIVED_NAME: Utf7 = utf7::IMAP.lenient();
|
||||
|
||||
pub fn canonicalise_inbox(name: &str) -> String {
|
||||
if name.eq_ignore_ascii_case("INBOX") {
|
||||
@@ -22,145 +23,17 @@ pub fn decode_mailbox_name(input: &str) -> Result<String, ImapError> {
|
||||
}
|
||||
|
||||
pub fn decode_mailbox_name_with(input: &str, utf8_accept: bool) -> Result<String, ImapError> {
|
||||
if utf8_accept {
|
||||
if utf8_accept || ends_in_open_shift(input) {
|
||||
return Ok(input.to_owned());
|
||||
}
|
||||
if input.is_ascii() && !input.contains('&') {
|
||||
return Ok(input.to_owned());
|
||||
}
|
||||
let bytes = input.as_bytes();
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] != b'&' {
|
||||
let start = i;
|
||||
while i < bytes.len() && bytes[i] != b'&' {
|
||||
i += 1;
|
||||
}
|
||||
out.push_str(&input[start..i]);
|
||||
continue;
|
||||
}
|
||||
if i + 1 < bytes.len() && bytes[i + 1] == b'-' {
|
||||
out.push('&');
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
let mut end = i + 1;
|
||||
while end < bytes.len() && bytes[end] != b'-' {
|
||||
end += 1;
|
||||
}
|
||||
if end == bytes.len() {
|
||||
return Err(ImapError::Parse(
|
||||
"unterminated modified UTF-7 sequence".into(),
|
||||
));
|
||||
}
|
||||
let encoded = &bytes[i + 1..end];
|
||||
let decoded = decode_b64_modified(encoded)?;
|
||||
let u16s: Vec<u16> = decoded
|
||||
.chunks(2)
|
||||
.filter(|c| c.len() == 2)
|
||||
.map(|c| u16::from_be_bytes([c[0], c[1]]))
|
||||
.collect();
|
||||
let s = String::from_utf16(&u16s).map_err(|e| ImapError::Parse(format!("utf-16: {e}")))?;
|
||||
out.push_str(&s);
|
||||
i = end + 1;
|
||||
}
|
||||
Ok(out)
|
||||
RECEIVED_NAME
|
||||
.decode(input)
|
||||
.map_err(|e| ImapError::Parse(format!("modified UTF-7 mailbox name: {e}")))
|
||||
}
|
||||
|
||||
pub fn alternate_mailbox_name(input: &str, utf8_accept: bool) -> Option<String> {
|
||||
let primary = encode_mailbox_name_with(input, utf8_accept);
|
||||
let alternate = encode_mailbox_name_with(input, !utf8_accept);
|
||||
if alternate == primary {
|
||||
None
|
||||
} else {
|
||||
Some(alternate)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn encode_mailbox_name(input: &str) -> String {
|
||||
encode_mailbox_name_with(input, false)
|
||||
}
|
||||
|
||||
pub fn encode_mailbox_name_with(input: &str, utf8_accept: bool) -> String {
|
||||
if utf8_accept {
|
||||
return input.to_owned();
|
||||
}
|
||||
let mut out = String::with_capacity(input.len());
|
||||
let mut pending: Vec<u16> = Vec::new();
|
||||
let flush = |pending: &mut Vec<u16>, out: &mut String| {
|
||||
if pending.is_empty() {
|
||||
return;
|
||||
}
|
||||
out.push('&');
|
||||
let mut raw = Vec::with_capacity(pending.len() * 2);
|
||||
for w in pending.iter() {
|
||||
raw.extend_from_slice(&w.to_be_bytes());
|
||||
}
|
||||
out.push_str(&encode_b64_modified(&raw));
|
||||
out.push('-');
|
||||
pending.clear();
|
||||
};
|
||||
for c in input.chars() {
|
||||
let cp = c as u32;
|
||||
if c == '&' {
|
||||
flush(&mut pending, &mut out);
|
||||
out.push_str("&-");
|
||||
} else if (0x20..=0x7E).contains(&cp) {
|
||||
flush(&mut pending, &mut out);
|
||||
out.push(c);
|
||||
} else {
|
||||
let mut buf = [0u16; 2];
|
||||
let units = c.encode_utf16(&mut buf);
|
||||
pending.extend_from_slice(units);
|
||||
}
|
||||
}
|
||||
flush(&mut pending, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn decode_b64_modified(bytes: &[u8]) -> Result<Vec<u8>, ImapError> {
|
||||
let mut out = Vec::with_capacity(bytes.len() * 3 / 4);
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in bytes {
|
||||
let v = match MODIFIED_UTF7_ALPHABET.iter().position(|&x| x == b) {
|
||||
Some(idx) => idx as u32,
|
||||
None => {
|
||||
return Err(ImapError::Parse(format!(
|
||||
"invalid modified UTF-7 byte {b:#04x}"
|
||||
)));
|
||||
}
|
||||
};
|
||||
acc = (acc << 6) | v;
|
||||
bits += 6;
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
let byte = ((acc >> bits) & 0xFF) as u8;
|
||||
out.push(byte);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn encode_b64_modified(bytes: &[u8]) -> String {
|
||||
let mut out = String::with_capacity(bytes.len() * 4 / 3 + 1);
|
||||
let mut acc: u32 = 0;
|
||||
let mut bits: u32 = 0;
|
||||
for &b in bytes {
|
||||
acc = (acc << 8) | b as u32;
|
||||
bits += 8;
|
||||
while bits >= 6 {
|
||||
bits -= 6;
|
||||
let idx = ((acc >> bits) & 0x3F) as usize;
|
||||
out.push(MODIFIED_UTF7_ALPHABET[idx] as char);
|
||||
}
|
||||
}
|
||||
if bits > 0 {
|
||||
let idx = ((acc << (6 - bits)) & 0x3F) as usize;
|
||||
out.push(MODIFIED_UTF7_ALPHABET[idx] as char);
|
||||
}
|
||||
out
|
||||
fn ends_in_open_shift(name: &str) -> bool {
|
||||
name.rsplit_once('&')
|
||||
.is_some_and(|(_, tail)| !tail.is_empty() && !tail.contains('-'))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -186,27 +59,52 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unterminated_sequence_errors() {
|
||||
let err = decode_mailbox_name("&ZeVnLIqe").unwrap_err();
|
||||
fn raw_ampersand_without_a_closing_dash_keeps_the_name_verbatim() {
|
||||
for name in [
|
||||
"R&D",
|
||||
"AT&T",
|
||||
"Tom&Jerry",
|
||||
"A&-B&C",
|
||||
"R&D Team",
|
||||
"&ZeVnLIqe",
|
||||
"Envoy&AOk",
|
||||
] {
|
||||
assert_eq!(decode_mailbox_name(name).unwrap(), name);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trailing_lone_ampersand_is_literal() {
|
||||
assert_eq!(decode_mailbox_name("Sales&").unwrap(), "Sales&");
|
||||
assert_eq!(decode_mailbox_name("&AOk-s&").unwrap(), "és&");
|
||||
assert_eq!(decode_mailbox_name("&").unwrap(), "&");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encoded_name_with_a_later_escaped_ampersand_decodes() {
|
||||
assert_eq!(decode_mailbox_name("Envoy&AOk-s").unwrap(), "Envoyés");
|
||||
assert_eq!(decode_mailbox_name("&AOk-/R&-D").unwrap(), "é/R&D");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn byte_outside_the_alphabet_inside_a_shift_errors() {
|
||||
for name in ["&AOk.s-", "&AOk&-", "&AOké-"] {
|
||||
let err = decode_mailbox_name(name).unwrap_err();
|
||||
assert!(matches!(err, ImapError::Parse(_)), "{name}: {err:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unpaired_surrogate_errors() {
|
||||
let err = decode_mailbox_name("&2D0-").unwrap_err();
|
||||
assert!(matches!(err, ImapError::Parse(_)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_passes_through_encode() {
|
||||
assert_eq!(encode_mailbox_name("INBOX"), "INBOX");
|
||||
assert_eq!(encode_mailbox_name("Sent Items"), "Sent Items");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn literal_amp_encodes_to_amp_dash() {
|
||||
assert_eq!(encode_mailbox_name("R&D"), "R&-D");
|
||||
assert_eq!(encode_mailbox_name("&"), "&-");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_ascii_encode_japanese() {
|
||||
let encoded = encode_mailbox_name("~peter/mail/日本語/台北");
|
||||
assert_eq!(encoded, "~peter/mail/&ZeVnLIqe-/&U,BTFw-");
|
||||
fn encoded_printable_ascii_and_leftover_bits_are_accepted() {
|
||||
assert_eq!(decode_mailbox_name("&AGE-").unwrap(), "a");
|
||||
assert_eq!(decode_mailbox_name("&AOl-").unwrap(), "é");
|
||||
assert_eq!(decode_mailbox_name("&AOk-&AOk-").unwrap(), "éé");
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -220,7 +118,7 @@ mod tests {
|
||||
"INBOX.Projects.Alpha",
|
||||
"Junk E-mail",
|
||||
] {
|
||||
let enc = encode_mailbox_name(s);
|
||||
let enc = utf7::IMAP.encode(s);
|
||||
let dec = decode_mailbox_name(&enc).unwrap();
|
||||
assert_eq!(dec, s, "roundtrip failed for {s:?}, enc={enc:?}");
|
||||
}
|
||||
@@ -247,34 +145,14 @@ mod tests {
|
||||
#[test]
|
||||
fn raw_utf8_name_round_trips_through_encode() {
|
||||
for s in ["Envoyés", "Gönderilmiş Postalar", "Çöp kutusu"] {
|
||||
assert_eq!(decode_mailbox_name(&encode_mailbox_name(s)).unwrap(), s);
|
||||
assert_eq!(decode_mailbox_name(&utf7::IMAP.encode(s)).unwrap(), s);
|
||||
assert_eq!(decode_mailbox_name(s).unwrap(), s);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alternate_encoding_offered_only_for_non_ascii_names() {
|
||||
assert_eq!(
|
||||
alternate_mailbox_name("Envoyés", false).as_deref(),
|
||||
Some("Envoyés")
|
||||
);
|
||||
assert_eq!(
|
||||
alternate_mailbox_name("Envoyés", true).as_deref(),
|
||||
Some("Envoy&AOk-s")
|
||||
);
|
||||
assert_eq!(alternate_mailbox_name("INBOX", false), None);
|
||||
assert_eq!(alternate_mailbox_name("Sent Items", true), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_accept_skips_mutf7_decode_so_ampersand_passes_through() {
|
||||
assert_eq!(decode_mailbox_name_with("R&D", true).unwrap(), "R&D");
|
||||
assert_eq!(decode_mailbox_name_with("日本語", true).unwrap(), "日本語");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn utf8_accept_skips_mutf7_encode_so_input_passes_through() {
|
||||
assert_eq!(encode_mailbox_name_with("日本語", true), "日本語");
|
||||
assert_eq!(encode_mailbox_name_with("R&D", true), "R&D");
|
||||
}
|
||||
}
|
||||
|
||||
+6
-4
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use encodify::base64::STANDARD;
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::jmap::error::JmapError;
|
||||
@@ -14,6 +13,7 @@ pub const SENTINEL_KEY: &str = "@blob";
|
||||
|
||||
const DEFAULT_MEDIA_TYPE: &str = "application/octet-stream";
|
||||
const MEDIA_TYPE_KEYS: [&str; 2] = ["mediaType", "contentType"];
|
||||
const DATA_URI_OVERHEAD: usize = "data:;base64,".len();
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum InlineShape {
|
||||
@@ -103,11 +103,13 @@ fn data_uri(map: &Map<String, Value>, bytes: &[u8]) -> String {
|
||||
.iter()
|
||||
.find_map(|k| map.get(*k).and_then(Value::as_str).and_then(uri_media_type))
|
||||
.unwrap_or_else(|| DEFAULT_MEDIA_TYPE.to_owned());
|
||||
let mut uri = String::with_capacity(13 + media_type.len() + bytes.len().div_ceil(3) * 4);
|
||||
let mut uri = String::with_capacity(
|
||||
DATA_URI_OVERHEAD + media_type.len() + STANDARD.encoded_len(bytes.len()),
|
||||
);
|
||||
uri.push_str("data:");
|
||||
uri.push_str(&media_type);
|
||||
uri.push_str(";base64,");
|
||||
STANDARD.encode_string(bytes, &mut uri);
|
||||
STANDARD.encode_append(bytes, &mut uri);
|
||||
uri
|
||||
}
|
||||
|
||||
|
||||
+9
-3
@@ -9,8 +9,7 @@ use std::sync::OnceLock;
|
||||
use std::sync::atomic::{AtomicU8, AtomicU64, Ordering};
|
||||
use std::time::{Duration, Instant, SystemTime};
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use encodify::base64::STANDARD;
|
||||
use serde_json::Value;
|
||||
use ureq::Agent;
|
||||
use ureq::config::{Config, RedirectAuthHeaders};
|
||||
@@ -27,6 +26,8 @@ const MAX_BODY: u64 = 512 * 1024 * 1024;
|
||||
|
||||
const LONG_RETRY_THRESHOLD: Duration = Duration::from_secs(10);
|
||||
|
||||
const BASIC: &str = "Basic ";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum Auth {
|
||||
Basic { user: String, password: String },
|
||||
@@ -37,7 +38,12 @@ impl Auth {
|
||||
pub fn header_value(&self) -> String {
|
||||
match self {
|
||||
Auth::Basic { user, password } => {
|
||||
format!("Basic {}", STANDARD.encode(format!("{user}:{password}")))
|
||||
let credentials = format!("{user}:{password}");
|
||||
let mut header =
|
||||
String::with_capacity(BASIC.len() + STANDARD.encoded_len(credentials.len()));
|
||||
header.push_str(BASIC);
|
||||
STANDARD.encode_append(credentials, &mut header);
|
||||
header
|
||||
}
|
||||
Auth::Bearer { token } => format!("Bearer {token}"),
|
||||
}
|
||||
|
||||
@@ -7,8 +7,7 @@
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::time::Instant;
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use encodify::base64::STANDARD;
|
||||
|
||||
use crate::imap::transport::{Connector, ImapStream};
|
||||
use crate::logging::Logger;
|
||||
@@ -201,17 +200,17 @@ impl SieveClient {
|
||||
payload.extend_from_slice(authcid.as_bytes());
|
||||
payload.push(0);
|
||||
payload.extend_from_slice(password.as_bytes());
|
||||
let encoded = BASE64.encode(&payload);
|
||||
let encoded = STANDARD.encode(&payload);
|
||||
self.send_authenticate("PLAIN", &encoded)
|
||||
}
|
||||
|
||||
pub fn authenticate_login(&mut self, authcid: &str, password: &str) -> Result<(), SieveError> {
|
||||
self.fresh_post_auth_caps = false;
|
||||
self.write_all(command::authenticate("LOGIN").as_bytes())?;
|
||||
let user_payload = BASE64.encode(authcid.as_bytes());
|
||||
let user_payload = STANDARD.encode(authcid);
|
||||
self.expect_continuation()?;
|
||||
self.write_all(command::continuation_payload(&user_payload).as_bytes())?;
|
||||
let pass_payload = BASE64.encode(password.as_bytes());
|
||||
let pass_payload = STANDARD.encode(password);
|
||||
self.expect_continuation()?;
|
||||
self.write_all(command::continuation_payload(&pass_payload).as_bytes())?;
|
||||
let block = read_response(&mut self.reader)?;
|
||||
@@ -237,7 +236,7 @@ impl SieveClient {
|
||||
payload.push_str(token);
|
||||
payload.push('\x01');
|
||||
payload.push('\x01');
|
||||
let encoded = BASE64.encode(payload.as_bytes());
|
||||
let encoded = STANDARD.encode(&payload);
|
||||
self.send_authenticate("OAUTHBEARER", &encoded)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use encodify::base64::LENIENT;
|
||||
|
||||
use crate::db::blobs;
|
||||
use crate::error::Error;
|
||||
@@ -56,13 +55,12 @@ pub fn fetch_attachments(
|
||||
let resp = ctx.client.call(ctx.url, "GetAttachment", &body)?;
|
||||
let inline = parse_get_attachment_inline(&resp.body)?;
|
||||
for att in inline {
|
||||
let cleaned = strip_ascii_whitespace(att.content_base64.as_bytes());
|
||||
if cleaned.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let bytes = STANDARD.decode(&cleaned).map_err(|e| {
|
||||
let bytes = LENIENT.decode(&att.content_base64).map_err(|e| {
|
||||
EwsError::Malformed(format!("attachment {}: base64: {e}", att.attachment_id))
|
||||
})?;
|
||||
if bytes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let media_type = att
|
||||
.content_type
|
||||
.unwrap_or_else(|| "application/octet-stream".to_owned());
|
||||
@@ -79,25 +77,3 @@ pub fn fetch_attachments(
|
||||
pub fn intern_attachment(conn: &rusqlite::Connection, bytes: &[u8]) -> Result<i64, Error> {
|
||||
blobs::intern_blob(conn, bytes).map_err(|e| Error::Partial(e.to_string()))
|
||||
}
|
||||
|
||||
fn strip_ascii_whitespace(input: &[u8]) -> Vec<u8> {
|
||||
let mut out = Vec::with_capacity(input.len());
|
||||
for b in input {
|
||||
if !matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
|
||||
out.push(*b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::strip_ascii_whitespace;
|
||||
|
||||
#[test]
|
||||
fn strips_whitespace_efficiently() {
|
||||
let input = b"AB CD\nEF\tGH\r\nIJ";
|
||||
let cleaned = strip_ascii_whitespace(input);
|
||||
assert_eq!(cleaned, b"ABCDEFGHIJ");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@
|
||||
* SPDX-License-Identifier: Apache-2.0 OR MIT
|
||||
*/
|
||||
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD;
|
||||
use encodify::base64::LENIENT;
|
||||
use rusqlite::{Connection, params};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -164,18 +163,15 @@ fn apply_message(
|
||||
existing_local_id: Option<i64>,
|
||||
counts: &mut TypeCounts,
|
||||
) -> Result<(), Error> {
|
||||
let mime_b64 = match item.mime_content.as_ref() {
|
||||
Some(s) => s.replace(['\n', '\r', ' ', '\t'], ""),
|
||||
None => {
|
||||
counts.skipped += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"message {} has no MimeContent; skipping",
|
||||
item.id.id
|
||||
));
|
||||
return Ok(());
|
||||
}
|
||||
let Some(mime_b64) = item.mime_content.as_ref() else {
|
||||
counts.skipped += 1;
|
||||
ctx.logger.warn(&format!(
|
||||
"message {} has no MimeContent; skipping",
|
||||
item.id.id
|
||||
));
|
||||
return Ok(());
|
||||
};
|
||||
let bytes = match STANDARD.decode(mime_b64.as_bytes()) {
|
||||
let bytes = match LENIENT.decode(mime_b64) {
|
||||
Ok(b) => b,
|
||||
Err(e) => {
|
||||
counts.failed += 1;
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use encodify::base64::STANDARD;
|
||||
use rusqlite::{Connection, Transaction, params};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -32,7 +33,6 @@ fn fetch_event_attachments(
|
||||
endpoints: &crate::exchange_graph::api::Endpoints,
|
||||
event_id: &str,
|
||||
) -> Vec<EventAttachment> {
|
||||
use base64::Engine;
|
||||
let url = endpoints.event_attachments(event_id);
|
||||
let Ok(body) = client.get_json_with_prefer(&url, &[]) else {
|
||||
return Vec::new();
|
||||
@@ -50,7 +50,7 @@ fn fetch_event_attachments(
|
||||
let Some(encoded) = item.get("contentBytes").and_then(Value::as_str) else {
|
||||
continue;
|
||||
};
|
||||
let Ok(bytes) = base64::engine::general_purpose::STANDARD.decode(encoded) else {
|
||||
let Ok(bytes) = STANDARD.decode(encoded) else {
|
||||
continue;
|
||||
};
|
||||
if bytes.is_empty() {
|
||||
|
||||
@@ -20,7 +20,6 @@ use crate::error::Error;
|
||||
use crate::imap::client::{ConnectMode, ImapClient};
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with};
|
||||
use crate::imap::response::{NamespaceEntry, Untagged};
|
||||
use crate::imap::retry::{
|
||||
BackoffState, Disposition, RetryPolicy, classify, is_negotiation_failure,
|
||||
@@ -114,28 +113,9 @@ pub(super) fn control_run_collect(
|
||||
pub(super) fn select_folder(
|
||||
client: &mut ImapClient,
|
||||
ctx: &ControlCtx,
|
||||
folder: &str,
|
||||
folder: &ResolvedFolder,
|
||||
) -> Result<crate::imap::CollectedResponse, Error> {
|
||||
let utf8_accept = client.utf8_accept();
|
||||
let wire_name = encode_mailbox_name_with(folder, utf8_accept);
|
||||
let first = control_run_collect(client, ctx, &command::select(&wire_name));
|
||||
let Err(Error::Partial(_)) = &first else {
|
||||
return first;
|
||||
};
|
||||
let Some(alternate) = alternate_mailbox_name(folder, utf8_accept) else {
|
||||
return first;
|
||||
};
|
||||
match control_run_collect(client, ctx, &command::select(&alternate)) {
|
||||
Ok(r) => {
|
||||
log_at(
|
||||
ctx.logger,
|
||||
LEVEL_DEFAULT,
|
||||
&format!("folder {folder:?}: selected with the alternate mailbox-name encoding"),
|
||||
);
|
||||
Ok(r)
|
||||
}
|
||||
Err(_) => first,
|
||||
}
|
||||
control_run_collect(client, ctx, &command::select(&folder.wire_name))
|
||||
}
|
||||
|
||||
pub(super) fn call_with_retry<F, T>(
|
||||
@@ -740,7 +720,7 @@ fn reconcile_folder(
|
||||
}
|
||||
}
|
||||
|
||||
let resp = select_folder(client, control_ctx, &folder.name)?;
|
||||
let resp = select_folder(client, control_ctx, folder)?;
|
||||
let mut uidvalidity: u32 = 0;
|
||||
let mut uidnext: u32 = 0;
|
||||
for u in &resp.untagged {
|
||||
@@ -826,6 +806,7 @@ fn reconcile_folder(
|
||||
for batch in &batches {
|
||||
pool.submit(FetchJob {
|
||||
folder: folder.name.clone(),
|
||||
wire_name: folder.wire_name.clone(),
|
||||
uidvalidity,
|
||||
uids: batch.to_vec(),
|
||||
});
|
||||
@@ -1149,7 +1130,7 @@ fn dry_run_summary(
|
||||
}
|
||||
|
||||
for folder in folders {
|
||||
let select_resp = match select_folder(client, control_ctx, &folder.name) {
|
||||
let select_resp = match select_folder(client, control_ctx, folder) {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
log_at(
|
||||
|
||||
@@ -22,6 +22,7 @@ pub struct FolderStatus {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct DiscoveredFolder {
|
||||
pub name: String,
|
||||
pub wire_name: String,
|
||||
pub delimiter: Option<char>,
|
||||
pub attributes: Vec<String>,
|
||||
pub subscribed: bool,
|
||||
@@ -32,6 +33,7 @@ pub struct DiscoveredFolder {
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ResolvedFolder {
|
||||
pub name: String,
|
||||
pub wire_name: String,
|
||||
pub leaf: String,
|
||||
pub parent_path: Option<String>,
|
||||
pub delimiter: Option<char>,
|
||||
@@ -85,6 +87,7 @@ pub fn collect_from_list(
|
||||
canonical.clone(),
|
||||
DiscoveredFolder {
|
||||
name: canonical,
|
||||
wire_name: name.clone(),
|
||||
delimiter: *delimiter,
|
||||
attributes: attributes.clone(),
|
||||
subscribed: false,
|
||||
@@ -156,6 +159,7 @@ pub fn apply_filters(
|
||||
let (leaf, parent_path) = split_parent(&f.name, delim);
|
||||
resolved.push(ResolvedFolder {
|
||||
name: f.name,
|
||||
wire_name: f.wire_name,
|
||||
leaf,
|
||||
parent_path,
|
||||
delimiter: delim,
|
||||
@@ -437,6 +441,7 @@ mod tests {
|
||||
let mut r = vec![
|
||||
ResolvedFolder {
|
||||
name: "Projects/Alpha".into(),
|
||||
wire_name: "Projects/Alpha".into(),
|
||||
leaf: "Alpha".into(),
|
||||
parent_path: Some("Projects".into()),
|
||||
delimiter: Some('/'),
|
||||
@@ -446,6 +451,7 @@ mod tests {
|
||||
},
|
||||
ResolvedFolder {
|
||||
name: "INBOX".into(),
|
||||
wire_name: "INBOX".into(),
|
||||
leaf: "INBOX".into(),
|
||||
parent_path: None,
|
||||
delimiter: Some('/'),
|
||||
@@ -455,6 +461,7 @@ mod tests {
|
||||
},
|
||||
ResolvedFolder {
|
||||
name: "Projects".into(),
|
||||
wire_name: "Projects".into(),
|
||||
leaf: "Projects".into(),
|
||||
parent_path: None,
|
||||
delimiter: Some('/'),
|
||||
|
||||
@@ -13,7 +13,6 @@ use crossbeam_channel::{Receiver, Sender, unbounded};
|
||||
use crate::imap::client::{ConnectMode, ImapClient};
|
||||
use crate::imap::command;
|
||||
use crate::imap::error::ImapError;
|
||||
use crate::imap::name::{alternate_mailbox_name, encode_mailbox_name_with};
|
||||
use crate::imap::response::Untagged;
|
||||
use crate::imap::retry::{BackoffState, Disposition, RetryPolicy, classify};
|
||||
use crate::imap::transport::Connector;
|
||||
@@ -26,6 +25,7 @@ pub const HARD_CAP: usize = 8;
|
||||
|
||||
pub struct FetchJob {
|
||||
pub folder: String,
|
||||
pub wire_name: String,
|
||||
pub uidvalidity: u32,
|
||||
pub uids: Vec<u32>,
|
||||
}
|
||||
@@ -231,16 +231,7 @@ fn run_one_job(
|
||||
event_tx: &Sender<FetchEvent>,
|
||||
) -> Result<(), ImapError> {
|
||||
if current_folder.as_deref() != Some(job.folder.as_str()) {
|
||||
let utf8_accept = client.utf8_accept();
|
||||
let wire = encode_mailbox_name_with(&job.folder, utf8_accept);
|
||||
if let Err(e) = client.run_collect(&command::select(&wire)) {
|
||||
match alternate_mailbox_name(&job.folder, utf8_accept) {
|
||||
Some(alternate) if matches!(e, ImapError::No(_)) => {
|
||||
client.run_collect(&command::select(&alternate))?;
|
||||
}
|
||||
_ => return Err(e),
|
||||
}
|
||||
}
|
||||
client.run_collect(&command::select(&job.wire_name))?;
|
||||
*current_folder = Some(job.folder.clone());
|
||||
}
|
||||
let set = command::format_uid_set(&job.uids, true);
|
||||
|
||||
@@ -897,6 +897,7 @@ fn opt_format_utc(value: &Option<time::OffsetDateTime>) -> Result<Option<String>
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db::init;
|
||||
use encodify::base64::STANDARD;
|
||||
use std::collections::HashMap;
|
||||
|
||||
struct MapResolver {
|
||||
@@ -929,15 +930,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn decode_data_uri(value: &Value, media_type: &str) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
let uri = value.as_str().unwrap_or_else(|| panic!("{value} is a URI"));
|
||||
let prefix = format!("data:{media_type};base64,");
|
||||
let payload = uri
|
||||
.strip_prefix(&prefix)
|
||||
.unwrap_or_else(|| panic!("{uri} does not start with {prefix}"));
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(payload)
|
||||
.expect("base64 payload")
|
||||
STANDARD.decode(payload).expect("base64 payload")
|
||||
}
|
||||
|
||||
fn assert_no_blob_id(value: &Value) {
|
||||
|
||||
Reference in New Issue
Block a user