From afd8febbf11a29b349bdb5a5ecb7fb1bf196dbc4 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sat, 7 Jun 2025 20:15:42 +0200 Subject: [PATCH] Percent encoding workaround for Apple Calendar bug --- crates/dav/src/calendar/update.rs | 17 +++++++------ crates/dav/src/card/update.rs | 17 +++++++------ crates/dav/src/common/acl.rs | 7 ++---- crates/dav/src/common/propfind.rs | 6 ++--- crates/dav/src/file/update.rs | 20 +++++++++------ crates/dav/src/lib.rs | 27 +++++++++++++++++++- crates/dav/src/principal/mod.rs | 4 +-- crates/dav/src/principal/propfind.rs | 14 +++++------ crates/groupware/src/cache/calcard.rs | 5 ++-- crates/groupware/src/cache/file.rs | 5 ++-- crates/groupware/src/calendar/storage.rs | 5 ++-- crates/groupware/src/lib.rs | 32 ++++++++++++++++++++++++ 12 files changed, 111 insertions(+), 48 deletions(-) diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index 5d6187a9..28686a47 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -37,6 +37,7 @@ use crate::{ uri::DavUriResource, }, file::DavFileResource, + fix_percent_encoding, }; use super::assert_is_unique_uid; @@ -69,9 +70,11 @@ impl CalendarUpdateRequestHandler for Server { .fetch_dav_resources(access_token, account_id, SyncCollection::Calendar) .await .caused_by(trc::location!())?; - let resource_name = resource - .resource - .ok_or(DavError::Code(StatusCode::CONFLICT))?; + let resource_name = fix_percent_encoding( + resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?, + ); if bytes.len() > self.core.groupware.max_ical_size { return Err(DavError::Condition(DavErrorCondition::new( @@ -96,7 +99,7 @@ impl CalendarUpdateRequestHandler for Server { } }; - if let Some(resource) = resources.by_path(resource_name) { + if let Some(resource) = resources.by_path(resource_name.as_ref()) { if resource.is_container() { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } @@ -130,7 +133,7 @@ impl CalendarUpdateRequestHandler for Server { collection: Collection::CalendarEvent, document_id: Some(document_id), etag: event.etag().into(), - path: resource_name, + path: resource_name.as_ref(), ..Default::default() }], Default::default(), @@ -210,7 +213,7 @@ impl CalendarUpdateRequestHandler for Server { } Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) - } else if let Some((Some(parent), name)) = resources.map_parent(resource_name) { + } else if let Some((Some(parent), name)) = resources.map_parent(resource_name.as_ref()) { if !parent.is_container() { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } @@ -234,7 +237,7 @@ impl CalendarUpdateRequestHandler for Server { account_id, collection: resource.collection, document_id: Some(u32::MAX), - path: resource_name, + path: resource_name.as_ref(), ..Default::default() }], Default::default(), diff --git a/crates/dav/src/card/update.rs b/crates/dav/src/card/update.rs index 22416f4b..235d69e4 100644 --- a/crates/dav/src/card/update.rs +++ b/crates/dav/src/card/update.rs @@ -28,6 +28,7 @@ use crate::{ uri::DavUriResource, }, file::DavFileResource, + fix_percent_encoding, }; use super::assert_is_unique_uid; @@ -60,9 +61,11 @@ impl CardUpdateRequestHandler for Server { .fetch_dav_resources(access_token, account_id, SyncCollection::AddressBook) .await .caused_by(trc::location!())?; - let resource_name = resource - .resource - .ok_or(DavError::Code(StatusCode::CONFLICT))?; + let resource_name = fix_percent_encoding( + resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?, + ); if bytes.len() > self.core.groupware.max_vcard_size { return Err(DavError::Condition(DavErrorCondition::new( @@ -87,7 +90,7 @@ impl CardUpdateRequestHandler for Server { } }; - if let Some(resource) = resources.by_path(resource_name) { + if let Some(resource) = resources.by_path(resource_name.as_ref()) { if resource.is_container() { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } @@ -121,7 +124,7 @@ impl CardUpdateRequestHandler for Server { collection: Collection::ContactCard, document_id: Some(document_id), etag: card.etag().into(), - path: resource_name, + path: resource_name.as_ref(), ..Default::default() }], Default::default(), @@ -184,7 +187,7 @@ impl CardUpdateRequestHandler for Server { self.commit_batch(batch).await.caused_by(trc::location!())?; Ok(HttpResponse::new(StatusCode::NO_CONTENT).with_etag_opt(etag)) - } else if let Some((Some(parent), name)) = resources.map_parent(resource_name) { + } else if let Some((Some(parent), name)) = resources.map_parent(resource_name.as_ref()) { if !parent.is_container() { return Err(DavError::Code(StatusCode::METHOD_NOT_ALLOWED)); } @@ -208,7 +211,7 @@ impl CardUpdateRequestHandler for Server { account_id, collection: resource.collection, document_id: Some(u32::MAX), - path: resource_name, + path: resource_name.as_ref(), ..Default::default() }], Default::default(), diff --git a/crates/dav/src/common/acl.rs b/crates/dav/src/common/acl.rs index 7ecda8c1..c5ef28a3 100644 --- a/crates/dav/src/common/acl.rs +++ b/crates/dav/src/common/acl.rs @@ -18,6 +18,7 @@ use dav_proto::{ }, }; use directory::{QueryBy, Type, backend::internal::manage::ManageDirectory}; +use groupware::RFC_3986; use groupware::{cache::GroupwareCache, calendar::Calendar, contact::AddressBook, file::FileNode}; use http_proto::HttpResponse; use hyper::StatusCode; @@ -26,7 +27,6 @@ use jmap_proto::types::{ collection::Collection, value::{AclGrant, ArchivedAclGrant}, }; -use percent_encoding::NON_ALPHANUMERIC; use rkyv::vec::ArchivedVec; use store::{ahash::AHashSet, roaring::RoaringBitmap, write::BatchBuilder}; use trc::AddContext; @@ -435,10 +435,7 @@ impl DavAclHandler for Server { Principal::Href(Href(format!( "{}/{}/", DavResourceName::Principal.base_path(), - percent_encoding::utf8_percent_encode( - &grant_account_name, - NON_ALPHANUMERIC - ), + percent_encoding::utf8_percent_encode(&grant_account_name, RFC_3986), ))) }; diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index e5292d28..393eb7ac 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -47,6 +47,7 @@ use dav_proto::{ }, }; use directory::{Permission, Type, backend::internal::manage::ManageDirectory}; +use groupware::RFC_3986; use groupware::{ DavCalendarResource, DavResourceName, cache::GroupwareCache, calendar::ArchivedTimezone, }; @@ -56,7 +57,6 @@ use jmap_proto::types::{ acl::Acl, collection::{Collection, SyncCollection}, }; -use percent_encoding::NON_ALPHANUMERIC; use std::sync::Arc; use store::{ ahash::AHashMap, @@ -263,7 +263,7 @@ impl PropFindRequestHandler for Server { DavResourceName::Cal.base_path(), percent_encoding::utf8_percent_encode( &access_token.name, - NON_ALPHANUMERIC + RFC_3986 ), ))], )); @@ -277,7 +277,7 @@ impl PropFindRequestHandler for Server { DavResourceName::Card.base_path(), percent_encoding::utf8_percent_encode( &access_token.name, - NON_ALPHANUMERIC + RFC_3986 ), ))], )); diff --git a/crates/dav/src/file/update.rs b/crates/dav/src/file/update.rs index f1521da9..05239a47 100644 --- a/crates/dav/src/file/update.rs +++ b/crates/dav/src/file/update.rs @@ -13,6 +13,7 @@ use crate::{ uri::DavUriResource, }, file::DavFileResource, + fix_percent_encoding, }; use common::{ Server, auth::AccessToken, sharing::EffectiveAcl, storage::index::ObjectIndexBuilder, @@ -60,15 +61,20 @@ impl FileUpdateRequestHandler for Server { .fetch_dav_resources(access_token, account_id, SyncCollection::FileNode) .await .caused_by(trc::location!())?; - let resource_name = resource - .resource - .ok_or(DavError::Code(StatusCode::CONFLICT))?; + let resource_name = fix_percent_encoding( + resource + .resource + .ok_or(DavError::Code(StatusCode::CONFLICT))?, + ); if bytes.len() > self.core.groupware.max_file_size { return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE)); } - if let Some(document_id) = resources.by_path(resource_name).map(|r| r.document_id()) { + if let Some(document_id) = resources + .by_path(resource_name.as_ref()) + .map(|r| r.document_id()) + { // Update let node_ = self .get_archive(account_id, Collection::FileNode, document_id) @@ -100,7 +106,7 @@ impl FileUpdateRequestHandler for Server { collection: resource.collection, document_id: Some(document_id), etag: node.etag().into(), - path: resource_name, + path: resource_name.as_ref(), ..Default::default() }], Default::default(), @@ -196,7 +202,7 @@ impl FileUpdateRequestHandler for Server { // Insert let orig_resource_name = resource_name; let (parent, resource_name) = resources - .map_parent(resource_name) + .map_parent(orig_resource_name.as_ref()) .ok_or(DavError::Code(StatusCode::CONFLICT))?; // Validate ACL @@ -220,7 +226,7 @@ impl FileUpdateRequestHandler for Server { account_id, collection: resource.collection, document_id: Some(u32::MAX), - path: orig_resource_name, + path: orig_resource_name.as_ref(), ..Default::default() }], Default::default(), diff --git a/crates/dav/src/lib.rs b/crates/dav/src/lib.rs index d4570f56..0a266bf7 100644 --- a/crates/dav/src/lib.rs +++ b/crates/dav/src/lib.rs @@ -15,8 +15,9 @@ use dav_proto::schema::{ request::DavPropertyValue, response::{Condition, List, Prop, PropStat, ResponseDescription, Status}, }; -use groupware::DavResourceName; +use groupware::{DavResourceName, RFC_3986}; use hyper::{Method, StatusCode}; +use std::borrow::Cow; use store::ahash::AHashMap; pub(crate) type Result = std::result::Result; @@ -224,3 +225,27 @@ impl PropStatBuilder { .collect() } } + +// Workaround for Apple bug with missing percent encoding in paths +pub(crate) fn fix_percent_encoding(path: &str) -> Cow { + let (parent, name) = if let Some((parent, name)) = path.rsplit_once('/') { + (Some(parent), name) + } else { + (None, path) + }; + + for &ch in name.as_bytes() { + if !matches!(ch, b'0'..=b'9' | b'A'..=b'Z' | b'a'..=b'z' | b'-' | b'.' | b'_' | b'~' | b'%') + { + let name = percent_encoding::percent_encode(name.as_bytes(), RFC_3986); + + return if let Some(parent) = parent { + Cow::Owned(format!("{parent}/{name}")) + } else { + Cow::Owned(name.to_string()) + }; + } + } + + path.into() +} diff --git a/crates/dav/src/principal/mod.rs b/crates/dav/src/principal/mod.rs index f7779911..2a4dc0b1 100644 --- a/crates/dav/src/principal/mod.rs +++ b/crates/dav/src/principal/mod.rs @@ -6,7 +6,7 @@ use common::auth::AccessToken; use dav_proto::schema::response::Href; -use percent_encoding::NON_ALPHANUMERIC; +use groupware::RFC_3986; use crate::DavResourceName; @@ -23,7 +23,7 @@ impl CurrentUserPrincipal for AccessToken { Href(format!( "{}/{}/", DavResourceName::Principal.base_path(), - percent_encoding::utf8_percent_encode(&self.name, NON_ALPHANUMERIC) + percent_encoding::utf8_percent_encode(&self.name, RFC_3986) )) } } diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index ef5eba65..a37d276f 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -20,7 +20,7 @@ use directory::{QueryBy, backend::internal::manage::ManageDirectory}; use groupware::cache::GroupwareCache; use hyper::StatusCode; use jmap_proto::types::collection::Collection; -use percent_encoding::NON_ALPHANUMERIC; +use groupware::RFC_3986; use trc::AddContext; use crate::{ @@ -212,7 +212,7 @@ impl PrincipalPropFind for Server { vec![Href(format!( "{}/{}/", DavResourceName::Principal.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ))], )); } @@ -257,7 +257,7 @@ impl PrincipalPropFind for Server { vec![Href(format!( "{}/{}/", DavResourceName::Principal.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ))], )); } @@ -267,7 +267,7 @@ impl PrincipalPropFind for Server { vec![Href(format!( "{}/{}/", DavResourceName::Cal.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ))], )); response.set_namespace(Namespace::CalDav); @@ -278,7 +278,7 @@ impl PrincipalPropFind for Server { vec![Href(format!( "{}/{}/", DavResourceName::Card.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ))], )); response.set_namespace(Namespace::CardDav); @@ -310,7 +310,7 @@ impl PrincipalPropFind for Server { Href(format!( "{}/{}/", base_path, - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), )), prop_stats, )); @@ -351,7 +351,7 @@ impl PrincipalPropFind for Server { Ok(Href(format!( "{}/{}/", DavResourceName::Principal.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ))) } } diff --git a/crates/groupware/src/cache/calcard.rs b/crates/groupware/src/cache/calcard.rs index ea86f62d..ae70928c 100644 --- a/crates/groupware/src/cache/calcard.rs +++ b/crates/groupware/src/cache/calcard.rs @@ -6,7 +6,7 @@ use super::GroupwareCache; use crate::{ - DavResourceName, + DavResourceName, RFC_3986, calendar::{ArchivedCalendar, ArchivedCalendarEvent, Calendar, CalendarEvent}, contact::{AddressBook, ArchivedAddressBook, ArchivedContactCard, ContactCard}, }; @@ -19,7 +19,6 @@ use jmap_proto::types::{ collection::{Collection, SyncCollection}, value::AclGrant, }; -use percent_encoding::NON_ALPHANUMERIC; use std::sync::Arc; use store::ahash::{AHashMap, AHashSet}; use tokio::sync::Semaphore; @@ -98,7 +97,7 @@ pub(super) async fn build_calcard_resources( DavResourceName::Card } .base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ), paths: AHashSet::with_capacity((container_ids.len() + item_ids.len()) as usize), resources: Vec::with_capacity((container_ids.len() + item_ids.len()) as usize), diff --git a/crates/groupware/src/cache/file.rs b/crates/groupware/src/cache/file.rs index c7dd849b..3ccfecb9 100644 --- a/crates/groupware/src/cache/file.rs +++ b/crates/groupware/src/cache/file.rs @@ -5,7 +5,7 @@ */ use crate::{ - DavResourceName, + DavResourceName, RFC_3986, file::{ArchivedFileNode, FileNode}, }; use common::{DavPath, DavResource, DavResourceMetadata, DavResources, Server}; @@ -15,7 +15,6 @@ use jmap_proto::types::{ property::Property, value::AclGrant, }; -use percent_encoding::NON_ALPHANUMERIC; use std::sync::Arc; use store::{ Deserialize, IterateParams, U32_LEN, ValueKey, @@ -50,7 +49,7 @@ pub(super) async fn build_file_resources( base_path: format!( "{}/{}/", DavResourceName::File.base_path(), - percent_encoding::utf8_percent_encode(&name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&name, RFC_3986), ), size: std::mem::size_of::() as u64, paths: AHashSet::with_capacity(resources.len()), diff --git a/crates/groupware/src/calendar/storage.rs b/crates/groupware/src/calendar/storage.rs index 5d169b4d..62679121 100644 --- a/crates/groupware/src/calendar/storage.rs +++ b/crates/groupware/src/calendar/storage.rs @@ -4,11 +4,10 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use crate::{DavResourceName, DestroyArchive}; +use crate::{DavResourceName, DestroyArchive, RFC_3986}; use calcard::common::timezone::Tz; use common::{Server, auth::AccessToken, storage::index::ObjectIndexBuilder}; use jmap_proto::types::collection::{Collection, VanishedCollection}; -use percent_encoding::NON_ALPHANUMERIC; use store::{ U16_LEN, U64_LEN, write::{Archive, BatchBuilder, TaskQueueClass, ValueClass, key::KeySerializer, now}, @@ -325,7 +324,7 @@ impl ArchivedCalendarEvent { "webcal://{}{}/{}/{}/{}", server.core.network.server_name, DavResourceName::Cal.base_path(), - percent_encoding::utf8_percent_encode(&access_token.name, NON_ALPHANUMERIC), + percent_encoding::utf8_percent_encode(&access_token.name, RFC_3986), calendar.name, event_name.name )); diff --git a/crates/groupware/src/lib.rs b/crates/groupware/src/lib.rs index 50f77af8..be1f3f5a 100644 --- a/crates/groupware/src/lib.rs +++ b/crates/groupware/src/lib.rs @@ -7,6 +7,7 @@ use calcard::common::timezone::Tz; use common::DavResources; use jmap_proto::types::collection::Collection; +use percent_encoding::{AsciiSet, CONTROLS}; pub mod cache; pub mod calendar; @@ -21,6 +22,37 @@ pub enum DavResourceName { Principal, } +pub const RFC_3986: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'!') + .add(b'"') + .add(b'#') + .add(b'$') + .add(b'%') + .add(b'&') + .add(b'\'') + .add(b'(') + .add(b')') + .add(b'*') + .add(b'+') + .add(b',') + .add(b'/') + .add(b':') + .add(b';') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'@') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + pub struct DestroyArchive(pub T); impl DavResourceName {