fix(webdav): RFC 4918 litmus compliance
This commit is contained in:
@@ -106,15 +106,27 @@ impl WebDavLockStore {
|
||||
|
||||
/// Attempt to acquire a lock on `path`.
|
||||
///
|
||||
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` if the resource
|
||||
/// is already exclusively locked by a different token.
|
||||
/// Returns `Ok(LockEntry)` on success, or `Err(existing)` when:
|
||||
/// - The existing lock is exclusive (blocks any new lock), or
|
||||
/// - The new lock is exclusive and any lock already exists (RFC 4918 §7.8).
|
||||
#[allow(clippy::result_large_err)]
|
||||
pub fn acquire(&self, path: &str, info: LockInfo) -> Result<LockEntry, LockEntry> {
|
||||
// Check for existing conflicting lock
|
||||
if let Some(existing) = self.by_path.get(path)
|
||||
&& existing.info.scope == LockScope::Exclusive
|
||||
{
|
||||
return Err(existing);
|
||||
if let Some(existing) = self.by_path.get(path) {
|
||||
// Exclusive existing lock → blocks everything.
|
||||
// New exclusive lock → blocked by any existing lock (shared or exclusive).
|
||||
if existing.info.scope == LockScope::Exclusive || info.scope == LockScope::Exclusive {
|
||||
return Err(existing);
|
||||
}
|
||||
// Both shared: keep the first holder as the enforcement sentinel in
|
||||
// `by_path` so releasing a secondary holder cannot clear the lock.
|
||||
// Register the new token only in the reverse index so UNLOCK works.
|
||||
let entry = LockEntry {
|
||||
info,
|
||||
path: path.to_owned(),
|
||||
};
|
||||
self.by_token
|
||||
.insert(entry.info.token.clone(), path.to_owned());
|
||||
return Ok(entry);
|
||||
}
|
||||
|
||||
let entry = LockEntry {
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod app_password_handler;
|
||||
pub mod auth_handler;
|
||||
pub mod batch_handler;
|
||||
pub mod caldav_handler;
|
||||
pub mod calendar_rest_handler;
|
||||
pub mod carddav_handler;
|
||||
pub mod chunked_upload_handler;
|
||||
pub mod contacts_handler;
|
||||
|
||||
@@ -1062,20 +1062,67 @@ fn enforce_native_lock(
|
||||
if_header: Option<&str>,
|
||||
path: &str,
|
||||
) -> Option<Response<Body>> {
|
||||
let entry = lock_store.get_by_path(path)?;
|
||||
if let Some(h) = if_header
|
||||
&& extract_if_header_tokens(h)
|
||||
.iter()
|
||||
.any(|t| t == &entry.info.token)
|
||||
{
|
||||
return None;
|
||||
// Check the exact path, then walk up parent collections for depth-infinity
|
||||
// locks (RFC 4918 §6.1: a lock on a collection with Depth: infinity also
|
||||
// covers all descendant members).
|
||||
let entry = lock_store.get_by_path(path).or_else(|| {
|
||||
let mut p = path;
|
||||
loop {
|
||||
let idx = p.rfind('/')?;
|
||||
p = &p[..idx];
|
||||
if p.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some(e) = lock_store.get_by_path(p) {
|
||||
if e.info.depth.eq_ignore_ascii_case("infinity") {
|
||||
return Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(entry) = entry {
|
||||
// Resource is locked: caller must supply the matching token in If:.
|
||||
if let Some(h) = if_header
|
||||
&& extract_if_header_tokens(h)
|
||||
.iter()
|
||||
.any(|t| t == &entry.info.token)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
return Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::LOCKED)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::LOCKED)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
|
||||
// Resource is not locked. If the If: header references lock tokens (not
|
||||
// resource-tag URLs), every such token must be active somewhere in the
|
||||
// store. A stale or fabricated token (e.g. DAV:no-lock) never matches,
|
||||
// so the If: condition fails → 412 Precondition Failed (RFC 4918 §10.4).
|
||||
if let Some(h) = if_header {
|
||||
let tokens = extract_if_header_tokens(h);
|
||||
let lock_refs: Vec<_> = tokens
|
||||
.iter()
|
||||
.filter(|t| !t.starts_with("http://") && !t.starts_with("https://"))
|
||||
.collect();
|
||||
if !lock_refs.is_empty()
|
||||
&& !lock_refs
|
||||
.iter()
|
||||
.any(|t| lock_store.get_by_token(t).is_some())
|
||||
{
|
||||
return Some(
|
||||
Response::builder()
|
||||
.status(StatusCode::PRECONDITION_FAILED)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -592,6 +592,8 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
|
||||
tracing::info!("Contacts REST API routes initialized");
|
||||
}
|
||||
|
||||
|
||||
|
||||
// NOTE: WebDAV routes are mounted at top-level (/webdav) in main.rs
|
||||
// for client compatibility, NOT under /api.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user