From d3546305f6066147b09077e249d7c16b8f5c5beb Mon Sep 17 00:00:00 2001 From: "M.Schmidt" Date: Sat, 11 Jul 2026 18:32:09 +0200 Subject: [PATCH] feat(webdav): implement HTTP PATCH for partial content updates (RFC 5789) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RFC 4918 §9.7.1 forbids partial updates on PUT; this adds PATCH as the supported mechanism instead, via an X-Update-Range header (bytes=- or append). Reuses the existing CAS/dedup pipeline by splicing the request body between the file's untouched prefix/suffix byte ranges and re-ingesting as one continuous stream, so unedited chunks dedup for free. --- src/interfaces/api/handlers/webdav_handler.rs | 325 +++++++++++++++++- src/interfaces/upload_ingest.rs | 31 ++ tests/api/webdav_patch.hurl | 226 ++++++++++++ 3 files changed, 581 insertions(+), 1 deletion(-) create mode 100644 tests/api/webdav_patch.hurl diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 550ba15c..4b8d546e 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -14,7 +14,9 @@ use axum::{ }; use bytes::{Buf, Bytes}; use chrono::Utc; +use futures::stream::{self, Stream}; use quick_xml::Writer; +use std::pin::Pin; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ @@ -408,6 +410,7 @@ async fn handle_webdav_dispatch( "GET" => handle_get(state, req, path).await, "HEAD" => handle_head(state, req, path).await, "PUT" => handle_put(state, req, path).await, + "PATCH" => handle_patch(state, req, path).await, "MKCOL" => handle_mkcol(state, req, path).await, "DELETE" => handle_delete(state, req, path).await, "MOVE" => handle_move(state, req, path).await, @@ -439,7 +442,7 @@ async fn handle_options(_path: String) -> Result, AppError> { .header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support .header( header::ALLOW, - "OPTIONS, GET, HEAD, PUT, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK", + "OPTIONS, GET, HEAD, PUT, PATCH, DELETE, PROPFIND, PROPPATCH, MKCOL, COPY, MOVE, LOCK, UNLOCK", ) .body(Body::empty()) .unwrap()) @@ -1996,6 +1999,285 @@ async fn handle_put( } } +/// Parses the `X-Update-Range` header used by [`handle_patch`] (RFC 5789 +/// partial content updates): either `append`, or `bytes=-` +/// (inclusive, 0-based). For the explicit-range form both bounds must fall +/// strictly within the current file size — growing the file via a byte +/// range isn't supported, use `append` or PUT for that. +/// +/// Returns `(start, end)`; `end` is `None` for `append`. +fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option), AppError> { + let header = header.trim(); + if header.eq_ignore_ascii_case("append") { + return Ok((size, None)); + } + let spec = header.strip_prefix("bytes=").ok_or_else(|| { + AppError::bad_request("X-Update-Range must be 'append' or 'bytes=-'") + })?; + let (start_str, end_str) = spec + .split_once('-') + .ok_or_else(|| AppError::bad_request("X-Update-Range must be 'bytes=-'"))?; + let start: u64 = start_str + .parse() + .map_err(|_| AppError::bad_request("X-Update-Range: invalid start offset"))?; + let end: u64 = end_str + .parse() + .map_err(|_| AppError::bad_request("X-Update-Range: invalid end offset"))?; + if start > end { + return Err(AppError::bad_request( + "X-Update-Range: start must be <= end", + )); + } + if end >= size { + return Err(AppError::new( + StatusCode::RANGE_NOT_SATISFIABLE, + format!("X-Update-Range end {end} is out of bounds for a {size}-byte file"), + "RangeNotSatisfiable", + )); + } + Ok((start, Some(end))) +} + +/** + * Handles PATCH requests (RFC 5789) for partial byte-range content updates. + * + * RFC 4918 §9.7.1 forbids partial content updates on PUT (see the explicit + * `Content-Range` rejection in [`handle_put`]); PATCH is the mechanism this + * server offers instead, via the `X-Update-Range` header (see + * [`parse_update_range`]). + * + * The new content is assembled by splicing the request body between the + * file's untouched prefix/suffix byte ranges and re-ingesting the result as + * one continuous stream through the same content-addressable pipeline PUT + * uses ([`upload_ingest::ingest_range_patch_to_cas`]) — unedited chunks on + * either side of the edit typically dedup for free. + * + * @param state The application state containing service dependencies + * @param req The HTTP request containing the partial content and + * `X-Update-Range` header + * @param path The requested resource path + * @return HTTP response: 204 with `Content-Range`/`ETag` on success + */ +async fn handle_patch( + state: Arc, + req: Request, + path: String, +) -> Result, AppError> { + use crate::interfaces::upload_ingest; + + let user = extract_user(&req)?; + let file_upload_service = &state.applications.file_upload_service; + let file_retrieval_service = &state.applications.file_retrieval_service; + + if path.is_empty() || path == "/" { + return Err(AppError::bad_request("Cannot PATCH the root folder")); + } + + // RFC 5789 doesn't define Content-Range semantics; this server uses a + // dedicated `X-Update-Range` header instead (see `parse_update_range`) + // to avoid ambiguity with HTTP Range-Request semantics. + if req.headers().contains_key(header::CONTENT_RANGE) { + return Err(AppError::bad_request( + "PATCH must not use Content-Range; use the X-Update-Range header instead", + )); + } + + let update_range_header = req + .headers() + .get("X-Update-Range") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .ok_or_else(|| AppError::bad_request("PATCH requires an X-Update-Range header"))?; + + // Extract all headers before consuming `req` into the body stream. + let if_header_owned = req + .headers() + .get("If") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()); + let if_none_match = req + .headers() + .get(header::IF_NONE_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()); + let if_match = req + .headers() + .get(header::IF_MATCH) + .and_then(|v| v.to_str().ok()) + .map(|s| s.trim().to_string()); + let content_length = req + .headers() + .get(header::CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + let content_type = req + .headers() + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("application/octet-stream") + .to_string(); + let max_upload = state.core.config.storage.direct_put_max_bytes; + + let scope = resolve_webdav_scope_or_405(&state, user.id, &path).await?; + let drive_id = scope.drive_id; + let path = scope.db_path; + + // ── Existence check ─────────────────────────────────────────────── + // Unlike PUT, PATCH requires an existing file — a partial update of + // nothing isn't meaningful. Resolver is drive-scoped, not + // owner-scoped (see `handle_put`'s identical comment), so the + // explicit `authz.require(Read, …)` below is the defence-in-depth + // existence-proof before any field of `file` is trusted. + let resolver = state.path_resolver.as_ref().ok_or_else(|| { + AppError::method_not_allowed("PATCH requires WebDAV path resolver support") + })?; + let file = match resolver.resolve_path_in_drive(&path, drive_id).await { + Ok(ResolvedResource::File(f)) => f, + Ok(ResolvedResource::Folder(_)) => { + return Err(AppError::conflict("Cannot PATCH a directory")); + } + Err(_) => return Err(AppError::not_found(format!("File not found: {}", path))), + }; + let file_uuid = Uuid::parse_str(&file.id) + .map_err(|_| AppError::not_found(format!("File not found: {}", path)))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::File(file_uuid), + ) + .await?; + + // ── Active-lock guard + RFC 4918 §10.4 If: evaluation ───────────── + if let Some(resp) = enforce_native_lock( + &state.webdav_lock_store, + if_header_owned.as_deref(), + &path, + Some(&file.etag), + ) { + return Ok(resp); + } + + // ── RFC 7232 conditional preconditions ──────────────────────────── + if let Some(ref inm) = if_none_match { + let server_tag = file.etag.trim_matches('"'); + if inm == "*" || inm.trim_matches('"') == server_tag { + return Err(AppError::precondition_failed( + "If-None-Match — resource already exists with that ETag", + )); + } + } + if let Some(ref im) = if_match + && im != "*" + { + let client_tag = im.trim_matches('"'); + let server_tag = file.etag.trim_matches('"'); + if client_tag != server_tag { + return Err(AppError::precondition_failed("If-Match — ETag mismatch")); + } + } + + // ── Range parsing + validation ───────────────────────────────────── + let (start, end) = parse_update_range(&update_range_header, file.size)?; + if let (Some(end), Some(len)) = (end, content_length) { + let expected = end - start + 1; + if len != expected { + return Err(AppError::bad_request(format!( + "Content-Length {len} does not match X-Update-Range span {expected}" + ))); + } + } + + // ── Splice prefix/suffix around the patched span ─────────────────── + let prefix_stream: Pin> + Send>> = + if start == 0 { + Box::pin(stream::empty()) + } else { + Box::into_pin( + file_retrieval_service + .get_file_range_stream_with_perms(&file.id, user.id, 0, Some(start)) + .await + .map_err(AppError::from)?, + ) + }; + let suffix_stream: Pin> + Send>> = match end + { + Some(end) if end + 1 < file.size => Box::into_pin( + file_retrieval_service + .get_file_range_stream_with_perms(&file.id, user.id, end + 1, None) + .await + .map_err(AppError::from)?, + ), + _ => Box::pin(stream::empty()), + }; + + let filename = crate::common::mime_detect::filename_from_path(&path).to_string(); + let ingested = upload_ingest::ingest_range_patch_to_cas( + prefix_stream, + req.into_body(), + suffix_stream, + &state.core.dedup_service, + &filename, + &content_type, + max_upload, + ) + .await?; + + // ── Quota enforcement ───────────────────────────────────────────── + if let Some(storage_svc) = state.storage_usage_service.as_ref() + && let Err(err) = storage_svc + .check_storage_quota(user.id, ingested.size) + .await + { + upload_ingest::discard_ingested(&state.core.dedup_service, &ingested).await; + tracing::warn!( + "⛔ WEBDAV PATCH REJECTED (quota): user={}, file={}, size={}", + user.id, + path, + ingested.size + ); + return Err(AppError::new( + StatusCode::INSUFFICIENT_STORAGE, + err.message, + "QuotaExceeded", + )); + } + + // ── Atomic store ────────────────────────────────────────────────── + let new_size = ingested.size; + let content_type = ingested.content_type.clone(); + let result = file_upload_service + .update_file_streaming_with_perms( + &path, + drive_id, + ingested.stored(), + &content_type, + None, + user.id, + ) + .await; + + match result { + Ok(file_dto) => { + // Everything from `start` to the new EOF reflects the patch + // (the untouched suffix, if any, may have shifted when the + // body's length differs from the replaced span). + let range_end = new_size.saturating_sub(1); + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::ETAG, &file_dto.etag) + .header( + header::CONTENT_RANGE, + format!("bytes {}-{}/{}", start, range_end, new_size), + ) + .body(Body::empty()) + .unwrap()) + } + Err(e) => Err(AppError::from(e)), + } +} + /** * Handles MKCOL requests to create folders. * @@ -3383,4 +3665,45 @@ mod tests { "/webdav/My%20Photos/2024/" ); } + + // ── RFC 5789 PATCH: `X-Update-Range` parsing ──────────────────── + + #[test] + fn parse_update_range_append() { + assert_eq!(parse_update_range("append", 100).unwrap(), (100, None)); + assert_eq!(parse_update_range("APPEND", 0).unwrap(), (0, None)); + } + + #[test] + fn parse_update_range_explicit_span() { + assert_eq!(parse_update_range("bytes=5-9", 100).unwrap(), (5, Some(9))); + // Single-byte span at offset 0. + assert_eq!(parse_update_range("bytes=0-0", 1).unwrap(), (0, Some(0))); + } + + #[test] + fn parse_update_range_rejects_missing_prefix() { + assert!(parse_update_range("5-9", 100).is_err()); + } + + #[test] + fn parse_update_range_rejects_malformed_bounds() { + assert!(parse_update_range("bytes=abc-9", 100).is_err()); + assert!(parse_update_range("bytes=5-abc", 100).is_err()); + assert!(parse_update_range("bytes=9", 100).is_err()); + } + + #[test] + fn parse_update_range_rejects_start_after_end() { + assert!(parse_update_range("bytes=9-5", 100).is_err()); + } + + #[test] + fn parse_update_range_rejects_end_at_or_past_size() { + // `end` must be strictly within the current file — growing the + // file via a byte-range PATCH isn't supported (use `append`). + let err = parse_update_range("bytes=5-9", 9).unwrap_err(); + assert_eq!(err.status_code, StatusCode::RANGE_NOT_SATISFIABLE); + assert!(parse_update_range("bytes=0-0", 0).is_err()); + } } diff --git a/src/interfaces/upload_ingest.rs b/src/interfaces/upload_ingest.rs index e440a99d..edf79933 100644 --- a/src/interfaces/upload_ingest.rs +++ b/src/interfaces/upload_ingest.rs @@ -13,6 +13,7 @@ //! detection before being forwarded unchanged. use std::path::{Path, PathBuf}; +use std::pin::Pin; use std::sync::Arc; use std::sync::Mutex as StdMutex; use std::sync::atomic::{AtomicBool, Ordering}; @@ -249,6 +250,36 @@ pub async fn ingest_body_to_cas( ingest_stream_to_cas(source, dedup, filename, claimed_type, max_bytes, None).await } +/// Splice a PATCH request body ([RFC 5789]) between the file's untouched +/// `prefix`/`suffix` byte ranges and ingest the result as one continuous +/// stream into the CDC chunk store. +/// +/// `prefix`/`suffix` are `stream::empty()`-backed when the edit starts at +/// byte 0 or reaches EOF respectively — callers build the real ranges from +/// [`FileRetrievalUseCase::get_file_range_stream_with_perms`](crate::application::ports::file_ports::FileRetrievalUseCase::get_file_range_stream_with_perms). +/// Because FastCDC chunking is content-defined rather than offset-defined, +/// unedited chunks on either side of the edit typically dedup for free. +/// +/// [RFC 5789]: https://www.rfc-editor.org/rfc/rfc5789 +pub async fn ingest_range_patch_to_cas( + prefix: Pin> + Send>>, + body: Body, + suffix: Pin> + Send>>, + dedup: &Arc, + filename: &str, + claimed_type: &str, + max_bytes: usize, +) -> Result { + let body_stream = BodyStream::new(body).filter_map(|item| async move { + match item { + Ok(frame) => frame.into_data().ok().map(Ok), + Err(e) => Some(Err(std::io::Error::other(e.to_string()))), + } + }); + let combined = prefix.chain(body_stream).chain(suffix); + ingest_stream_to_cas(combined, dedup, filename, claimed_type, max_bytes, None).await +} + /// Adapt a multipart field into a byte stream for [`ingest_stream_to_cas`]. /// /// Terminates after the first error — multipart fields are not resumable. diff --git a/tests/api/webdav_patch.hurl b/tests/api/webdav_patch.hurl new file mode 100644 index 00000000..c61fd49d --- /dev/null +++ b/tests/api/webdav_patch.hurl @@ -0,0 +1,226 @@ +# ============================================================= +# OxiCloud — WebDAV PATCH (RFC 5789) partial content update +# ============================================================= +# RFC 4918 §9.7.1 forbids partial updates on PUT (a `Content-Range` +# on PUT is rejected, see webdav_handler.rs::handle_put). PATCH is +# the mechanism this server offers instead, via a dedicated +# `X-Update-Range` header: `bytes=-` (inclusive) or +# `append`. See webdav_handler.rs::handle_patch / +# parse_update_range for the implementation. +# +# Coverage: +# 1. Mid-file byte-range overwrite → 204, GET reflects the splice. +# 2. Append → 204, GET reflects the appended tail. +# 3. Out-of-range span (end >= size) → 416. +# 4. If-Match precondition failure → 412. +# 5. Locked resource without a lock token → 423. +# 6. PATCH on a directory → 409. +# 7. PATCH on a missing resource → 404. +# 8. PATCH without X-Update-Range → 400. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a 10-byte probe file: "0123456789" +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +0123456789 +``` + +HTTP 201 +[Captures] +probe_etag: header "ETag" + + +# ───────────────────────────────────────────────────────────── +# Step 3 — Mid-file overwrite: replace bytes 3-5 (inclusive, +# 0-based) with "XYZ". +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +X-Update-Range: bytes=3-5 +Content-Type: text/plain +``` +XYZ +``` + +HTTP 204 +[Asserts] +header "Content-Range" matches "^bytes 3-\\d+/\\d+$" + + +GET {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +body startsWith "012XYZ" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — Append to the end of the file. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +X-Update-Range: append +Content-Type: text/plain +``` +-APPENDED +``` + +HTTP 204 + + +GET {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} + +HTTP 200 +[Asserts] +body endsWith "-APPENDED" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Out-of-range span: `end` must be strictly within the +# current file size (growing via a byte-range PATCH +# isn't supported — use `append` for that). +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +X-Update-Range: bytes=1000-1005 +Content-Type: text/plain +``` +oops +``` + +HTTP 416 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — If-Match precondition failure. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +X-Update-Range: bytes=0-2 +If-Match: "not-the-real-etag" +Content-Type: text/plain +``` +NOP +``` + +HTTP 412 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Locked resource without a matching lock token. +# ───────────────────────────────────────────────────────────── +LOCK {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + patch-test + +``` + +HTTP 200 +[Captures] +lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])" + + +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +X-Update-Range: bytes=0-2 +Content-Type: text/plain +``` +NOP +``` + +HTTP 423 + + +# Release the lock so cleanup below can proceed. +UNLOCK {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +Lock-Token: <{{lock_token}}> + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — PATCH on a directory → 409 Conflict. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/webdav/patch-probe-dir/ +Authorization: Bearer {{token}} + +HTTP 201 + + +PATCH {{base_url}}/webdav/patch-probe-dir/ +Authorization: Bearer {{token}} +X-Update-Range: bytes=0-2 +Content-Type: text/plain +``` +NOP +``` + +HTTP 409 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — PATCH on a missing resource → 404. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe-does-not-exist.txt +Authorization: Bearer {{token}} +X-Update-Range: bytes=0-2 +Content-Type: text/plain +``` +NOP +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — PATCH without X-Update-Range → 400. +# ───────────────────────────────────────────────────────────── +PATCH {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +NOP +``` + +HTTP 400 + + +# ───────────────────────────────────────────────────────────── +# Cleanup +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/webdav/patch-probe.txt +Authorization: Bearer {{token}} + +HTTP 204 + + +DELETE {{base_url}}/webdav/patch-probe-dir/ +Authorization: Bearer {{token}} + +HTTP 204