Merge pull request #635 from swissiety/rfc-6868-param-encoding

fix(vcard): add RFC 6868 parameter value encoding
This commit is contained in:
Dionisio Pozo
2026-08-12 10:32:26 +02:00
committed by GitHub
5 changed files with 280 additions and 5 deletions
+10 -3
View File
@@ -11,6 +11,7 @@ use quick_xml::{
*/
use std::io::{BufReader, Read, Write};
use crate::application::adapters::param_encoding::render_param_value;
use crate::application::adapters::webdav_adapter::{
PropFindRequest, PropFindType, QualifiedName, Result, WebDavAdapter, WebDavError,
};
@@ -976,24 +977,30 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
}
for email in &contact.email {
let mut ty = String::new();
crate::common::fmt::push_upper(&mut ty, &email.r#type);
vcard.push_str("EMAIL;TYPE=");
crate::common::fmt::push_upper(&mut vcard, &email.r#type);
vcard.push_str(&render_param_value(&ty));
vcard.push(':');
vcard.push_str(&email.email);
vcard.push_str("\r\n");
}
for phone in &contact.phone {
let mut ty = String::new();
crate::common::fmt::push_upper(&mut ty, &phone.r#type);
vcard.push_str("TEL;TYPE=");
crate::common::fmt::push_upper(&mut vcard, &phone.r#type);
vcard.push_str(&render_param_value(&ty));
vcard.push(':');
vcard.push_str(&phone.number);
vcard.push_str("\r\n");
}
for addr in &contact.address {
let mut ty = String::new();
crate::common::fmt::push_upper(&mut ty, &addr.r#type);
vcard.push_str("ADR;TYPE=");
crate::common::fmt::push_upper(&mut vcard, &addr.r#type);
vcard.push_str(&render_param_value(&ty));
let _ = write!(
vcard,
":;;{};{};{};{};{}\r\n",
+1
View File
@@ -2,6 +2,7 @@
pub mod caldav_adapter;
pub mod carddav_adapter;
pub mod param_encoding;
pub mod plugin_lifecycle_hook;
pub mod plugin_user_lifecycle_hook;
pub mod webdav_adapter;
+155
View File
@@ -0,0 +1,155 @@
//! RFC 6868 parameter value encoding, shared by the vCard (RFC 6350) and
//! iCalendar (RFC 5545) generators/parsers.
//!
//! A vCard/iCalendar parameter value must be double-quoted when it
//! contains a COMMA, SEMICOLON, or COLON (the characters that would
//! otherwise be ambiguous with the surrounding content-line grammar).
//! But a quoted value's grammar (`QSTR = DQUOTE *QSAFE-CHAR DQUOTE`)
//! still can't contain a literal DQUOTE, and has no way to represent an
//! embedded newline — RFC 6868 defines three caret-escape sequences to
//! carry them inside a quoted value:
//!
//! - `^n` — newline
//! - `^^` — a literal `^`
//! - `^'` — a literal `"` (DQUOTE)
//!
//! Any other `^x` sequence is left unchanged when decoding (RFC 6868
//! §3.2: "any other occurrence of ^ ... MUST be treated as … literal").
/// Encode a raw parameter value's special characters per RFC 6868. Only
/// meaningful once the value is going to be quoted — see
/// [`render_param_value`].
pub fn encode_param_value(raw: &str) -> String {
let mut out = String::with_capacity(raw.len());
for ch in raw.chars() {
match ch {
'^' => out.push_str("^^"),
'"' => out.push_str("^'"),
'\n' => out.push_str("^n"),
'\r' => {} // Bare CR is not itself encodable; CRLF collapses to the `\n` case above.
_ => out.push(ch),
}
}
out
}
/// Decode RFC 6868 caret-escape sequences back to raw characters.
/// Unrecognized `^x` sequences pass through with the caret kept literal.
pub fn decode_param_value(encoded: &str) -> String {
let mut out = String::with_capacity(encoded.len());
let mut chars = encoded.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '^' {
out.push(ch);
continue;
}
match chars.peek() {
Some('n') => {
out.push('\n');
chars.next();
}
Some('^') => {
out.push('^');
chars.next();
}
Some('\'') => {
out.push('"');
chars.next();
}
_ => out.push('^'),
}
}
out
}
/// Whether `value` must be double-quoted per the vCard (RFC 6350 §3.3)
/// / iCalendar (RFC 5545 §3.2) `param-value` grammar.
fn needs_quoting(value: &str) -> bool {
value.contains(',') || value.contains(';') || value.contains(':')
}
/// Render `value` for a content-line parameter position: quoted and
/// RFC 6868-encoded if it contains characters the bare-token grammar
/// can't carry (comma/semicolon/colon triggering quoting, or a
/// DQUOTE/caret/newline that would break even a quoted value),
/// otherwise returned unchanged.
/// Applies to any parameter whose value uses the `param-value` grammar
/// (RFC 5545 §3.1 / RFC 6350 §3.3), not just TYPE — wrap every
/// free-text parameter value built from user input, not property
/// values (those use backslash escaping, not this caret scheme).
pub fn render_param_value(value: &str) -> String {
let needs_encoding = value.contains('"') || value.contains('^') || value.contains('\n');
if needs_quoting(value) || needs_encoding {
format!("\"{}\"", encode_param_value(value))
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn encode_escapes_all_three_sequences() {
assert_eq!(encode_param_value("a^b"), "a^^b");
assert_eq!(encode_param_value("say \"hi\""), "say ^'hi^'");
assert_eq!(encode_param_value("line1\nline2"), "line1^nline2");
}
#[test]
fn encode_leaves_plain_text_unchanged() {
assert_eq!(encode_param_value("Work Phone"), "Work Phone");
}
#[test]
fn decode_reverses_encode_roundtrip() {
let originals = [
"a^b",
"say \"hi\"",
"line1\nline2",
"^n literal caret then n",
];
for original in originals {
let encoded = encode_param_value(original);
assert_eq!(decode_param_value(&encoded), original);
}
}
#[test]
fn decode_leaves_unrecognized_caret_sequences_literal() {
// `^x` is not one of the three defined sequences — caret stays.
assert_eq!(decode_param_value("a^xb"), "a^xb");
// Trailing caret with nothing after it.
assert_eq!(decode_param_value("trailing^"), "trailing^");
}
#[test]
fn needs_quoting_detects_grammar_special_chars() {
assert!(needs_quoting("Smith, John"));
assert!(needs_quoting("a;b"));
assert!(needs_quoting("a:b"));
assert!(!needs_quoting("plain-value"));
}
#[test]
fn render_param_value_quotes_only_when_needed() {
assert_eq!(render_param_value("WORK"), "WORK");
assert_eq!(render_param_value("Smith, John"), "\"Smith, John\"");
}
#[test]
fn render_param_value_quotes_and_encodes_embedded_dquote() {
// No comma/semicolon/colon, but the embedded DQUOTE alone forces
// quoting (otherwise the bare token would itself be invalid).
assert_eq!(render_param_value("say \"hi\""), "\"say ^'hi^'\"");
}
#[test]
fn render_param_value_quotes_and_encodes_when_both_triggers_present() {
assert_eq!(
render_param_value("Smith, \"Johnny\""),
"\"Smith, ^'Johnny^'\""
);
}
}
+7 -2
View File
@@ -2,6 +2,7 @@ use chrono::Utc;
use std::sync::Arc;
use uuid::Uuid;
use crate::application::adapters::param_encoding::render_param_value;
use crate::application::dtos::address_book_dto::{
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
};
@@ -286,8 +287,10 @@ impl ContactService {
// Email addresses
for email in contact.email() {
let mut ty = String::new();
crate::common::fmt::push_upper(&mut ty, &email.r#type);
vcard.push_str("EMAIL;TYPE=");
crate::common::fmt::push_upper(&mut vcard, &email.r#type);
vcard.push_str(&render_param_value(&ty));
vcard.push(':');
vcard.push_str(&email.email);
vcard.push_str("\r\n");
@@ -313,8 +316,10 @@ impl ContactService {
let postal_code = addr.postal_code.as_deref().unwrap_or_default();
let country = addr.country.as_deref().unwrap_or_default();
let mut ty = String::new();
crate::common::fmt::push_upper(&mut ty, &addr.r#type);
vcard.push_str("ADR;TYPE=");
crate::common::fmt::push_upper(&mut vcard, &addr.r#type);
vcard.push_str(&render_param_value(&ty));
let _ = write!(
vcard,
":;;{};{};{};{};{}\r\n",