Merge pull request #596 from swissiety/rfc-5789-http-patch
This commit is contained in:
@@ -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::{
|
||||
@@ -29,6 +31,7 @@ use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUse
|
||||
use crate::application::ports::folder_ports::FolderUseCase;
|
||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
||||
use crate::application::services::file_upload_service::FileUploadService;
|
||||
use crate::application::services::folder_service::FolderService;
|
||||
use crate::common::di::AppState;
|
||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||
@@ -38,6 +41,7 @@ use crate::infrastructure::services::webdav_dead_property_store::{DeadPropertySt
|
||||
use crate::interfaces::errors::AppError;
|
||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||
use crate::interfaces::upload_ingest::{IngestedBlob, RangeSegment, discard_ingested};
|
||||
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
@@ -408,6 +412,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 +444,7 @@ async fn handle_options(_path: String) -> Result<Response<Body>, 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())
|
||||
@@ -1655,7 +1660,7 @@ fn evaluate_if_header(
|
||||
///
|
||||
/// Shared by `handle_put`, `handle_delete`, `handle_move`,
|
||||
/// `handle_copy`, and `handle_proppatch`.
|
||||
fn enforce_native_lock(
|
||||
pub(crate) fn enforce_native_lock(
|
||||
lock_store: &crate::infrastructure::services::webdav_lock_service::WebDavLockStore,
|
||||
if_header: Option<&str>,
|
||||
path: &str,
|
||||
@@ -1891,39 +1896,20 @@ async fn handle_put(
|
||||
|
||||
// ── RFC 7232 conditional preconditions ────────────────────────────
|
||||
// Evaluated before ingesting the body to save bandwidth on doomed requests.
|
||||
if let Some(ref inm) = if_none_match {
|
||||
// If-None-Match: * → fail if resource exists (prevent overwrite)
|
||||
if inm == "*" && file_existed {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-None-Match: * — resource already exists",
|
||||
));
|
||||
}
|
||||
// Shared with `handle_patch` (both surfaces) — handles comma-separated
|
||||
// multi-value lists and the weak/strong distinction the previous
|
||||
// hand-rolled single-tag comparison here didn't.
|
||||
if let Some(ref value) = if_none_match
|
||||
&& if_none_match_precondition_fails(value, current_etag.as_deref())
|
||||
{
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-None-Match — resource already exists with that ETag",
|
||||
));
|
||||
}
|
||||
if let Some(ref im) = if_match {
|
||||
if im == "*" {
|
||||
// If-Match: * → fail if resource does not exist
|
||||
if !file_existed {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-Match: * — resource does not exist",
|
||||
));
|
||||
}
|
||||
} else {
|
||||
// If-Match: <etag> → strong comparison against current ETag
|
||||
match ¤t_etag {
|
||||
None => {
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-Match — resource does not exist",
|
||||
));
|
||||
}
|
||||
Some(etag) => {
|
||||
let client_tag = im.trim_matches('"');
|
||||
let server_tag = etag.trim_matches('"');
|
||||
if client_tag != server_tag {
|
||||
return Err(AppError::precondition_failed("If-Match — ETag mismatch"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(ref value) = if_match
|
||||
&& if_match_precondition_fails(value, current_etag.as_deref())
|
||||
{
|
||||
return Err(AppError::precondition_failed("If-Match — ETag mismatch"));
|
||||
}
|
||||
|
||||
// ── Streaming ingest ──────────────────────────────────────────────
|
||||
@@ -1971,6 +1957,7 @@ async fn handle_put(
|
||||
&content_type,
|
||||
None,
|
||||
user.id,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
@@ -1986,7 +1973,7 @@ async fn handle_put(
|
||||
};
|
||||
Ok(Response::builder()
|
||||
.status(status)
|
||||
.header(header::ETAG, &file_dto.etag)
|
||||
.header(header::ETAG, format!("\"{}\"", file_dto.etag))
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
@@ -1998,6 +1985,412 @@ async fn handle_put(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses the `X-Update-Range` header used by [`handle_patch`] (RFC 5789
|
||||
/// partial content updates): either `append`, or `bytes=<start>-<end>`
|
||||
/// (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`.
|
||||
///
|
||||
/// `pub(crate)` so the NextCloud-surface PATCH handler
|
||||
/// (`nextcloud/webdav_handler.rs::handle_patch`) can reuse it instead of
|
||||
/// duplicating the parsing logic.
|
||||
pub(crate) fn parse_update_range(header: &str, size: u64) -> Result<(u64, Option<u64>), 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=<start>-<end>'")
|
||||
})?;
|
||||
let (start_str, end_str) = spec
|
||||
.split_once('-')
|
||||
.ok_or_else(|| AppError::bad_request("X-Update-Range must be 'bytes=<start>-<end>'"))?;
|
||||
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)))
|
||||
}
|
||||
|
||||
/// Strip the optional `W/` weak prefix and surrounding double-quotes
|
||||
/// from one ETag value in an `If-Match` / `If-None-Match` list. Returns
|
||||
/// `(is_weak, inner)`.
|
||||
fn parse_etag_value(raw: &str) -> (bool, &str) {
|
||||
let trimmed = raw.trim();
|
||||
if let Some(rest) = trimmed.strip_prefix("W/") {
|
||||
(true, rest.trim().trim_matches('"'))
|
||||
} else {
|
||||
(false, trimmed.trim_matches('"'))
|
||||
}
|
||||
}
|
||||
|
||||
/// RFC 7232 §3.2 — `If-None-Match` fails when:
|
||||
/// - the header value is `*` and a current representation exists, OR
|
||||
/// - any listed ETag matches the current representation (weak comparison
|
||||
/// — weak validators in the request are equivalent to strong for the
|
||||
/// match itself, only If-Match is required to be strong).
|
||||
///
|
||||
/// `pub(crate)` so both the plain and NextCloud-surface WebDAV handlers
|
||||
/// share one RFC 7232-conformant implementation instead of each
|
||||
/// reimplementing ETag comparison (multi-value lists, `W/` weak prefix).
|
||||
pub(crate) fn if_none_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
|
||||
let v = header.trim();
|
||||
if v == "*" {
|
||||
return current_etag.is_some();
|
||||
}
|
||||
let Some(current) = current_etag else {
|
||||
return false;
|
||||
};
|
||||
v.split(',').any(|tag| {
|
||||
let (_, parsed) = parse_etag_value(tag);
|
||||
!parsed.is_empty() && parsed == current
|
||||
})
|
||||
}
|
||||
|
||||
/// RFC 7232 §3.1 — `If-Match` fails when:
|
||||
/// - the resource doesn't currently exist (no strong validator to match), OR
|
||||
/// - the header isn't `*` and no listed ETag strong-matches the current one
|
||||
/// (weak validators in the request never satisfy a strong-match).
|
||||
pub(crate) fn if_match_precondition_fails(header: &str, current_etag: Option<&str>) -> bool {
|
||||
let v = header.trim();
|
||||
let Some(current) = current_etag else {
|
||||
return true;
|
||||
};
|
||||
if v == "*" {
|
||||
return false;
|
||||
}
|
||||
!v.split(',').any(|tag| {
|
||||
let (is_weak, parsed) = parse_etag_value(tag);
|
||||
!is_weak && !parsed.is_empty() && parsed == current
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the untouched prefix/suffix byte-range streams either side of a
|
||||
/// PATCH edit, paired with their known lengths (`upload_ingest::RangeSegment`)
|
||||
/// ready to hand to `ingest_range_patch_to_cas`.
|
||||
///
|
||||
/// `pub(crate)` so both the plain and NextCloud-surface PATCH handlers share
|
||||
/// one implementation instead of each re-deriving the same offsets — this was
|
||||
/// byte-identical duplicated code before the DRY pass that added this fn.
|
||||
pub(crate) async fn splice_patch_streams(
|
||||
file_retrieval: &FileRetrievalService,
|
||||
file_id: &str,
|
||||
caller_id: Uuid,
|
||||
start: u64,
|
||||
end: Option<u64>,
|
||||
file_size: u64,
|
||||
) -> Result<(RangeSegment, RangeSegment), AppError> {
|
||||
let prefix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
||||
if start == 0 {
|
||||
Box::pin(stream::empty())
|
||||
} else {
|
||||
Box::into_pin(
|
||||
file_retrieval
|
||||
.get_file_range_stream_with_perms(file_id, caller_id, 0, Some(start))
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
)
|
||||
};
|
||||
let suffix_len = match end {
|
||||
Some(end) if end + 1 < file_size => file_size - (end + 1),
|
||||
_ => 0,
|
||||
};
|
||||
let suffix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> = match end
|
||||
{
|
||||
Some(end) if end + 1 < file_size => Box::into_pin(
|
||||
file_retrieval
|
||||
.get_file_range_stream_with_perms(file_id, caller_id, end + 1, None)
|
||||
.await
|
||||
.map_err(AppError::from)?,
|
||||
),
|
||||
_ => Box::pin(stream::empty()),
|
||||
};
|
||||
Ok(((prefix_stream, start), (suffix_stream, suffix_len)))
|
||||
}
|
||||
|
||||
/// Quota-check + compare-and-swap write for a PATCH edit already spliced and
|
||||
/// ingested into the chunk store (`ingested`). On quota rejection the blob
|
||||
/// reference just taken by ingest is released and `QuotaExceeded` (507) is
|
||||
/// returned; on success this is the CAS write keyed on `expected_hash` (the
|
||||
/// file's pre-splice content hash) that closes the race between two
|
||||
/// concurrent PATCHes to disjoint ranges of the same file (see
|
||||
/// `FileBlobWritePort::swap_blob_hash`).
|
||||
///
|
||||
/// `pub(crate)` — shared by the plain and NextCloud-surface PATCH handlers;
|
||||
/// `log_prefix` lets each surface keep its own log-line tag (`"WEBDAV PATCH"`
|
||||
/// vs `"NC WEBDAV PATCH"`) for grep-ability.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) async fn cas_write_patch(
|
||||
state: &AppState,
|
||||
upload_service: &FileUploadService,
|
||||
path: &str,
|
||||
drive_id: Uuid,
|
||||
ingested: &IngestedBlob,
|
||||
caller_id: Uuid,
|
||||
expected_hash: &str,
|
||||
log_prefix: &str,
|
||||
) -> Result<FileDto, AppError> {
|
||||
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
||||
&& let Err(err) = storage_svc
|
||||
.check_storage_quota(caller_id, ingested.size)
|
||||
.await
|
||||
{
|
||||
discard_ingested(&state.core.dedup_service, ingested).await;
|
||||
tracing::warn!(
|
||||
"⛔ {} REJECTED (quota): user={}, file={}, size={}",
|
||||
log_prefix,
|
||||
caller_id,
|
||||
path,
|
||||
ingested.size
|
||||
);
|
||||
return Err(AppError::new(
|
||||
StatusCode::INSUFFICIENT_STORAGE,
|
||||
err.message,
|
||||
"QuotaExceeded",
|
||||
));
|
||||
}
|
||||
|
||||
let content_type = ingested.content_type.clone();
|
||||
upload_service
|
||||
.update_file_streaming_with_perms(
|
||||
path,
|
||||
drive_id,
|
||||
ingested.stored(),
|
||||
&content_type,
|
||||
None,
|
||||
caller_id,
|
||||
Some(expected_hash),
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::from)
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<AppState>,
|
||||
req: Request<Body>,
|
||||
path: String,
|
||||
) -> Result<Response<Body>, 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::<u64>().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 ────────────────────────────
|
||||
let current_etag = Some(file.etag.as_str());
|
||||
if let Some(ref value) = if_none_match
|
||||
&& if_none_match_precondition_fails(value, current_etag)
|
||||
{
|
||||
return Err(AppError::precondition_failed(
|
||||
"If-None-Match — resource already exists with that ETag",
|
||||
));
|
||||
}
|
||||
if let Some(ref value) = if_match
|
||||
&& if_match_precondition_fails(value, current_etag)
|
||||
{
|
||||
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_segment, suffix_segment) = splice_patch_streams(
|
||||
file_retrieval_service,
|
||||
&file.id,
|
||||
user.id,
|
||||
start,
|
||||
end,
|
||||
file.size,
|
||||
)
|
||||
.await?;
|
||||
let filename = crate::common::mime_detect::filename_from_path(&path).to_string();
|
||||
let ingested = upload_ingest::ingest_range_patch_to_cas(
|
||||
prefix_segment,
|
||||
req.into_body(),
|
||||
suffix_segment,
|
||||
&state.core.dedup_service,
|
||||
&filename,
|
||||
&content_type,
|
||||
upload_ingest::PatchIngestBudget {
|
||||
max_bytes: max_upload,
|
||||
expected_body_len: end.map(|end| end - start + 1),
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
// ── Quota enforcement + atomic store, compare-and-swap on the
|
||||
// pre-splice content hash ─────────────────────────────────────────
|
||||
// `file.content_hash` was snapshotted before the (potentially slow)
|
||||
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
||||
// write itself a compare-and-swap: the repository checks and applies
|
||||
// under the same row lock, so nothing else can write to this file
|
||||
// between the check and the write. This is what actually closes the
|
||||
// race two concurrent PATCHes to disjoint ranges could otherwise hit
|
||||
// — each individually passing its own If-Match check against the
|
||||
// same stale snapshot, then blindly overwriting each other.
|
||||
let new_size = ingested.size;
|
||||
let file_dto = cas_write_patch(
|
||||
&state,
|
||||
file_upload_service,
|
||||
&path,
|
||||
drive_id,
|
||||
&ingested,
|
||||
user.id,
|
||||
&file.content_hash,
|
||||
"WEBDAV PATCH",
|
||||
)
|
||||
.await?;
|
||||
|
||||
// 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, format!("\"{}\"", file_dto.etag))
|
||||
.header(
|
||||
header::CONTENT_RANGE,
|
||||
format!("bytes {}-{}/{}", start, range_end, new_size),
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap())
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles MKCOL requests to create folders.
|
||||
*
|
||||
@@ -3385,4 +3778,97 @@ 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());
|
||||
}
|
||||
|
||||
// ── RFC 7232 If-Match / If-None-Match — multi-value lists ───────
|
||||
//
|
||||
// Regression coverage for `handle_put`'s hand-rolled precondition
|
||||
// check, which only ever compared the header as a single tag and
|
||||
// never split on commas — a client sending the standard
|
||||
// comma-separated multi-value form would silently mismatch even
|
||||
// when one of the listed ETags matched. Both handlers now share
|
||||
// `if_none_match_precondition_fails`/`if_match_precondition_fails`,
|
||||
// which already handled this correctly for `handle_patch`.
|
||||
|
||||
#[test]
|
||||
fn if_none_match_multi_value_list_matches_second_tag() {
|
||||
assert!(if_none_match_precondition_fails(
|
||||
r#""aaa", "bbb", "ccc""#,
|
||||
Some("bbb")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_none_match_multi_value_list_no_match_passes() {
|
||||
assert!(!if_none_match_precondition_fails(
|
||||
r#""aaa", "bbb", "ccc""#,
|
||||
Some("zzz")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_match_multi_value_list_matches_last_tag() {
|
||||
assert!(!if_match_precondition_fails(
|
||||
r#""aaa", "bbb", "ccc""#,
|
||||
Some("ccc")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_match_multi_value_list_no_match_fails() {
|
||||
assert!(if_match_precondition_fails(
|
||||
r#""aaa", "bbb", "ccc""#,
|
||||
Some("zzz")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn if_match_weak_tag_in_list_never_satisfies() {
|
||||
// If-Match requires a strong comparison — a weak validator in the
|
||||
// list must not satisfy it even if the underlying tag matches.
|
||||
assert!(if_match_precondition_fails(
|
||||
r#"W/"aaa", "bbb""#,
|
||||
Some("aaa")
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +413,7 @@ async fn put_file(
|
||||
&content_type,
|
||||
None,
|
||||
claims_sub_uuid,
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user