fix error mapping for put; cross surface interop ressource locking; put quota leftovers;
This commit is contained in:
@@ -31,6 +31,7 @@ use crate::application::ports::file_ports::{FileManagementUseCase, FileUploadUse
|
|||||||
use crate::application::ports::folder_ports::FolderUseCase;
|
use crate::application::ports::folder_ports::FolderUseCase;
|
||||||
use crate::application::ports::storage_ports::StorageUsagePort;
|
use crate::application::ports::storage_ports::StorageUsagePort;
|
||||||
use crate::application::services::file_retrieval_service::FileRetrievalService;
|
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::application::services::folder_service::FolderService;
|
||||||
use crate::common::di::AppState;
|
use crate::common::di::AppState;
|
||||||
use crate::domain::repositories::drive_repository::DriveRepository;
|
use crate::domain::repositories::drive_repository::DriveRepository;
|
||||||
@@ -40,6 +41,7 @@ use crate::infrastructure::services::webdav_dead_property_store::{DeadPropertySt
|
|||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
use crate::interfaces::middleware::auth::{AuthUser, CurrentUser};
|
||||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
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 percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -1892,40 +1894,21 @@ async fn handle_put(
|
|||||||
|
|
||||||
// ── RFC 7232 conditional preconditions ────────────────────────────
|
// ── RFC 7232 conditional preconditions ────────────────────────────
|
||||||
// Evaluated before ingesting the body to save bandwidth on doomed requests.
|
// Evaluated before ingesting the body to save bandwidth on doomed requests.
|
||||||
if let Some(ref inm) = if_none_match {
|
// Shared with `handle_patch` (both surfaces) — handles comma-separated
|
||||||
// If-None-Match: * → fail if resource exists (prevent overwrite)
|
// multi-value lists and the weak/strong distinction the previous
|
||||||
if inm == "*" && file_existed {
|
// 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(
|
return Err(AppError::precondition_failed(
|
||||||
"If-None-Match: * — resource already exists",
|
"If-None-Match — resource already exists with that ETag",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
if let Some(ref value) = if_match
|
||||||
if let Some(ref im) = if_match {
|
&& if_match_precondition_fails(value, current_etag.as_deref())
|
||||||
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"));
|
return Err(AppError::precondition_failed("If-Match — ETag mismatch"));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Streaming ingest ──────────────────────────────────────────────
|
// ── Streaming ingest ──────────────────────────────────────────────
|
||||||
let filename = crate::common::mime_detect::filename_from_path(&path).to_string();
|
let filename = crate::common::mime_detect::filename_from_path(&path).to_string();
|
||||||
@@ -1988,7 +1971,7 @@ async fn handle_put(
|
|||||||
};
|
};
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(status)
|
.status(status)
|
||||||
.header(header::ETAG, &file_dto.etag)
|
.header(header::ETAG, format!("\"{}\"", file_dto.etag))
|
||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
@@ -2096,6 +2079,106 @@ pub(crate) fn if_match_precondition_fails(header: &str, current_etag: Option<&st
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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.
|
* Handles PATCH requests (RFC 5789) for partial byte-range content updates.
|
||||||
*
|
*
|
||||||
@@ -2244,36 +2327,20 @@ async fn handle_patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Splice prefix/suffix around the patched span ───────────────────
|
// ── Splice prefix/suffix around the patched span ───────────────────
|
||||||
let prefix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
let (prefix_segment, suffix_segment) = splice_patch_streams(
|
||||||
if start == 0 {
|
file_retrieval_service,
|
||||||
Box::pin(stream::empty())
|
&file.id,
|
||||||
} else {
|
user.id,
|
||||||
Box::into_pin(
|
start,
|
||||||
file_retrieval_service
|
end,
|
||||||
.get_file_range_stream_with_perms(&file.id, user.id, 0, Some(start))
|
file.size,
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?,
|
|
||||||
)
|
)
|
||||||
};
|
.await?;
|
||||||
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_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 filename = crate::common::mime_detect::filename_from_path(&path).to_string();
|
||||||
let ingested = upload_ingest::ingest_range_patch_to_cas(
|
let ingested = upload_ingest::ingest_range_patch_to_cas(
|
||||||
(prefix_stream, start),
|
prefix_segment,
|
||||||
req.into_body(),
|
req.into_body(),
|
||||||
(suffix_stream, suffix_len),
|
suffix_segment,
|
||||||
&state.core.dedup_service,
|
&state.core.dedup_service,
|
||||||
&filename,
|
&filename,
|
||||||
&content_type,
|
&content_type,
|
||||||
@@ -2284,27 +2351,8 @@ async fn handle_patch(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// ── Quota enforcement ─────────────────────────────────────────────
|
// ── Quota enforcement + atomic store, compare-and-swap on the
|
||||||
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
// pre-splice content hash ─────────────────────────────────────────
|
||||||
&& 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, compare-and-swap on the pre-splice content hash ──
|
|
||||||
// `file.content_hash` was snapshotted before the (potentially slow)
|
// `file.content_hash` was snapshotted before the (potentially slow)
|
||||||
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
||||||
// write itself a compare-and-swap: the repository checks and applies
|
// write itself a compare-and-swap: the repository checks and applies
|
||||||
@@ -2314,28 +2362,25 @@ async fn handle_patch(
|
|||||||
// — each individually passing its own If-Match check against the
|
// — each individually passing its own If-Match check against the
|
||||||
// same stale snapshot, then blindly overwriting each other.
|
// same stale snapshot, then blindly overwriting each other.
|
||||||
let new_size = ingested.size;
|
let new_size = ingested.size;
|
||||||
let content_type = ingested.content_type.clone();
|
let file_dto = cas_write_patch(
|
||||||
let result = file_upload_service
|
&state,
|
||||||
.update_file_streaming_with_perms(
|
file_upload_service,
|
||||||
&path,
|
&path,
|
||||||
drive_id,
|
drive_id,
|
||||||
ingested.stored(),
|
&ingested,
|
||||||
&content_type,
|
|
||||||
None,
|
|
||||||
user.id,
|
user.id,
|
||||||
Some(&file.content_hash),
|
&file.content_hash,
|
||||||
|
"WEBDAV PATCH",
|
||||||
)
|
)
|
||||||
.await;
|
.await?;
|
||||||
|
|
||||||
match result {
|
|
||||||
Ok(file_dto) => {
|
|
||||||
// Everything from `start` to the new EOF reflects the patch
|
// Everything from `start` to the new EOF reflects the patch
|
||||||
// (the untouched suffix, if any, may have shifted when the
|
// (the untouched suffix, if any, may have shifted when the
|
||||||
// body's length differs from the replaced span).
|
// body's length differs from the replaced span).
|
||||||
let range_end = new_size.saturating_sub(1);
|
let range_end = new_size.saturating_sub(1);
|
||||||
Ok(Response::builder()
|
Ok(Response::builder()
|
||||||
.status(StatusCode::NO_CONTENT)
|
.status(StatusCode::NO_CONTENT)
|
||||||
.header(header::ETAG, &file_dto.etag)
|
.header(header::ETAG, format!("\"{}\"", file_dto.etag))
|
||||||
.header(
|
.header(
|
||||||
header::CONTENT_RANGE,
|
header::CONTENT_RANGE,
|
||||||
format!("bytes {}-{}/{}", start, range_end, new_size),
|
format!("bytes {}-{}/{}", start, range_end, new_size),
|
||||||
@@ -2343,9 +2388,6 @@ async fn handle_patch(
|
|||||||
.body(Body::empty())
|
.body(Body::empty())
|
||||||
.unwrap())
|
.unwrap())
|
||||||
}
|
}
|
||||||
Err(e) => Err(AppError::from(e)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles MKCOL requests to create folders.
|
* Handles MKCOL requests to create folders.
|
||||||
@@ -3775,4 +3817,56 @@ mod tests {
|
|||||||
assert_eq!(err.status_code, StatusCode::RANGE_NOT_SATISFIABLE);
|
assert_eq!(err.status_code, StatusCode::RANGE_NOT_SATISFIABLE);
|
||||||
assert!(parse_update_range("bytes=0-0", 0).is_err());
|
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")
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,13 +5,11 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use bytes::{Buf, Bytes};
|
use bytes::{Buf, Bytes};
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
use futures::stream::{self, Stream};
|
|
||||||
use quick_xml::{
|
use quick_xml::{
|
||||||
Writer,
|
Writer,
|
||||||
events::{BytesEnd, BytesStart, BytesText, Event},
|
events::{BytesEnd, BytesStart, BytesText, Event},
|
||||||
};
|
};
|
||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::pin::Pin;
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
@@ -32,9 +30,9 @@ use crate::domain::services::authorization::{Permission, Resource, Subject};
|
|||||||
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
use crate::infrastructure::services::path_resolver_service::ResolvedResource;
|
||||||
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
use crate::infrastructure::services::webdav_dead_property_store::ResourceRef;
|
||||||
use crate::interfaces::api::handlers::webdav_handler::{
|
use crate::interfaces::api::handlers::webdav_handler::{
|
||||||
PROPFIND_BATCH_SIZE, dead_props_for, enforce_native_lock, file_dead_props, files_dead_props_map, folder_dead_props,
|
PROPFIND_BATCH_SIZE, cas_write_patch, dead_props_for, enforce_native_lock, file_dead_props,
|
||||||
if_match_precondition_fails, if_none_match_precondition_fails, parse_update_range,
|
files_dead_props_map, folder_dead_props, folders_dead_props_map, if_match_precondition_fails,
|
||||||
folders_dead_props_map, streamed_file_dead_props,
|
if_none_match_precondition_fails, parse_update_range, splice_patch_streams,
|
||||||
};
|
};
|
||||||
use crate::interfaces::errors::AppError;
|
use crate::interfaces::errors::AppError;
|
||||||
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
use crate::interfaces::range_requests::{not_modified_response, range_response};
|
||||||
@@ -837,6 +835,13 @@ async fn handle_put(
|
|||||||
.and_then(|v| v.to_str().ok())
|
.and_then(|v| v.to_str().ok())
|
||||||
.and_then(|v| v.parse::<i64>().ok());
|
.and_then(|v| v.parse::<i64>().ok());
|
||||||
|
|
||||||
|
// Extract before consuming `req` into the body stream further down.
|
||||||
|
let if_header = req
|
||||||
|
.headers()
|
||||||
|
.get("If")
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
// ── Conditional preconditions (RFC 7232 §3.1 / §3.2) ─────────────
|
// ── Conditional preconditions (RFC 7232 §3.1 / §3.2) ─────────────
|
||||||
// Evaluated BEFORE body ingestion so a rejected PUT doesn't waste
|
// Evaluated BEFORE body ingestion so a rejected PUT doesn't waste
|
||||||
// bandwidth or disk I/O on a body the server is going to throw away.
|
// bandwidth or disk I/O on a body the server is going to throw away.
|
||||||
@@ -865,6 +870,49 @@ async fn handle_put(
|
|||||||
return Ok(precondition_failed_response());
|
return Ok(precondition_failed_response());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Existence-check depth (RFC 4918 §9.7.1) ───────────────────────
|
||||||
|
// Mirrors the plain WebDAV surface's `handle_put`: PUT to an existing
|
||||||
|
// directory is 400, PUT under a missing parent is 409 (not the generic
|
||||||
|
// 500 a downstream `NotFound` would otherwise surface as).
|
||||||
|
if existing.is_none() {
|
||||||
|
if state
|
||||||
|
.applications
|
||||||
|
.folder_service
|
||||||
|
.get_folder_by_path(&internal_path, chroot.drive_id)
|
||||||
|
.await
|
||||||
|
.is_ok()
|
||||||
|
{
|
||||||
|
return Err(AppError::bad_request("Cannot PUT to a directory"));
|
||||||
|
}
|
||||||
|
let parent_path = internal_path
|
||||||
|
.rfind('/')
|
||||||
|
.map(|i| &internal_path[..i])
|
||||||
|
.unwrap_or("");
|
||||||
|
if !parent_path.is_empty() {
|
||||||
|
state
|
||||||
|
.applications
|
||||||
|
.folder_service
|
||||||
|
.get_folder_by_path(parent_path, chroot.drive_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| {
|
||||||
|
AppError::conflict(format!("Parent folder not found: {}", parent_path))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Active-lock guard (RFC 4918 §10.4 If: evaluation) ─────────────
|
||||||
|
// Shared with the plain WebDAV surface and with this surface's own
|
||||||
|
// `handle_patch`, so a LOCK taken via /webdav/ also protects the same
|
||||||
|
// file reached through /remote.php/dav/.
|
||||||
|
if let Some(resp) = enforce_native_lock(
|
||||||
|
&state.webdav_lock_store,
|
||||||
|
if_header.as_deref(),
|
||||||
|
&internal_path,
|
||||||
|
current_etag,
|
||||||
|
) {
|
||||||
|
return Ok(resp);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Direct PUT cap ───────────────────────────────────────────────
|
// ── Direct PUT cap ───────────────────────────────────────────────
|
||||||
// We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size`
|
// We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size`
|
||||||
// (default 10 GB). Larger files must come through the chunked upload
|
// (default 10 GB). Larger files must come through the chunked upload
|
||||||
@@ -897,13 +945,35 @@ async fn handle_put(
|
|||||||
// using the lookup already done above for the precondition check.
|
// using the lookup already done above for the precondition check.
|
||||||
let existed = existing.is_some();
|
let existed = existing.is_some();
|
||||||
|
|
||||||
|
// ── Quota enforcement ─────────────────────────────────────────────
|
||||||
|
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
||||||
|
&& let Err(err) = storage_svc
|
||||||
|
.check_storage_quota(session.user.id, ingested.size)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
discard_ingested(&state.core.dedup_service, &ingested).await;
|
||||||
|
tracing::warn!(
|
||||||
|
"⛔ NC WEBDAV PUT REJECTED (quota): user={}, file={}, size={}",
|
||||||
|
session.user.id,
|
||||||
|
internal_path,
|
||||||
|
ingested.size
|
||||||
|
);
|
||||||
|
return Err(AppError::new(
|
||||||
|
StatusCode::INSUFFICIENT_STORAGE,
|
||||||
|
err.message,
|
||||||
|
"QuotaExceeded",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
// Single streaming path — handles both update and create internally,
|
// Single streaming path — handles both update and create internally,
|
||||||
// swapping the file row onto the already-ingested blob.
|
// swapping the file row onto the already-ingested blob.
|
||||||
// AuthZ audit #6 (2026-07-12): route `_with_perms` errors through
|
// AuthZ audit #6 (2026-07-12): route `_with_perms` errors through
|
||||||
// `AppError::from` so authz denials surface as 404 (the anti-enum
|
// `AppError::from` so authz denials surface as 404 (the anti-enum
|
||||||
// shape) instead of a `map_err → internal_error` 500 that gives a
|
// shape) instead of a `map_err → internal_error` 500 that gives a
|
||||||
// probing caller an "exists-but-denied" oracle. Also preserves
|
// probing caller an "exists-but-denied" oracle. Also preserves
|
||||||
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400`.
|
// `QuotaExceeded → 507`, `AlreadyExists → 409`, `InvalidInput → 400` —
|
||||||
|
// matching this surface's own `handle_patch` and the plain WebDAV
|
||||||
|
// `handle_put`.
|
||||||
let stored = upload_service
|
let stored = upload_service
|
||||||
.update_file_streaming_with_perms(
|
.update_file_streaming_with_perms(
|
||||||
&internal_path,
|
&internal_path,
|
||||||
@@ -943,9 +1013,9 @@ async fn handle_put(
|
|||||||
/// pipeline `handle_put` uses ([`ingest_range_patch_to_cas`]) — unedited
|
/// pipeline `handle_put` uses ([`ingest_range_patch_to_cas`]) — unedited
|
||||||
/// chunks on either side of the edit typically dedup for free.
|
/// chunks on either side of the edit typically dedup for free.
|
||||||
///
|
///
|
||||||
/// No active-lock guard here — the NC surface has no LOCK/UNLOCK dispatch
|
/// Shares an active-lock guard with the plain WebDAV surface (see below) so
|
||||||
/// arm at all (see `handle_options`'s doc comment), matching `handle_put`
|
/// a LOCK taken via `/webdav/` also protects the same file reached through
|
||||||
/// above, which has the same omission.
|
/// `/remote.php/dav/`.
|
||||||
async fn handle_patch(
|
async fn handle_patch(
|
||||||
state: Arc<AppState>,
|
state: Arc<AppState>,
|
||||||
req: Request<Body>,
|
req: Request<Body>,
|
||||||
@@ -1086,36 +1156,20 @@ async fn handle_patch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Splice prefix/suffix around the patched span ───────────────────
|
// ── Splice prefix/suffix around the patched span ───────────────────
|
||||||
let prefix_stream: Pin<Box<dyn Stream<Item = Result<Bytes, std::io::Error>> + Send>> =
|
let (prefix_segment, suffix_segment) = splice_patch_streams(
|
||||||
if start == 0 {
|
file_service,
|
||||||
Box::pin(stream::empty())
|
&file.id,
|
||||||
} else {
|
session.user.id,
|
||||||
Box::into_pin(
|
start,
|
||||||
file_service
|
end,
|
||||||
.get_file_range_stream_with_perms(&file.id, session.user.id, 0, Some(start))
|
file.size,
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?,
|
|
||||||
)
|
)
|
||||||
};
|
.await?;
|
||||||
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_service
|
|
||||||
.get_file_range_stream_with_perms(&file.id, session.user.id, end + 1, None)
|
|
||||||
.await
|
|
||||||
.map_err(AppError::from)?,
|
|
||||||
),
|
|
||||||
_ => Box::pin(stream::empty()),
|
|
||||||
};
|
|
||||||
let filename = filename_from_path(subpath).to_string();
|
let filename = filename_from_path(subpath).to_string();
|
||||||
let ingested = ingest_range_patch_to_cas(
|
let ingested = ingest_range_patch_to_cas(
|
||||||
(prefix_stream, start),
|
prefix_segment,
|
||||||
req.into_body(),
|
req.into_body(),
|
||||||
(suffix_stream, suffix_len),
|
suffix_segment,
|
||||||
&state.core.dedup_service,
|
&state.core.dedup_service,
|
||||||
&filename,
|
&filename,
|
||||||
&claimed_type,
|
&claimed_type,
|
||||||
@@ -1126,27 +1180,8 @@ async fn handle_patch(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
// ── Quota enforcement ─────────────────────────────────────────────
|
// ── Quota enforcement + atomic store, compare-and-swap on the
|
||||||
if let Some(storage_svc) = state.storage_usage_service.as_ref()
|
// pre-splice content hash ─────────────────────────────────────────
|
||||||
&& let Err(err) = storage_svc
|
|
||||||
.check_storage_quota(session.user.id, ingested.size)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
discard_ingested(&state.core.dedup_service, &ingested).await;
|
|
||||||
tracing::warn!(
|
|
||||||
"⛔ NC WEBDAV PATCH REJECTED (quota): user={}, file={}, size={}",
|
|
||||||
session.user.id,
|
|
||||||
internal_path,
|
|
||||||
ingested.size
|
|
||||||
);
|
|
||||||
return Err(AppError::new(
|
|
||||||
StatusCode::INSUFFICIENT_STORAGE,
|
|
||||||
err.message,
|
|
||||||
"QuotaExceeded",
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Atomic store, compare-and-swap on the pre-splice content hash ──
|
|
||||||
// `file.content_hash` was snapshotted before the (potentially slow)
|
// `file.content_hash` was snapshotted before the (potentially slow)
|
||||||
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
// splice + CAS-ingest above. Passing it as `expected_hash` makes the
|
||||||
// write itself a compare-and-swap: the repository checks and applies
|
// write itself a compare-and-swap: the repository checks and applies
|
||||||
@@ -1156,19 +1191,17 @@ async fn handle_patch(
|
|||||||
// — each individually passing its own If-Match check against the
|
// — each individually passing its own If-Match check against the
|
||||||
// same stale snapshot, then blindly overwriting each other.
|
// same stale snapshot, then blindly overwriting each other.
|
||||||
let new_size = ingested.size;
|
let new_size = ingested.size;
|
||||||
let content_type = ingested.content_type.clone();
|
let stored = cas_write_patch(
|
||||||
let stored = upload_service
|
&state,
|
||||||
.update_file_streaming_with_perms(
|
upload_service,
|
||||||
&internal_path,
|
&internal_path,
|
||||||
chroot.drive_id,
|
chroot.drive_id,
|
||||||
ingested.stored(),
|
&ingested,
|
||||||
&content_type,
|
|
||||||
None,
|
|
||||||
session.user.id,
|
session.user.id,
|
||||||
Some(&file.content_hash),
|
&file.content_hash,
|
||||||
|
"NC WEBDAV PATCH",
|
||||||
)
|
)
|
||||||
.await
|
.await?;
|
||||||
.map_err(AppError::from)?;
|
|
||||||
|
|
||||||
// Everything from `start` to the new EOF reflects the patch (the
|
// Everything from `start` to the new EOF reflects the patch (the
|
||||||
// untouched suffix, if any, may have shifted when the body's length
|
// untouched suffix, if any, may have shifted when the body's length
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
# =============================================================
|
||||||
|
# OxiCloud — NextCloud PUT gaps closed by bringing handle_put up to
|
||||||
|
# parity with handle_patch
|
||||||
|
# =============================================================
|
||||||
|
# `nc_webdav_patch_consistency.hurl` covers the same four gap classes
|
||||||
|
# for PATCH; this file targets the NC surface's `handle_put`
|
||||||
|
# (nextcloud/webdav_handler.rs), which had fallen behind PATCH's
|
||||||
|
# hardening across the RFC 5789 commits:
|
||||||
|
#
|
||||||
|
# 1. Error mapping: the write step mapped every `DomainError` to a
|
||||||
|
# raw 500 (`AppError::internal_error(format!("Failed to store
|
||||||
|
# file: {}", e))`) instead of `AppError::from(e)` — a VIEWER
|
||||||
|
# (Read only, no Update) overwriting a file got a 500 leak
|
||||||
|
# instead of the anti-enum 404 the rest of the codebase relies
|
||||||
|
# on.
|
||||||
|
# 2. Cross-surface lock interop: PUT via `/remote.php/dav/` didn't
|
||||||
|
# consult the lock store a LOCK taken via the plain `/webdav/`
|
||||||
|
# surface writes to at all.
|
||||||
|
# 3. Quota/507: PUT via the NC surface bypassed
|
||||||
|
# `check_storage_quota` entirely (PATCH already enforced it).
|
||||||
|
# 4. Existence-check depth (RFC 4918 §9.7.1): PUT to an existing
|
||||||
|
# directory should be 400, and PUT under a missing parent folder
|
||||||
|
# should be 409 — neither check existed on the NC surface; both
|
||||||
|
# failure modes fell through to whatever `update_file_streaming_
|
||||||
|
# with_perms` did internally.
|
||||||
|
#
|
||||||
|
# Self-contained: provisions its own throwaway users/drive so it can
|
||||||
|
# run alongside the rest of the suite.
|
||||||
|
# =============================================================
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Setup — Admin JWT login.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "{{username}}", "password": "{{password}}" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
admin_jwt: jsonpath "$.access_token"
|
||||||
|
admin_user_id: jsonpath "$.user.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
# Part A — Error mapping: Editor can overwrite via PUT; Viewer
|
||||||
|
# (Read only) gets 404, not a raw 500
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A1 — Provision `ncput_editor` (EDITOR) and `ncput_viewer`
|
||||||
|
# (VIEWER, Read only).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "ncput_editor",
|
||||||
|
"password": "NcPutEditorPwd1!",
|
||||||
|
"email": "ncput_editor@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
editor_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "ncput_viewer",
|
||||||
|
"password": "NcPutViewerPwd1!",
|
||||||
|
"email": "ncput_viewer@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
viewer_user_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A2 — Log both in, mint an NC app password for each.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "ncput_editor", "password": "NcPutEditorPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
editor_jwt: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{editor_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_webdav_put_gaps (editor)" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
editor_nc_username: jsonpath "$.username"
|
||||||
|
editor_nc_password: jsonpath "$.password"
|
||||||
|
editor_ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "ncput_viewer", "password": "NcPutViewerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
viewer_jwt: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{viewer_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_webdav_put_gaps (viewer)" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
viewer_nc_username: jsonpath "$.username"
|
||||||
|
viewer_nc_password: jsonpath "$.password"
|
||||||
|
viewer_ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A3 — Admin creates a shared drive, grants `ncput_editor`
|
||||||
|
# EDITOR (Read + Update) and `ncput_viewer` VIEWER
|
||||||
|
# (Read only).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/drives
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"kind": "shared",
|
||||||
|
"name": "ncput-shared",
|
||||||
|
"owner": { "type": "user", "id": "{{admin_user_id}}" }
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
shared_drive_id: jsonpath "$.id"
|
||||||
|
shared_root_id: jsonpath "$.root_folder_id"
|
||||||
|
|
||||||
|
|
||||||
|
POST {{base_url}}/api/grants
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"subject": { "type": "user", "id": "{{editor_user_id}}" },
|
||||||
|
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||||
|
"role": "editor"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
POST {{base_url}}/api/grants
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"subject": { "type": "user", "id": "{{viewer_user_id}}" },
|
||||||
|
"resource": { "type": "drive", "id": "{{shared_drive_id}}" },
|
||||||
|
"role": "viewer"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A4 — Admin seeds a file in the shared drive via the plain
|
||||||
|
# WebDAV surface (`@drive/<id>/` scheme).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncput-file.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: text/plain
|
||||||
|
`0123456789`
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A5 — Bootstrap the composite BasicAuth usernames (see
|
||||||
|
# nc_multidrive_move_regression.hurl for the mechanism).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
GET {{base_url}}/ready
|
||||||
|
[Options]
|
||||||
|
variable: nc_basic_editor={{editor_nc_username}}~{{shared_root_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
GET {{base_url}}/ready
|
||||||
|
[Options]
|
||||||
|
variable: nc_basic_viewer={{viewer_nc_username}}~{{shared_root_id}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A6 — EDITOR (has Update via the drive grant) CAN overwrite
|
||||||
|
# via PUT.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncput-file.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||||
|
`XYZ`
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
GET {{base_url}}/remote.php/dav/files/{{nc_basic_editor}}/ncput-file.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_editor}}: {{editor_nc_password}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body == "XYZ"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step A7 — VIEWER (has Read via the grant, but not Update) is
|
||||||
|
# denied → 404 anti-enum, not a raw 500. Before the fix,
|
||||||
|
# `handle_put`'s write step mapped every `DomainError`
|
||||||
|
# (including this authz denial) to
|
||||||
|
# `AppError::internal_error(...)`, leaking a 500.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_basic_viewer}}/ncput-file.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_basic_viewer}}: {{viewer_nc_password}}
|
||||||
|
`NOP`
|
||||||
|
|
||||||
|
HTTP 404
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup Part A.
|
||||||
|
DELETE {{base_url}}/webdav/@drive/{{shared_drive_id}}/ncput-file.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{editor_ap_id}}
|
||||||
|
Authorization: Bearer {{editor_jwt}}
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{viewer_ap_id}}
|
||||||
|
Authorization: Bearer {{viewer_jwt}}
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
# Part B — Cross-surface lock interop
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step B1 — Mint admin's own NC app password (bare-username
|
||||||
|
# surface — admin's personal drive, same file tree as
|
||||||
|
# `/webdav/`).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_webdav_put_gaps (lock interop)" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
nc_username: jsonpath "$.username"
|
||||||
|
nc_password: jsonpath "$.password"
|
||||||
|
lock_ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step B2 — Seed the file via the plain surface, LOCK it there.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: text/plain
|
||||||
|
`0123456789`
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
LOCK {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
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>nc-put-lock-interop-test</D:owner>
|
||||||
|
</D:lockinfo>
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
interop_lock_token: xpath "string(//*[local-name()='locktoken']/*[local-name()='href'])"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step B3 — PUT the SAME file via the NC surface, no lock token
|
||||||
|
# → 423. Pre-fix, the NC surface's `handle_put` didn't
|
||||||
|
# consult the plain surface's lock store at all.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-lock-interop-probe.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
`NOP`
|
||||||
|
|
||||||
|
HTTP 423
|
||||||
|
|
||||||
|
|
||||||
|
# Release the lock via the plain surface so cleanup below works.
|
||||||
|
UNLOCK {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Lock-Token: <{{interop_lock_token}}>
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup Part B.
|
||||||
|
DELETE {{base_url}}/webdav/nc-put-lock-interop-probe.txt
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
# Part C — Quota/507 via the NC surface leaves the file untouched
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step C1 — Provision `ncput_quota_owner` with a 50-byte quota.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
POST {{base_url}}/api/admin/users
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{
|
||||||
|
"username": "ncput_quota_owner",
|
||||||
|
"password": "NcPutQuotaOwnerPwd1!",
|
||||||
|
"email": "ncput_quota_owner@example.com",
|
||||||
|
"role": "user"
|
||||||
|
}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
quota_owner_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
PUT {{base_url}}/api/admin/users/{{quota_owner_id}}/quota
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "quota_bytes": 50 }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/login
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "username": "ncput_quota_owner", "password": "NcPutQuotaOwnerPwd1!" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
quota_owner_jwt: jsonpath "$.access_token"
|
||||||
|
|
||||||
|
POST {{base_url}}/api/auth/app-passwords
|
||||||
|
Authorization: Bearer {{quota_owner_jwt}}
|
||||||
|
Content-Type: application/json
|
||||||
|
{ "label": "nc_webdav_put_gaps (quota)" }
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Captures]
|
||||||
|
quota_nc_username: jsonpath "$.username"
|
||||||
|
quota_nc_password: jsonpath "$.password"
|
||||||
|
quota_ap_id: jsonpath "$.id"
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step C2 — Seed a 10-byte file (under quota), then overwrite it
|
||||||
|
# with a payload that blows past the 50-byte quota → 507.
|
||||||
|
# File must come back unchanged.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{quota_nc_username}}: {{quota_nc_password}}
|
||||||
|
`0123456789`
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
[Captures]
|
||||||
|
quota_probe_etag: header "ETag"
|
||||||
|
|
||||||
|
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{quota_nc_username}}: {{quota_nc_password}}
|
||||||
|
`this-is-a-100-byte-ish-payload-that-blows-past-the-fifty-byte-quota-set-for-this-throwaway-user-abc`
|
||||||
|
|
||||||
|
HTTP 507
|
||||||
|
|
||||||
|
|
||||||
|
GET {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{quota_nc_username}}: {{quota_nc_password}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
[Asserts]
|
||||||
|
body == "0123456789"
|
||||||
|
header "ETag" contains {{quota_probe_etag}}
|
||||||
|
|
||||||
|
|
||||||
|
# Cleanup Part C.
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{quota_nc_username}}/nc-put-quota-probe.txt
|
||||||
|
[BasicAuth]
|
||||||
|
{{quota_nc_username}}: {{quota_nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{quota_ap_id}}
|
||||||
|
Authorization: Bearer {{quota_owner_jwt}}
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
# Part D — Existence-check depth (RFC 4918 §9.7.1): folder-collision
|
||||||
|
# and missing-parent, previously unchecked on the NC surface
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step D1 — PUT to an existing directory → 400 (not whatever the
|
||||||
|
# write step's internals happened to produce).
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
MKCOL {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 201
|
||||||
|
|
||||||
|
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
`NOP`
|
||||||
|
|
||||||
|
HTTP 400
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-probe-dir/
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
# Step D2 — PUT under a nonexistent parent folder → 409 Conflict
|
||||||
|
# (RFC 4918 §9.7.1), not a generic error from further down
|
||||||
|
# the write path.
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
PUT {{base_url}}/remote.php/dav/files/{{nc_username}}/nc-put-missing-parent/probe.txt
|
||||||
|
Content-Type: text/plain
|
||||||
|
[BasicAuth]
|
||||||
|
{{nc_username}}: {{nc_password}}
|
||||||
|
`NOP`
|
||||||
|
|
||||||
|
HTTP 409
|
||||||
|
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/auth/app-passwords/{{lock_ap_id}}
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
# Teardown
|
||||||
|
# ═════════════════════════════════════════════════════════════
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{editor_user_id}}
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{viewer_user_id}}
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/drives/{{shared_drive_id}}
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 204
|
||||||
|
|
||||||
|
DELETE {{base_url}}/api/admin/users/{{quota_owner_id}}
|
||||||
|
Authorization: Bearer {{admin_jwt}}
|
||||||
|
|
||||||
|
HTTP 200
|
||||||
Reference in New Issue
Block a user