mirror of
https://github.com/chatmail/core.git
synced 2026-05-23 00:36:32 +03:00
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.
This commit is contained in:
@@ -46,10 +46,15 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
|||||||
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
Some(datetime.format("%Y%m%dT%H%M%SZ").to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn escape(s: &str) -> String {
|
||||||
|
s.replace(',', "\\,")
|
||||||
|
}
|
||||||
|
|
||||||
let mut res = "".to_string();
|
let mut res = "".to_string();
|
||||||
for c in contacts {
|
for c in contacts {
|
||||||
let addr = &c.addr;
|
// Mustn't contain ',', but it's easier to escape than to error out.
|
||||||
let display_name = c.display_name();
|
let addr = escape(&c.addr);
|
||||||
|
let display_name = escape(c.display_name());
|
||||||
res += &format!(
|
res += &format!(
|
||||||
"BEGIN:VCARD\r\n\
|
"BEGIN:VCARD\r\n\
|
||||||
VERSION:4.0\r\n\
|
VERSION:4.0\r\n\
|
||||||
@@ -57,13 +62,13 @@ pub fn make_vcard(contacts: &[VcardContact]) -> String {
|
|||||||
FN:{display_name}\r\n"
|
FN:{display_name}\r\n"
|
||||||
);
|
);
|
||||||
if let Some(key) = &c.key {
|
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 {
|
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 {
|
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) {
|
if let Some(timestamp) = format_timestamp(c) {
|
||||||
res += &format!("REV:{timestamp}\r\n");
|
res += &format!("REV:{timestamp}\r\n");
|
||||||
@@ -84,8 +89,8 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/// Returns (parameters, value) tuple.
|
/// Returns (parameters, raw value) tuple.
|
||||||
fn vcard_property<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> {
|
fn vcard_property_raw<'a>(line: &'a str, property: &str) -> Option<(&'a str, &'a str)> {
|
||||||
let remainder = remove_prefix(line, property)?;
|
let remainder = remove_prefix(line, property)?;
|
||||||
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
|
// If `s` is `EMAIL;TYPE=work:alice@example.com` and `property` is `EMAIL`,
|
||||||
// then `remainder` is now `;TYPE=work:alice@example.com`
|
// then `remainder` is now `;TYPE=work:alice@example.com`
|
||||||
@@ -115,23 +120,25 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
|||||||
}
|
}
|
||||||
Some((params, value))
|
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> {
|
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")
|
if params.eq_ignore_ascii_case("PGP;ENCODING=BASE64")
|
||||||
|| params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b")
|
|| params.eq_ignore_ascii_case("TYPE=PGP;ENCODING=b")
|
||||||
{
|
{
|
||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
if let Some(value) = remove_prefix(value, "data:application/pgp-keys;base64,")
|
remove_prefix(value, "data:application/pgp-keys;base64\\,")
|
||||||
.or_else(|| remove_prefix(value, r"data:application/pgp-keys;base64\,"))
|
// Old Delta Chat format.
|
||||||
{
|
.or_else(|| remove_prefix(value, "data:application/pgp-keys;base64,"))
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
fn base64_photo(line: &str) -> Option<&str> {
|
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")
|
if params.eq_ignore_ascii_case("JPEG;ENCODING=BASE64")
|
||||||
|| params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG")
|
|| params.eq_ignore_ascii_case("ENCODING=BASE64;JPEG")
|
||||||
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b")
|
|| params.eq_ignore_ascii_case("TYPE=JPEG;ENCODING=b")
|
||||||
@@ -141,13 +148,9 @@ pub fn parse_vcard(vcard: &str) -> Vec<VcardContact> {
|
|||||||
{
|
{
|
||||||
return Some(value);
|
return Some(value);
|
||||||
}
|
}
|
||||||
if let Some(value) = remove_prefix(value, "data:image/jpeg;base64,")
|
remove_prefix(value, "data:image/jpeg;base64\\,")
|
||||||
.or_else(|| remove_prefix(value, r"data:image/jpeg;base64\,"))
|
// Old Delta Chat format.
|
||||||
{
|
.or_else(|| remove_prefix(value, "data:image/jpeg;base64,"))
|
||||||
return Some(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
fn parse_datetime(datetime: &str) -> Result<i64> {
|
fn parse_datetime(datetime: &str) -> Result<i64> {
|
||||||
// According to https://www.rfc-editor.org/rfc/rfc6350#section-4.3.5, the timestamp
|
// 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<VcardContact> {
|
|||||||
} else if let Some((_params, rev)) = vcard_property(line, "rev") {
|
} else if let Some((_params, rev)) = vcard_property(line, "rev") {
|
||||||
datetime.get_or_insert(rev);
|
datetime.get_or_insert(rev);
|
||||||
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
} else if line.eq_ignore_ascii_case("END:VCARD") {
|
||||||
let (authname, addr) =
|
let (authname, addr) = sanitize_name_and_addr(
|
||||||
sanitize_name_and_addr(display_name.unwrap_or(""), addr.unwrap_or(""));
|
&display_name.unwrap_or_default(),
|
||||||
|
&addr.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
|
||||||
contacts.push(VcardContact {
|
contacts.push(VcardContact {
|
||||||
authname,
|
authname,
|
||||||
addr,
|
addr,
|
||||||
key: key.map(|s| s.to_string()),
|
key: key.map(|s| s.to_string()),
|
||||||
profile_image: photo.map(|s| s.to_string()),
|
profile_image: photo.map(|s| s.to_string()),
|
||||||
biography: biography.map(|b| b.to_owned()),
|
biography,
|
||||||
timestamp: datetime
|
timestamp: datetime
|
||||||
|
.as_deref()
|
||||||
.context("No timestamp in vcard")
|
.context("No timestamp in vcard")
|
||||||
.and_then(parse_datetime),
|
.and_then(parse_datetime),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -108,9 +108,9 @@ fn test_make_and_parse_vcard() {
|
|||||||
VERSION:4.0\r\n\
|
VERSION:4.0\r\n\
|
||||||
EMAIL:alice@example.org\r\n\
|
EMAIL:alice@example.org\r\n\
|
||||||
FN:Alice Wonderland\r\n\
|
FN:Alice Wonderland\r\n\
|
||||||
KEY:data:application/pgp-keys;base64,[base64-data]\r\n\
|
KEY:data:application/pgp-keys;base64\\,[base64-data]\r\n\
|
||||||
PHOTO:data:image/jpeg;base64,image in Base64\r\n\
|
PHOTO:data:image/jpeg;base64\\,image in Base64\r\n\
|
||||||
NOTE:Hi, I'm Alice\r\n\
|
NOTE:Hi\\, I'm Alice\r\n\
|
||||||
REV:20240418T184242Z\r\n\
|
REV:20240418T184242Z\r\n\
|
||||||
END:VCARD\r\n",
|
END:VCARD\r\n",
|
||||||
"BEGIN:VCARD\r\n\
|
"BEGIN:VCARD\r\n\
|
||||||
@@ -249,7 +249,8 @@ END:VCARD",
|
|||||||
assert_eq!(contacts[0].profile_image, None);
|
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]
|
#[test]
|
||||||
fn test_protonmail_vcard2() {
|
fn test_protonmail_vcard2() {
|
||||||
let contacts = parse_vcard(
|
let contacts = parse_vcard(
|
||||||
|
|||||||
Reference in New Issue
Block a user