feat(webdav): implement HTTP PATCH for partial content updates (RFC 5789)
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=<start>-<end> 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.
This commit is contained in:
@@ -14,7 +14,9 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::{Buf, Bytes};
|
use bytes::{Buf, Bytes};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use futures::stream::{self, Stream};
|
||||||
use quick_xml::Writer;
|
use quick_xml::Writer;
|
||||||
|
use std::pin::Pin;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::application::adapters::webdav_adapter::{
|
use crate::application::adapters::webdav_adapter::{
|
||||||
@@ -408,6 +410,7 @@ async fn handle_webdav_dispatch(
|
|||||||
"GET" => handle_get(state, req, path).await,
|
"GET" => handle_get(state, req, path).await,
|
||||||
"HEAD" => handle_head(state, req, path).await,
|
"HEAD" => handle_head(state, req, path).await,
|
||||||
"PUT" => handle_put(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,
|
"MKCOL" => handle_mkcol(state, req, path).await,
|
||||||
"DELETE" => handle_delete(state, req, path).await,
|
"DELETE" => handle_delete(state, req, path).await,
|
||||||
"MOVE" => handle_move(state, req, path).await,
|
"MOVE" => handle_move(state, req, path).await,
|
||||||
@@ -439,7 +442,7 @@ async fn handle_options(_path: String) -> Result<Response<Body>, AppError> {
|
|||||||
.header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support
|
.header(HEADER_DAV, "1, 2") // Class 1 and 2 WebDAV support
|
||||||
.header(
|
.header(
|
||||||
header::ALLOW,
|
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())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.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=<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`.
|
||||||
|
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)))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 ────────────────────────────
|
||||||
|
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + 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.
|
* Handles MKCOL requests to create folders.
|
||||||
*
|
*
|
||||||
@@ -3383,4 +3665,45 @@ mod tests {
|
|||||||
"/webdav/My%20Photos/2024/"
|
"/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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
//! detection before being forwarded unchanged.
|
//! detection before being forwarded unchanged.
|
||||||
|
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::pin::Pin;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::sync::Mutex as StdMutex;
|
use std::sync::Mutex as StdMutex;
|
||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
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
|
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<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||||
|
body: Body,
|
||||||
|
suffix: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>>,
|
||||||
|
dedup: &Arc<DedupService>,
|
||||||
|
filename: &str,
|
||||||
|
claimed_type: &str,
|
||||||
|
max_bytes: usize,
|
||||||
|
) -> Result<IngestedBlob, AppError> {
|
||||||
|
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`].
|
/// Adapt a multipart field into a byte stream for [`ingest_stream_to_cas`].
|
||||||
///
|
///
|
||||||
/// Terminates after the first error — multipart fields are not resumable.
|
/// Terminates after the first error — multipart fields are not resumable.
|
||||||
|
|||||||
@@ -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=<start>-<end>` (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
|
||||||
|
```
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:lockinfo xmlns:D="DAV:">
|
||||||
|
<D:lockscope><D:exclusive/></D:lockscope>
|
||||||
|
<D:locktype><D:write/></D:locktype>
|
||||||
|
<D:owner>patch-test</D:owner>
|
||||||
|
</D:lockinfo>
|
||||||
|
```
|
||||||
|
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user