From 5416e35d4ddd4037c235cbd750c021eed7cd6b73 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Thu, 1 May 2025 16:35:07 +0200 Subject: [PATCH] WebDAV COPY/MOVE tests --- Cargo.lock | 2 + LICENSES/LicenseRef-SEL.txt | 7 +- crates/common/src/lib.rs | 8 - crates/dav-proto/Cargo.toml | 6 +- .../resources/requests/report-019.json | 2 +- crates/dav-proto/src/parser/header.rs | 26 +- crates/dav-proto/src/requests/report.rs | 23 +- crates/dav-proto/src/schema/request.rs | 4 +- crates/dav/src/calendar/copy_move.rs | 157 ++-- crates/dav/src/calendar/mod.rs | 43 +- crates/dav/src/calendar/update.rs | 2 +- crates/dav/src/card/copy_move.rs | 155 ++-- crates/dav/src/common/lock.rs | 35 +- crates/dav/src/common/mod.rs | 32 +- crates/dav/src/common/propfind.rs | 171 ++-- crates/dav/src/common/uri.rs | 25 +- crates/dav/src/file/copy_move.rs | 51 +- crates/dav/src/principal/propfind.rs | 8 +- crates/groupware/src/calendar/index.rs | 10 +- crates/groupware/src/contact/index.rs | 10 +- crates/groupware/src/hierarchy.rs | 48 +- crates/jmap-proto/src/types/collection.rs | 9 + tests/Cargo.toml | 2 + tests/src/webdav/basic.rs | 6 +- tests/src/webdav/copy_move.rs | 752 ++++++++++++++++++ tests/src/webdav/mkcol.rs | 14 +- tests/src/webdav/mod.rs | 341 +++++++- tests/src/webdav/put_get.rs | 14 +- 28 files changed, 1600 insertions(+), 363 deletions(-) create mode 100644 tests/src/webdav/copy_move.rs diff --git a/Cargo.lock b/Cargo.lock index c0b46e7b..3320cfab 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7326,6 +7326,8 @@ dependencies = [ "common", "compact_str", "csv", + "dav", + "dav-proto", "directory", "ece", "email", diff --git a/LICENSES/LicenseRef-SEL.txt b/LICENSES/LicenseRef-SEL.txt index 79b44ae7..25f146ef 100644 --- a/LICENSES/LicenseRef-SEL.txt +++ b/LICENSES/LicenseRef-SEL.txt @@ -1,7 +1,7 @@ Stalwart Enterprise License 1.0 (SELv1) Agreement ================================================= -Last Update: July 8, 2024 +Last Update: April 29, 2025 PLEASE CAREFULLY READ THIS STALWART ENTERPRISE LICENSE AGREEMENT ("AGREEMENT"). THIS AGREEMENT CONSTITUTES A LEGALLY BINDING AGREEMENT BETWEEN YOU AND STALWART LABS LTD AND GOVERNS YOUR USE OF THE SOFTWARE (DEFINED BELOW). IF YOU DO NOT AGREE WITH THIS AGREEMENT, YOU MAY NOT USE THE SOFTWARE. IF YOU ARE USING THE SOFTWARE ON BEHALF OF A LEGAL ENTITY, YOU REPRESENT AND WARRANT THAT YOU HAVE AUTHORITY TO AGREE TO THIS AGREEMENT ON BEHALF OF SUCH ENTITY. IF YOU DO NOT HAVE SUCH AUTHORITY, DO NOT USE THE SOFTWARE IN ANY MANNER. @@ -9,7 +9,7 @@ This Agreement is entered into by and between Stalwart Labs Ltd and you, or the 1. DEFINITIONS -1.1. "Software" refers to the Stalwart Mail Server Enterprise Edition software, including all its versions, updates, modifications, accompanying documentation, and related materials. +1.1. "Software" refers to the Stalwart Mail & Collaboration Server Enterprise Edition software, including all its versions, updates, modifications, accompanying documentation, and related materials. 1.2. "Subscription" refers to the paid access to the Software provided by Licensor to Licensee. 1.3. "Licensor" refers to Stalwart Labs Ltd, the entity providing the Software. 1.4. "Licensee" refers to the individual or entity installing, accessing, or using the Software with a valid Subscription. @@ -53,7 +53,8 @@ This Agreement is entered into by and between Stalwart Labs Ltd and you, or the 7. LIMITATION OF LIABILITY -In no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from (i) your use or inability to use the Software; (ii) any unauthorized access to or use of our servers and/or any personal information stored therein. +7.1. In no event will the Licensor be liable for any indirect, incidental, special, consequential, or punitive damages, or any loss of profits or revenues, whether incurred directly or indirectly, or any loss of data, use, goodwill, or other intangible losses, resulting from (i) your use or inability to use the Software; (ii) any unauthorized access to or use of our servers and/or any personal information stored therein. +7.3. Except for liability arising from death or personal injury caused by negligence, fraud, or willful misconduct, Licensor's total aggregate liability for any and all claims under this Agreement shall be limited to the total Subscription fees paid by Licensee to Licensor in the twelve (12) months immediately preceding the event giving rise to the claim. 8. GOVERNING LAW & JURISDICTION diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index a36eca2a..7a27b8ff 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -515,14 +515,6 @@ impl DavResources { .filter(move |item| item.parent_id.is_some_and(|id| id == parent_id)) } - pub fn is_ancestor_of(&self, ancestor: u32, descendant: u32) -> bool { - let ancestor = &self.paths.by_id(ancestor).unwrap().name; - let descendant = &self.paths.by_id(descendant).unwrap().name; - - let prefix = format!("{ancestor}/"); - descendant.starts_with(&prefix) || descendant == ancestor - } - pub fn format_resource(&self, resource: &DavResource) -> String { if resource.is_container() { format!("{}{}/", self.base_path, resource.name) diff --git a/crates/dav-proto/Cargo.toml b/crates/dav-proto/Cargo.toml index 4fe6b06e..e97e6ef1 100644 --- a/crates/dav-proto/Cargo.toml +++ b/crates/dav-proto/Cargo.toml @@ -15,4 +15,8 @@ rkyv = { version = "0.8.10", features = ["little_endian"] } calcard = { path = "/Users/me/code/calcard", features = ["serde", "rkyv"] } serde = { version = "1.0.217", features = ["derive"] } serde_json = "1.0.138" -chrono = { version = "0.4.40", features = ["serde"] } \ No newline at end of file +chrono = { version = "0.4.40", features = ["serde"] } + +[features] +test_mode = [] +enterprise = [] diff --git a/crates/dav-proto/resources/requests/report-019.json b/crates/dav-proto/resources/requests/report-019.json index 52402ea6..10d2bbd6 100644 --- a/crates/dav-proto/resources/requests/report-019.json +++ b/crates/dav-proto/resources/requests/report-019.json @@ -12,6 +12,6 @@ } ] }, - "level_inf": true, + "depth": "Infinity", "limit": 9 } \ No newline at end of file diff --git a/crates/dav-proto/src/parser/header.rs b/crates/dav-proto/src/parser/header.rs index d07e257e..08e19088 100644 --- a/crates/dav-proto/src/parser/header.rs +++ b/crates/dav-proto/src/parser/header.rs @@ -17,7 +17,7 @@ impl<'x> RequestHeaders<'x> { pub fn parse(&mut self, key: &str, value: &'x str) -> bool { hashify::fnc_map_ignore_case!(key.as_bytes(), "Depth" => { - if let Some(depth) = Depth::parse(value) { + if let Some(depth) = Depth::parse(value.as_bytes()) { self.depth = depth; return true; } @@ -292,11 +292,12 @@ pub fn dav_base_uri(uri: &str) -> Option<&str> { } impl Depth { - pub fn parse(value: &str) -> Option { - hashify::tiny_map!(value.as_bytes(), + pub fn parse(value: &[u8]) -> Option { + hashify::tiny_map!(value, "0" => Depth::Zero, "1" => Depth::One, "infinity" => Depth::Infinity, + "infinite" => Depth::Infinity, ) } } @@ -595,6 +596,25 @@ mod tests { }], }], ), + ( + r#" (["1234"]) (Not ["4217"])"#, + vec![ + If { + resource: "/test/file.txt".into(), + list: vec![Condition::ETag { + is_not: false, + tag: "\"1234\"", + }], + }, + If { + resource: "/specs/rfc2518.doc".into(), + list: vec![Condition::ETag { + is_not: true, + tag: "\"4217\"", + }], + }, + ], + ), ] { assert!(headers.parse("If", input)); assert_eq!(headers.if_, expected, "Failed for input: {}", input); diff --git a/crates/dav-proto/src/requests/report.rs b/crates/dav-proto/src/requests/report.rs index bc5122cf..f09133d6 100644 --- a/crates/dav-proto/src/requests/report.rs +++ b/crates/dav-proto/src/requests/report.rs @@ -21,6 +21,7 @@ use crate::{ }, Attribute, Collation, Element, MatchType, NamedElement, Namespace, }, + Depth, }; impl DavParser for Report { @@ -450,7 +451,7 @@ impl DavParser for SyncCollection { properties: PropFind::AllProp(vec![]), limit: None, sync_token: None, - level_inf: false, + depth: Depth::None, }; loop { @@ -482,8 +483,8 @@ impl DavParser for SyncCollection { ns: Namespace::Dav, element: Element::SyncLevel, } => { - if let Some(Ok(_)) = stream.parse_value::()? { - sc.level_inf = true; + if let Some(Ok(depth)) = stream.parse_value::()? { + sc.depth = depth; } } name => return Err(name.into_unexpected()), @@ -626,22 +627,12 @@ impl Filter { } } -struct Infinite; - -impl XmlValueParser for Infinite { +impl XmlValueParser for Depth { fn parse_bytes(bytes: &[u8]) -> Option { - if bytes == b"infinite" { - Some(Infinite) - } else { - None - } + Depth::parse(bytes) } fn parse_str(text: &str) -> Option { - if text == "infinite" { - Some(Infinite) - } else { - None - } + Depth::parse(text.as_bytes()) } } diff --git a/crates/dav-proto/src/schema/request.rs b/crates/dav-proto/src/schema/request.rs index 53f96d96..27acad8e 100644 --- a/crates/dav-proto/src/schema/request.rs +++ b/crates/dav-proto/src/schema/request.rs @@ -9,6 +9,8 @@ use calcard::{ vcard::{VCardParameterName, VCardProperty}, }; +use crate::Depth; + use super::{ property::{DavProperty, DavValue, LockScope, LockType, TimeRange}, response::Ace, @@ -136,7 +138,7 @@ pub struct MultiGet { pub struct SyncCollection { pub sync_token: Option, pub properties: PropFind, - pub level_inf: bool, + pub depth: Depth, pub limit: Option, } diff --git a/crates/dav/src/calendar/copy_move.rs b/crates/dav/src/calendar/copy_move.rs index cd17be15..9e9fe57f 100644 --- a/crates/dav/src/calendar/copy_move.rs +++ b/crates/dav/src/calendar/copy_move.rs @@ -5,7 +5,7 @@ */ use common::{Server, auth::AccessToken}; -use dav_proto::{Depth, RequestHeaders, schema::response::CalCondition}; +use dav_proto::{Depth, RequestHeaders}; use groupware::{ DavName, DestroyArchive, calendar::{Calendar, CalendarEvent, CalendarPreferences, Timezone}, @@ -17,7 +17,14 @@ use jmap_proto::types::{acl::Acl, collection::Collection}; use store::write::BatchBuilder; use trc::AddContext; -use crate::{DavError, DavErrorCondition, common::uri::DavUriResource, file::DavFileResource}; +use crate::{ + DavError, DavMethod, + common::{ + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; use super::assert_is_unique_uid; @@ -77,11 +84,12 @@ impl CalendarCopyMoveRequestHandler for Server { // Validate destination let destination = self - .validate_uri( + .validate_uri_with_status( access_token, headers .destination .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, ) .await?; if destination.collection != Collection::Calendar { @@ -98,11 +106,53 @@ impl CalendarCopyMoveRequestHandler for Server { .caused_by(trc::location!())? }; - // Map destination + // Validate headers let destination_resource_name = destination .resource .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; - if let Some(to_resource) = to_resources.paths.by_name(destination_resource_name) { + let to_resource = to_resources.paths.by_name(destination_resource_name); + self.validate_headers( + access_token, + &headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: if from_resource.is_container() { + Collection::Calendar + } else { + Collection::CalendarEvent + }, + document_id: Some(from_resource.document_id), + path: from_resource_name, + ..Default::default() + }, + ResourceState { + account_id: to_account_id, + collection: to_resource + .map(|r| { + if r.is_container() { + Collection::Calendar + } else { + Collection::CalendarEvent + } + }) + .unwrap_or(Collection::Calendar), + document_id: Some(to_resource.map(|r| r.document_id).unwrap_or(u32::MAX)), + path: destination_resource_name, + ..Default::default() + }, + ], + Default::default(), + if is_move { + DavMethod::MOVE + } else { + DavMethod::COPY + }, + ) + .await?; + + // Map destination + if let Some(to_resource) = to_resource { if from_resource.name == to_resource.name { // Same resource return Err(DavError::Code(StatusCode::BAD_GATEWAY)); @@ -197,12 +247,6 @@ impl CalendarCopyMoveRequestHandler for Server { return Err(DavError::Code(StatusCode::FORBIDDEN)); } - let to_base_path = to_resources - .format_resource(to_resource) - .rsplit_once('/') - .unwrap() - .0 - .to_string(); if is_move { move_event( self, @@ -213,7 +257,6 @@ impl CalendarCopyMoveRequestHandler for Server { to_account_id, to_resource.document_id.into(), to_calendar_id, - to_base_path, new_name, ) .await @@ -226,7 +269,6 @@ impl CalendarCopyMoveRequestHandler for Server { to_account_id, to_resource.document_id.into(), to_calendar_id, - to_base_path, new_name, ) .await @@ -238,7 +280,7 @@ impl CalendarCopyMoveRequestHandler for Server { to_resources.map_parent(destination_resource_name) { if let Some(parent_resource) = parent_resource { - // Creating items under a event is not allowed + // Creating items under an event is not allowed // Copying/moving containers under a container is not allowed if !parent_resource.is_container() || from_resource.is_container() { return Err(DavError::Code(StatusCode::BAD_GATEWAY)); @@ -291,7 +333,6 @@ impl CalendarCopyMoveRequestHandler for Server { to_account_id, None, to_calendar_id, - to_resources.format_resource(parent_resource), new_name, ) .await @@ -312,10 +353,9 @@ impl CalendarCopyMoveRequestHandler for Server { access_token, from_account_id, from_resource.document_id, - from_calendar_id, + to_account_id, None, to_calendar_id, - to_resources.format_resource(parent_resource), new_name, ) .await @@ -421,7 +461,6 @@ async fn copy_event( to_account_id: u32, to_document_id: Option, to_calendar_id: u32, - to_base_path: String, new_name: &str, ) -> crate::Result { // Fetch event @@ -435,18 +474,21 @@ async fn copy_event( .caused_by(trc::location!())?; let mut batch = BatchBuilder::new(); + // Validate UID + assert_is_unique_uid( + server, + server + .fetch_dav_resources(access_token, to_account_id, Collection::Calendar) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_calendar_id, + event.inner.data.event.uids().next(), + ) + .await?; + if from_account_id == to_account_id { - if let Some(name) = event - .inner - .names - .iter() - .find(|n| n.parent_id == to_calendar_id) - { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::NoUidConflict(format!("{}{}", to_base_path, name.name).into()), - ))); - } let mut new_event = event .deserialize::() .caused_by(trc::location!())?; @@ -464,20 +506,6 @@ async fn copy_event( ) .caused_by(trc::location!())?; } else { - // Validate UID - assert_is_unique_uid( - server, - server - .fetch_dav_resources(access_token, to_account_id, Collection::Calendar) - .await - .caused_by(trc::location!())? - .as_ref(), - to_account_id, - to_calendar_id, - event.inner.data.event.uids().next().unwrap_or_default(), - ) - .await?; - let mut new_event = event .deserialize::() .caused_by(trc::location!())?; @@ -540,7 +568,6 @@ async fn move_event( to_account_id: u32, to_document_id: Option, to_calendar_id: u32, - to_base_path: String, new_name: &str, ) -> crate::Result { // Fetch event @@ -553,16 +580,30 @@ async fn move_event( .to_unarchived::() .caused_by(trc::location!())?; + // Validate UID + if from_account_id != to_account_id + || from_calendar_id != to_calendar_id + || to_document_id.is_none() + { + assert_is_unique_uid( + server, + server + .fetch_dav_resources(access_token, to_account_id, Collection::Calendar) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_calendar_id, + event.inner.data.event.uids().next(), + ) + .await?; + } + let mut batch = BatchBuilder::new(); if from_account_id == to_account_id { let mut name_idx = None; for (idx, name) in event.inner.names.iter().enumerate() { - if name.parent_id == to_calendar_id { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::NoUidConflict(format!("{}{}", to_base_path, name.name).into()), - ))); - } else if name.parent_id == from_calendar_id { + if name.parent_id == from_calendar_id { name_idx = Some(idx); break; } @@ -592,20 +633,6 @@ async fn move_event( ) .caused_by(trc::location!())?; } else { - // Validate UID - assert_is_unique_uid( - server, - server - .fetch_dav_resources(access_token, to_account_id, Collection::Calendar) - .await - .caused_by(trc::location!())? - .as_ref(), - to_account_id, - to_calendar_id, - event.inner.data.event.uids().next().unwrap_or_default(), - ) - .await?; - let mut new_event = event .deserialize::() .caused_by(trc::location!())?; @@ -848,7 +875,7 @@ async fn copy_container( access_token, event, from_account_id, - from_document_id, + from_child_document_id, &mut batch, ) .caused_by(trc::location!())?; diff --git a/crates/dav/src/calendar/mod.rs b/crates/dav/src/calendar/mod.rs index 112a281e..944002b4 100644 --- a/crates/dav/src/calendar/mod.rs +++ b/crates/dav/src/calendar/mod.rs @@ -92,27 +92,30 @@ pub(crate) async fn assert_is_unique_uid( resources: &DavResources, account_id: u32, calendar_id: u32, - uid: &str, + uid: Option<&str>, ) -> crate::Result<()> { - let hits = server - .store() - .filter( - account_id, - Collection::CalendarEvent, - vec![Filter::eq(IDX_UID, uid.as_bytes().to_vec())], - ) - .await - .caused_by(trc::location!())?; - if !hits.results.is_empty() { - for path in resources.paths.iter() { - if !path.is_container() - && hits.results.contains(path.document_id) - && path.parent_id.unwrap() == calendar_id - { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CalCondition::NoUidConflict(resources.format_resource(path).into()), - ))); + if let Some(uid) = uid { + let hits = server + .store() + .filter( + account_id, + Collection::CalendarEvent, + vec![Filter::eq(IDX_UID, uid.as_bytes().to_vec())], + ) + .await + .caused_by(trc::location!())?; + + if !hits.results.is_empty() { + for path in resources.paths.iter() { + if !path.is_container() + && hits.results.contains(path.document_id) + && path.parent_id.unwrap() == calendar_id + { + return Err(DavError::Condition(DavErrorCondition::new( + StatusCode::PRECONDITION_FAILED, + CalCondition::NoUidConflict(resources.format_resource(path).into()), + ))); + } } } } diff --git a/crates/dav/src/calendar/update.rs b/crates/dav/src/calendar/update.rs index e805d412..97b523c0 100644 --- a/crates/dav/src/calendar/update.rs +++ b/crates/dav/src/calendar/update.rs @@ -247,7 +247,7 @@ impl CalendarUpdateRequestHandler for Server { &resources, account_id, parent.document_id, - validate_ical(&ical)?, + validate_ical(&ical)?.into(), ) .await?; diff --git a/crates/dav/src/card/copy_move.rs b/crates/dav/src/card/copy_move.rs index b7ecb033..6a6f7eb3 100644 --- a/crates/dav/src/card/copy_move.rs +++ b/crates/dav/src/card/copy_move.rs @@ -5,7 +5,7 @@ */ use common::{Server, auth::AccessToken}; -use dav_proto::{Depth, RequestHeaders, schema::response::CardCondition}; +use dav_proto::{Depth, RequestHeaders}; use groupware::{ DavName, DestroyArchive, contact::{AddressBook, ContactCard}, @@ -17,7 +17,14 @@ use jmap_proto::types::{acl::Acl, collection::Collection}; use store::write::BatchBuilder; use trc::AddContext; -use crate::{DavError, DavErrorCondition, common::uri::DavUriResource, file::DavFileResource}; +use crate::{ + DavError, DavMethod, + common::{ + lock::{LockRequestHandler, ResourceState}, + uri::DavUriResource, + }, + file::DavFileResource, +}; use super::assert_is_unique_uid; @@ -77,11 +84,12 @@ impl CardCopyMoveRequestHandler for Server { // Validate destination let destination = self - .validate_uri( + .validate_uri_with_status( access_token, headers .destination .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, ) .await?; if destination.collection != Collection::AddressBook { @@ -98,11 +106,53 @@ impl CardCopyMoveRequestHandler for Server { .caused_by(trc::location!())? }; - // Map destination + // Validate headers let destination_resource_name = destination .resource .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; - if let Some(to_resource) = to_resources.paths.by_name(destination_resource_name) { + let to_resource = to_resources.paths.by_name(destination_resource_name); + self.validate_headers( + access_token, + &headers, + vec![ + ResourceState { + account_id: from_account_id, + collection: if from_resource.is_container() { + Collection::AddressBook + } else { + Collection::ContactCard + }, + document_id: Some(from_resource.document_id), + path: from_resource_name, + ..Default::default() + }, + ResourceState { + account_id: to_account_id, + collection: to_resource + .map(|r| { + if r.is_container() { + Collection::AddressBook + } else { + Collection::ContactCard + } + }) + .unwrap_or(Collection::AddressBook), + document_id: Some(to_resource.map(|r| r.document_id).unwrap_or(u32::MAX)), + path: destination_resource_name, + ..Default::default() + }, + ], + Default::default(), + if is_move { + DavMethod::MOVE + } else { + DavMethod::COPY + }, + ) + .await?; + + // Map destination + if let Some(to_resource) = to_resource { if from_resource.name == to_resource.name { // Same resource return Err(DavError::Code(StatusCode::BAD_GATEWAY)); @@ -197,12 +247,6 @@ impl CardCopyMoveRequestHandler for Server { return Err(DavError::Code(StatusCode::FORBIDDEN)); } - let to_base_path = to_resources - .format_resource(to_resource) - .rsplit_once('/') - .unwrap() - .0 - .to_string(); if is_move { move_card( self, @@ -213,7 +257,6 @@ impl CardCopyMoveRequestHandler for Server { to_account_id, to_resource.document_id.into(), to_addressbook_id, - to_base_path, new_name, ) .await @@ -226,7 +269,6 @@ impl CardCopyMoveRequestHandler for Server { to_account_id, to_resource.document_id.into(), to_addressbook_id, - to_base_path, new_name, ) .await @@ -291,7 +333,6 @@ impl CardCopyMoveRequestHandler for Server { to_account_id, None, to_addressbook_id, - to_resources.format_resource(parent_resource), new_name, ) .await @@ -312,10 +353,9 @@ impl CardCopyMoveRequestHandler for Server { access_token, from_account_id, from_resource.document_id, - from_addressbook_id, + to_account_id, None, to_addressbook_id, - to_resources.format_resource(parent_resource), new_name, ) .await @@ -421,7 +461,6 @@ async fn copy_card( to_account_id: u32, to_document_id: Option, to_addressbook_id: u32, - to_base_path: String, new_name: &str, ) -> crate::Result { // Fetch card @@ -435,18 +474,21 @@ async fn copy_card( .caused_by(trc::location!())?; let mut batch = BatchBuilder::new(); + // Validate UID + assert_is_unique_uid( + server, + server + .fetch_dav_resources(access_token, to_account_id, Collection::AddressBook) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_addressbook_id, + card.inner.card.uid(), + ) + .await?; + if from_account_id == to_account_id { - if let Some(name) = card - .inner - .names - .iter() - .find(|n| n.parent_id == to_addressbook_id) - { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CardCondition::NoUidConflict(format!("{}{}", to_base_path, name.name).into()), - ))); - } let mut new_card = card .deserialize::() .caused_by(trc::location!())?; @@ -464,20 +506,6 @@ async fn copy_card( ) .caused_by(trc::location!())?; } else { - // Validate UID - assert_is_unique_uid( - server, - server - .fetch_dav_resources(access_token, to_account_id, Collection::AddressBook) - .await - .caused_by(trc::location!())? - .as_ref(), - to_account_id, - to_addressbook_id, - card.inner.card.uid(), - ) - .await?; - let mut new_card = card .deserialize::() .caused_by(trc::location!())?; @@ -540,7 +568,6 @@ async fn move_card( to_account_id: u32, to_document_id: Option, to_addressbook_id: u32, - to_base_path: String, new_name: &str, ) -> crate::Result { // Fetch card @@ -553,16 +580,30 @@ async fn move_card( .to_unarchived::() .caused_by(trc::location!())?; + // Validate UID + if from_account_id != to_account_id + || from_addressbook_id != to_addressbook_id + || to_document_id.is_none() + { + assert_is_unique_uid( + server, + server + .fetch_dav_resources(access_token, to_account_id, Collection::AddressBook) + .await + .caused_by(trc::location!())? + .as_ref(), + to_account_id, + to_addressbook_id, + card.inner.card.uid(), + ) + .await?; + } + let mut batch = BatchBuilder::new(); if from_account_id == to_account_id { let mut name_idx = None; for (idx, name) in card.inner.names.iter().enumerate() { - if name.parent_id == to_addressbook_id { - return Err(DavError::Condition(DavErrorCondition::new( - StatusCode::PRECONDITION_FAILED, - CardCondition::NoUidConflict(format!("{}{}", to_base_path, name.name).into()), - ))); - } else if name.parent_id == from_addressbook_id { + if name.parent_id == from_addressbook_id { name_idx = Some(idx); break; } @@ -592,20 +633,6 @@ async fn move_card( ) .caused_by(trc::location!())?; } else { - // Validate UID - assert_is_unique_uid( - server, - server - .fetch_dav_resources(access_token, to_account_id, Collection::AddressBook) - .await - .caused_by(trc::location!())? - .as_ref(), - to_account_id, - to_addressbook_id, - card.inner.card.uid(), - ) - .await?; - let mut new_card = card .deserialize::() .caused_by(trc::location!())?; @@ -838,7 +865,7 @@ async fn copy_container( access_token, card, from_account_id, - from_document_id, + from_child_document_id, &mut batch, ) .caused_by(trc::location!())?; diff --git a/crates/dav/src/common/lock.rs b/crates/dav/src/common/lock.rs index 0f0e47f3..fc686e04 100644 --- a/crates/dav/src/common/lock.rs +++ b/crates/dav/src/common/lock.rs @@ -343,7 +343,11 @@ impl LockRequestHandler for Server { | DavMethod::POST | DavMethod::PUT | DavMethod::PATCH => { - if headers.overwrite_fail && resources.last().is_some_and(|r| r.etag.is_some()) { + if headers.overwrite_fail + && resources.last().is_some_and(|r| { + r.etag.is_some() || r.document_id.is_some_and(|id| id != u32::MAX) + }) + { return Err(DavError::Code(StatusCode::PRECONDITION_FAILED)); } } @@ -431,10 +435,18 @@ impl LockRequestHandler for Server { .await .ok() .and_then(|r| { + let path = r.resource?; + Some(ResourceState { account_id: r.account_id?, - collection: r.collection, - path: r.resource?, + collection: if !matches!(r.collection, Collection::FileNode) + && path.contains('/') + { + r.collection.child_collection().unwrap_or(r.collection) + } else { + r.collection + }, + path, ..Default::default() }) }) @@ -580,23 +592,6 @@ impl LockRequestHandler for Server { } } -/*impl LockItems { - pub fn insert_or_refresh_lock(&mut self, item: LockItem, href: String) -> ActiveLock { - if let Some(idx) = self.0.iter().position(|i| i.lock_id == item.lock_id) { - let update = &mut self.0[idx]; - update.expires = item.expires; - update.depth_infinity = item.depth_infinity; - update.owner_dav = item.owner_dav; - update.exclusive = item.exclusive; - update.to_active_lock(href) - } else { - let active_lock = item.to_active_lock(href); - self.0.push(item); - active_lock - } - } -}*/ - impl LockData { pub fn remove_lock(&mut self, lock_id: u64) -> bool { for (lock_path, lock_items) in self.locks.iter_mut() { diff --git a/crates/dav/src/common/mod.rs b/crates/dav/src/common/mod.rs index d6a4553c..b4151da7 100644 --- a/crates/dav/src/common/mod.rs +++ b/crates/dav/src/common/mod.rs @@ -42,7 +42,7 @@ pub mod uri; pub(crate) struct DavQuery<'x> { pub resource: DavQueryResource<'x>, pub propfind: PropFind, - pub from_change_id: Option, + pub sync_type: SyncType, pub depth: usize, pub limit: Option, pub ret: Return, @@ -50,6 +50,14 @@ pub(crate) struct DavQuery<'x> { pub expand: bool, } +#[derive(Default, Debug)] +pub(crate) enum SyncType { + #[default] + None, + Initial, + From(u64), +} + #[derive(Default, Debug)] pub(crate) enum DavQueryResource<'x> { Uri(OwnedUri<'x>), @@ -215,14 +223,18 @@ impl<'x> DavQuery<'x> { Self { resource: DavQueryResource::Uri(resource), propfind: changes.properties, - from_change_id: changes + sync_type: changes .sync_token .as_deref() .and_then(Urn::parse) .and_then(|urn| urn.try_unwrap_sync()) - .unwrap_or_default() - .into(), - depth: if changes.level_inf { usize::MAX } else { 1 }, + .map(SyncType::From) + .unwrap_or(SyncType::Initial), + depth: match changes.depth { + Depth::One => 1, + Depth::Infinity => usize::MAX, + _ => 0, + }, limit: changes.limit, ret: headers.ret, depth_no_root: headers.depth_no_root, @@ -422,3 +434,13 @@ impl<'x> ArchivedResource<'x> { } } } + +impl SyncType { + pub fn is_none(&self) -> bool { + matches!(self, SyncType::None) + } + + pub fn is_none_or_initial(&self) -> bool { + matches!(self, SyncType::None | SyncType::Initial) + } +} diff --git a/crates/dav/src/common/propfind.rs b/crates/dav/src/common/propfind.rs index f736f89a..4c0c4f50 100644 --- a/crates/dav/src/common/propfind.rs +++ b/crates/dav/src/common/propfind.rs @@ -51,14 +51,14 @@ use percent_encoding::NON_ALPHANUMERIC; use std::sync::Arc; use store::{ ahash::AHashMap, - query::log::Query, + query::log::{Change, Query}, roaring::RoaringBitmap, write::{AlignedBytes, Archive}, }; use trc::AddContext; use super::{ - ArchivedResource, DavCollection, DavQuery, DavQueryFilter, ETag, + ArchivedResource, DavCollection, DavQuery, DavQueryFilter, ETag, SyncType, acl::{DavAclHandler, Privileges}, lock::{LockData, build_lock_key}, uri::{UriResource, Urn}, @@ -359,6 +359,7 @@ impl PropFindRequestHandler for Server { let account_id = resource.account_id; collection_container = resource.collection; collection_children = collection_container.child_collection().unwrap(); + let container_has_children = collection_children != collection_container; let resources = self .fetch_dav_resources(access_token, account_id, collection_container) .await @@ -367,13 +368,17 @@ impl PropFindRequestHandler for Server { ctag = Some(resources.modseq.unwrap_or_default()); // Obtain document ids - let mut document_ids = if !access_token.is_member(account_id) { + let mut display_containers = if !access_token.is_member(account_id) { self.shared_containers( access_token, account_id, collection_container, - [Acl::ReadItems], - false, + [if container_has_children { + Acl::ReadItems + } else { + Acl::Read + }], + true, ) .await .caused_by(trc::location!())? @@ -381,60 +386,118 @@ impl PropFindRequestHandler for Server { } else { None }; + let mut display_children = display_containers + .as_ref() + .filter(|_| container_has_children) + .map(|containers| { + RoaringBitmap::from_iter(resources.paths.iter().filter_map(|r| { + if r.parent_id + .is_some_and(|parent_id| containers.contains(parent_id)) + { + Some(r.document_id) + } else { + None + } + })) + }); // Filter by changelog - if let Some(change_id) = query.from_change_id { - let changelog = self - .store() - .changes(account_id, collection_children, Query::Since(change_id)) - .await - .caused_by(trc::location!())?; - let limit = std::cmp::min( - query.limit.unwrap_or(u32::MAX) as usize, - self.core.groupware.max_changes, - ); - - // Set sync token - let sync_token = if changelog.to_change_id != 0 { - let sync_token = Urn::Sync(changelog.to_change_id).to_string(); - data.accounts.entry(account_id).or_default().sync_token = - sync_token.clone().into(); - sync_token - } else { - data.sync_token(self, account_id, collection_children) + match query.sync_type { + SyncType::From(change_id) => { + let limit = std::cmp::min( + query.limit.unwrap_or(u32::MAX) as usize, + self.core.groupware.max_changes, + ); + let container_changes = self + .store() + .changes(account_id, collection_container, Query::Since(change_id)) .await - .caused_by(trc::location!())? - }; - response.set_sync_token(sync_token); + .caused_by(trc::location!())?; + let children_changes = if container_has_children { + self.store() + .changes(account_id, collection_children, Query::Since(change_id)) + .await + .caused_by(trc::location!())? + .into() + } else { + None + }; + let change_id = std::cmp::max( + container_changes.to_change_id, + children_changes.as_ref().map_or(0, |c| c.to_change_id), + ); - let mut changes = RoaringBitmap::from_iter( - changelog.changes.iter().map(|change| change.id() as u32), - ); - if changes.len() as usize > limit { - changes = RoaringBitmap::from_sorted_iter(changes.into_iter().take(limit)) - .unwrap(); + // Set sync token + let sync_token = if change_id != 0 { + let sync_token = Urn::Sync(change_id).to_string(); + data.accounts.entry(account_id).or_default().sync_token = + sync_token.clone().into(); + sync_token + } else { + data.sync_token(self, account_id, collection_container) + .await + .caused_by(trc::location!())? + }; + response.set_sync_token(sync_token); + + for (changes, document_ids) in [ + Some((container_changes, &mut display_containers)), + children_changes.map(|changes| (changes, &mut display_children)), + ] + .into_iter() + .flatten() + { + let changes = RoaringBitmap::from_iter( + changes + .changes + .iter() + .filter_map(|change| match change { + Change::Insert(id) | Change::Update(id) => Some(*id as u32), + _ => None, + }) + .take(limit), + ); + if let Some(document_ids) = document_ids { + *document_ids &= changes; + } else { + *document_ids = Some(changes); + } + } } - if let Some(document_ids) = &mut document_ids { - *document_ids &= changes; - } else { - document_ids = Some(changes); + SyncType::Initial => { + response.set_sync_token( + data.sync_token(self, account_id, collection_container) + .await + .caused_by(trc::location!())?, + ); } + SyncType::None => (), } paths = if let Some(resource) = resource.resource { resources .subtree_with_depth(resource, query.depth) .filter(|item| { - document_ids - .as_ref() - .is_none_or(|d| d.contains(item.document_id)) + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id) + } else { + display_children.as_ref().is_some_and(|children| { + children.contains(item.document_id) + }) + } + } else { + containers.contains(item.document_id) + } + }) }) .map(|item| { PropFindItem::new(resources.format_resource(item), account_id, item) }) .collect::>() } else { - if !query.depth_no_root || query.from_change_id.is_none() { + if !query.depth_no_root && query.sync_type.is_none_or_initial() { self.prepare_principal_propfind_response( access_token, collection_container, @@ -443,19 +506,29 @@ impl PropFindRequestHandler for Server { &mut response, ) .await?; + } - if query.depth == 0 { - return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) - .with_xml_body(response.to_string())); - } + if query.depth == 0 { + return Ok(HttpResponse::new(StatusCode::MULTI_STATUS) + .with_xml_body(response.to_string())); } resources .tree_with_depth(query.depth - 1) .filter(|item| { - document_ids - .as_ref() - .is_none_or(|d| d.contains(item.document_id)) + display_containers.as_ref().is_none_or(|containers| { + if container_has_children { + if item.is_container() { + containers.contains(item.document_id) + } else { + display_children.as_ref().is_some_and(|children| { + children.contains(item.document_id) + }) + } + } else { + containers.contains(item.document_id) + } + }) }) .map(|item| { PropFindItem::new(resources.format_resource(item), account_id, item) @@ -463,7 +536,7 @@ impl PropFindRequestHandler for Server { .collect::>() }; - if paths.is_empty() && query.from_change_id.is_none() { + if paths.is_empty() && query.sync_type.is_none() { if let Some(resource) = resource.resource { response.add_response(Response::new_status( [resources.format_item(resource)], diff --git a/crates/dav/src/common/uri.rs b/crates/dav/src/common/uri.rs index 78013881..a0fd4042 100644 --- a/crates/dav/src/common/uri.rs +++ b/crates/dav/src/common/uri.rs @@ -34,6 +34,13 @@ pub(crate) type OwnedUri<'x> = UriResource>; pub(crate) type DocumentUri = UriResource; pub(crate) trait DavUriResource: Sync + Send { + fn validate_uri_with_status<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + error_status: StatusCode, + ) -> impl Future>> + Send; + fn validate_uri<'x>( &self, access_token: &AccessToken, @@ -52,10 +59,20 @@ impl DavUriResource for Server { &self, access_token: &AccessToken, uri: &'x str, + ) -> crate::Result> { + self.validate_uri_with_status(access_token, uri, StatusCode::NOT_FOUND) + .await + } + + async fn validate_uri_with_status<'x>( + &self, + access_token: &AccessToken, + uri: &'x str, + error_status: StatusCode, ) -> crate::Result> { let (_, uri_parts) = uri .split_once("/dav/") - .ok_or(DavError::Code(StatusCode::NOT_FOUND))?; + .ok_or(DavError::Code(error_status))?; let mut uri_parts = uri_parts .trim_end_matches('/') @@ -65,7 +82,7 @@ impl DavUriResource for Server { collection: uri_parts .next() .and_then(DavResourceName::parse) - .ok_or(DavError::Code(StatusCode::NOT_FOUND))? + .ok_or(DavError::Code(error_status))? .into(), account_id: None, resource: None, @@ -75,7 +92,7 @@ impl DavUriResource for Server { let account_id = if let Some(account_id) = account.strip_prefix('_') { account_id .parse::() - .map_err(|_| DavError::Code(StatusCode::NOT_FOUND))? + .map_err(|_| DavError::Code(error_status))? } else { let account = decode_path_element(account); if access_token.name == account { @@ -85,7 +102,7 @@ impl DavUriResource for Server { .get_principal_id(&account) .await .caused_by(trc::location!())? - .ok_or(DavError::Code(StatusCode::NOT_FOUND))? + .ok_or(DavError::Code(error_status))? } }; diff --git a/crates/dav/src/file/copy_move.rs b/crates/dav/src/file/copy_move.rs index 6c68fe57..9989bb5b 100644 --- a/crates/dav/src/file/copy_move.rs +++ b/crates/dav/src/file/copy_move.rs @@ -55,6 +55,7 @@ impl FileCopyMoveRequestHandler for Server { .await .caused_by(trc::location!())?; let from_resource = from_files.map_resource::(&from_resource_)?; + let from_resource_name = from_resource_.resource.unwrap(); // Validate source ACLs if !access_token.is_member(from_account_id) { @@ -82,11 +83,12 @@ impl FileCopyMoveRequestHandler for Server { // Validate destination let destination = self - .validate_uri( + .validate_uri_with_status( access_token, headers .destination .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?, + StatusCode::BAD_GATEWAY, ) .await?; if destination.collection != Collection::FileNode { @@ -107,6 +109,15 @@ impl FileCopyMoveRequestHandler for Server { let destination_resource_name = destination .resource .ok_or(DavError::Code(StatusCode::BAD_GATEWAY))?; + if from_account_id == to_account_id + && (from_resource_name == destination_resource_name + || from_resource_name + .strip_prefix(destination_resource_name) + .is_some_and(|v| v.is_empty() || v.starts_with('/'))) + { + return Ok(HttpResponse::new(StatusCode::BAD_GATEWAY)); + } + let mut delete_destination = None; // Check if the resource exists let mut destination = @@ -134,17 +145,14 @@ impl FileCopyMoveRequestHandler for Server { }; destination.account_id = to_account_id; - if from_account_id == destination.account_id && delete_destination.is_none() { - if Some(from_resource.resource.document_id) == destination.document_id { - // Move or copy to the same location - return Ok(HttpResponse::new(StatusCode::BAD_GATEWAY)); - } else if from_resource.resource.parent_id == destination.parent_id - && destination.new_name.is_some() - && is_move - { - // Rename - return rename_item(self, access_token, from_resource, destination).await; - } + if delete_destination.is_none() + && from_account_id == destination.account_id + && from_resource.resource.parent_id == destination.document_id + && destination.new_name.is_some() + && is_move + { + // Rename + return rename_item(self, access_token, from_resource, destination).await; } // Validate destination ACLs @@ -187,7 +195,13 @@ impl FileCopyMoveRequestHandler for Server { ResourceState { account_id: to_account_id, collection: Collection::FileNode, - document_id: Some(destination.document_id.unwrap_or(u32::MAX)), + document_id: Some( + delete_destination + .as_ref() + .unwrap_or(&destination) + .document_id + .unwrap_or(u32::MAX), + ), path: destination_resource_name, ..Default::default() }, @@ -229,7 +243,7 @@ impl FileCopyMoveRequestHandler for Server { .subtree(destination_resource_name) .collect::>(); if !ids.is_empty() { - ids.sort_unstable_by(|a, b| b.hierarchy_sequence().cmp(&a.hierarchy_sequence())); + ids.sort_unstable_by_key(|b| std::cmp::Reverse(b.hierarchy_sequence())); let mut sorted_ids = Vec::with_capacity(ids.len()); sorted_ids.extend(ids.into_iter().map(|a| a.document_id)); DestroyArchive(sorted_ids) @@ -245,7 +259,6 @@ impl FileCopyMoveRequestHandler for Server { self, access_token, from_files, - to_files, from_resource, destination, headers.depth, @@ -296,7 +309,7 @@ pub(crate) struct Destination { pub account_id: u32, pub new_name: Option, pub document_id: Option, - pub parent_id: Option, + //pub parent_id: Option, pub is_container: bool, } @@ -305,7 +318,6 @@ impl Default for Destination { Self { account_id: Default::default(), document_id: Default::default(), - parent_id: Default::default(), new_name: Default::default(), is_container: true, } @@ -317,7 +329,6 @@ async fn move_container( server: &Server, access_token: &AccessToken, from_files: Arc, - to_files: Arc, from_resource: UriResource, destination: Destination, depth: Depth, @@ -328,9 +339,6 @@ async fn move_container( let parent_id = destination.document_id.map(|id| id + 1).unwrap_or(0); if from_account_id == to_account_id { - if parent_id != 0 && to_files.is_ancestor_of(from_document_id, parent_id - 1) { - return Err(DavError::Code(StatusCode::BAD_GATEWAY)); - } let node_ = server .get_archive(from_account_id, Collection::FileNode, from_document_id) .await @@ -773,7 +781,6 @@ impl FromDavResource for Destination { Destination { account_id: u32::MAX, document_id: Some(item.document_id), - parent_id: item.parent_id, is_container: item.is_container(), new_name: None, } diff --git a/crates/dav/src/principal/propfind.rs b/crates/dav/src/principal/propfind.rs index f793b04e..942d84cf 100644 --- a/crates/dav/src/principal/propfind.rs +++ b/crates/dav/src/principal/propfind.rs @@ -288,15 +288,15 @@ impl PrincipalPropFind for Server { let mut prop_stats = Vec::with_capacity(2); - if !fields.is_empty() { - prop_stats.push(PropStat::new_list(fields)); - } - if !fields_not_found.is_empty() { prop_stats .push(PropStat::new_list(fields_not_found).with_status(StatusCode::NOT_FOUND)); } + if !fields.is_empty() || prop_stats.is_empty() { + prop_stats.push(PropStat::new_list(fields)); + } + response.add_response(Response::new_propstat( Href(format!( "{}/{}/", diff --git a/crates/groupware/src/calendar/index.rs b/crates/groupware/src/calendar/index.rs index c3fd8015..5e551e96 100644 --- a/crates/groupware/src/calendar/index.rs +++ b/crates/groupware/src/calendar/index.rs @@ -7,7 +7,7 @@ use common::storage::index::{ IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject, }; -use jmap_proto::types::{collection::Collection, value::AclGrant}; +use jmap_proto::types::value::AclGrant; use store::{SerializeInfallible, write::key::KeySerializer}; use crate::{IDX_NAME, IDX_TIME, IDX_UID}; @@ -123,10 +123,6 @@ impl IndexableObject for CalendarEvent { + self.size, }, IndexValue::LogChild { prefix: None }, - IndexValue::LogParent { - collection: Collection::Calendar.into(), - ids: self.names.iter().map(|n| n.parent_id).collect(), - }, ] .into_iter() } @@ -167,10 +163,6 @@ impl IndexableObject for &ArchivedCalendarEvent { + self.size, }, IndexValue::LogChild { prefix: None }, - IndexValue::LogParent { - collection: Collection::Calendar.into(), - ids: self.names.iter().map(|n| n.parent_id.to_native()).collect(), - }, ] .into_iter() } diff --git a/crates/groupware/src/contact/index.rs b/crates/groupware/src/contact/index.rs index 3b7066b8..b4115564 100644 --- a/crates/groupware/src/contact/index.rs +++ b/crates/groupware/src/contact/index.rs @@ -7,7 +7,7 @@ use common::storage::index::{ IndexItem, IndexValue, IndexableAndSerializableObject, IndexableObject, }; -use jmap_proto::types::{collection::Collection, value::AclGrant}; +use jmap_proto::types::value::AclGrant; use store::SerializeInfallible; use crate::{IDX_NAME, IDX_UID}; @@ -89,10 +89,6 @@ impl IndexableObject for ContactCard { + self.size, }, IndexValue::LogChild { prefix: None }, - IndexValue::LogParent { - collection: Collection::AddressBook.into(), - ids: self.names.iter().map(|n| n.parent_id).collect(), - }, ] .into_iter() } @@ -120,10 +116,6 @@ impl IndexableObject for &ArchivedContactCard { + self.size, }, IndexValue::LogChild { prefix: None }, - IndexValue::LogParent { - collection: Collection::AddressBook.into(), - ids: self.names.iter().map(|n| n.parent_id.to_native()).collect(), - }, ] .into_iter() } diff --git a/crates/groupware/src/hierarchy.rs b/crates/groupware/src/hierarchy.rs index ddc7a0a1..dfe9c2ed 100644 --- a/crates/groupware/src/hierarchy.rs +++ b/crates/groupware/src/hierarchy.rs @@ -60,11 +60,21 @@ impl DavHierarchy for Server { account_id: u32, collection: Collection, ) -> trc::Result> { - let change_id = self + let is_files = collection == Collection::FileNode; + let mut change_id = self .store() .get_last_change_id(account_id, collection) .await .caused_by(trc::location!())?; + if !is_files { + let child_change_id = self + .store() + .get_last_change_id(account_id, collection.child_collection().unwrap()) + .await + .caused_by(trc::location!())?; + change_id = change_id.max(child_change_id); + } + let resource_id = DavResourceId { account_id, collection: collection.into(), @@ -78,28 +88,26 @@ impl DavHierarchy for Server { { Ok(files) } else { - let mut files = match collection { - Collection::Calendar | Collection::AddressBook => { - let files = build_hierarchy(self, account_id, collection).await?; - if files.paths.is_empty() { - match collection { - Collection::Calendar => { - self.create_default_calendar(access_token, account_id) - .await? - } - Collection::AddressBook => { - self.create_default_addressbook(access_token, account_id) - .await? - } - _ => unreachable!(), + let mut files = if !is_files { + let files = build_hierarchy(self, account_id, collection).await?; + if files.paths.is_empty() { + match collection { + Collection::Calendar => { + self.create_default_calendar(access_token, account_id) + .await? } - build_hierarchy(self, account_id, collection).await? - } else { - files + Collection::AddressBook => { + self.create_default_addressbook(access_token, account_id) + .await? + } + _ => unreachable!(), } + build_hierarchy(self, account_id, collection).await? + } else { + files } - Collection::FileNode => build_file_hierarchy(self, account_id).await?, - _ => unreachable!(), + } else { + build_file_hierarchy(self, account_id).await? }; files.modseq = change_id; diff --git a/crates/jmap-proto/src/types/collection.rs b/crates/jmap-proto/src/types/collection.rs index 4cb21b29..5dc2e422 100644 --- a/crates/jmap-proto/src/types/collection.rs +++ b/crates/jmap-proto/src/types/collection.rs @@ -35,6 +35,15 @@ pub enum Collection { } impl Collection { + pub fn main_collection(&self) -> Collection { + match self { + Collection::Email => Collection::Mailbox, + Collection::CalendarEvent => Collection::Calendar, + Collection::ContactCard => Collection::AddressBook, + _ => *self, + } + } + pub fn parent_collection(&self) -> Option { match self { Collection::Email => Some(Collection::Mailbox), diff --git a/tests/Cargo.toml b/tests/Cargo.toml index 43f21f60..3b5ec623 100644 --- a/tests/Cargo.toml +++ b/tests/Cargo.toml @@ -26,6 +26,8 @@ jmap = { path = "../crates/jmap", features = ["test_mode", "enterprise"] } jmap_proto = { path = "../crates/jmap-proto" } imap = { path = "../crates/imap", features = ["test_mode"] } imap_proto = { path = "../crates/imap-proto" } +dav = { path = "../crates/dav", features = ["test_mode"] } +dav-proto = { path = "../crates/dav-proto", features = ["test_mode"] } groupware = { path = "../crates/groupware", features = ["test_mode"] } http = { path = "../crates/http", features = ["test_mode", "enterprise"] } http_proto = { path = "../crates/http-proto" } diff --git a/tests/src/webdav/basic.rs b/tests/src/webdav/basic.rs index 90d64c24..f3b3338b 100644 --- a/tests/src/webdav/basic.rs +++ b/tests/src/webdav/basic.rs @@ -39,10 +39,6 @@ pub async fn test(test: &WebDavTest) { .await .match_many( "D:multistatus.D:response.D:href", - [ - "/dav/cal/", - "/dav/cal/jane/", - "/dav/cal/support%40example%2Ecom/", - ], + ["/dav/cal/", "/dav/cal/jane/", "/dav/cal/support/"], ); } diff --git a/tests/src/webdav/copy_move.rs b/tests/src/webdav/copy_move.rs new file mode 100644 index 00000000..da297614 --- /dev/null +++ b/tests/src/webdav/copy_move.rs @@ -0,0 +1,752 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use super::{DavResponse, WebDavTest}; +use crate::webdav::GenerateTestDavResource; +use ahash::AHashSet; +use dav_proto::Depth; +use groupware::DavResourceName; +use hyper::StatusCode; + +pub async fn test(test: &WebDavTest) { + let client = test.client("jane"); + + for resource_type in [ + DavResourceName::File, + DavResourceName::Cal, + DavResourceName::Card, + ] { + println!("Running COPY/MOVE tests ({})...", resource_type.base_path()); + let user_base_path = format!("{}/jane", resource_type.base_path()); + let group_base_path = format!("{}/support", resource_type.base_path()); + let default_test_depth = if resource_type == DavResourceName::File { + 2 + } else { + 0 + }; + + // Obtain sync token + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_eq!( + response.hrefs().len(), + if resource_type == DavResourceName::File { + 1 + } else { + 2 + }, + "{:?}", + response.hrefs() + ); + + // Create nested files and folders + let (hierarchy_root, mut hierarchy) = client + .create_hierarchy(&user_base_path, default_test_depth, 2, 3) + .await; + let prev_sync_token = response.sync_token(); + let response = client + .sync_collection( + &user_base_path, + prev_sync_token, + Depth::Infinity, + ["D:getetag"], + ) + .await; + let sync_token = response.sync_token(); + let changed_hrefs = response.hrefs(); + assert_ne!(sync_token, prev_sync_token); + assert_eq!( + changed_hrefs, + hierarchy.iter().map(|x| x.0.as_str()).collect::>(), + "lengths {} & {}", + changed_hrefs.len(), + hierarchy.len() + ); + client.validate_values(&hierarchy).await; + + // Copying and moving to the same or root containers is invalid + for method in ["COPY", "MOVE"] { + for destination in [ + "/dav", + "/dav/cal", + "/dav/card", + "/dav/file", + "/dav/pal", + hierarchy_root.as_str(), + ] { + client + .request_with_headers( + method, + &hierarchy_root, + [("destination", destination)], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + } + + // Test 1: Rename container + let new_hierarchy_root = format!("{user_base_path}/Test_Folder/"); + client + .request_with_headers( + "MOVE", + &hierarchy_root, + [("destination", new_hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + replace_prefix(&mut hierarchy, &hierarchy_root, &new_hierarchy_root); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + let hierarchy_root = new_hierarchy_root; + + // Test 2: Copy container + let new_hierarchy_root = format!("{user_base_path}/Test_Folder_Copy/"); + client + .request_with_headers( + "COPY", + &hierarchy_root, + [("destination", new_hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + let mut copied_hierarchy = hierarchy.clone(); + replace_prefix(&mut copied_hierarchy, &hierarchy_root, &new_hierarchy_root); + copied_hierarchy.extend_from_slice(&hierarchy); + assert_result(&response, &copied_hierarchy); + client.validate_values(&copied_hierarchy).await; + + // Test 3: Delete original container + client + .request("DELETE", &new_hierarchy_root, "") + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 4: Create a shallow container and overwrite the previous one using MOVE + let (new_hierarchy_root, mut hierarchy) = + client.create_hierarchy(&user_base_path, 0, 0, 3).await; + client + .request_with_headers( + "MOVE", + &new_hierarchy_root, + [("destination", hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + replace_prefix(&mut hierarchy, &new_hierarchy_root, &hierarchy_root); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 5: Create a deep container and overwrite the previous one using COPY + let (new_hierarchy_root, new_hierarchy) = client + .create_hierarchy(&user_base_path, default_test_depth, 1, 2) + .await; + client + .request_with_headers( + "COPY", + &new_hierarchy_root, + [("destination", hierarchy_root.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + let mut orig_hierarchy = new_hierarchy.clone(); + replace_prefix(&mut orig_hierarchy, &new_hierarchy_root, &hierarchy_root); + let mut full_hierarchy = new_hierarchy.clone(); + full_hierarchy.extend_from_slice(&orig_hierarchy); + assert_result(&response, &full_hierarchy); + client.validate_values(&full_hierarchy).await; + + // Test 6: Copy and move containers to a shared account + let shared_hierarchy_root_1 = format!("{group_base_path}/Test_Shared_Folder_1/"); + let shared_hierarchy_root_2 = format!("{group_base_path}/Test_Shared_Folder_2/"); + client + .request_with_headers( + "MOVE", + &new_hierarchy_root, + [("destination", shared_hierarchy_root_1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "COPY", + &hierarchy_root, + [("destination", shared_hierarchy_root_2.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &orig_hierarchy); + client.validate_values(&orig_hierarchy).await; + let response = client + .sync_collection(&group_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + replace_prefix( + &mut full_hierarchy, + &new_hierarchy_root, + &shared_hierarchy_root_1, + ); + replace_prefix( + &mut full_hierarchy, + &hierarchy_root, + &shared_hierarchy_root_2, + ); + assert_result(&response, &full_hierarchy); + client.validate_values(&full_hierarchy).await; + + // Delete all containers + for shared_container in [ + shared_hierarchy_root_1, + shared_hierarchy_root_2, + hierarchy_root, + ] { + client + .request("DELETE", &shared_container, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + // Create test containers + let mut hierarchy = vec![]; + for folder_name in ["folder1", "folder2", "folder3"] { + let folder_path = format!("{user_base_path}/{folder_name}/"); + + client + .mkcol("MKCOL", &folder_path, [], []) + .await + .with_status(StatusCode::CREATED); + + for file_name in ["file1", "file2", "file3"] { + let file_path = format!("{folder_path}{file_name}"); + let file_contents = resource_type.generate(); + client + .request("PUT", &file_path, &file_contents) + .await + .with_status(StatusCode::CREATED); + hierarchy.push((file_path, file_contents)); + } + + hierarchy.push((folder_path, "".to_string())); + } + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 7: Copying or moving files to the root container is not allowed + let folder1_file1 = format!("{user_base_path}/folder1/file1"); + if resource_type != DavResourceName::File { + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &folder1_file1, + [("destination", user_base_path.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + client + .request_with_headers( + method, + &folder1_file1, + [("destination", format!("{user_base_path}/folder2").as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + } + + // Test 8: Copying or moving to the same location is not allowed + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &folder1_file1, + [("destination", folder1_file1.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + + // Test 9: Rename file + let folder1_file1_new = format!("{user_base_path}/folder1/file1_new"); + client + .request_with_headers( + "MOVE", + &folder1_file1, + [("destination", folder1_file1_new.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + rename(&mut hierarchy, &folder1_file1, &folder1_file1_new); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 10: Move a file under a different container + let folder2_file1_from_folder1 = format!("{user_base_path}/folder2/file1_from_folder1"); + client + .request_with_headers( + "MOVE", + &folder1_file1_new, + [("destination", folder2_file1_from_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + rename( + &mut hierarchy, + &folder1_file1_new, + &folder2_file1_from_folder1, + ); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 11: Move and overwrite a file under a different container + let folder1_file2 = format!("{user_base_path}/folder1/file2"); + client + .request_with_headers( + "MOVE", + &folder2_file1_from_folder1, + [("destination", folder1_file2.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + delete(&mut hierarchy, &folder1_file2); + rename(&mut hierarchy, &folder2_file1_from_folder1, &folder1_file2); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 12: Copy a file under a different container + let file3_path = format!("{user_base_path}/folder1/file3"); + let folder3_file3_from_folder1 = format!("{user_base_path}/folder3/file3_from_folder1"); + client + .request_with_headers( + "COPY", + &file3_path, + [("destination", folder3_file3_from_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + copy(&mut hierarchy, &file3_path, &folder3_file3_from_folder1); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 12: Copy and overwrite a file under a different container + let folder2_file2 = format!("{user_base_path}/folder2/file2"); + client + .request_with_headers( + "COPY", + &folder3_file3_from_folder1, + [("destination", folder2_file2.as_str())], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + delete(&mut hierarchy, &folder2_file2); + copy(&mut hierarchy, &folder3_file3_from_folder1, &folder2_file2); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 13: Copy and move files to a shared container + let shared_hierarchy_root = format!("{group_base_path}/Test_Child_Folder/"); + let folder3_file1 = format!("{user_base_path}/folder3/file1"); + let shared_file_1 = format!("{shared_hierarchy_root}shared_file_1"); + let shared_file_2 = format!("{shared_hierarchy_root}shared_file_2"); + client + .mkcol("MKCOL", &shared_hierarchy_root, [], []) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "MOVE", + &folder3_file1, + [("destination", shared_file_1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + client + .request_with_headers( + "COPY", + &folder1_file2, + [("destination", shared_file_2.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let shared_hierarchy = vec![ + (shared_hierarchy_root.clone(), "".to_string()), + ( + shared_file_1, + get_contents(&hierarchy, &folder3_file1).unwrap(), + ), + ( + shared_file_2, + get_contents(&hierarchy, &folder1_file2).unwrap(), + ), + ]; + delete(&mut hierarchy, &folder3_file1); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + let response = client + .sync_collection(&group_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &shared_hierarchy); + client.validate_values(&shared_hierarchy).await; + client + .request("DELETE", &shared_hierarchy_root, "") + .await + .with_status(StatusCode::NO_CONTENT); + + if resource_type == DavResourceName::File { + // Test 14: Move a container under a different container + let folder2 = format!("{user_base_path}/folder2/"); + let folder3 = format!("{user_base_path}/folder3/"); + let folder2_folder3 = format!("{user_base_path}/folder2/folder3/"); + client + .request_with_headers( + "MOVE", + &folder3, + [("destination", folder2_folder3.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + replace_prefix(&mut hierarchy, &folder3, &folder2_folder3); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + + // Test 15: Moving or copying a parent under a child is not allowed + for method in ["MOVE", "COPY"] { + client + .request_with_headers( + method, + &folder2_folder3, + [("destination", folder2.as_str())], + "", + ) + .await + .with_status(StatusCode::BAD_GATEWAY); + } + + // Test 16: Copy a container under a different container + let folder1 = format!("{user_base_path}/folder1/"); + let folder2_folder1 = format!("{user_base_path}/folder2/folder1/"); + client + .request_with_headers( + "COPY", + &folder1, + [("destination", folder2_folder1.as_str())], + "", + ) + .await + .with_status(StatusCode::CREATED); + let response = client + .sync_collection(&user_base_path, "", Depth::Infinity, ["D:getetag"]) + .await; + copy_prefix(&mut hierarchy, &folder1, &folder2_folder1); + assert_result(&response, &hierarchy); + client.validate_values(&hierarchy).await; + } else { + // Test 17: UID collision + let folder1 = format!("{user_base_path}/folder1/"); + let folder2 = format!("{user_base_path}/folder2/"); + let file_contents = resource_type.generate(); + for folder_path in [&folder1, &folder2] { + let file_path = format!("{folder_path}uid_test"); + client + .request("PUT", &file_path, file_contents.as_str()) + .await + .with_status(StatusCode::CREATED); + } + let uid_file_src = format!("{folder1}uid_test"); + let uid_file_dest = format!("{folder2}uid_test_dup"); + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &uid_file_src, + [("destination", uid_file_dest.as_str())], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_failed_precondition( + if resource_type == DavResourceName::Cal { + "A:no-uid-conflict.D:href" + } else { + "B:no-uid-conflict.D:href" + }, + &format!("{folder2}uid_test"), + ); + } + } + + // Delete all containers and create a new one + client + .request("DELETE", &format!("{user_base_path}/folder3/"), "") + .await + .with_status(if resource_type == DavResourceName::File { + StatusCode::NOT_FOUND + } else { + StatusCode::NO_CONTENT + }); + for folder in ["folder1", "folder2"] { + let folder_path = format!("{user_base_path}/{folder}/"); + client + .request("DELETE", &folder_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + // Create a new test container and file + let test_base_path = format!("{user_base_path}/My_Test_Folder/"); + client + .mkcol("MKCOL", &test_base_path, [], []) + .await + .with_status(StatusCode::CREATED); + let test_contents_1 = resource_type.generate(); + let test_contents_2 = resource_type.generate(); + let test_file1_path = format!("{test_base_path}test_file_1"); + let test_file2_path = format!("{test_base_path}test_file_2"); + let test_etag_1 = client + .request("PUT", &test_file1_path, test_contents_1.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + let test_etag_2 = client + .request("PUT", &test_file2_path, test_contents_2.as_str()) + .await + .with_status(StatusCode::CREATED) + .etag() + .to_string(); + + // Test 18: Failed DAV preconditions + for method in ["COPY", "MOVE"] { + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("overwrite", "F"), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if-none-match", "*"), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + + let iff = format!( + "<{test_file1_path}> (Not [{test_etag_1}]) <{test_file2_path}> (Not [{test_etag_2}])", + ); + client + .request_with_headers( + method, + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if", iff.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::PRECONDITION_FAILED) + .with_empty_body(); + } + + // Test 18: Successful DAV preconditions + let iff = + format!("<{test_file1_path}> ([{test_etag_1}]) <{test_file2_path}> ([{test_etag_2}])",); + client + .request_with_headers( + "MOVE", + &test_file1_path, + [ + ("destination", test_file2_path.as_str()), + ("if", iff.as_str()), + ], + "", + ) + .await + .with_status(StatusCode::NO_CONTENT); + + // Delete the test container + client + .request("DELETE", &test_base_path, "") + .await + .with_status(StatusCode::NO_CONTENT); + } + + client.delete_default_containers().await; + client.delete_default_containers_by_account("support").await; + test.assert_is_empty().await; +} + +fn assert_result(response: &DavResponse, hierarchy: &[(String, String)]) { + assert!(!hierarchy.is_empty()); + let response = response + .hrefs() + .into_iter() + .filter(|h| { + !h.ends_with("/jane/") && !h.ends_with("/support/") && !h.ends_with("/default/") + }) + .collect::>(); + let hierarchy = hierarchy + .iter() + .map(|x| x.0.as_str()) + .collect::>(); + + if hierarchy != response { + println!("\nMissing: {:?}", hierarchy.difference(&response)); + println!("\nExtra: {:?}", response.difference(&hierarchy)); + + panic!( + "Hierarchy mismatch: expected {} items, received {} items", + hierarchy.len(), + response.len() + ); + } +} + +fn replace_prefix(items: &mut [(String, String)], old_prefix: &str, new_prefix: &str) { + let mut did_replace = false; + for (href, _) in items.iter_mut() { + if let Some(value) = href.strip_prefix(old_prefix) { + *href = format!("{new_prefix}{value}"); + did_replace = true; + } + } + if !did_replace { + panic!("Prefix not found: {}", old_prefix); + } +} + +fn rename(items: &mut [(String, String)], old_name: &str, new_name: &str) { + for (href, _) in items.iter_mut() { + if href == old_name { + *href = new_name.to_string(); + return; + } + } + panic!("Item not found: {}", old_name); +} + +fn delete(items: &mut Vec<(String, String)>, name: &str) { + let mut did_delete = false; + items.retain(|(href, _)| { + did_delete = did_delete || href == name; + href != name + }); + + if !did_delete { + panic!("Item not found: {}", name); + } +} + +fn copy(items: &mut Vec<(String, String)>, old_name: &str, new_name: &str) { + for (href, contents) in items.iter_mut() { + if href == old_name { + let value = (new_name.to_string(), contents.to_string()); + items.push(value); + return; + } + } + panic!("Item not found: {}", old_name); +} + +fn copy_prefix(items: &mut Vec<(String, String)>, old_prefix: &str, new_prefix: &str) { + let mut new_items = vec![]; + for (href, contents) in items.iter() { + if let Some(value) = href.strip_prefix(old_prefix) { + new_items.push((format!("{new_prefix}{value}"), contents.to_string())); + } + } + if !new_items.is_empty() { + items.extend(new_items); + } else { + panic!("Prefix not found: {}", old_prefix); + } +} + +fn get_contents(items: &[(String, String)], name: &str) -> Option { + for (href, contents) in items.iter() { + if href == name { + return Some(contents.to_string()); + } + } + None +} diff --git a/tests/src/webdav/mkcol.rs b/tests/src/webdav/mkcol.rs index 0ca43c4e..a2ba5539 100644 --- a/tests/src/webdav/mkcol.rs +++ b/tests/src/webdav/mkcol.rs @@ -131,27 +131,27 @@ pub async fn test(test: &WebDavTest) { ["D:collection", "A:calendar"].as_slice(), ), ] { - let response = client + let mut response = client .mkcol( "MKCOL", path, resource_types.iter().copied(), properties.iter().copied(), ) - .await; - response + .await .with_status(StatusCode::CREATED) .match_many("D:mkcol-response.D:propstat.D:status", ["HTTP/1.1 200 OK"]); for (property, _) in properties { - response.match_one( + response = response.match_one( &format!("D:mkcol-response.D:propstat.D:prop.{property}"), "", ); } // Check the properties of the created collection - let response = client.propfind(path, properties.iter().map(|x| x.0)).await; - response + let mut response = client + .propfind(path, properties.iter().map(|x| x.0)) + .await .with_status(StatusCode::MULTI_STATUS) .match_one("D:multistatus.D:response.D:href", path) .match_one( @@ -159,7 +159,7 @@ pub async fn test(test: &WebDavTest) { "HTTP/1.1 200 OK", ); for (property, value) in properties { - response.match_one( + response = response.match_one( &format!("D:multistatus.D:response.D:propstat.D:prop.{property}"), value, ); diff --git a/tests/src/webdav/mod.rs b/tests/src/webdav/mod.rs index e0caef5d..b135f988 100644 --- a/tests/src/webdav/mod.rs +++ b/tests/src/webdav/mod.rs @@ -21,7 +21,8 @@ use common::{ core::BuildServer, manager::boot::build_ipc, }; -use groupware::hierarchy::DavHierarchy; +use dav_proto::Depth; +use groupware::{DavResourceName, hierarchy::DavHierarchy}; use http::HttpSessionManager; use hyper::{HeaderMap, Method, StatusCode, header::AUTHORIZATION}; use imap::core::ImapSessionManager; @@ -36,10 +37,12 @@ use std::{ sync::Arc, time::{Duration, Instant}, }; +use store::rand::{Rng, distr::Alphanumeric, rng}; use tokio::sync::watch; use utils::config::Config; pub mod basic; +pub mod copy_move; pub mod mkcol; pub mod put_get; @@ -327,13 +330,9 @@ async fn init_webdav_tests(store_id: &str, delete_if_exists: bool) -> WebDavTest } } store - .create_test_group( - "support@example.com", - "Support Group", - &["support@example.com"], - ) + .create_test_group("support", "Support Group", &["support@example.com"]) .await; - store.add_to_group("jane", "support@example.com").await; + store.add_to_group("jane", "support").await; WebDavTest { server: inner.build_server(), @@ -355,9 +354,10 @@ pub async fn webdav_tests() { ) .await; - //basic::test(&handle).await; - //put_get::test(&handle).await; + basic::test(&handle).await; + put_get::test(&handle).await; mkcol::test(&handle).await; + copy_move::test(&handle).await; // Print elapsed time let elapsed = start_time.elapsed(); @@ -498,15 +498,22 @@ impl DummyWebDavClient { let mut request = concat!( "", "", - "" + "" ) .to_string(); - for resource_type in resource_types { + let mut has_resource_type = false; + for (idx, resource_type) in resource_types.into_iter().enumerate() { + if idx == 0 { + request.push_str(""); + } request.push_str(&format!("<{resource_type}/>")); + has_resource_type = true; } - request.push_str(""); + if has_resource_type { + request.push_str(""); + } for (key, value) in properties { request.push_str(&format!("<{key}>{value}")); @@ -541,9 +548,151 @@ impl DummyWebDavClient { self.request("PROPFIND", path, &request).await } + pub async fn sync_collection( + &self, + path: &str, + sync_token: &str, + depth: Depth, + properties: impl IntoIterator, + ) -> DavResponse { + let mut request = concat!( + "", + "", + "" + ) + .to_string(); + + for property in properties { + request.push_str(&format!("<{property}/>")); + } + + request.push_str(""); + request.push_str(sync_token); + request.push_str(""); + request.push_str(match depth { + Depth::One => "1", + Depth::Infinity => "infinite", + _ => "0", + }); + request.push_str(""); + + self.request("REPORT", path, &request) + .await + .with_status(StatusCode::MULTI_STATUS) + } + + pub async fn create_hierarchy( + &self, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + ) -> (String, Vec<(String, String)>) { + let resource_type = if base_path.starts_with("/dav/card/") { + DavResourceName::Card + } else if base_path.starts_with("/dav/cal/") { + DavResourceName::Cal + } else { + DavResourceName::File + }; + + let mut created_resources = Vec::new(); + + self.create_hierarchy_recursive( + resource_type, + base_path, + max_depth, + containers_per_level, + files_per_container, + 0, + &mut created_resources, + ) + .await; + + let root_folder = created_resources.first().unwrap().0.clone(); + created_resources.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + (root_folder, created_resources) + } + + #[allow(clippy::too_many_arguments)] + async fn create_hierarchy_recursive( + &self, + resource_type: DavResourceName, + base_path: &str, + max_depth: usize, + containers_per_level: usize, + files_per_container: usize, + current_depth: usize, + created_resources: &mut Vec<(String, String)>, + ) { + let folder_name = generate_random_name(4); + let folder_path = format!("{base_path}/Folder_{folder_name}"); + + self.mkcol("MKCOL", &folder_path, [], []) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((format!("{folder_path}/"), "".to_string())); + + for _ in 0..files_per_container { + let file_name = generate_random_name(8); + let file_path = format!( + "{folder_path}/{file_name}.{}", + match resource_type { + DavResourceName::Card => "vcf", + DavResourceName::Cal => "ics", + DavResourceName::File => "txt", + _ => unreachable!(), + } + ); + let content = match resource_type { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 500), + _ => unreachable!(), + }; + + self.request("PUT", &file_path, &content) + .await + .with_status(StatusCode::CREATED); + + created_resources.push((file_path, content)); + } + + if current_depth < max_depth { + for _ in 0..containers_per_level { + Box::pin(self.create_hierarchy_recursive( + resource_type, + &folder_path, + max_depth, + containers_per_level, + files_per_container, + current_depth + 1, + created_resources, + )) + .await; + } + } + } + + pub async fn validate_values(&self, items: &[(String, String)]) { + for (path, value) in items { + if !path.ends_with('/') { + self.request("GET", path, "") + .await + .with_status(StatusCode::OK) + .with_body(value); + } + } + } + pub async fn delete_default_containers(&self) { + self.delete_default_containers_by_account(self.name).await; + } + + pub async fn delete_default_containers_by_account(&self, account: &str) { for col in ["card", "cal"] { - self.request("DELETE", &format!("/dav/{col}/{}/default", self.name), "") + self.request("DELETE", &format!("/dav/{col}/{account}/default"), "") .await .with_status(StatusCode::NO_CONTENT); } @@ -551,7 +700,7 @@ impl DummyWebDavClient { } impl DavResponse { - pub fn with_status(&self, status: StatusCode) -> &Self { + pub fn with_status(self, status: StatusCode) -> Self { if self.status != status { self.dump_response(); panic!("Expected {status} but got {}", self.status) @@ -559,12 +708,12 @@ impl DavResponse { self } - pub fn with_redirect_to(&self, url: &str) -> &Self { + pub fn with_redirect_to(self, url: &str) -> Self { self.with_status(StatusCode::TEMPORARY_REDIRECT) .with_header("location", url) } - pub fn with_header(&self, header: &str, value: &str) -> &Self { + pub fn with_header(self, header: &str, value: &str) -> Self { if self.headers.get(header).is_some_and(|v| v == value) { self } else { @@ -573,7 +722,7 @@ impl DavResponse { } } - pub fn with_body(&self, expect_body: impl AsRef) -> &Self { + pub fn with_body(self, expect_body: impl AsRef) -> Self { let expect_body = expect_body.as_ref(); if self.body.is_ok() { let body = self.body.as_ref().unwrap(); @@ -588,6 +737,20 @@ impl DavResponse { } } + pub fn with_empty_body(self) -> Self { + if self.body.is_ok() { + let body = self.body.as_ref().unwrap(); + if !body.is_empty() { + self.dump_response(); + panic!("Expected empty body but got {body:?}"); + } + self + } else { + self.dump_response(); + panic!("Expected empty body but no body was returned.") + } + } + pub fn header(&self, header: &str) -> &str { if let Some(value) = self.headers.get(header) { value @@ -601,6 +764,24 @@ impl DavResponse { self.header("etag") } + pub fn sync_token(&self) -> &str { + self.find_keys("D:multistatus.D:sync-token") + .next() + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| { + self.dump_response(); + panic!("Sync token not found.") + }) + } + + pub fn hrefs(&self) -> Vec<&str> { + let mut hrefs = self + .find_keys("D:multistatus.D:response.D:href") + .collect::>(); + hrefs.sort_unstable(); + hrefs + } + fn dump_response(&self) { eprintln!("-------------------------------------"); eprintln!("Status: {}", self.status); @@ -625,7 +806,7 @@ impl DavResponse { } // Poor man's XPath - pub fn match_one(&self, query: &str, expect: impl AsRef) -> &Self { + pub fn match_one(self, query: &str, expect: impl AsRef) -> Self { let expect = expect.as_ref(); if let Some(value) = self.find_keys(query).next() { if value != expect { @@ -639,7 +820,7 @@ impl DavResponse { self } - pub fn match_many(&self, query: &str, expect: I) -> &Self + pub fn match_many(self, query: &str, expect: I) -> Self where I: IntoIterator, T: AsRef, @@ -654,7 +835,7 @@ impl DavResponse { self } - pub fn with_failed_precondition(&self, precondition: &str, value: &str) -> &Self { + pub fn with_failed_precondition(self, precondition: &str, value: &str) -> Self { let error = format!("D:error.{precondition}"); if self.find_keys(&error).next().is_none_or(|v| v != value) { self.dump_response(); @@ -825,3 +1006,123 @@ END:DAYLIGHT END:VTIMEZONE END:VCALENDAR "#; + +pub trait GenerateTestDavResource { + fn generate(&self) -> String; +} + +impl GenerateTestDavResource for DavResourceName { + fn generate(&self) -> String { + match self { + DavResourceName::Card => generate_random_vcard(), + DavResourceName::Cal => generate_random_ical(), + DavResourceName::File => generate_random_content(100, 200), + _ => unreachable!(), + } + } +} + +fn generate_random_vcard() -> String { + r#"BEGIN:VCARD +VERSION:4.0 +UID:$UID +FN:$NAME +END:VCARD +"# + .replace("$UID", &generate_random_name(8)) + .replace("$NAME", &generate_random_name(10)) + .replace('\n', "\r\n") +} + +fn generate_random_ical() -> String { + r#"BEGIN:VCALENDAR +VERSION:2.0 +BEGIN:VEVENT +UID:$UID +SUMMARY:$SUMMARY +DESCRIPTION:$DESCRIPTION +END:VEVENT +END:VCALENDAR +"# + .replace("$UID", &generate_random_name(8)) + .replace("$SUMMARY", &generate_random_name(10)) + .replace("$DESCRIPTION", &generate_random_name(20)) + .replace('\n', "\r\n") +} + +fn generate_random_content(min_chars: usize, max_chars: usize) -> String { + let mut rng = rng(); + let length = rng.random_range(min_chars..=max_chars); + + let words = [ + "lorem", + "ipsum", + "dolor", + "sit", + "amet", + "consectetur", + "adipiscing", + "elit", + "sed", + "do", + "eiusmod", + "tempor", + "incididunt", + "ut", + "labore", + "et", + "dolore", + "magna", + "aliqua", + "ut", + "enim", + "ad", + "minim", + "veniam", + "quis", + "nostrud", + "exercitation", + "ullamco", + "laboris", + "nisi", + "ut", + "aliquip", + "ex", + "ea", + "commodo", + "consequat", + ]; + + let mut content = String::with_capacity(length); + + while content.len() < length { + let word_idx = rng.random_range(0..words.len()); + if !content.is_empty() { + content.push(' '); + } + if rng.random_ratio(1, 10) { + content.push('.'); + let word = words[word_idx]; + let mut chars = word.chars(); + if let Some(first_char) = chars.next() { + content.push_str(&first_char.to_uppercase().to_string()); + content.push_str(chars.as_str()); + } + } else { + content.push_str(words[word_idx]); + } + } + + if !content.ends_with('.') { + content.push('.'); + } + + content +} + +fn generate_random_name(length: usize) -> String { + let mut rng = rng(); + (0..length) + .map(|_| rng.sample(Alphanumeric) as char) + .collect() +} diff --git a/tests/src/webdav/put_get.rs b/tests/src/webdav/put_get.rs index 8ab5f2c1..aaeb44c6 100644 --- a/tests/src/webdav/put_get.rs +++ b/tests/src/webdav/put_get.rs @@ -136,12 +136,14 @@ pub async fn test(test: &WebDavTest) { while chunky_contents.len() < max_size { chunky_contents.push_str(contents); } - let response = client.request("PUT", path, chunky_contents).await; - response.with_status( - expect - .map(|_| StatusCode::PRECONDITION_FAILED) - .unwrap_or(StatusCode::PAYLOAD_TOO_LARGE), - ); + let response = client + .request("PUT", path, chunky_contents) + .await + .with_status( + expect + .map(|_| StatusCode::PRECONDITION_FAILED) + .unwrap_or(StatusCode::PAYLOAD_TOO_LARGE), + ); if let Some(expect) = expect { response.with_failed_precondition(expect, &max_size.to_string()); }