mirror of
https://github.com/storytold/craft-libs.git
synced 2026-11-13 22:12:12 +00:00
craft-mime: strict base64, mailto links and code page labels from MailCraft (#934)
What MailCraft's mime and charset crates still had that craft-mime did not: strict RFC 4648 base64 (crates/charset), mailto: links (RFC 6068, crates/mime) and the Windows code page to charset label table (crates/pst). With these, MailCraft's charset crate has nothing craft-mime lacks. Co-authored-by: Martin Ball <98639962+benandez@users.noreply.github.com>
This commit is contained in:
1 parent
2e0bbc3a9c
commit
090f121943
6 files changed
+551
-7
No files matched your search
+11
-6
@@ -16,6 +16,10 @@ MHTML web archives and mbox files.
|
||||
qp_decode}`, `charset::{decode, guess}`, `parse_address_list` / `format_address_list`,
|
||||
`parse_date` / `format_date`, `split_multipart`, `mbox_messages` / `write_mbox_message`, and
|
||||
`Resources` / `start_part` / `resolve_location` for `multipart/related`.
|
||||
- **Beside MIME**, what the same callers need: `parse_mailto` reads a `mailto:` link (RFC 6068:
|
||||
recipients, subject and body only, sizes capped, no line break into a header), `base64` is
|
||||
strict RFC 4648 (both alphabets, padded or not, one spelling per value) for keys, tokens and
|
||||
credentials, and `charset::code_page_label` names the character set of a Windows code page.
|
||||
|
||||
Written from the public RFCs: 5322 (messages), 2045–2049 (MIME), 2047 (encoded-words), 2231
|
||||
(parameter values), 2387 and 2557 (`multipart/related`, MHTML), 4155 (mbox, `mboxrd` quoting).
|
||||
@@ -89,10 +93,7 @@ always there as `headers.get("date")`.
|
||||
- S/MIME and PGP: detecting protected messages, canonical CRLF form, keeping the original
|
||||
bytes. `split_headers` and `split_multipart` give the raw slices those need.
|
||||
- File-name-to-media-type tables and default attachment names.
|
||||
- `mailto:` links (RFC 6068), which are not MIME.
|
||||
- For MHT: picking the page, reading its HTML, and the budget for pictures loaded.
|
||||
- Strict base64 (RFC 4648 with refusals, URL-safe alphabet) for keys and tokens. This crate has
|
||||
the mail kind only: an encoder, and a decoder that skips whatever is not base64.
|
||||
|
||||
## Migration
|
||||
|
||||
@@ -113,7 +114,8 @@ No app uses this crate yet. These tables say what replaces what.
|
||||
| `parse::Walk` (the recursion over parts, depth and part caps) | `parse` / `parse_entity`, then a walk over `Part`s |
|
||||
| `parse::is_media_type`, content id and file name extraction | `Part::declared_media_type`, `content_id`, `file_name`, `disposition` |
|
||||
| `charset::decode`, `guess`, `unix_lines` (`mailcraft-charset`) | `charset::` the same names |
|
||||
| `mailcraft_charset::base64` (strict) | stays |
|
||||
| `mailcraft_charset::base64` (strict) | `base64::` the same names (`STANDARD`, `STANDARD_NO_PAD`, `URL_SAFE`, `URL_SAFE_NO_PAD`, `Config`, `DecodeError`) |
|
||||
| `mailcraft_pst::code_page_label` | `charset::code_page_label` |
|
||||
| `address::parse_list`, `format_address`, `format_list` | `parse_address_list`, `format_address`, `format_address_list`, on `Mailbox { name, email }` |
|
||||
| `mailcraft_time::parse_rfc5322`, `format_rfc5322` | `parse_date`, `format_date`, on `DateTime` |
|
||||
| `mbox::mbox_messages`, `parse_mbox` | `mbox_messages` gives each message's bytes; MailCraft parses them |
|
||||
@@ -126,7 +128,8 @@ No app uses this crate yet. These tables say what replaces what.
|
||||
| `media::safe_media_type` | `safe_media_type` |
|
||||
| `media::media_type_for`, `default_name` | stay |
|
||||
| `parse::read_headers`, `write::write_header`, `calendar_form`, `body` | stay (model mapping) |
|
||||
| `protected`, `signed`, `rich`, `mailto` | stay |
|
||||
| `mailto::{Mailto, is_mailto, parse_mailto}` | the same names; `mailto::cut`, which `rich` borrows, stays |
|
||||
| `protected`, `signed`, `rich` | stay |
|
||||
|
||||
### WordCraft (`crates/formats/src/mht.rs`)
|
||||
|
||||
@@ -153,7 +156,9 @@ settable).
|
||||
| Part | Source |
|
||||
|---|---|
|
||||
| Headers, parameters, encoded-words, transfer encodings, address lists, multipart splitting, mbox, the writer's parts and boundaries, their tests | MailCraft `crates/mime` [a44a450](https://github.com/UCS-Care-Group/mailcraft/commit/a44a45013d1c6396229c40ab60172d1882b368d5) |
|
||||
| Character sets | MailCraft `crates/charset`, same commit |
|
||||
| Character sets, strict base64 | MailCraft `crates/charset`, same commit |
|
||||
| `mailto:` links | MailCraft `crates/mime` (`mailto.rs`), same commit |
|
||||
| Code page labels | MailCraft `crates/pst` (`codepage.rs`), same commit |
|
||||
| Date parsing and formatting | MailCraft `crates/time` (`text.rs`), same commit; the calendar arithmetic replaced by `chrono` |
|
||||
| `multipart/related` lookups, location resolving, comment-aware parameters, the MHT tests | WordCraft `crates/formats/src/mht.rs` [639b813](https://github.com/storytold/wordcraft/commit/639b8133ea31efb63aac381a7ced8b8aed32aa30) |
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Base64 (RFC 4648 sections 4 and 5), written from the RFC.
|
||||
//!
|
||||
//! Four ready-made [`Config`]s: [`STANDARD`] (the `+/` alphabet, padded with `=`),
|
||||
//! [`STANDARD_NO_PAD`], [`URL_SAFE`] (the `-_` alphabet, padded) and [`URL_SAFE_NO_PAD`].
|
||||
//!
|
||||
//! Encoding is the RFC's. **Decoding is strict**: nothing but the config's alphabet (no white
|
||||
//! space, no line breaks), padding exactly as the config says (a padded config requires it and
|
||||
//! counts it; an unpadded one refuses `=`), a length that can be, and the unused bits of the last
|
||||
//! symbol zero (so every value has one spelling). A caller that reads mail, where line breaks and
|
||||
//! missing padding are everyday, cleans its input first, as it always has; the strictness is what
|
||||
//! the signature, key and credential checks want. No input panics, and the output is never longer
|
||||
//! than three quarters of the input.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
/// Which alphabet and whether padded.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct Config {
|
||||
url_safe: bool,
|
||||
padded: bool,
|
||||
}
|
||||
|
||||
/// RFC 4648 section 4: `A-Za-z0-9+/`, padded with `=`.
|
||||
pub const STANDARD: Config = Config { url_safe: false, padded: true };
|
||||
/// Section 4 without padding.
|
||||
pub const STANDARD_NO_PAD: Config = Config { url_safe: false, padded: false };
|
||||
/// Section 5: `A-Za-z0-9-_`, padded with `=`.
|
||||
pub const URL_SAFE: Config = Config { url_safe: true, padded: true };
|
||||
/// Section 5 without padding (OAuth's PKCE and JSON Web Tokens).
|
||||
pub const URL_SAFE_NO_PAD: Config = Config { url_safe: true, padded: false };
|
||||
|
||||
/// Why a text is not base64 of the config's kind. Says nothing of the text itself.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub struct DecodeError;
|
||||
|
||||
impl fmt::Display for DecodeError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
f.write_str("not valid base64")
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for DecodeError {}
|
||||
|
||||
impl Config {
|
||||
/// The symbol for the six bits `v` (only the low six are looked at).
|
||||
fn symbol(self, v: u32) -> char {
|
||||
let v = (v & 63) as u8;
|
||||
let c = match v {
|
||||
0..=25 => b'A'.saturating_add(v),
|
||||
26..=51 => b'a'.saturating_add(v.saturating_sub(26)),
|
||||
52..=61 => b'0'.saturating_add(v.saturating_sub(52)),
|
||||
62 if self.url_safe => b'-',
|
||||
62 => b'+',
|
||||
_ if self.url_safe => b'_',
|
||||
_ => b'/',
|
||||
};
|
||||
char::from(c)
|
||||
}
|
||||
|
||||
/// The six bits a symbol stands for, if it is one.
|
||||
fn value(self, c: u8) -> Option<u32> {
|
||||
let v = match c {
|
||||
b'A'..=b'Z' => c.saturating_sub(b'A'),
|
||||
b'a'..=b'z' => c.saturating_sub(b'a').saturating_add(26),
|
||||
b'0'..=b'9' => c.saturating_sub(b'0').saturating_add(52),
|
||||
b'+' if !self.url_safe => 62,
|
||||
b'/' if !self.url_safe => 63,
|
||||
b'-' if self.url_safe => 62,
|
||||
b'_' if self.url_safe => 63,
|
||||
_ => return None,
|
||||
};
|
||||
Some(u32::from(v))
|
||||
}
|
||||
|
||||
/// The text for `data`.
|
||||
pub fn encode(self, data: impl AsRef<[u8]>) -> String {
|
||||
let data = data.as_ref();
|
||||
let mut out = String::with_capacity(data.len().div_ceil(3).saturating_mul(4));
|
||||
let (groups, remainder) = data.as_chunks::<3>();
|
||||
for [a, b, c] in groups {
|
||||
let n = u32::from(*a) << 16 | u32::from(*b) << 8 | u32::from(*c);
|
||||
for shift in [18, 12, 6, 0] {
|
||||
out.push(self.symbol(n >> shift));
|
||||
}
|
||||
}
|
||||
match *remainder {
|
||||
[a] => {
|
||||
let n = u32::from(a) << 16;
|
||||
out.push(self.symbol(n >> 18));
|
||||
out.push(self.symbol(n >> 12));
|
||||
if self.padded {
|
||||
out.push_str("==");
|
||||
}
|
||||
}
|
||||
[a, b] => {
|
||||
let n = u32::from(a) << 16 | u32::from(b) << 8;
|
||||
out.push(self.symbol(n >> 18));
|
||||
out.push(self.symbol(n >> 12));
|
||||
out.push(self.symbol(n >> 6));
|
||||
if self.padded {
|
||||
out.push('=');
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of `text`, which must be exactly base64 of this config's kind (see the module).
|
||||
pub fn decode(self, text: impl AsRef<[u8]>) -> Result<Vec<u8>, DecodeError> {
|
||||
let text = text.as_ref();
|
||||
let (symbols, pad) = if self.padded {
|
||||
if text.len() % 4 != 0 {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
let pad = text.iter().rev().take(2).take_while(|c| **c == b'=').count();
|
||||
(text.get(..text.len().saturating_sub(pad)).unwrap_or_default(), pad)
|
||||
} else {
|
||||
(text, 0)
|
||||
};
|
||||
// A last group of one symbol holds under eight bits; with padding, `=` fills it to four.
|
||||
let rest = symbols.len() % 4;
|
||||
if rest == 1 || (self.padded && pad != (4 - rest) % 4) {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
let mut out = Vec::with_capacity(symbols.len().saturating_mul(3) / 4);
|
||||
let (mut acc, mut bits) = (0u32, 0u32);
|
||||
for c in symbols {
|
||||
acc = acc << 6 | self.value(*c).ok_or(DecodeError)?;
|
||||
bits = bits.saturating_add(6);
|
||||
if bits >= 8 {
|
||||
bits -= 8;
|
||||
out.push((acc >> bits) as u8);
|
||||
acc &= (1 << bits) - 1;
|
||||
}
|
||||
}
|
||||
// What is left over must be zero: one spelling for one value.
|
||||
if acc != 0 {
|
||||
return Err(DecodeError);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "base64_tests.rs"]
|
||||
mod base64_tests;
|
||||
@@ -0,0 +1,81 @@
|
||||
//! Base64: the RFC 4648 test vectors, the strict refusals, and that only the one spelling of a
|
||||
//! value decodes, on every config.
|
||||
|
||||
use super::*;
|
||||
use proptest::prelude::*;
|
||||
|
||||
#[test]
|
||||
fn the_rfc_vectors_round_trip_in_every_config() {
|
||||
let vectors = [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foob", "Zm9vYg=="), ("fooba", "Zm9vYmE="), ("foobar", "Zm9vYmFy")];
|
||||
for (plain, coded) in vectors {
|
||||
assert_eq!(STANDARD.encode(plain), coded);
|
||||
assert_eq!(URL_SAFE.encode(plain), coded);
|
||||
assert_eq!(STANDARD_NO_PAD.encode(plain), coded.trim_end_matches('='));
|
||||
assert_eq!(URL_SAFE_NO_PAD.encode(plain), coded.trim_end_matches('='));
|
||||
assert_eq!(STANDARD.decode(coded).unwrap(), plain.as_bytes());
|
||||
assert_eq!(URL_SAFE_NO_PAD.decode(coded.trim_end_matches('=')).unwrap(), plain.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_two_alphabets_differ_in_the_last_two_symbols() {
|
||||
assert_eq!(STANDARD.encode([0xfb, 0xff]), "+/8=");
|
||||
assert_eq!(URL_SAFE.encode([0xfb, 0xff]), "-_8=");
|
||||
assert!(URL_SAFE.decode("+/8=").is_err());
|
||||
assert!(STANDARD.decode("-_8=").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn what_is_not_exactly_base64_is_refused() {
|
||||
for bad in ["Zg", "Zg=", "Zg===", "Z", "Z===", "====", "Zm9v\n", " Zm9v", "Zm 9v", "Zh==", "Zm9=", "Zm=v", "Zm9v=", "é", "Zm9\u{0}"] {
|
||||
assert!(STANDARD.decode(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
for bad in ["Zg==", "Z", "Zh", "Zm9v\n", "Zm 9v", "Zg=", "Zm9="] {
|
||||
assert!(STANDARD_NO_PAD.decode(bad).is_err(), "{bad:?}");
|
||||
}
|
||||
assert!(STANDARD_NO_PAD.decode("Zm8").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hostile_inputs_do_not_panic() {
|
||||
let big = vec![b'A'; 1 << 20];
|
||||
for cfg in [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD] {
|
||||
assert_eq!(cfg.decode(&big).map(|b| b.len()).unwrap_or(0), 786_432);
|
||||
for bytes in [&b""[..], b"=", b"==", b"===", b"\xff\xfe", b"=A=A", b"AA=A"] {
|
||||
let _ = cfg.decode(bytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const CONFIGS: [Config; 4] = [STANDARD, STANDARD_NO_PAD, URL_SAFE, URL_SAFE_NO_PAD];
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn what_is_encoded_decodes_to_itself(data in proptest::collection::vec(any::<u8>(), 0..400)) {
|
||||
for config in CONFIGS {
|
||||
prop_assert_eq!(config.decode(config.encode(&data)).ok(), Some(data.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
/// Strict: a text is accepted only when it is the one spelling of its bytes.
|
||||
#[test]
|
||||
fn what_decodes_is_the_one_spelling_of_its_bytes(
|
||||
data in proptest::collection::vec(any::<u8>(), 0..400),
|
||||
junk in proptest::collection::vec(any::<u8>(), 0..64),
|
||||
text in "[A-Za-z0-9+/=_ -]{0,40}",
|
||||
) {
|
||||
for config in CONFIGS {
|
||||
let mut cut = config.encode(&data).into_bytes();
|
||||
cut.pop();
|
||||
let mut pad = cut.clone();
|
||||
if let Some(first) = pad.first_mut() {
|
||||
*first = b'=';
|
||||
}
|
||||
for candidate in [junk.as_slice(), text.as_bytes(), cut.as_slice(), pad.as_slice()] {
|
||||
if let Ok(bytes) = config.decode(candidate) {
|
||||
prop_assert_eq!(config.encode(&bytes).into_bytes(), candidate.to_vec());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,52 @@ pub fn guess(bytes: &[u8]) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// The character set a Windows code page stands for (as Outlook items, RTF and
|
||||
/// Windows APIs number them), by the label [`decode`] reads it under. `None` for a page that has no
|
||||
/// character set here.
|
||||
pub fn code_page_label(cp: i64) -> Option<&'static str> {
|
||||
Some(match cp {
|
||||
65001 => "utf-8",
|
||||
1200 => "utf-16le",
|
||||
1201 => "utf-16be",
|
||||
874 => "windows-874",
|
||||
1250 => "windows-1250",
|
||||
1251 => "windows-1251",
|
||||
1252 | 28591 => "windows-1252",
|
||||
1253 => "windows-1253",
|
||||
1254 => "windows-1254",
|
||||
1255 => "windows-1255",
|
||||
1256 => "windows-1256",
|
||||
1257 => "windows-1257",
|
||||
1258 => "windows-1258",
|
||||
932 => "shift_jis",
|
||||
936 => "gbk",
|
||||
54936 => "gb18030",
|
||||
949 | 51949 => "euc-kr",
|
||||
950 => "big5",
|
||||
51932 => "euc-jp",
|
||||
50220..=50222 => "iso-2022-jp",
|
||||
866 => "ibm866",
|
||||
20866 => "koi8-r",
|
||||
21866 => "koi8-u",
|
||||
10000 => "macintosh",
|
||||
10007 => "x-mac-cyrillic",
|
||||
28592 => "iso-8859-2",
|
||||
28593 => "iso-8859-3",
|
||||
28594 => "iso-8859-4",
|
||||
28595 => "iso-8859-5",
|
||||
28596 => "iso-8859-6",
|
||||
28597 => "iso-8859-7",
|
||||
28598 => "iso-8859-8",
|
||||
38598 => "iso-8859-8-i",
|
||||
28599 => "iso-8859-9",
|
||||
28603 => "iso-8859-13",
|
||||
28605 => "iso-8859-15",
|
||||
20127 => "us-ascii",
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Line endings as `\n`: CRLF and a lone CR both become LF.
|
||||
pub fn unix_lines(text: &str) -> String {
|
||||
if !text.contains('\r') {
|
||||
@@ -98,6 +144,22 @@ mod tests {
|
||||
assert_eq!(guess(b"\xE2\x82"), "\u{e2}\u{201a}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_pages_name_character_sets_the_decoder_knows() {
|
||||
assert_eq!(code_page_label(1252), Some("windows-1252"));
|
||||
assert_eq!(code_page_label(28591), Some("windows-1252"));
|
||||
assert_eq!(code_page_label(65001), Some("utf-8"));
|
||||
assert_eq!(code_page_label(0), None);
|
||||
assert_eq!(code_page_label(-1), None);
|
||||
// Every label is one the Encoding Standard knows (US-ASCII reads as windows-1252 there).
|
||||
for cp in 0..66_000 {
|
||||
if let Some(label) = code_page_label(cp) {
|
||||
assert!(encoding_rs::Encoding::for_label(label.as_bytes()).is_some(), "{cp} {label}");
|
||||
}
|
||||
}
|
||||
assert_eq!(decode(&[0xC0, 0xC1], code_page_label(1251).unwrap_or_default()), "АБ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_endings() {
|
||||
assert_eq!(unix_lines("a\r\nb\rc\nd\r\n"), "a\nb\nc\nd\n");
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
//! ([`transfer`]), character sets ([`charset`]), address lists ([`parse_address_list`],
|
||||
//! [`format_address_list`]), dates ([`parse_date`], [`format_date`]), mbox files
|
||||
//! ([`mbox_messages`], [`write_mbox_message`]) and the lookups of `multipart/related`
|
||||
//! ([`Resources`]).
|
||||
//! ([`Resources`]). Beside MIME: `mailto:` links ([`parse_mailto`], RFC 6068) and strict
|
||||
//! base64 ([`base64`], RFC 4648).
|
||||
//!
|
||||
//! Written from the public RFCs: 5322 (messages), 2045–2049 (MIME), 2047 (encoded-words), 2231
|
||||
//! (parameter values), 2387 and 2557 (`multipart/related`, MHTML) and 4155 (mbox).
|
||||
@@ -25,12 +26,14 @@
|
||||
//! What a message means (which part is the body, which parts are files, what a flag header
|
||||
//! says) is the caller's: this crate has no notion of a mailbox or a document.
|
||||
|
||||
pub mod base64;
|
||||
pub mod charset;
|
||||
pub mod transfer;
|
||||
|
||||
mod address;
|
||||
mod date;
|
||||
mod header;
|
||||
mod mailto;
|
||||
mod mbox;
|
||||
mod part;
|
||||
mod related;
|
||||
@@ -40,6 +43,7 @@ mod write;
|
||||
pub use address::{MAX_ADDRESSES, Mailbox, format_address, format_address_list, parse_address_list};
|
||||
pub use date::{DateTime, format_date, parse_date};
|
||||
pub use header::{Field, Headers, MAX_PARAMS, Params, clean, is_field_name, parse_params, split_headers, write_field};
|
||||
pub use mailto::{Mailto, is_mailto, parse_mailto};
|
||||
pub use mbox::{MboxMessages, mbox_messages, write_mbox_message};
|
||||
pub use part::{Body, Message, Part, parse, parse_entity, split_multipart};
|
||||
pub use related::{Resources, resolve_location, start_part};
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
//! `mailto:` links (RFC 6068): who a new message is for and how it starts.
|
||||
//!
|
||||
//! A link is hostile input like any other: it comes from a web page, a document or a message.
|
||||
//! Only the recipients, the subject and the body are taken from it. Every other header field a
|
||||
//! link names is ignored (RFC 6068 section 7: a link must not get to set headers of its choice),
|
||||
//! nothing taken from it can carry a line break into a header, and sizes are capped.
|
||||
|
||||
/// What a `mailto:` link asks a new message to start with.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Mailto {
|
||||
pub to: Vec<String>,
|
||||
pub cc: Vec<String>,
|
||||
pub bcc: Vec<String>,
|
||||
pub subject: String,
|
||||
pub body: String,
|
||||
}
|
||||
|
||||
/// The most of a link that is read.
|
||||
const MAX_LINK: usize = 64 * 1024;
|
||||
/// The most recipients taken for one field.
|
||||
const MAX_ADDRESSES: usize = 200;
|
||||
/// The longest subject taken (a header line's limit in RFC 5322).
|
||||
const MAX_SUBJECT: usize = 998;
|
||||
|
||||
/// Whether `text` is a `mailto:` link (by its scheme, whatever its case).
|
||||
pub fn is_mailto(text: &str) -> bool {
|
||||
rest_of(text).is_some()
|
||||
}
|
||||
|
||||
/// What follows the scheme, when `text` is a `mailto:` link.
|
||||
fn rest_of(text: &str) -> Option<&str> {
|
||||
let text = text.trim();
|
||||
let scheme = text.get(..7)?;
|
||||
if scheme.eq_ignore_ascii_case("mailto:") { text.get(7..) } else { None }
|
||||
}
|
||||
|
||||
/// Read a `mailto:` link. `None` when `link` isn't one; a link with nothing after the scheme is
|
||||
/// an empty message.
|
||||
pub fn parse_mailto(link: &str) -> Option<Mailto> {
|
||||
let rest = cut(rest_of(link)?, MAX_LINK);
|
||||
// A fragment is no part of the message.
|
||||
let rest = rest.split('#').next().unwrap_or("");
|
||||
let (path, query) = rest.split_once('?').unwrap_or((rest, ""));
|
||||
let mut m = Mailto::default();
|
||||
// Commas separate the addresses before they are decoded: an encoded comma belongs to its address.
|
||||
for address in path.split(',') {
|
||||
add(&mut m.to, &decode(address));
|
||||
}
|
||||
let (mut subject, mut body) = (None, None);
|
||||
for field in query.split('&') {
|
||||
let (name, value) = field.split_once('=').unwrap_or((field, ""));
|
||||
let value = decode(value);
|
||||
match decode(name).to_ascii_lowercase().as_str() {
|
||||
"to" => add_list(&mut m.to, &value),
|
||||
"cc" => add_list(&mut m.cc, &value),
|
||||
"bcc" => add_list(&mut m.bcc, &value),
|
||||
// The first of each counts; a link that repeats one is not to be trusted with the rest.
|
||||
"subject" if subject.is_none() => subject = Some(one_line(&value, MAX_SUBJECT)),
|
||||
"body" if body.is_none() => body = Some(lines(&value)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
m.subject = subject.unwrap_or_default();
|
||||
m.body = body.unwrap_or_default();
|
||||
Some(m)
|
||||
}
|
||||
|
||||
/// `text` up to `max` bytes, cut where a character ends.
|
||||
pub(crate) fn cut(text: &str, max: usize) -> &str {
|
||||
if text.len() <= max {
|
||||
return text;
|
||||
}
|
||||
let mut end = max;
|
||||
while end > 0 && !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
text.get(..end).unwrap_or("")
|
||||
}
|
||||
|
||||
/// Percent-decoding. An escape that isn't one stays as it is written, a plus sign is a plus sign
|
||||
/// (RFC 6068 section 5), and bytes that aren't UTF-8 become the replacement character.
|
||||
fn decode(text: &str) -> String {
|
||||
let bytes = text.as_bytes();
|
||||
let mut out = Vec::with_capacity(bytes.len());
|
||||
let mut i = 0;
|
||||
while let Some(&b) = bytes.get(i) {
|
||||
let escaped = if b == b'%' { hex(bytes.get(i + 1)).zip(hex(bytes.get(i + 2))) } else { None };
|
||||
match escaped {
|
||||
Some((high, low)) => {
|
||||
out.push((high << 4) | low);
|
||||
i += 3;
|
||||
}
|
||||
None => {
|
||||
out.push(b);
|
||||
i += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
}
|
||||
|
||||
fn hex(b: Option<&u8>) -> Option<u8> {
|
||||
char::from(*b?).to_digit(16).and_then(|d| u8::try_from(d).ok())
|
||||
}
|
||||
|
||||
/// One header line's worth of text: line breaks and other control characters become spaces.
|
||||
fn one_line(text: &str, max: usize) -> String {
|
||||
let line: String = text.chars().map(|c| if c.is_control() { ' ' } else { c }).collect();
|
||||
cut(line.trim(), max).trim_end().to_string()
|
||||
}
|
||||
|
||||
/// Body text: every kind of line break becomes `\n`, and no other control character but the tab
|
||||
/// is kept.
|
||||
fn lines(text: &str) -> String {
|
||||
text.replace("\r\n", "\n").chars().map(|c| if c == '\r' { '\n' } else { c }).filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect()
|
||||
}
|
||||
|
||||
/// One address, if anything is left of it once it is a single clean line.
|
||||
fn add(list: &mut Vec<String>, address: &str) {
|
||||
let address = one_line(address, MAX_SUBJECT);
|
||||
if !address.is_empty() && list.len() < MAX_ADDRESSES {
|
||||
list.push(address);
|
||||
}
|
||||
}
|
||||
|
||||
/// The addresses of a decoded header value: separated by commas, except inside a quoted name or
|
||||
/// an angle-bracketed address.
|
||||
fn add_list(list: &mut Vec<String>, value: &str) {
|
||||
let (mut start, mut quoted, mut angle, mut escaped) = (0, false, false, false);
|
||||
for (i, c) in value.char_indices() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
match c {
|
||||
'\\' if quoted => escaped = true,
|
||||
'"' => quoted = !quoted,
|
||||
'<' if !quoted => angle = true,
|
||||
'>' if !quoted => angle = false,
|
||||
',' if !quoted && !angle => {
|
||||
add(list, value.get(start..i).unwrap_or(""));
|
||||
start = i + 1;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
add(list, value.get(start..).unwrap_or(""));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use proptest::prelude::*;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn s(list: &[&str]) -> Vec<String> {
|
||||
list.iter().map(|a| a.to_string()).collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_the_examples_of_rfc_6068() {
|
||||
assert_eq!(parse_mailto("mailto:chris@example.org"), Some(Mailto { to: s(&["chris@example.org"]), ..Default::default() }));
|
||||
let m = parse_mailto("mailto:infobot@example.org?subject=current-issue").unwrap();
|
||||
assert_eq!((m.to, m.subject.as_str()), (s(&["infobot@example.org"]), "current-issue"));
|
||||
let m = parse_mailto("mailto:infobot@example.org?body=send%20current-issue%0D%0Asend%20index").unwrap();
|
||||
assert_eq!(m.body, "send current-issue\nsend index");
|
||||
let m = parse_mailto("mailto:joe@example.org?cc=bob@example.org&body=hello").unwrap();
|
||||
assert_eq!((m.to, m.cc, m.body.as_str()), (s(&["joe@example.org"]), s(&["bob@example.org"]), "hello"));
|
||||
// Addresses in the path, in `to` fields, or both.
|
||||
let m = parse_mailto("mailto:addr1@an.example,addr2@an.example").unwrap();
|
||||
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
|
||||
let m = parse_mailto("mailto:?to=addr1@an.example,addr2@an.example").unwrap();
|
||||
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
|
||||
let m = parse_mailto("mailto:addr1@an.example?to=addr2@an.example").unwrap();
|
||||
assert_eq!(m.to, s(&["addr1@an.example", "addr2@an.example"]));
|
||||
// Encoded characters in an address, and UTF-8 in a subject.
|
||||
assert_eq!(parse_mailto("mailto:gorby%25kremvax@example.org").unwrap().to, s(&["gorby%kremvax@example.org"]));
|
||||
assert_eq!(parse_mailto("mailto:user@example.org?subject=caf%C3%A9").unwrap().subject, "café");
|
||||
assert_eq!(parse_mailto("mailto:%22not%40me%22@example.org").unwrap().to, s(&["\"not@me\"@example.org"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn knows_a_link_from_anything_else() {
|
||||
assert!(is_mailto(" MailTo:someone@example.org "));
|
||||
assert!(!is_mailto("mailbox.example"));
|
||||
assert!(!is_mailto("mail"));
|
||||
assert!(!is_mailto("https://example.org/?mailto:x"));
|
||||
assert_eq!(parse_mailto("C:\\mail\\mailto.eml"), None);
|
||||
assert_eq!(parse_mailto("mailto:"), Some(Mailto::default()));
|
||||
assert_eq!(parse_mailto("MAILTO:a@example.org#top").unwrap().to, s(&["a@example.org"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_link_sets_nothing_but_recipients_subject_and_body() {
|
||||
let m =
|
||||
parse_mailto("mailto:a@example.org?from=boss@example.org&reply-to=x@evil.example&attach=C:%5Csecrets.txt&X-Mailer=m&subject=Hi").unwrap();
|
||||
assert_eq!(m, Mailto { to: s(&["a@example.org"]), subject: "Hi".into(), ..Default::default() });
|
||||
// No line break reaches a header, whatever the link encodes.
|
||||
let m = parse_mailto(
|
||||
"mailto:a@example.org%0D%0ABcc:%20x@evil.example?subject=Hi%0D%0ABcc:%20y@evil.example&cc=c@example.org%0Abcc:z@evil.example",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(m.bcc.is_empty());
|
||||
for text in m.to.iter().chain(&m.cc).chain([&m.subject]) {
|
||||
assert!(!text.contains(['\r', '\n', '\0']), "{text:?}");
|
||||
}
|
||||
// The first subject and body count.
|
||||
let m = parse_mailto("mailto:?subject=one&subject=two&body=first&body=second").unwrap();
|
||||
assert_eq!((m.subject.as_str(), m.body.as_str()), ("one", "first"));
|
||||
// A plus sign is not a space, a broken escape is what it looks like.
|
||||
let m = parse_mailto("mailto:a+b@example.org?subject=1+1%3D2%&body=100%25%zz").unwrap();
|
||||
assert_eq!((m.to, m.subject.as_str(), m.body.as_str()), (s(&["a+b@example.org"]), "1+1=2%", "100%%zz"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_recipient_lists_the_way_addresses_are_written() {
|
||||
let m = parse_mailto("mailto:?cc=%22Rivera%2C%20Tom%C3%A1s%22%20%3Ctomas@example.org%3E,%20lin@example.org&bcc=a@example.org,,b@example.org")
|
||||
.unwrap();
|
||||
assert_eq!(m.cc, s(&["\"Rivera, Tomás\" <tomas@example.org>", "lin@example.org"]));
|
||||
assert_eq!(m.bcc, s(&["a@example.org", "b@example.org"]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sizes_are_capped() {
|
||||
let many = format!("mailto:{}", "a@example.org,".repeat(1000));
|
||||
assert_eq!(parse_mailto(&many).unwrap().to.len(), MAX_ADDRESSES);
|
||||
let long = format!("mailto:a@example.org?subject={}&body={}", "é".repeat(5000), "x".repeat(200_000));
|
||||
let m = parse_mailto(&long).unwrap();
|
||||
assert!(m.subject.len() <= MAX_SUBJECT && m.subject.chars().all(|c| c == 'é'));
|
||||
assert!(m.body.len() <= MAX_LINK);
|
||||
}
|
||||
|
||||
proptest! {
|
||||
#[test]
|
||||
fn any_text_is_read_without_trouble(text in ".{0,300}", tail in proptest::collection::vec(any::<u8>(), 0..300)) {
|
||||
let _ = parse_mailto(&text);
|
||||
let link = format!("mailto:{text}?{}", String::from_utf8_lossy(&tail));
|
||||
let m = parse_mailto(&link).unwrap();
|
||||
for line in m.to.iter().chain(&m.cc).chain(&m.bcc).chain([&m.subject]) {
|
||||
prop_assert!(!line.chars().any(char::is_control));
|
||||
}
|
||||
prop_assert!(!m.body.contains('\r'));
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user