WebDAV LOCK and multiget/sync-collection REPORT

This commit is contained in:
mdecimus 2025-05-02 19:05:14 +02:00
parent 2b9e0816eb
commit d72ae41058
8 changed files with 583 additions and 24 deletions

View file

@ -45,7 +45,9 @@ impl GroupwareConfig {
live_property_size: config
.property("dav.limits.size.live-property")
.unwrap_or(250),
max_lock_timeout: config.property("dav.limits.timeout.max-lock").unwrap_or(60),
max_lock_timeout: config
.property("dav.limits.timeout.max-lock")
.unwrap_or(3600),
max_locks_per_user: config
.property("dav.limits.max-locks-per-user")
.unwrap_or(10),

View file

@ -11,6 +11,7 @@ use dav_proto::schema::request::{DavPropertyValue, DeadProperty};
use dav_proto::schema::response::{BaseCondition, List, PropResponse};
use dav_proto::{Condition, Depth, Timeout};
use dav_proto::{RequestHeaders, schema::request::LockInfo};
use groupware::hierarchy::DavHierarchy;
use http_proto::HttpResponse;
use hyper::StatusCode;
use jmap_proto::types::collection::Collection;
@ -260,6 +261,13 @@ impl LockRequestHandler for Server {
lock_item.expires = expires;
if let LockRequest::Lock(lock_info) = lock_info {
// Validate lock_info
if lock_info.owner.as_ref().is_some_and(|o| {
o.size() > self.core.groupware.dead_property_size.unwrap_or(512)
}) {
return Err(DavError::Code(StatusCode::PAYLOAD_TOO_LARGE));
}
lock_item.lock_id = store::rand::random::<u64>() ^ expires;
lock_item.owner = access_token.primary_id;
lock_item.depth_infinity = matches!(headers.depth, Depth::Infinity);
@ -270,15 +278,19 @@ impl LockRequestHandler for Server {
let base_path = base_path.get_or_insert_with(|| headers.base_uri().unwrap_or_default());
let active_lock = lock_item.to_active_lock(format!("{base_path}/{resource_path}"));
HttpResponse::new(StatusCode::CREATED)
.with_lock_token(&active_lock.lock_token.as_ref().unwrap().0)
.with_xml_body(
PropResponse::new(vec![DavPropertyValue::new(
WebDavProperty::LockDiscovery,
vec![active_lock],
)])
.to_string(),
)
HttpResponse::new(if if_lock_token == 0 {
StatusCode::CREATED
} else {
StatusCode::OK
})
.with_lock_token(&active_lock.lock_token.as_ref().unwrap().0)
.with_xml_body(
PropResponse::new(vec![DavPropertyValue::new(
WebDavProperty::LockDiscovery,
vec![active_lock],
)])
.to_string(),
)
} else {
let lock_id = headers
.lock_token
@ -542,10 +554,14 @@ impl LockRequestHandler for Server {
// Fetch sync token
if needs_sync_token && resource_state.sync_token.is_none() {
let change_id = self
.store()
.get_last_change_id(resource_state.account_id, resource_state.collection)
.fetch_dav_resources(
access_token,
resource_state.account_id,
resource_state.collection.main_collection(),
)
.await
.caused_by(trc::location!())?;
.caused_by(trc::location!())?
.modseq;
resource_state.sync_token =
Some(Urn::Sync(change_id.unwrap_or_default()).to_string());
}
@ -679,7 +695,7 @@ impl<'x> LockCaches<'x> {
pub fn is_cached(&self, resource_state: &ResourceState<'_>) -> Option<usize> {
self.caches.iter().position(|cache| {
resource_state.account_id == cache.account_id
&& resource_state.collection == cache.collection
&& resource_state.collection.main_collection() == cache.collection.main_collection()
})
}
@ -844,13 +860,13 @@ impl ArchivedLockItem {
impl OwnedUri<'_> {
pub fn lock_key(&self) -> Vec<u8> {
build_lock_key(self.account_id, self.collection)
build_lock_key(self.account_id, self.collection.main_collection())
}
}
impl ResourceState<'_> {
pub fn lock_key(&self) -> Vec<u8> {
build_lock_key(self.account_id, self.collection)
build_lock_key(self.account_id, self.collection.main_collection())
}
}

View file

@ -30,14 +30,14 @@ pub async fn test(test: &WebDavTest) {
client
.request("PROPFIND", "/.well-known/carddav", "")
.await
.match_many(
.with_values(
"D:multistatus.D:response.D:href",
["/dav/card/", "/dav/card/john/"],
);
test.client("jane")
.request("PROPFIND", "/.well-known/caldav", "")
.await
.match_many(
.with_values(
"D:multistatus.D:response.D:href",
["/dav/cal/", "/dav/cal/jane/", "/dav/cal/support/"],
);

257
tests/src/webdav/lock.rs Normal file
View file

@ -0,0 +1,257 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{DavResponse, DummyWebDavClient, WebDavTest};
use crate::webdav::GenerateTestDavResource;
use dav_proto::schema::property::{DavProperty, WebDavProperty};
use groupware::DavResourceName;
use hyper::StatusCode;
pub async fn test(test: &WebDavTest) {
let client = test.client("john");
for resource_type in [
DavResourceName::File,
DavResourceName::Cal,
DavResourceName::Card,
] {
println!(
"Running LOCK/UNLOCK tests ({})...",
resource_type.base_path()
);
let base_path = format!("{}/john", resource_type.base_path());
// Test 1: Creating a collection under an unmapped resource without providing a lock token should fail
let path = format!("{base_path}/do-not-write");
let response = client
.lock_create(&path, "super-owner", true, "infinity", "Second-123")
.await
.with_status(StatusCode::CREATED);
let lock_token = response
.with_value(
"D:prop.D:lockdiscovery.D:activelock.D:owner.D:href",
"super-owner",
)
.with_value("D:prop.D:lockdiscovery.D:activelock.D:depth", "infinity")
.with_value(
"D:prop.D:lockdiscovery.D:activelock.D:timeout",
"Second-123",
)
.lock_token()
.to_string();
// Test 2: Refreshing a lock token with an invalid a lock token should fail
client
.lock_refresh(&path, "urn:stalwart:davlock:1234", "infinity", "Second-456")
.await
.with_status(StatusCode::PRECONDITION_FAILED);
// Test 3: Refreshing a lock token with valid a lock token should succeed
client
.lock_refresh(&path, &lock_token, "infinity", "Second-456")
.await
.with_status(StatusCode::OK)
.with_value(
"D:prop.D:lockdiscovery.D:activelock.D:owner.D:href",
"super-owner",
)
.with_value(
"D:prop.D:lockdiscovery.D:activelock.D:timeout",
"Second-456",
);
// Test 3: Creating a collection under an unmapped resource with a lock token should fail
client
.request_with_headers("MKCOL", &path, [], "")
.await
.with_status(StatusCode::LOCKED)
.with_value("D:error.D:lock-token-submitted.D:href", &path);
// Test 4: Creating a collection under a mapped resource with a lock token should succeed
client
.request_with_headers(
"MKCOL",
&path,
[("if", format!("(<{lock_token}>)").as_str())],
"",
)
.await
.with_status(StatusCode::CREATED);
// Test 5: Creating a file under a locked resource without a lock token should fail
let file_path = format!("{path}/file.txt");
let contents = resource_type.generate();
client
.request("PUT", &file_path, &contents)
.await
.with_status(StatusCode::LOCKED)
.with_value("D:error.D:lock-token-submitted.D:href", &path);
// Test 6: Creating a file under a locked resource with a lock token should succeed
client
.request_with_headers(
"PUT",
&file_path,
[("if", format!("(<{lock_token}>)").as_str())],
&contents,
)
.await
.with_status(StatusCode::CREATED);
// Test 7: Locks should be included in propfind responses
let response = client
.propfind(&path, [DavProperty::WebDav(WebDavProperty::LockDiscovery)])
.await;
for href in [path.clone() + "/", file_path] {
let props = response.properties(&href);
props
.get(DavProperty::WebDav(WebDavProperty::LockDiscovery))
.with_values([
"D:activelock.D:owner.D:href:super-owner",
"D:activelock.D:timeout:Second-456",
"D:activelock.D:depth:infinity",
format!("D:activelock.D:locktoken.D:href:{lock_token}").as_str(),
format!("D:activelock.D:lockroot.D:href:{path}").as_str(),
"D:activelock.D:locktype.D:write",
"D:activelock.D:lockscope.D:exclusive",
]);
}
// Test 8: Delete with and without a lock token
client
.request("DELETE", &path, "")
.await
.with_status(StatusCode::LOCKED)
.with_value("D:error.D:lock-token-submitted.D:href", &path);
client
.request_with_headers(
"DELETE",
&path,
[("if", format!("(<{lock_token}>)").as_str())],
"",
)
.await
.with_status(StatusCode::NO_CONTENT);
// Test 9: Unlock with and without a lock token
client
.unlock(&path, "urn:stalwart:davlock:1234")
.await
.with_status(StatusCode::CONFLICT)
.with_value("D:error.D:lock-token-matches-request-uri", "");
client
.unlock(&path, &lock_token)
.await
.with_status(StatusCode::NO_CONTENT);
// Test 10: Locking with a large dead property should fail
let path = format!("{base_path}/invalid-lock");
client
.lock_create(
&path,
(0..=test.server.core.groupware.dead_property_size.unwrap() + 1)
.map(|_| "a")
.collect::<String>()
.as_str(),
true,
"infinity",
"Second-123",
)
.await
.with_status(StatusCode::PAYLOAD_TOO_LARGE);
// Test 11: Too many locks should fail
for i in 0..test.server.core.groupware.max_locks_per_user {
client
.lock_create(
&format!("{base_path}/invalid-lock-{i}"),
"super-owner",
true,
"infinity",
"Second-123",
)
.await
.with_status(StatusCode::CREATED);
}
client
.lock_create(
&format!("{base_path}/invalid-lock-greedy"),
"super-owner",
true,
"infinity",
"Second-123",
)
.await
.with_status(StatusCode::TOO_MANY_REQUESTS);
}
client.delete_default_containers().await;
test.assert_is_empty().await;
}
const LOCK_REQUEST: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
<D:lockinfo xmlns:D='DAV:'>
<D:lockscope><D:$TYPE/></D:lockscope>
<D:locktype><D:write/></D:locktype>
<D:owner>
<D:href>$OWNER</D:href>
</D:owner>
</D:lockinfo>"#;
impl DummyWebDavClient {
pub async fn lock_create(
&self,
path: &str,
owner: &str,
is_exclusive: bool,
depth: &str,
timeout: &str,
) -> DavResponse {
let lock_request = LOCK_REQUEST
.replace("$TYPE", if is_exclusive { "exclusive" } else { "shared" })
.replace("$OWNER", owner);
self.request_with_headers(
"LOCK",
path,
[("depth", depth), ("timeout", timeout)],
&lock_request,
)
.await
}
pub async fn lock_refresh(
&self,
path: &str,
lock_token: &str,
depth: &str,
timeout: &str,
) -> DavResponse {
let condition = format!("(<{lock_token}>)");
self.request_with_headers(
"LOCK",
path,
[
("if", condition.as_str()),
("depth", depth),
("timeout", timeout),
],
"",
)
.await
}
pub async fn unlock(&self, path: &str, lock_token: &str) -> DavResponse {
let condition = format!("<{lock_token}>");
self.request_with_headers("UNLOCK", path, [("lock-token", condition.as_str())], "")
.await
}
}
impl DavResponse {
pub fn lock_token(&self) -> &str {
self.value("D:prop.D:lockdiscovery.D:activelock.D:locktoken.D:href")
}
}

View file

@ -94,11 +94,11 @@ pub async fn test(test: &WebDavTest) {
.mkcol("MKCOL", path, ["D:collection", resource_type], [])
.await
.with_status(StatusCode::FORBIDDEN)
.match_one(
.with_value(
"D:mkcol-response.D:propstat.D:error.D:valid-resourcetype",
"",
)
.match_one("D:mkcol-response.D:propstat.D:prop.D:resourcetype", "");
.with_value("D:mkcol-response.D:propstat.D:prop.D:resourcetype", "");
}
// Create using extended MKCOL
@ -172,8 +172,8 @@ pub async fn test(test: &WebDavTest) {
)
.await
.with_status(StatusCode::CREATED)
.match_one("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "")
.match_many(
.with_value("A:mkcalendar-response.D:propstat.D:prop.D:displayname", "")
.with_values(
"A:mkcalendar-response.D:propstat.D:status",
["HTTP/1.1 200 OK"],
);

View file

@ -46,9 +46,12 @@ use utils::config::Config;
pub mod basic;
pub mod copy_move;
pub mod lock;
pub mod mkcol;
pub mod multiget;
pub mod prop;
pub mod put_get;
pub mod sync;
const SERVER: &str = r#"
[server]
@ -358,11 +361,32 @@ pub async fn webdav_tests() {
)
.await;
/*
TODO:
- Principals:
- PrincipalMatch
- PrincipalPropertySearch
- PrincipalSearchPropertySet
- Expand property
- ACLs:
- ACL Method
- AclPrincipalPropSet
- Addressbook Query
- Calendar Query
- Freebusy Query
*/
basic::test(&handle).await;
put_get::test(&handle).await;
mkcol::test(&handle).await;
copy_move::test(&handle).await;
prop::test(&handle).await;
multiget::test(&handle).await;
sync::test(&handle).await;
lock::test(&handle).await;
// Print elapsed time
let elapsed = start_time.elapsed();
@ -784,8 +808,15 @@ impl DavResponse {
.map(|(_, value)| value.as_str())
}
pub fn value(&self, name: &str) -> &str {
self.find_keys(name).next().unwrap_or_else(|| {
self.dump_response();
panic!("Key {name} not found.")
})
}
// Poor man's XPath
pub fn match_one(self, query: &str, expect: impl AsRef<str>) -> Self {
pub fn with_value(self, query: &str, expect: impl AsRef<str>) -> Self {
let expect = expect.as_ref();
if let Some(value) = self.find_keys(query).next() {
if value != expect {
@ -799,7 +830,7 @@ impl DavResponse {
self
}
pub fn match_many<I, T>(self, query: &str, expect: I) -> Self
pub fn with_values<I, T>(self, query: &str, expect: I) -> Self
where
I: IntoIterator<Item = T>,
T: AsRef<str>,

View file

@ -0,0 +1,104 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::WebDavTest;
use crate::webdav::GenerateTestDavResource;
use dav_proto::schema::property::{CalDavProperty, CardDavProperty, DavProperty, WebDavProperty};
use groupware::DavResourceName;
use hyper::StatusCode;
const MULTIGET_CALENDAR: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
<C:calendar-multiget xmlns:D="DAV:" xmlns:C="urn:ietf:params:xml:ns:caldav">
<D:prop>
<D:getetag/>
<C:calendar-data/>
</D:prop>
<D:href>$PATH_1</D:href>
<D:href>$PATH_2</D:href>
</C:calendar-multiget>
"#;
const MULTIGET_ADDRESSBOOK: &str = r#"<?xml version="1.0" encoding="utf-8" ?>
<C:addressbook-multiget xmlns:D="DAV:"
xmlns:C="urn:ietf:params:xml:ns:carddav">
<D:prop>
<D:getetag/>
<C:address-data/>
</D:prop>
<D:href>$PATH_1</D:href>
<D:href>$PATH_2</D:href>
</C:addressbook-multiget>
"#;
pub async fn test(test: &WebDavTest) {
let client = test.client("john");
for resource_type in [DavResourceName::Cal, DavResourceName::Card] {
println!(
"Running REPORT multiget tests ({})...",
resource_type.base_path()
);
let mut paths = Vec::new();
for name in ["file1", "file2"] {
let contents = resource_type.generate();
let path = format!("{}/john/default/{}", resource_type.base_path(), name);
let etag = client
.request("PUT", &path, contents.as_str())
.await
.with_status(StatusCode::CREATED)
.etag()
.to_string();
paths.push((path, etag, contents));
}
if resource_type == DavResourceName::Cal {
let path = format!("{}/john", resource_type.base_path());
let body = MULTIGET_CALENDAR
.replace("$PATH_1", &paths[0].0)
.replace("$PATH_2", &paths[1].0);
let response = client
.request("REPORT", &path, &body)
.await
.with_status(StatusCode::MULTI_STATUS)
.into_propfind_response(None);
for (path, etag, contents) in paths {
let props = response.properties(&path);
props
.get(DavProperty::WebDav(WebDavProperty::GetETag))
.with_values([etag.as_str()]);
props
.get(DavProperty::CalDav(CalDavProperty::CalendarData(
Default::default(),
)))
.with_values([contents.as_str()]);
}
} else {
let path = format!("{}/john", resource_type.base_path());
let body = MULTIGET_ADDRESSBOOK
.replace("$PATH_1", &paths[0].0)
.replace("$PATH_2", &paths[1].0);
let response = client
.request("REPORT", &path, &body)
.await
.with_status(StatusCode::MULTI_STATUS)
.into_propfind_response(None);
for (path, etag, contents) in paths {
let props = response.properties(&path);
props
.get(DavProperty::WebDav(WebDavProperty::GetETag))
.with_values([etag.as_str()]);
props
.get(DavProperty::CardDav(CardDavProperty::AddressData(
Default::default(),
)))
.with_values([contents.as_str()]);
}
}
}
client.delete_default_containers().await;
test.assert_is_empty().await;
}

149
tests/src/webdav/sync.rs Normal file
View file

@ -0,0 +1,149 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::WebDavTest;
use crate::webdav::GenerateTestDavResource;
use dav_proto::Depth;
use groupware::DavResourceName;
use hyper::StatusCode;
pub async fn test(test: &WebDavTest) {
let client = test.client("john");
for resource_type in [
DavResourceName::File,
DavResourceName::Cal,
DavResourceName::Card,
] {
println!(
"Running REPORT sync-collection tests ({})...",
resource_type.base_path()
);
let user_base_path = format!("{}/john", resource_type.base_path());
// Test 1: Initial sync
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()
);
let sync_token_1 = response.sync_token().to_string();
// Test 2: No changes since last sync
let response = client
.sync_collection(
&user_base_path,
&sync_token_1,
Depth::Infinity,
["D:getetag"],
)
.await;
assert_eq!(response.hrefs(), Vec::<String>::new());
// Test 3: Create a collection and make sure it is synced
let new_collection = format!("{}/new-collection/", user_base_path);
client
.mkcol("MKCOL", &new_collection, [], [])
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(
&user_base_path,
&sync_token_1,
Depth::Infinity,
["D:getetag"],
)
.await;
assert_eq!(response.hrefs(), vec![new_collection.clone()]);
let sync_token_2 = response.sync_token().to_string();
// Test 4: Create a file and make sure it is synced
let new_file = format!("{new_collection}new-file");
let contents = resource_type.generate();
client
.request("PUT", &new_file, &contents)
.await
.with_status(StatusCode::CREATED);
let response = client
.sync_collection(
&user_base_path,
&sync_token_1,
Depth::Infinity,
["D:getetag"],
)
.await;
assert_eq!(
response.hrefs(),
vec![new_collection.clone(), new_file.clone()]
);
let sync_token_3 = response.sync_token().to_string();
let response = client
.sync_collection(
&user_base_path,
&sync_token_2,
Depth::Infinity,
["D:getetag"],
)
.await;
assert_eq!(response.hrefs(), vec![new_file.clone()]);
// Test 5: sync-token with Depth 1
let response = client
.sync_collection(&user_base_path, &sync_token_1, Depth::One, ["D:getetag"])
.await;
assert_eq!(response.hrefs(), vec![new_collection.clone()]);
// Test 6: sync-token with Depth 0
let response = client
.sync_collection(&new_collection, &sync_token_1, Depth::Zero, ["D:getetag"])
.await;
assert_eq!(response.hrefs(), vec![new_collection.clone()]);
// Test 7: Outdated sync-token in If header should fail
let new_file2 = format!("{new_collection}new-file2");
let contents = resource_type.generate();
let condition = format!("(<{sync_token_2}>)");
client
.request_with_headers(
"PUT",
&new_file2,
[("if", condition.as_str())],
contents.as_str(),
)
.await
.with_status(StatusCode::PRECONDITION_FAILED)
.with_empty_body();
// Test 8: Correct sync-token in If header should work
let condition = format!("(<{sync_token_3}>)");
client
.request_with_headers(
"PUT",
&new_file2,
[("if", condition.as_str())],
contents.as_str(),
)
.await
.with_status(StatusCode::CREATED)
.with_empty_body();
client
.request("DELETE", &new_collection, "")
.await
.with_status(StatusCode::NO_CONTENT);
}
client.delete_default_containers().await;
test.assert_is_empty().await;
}