diff --git a/Dockerfile b/Dockerfile index 46bfa69a..21cb9e57 100644 --- a/Dockerfile +++ b/Dockerfile @@ -105,7 +105,6 @@ FROM base AS builder-cache WORKDIR /app COPY Cargo.toml Cargo.lock build.rs ./ COPY src src -COPY static static COPY migrations migrations COPY templates templates COPY --from=frontend /static-dist ./static-dist diff --git a/src/application/adapters/webdav_adapter.rs b/src/application/adapters/webdav_adapter.rs index 6731e185..47d33bbe 100644 --- a/src/application/adapters/webdav_adapter.rs +++ b/src/application/adapters/webdav_adapter.rs @@ -72,6 +72,46 @@ impl std::fmt::Display for QualifiedName { } } +/// Whether PROPPATCH must refuse to set/remove this property as a dead +/// property (RFC 4918 §9.2 — server MAY reject a PROPPATCH attempt on a +/// live property; DeadPropertyStore has no business holding a value that +/// PROPFIND / REPORT already emit from live server state). +pub fn is_protected_property(qn: &QualifiedName) -> bool { + match qn.namespace.as_str() { + // RFC 4918 §15 — the DAV: namespace is server-owned in its + // entirety. Any PROPPATCH into it either forges a live + // property (dual-emission) or accumulates unread garbage + // (silent litter). + "DAV:" => true, + + // Every name below appears verbatim in write_folder_response + // / write_file_response in the NC handler. Adding a new + // live emitter → add its name here. + "http://owncloud.org/ns" => matches!( + qn.name.as_str(), + "favorite" + | "fileid" + | "id" + | "owner-id" + | "owner-display-name" + | "permissions" + | "share-types" + | "size" + ), + + "http://nextcloud.org/ns" => matches!( + qn.name.as_str(), + "has-preview" | "is-encrypted" | "mount-type" | "creation_time" | "upload_time" + ), + + "http://open-collaboration-services.org/ns" => { + matches!(qn.name.as_str(), "share-permissions") + } + + _ => false, + } +} + /// PROPFIND request type #[derive(Debug, PartialEq)] pub enum PropFindType { @@ -461,7 +501,12 @@ impl WebDavAdapter { /// /// Written AFTER the live-property propstats inside a ``. /// Only emitted when `dead_props` is non-empty. - fn write_dead_props_propstat( + /// + /// `pub(crate)` so the NextCloud-compatible handler + /// (`interfaces::nextcloud::webdav_handler`) can append the same + /// dead-property block to its own bespoke PROPFIND writers instead + /// of duplicating this XML shape. + pub(crate) fn write_dead_props_propstat( xml_writer: &mut Writer, dead_props: &[(QualifiedName, Option)], ) -> Result<()> { diff --git a/src/interfaces/api/handlers/webdav_handler.rs b/src/interfaces/api/handlers/webdav_handler.rs index 6761efce..0a29f029 100644 --- a/src/interfaces/api/handlers/webdav_handler.rs +++ b/src/interfaces/api/handlers/webdav_handler.rs @@ -18,7 +18,7 @@ use quick_xml::Writer; use uuid::Uuid; use crate::application::adapters::webdav_adapter::{ - LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, + LockInfo, PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, }; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; @@ -980,6 +980,12 @@ async fn handle_proppatch( let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); for op in &ops { match op { + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } PropPatchOp::Set(pv) => { dead_props .set(resource_ref, pv.name.clone(), pv.value.clone()) @@ -1334,7 +1340,11 @@ async fn resolve_or_legacy( /// the dead-prop lookup is broken; surfacing a 500 here would mask the /// resource entirely from sync clients. The legacy path-keyed lookup /// behaved the same way (`.unwrap_or_default()`); we preserve it. -async fn file_dead_props( +/// +/// `pub(crate)` — also reused by the NextCloud-compatible PROPFIND +/// handler (`interfaces::nextcloud::webdav_handler`), which needs the +/// same lenient fetch for its own response writers. +pub(crate) async fn file_dead_props( state: &Arc, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { @@ -1349,8 +1359,9 @@ async fn file_dead_props( } /// Same shape as `file_dead_props` but for folder rows. Used by the -/// streaming PROPFIND walker. -async fn folder_dead_props( +/// streaming PROPFIND walker (and, via `pub(crate)`, by the NextCloud +/// handler's own streaming walker). +pub(crate) async fn folder_dead_props( store: &DeadPropertyStore, folder: &FolderDto, ) -> Vec<(QualifiedName, Option)> { @@ -1366,7 +1377,7 @@ async fn folder_dead_props( /// File-leaf variant for the streaming walker (takes a `&DeadPropertyStore` /// rather than the full `&Arc` so it can be called from inside /// the async-stream future without cloning state). -async fn streamed_file_dead_props( +pub(crate) async fn streamed_file_dead_props( store: &DeadPropertyStore, file: &FileDto, ) -> Vec<(QualifiedName, Option)> { diff --git a/src/interfaces/nextcloud/report_handler.rs b/src/interfaces/nextcloud/report_handler.rs index 39f913c0..2078613c 100644 --- a/src/interfaces/nextcloud/report_handler.rs +++ b/src/interfaces/nextcloud/report_handler.rs @@ -21,6 +21,7 @@ use crate::application::ports::folder_ports::FolderUseCase; use crate::application::ports::inbound::SearchUseCase; use crate::common::di::AppState; use crate::domain::entities::file::File; +use crate::interfaces::api::handlers::webdav_handler::{file_dead_props, folder_dead_props}; use crate::interfaces::errors::AppError; use crate::interfaces::nextcloud::webdav_handler::{ batch_resolve_ids, format_oc_id, nc_href, write_file_response, write_folder_response, @@ -178,14 +179,15 @@ async fn handle_filter_files( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -203,14 +205,15 @@ async fn handle_filter_files( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -308,14 +311,15 @@ async fn handle_search( let href = nc_href(url_user, subpath); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = file_dead_props(&state, file).await; write_file_response( &mut xml, file, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } @@ -334,14 +338,15 @@ async fn handle_search( let href = format!("{}/", nc_href(url_user, subpath)); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); + let dead = folder_dead_props(&state.webdav_dead_props, folder).await; write_folder_response( &mut xml, folder, &href, - fid, - oc_id.as_deref(), + (fid, oc_id.as_deref()), &user.username, &favorite_ids, + &dead, ) .map_err(|e| AppError::internal_error(format!("XML write error: {}", e)))?; } diff --git a/src/interfaces/nextcloud/webdav_handler.rs b/src/interfaces/nextcloud/webdav_handler.rs index 74eba460..2468fa33 100644 --- a/src/interfaces/nextcloud/webdav_handler.rs +++ b/src/interfaces/nextcloud/webdav_handler.rs @@ -13,7 +13,9 @@ use std::collections::{HashMap, HashSet}; use std::sync::Arc; use uuid::Uuid; -use crate::application::adapters::webdav_adapter::{PropFindRequest, WebDavAdapter}; +use crate::application::adapters::webdav_adapter::{ + PropFindRequest, PropPatchOp, QualifiedName, WebDavAdapter, is_protected_property, +}; use crate::application::dtos::pagination::PaginationRequestDto; use crate::application::ports::authorization_ports::AuthorizationEngine; use crate::application::ports::favorites_ports::FavoritesUseCase; @@ -26,7 +28,10 @@ use crate::common::di::AppState; use crate::common::mime_detect::filename_from_path; use crate::domain::services::authorization::{Permission, Resource, Subject}; use crate::infrastructure::services::path_resolver_service::ResolvedResource; -use crate::interfaces::api::handlers::webdav_handler::PROPFIND_BATCH_SIZE; +use crate::infrastructure::services::webdav_dead_property_store::ResourceRef; +use crate::interfaces::api::handlers::webdav_handler::{ + PROPFIND_BATCH_SIZE, file_dead_props, folder_dead_props, streamed_file_dead_props, +}; use crate::interfaces::errors::AppError; use crate::interfaces::range_requests::{not_modified_response, range_response}; use crate::interfaces::upload_ingest::ingest_body_to_cas; @@ -371,6 +376,8 @@ async fn handle_propfind( let nc = state.nextcloud.as_ref(); let file_id_svc = nc.map(|n| &n.file_ids); + let dead_props = file_dead_props(&state, &file).await; + let mut buf = Vec::new(); write_nc_file_multistatus( &mut buf, @@ -379,7 +386,7 @@ async fn handle_propfind( &user.username, subpath, file_id_svc, - &favorite_ids, + (&favorite_ids, &dead_props), ) .await .map_err(|e| AppError::internal_error(format!("XML generation failed: {}", e)))?; @@ -620,6 +627,12 @@ async fn handle_head( // ──────────────────── PROPPATCH ──────────────────── +/// The `oc:favorite` element is live server state routed through the +/// favorites service, not a dead property — every other +/// namespace/local-name pair PROPPATCH sends is stored verbatim via +/// `DeadPropertyStore`. +const OC_FAVORITE_NS: &str = "http://owncloud.org/ns"; + async fn handle_proppatch( state: Arc, req: Request, @@ -633,171 +646,139 @@ async fn handle_proppatch( .await .map_err(|e| AppError::bad_request(format!("Failed to read body: {}", e)))?; - let body_str = String::from_utf8_lossy(&body_bytes); - - // Resolve the target resource once — needed for two things: - // 1. Applying the oc:favorite mutation when the PROPPATCH body - // carries one (`item_type` distinguishes file vs folder rows - // in the favorites table). - // 2. Picking the right `` shape in the multi-status + // Resolve the target resource — needed for three things: + // 1. The dead-property store key is the resource id (folder_id + // XOR file_id), so we need a `ResourceRef`. + // 2. Applying the oc:favorite mutation (`item_type` distinguishes + // file vs folder rows in the favorites table). + // 3. Picking the right `` shape in the multi-status // response: collection (folder) hrefs MUST end in `/` per // RFC 4918 §5.2 — see `nc_collection_href` for the full - // reasoning. Without this distinction the NC desktop client - // parser aborted on PROPFIND; PROPPATCH would hit the same - // wall the moment the user favourited a folder. + // reasoning. // - // When the resource is missing we tolerate it for the no-op - // PROPPATCH path (no favorite directive in the body) — matches - // the prior behaviour. A PROPPATCH that *does* try to set - // favorite on a missing resource still returns NotFound. + // A missing resource is now always a 404: unlike the previous + // favorite-only implementation (which merely re-declared success + // without doing anything), this handler performs real writes, so + // silently no-opping on a nonexistent path would be a foot-gun — + // matches the native `/webdav/` handler's contract. let internal_path = nc_to_internal_path(chroot, subpath)?; - // Single-query path resolution — PROPPATCH may target either a // folder or a file. Post-D7 the resolver is drive-scoped, so we // `authz.require(Read, …)` on the returned resource before // reading its type. The favorite mutation below itself doesn't // require additional authz (favorites are per-user; the caller can // favourite any resource they can see). - let resource = match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await { - Some(ResolvedResource::File(f)) => { - let file_uuid = - Uuid::parse_str(&f.id).map_err(|_| AppError::not_found("Resource not found"))?; - state - .authorization - .require( - Subject::User(user.id), - Permission::Read, - Resource::File(file_uuid), - ) - .await?; - Some((f.id, "file")) - } - Some(ResolvedResource::Folder(folder)) => { - let folder_uuid = Uuid::parse_str(&folder.id) - .map_err(|_| AppError::not_found("Resource not found"))?; - state - .authorization - .require( - Subject::User(user.id), - Permission::Read, - Resource::Folder(folder_uuid), - ) - .await?; - Some((folder.id, "folder")) - } - None => None, - }; - let is_collection = matches!(resource, Some((_, "folder"))); - - // Parse oc:favorite value from PROPPATCH XML. - let favorite_value = parse_proppatch_favorite(&body_str); - - if let Some(value) = favorite_value { - let Some((item_id, item_type)) = resource else { - return Err(AppError::not_found("Resource not found")); + let (resource_ref, item_id, item_type, is_collection) = + match nc_resolve_or_fallback(&state, &internal_path, chroot.drive_id).await { + Some(ResolvedResource::File(file)) => { + let id = Uuid::parse_str(&file.id) + .map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require(Subject::User(user.id), Permission::Read, Resource::File(id)) + .await?; + (ResourceRef::File(id), file.id, "file", false) + } + Some(ResolvedResource::Folder(folder)) => { + let id = Uuid::parse_str(&folder.id) + .map_err(|_| AppError::not_found("Resource not found"))?; + state + .authorization + .require( + Subject::User(user.id), + Permission::Read, + Resource::Folder(id), + ) + .await?; + (ResourceRef::Folder(id), folder.id, "folder", true) + } + None => return Err(AppError::not_found("Resource not found")), }; - if let Some(fav_svc) = state.favorites_service.as_ref() { - if value == 1 { - fav_svc - .add_to_favorites(user.id, &item_id, item_type) + let ops = WebDavAdapter::parse_proppatch(body_bytes.reader()) + .map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH request: {}", e)))?; + + let dead_props = &state.webdav_dead_props; + let mut results: Vec<(&QualifiedName, bool)> = Vec::new(); + for op in &ops { + let is_favorite = + |name: &QualifiedName| name.namespace == OC_FAVORITE_NS && name.name == "favorite"; + match op { + PropPatchOp::Set(pv) if is_favorite(&pv.name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + if pv.value.as_deref().map(str::trim) == Some("1") { + fav_svc + .add_to_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to add favorite: {e}")) + })?; + } else { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + } + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) if is_favorite(name) => { + if let Some(fav_svc) = state.favorites_service.as_ref() { + fav_svc + .remove_from_favorites(user.id, &item_id, item_type) + .await + .map_err(|e| { + AppError::internal_error(format!("Failed to remove favorite: {e}")) + })?; + } + results.push((name, true)); + } + PropPatchOp::Set(pv) if is_protected_property(&pv.name) => { + results.push((&pv.name, false)); + } + PropPatchOp::Remove(name) if is_protected_property(name) => { + results.push((name, false)); + } + PropPatchOp::Set(pv) => { + dead_props + .set(resource_ref, pv.name.clone(), pv.value.clone()) .await .map_err(|e| { - AppError::internal_error(format!("Failed to add favorite: {}", e)) - })?; - } else { - fav_svc - .remove_from_favorites(user.id, &item_id, item_type) - .await - .map_err(|e| { - AppError::internal_error(format!("Failed to remove favorite: {}", e)) + AppError::internal_error(format!("Failed to store dead property: {e}")) })?; + results.push((&pv.name, true)); + } + PropPatchOp::Remove(name) => { + dead_props.remove(resource_ref, name).await.map_err(|e| { + AppError::internal_error(format!("Failed to remove dead property: {e}")) + })?; + results.push((name, true)); } } } - // Return 207 Multi-Status with success response using quick_xml - // for safe escaping. Collection vs file href chosen by resource - // type to satisfy the RFC 4918 §5.2 trailing-slash invariant — - // see the comment block at the top of this function. + // Collection vs file href chosen by resource type to satisfy the + // RFC 4918 §5.2 trailing-slash invariant — see the comment block + // at the top of this function. let href = if is_collection { nc_collection_href(url_user, subpath) } else { nc_href(url_user, subpath) }; - let mut buf = Vec::new(); - { - let mut xml = Writer::new(&mut buf); - xml.write_event(Event::Text(BytesText::new( - "", - ))) - .map_err(|e| AppError::internal_error(format!("XML write failed: {}", e)))?; - - let mut ms = BytesStart::new("d:multistatus"); - ms.push_attribute(("xmlns:d", "DAV:")); - ms.push_attribute(("xmlns:oc", "http://owncloud.org/ns")); - xml.write_event(Event::Start(ms)) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - - xml.write_event(Event::Start(BytesStart::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:href", &href) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Start(BytesStart::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::Empty(BytesStart::new("oc:favorite"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:prop"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - write_text_element(&mut xml, "d:status", "HTTP/1.1 200 OK") - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:propstat"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:response"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) - .map_err(|e| AppError::internal_error(format!("XML: {}", e)))?; - } + let mut response_body = Vec::new(); + WebDavAdapter::generate_proppatch_response(&mut response_body, &href, &results).map_err( + |e| AppError::internal_error(format!("Failed to generate PROPPATCH response: {}", e)), + )?; Ok(Response::builder() .status(StatusCode::MULTI_STATUS) .header(header::CONTENT_TYPE, "application/xml; charset=utf-8") - .body(Body::from(buf)) + .body(Body::from(response_body)) .unwrap()) } -/// Parse the oc:favorite value from a PROPPATCH XML body using quick_xml. -fn parse_proppatch_favorite(body: &str) -> Option { - use quick_xml::Reader; - - let mut reader = Reader::from_str(body); - let mut inside_favorite = false; - - loop { - match reader.read_event() { - Ok(Event::Start(ref e)) => { - let local = e.local_name(); - if local.as_ref() == b"favorite" { - inside_favorite = true; - } - } - Ok(Event::Text(ref e)) if inside_favorite => { - let text = e.decode().ok()?; - return text.trim().parse::().ok(); - } - Ok(Event::End(ref e)) if e.local_name().as_ref() == b"favorite" => { - inside_favorite = false; - } - Ok(Event::Eof) => break, - Err(_) => break, - _ => {} - } - } - None -} - // ──────────────────── PUT ──────────────────── /// Strip the optional `W/` weak prefix and surrounding double-quotes @@ -1441,6 +1422,10 @@ fn write_nc_multistatus_open(xml: &mut Writer) -> Result<( /// Generate the multistatus XML for a single-file PROPFIND. The folder /// case streams via [`build_nc_streaming_propfind`] instead. +/// +/// `extras` bundles `(favorite_ids, dead_props)` — both are per-resource +/// decorations fetched by the caller — to stay under clippy's +/// argument-count lint. async fn write_nc_file_multistatus( writer: W, file: &FileDto, @@ -1448,8 +1433,9 @@ async fn write_nc_file_multistatus( username: &str, subpath: &str, file_id_svc: Option<&Arc>, - favorite_ids: &HashSet, + extras: (&HashSet, &[(QualifiedName, Option)]), ) -> Result<(), String> { + let (favorite_ids, dead_props) = extras; let (file_id_map, _) = batch_resolve_ids(file_id_svc, std::slice::from_ref(&file.id), &[]).await; @@ -1468,10 +1454,10 @@ async fn write_nc_file_multistatus( &mut xml, file, &href, - file_id, - oc_id.as_deref(), + (file_id, oc_id.as_deref()), username, favorite_ids, + dead_props, )?; xml.write_event(Event::End(BytesEnd::new("d:multistatus"))) @@ -1514,6 +1500,7 @@ fn build_nc_streaming_propfind( }; let (_, folder_id_map) = batch_resolve_ids(file_id_svc, &[], std::slice::from_ref(&folder.id)).await; + let folder_dead = folder_dead_props(&state.webdav_dead_props, &folder).await; let mut buf = Vec::with_capacity(4096); { @@ -1522,7 +1509,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &subpath); let fid = folder_id_map.get(&folder.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, &folder, &href, fid, oc_id.as_deref(), &username, &folder_favs) + write_folder_response(&mut xml, &folder, &href, (fid, oc_id.as_deref()), &username, &folder_favs, &folder_dead) .map_err(std::io::Error::other)?; } yield Bytes::from(buf); @@ -1551,11 +1538,15 @@ fn build_nc_streaming_propfind( }; let file_uuids: Vec = batch.iter().map(|f| f.id.clone()).collect(); let (file_id_map, _) = batch_resolve_ids(file_id_svc, &file_uuids, &[]).await; + let mut file_deads = Vec::with_capacity(batch_len); + for file in &batch { + file_deads.push(streamed_file_dead_props(&state.webdav_dead_props, file).await); + } let mut chunk = Vec::with_capacity(batch_len * 1024); { let mut xml = Writer::new(&mut chunk); - for file in &batch { + for (file, dead) in batch.iter().zip(file_deads.iter()) { let child_sub = if subpath.is_empty() { file.name.clone() } else { @@ -1564,7 +1555,7 @@ fn build_nc_streaming_propfind( let href = nc_href(&username, &child_sub); let fid = file_id_map.get(&file.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_file_response(&mut xml, file, &href, fid, oc_id.as_deref(), &username, &favs) + write_file_response(&mut xml, file, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1600,11 +1591,15 @@ fn build_nc_streaming_propfind( }; let folder_uuids: Vec = result.items.iter().map(|sf| sf.id.clone()).collect(); let (_, sub_id_map) = batch_resolve_ids(file_id_svc, &[], &folder_uuids).await; + let mut sub_deads = Vec::with_capacity(result.items.len()); + for sf in &result.items { + sub_deads.push(folder_dead_props(&state.webdav_dead_props, sf).await); + } let mut chunk = Vec::with_capacity(result.items.len() * 1024); { let mut xml = Writer::new(&mut chunk); - for sf in &result.items { + for (sf, dead) in result.items.iter().zip(sub_deads.iter()) { let child_sub = if subpath.is_empty() { sf.name.clone() } else { @@ -1613,7 +1608,7 @@ fn build_nc_streaming_propfind( let href = nc_collection_href(&username, &child_sub); let fid = sub_id_map.get(&sf.id).copied(); let oc_id = fid.map(|id| format_oc_id(id, file_id_svc)); - write_folder_response(&mut xml, sf, &href, fid, oc_id.as_deref(), &username, &favs) + write_folder_response(&mut xml, sf, &href, (fid, oc_id.as_deref()), &username, &favs, dead) .map_err(std::io::Error::other)?; } } @@ -1648,15 +1643,20 @@ fn build_nc_streaming_propfind( .unwrap() } +/// `oc_ids` bundles `(file_id, oc_id)` — always fetched and passed +/// together (`oc_id` is derived from `file_id`) — to stay under +/// clippy's argument-count lint now that `dead_props` is also threaded +/// through. pub fn write_folder_response( xml: &mut Writer, folder: &FolderDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1727,21 +1727,26 @@ pub fn write_folder_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?; Ok(()) } +/// See `write_folder_response` for why `(file_id, oc_id)` are bundled +/// into `oc_ids`. pub fn write_file_response( xml: &mut Writer, file: &FileDto, href: &str, - file_id: Option, - oc_id: Option<&str>, + oc_ids: (Option, Option<&str>), owner: &str, favorite_ids: &HashSet, + dead_props: &[(QualifiedName, Option)], ) -> Result<(), String> { + let (file_id, oc_id) = oc_ids; xml.write_event(Event::Start(BytesStart::new("d:response"))) .xml_err()?; @@ -1816,6 +1821,8 @@ pub fn write_file_response( xml.write_event(Event::End(BytesEnd::new("d:propstat"))) .xml_err()?; + WebDavAdapter::write_dead_props_propstat(xml, dead_props).xml_err()?; + xml.write_event(Event::End(BytesEnd::new("d:response"))) .xml_err()?; diff --git a/tests/api/nc_webdav_dead_properties.hurl b/tests/api/nc_webdav_dead_properties.hurl new file mode 100644 index 00000000..e2923874 --- /dev/null +++ b/tests/api/nc_webdav_dead_properties.hurl @@ -0,0 +1,470 @@ +# ============================================================= +# OxiCloud — NextCloud WebDAV: dead-properties (RFC 4918 §4.2) +# ============================================================= +# `tests/api/webdav_dead_properties.hurl` covers the native +# `/webdav/` surface end-to-end. This file covers the same +# PROPPATCH/PROPFIND contract on the NextCloud-compatible surface +# (`/remote.php/dav/files/{user}/...`), which — until now — had NO +# generic dead-property support: PROPPATCH only special-cased +# `oc:favorite` via an ad hoc XML scan and silently discarded any +# other property while still claiming `200 OK`; PROPFIND always +# emitted a fixed hardcoded property set with no dead-property +# lookup at all. A client (or litmus) PROPPATCHing a custom label +# through the NextCloud mount got a false success and then never +# saw the property again. +# +# Coverage: +# 1. Setup: JWT login, mint an NC app password. +# 2. PUT a probe file via the NC DAV surface. +# 3. PROPPATCH set a custom property → 207. +# 4. PROPFIND → value round-trips verbatim. +# 5. PROPPATCH upsert (same name, new value) → PROPFIND confirms +# overwrite, not a duplicate row. +# 6. PROPPATCH remove → PROPFIND confirms absence. +# 7. PROPPATCH on a nonexistent resource → 404 (the tightened +# contract: PROPPATCH now does real work, so a previous +# "always claim success" no-op on a missing resource would be +# a foot-gun, not a feature). +# 8. Re-set a property, MOVE the file → PROPFIND on the new path +# still returns it (resource id is stable across MOVE). +# 9. DELETE, then PUT a fresh file at the same path → PROPFIND +# does NOT see the old marker (new resource, no leaked state). +# 10. Regression guard: `oc:favorite` PROPPATCH/PROPFIND still +# works, unaffected by the refactor from the ad hoc favorite +# scanner to generic `WebDavAdapter::parse_proppatch`. +# 11. Folder coverage: MKCOL, PROPPATCH a dead property on the +# folder, PROPFIND confirms it, cleanup. +# +# XPath assertions use `local-name()` so the test is robust against +# the server's chosen namespace prefix for dead properties (`X:`). +# +# NOTE: in Hurl, [BasicAuth] must be the LAST section before the +# blank-line/body — any request headers go above it, not below. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — JWT login, then mint an NC app password (NC DAV uses +# Basic Auth, not the JWT bearer token). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +jwt: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{jwt}} +Content-Type: application/json +{ "label": "nc_webdav_dead_properties" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a probe file through the NC DAV surface. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +hello nc dead properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PROPPATCH set a custom (dead) property. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + hello-nc-dead-property + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND confirms the round-trip. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "hello-nc-dead-property" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — Upsert: setting the same name again overwrites rather +# than duplicating (ON CONFLICT DO UPDATE at the store). +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + updated-nc-value + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "updated-nc-value" +xpath "count(//*[local-name()='testlabel'])" == 1 + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Remove the property; PROPFIND confirms absence. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='testlabel'])" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 7 — PROPPATCH against a nonexistent resource → 404. +# Prior behaviour on this handler silently no-opped +# (and still claimed success) when the body carried no +# `oc:favorite` directive; now that PROPPATCH performs +# real dead-property writes, a missing resource must be +# a hard failure, matching the native `/webdav/` handler. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-does-not-exist.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + should-not-be-stored + + + +``` + +HTTP 404 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — Re-set a marker, MOVE the file, confirm the property +# followed the resource (id-stable across MOVE — no +# store-side rename bookkeeping needed). +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + survives-nc-move + + + +``` + +HTTP 207 + + +MOVE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-probe.txt +Destination: {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +# Fresh destination → 201 (RFC 4918 §9.9.4). +HTTP 201 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "survives-nc-move" + + +# ───────────────────────────────────────────────────────────── +# Step 9 — DELETE, then PUT a fresh file at the same path: the +# old marker must NOT resurface (new resource, no leaked +# dead-property state). Whether DELETE soft-deletes to +# trash or hard-deletes, the recreated path resolves to +# a brand-new resource id with no dead-property rows of +# its own. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +fresh file at the same nc path +``` + +HTTP 201 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='testlabel'])" == 0 + + +# ───────────────────────────────────────────────────────────── +# Step 10 — Regression guard: `oc:favorite` still works after the +# PROPPATCH handler was rewritten from an ad hoc +# favorite-only scanner to generic dead-property +# handling with an `oc:favorite` special case. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 1 + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "1" + + +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 0 + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "0" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — probe file. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-moved.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Folder coverage: MKCOL, PROPPATCH, PROPFIND, cleanup. +# ───────────────────────────────────────────────────────────── +MKCOL {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 201 + + +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + nc-folder-keeps-this + + + +``` + +HTTP 207 + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='foldermark'])" == "nc-folder-keeps-this" + + +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-dead-props-folder/ +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Teardown — revoke the app password minted in Step 1. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{jwt}} + +HTTP 200 diff --git a/tests/api/run.sh b/tests/api/run.sh index a62f0406..46c09a6b 100755 --- a/tests/api/run.sh +++ b/tests/api/run.sh @@ -188,6 +188,8 @@ hurl --variables-file "$API_DIR/test.env" --file-root "$REPO_ROOT/tests" --test "$API_DIR/cross_drive_move.hurl" \ "$API_DIR/cross_drive_copy.hurl" \ "$API_DIR/webdav_dead_properties.hurl" \ + "$API_DIR/nc_webdav_dead_properties.hurl" \ + "$API_DIR/webdav_protected_properties.hurl" \ "$API_DIR/webdav_drive_root.hurl" \ "$API_DIR/webdav_permissions.hurl" \ "$API_DIR/webdav_nested_move_cascade.hurl" \ diff --git a/tests/api/webdav_protected_properties.hurl b/tests/api/webdav_protected_properties.hurl new file mode 100644 index 00000000..66219024 --- /dev/null +++ b/tests/api/webdav_protected_properties.hurl @@ -0,0 +1,368 @@ +# ============================================================= +# OxiCloud — WebDAV protected properties (RFC 4918 §9.2 / §15) +# ============================================================= +# `DeadPropertyStore` lets a PROPPATCH set arbitrary namespace/name +# pairs verbatim (RFC 4918 §4.2). Without a denylist, a client could +# PROPPATCH `DAV:getetag`, `oc:fileid`, `oc:permissions`, etc. — names +# the server ALSO emits as live state in PROPFIND/REPORT responses +# (see `write_file_response` / `write_folder_response` in the NC +# handler and the native PROPFIND writer). That produces either a +# forged live property (the server would need to pick which of two +# values to emit) or a silently stored, never-read row. +# +# `is_protected_property()` (src/application/adapters/webdav_adapter.rs) +# defends the whole `DAV:` namespace plus the specific oc:/nc:/ocs: +# names the server actually emits elsewhere. Both PROPPATCH handlers +# (native `/webdav/` and NC `/remote.php/dav/`) consult it before +# touching `DeadPropertyStore`, and reject with RFC 4918 §9.2's +# per-property `403 Forbidden` inside the 207 multi-status — not a +# blanket request failure, and not a silent no-op success. +# +# Coverage: +# 1. Native /webdav/: PROPPATCH set on `D:displayname` (DAV: +# namespace) → 207 envelope, inner 403 for that property. +# 2. PROPFIND confirms the live displayname is unchanged — the +# forged value never landed anywhere. +# 3. Native /webdav/: PROPPATCH remove on `D:getetag` → same 403 +# contract on the Remove path, not just Set. +# 4. Native /webdav/: an oc:-namespaced protected name +# (`oc:fileid`) is blocked even on the surface that doesn't +# normally speak NextCloud namespaces — protection is +# namespace-global, not surface-scoped. +# 5. Mixed request: one protected DAV: prop + one ordinary custom +# dead property in the SAME PROPPATCH → 207 with both a 403 +# propstat block and a 200 propstat block; the custom property +# DOES get stored (per-property granularity, not all-or-nothing +# rejection). +# 6. NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`) → 403; PROPFIND confirms it was never +# written to the dead-property store. +# 7. NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# 8. Regression guard: `oc:favorite` is on the protected list too +# (it's live state the NC handler emits), but the handler's +# favorite special-case runs BEFORE the protected-property +# check, so toggling favorite through PROPPATCH still works — +# protection must not swallow the one oc: name that's +# legitimately client-writable via a side channel. +# ============================================================= + + +# ───────────────────────────────────────────────────────────── +# Step 1 — Login, capture JWT; mint an NC app password for the +# NC-surface half of this file (NC DAV uses Basic Auth). +# ───────────────────────────────────────────────────────────── +POST {{base_url}}/api/auth/login +Content-Type: application/json +{ "username": "{{username}}", "password": "{{password}}" } + +HTTP 200 +[Captures] +token: jsonpath "$.access_token" + + +POST {{base_url}}/api/auth/app-passwords +Authorization: Bearer {{token}} +Content-Type: application/json +{ "label": "webdav_protected_properties" } + +HTTP 200 +[Captures] +nc_username: jsonpath "$.username" +nc_password: jsonpath "$.password" +ap_id: jsonpath "$.id" + + +# ───────────────────────────────────────────────────────────── +# Step 2 — PUT a probe file via native WebDAV. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: text/plain +``` +hello protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 3 — PROPPATCH set on DAV:displayname (live property) must +# be rejected with a per-property 403, not silently +# accepted into DeadPropertyStore. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged-name + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 4 — PROPFIND confirms the live displayname is untouched. +# ───────────────────────────────────────────────────────────── +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='displayname'])" == "protected-props-probe.txt" + + +# ───────────────────────────────────────────────────────────── +# Step 5 — PROPPATCH remove on DAV:getetag → same 403 contract +# on the Remove path. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 6 — Protection is namespace-global: an oc:-namespaced +# protected name is blocked even on the native /webdav/ +# surface, which doesn't otherwise speak NextCloud +# namespaces. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + should-not-be-stored + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 7 — Mixed request: one protected DAV: prop + one ordinary +# custom dead property in the SAME PROPPATCH → per- +# property granularity, not all-or-nothing rejection. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Content-Type: application/xml; charset=utf-8 +``` + + + + + forged + allowed-alongside-protected + + + +``` + +HTTP 207 +[Asserts] +xpath "count(//*[local-name()='propstat'])" == 2 +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='resourcetype']]/*[local-name()='status'])" contains "403" +xpath "string(//*[local-name()='propstat'][*[local-name()='prop']/*[local-name()='testlabel']]/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} +Depth: 0 +Content-Type: application/xml; charset=utf-8 +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='testlabel'])" == "allowed-alongside-protected" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — native probe file. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/webdav/protected-props-probe.txt +Authorization: Bearer {{token}} + +HTTP 204 + + +# ───────────────────────────────────────────────────────────── +# Step 8 — NC surface: PUT a probe file via the NC DAV mount. +# ───────────────────────────────────────────────────────────── +PUT {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: text/plain +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` +hello nc protected properties +``` + +HTTP 201 + + +# ───────────────────────────────────────────────────────────── +# Step 9 — NC surface: PROPPATCH set on a protected oc: name +# (`oc:permissions`, not the specially-handled favorite) +# → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='permissions'])" != "forged" + + +# ───────────────────────────────────────────────────────────── +# Step 10 — NC surface: PROPPATCH set on a protected nc: name +# (`nc:has-preview`) → 403. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + forged + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "403" + + +# ───────────────────────────────────────────────────────────── +# Step 11 — Regression guard: oc:favorite is on the protected +# list too, but the handler's favorite special-case +# runs before the protection check, so toggling it via +# PROPPATCH must still work end to end. +# ───────────────────────────────────────────────────────────── +PROPPATCH {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + + 1 + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='propstat']/*[local-name()='status'])" contains "200 OK" + + +PROPFIND {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +Depth: 0 +Content-Type: application/xml; charset=utf-8 +[BasicAuth] +{{nc_username}}: {{nc_password}} +``` + + + + +``` + +HTTP 207 +[Asserts] +xpath "string(//*[local-name()='favorite'])" == "1" + + +# ───────────────────────────────────────────────────────────── +# Cleanup — NC probe file, teardown app password. +# ───────────────────────────────────────────────────────────── +DELETE {{base_url}}/remote.php/dav/files/{{username}}/nc-protected-props-probe.txt +[BasicAuth] +{{nc_username}}: {{nc_password}} + +HTTP 204 + + +DELETE {{base_url}}/api/auth/app-passwords/{{ap_id}} +Authorization: Bearer {{token}} + +HTTP 200