diff --git a/crates/mime/README.md b/crates/mime/README.md index 9fd1207bb..34316b7bb 100644 --- a/crates/mime/README.md +++ b/crates/mime/README.md @@ -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) | diff --git a/crates/mime/src/base64.rs b/crates/mime/src/base64.rs new file mode 100644 index 000000000..a93a6fdfb --- /dev/null +++ b/crates/mime/src/base64.rs @@ -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 { + 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, 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; diff --git a/crates/mime/src/base64_tests.rs b/crates/mime/src/base64_tests.rs new file mode 100644 index 000000000..e3d52794e --- /dev/null +++ b/crates/mime/src/base64_tests.rs @@ -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::(), 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::(), 0..400), + junk in proptest::collection::vec(any::(), 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()); + } + } + } + } +} diff --git a/crates/mime/src/charset.rs b/crates/mime/src/charset.rs index 59f45cd67..9eacb1462 100644 --- a/crates/mime/src/charset.rs +++ b/crates/mime/src/charset.rs @@ -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"); diff --git a/crates/mime/src/lib.rs b/crates/mime/src/lib.rs index c59d6e9c8..964eb6f06 100644 --- a/crates/mime/src/lib.rs +++ b/crates/mime/src/lib.rs @@ -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}; diff --git a/crates/mime/src/mailto.rs b/crates/mime/src/mailto.rs new file mode 100644 index 000000000..36a8f1c70 --- /dev/null +++ b/crates/mime/src/mailto.rs @@ -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, + pub cc: Vec, + pub bcc: Vec, + 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 { + 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 { + 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, 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, 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 { + 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\" ", "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::(), 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')); + } + } +}