diff --git a/src/application/adapters/carddav_adapter.rs b/src/application/adapters/carddav_adapter.rs index 3728c8b6..8aa00f1d 100644 --- a/src/application/adapters/carddav_adapter.rs +++ b/src/application/adapters/carddav_adapter.rs @@ -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", diff --git a/src/application/adapters/mod.rs b/src/application/adapters/mod.rs index 60ec06b9..d0440c2c 100644 --- a/src/application/adapters/mod.rs +++ b/src/application/adapters/mod.rs @@ -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; diff --git a/src/application/adapters/param_encoding.rs b/src/application/adapters/param_encoding.rs new file mode 100644 index 00000000..edaa17db --- /dev/null +++ b/src/application/adapters/param_encoding.rs @@ -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^'\"" + ); + } +} diff --git a/src/application/services/contact_service.rs b/src/application/services/contact_service.rs index a46a4b5f..0387c12c 100644 --- a/src/application/services/contact_service.rs +++ b/src/application/services/contact_service.rs @@ -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", diff --git a/tests/api/webdav_carddav_param_encoding.hurl b/tests/api/webdav_carddav_param_encoding.hurl new file mode 100644 index 00000000..cc9a2c06 --- /dev/null +++ b/tests/api/webdav_carddav_param_encoding.hurl @@ -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