diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 3d10fcf3..e9dd1393 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -31,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; @@ -40,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; @@ -1892,39 +1894,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: → 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 ────────────────────────────────────────────── @@ -1988,7 +1971,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()) } @@ -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, + file_size: u64, +) -> Result<(RangeSegment, RangeSegment), AppError> { + let prefix_stream: Pin> + 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> + 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 { + 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. * @@ -2244,36 +2327,20 @@ async fn handle_patch( } // ── Splice prefix/suffix around the patched span ─────────────────── - let prefix_stream: Pin> + Send>> = - if start == 0 { - Box::pin(stream::empty()) - } else { - Box::into_pin( - file_retrieval_service - .get_file_range_stream_with_perms(&file.id, user.id, 0, Some(start)) - .await - .map_err(AppError::from)?, - ) - }; - let suffix_len = match end { - Some(end) if end + 1 < file.size => file.size - (end + 1), - _ => 0, - }; - let suffix_stream: Pin> + Send>> = match end - { - Some(end) if end + 1 < file.size => Box::into_pin( - file_retrieval_service - .get_file_range_stream_with_perms(&file.id, user.id, end + 1, None) - .await - .map_err(AppError::from)?, - ), - _ => Box::pin(stream::empty()), - }; + let (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_stream, start), + prefix_segment, req.into_body(), - (suffix_stream, suffix_len), + suffix_segment, &state.core.dedup_service, &filename, &content_type, @@ -2284,27 +2351,8 @@ async fn handle_patch( ) .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, compare-and-swap on the pre-splice content hash ── + // ── 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 @@ -2314,37 +2362,31 @@ async fn handle_patch( // — each individually passing its own If-Match check against the // same stale snapshot, then blindly overwriting each other. 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, - Some(&file.content_hash), - ) - .await; + let file_dto = cas_write_patch( + &state, + file_upload_service, + &path, + drive_id, + &ingested, + user.id, + &file.content_hash, + "WEBDAV PATCH", + ) + .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)), - } + // 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()) } /** @@ -3775,4 +3817,56 @@ mod tests { 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") + )); + } } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 6f6b90c3..a608ebcd 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -5,13 +5,11 @@ use axum::{ }; use bytes::{Buf, Bytes}; use chrono::Utc; -use futures::stream::{self, Stream}; use quick_xml::{ Writer, events::{BytesEnd, BytesStart, BytesText, Event}, }; use std::collections::{HashMap, HashSet}; -use std::pin::Pin; use std::sync::Arc; 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::webdav_dead_property_store::ResourceRef; 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, - if_match_precondition_fails, if_none_match_precondition_fails, parse_update_range, - folders_dead_props_map, streamed_file_dead_props, + PROPFIND_BATCH_SIZE, cas_write_patch, dead_props_for, enforce_native_lock, file_dead_props, + files_dead_props_map, folder_dead_props, folders_dead_props_map, if_match_precondition_fails, + if_none_match_precondition_fails, parse_update_range, splice_patch_streams, }; use crate::interfaces::errors::AppError; 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.parse::().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) ───────────── // 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. @@ -865,6 +870,49 @@ async fn handle_put( 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 ─────────────────────────────────────────────── // We use `direct_put_max_bytes` (default 1 GiB), not `max_upload_size` // (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. 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, // swapping the file row onto the already-ingested blob. // AuthZ audit #6 (2026-07-12): route `_with_perms` errors through // `AppError::from` so authz denials surface as 404 (the anti-enum // shape) instead of a `map_err → internal_error` 500 that gives a // 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 .update_file_streaming_with_perms( &internal_path, @@ -943,9 +1013,9 @@ async fn handle_put( /// pipeline `handle_put` uses ([`ingest_range_patch_to_cas`]) — unedited /// chunks on either side of the edit typically dedup for free. /// -/// No active-lock guard here — the NC surface has no LOCK/UNLOCK dispatch -/// arm at all (see `handle_options`'s doc comment), matching `handle_put` -/// above, which has the same omission. +/// Shares an active-lock guard with the plain WebDAV surface (see below) so +/// a LOCK taken via `/webdav/` also protects the same file reached through +/// `/remote.php/dav/`. async fn handle_patch( state: Arc, req: Request, @@ -1086,36 +1156,20 @@ async fn handle_patch( } // ── Splice prefix/suffix around the patched span ─────────────────── - let prefix_stream: Pin> + Send>> = - if start == 0 { - Box::pin(stream::empty()) - } else { - Box::into_pin( - file_service - .get_file_range_stream_with_perms(&file.id, session.user.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> + 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 (prefix_segment, suffix_segment) = splice_patch_streams( + file_service, + &file.id, + session.user.id, + start, + end, + file.size, + ) + .await?; let filename = filename_from_path(subpath).to_string(); let ingested = ingest_range_patch_to_cas( - (prefix_stream, start), + prefix_segment, req.into_body(), - (suffix_stream, suffix_len), + suffix_segment, &state.core.dedup_service, &filename, &claimed_type, @@ -1126,27 +1180,8 @@ async fn handle_patch( ) .await?; - // ── 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 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 ── + // ── 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 @@ -1156,19 +1191,17 @@ async fn handle_patch( // — each individually passing its own If-Match check against the // same stale snapshot, then blindly overwriting each other. let new_size = ingested.size; - let content_type = ingested.content_type.clone(); - let stored = upload_service - .update_file_streaming_with_perms( - &internal_path, - chroot.drive_id, - ingested.stored(), - &content_type, - None, - session.user.id, - Some(&file.content_hash), - ) - .await - .map_err(AppError::from)?; + let stored = cas_write_patch( + &state, + upload_service, + &internal_path, + chroot.drive_id, + &ingested, + session.user.id, + &file.content_hash, + "NC 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 diff --git a/tests/api/nc_webdav_put_gaps.hurl b/tests/api/nc_webdav_put_gaps.hurl new file mode 100644 index 00000000..73a59d53 --- /dev/null +++ b/tests/api/nc_webdav_put_gaps.hurl @@ -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//` 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 +``` + + + + + nc-put-lock-interop-test + +``` + +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