//! 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, /// The contact's profile image (=avatar) in Base64, vcard property `photo` pub profile_image: Option, /// The timestamp when the vcard was created / last updated, vcard property `rev` pub timestamp: Result, } 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 { 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 { 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 { // 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 = 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 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 { 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 { 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 " /// - 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 = 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 { 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 { 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" ); } }