mirror of
https://github.com/chatmail/core.git
synced 2026-04-17 21:46:35 +03:00
About the first TODO: I tried this out, but it didn't actually improve things, for two reasons: 1. The trick with `#![cfg_attr(not(test), warn(clippy::indexing_slicing))]` that enables the lint everywhere except for tests doesn't work with workspace-wide lints. (Context: We want to lint against indexing because it might panic, but in a test panicking is fine, so we don't want to enable the lint in tests). 2. Most of our crates have different sets of lints right now, so it would only be very few crates that use the workspace-wide list of lints. About the second TODO: It's not feasible right now to fully parse vCards, and for our good-enough parser the current behavior is fine, I think. If we fail to parse some realworld vCards because of this, we can still improve it.
811 lines
27 KiB
Rust
811 lines
27 KiB
Rust
//! Contact-related tools, like parsing vcards and sanitizing name and address
|
|
|
|
#![forbid(unsafe_code)]
|
|
#![warn(
|
|
unused,
|
|
clippy::correctness,
|
|
missing_debug_implementations,
|
|
missing_docs,
|
|
clippy::all,
|
|
clippy::wildcard_imports,
|
|
clippy::needless_borrow,
|
|
clippy::cast_lossless,
|
|
clippy::unused_async,
|
|
clippy::explicit_iter_loop,
|
|
clippy::explicit_into_iter_loop,
|
|
clippy::cloned_instead_of_copied
|
|
)]
|
|
#![cfg_attr(not(test), warn(clippy::indexing_slicing))]
|
|
#![allow(
|
|
clippy::match_bool,
|
|
clippy::mixed_read_write_in_expression,
|
|
clippy::bool_assert_comparison,
|
|
clippy::manual_split_once,
|
|
clippy::format_push_string,
|
|
clippy::bool_to_int_with_if,
|
|
clippy::manual_range_contains
|
|
)]
|
|
|
|
use std::fmt;
|
|
use std::ops::Deref;
|
|
|
|
use anyhow::bail;
|
|
use anyhow::Context as _;
|
|
use anyhow::Result;
|
|
use chrono::{DateTime, NaiveDateTime};
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
|
|
#[derive(Debug)]
|
|
/// A Contact, as represented in a VCard.
|
|
pub struct VcardContact {
|
|
/// The email address, vcard property `email`
|
|
pub addr: String,
|
|
/// This must be the name authorized by the contact itself, not a locally given name. Vcard
|
|
/// property `fn`. Can be empty, one should use `display_name()` to obtain the display name.
|
|
pub authname: String,
|
|
/// The contact's public PGP key in Base64, vcard property `key`
|
|
pub key: Option<String>,
|
|
/// The contact's profile image (=avatar) in Base64, vcard property `photo`
|
|
pub profile_image: Option<String>,
|
|
/// The timestamp when the vcard was created / last updated, vcard property `rev`
|
|
pub timestamp: Result<i64>,
|
|
}
|
|
|
|
impl VcardContact {
|
|
/// Returns the contact's display name.
|
|
pub fn display_name(&self) -> &str {
|
|
match self.authname.is_empty() {
|
|
false => &self.authname,
|
|
true => &self.addr,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns a vCard containing given contacts.
|
|
///
|
|
/// Calling [`parse_vcard()`] on the returned result is a reverse operation.
|
|
pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
|
fn format_timestamp(c: &VcardContact) -> Option<String> {
|
|
let timestamp = *c.timestamp.as_ref().ok()?;
|
|
let datetime = DateTime::from_timestamp(timestamp, 0)?;
|
|
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
|
}
|
|
|
|
let mut res = "".to_string();
|
|
for c in contacts {
|
|
let addr = &c.addr;
|
|
let display_name = c.display_name();
|
|
res += &format!(
|
|
"BEGIN:VCARD\n\
|
|
VERSION:4.0\n\
|
|
EMAIL:{addr}\n\
|
|
FN:{display_name}\n"
|
|
);
|
|
if let Some(key) = &c.key {
|
|
res += &format!("KEY:data:application/pgp-keys;base64,{key}\n");
|
|
}
|
|
if let Some(profile_image) = &c.profile_image {
|
|
res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\n");
|
|
}
|
|
if let Some(timestamp) = format_timestamp(c) {
|
|
res += &format!("REV:{timestamp}\n");
|
|
}
|
|
res += "END:VCARD\n";
|
|
}
|
|
res
|
|
}
|
|
|
|
/// Parses `VcardContact`s from a given `&str`.
|
|
pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
|
fn remove_prefix<'a>(s: &'a str, prefix: &str) -> Option<&'a str> {
|
|
let start_of_s = s.get(..prefix.len())?;
|
|
|
|
if start_of_s.eq_ignore_ascii_case(prefix) {
|
|
s.get(prefix.len()..)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
fn vcard_property<'a>(s: &'a str, property: &str) -> Option<&'a str> {
|
|
let remainder = remove_prefix(s, property)?;
|
|
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
|
|
// then `remainder` is now `;TYPE=work:alice@example.com`
|
|
|
|
// Note: This doesn't handle the case where there are quotes around a colon,
|
|
// like `NAME;Foo="Some quoted text: that contains a colon":value`.
|
|
// This could be improved in the future, but for now, the parsing is good enough.
|
|
let (params, value) = remainder.split_once(':')?;
|
|
// In the example from above, `params` is now `;TYPE=work`
|
|
// and `value` is now `alice@example.com`
|
|
|
|
if params
|
|
.chars()
|
|
.next()
|
|
.filter(|c| !c.is_ascii_punctuation() || *c == '_')
|
|
.is_some()
|
|
{
|
|
// `s` started with `property`, but the next character after it was not punctuation,
|
|
// so this line's property is actually something else
|
|
return None;
|
|
}
|
|
Some(value)
|
|
}
|
|
fn parse_datetime(datetime: &str) -> Result<i64> {
|
|
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
|
|
// is in ISO.8601.2004 format. DateTime::parse_from_rfc3339() apparently parses
|
|
// ISO.8601, but fails to parse any of the examples given.
|
|
// So, instead just parse using a format string.
|
|
|
|
// Parses 19961022T140000Z, 19961022T140000-05, or 19961022T140000-0500.
|
|
let timestamp = match DateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S%#z") {
|
|
Ok(datetime) => datetime.timestamp(),
|
|
// Parses 19961022T140000.
|
|
Err(e) => match NaiveDateTime::parse_from_str(datetime, "%Y%m%dT%H%M%S") {
|
|
Ok(datetime) => datetime
|
|
.and_local_timezone(chrono::offset::Local)
|
|
.single()
|
|
.context("Could not apply local timezone to parsed date and time")?
|
|
.timestamp(),
|
|
Err(_) => return Err(e.into()),
|
|
},
|
|
};
|
|
Ok(timestamp)
|
|
}
|
|
|
|
// Remove line folding, see https://datatracker.ietf.org/doc/html/rfc6350#section-3.2
|
|
static NEWLINE_AND_SPACE_OR_TAB: Lazy<Regex> = Lazy::new(|| Regex::new("\r?\n[\t ]").unwrap());
|
|
let unfolded_lines = NEWLINE_AND_SPACE_OR_TAB.replace_all(vcard, "");
|
|
|
|
let mut lines = unfolded_lines.lines().peekable();
|
|
let mut contacts = Vec::new();
|
|
|
|
while lines.peek().is_some() {
|
|
// Skip to the start of the vcard:
|
|
for line in lines.by_ref() {
|
|
if line.eq_ignore_ascii_case("BEGIN:VCARD") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let mut display_name = None;
|
|
let mut addr = None;
|
|
let mut key = None;
|
|
let mut photo = None;
|
|
let mut datetime = None;
|
|
|
|
for mut line in lines.by_ref() {
|
|
if let Some(remainder) = remove_prefix(line, "item1.") {
|
|
// Remove the group name, if the group is called "item1".
|
|
// If necessary, we can improve this to also remove groups that are called something different that "item1".
|
|
//
|
|
// Search "group name" at https://datatracker.ietf.org/doc/html/rfc6350 for more infos.
|
|
line = remainder;
|
|
}
|
|
|
|
if let Some(email) = vcard_property(line, "email") {
|
|
addr.get_or_insert(email);
|
|
} else if let Some(name) = vcard_property(line, "fn") {
|
|
display_name.get_or_insert(name);
|
|
} else if let Some(k) = remove_prefix(line, "KEY;PGP;ENCODING=BASE64:")
|
|
.or_else(|| remove_prefix(line, "KEY;TYPE=PGP;ENCODING=b:"))
|
|
.or_else(|| remove_prefix(line, "KEY:data:application/pgp-keys;base64,"))
|
|
.or_else(|| remove_prefix(line, "KEY;PREF=1:data:application/pgp-keys;base64,"))
|
|
{
|
|
key.get_or_insert(k);
|
|
} else if let Some(p) = remove_prefix(line, "PHOTO;JPEG;ENCODING=BASE64:")
|
|
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;JPEG:"))
|
|
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=b:"))
|
|
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=b;TYPE=JPEG:"))
|
|
.or_else(|| remove_prefix(line, "PHOTO;ENCODING=BASE64;TYPE=JPEG:"))
|
|
.or_else(|| remove_prefix(line, "PHOTO;TYPE=JPEG;ENCODING=BASE64:"))
|
|
.or_else(|| remove_prefix(line, "PHOTO:data:image/jpeg;base64,"))
|
|
{
|
|
photo.get_or_insert(p);
|
|
} else if let Some(rev) = vcard_property(line, "rev") {
|
|
datetime.get_or_insert(rev);
|
|
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
|
break;
|
|
}
|
|
}
|
|
|
|
let (authname, addr) =
|
|
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
|
|
|
|
contacts.push(VcardContact {
|
|
authname,
|
|
addr,
|
|
key: key.map(|s| s.to_string()),
|
|
profile_image: photo.map(|s| s.to_string()),
|
|
timestamp: datetime
|
|
.context("No timestamp in vcard")
|
|
.and_then(parse_datetime),
|
|
});
|
|
}
|
|
|
|
contacts
|
|
}
|
|
|
|
/// Valid contact address.
|
|
#[derive(Debug, Clone)]
|
|
pub struct ContactAddress(String);
|
|
|
|
impl Deref for ContactAddress {
|
|
type Target = str;
|
|
|
|
fn deref(&self) -> &Self::Target {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl AsRef<str> for ContactAddress {
|
|
fn as_ref(&self) -> &str {
|
|
&self.0
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for ContactAddress {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
impl ContactAddress {
|
|
/// Constructs a new contact address from string,
|
|
/// normalizing and validating it.
|
|
pub fn new(s: &str) -> Result<Self> {
|
|
let addr = addr_normalize(s);
|
|
if !may_be_valid_addr(&addr) {
|
|
bail!("invalid address {:?}", s);
|
|
}
|
|
Ok(Self(addr.to_string()))
|
|
}
|
|
}
|
|
|
|
/// Allow converting [`ContactAddress`] to an SQLite type.
|
|
impl rusqlite::types::ToSql for ContactAddress {
|
|
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
|
|
let val = rusqlite::types::Value::Text(self.0.to_string());
|
|
let out = rusqlite::types::ToSqlOutput::Owned(val);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
/// Takes a name and an address and sanitizes them:
|
|
/// - Extracts a name from the addr if the addr is in form "Alice <alice@example.org>"
|
|
/// - Removes special characters from the name, see [`sanitize_name()`]
|
|
/// - Removes the name if it is equal to the address by setting it to ""
|
|
pub fn sanitize_name_and_addr(name: &str, addr: &str) -> (String, String) {
|
|
static ADDR_WITH_NAME_REGEX: Lazy<Regex> = Lazy::new(|| Regex::new("(.*)<(.*)>").unwrap());
|
|
let (name, addr) = if let Some(captures) = ADDR_WITH_NAME_REGEX.captures(addr.as_ref()) {
|
|
(
|
|
if name.is_empty() {
|
|
captures.get(1).map_or("", |m| m.as_str())
|
|
} else {
|
|
name
|
|
},
|
|
captures
|
|
.get(2)
|
|
.map_or("".to_string(), |m| m.as_str().to_string()),
|
|
)
|
|
} else {
|
|
(name, addr.to_string())
|
|
};
|
|
let mut name = sanitize_name(name);
|
|
|
|
// If the 'display name' is just the address, remove it:
|
|
// Otherwise, the contact would sometimes be shown as "alice@example.com (alice@example.com)" (see `get_name_n_addr()`).
|
|
// If the display name is empty, DC will just show the address when it needs a display name.
|
|
if name == addr {
|
|
name = "".to_string();
|
|
}
|
|
|
|
(name, addr)
|
|
}
|
|
|
|
/// Sanitizes a name.
|
|
///
|
|
/// - Removes newlines and trims the string
|
|
/// - Removes quotes (come from some bad MUA implementations)
|
|
/// - Removes potentially-malicious bidi characters
|
|
pub fn sanitize_name(name: &str) -> String {
|
|
let name = sanitize_single_line(name);
|
|
|
|
match name.as_bytes() {
|
|
[b'\'', .., b'\''] | [b'\"', .., b'\"'] | [b'<', .., b'>'] => name
|
|
.get(1..name.len() - 1)
|
|
.map_or("".to_string(), |s| s.trim().to_string()),
|
|
_ => name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Sanitizes user input
|
|
///
|
|
/// - Removes newlines and trims the string
|
|
/// - Removes potentially-malicious bidi characters
|
|
pub fn sanitize_single_line(input: &str) -> String {
|
|
sanitize_bidi_characters(input.replace(['\n', '\r'], " ").trim())
|
|
}
|
|
|
|
const RTLO_CHARACTERS: [char; 5] = ['\u{202A}', '\u{202B}', '\u{202C}', '\u{202D}', '\u{202E}'];
|
|
const ISOLATE_CHARACTERS: [char; 3] = ['\u{2066}', '\u{2067}', '\u{2068}'];
|
|
const POP_ISOLATE_CHARACTER: char = '\u{2069}';
|
|
/// Some control unicode characters can influence whether adjacent text is shown from
|
|
/// left to right or from right to left.
|
|
///
|
|
/// Since user input is not supposed to influence how adjacent text looks,
|
|
/// this function removes some of these characters.
|
|
///
|
|
/// Also see https://github.com/deltachat/deltachat-core-rust/issues/3479.
|
|
pub fn sanitize_bidi_characters(input_str: &str) -> String {
|
|
// RTLO_CHARACTERS are apparently rarely used in practice.
|
|
// They can impact all following text, so, better remove them all:
|
|
let input_str = input_str.replace(|char| RTLO_CHARACTERS.contains(&char), "");
|
|
|
|
// If the ISOLATE characters are not ended with a POP DIRECTIONAL ISOLATE character,
|
|
// we regard the input as potentially malicious and simply remove all ISOLATE characters.
|
|
// See https://en.wikipedia.org/wiki/Bidirectional_text#Unicode_bidi_support
|
|
// and https://www.w3.org/International/questions/qa-bidi-unicode-controls.en
|
|
// for an explanation about ISOLATE characters.
|
|
fn isolate_characters_are_valid(input_str: &str) -> bool {
|
|
let mut isolate_character_nesting: i32 = 0;
|
|
for char in input_str.chars() {
|
|
if ISOLATE_CHARACTERS.contains(&char) {
|
|
isolate_character_nesting += 1;
|
|
} else if char == POP_ISOLATE_CHARACTER {
|
|
isolate_character_nesting -= 1;
|
|
}
|
|
|
|
// According to Wikipedia, 125 levels are allowed:
|
|
// https://en.wikipedia.org/wiki/Unicode_control_characters
|
|
// (although, in practice, we could also significantly lower this number)
|
|
if isolate_character_nesting < 0 || isolate_character_nesting > 125 {
|
|
return false;
|
|
}
|
|
}
|
|
isolate_character_nesting == 0
|
|
}
|
|
|
|
if isolate_characters_are_valid(&input_str) {
|
|
input_str
|
|
} else {
|
|
input_str.replace(
|
|
|char| ISOLATE_CHARACTERS.contains(&char) || POP_ISOLATE_CHARACTER == char,
|
|
"",
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Returns false if addr is an invalid address, otherwise true.
|
|
pub fn may_be_valid_addr(addr: &str) -> bool {
|
|
let res = EmailAddress::new(addr);
|
|
res.is_ok()
|
|
}
|
|
|
|
/// Returns address lowercased,
|
|
/// with whitespace trimmed and `mailto:` prefix removed.
|
|
pub fn addr_normalize(addr: &str) -> String {
|
|
let norm = addr.trim().to_lowercase();
|
|
|
|
if norm.starts_with("mailto:") {
|
|
norm.get(7..).unwrap_or(&norm).to_string()
|
|
} else {
|
|
norm
|
|
}
|
|
}
|
|
|
|
/// Compares two email addresses, normalizing them beforehand.
|
|
pub fn addr_cmp(addr1: &str, addr2: &str) -> bool {
|
|
let norm1 = addr_normalize(addr1);
|
|
let norm2 = addr_normalize(addr2);
|
|
|
|
norm1 == norm2
|
|
}
|
|
|
|
///
|
|
/// Represents an email address, right now just the `name@domain` portion.
|
|
///
|
|
/// # Example
|
|
///
|
|
/// ```
|
|
/// use deltachat_contact_tools::EmailAddress;
|
|
/// let email = match EmailAddress::new("someone@example.com") {
|
|
/// Ok(addr) => addr,
|
|
/// Err(e) => panic!("Error parsing address, error was {}", e),
|
|
/// };
|
|
/// assert_eq!(&email.local, "someone");
|
|
/// assert_eq!(&email.domain, "example.com");
|
|
/// assert_eq!(email.to_string(), "someone@example.com");
|
|
/// ```
|
|
#[derive(Debug, PartialEq, Eq, Clone)]
|
|
pub struct EmailAddress {
|
|
/// Local part of the email address.
|
|
pub local: String,
|
|
|
|
/// Email address domain.
|
|
pub domain: String,
|
|
}
|
|
|
|
impl fmt::Display for EmailAddress {
|
|
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
|
write!(f, "{}@{}", self.local, self.domain)
|
|
}
|
|
}
|
|
|
|
impl EmailAddress {
|
|
/// Performs a dead-simple parse of an email address.
|
|
pub fn new(input: &str) -> Result<EmailAddress> {
|
|
if input.is_empty() {
|
|
bail!("empty string is not valid");
|
|
}
|
|
let parts: Vec<&str> = input.rsplitn(2, '@').collect();
|
|
|
|
if input
|
|
.chars()
|
|
.any(|c| c.is_whitespace() || c == '<' || c == '>')
|
|
{
|
|
bail!("Email {:?} must not contain whitespaces, '>' or '<'", input);
|
|
}
|
|
|
|
match &parts[..] {
|
|
[domain, local] => {
|
|
if local.is_empty() {
|
|
bail!("empty string is not valid for local part in {:?}", input);
|
|
}
|
|
if domain.is_empty() {
|
|
bail!("missing domain after '@' in {:?}", input);
|
|
}
|
|
if domain.ends_with('.') {
|
|
bail!("Domain {domain:?} should not contain the dot in the end");
|
|
}
|
|
Ok(EmailAddress {
|
|
local: (*local).to_string(),
|
|
domain: (*domain).to_string(),
|
|
})
|
|
}
|
|
_ => bail!("Email {:?} must contain '@' character", input),
|
|
}
|
|
}
|
|
}
|
|
|
|
impl rusqlite::types::ToSql for EmailAddress {
|
|
fn to_sql(&self) -> rusqlite::Result<rusqlite::types::ToSqlOutput> {
|
|
let val = rusqlite::types::Value::Text(self.to_string());
|
|
let out = rusqlite::types::ToSqlOutput::Owned(val);
|
|
Ok(out)
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use chrono::TimeZone;
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_vcard_thunderbird() {
|
|
let contacts = parse_vcard(
|
|
"BEGIN:VCARD
|
|
VERSION:4.0
|
|
FN:'Alice Mueller'
|
|
EMAIL;PREF=1:alice.mueller@posteo.de
|
|
UID:a8083264-ca47-4be7-98a8-8ec3db1447ca
|
|
END:VCARD
|
|
BEGIN:VCARD
|
|
VERSION:4.0
|
|
FN:'bobzzz@freenet.de'
|
|
EMAIL;PREF=1:bobzzz@freenet.de
|
|
UID:cac4fef4-6351-4854-bbe4-9b6df857eaed
|
|
END:VCARD
|
|
",
|
|
);
|
|
|
|
assert_eq!(contacts[0].addr, "alice.mueller@posteo.de".to_string());
|
|
assert_eq!(contacts[0].authname, "Alice Mueller".to_string());
|
|
assert_eq!(contacts[0].key, None);
|
|
assert_eq!(contacts[0].profile_image, None);
|
|
assert!(contacts[0].timestamp.is_err());
|
|
|
|
assert_eq!(contacts[1].addr, "bobzzz@freenet.de".to_string());
|
|
assert_eq!(contacts[1].authname, "".to_string());
|
|
assert_eq!(contacts[1].key, None);
|
|
assert_eq!(contacts[1].profile_image, None);
|
|
assert!(contacts[1].timestamp.is_err());
|
|
|
|
assert_eq!(contacts.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_vcard_simple_example() {
|
|
let contacts = parse_vcard(
|
|
"BEGIN:VCARD
|
|
VERSION:4.0
|
|
FN:Alice Wonderland
|
|
N:Wonderland;Alice;;;Ms.
|
|
GENDER:W
|
|
EMAIL;TYPE=work:alice@example.com
|
|
KEY;TYPE=PGP;ENCODING=b:[base64-data]
|
|
REV:20240418T184242Z
|
|
|
|
END:VCARD",
|
|
);
|
|
|
|
assert_eq!(contacts[0].addr, "alice@example.com".to_string());
|
|
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
|
assert_eq!(contacts[0].key, Some("[base64-data]".to_string()));
|
|
assert_eq!(contacts[0].profile_image, None);
|
|
assert_eq!(*contacts[0].timestamp.as_ref().unwrap(), 1713465762);
|
|
|
|
assert_eq!(contacts.len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn test_make_and_parse_vcard() {
|
|
let contacts = [
|
|
VcardContact {
|
|
addr: "alice@example.org".to_string(),
|
|
authname: "Alice Wonderland".to_string(),
|
|
key: Some("[base64-data]".to_string()),
|
|
profile_image: Some("image in Base64".to_string()),
|
|
timestamp: Ok(1713465762),
|
|
},
|
|
VcardContact {
|
|
addr: "bob@example.com".to_string(),
|
|
authname: "".to_string(),
|
|
key: None,
|
|
profile_image: None,
|
|
timestamp: Ok(0),
|
|
},
|
|
];
|
|
let items = [
|
|
"BEGIN:VCARD\n\
|
|
VERSION:4.0\n\
|
|
EMAIL:alice@example.org\n\
|
|
FN:Alice Wonderland\n\
|
|
KEY:data:application/pgp-keys;base64,[base64-data]\n\
|
|
PHOTO:data:image/jpeg;base64,image in Base64\n\
|
|
REV:20240418T184242Z\n\
|
|
END:VCARD\n",
|
|
"BEGIN:VCARD\n\
|
|
VERSION:4.0\n\
|
|
EMAIL:bob@example.com\n\
|
|
FN:bob@example.com\n\
|
|
REV:19700101T000000Z\n\
|
|
END:VCARD\n",
|
|
];
|
|
let mut expected = "".to_string();
|
|
for len in 0..=contacts.len() {
|
|
let contacts = &contacts[0..len];
|
|
let vcard = make_vcard(contacts);
|
|
if len > 0 {
|
|
expected += items[len - 1];
|
|
}
|
|
assert_eq!(vcard, expected);
|
|
let parsed = parse_vcard(&vcard);
|
|
assert_eq!(parsed.len(), contacts.len());
|
|
for i in 0..parsed.len() {
|
|
assert_eq!(parsed[i].addr, contacts[i].addr);
|
|
assert_eq!(parsed[i].authname, contacts[i].authname);
|
|
assert_eq!(parsed[i].key, contacts[i].key);
|
|
assert_eq!(parsed[i].profile_image, contacts[i].profile_image);
|
|
assert_eq!(
|
|
parsed[i].timestamp.as_ref().unwrap(),
|
|
contacts[i].timestamp.as_ref().unwrap()
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_contact_address() -> Result<()> {
|
|
let alice_addr = "alice@example.org";
|
|
let contact_address = ContactAddress::new(alice_addr)?;
|
|
assert_eq!(contact_address.as_ref(), alice_addr);
|
|
|
|
let invalid_addr = "<> foobar";
|
|
assert!(ContactAddress::new(invalid_addr).is_err());
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[test]
|
|
fn test_emailaddress_parse() {
|
|
assert_eq!(EmailAddress::new("").is_ok(), false);
|
|
assert_eq!(
|
|
EmailAddress::new("user@domain.tld").unwrap(),
|
|
EmailAddress {
|
|
local: "user".into(),
|
|
domain: "domain.tld".into(),
|
|
}
|
|
);
|
|
assert_eq!(
|
|
EmailAddress::new("user@localhost").unwrap(),
|
|
EmailAddress {
|
|
local: "user".into(),
|
|
domain: "localhost".into()
|
|
}
|
|
);
|
|
assert_eq!(EmailAddress::new("uuu").is_ok(), false);
|
|
assert_eq!(EmailAddress::new("dd.tt").is_ok(), false);
|
|
assert!(EmailAddress::new("tt.dd@uu").is_ok());
|
|
assert!(EmailAddress::new("u@d").is_ok());
|
|
assert!(EmailAddress::new("u@d.").is_err());
|
|
assert!(EmailAddress::new("u@d.t").is_ok());
|
|
assert_eq!(
|
|
EmailAddress::new("u@d.tt").unwrap(),
|
|
EmailAddress {
|
|
local: "u".into(),
|
|
domain: "d.tt".into(),
|
|
}
|
|
);
|
|
assert!(EmailAddress::new("u@tt").is_ok());
|
|
assert_eq!(EmailAddress::new("@d.tt").is_ok(), false);
|
|
}
|
|
|
|
#[test]
|
|
fn test_vcard_android() {
|
|
let contacts = parse_vcard(
|
|
"BEGIN:VCARD
|
|
VERSION:2.1
|
|
N:;Bob;;;
|
|
FN:Bob
|
|
TEL;CELL:+1-234-567-890
|
|
EMAIL;HOME:bob@example.org
|
|
END:VCARD
|
|
BEGIN:VCARD
|
|
VERSION:2.1
|
|
N:;Alice;;;
|
|
FN:Alice
|
|
EMAIL;HOME:alice@example.org
|
|
END:VCARD
|
|
",
|
|
);
|
|
|
|
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
|
assert_eq!(contacts[0].authname, "Bob".to_string());
|
|
assert_eq!(contacts[0].key, None);
|
|
assert_eq!(contacts[0].profile_image, None);
|
|
|
|
assert_eq!(contacts[1].addr, "alice@example.org".to_string());
|
|
assert_eq!(contacts[1].authname, "Alice".to_string());
|
|
assert_eq!(contacts[1].key, None);
|
|
assert_eq!(contacts[1].profile_image, None);
|
|
|
|
assert_eq!(contacts.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn test_vcard_local_datetime() {
|
|
let contacts = parse_vcard(
|
|
"BEGIN:VCARD\n\
|
|
VERSION:4.0\n\
|
|
FN:Alice Wonderland\n\
|
|
EMAIL;TYPE=work:alice@example.org\n\
|
|
REV:20240418T184242\n\
|
|
END:VCARD",
|
|
);
|
|
assert_eq!(contacts.len(), 1);
|
|
assert_eq!(contacts[0].addr, "alice@example.org".to_string());
|
|
assert_eq!(contacts[0].authname, "Alice Wonderland".to_string());
|
|
assert_eq!(
|
|
*contacts[0].timestamp.as_ref().unwrap(),
|
|
chrono::offset::Local
|
|
.with_ymd_and_hms(2024, 4, 18, 18, 42, 42)
|
|
.unwrap()
|
|
.timestamp()
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_vcard_with_base64_avatar() {
|
|
// This is not an actual base64-encoded avatar, it's just to test the parsing.
|
|
// This one is Android-like.
|
|
let vcard0 = "BEGIN:VCARD
|
|
VERSION:2.1
|
|
N:;Bob;;;
|
|
FN:Bob
|
|
EMAIL;HOME:bob@example.org
|
|
PHOTO;ENCODING=BASE64;JPEG:/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEU
|
|
AAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAA
|
|
L8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==
|
|
|
|
END:VCARD
|
|
";
|
|
// This one is DOS-like.
|
|
let vcard1 = vcard0.replace('\n', "\r\n");
|
|
for vcard in [vcard0, vcard1.as_str()] {
|
|
let contacts = parse_vcard(vcard);
|
|
assert_eq!(contacts.len(), 1);
|
|
assert_eq!(contacts[0].addr, "bob@example.org".to_string());
|
|
assert_eq!(contacts[0].authname, "Bob".to_string());
|
|
assert_eq!(contacts[0].key, None);
|
|
assert_eq!(contacts[0].profile_image.as_deref().unwrap(), "/9j/4AAQSkZJRgABAQAAAQABAAD/4gIoSUNDX1BST0ZJTEUAAQEAAAIYAAAAAAQwAABtbnRyUkdCIFhZWiAAAAAAAAAAAAAAAABhY3NwAAAAAAAAAAAAAAAAL8bRuAJYoZUYrI4ZY3VWwxw4Ay28AAGBISScmf/2Q==");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_protonmail_vcard() {
|
|
let contacts = parse_vcard(
|
|
"BEGIN:VCARD
|
|
VERSION:4.0
|
|
FN;PREF=1:Alice Wonderland
|
|
UID:proton-web-03747582-328d-38dc-5ddd-000000000000
|
|
ITEM1.EMAIL;PREF=1:alice@example.org
|
|
ITEM1.KEY;PREF=1:data:application/pgp-keys;base64,aaaaaaaaaaaaaaaaaaaaaaaaa
|
|
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
|
|
ITEM1.KEY;PREF=2:data:application/pgp-keys;base64,bbbbbbbbbbbbbbbbbbbbbbbbb
|
|
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb
|
|
ITEM1.X-PM-ENCRYPT:true
|
|
ITEM1.X-PM-SIGN:true
|
|
END:VCARD",
|
|
);
|
|
|
|
assert_eq!(contacts.len(), 1);
|
|
assert_eq!(&contacts[0].addr, "alice@example.org");
|
|
assert_eq!(&contacts[0].authname, "Alice Wonderland");
|
|
assert_eq!(contacts[0].key.as_ref().unwrap(), "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa");
|
|
assert!(contacts[0].timestamp.is_err());
|
|
assert_eq!(contacts[0].profile_image, None);
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_name() {
|
|
assert_eq!(&sanitize_name(" hello world "), "hello world");
|
|
assert_eq!(&sanitize_name("<"), "<");
|
|
assert_eq!(&sanitize_name(">"), ">");
|
|
assert_eq!(&sanitize_name("'"), "'");
|
|
assert_eq!(&sanitize_name("\""), "\"");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_single_line() {
|
|
assert_eq!(sanitize_single_line("Hi\naiae "), "Hi aiae");
|
|
assert_eq!(sanitize_single_line("\r\nahte\n\r"), "ahte");
|
|
}
|
|
|
|
#[test]
|
|
fn test_sanitize_bidi_characters() {
|
|
// Legit inputs:
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2067}ting Delta Chat\u{2069}"),
|
|
"Tes\u{2067}ting Delta Chat\u{2069}"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2067}ting \u{2068} Delta Chat\u{2069}\u{2069}"),
|
|
"Tes\u{2067}ting \u{2068} Delta Chat\u{2069}\u{2069}"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2067}ting\u{2069} Delta Chat\u{2067}\u{2069}"),
|
|
"Tes\u{2067}ting\u{2069} Delta Chat\u{2067}\u{2069}"
|
|
);
|
|
|
|
// Potentially-malicious inputs:
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{202C}ting Delta Chat"),
|
|
"Testing Delta Chat"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Testing Delta Chat\u{2069}"),
|
|
"Testing Delta Chat"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2067}ting Delta Chat"),
|
|
"Testing Delta Chat"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2069}ting Delta Chat\u{2067}"),
|
|
"Testing Delta Chat"
|
|
);
|
|
|
|
assert_eq!(
|
|
&sanitize_bidi_characters("Tes\u{2068}ting Delta Chat"),
|
|
"Testing Delta Chat"
|
|
);
|
|
}
|
|
}
|