From 0622289420c74b551a96cc370daf436da280cd6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jagoda=20Estera=20=C5=9Al=C4=85zak?= <128227338+j-g00da@users.noreply.github.com> Date: Tue, 3 Mar 2026 13:45:32 +0100 Subject: [PATCH] fix(vcard): Improve property value escaping (#7931) Implements property value escaping according to RFC6350 section 3.4. Fixes: #7893 --- deltachat-contact-tools/src/vcard.rs | 45 ++++++++++++++++--- .../src/vcard/vcard_tests.rs | 15 ++++++- src/contact/contact_tests.rs | 7 ++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/deltachat-contact-tools/src/vcard.rs b/deltachat-contact-tools/src/vcard.rs index 77ad5bef4..0b09e9727 100644 --- a/deltachat-contact-tools/src/vcard.rs +++ b/deltachat-contact-tools/src/vcard.rs @@ -36,6 +36,45 @@ impl VcardContact { } } +fn escape(s: &str) -> String { + // https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4 + s + // backslash must be first! + .replace(r"\", r"\\") + .replace(',', r"\,") + .replace(';', r"\;") + .replace('\n', r"\n") +} + +fn unescape(s: &str) -> String { + // https://www.rfc-editor.org/rfc/rfc6350.html#section-3.4 + let mut out = String::new(); + + let mut chars = s.chars(); + while let Some(c) = chars.next() { + if c == '\\' { + if let Some(next) = chars.next() { + match next { + '\\' | ',' | ';' => out.push(next), + 'n' | 'N' => out.push('\n'), + _ => { + // Invalid escape sequence (keep unchanged) + out.push('\\'); + out.push(next); + } + } + } else { + // Invalid escape sequence (keep unchanged) + out.push('\\'); + } + } else { + out.push(c); + } + } + + out +} + /// Returns a vCard containing given contacts. /// /// Calling [`parse_vcard()`] on the returned result is a reverse operation. @@ -46,10 +85,6 @@ 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 { // Mustn't contain ',', but it's easier to escape than to error out. @@ -124,7 +159,7 @@ pub fn parse_vcard(vcard: &str) -> Vec { 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("\\,", ","))) + Some((params, unescape(value))) } fn base64_key(line: &str) -> Option<&str> { let (params, value) = vcard_property_raw(line, "key")?; diff --git a/deltachat-contact-tools/src/vcard/vcard_tests.rs b/deltachat-contact-tools/src/vcard/vcard_tests.rs index e8eb3afe9..5ebc8d616 100644 --- a/deltachat-contact-tools/src/vcard/vcard_tests.rs +++ b/deltachat-contact-tools/src/vcard/vcard_tests.rs @@ -91,7 +91,7 @@ fn test_make_and_parse_vcard() { authname: "Alice Wonderland".to_string(), key: Some("[base64-data]".to_string()), profile_image: Some("image in Base64".to_string()), - biography: Some("Hi, I'm Alice".to_string()), + biography: Some("Hi,\nI'm Alice; and this is a backslash: \\".to_string()), timestamp: Ok(1713465762), }, VcardContact { @@ -110,7 +110,7 @@ fn test_make_and_parse_vcard() { 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\ + NOTE:Hi\\,\\nI'm Alice\\; and this is a backslash: \\\\\r\n\ REV:20240418T184242Z\r\n\ END:VCARD\r\n", "BEGIN:VCARD\r\n\ @@ -276,3 +276,14 @@ END:VCARD", assert!(contacts[0].timestamp.is_err()); assert_eq!(contacts[0].profile_image.as_ref().unwrap(), "/9aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/Z"); } + +#[test] +fn test_vcard_value_escape_unescape() { + let original = "Text, with; chars and a \\ and a newline\nand a literal newline \\n"; + let expected_escaped = r"Text\, with\; chars and a \\ and a newline\nand a literal newline \\n"; + + let escaped = escape(original); + assert_eq!(escaped, expected_escaped); + let unescaped = unescape(&escaped); + assert_eq!(original, unescaped); +} diff --git a/src/contact/contact_tests.rs b/src/contact/contact_tests.rs index b4682ebdc..fd3aa66b9 100644 --- a/src/contact/contact_tests.rs +++ b/src/contact/contact_tests.rs @@ -1145,8 +1145,11 @@ async fn test_make_n_import_vcard() -> Result<()> { let alice = &tcm.alice().await; let bob = &tcm.bob().await; bob.set_config(Config::Displayname, Some("Bob")).await?; - bob.set_config(Config::Selfstatus, Some("It's me, bob")) - .await?; + bob.set_config( + Config::Selfstatus, + Some("It's me,\nbob; and here's a backslash: \\"), + ) + .await?; let avatar_path = bob.dir.path().join("avatar.png"); let avatar_bytes = include_bytes!("../../test-data/image/avatar64x64.png"); let avatar_base64 = base64::engine::general_purpose::STANDARD.encode(avatar_bytes);