From 7ec732977a0442ae188490ddc5900cb293641974 Mon Sep 17 00:00:00 2001 From: iequidoo Date: Fri, 13 Jun 2025 20:53:39 -0300 Subject: [PATCH] fix(contact-tools): Escape commas in vCards' FN, KEY, PHOTO, NOTE (#6912) Citing @link2xt: > RFC examples sometimes don't escape commas, but there is errata that fixes some of them. Also this unescapes commas in all fields. This can lead to, say, an email address with commas, but anyway the caller should check parsed `VcardContact`'s fields for correctness. --- deltachat-contact-tools/src/vcard.rs | 58 ++++++++++--------- .../src/vcard/vcard_tests.rs | 9 +-- 2 files changed, 37 insertions(+), 30 deletions(-) diff --git a/deltachat-contact-tools/src/vcard.rs b/deltachat-contact-tools/src/vcard.rs index faa1cb463..77ad5bef4 100644 --- a/deltachat-contact-tools/src/vcard.rs +++ b/deltachat-contact-tools/src/vcard.rs @@ -46,10 +46,15 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String { Some(datetime.format("%Y%m%dT%H%M%SZ").to_string()) } + fn escape(s: &str) -> String { + s.replace(',', "\\,") + } + let mut res = "".to_string(); for c in contacts { - let addr = &c.addr; - let display_name = c.display_name(); + // Mustn't contain ',', but it's easier to escape than to error out. + let addr = escape(&c.addr); + let display_name = escape(c.display_name()); res += &format!( "BEGIN:VCARD\r\n\ VERSION:4.0\r\n\ @@ -57,13 +62,13 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String { FN:{display_name}\r\n" ); if let Some(key) = &c.key { - res += &format!("KEY:data:application/pgp-keys;base64,{key}\r\n"); + res += &format!("KEY:data:application/pgp-keys;base64\\,{key}\r\n"); } if let Some(profile_image) = &c.profile_image { - res += &format!("PHOTO:data:image/jpeg;base64,{profile_image}\r\n"); + res += &format!("PHOTO:data:image/jpeg;base64\\,{profile_image}\r\n"); } if let Some(biography) = &c.biography { - res += &format!("NOTE:{biography}\r\n"); + res += &format!("NOTE:{}\r\n", escape(biography)); } if let Some(timestamp) = format_timestamp(c) { res += &format!("REV:{timestamp}\r\n"); @@ -84,8 +89,8 @@ pub fn parse_vcard(vcard: &str) -> Vec { None } } - /// Returns (parameters, value) tuple. - fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> { + /// Returns (parameters, raw value) tuple. + fn vcard_property_raw<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> { let remainder = remove_prefix(line, property)?; // If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`, // then `remainder` is now `;TYPE=work:alice@example.com` @@ -115,23 +120,25 @@ pub fn parse_vcard(vcard: &str) -> Vec { } Some((params, value)) } + /// Returns (parameters, unescaped value) tuple. + fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, String)> { + let (params, value) = vcard_property_raw(line, property)?; + // Some fields can't contain commas, but unescape them everywhere for safety. + Some((params, value.replace("\\,", ","))) + } fn base64_key(line: &str) -> Option<&str> { - let (params, value) = vcard_property(line, "key")?; + let (params, value) = vcard_property_raw(line, "key")?; if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64") || params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b") { return Some(value); } - if let Some(value) = remove_prefix(value, "data:application/pgp-keys;base64,") - .or_else(|| remove_prefix(value, r"data:application/pgp-keys;base64\,")) - { - return Some(value); - } - - None + remove_prefix(value, "data:application/pgp-keys;base64\\,") + // Old Delta Chat format. + .or_else(|| remove_prefix(value, "data:application/pgp-keys;base64,")) } fn base64_photo(line: &str) -> Option<&str> { - let (params, value) = vcard_property(line, "photo")?; + let (params, value) = vcard_property_raw(line, "photo")?; if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64") || params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG") || params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b") @@ -141,13 +148,9 @@ pub fn parse_vcard(vcard: &str) -> Vec { { return Some(value); } - if let Some(value) = remove_prefix(value, "data:image/jpeg;base64,") - .or_else(|| remove_prefix(value, r"data:image/jpeg;base64\,")) - { - return Some(value); - } - - None + remove_prefix(value, "data:image/jpeg;base64\\,") + // Old Delta Chat format. + .or_else(|| remove_prefix(value, "data:image/jpeg;base64,")) } fn parse_datetime(datetime: &str) -> Result { // According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp @@ -216,16 +219,19 @@ pub fn parse_vcard(vcard: &str) -> Vec { } else if let Some((_params, rev)) = vcard_property(line, "rev") { datetime.get_or_insert(rev); } else if line.eq_ignore_ascii_case("END:VCARD") { - let (authname, addr) = - sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or("")); + let (authname, addr) = sanitize_name_and_addr( + &display_name.unwrap_or_default(), + &addr.unwrap_or_default(), + ); contacts.push(VcardContact { authname, addr, key: key.map(|s| s.to_string()), profile_image: photo.map(|s| s.to_string()), - biography: biography.map(|b| b.to_owned()), + biography, timestamp: datetime + .as_deref() .context("No timestamp in vcard") .and_then(parse_datetime), }); diff --git a/deltachat-contact-tools/src/vcard/vcard_tests.rs b/deltachat-contact-tools/src/vcard/vcard_tests.rs index 60d2b0bdb..e8eb3afe9 100644 --- a/deltachat-contact-tools/src/vcard/vcard_tests.rs +++ b/deltachat-contact-tools/src/vcard/vcard_tests.rs @@ -108,9 +108,9 @@ fn test_make_and_parse_vcard() { VERSION:4.0\r\n\ EMAIL:alice@example.org\r\n\ FN:Alice Wonderland\r\n\ - KEY:data:application/pgp-keys;base64,[base64-data]\r\n\ - PHOTO:data:image/jpeg;base64,image in Base64\r\n\ - NOTE:Hi, I'm Alice\r\n\ + KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\ + PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\ + NOTE:Hi\\, I'm Alice\r\n\ REV:20240418T184242Z\r\n\ END:VCARD\r\n", "BEGIN:VCARD\r\n\ @@ -249,7 +249,8 @@ END:VCARD", assert_eq!(contacts[0].profile_image, None); } -/// Proton at some point slightly changed the format of their vcards +/// Proton at some point slightly changed the format of their vcards. +/// This also tests unescaped commas in PHOTO and KEY (old Delta Chat format). #[test] fn test_protonmail_vcard2() { let contacts = parse_vcard(