Merge pull request #635 from swissiety/rfc-6868-param-encoding
fix(vcard): add RFC 6868 parameter value encoding
This commit is contained in:
@@ -11,6 +11,7 @@ use quick_xml::{
|
|||||||
*/
|
*/
|
||||||
use std::io::{BufReader, Read, Write};
|
use std::io::{BufReader, Read, Write};
|
||||||
|
|
||||||
|
use crate::application::adapters::param_encoding::render_param_value;
|
||||||
use crate::application::adapters::webdav_adapter::{
|
use crate::application::adapters::webdav_adapter::{
|
||||||
PropFindRequest, PropFindType, QualifiedName, Result, WebDavAdapter, WebDavError,
|
PropFindRequest, PropFindType, QualifiedName, Result, WebDavAdapter, WebDavError,
|
||||||
};
|
};
|
||||||
@@ -976,24 +977,30 @@ pub fn contact_to_vcard(contact: &ContactDto) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for email in &contact.email {
|
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=");
|
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(':');
|
||||||
vcard.push_str(&email.email);
|
vcard.push_str(&email.email);
|
||||||
vcard.push_str("\r\n");
|
vcard.push_str("\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
for phone in &contact.phone {
|
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=");
|
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(':');
|
||||||
vcard.push_str(&phone.number);
|
vcard.push_str(&phone.number);
|
||||||
vcard.push_str("\r\n");
|
vcard.push_str("\r\n");
|
||||||
}
|
}
|
||||||
|
|
||||||
for addr in &contact.address {
|
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=");
|
vcard.push_str("ADR;TYPE=");
|
||||||
crate::common::fmt::push_upper(&mut vcard, &addr.r#type);
|
vcard.push_str(&render_param_value(&ty));
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
vcard,
|
vcard,
|
||||||
":;;{};{};{};{};{}\r\n",
|
":;;{};{};{};{};{}\r\n",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
pub mod caldav_adapter;
|
pub mod caldav_adapter;
|
||||||
pub mod carddav_adapter;
|
pub mod carddav_adapter;
|
||||||
|
pub mod param_encoding;
|
||||||
pub mod plugin_lifecycle_hook;
|
pub mod plugin_lifecycle_hook;
|
||||||
pub mod plugin_user_lifecycle_hook;
|
pub mod plugin_user_lifecycle_hook;
|
||||||
pub mod webdav_adapter;
|
pub mod webdav_adapter;
|
||||||
|
|||||||
@@ -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^'\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ use chrono::Utc;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::application::adapters::param_encoding::render_param_value;
|
||||||
use crate::application::dtos::address_book_dto::{
|
use crate::application::dtos::address_book_dto::{
|
||||||
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
AddressBookDto, CreateAddressBookDto, UpdateAddressBookDto,
|
||||||
};
|
};
|
||||||
@@ -286,8 +287,10 @@ impl ContactService {
|
|||||||
|
|
||||||
// Email addresses
|
// Email addresses
|
||||||
for email in contact.email() {
|
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=");
|
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(':');
|
||||||
vcard.push_str(&email.email);
|
vcard.push_str(&email.email);
|
||||||
vcard.push_str("\r\n");
|
vcard.push_str("\r\n");
|
||||||
@@ -313,8 +316,10 @@ impl ContactService {
|
|||||||
let postal_code = addr.postal_code.as_deref().unwrap_or_default();
|
let postal_code = addr.postal_code.as_deref().unwrap_or_default();
|
||||||
let country = addr.country.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=");
|
vcard.push_str("ADR;TYPE=");
|
||||||
crate::common::fmt::push_upper(&mut vcard, &addr.r#type);
|
vcard.push_str(&render_param_value(&ty));
|
||||||
let _ = write!(
|
let _ = write!(
|
||||||
vcard,
|
vcard,
|
||||||
":;;{};{};{};{};{}\r\n",
|
":;;{};{};{};{};{}\r\n",
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — vCard parameter value encoding (RFC 6868)
|
||||||
|
# =============================================================
|
||||||
|
# Contact `type` fields (EMAIL/TEL/ADR) are free text, not a fixed
|
||||||
|
# enum — a value containing a comma, semicolon, colon, or DQUOTE
|
||||||
|
# previously broke the generated vCard's grammar outright (no quoting
|
||||||
|
# or escaping was applied at all). See
|
||||||
|
# application/adapters/param_encoding.rs::render_param_value, wired
|
||||||
|
# into carddav_adapter.rs::contact_to_vcard and
|
||||||
|
# contact_service.rs::generate_vcard.
|
||||||
|
#
|
||||||
|
# Coverage:
|
||||||
|
# 1. A plain type value ("work") is emitted unquoted, unchanged.
|
||||||
|
# 2. A type value containing a comma and an embedded DQUOTE is
|
||||||
|
# emitted quoted and RFC 6868-encoded (embedded DQUOTE → `^'`),
|
||||||
|
# producing a well-formed content line instead of broken syntax.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 1 — Login, capture JWT
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
token: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 2 — Fresh address book.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/address-books
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "name": "param-encoding-test", "description": "", "is_public": false }
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
book_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 3 — Contact with an EMAIL type needing RFC 6868 encoding:
|
||||||
|
# contains a comma (grammar-triggers quoting) AND an
|
||||||
|
# embedded DQUOTE (needs caret-escaping even once quoted).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/address-books/{{book_id}}/contacts
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"first_name": "Special",
|
||||||
|
"last_name": "Case",
|
||||||
|
"full_name": "Special Case",
|
||||||
|
"email": [
|
||||||
|
{ "email": "special@example.com", "type": "Work, \"Primary\"", "is_primary": true }
|
||||||
|
],
|
||||||
|
"phone": [
|
||||||
|
{ "number": "+1-555-0199", "type": "mobile", "is_primary": true }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
contact_id: jsonpath "$.id"
|
||||||
|
contact_uid: jsonpath "$.uid"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step 4 — Fetch the generated vCard via CardDAV GET.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/carddav/{{book_id}}/{{contact_uid}}.vcf
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body startsWith "BEGIN:VCARD"
|
||||||
|
# Plain type value ("mobile") stays bare, unquoted.
|
||||||
|
body contains "TEL;TYPE=MOBILE:+1-555-0199"
|
||||||
|
# Comma + embedded DQUOTE forces quoting and RFC 6868 caret-encoding.
|
||||||
|
body contains "EMAIL;TYPE=\"WORK, ^'PRIMARY^'\":special@example.com"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Cleanup
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
contact_etag: header "ETag"
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/address-books/{{book_id}}/contacts/{{contact_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
If-Match: {{contact_etag}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/address-books/{{book_id}}
|
||||||
|
Authorization: Bearer {{token}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
Reference in New Issue
Block a user