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(