aster_forge_webdav/
resource.rs

1//! Resource mutation path rules and response composition.
2
3use http::header::{CACHE_CONTROL, CONTENT_LOCATION, CONTENT_TYPE};
4use http::{HeaderValue, StatusCode};
5
6use crate::response::no_store_empty_response;
7use crate::{
8    DavBackendError, DavErrorCondition, DavFileSystem, DavMultiStatusError, DavMultiStatusItem,
9    DavMultiStatusLimits, DavPath, DavResourceKind, DavResponse, Depth, FsError,
10    dav_multistatus_bytes, href_for_dav_path,
11};
12
13/// COPY or MOVE operation selected by the request method.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum DavCopyMoveMethod {
16    Copy,
17    Move,
18}
19
20/// Resource-shape decisions needed by the Drive mutation adapter.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub struct DavCopyMovePlan {
23    pub recursive_collection: bool,
24    pub destination_deep: bool,
25}
26
27/// Protocol failure selected after source/destination metadata is known.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
29pub enum DavMutationPlanError {
30    #[error("invalid mutation Depth")]
31    BadRequest,
32    #[error("resource mutation is not supported for this target")]
33    MethodNotAllowed,
34    #[error("resource mutation conflicts with the current hierarchy")]
35    Conflict,
36    #[error("forbidden mutation path relation")]
37    Forbidden,
38    #[error("destination exists while Overwrite is disabled")]
39    PreconditionFailed,
40}
41
42/// Failure while checking the canonical parent collection of a mutation target.
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
44pub enum DavParentCollectionError {
45    /// The target is the mount root and therefore has no mutable parent.
46    #[error("resource mutation is not supported for this target")]
47    MethodNotAllowed,
48    /// The canonical parent is missing or is not a collection.
49    #[error("resource mutation conflicts with the current hierarchy")]
50    Conflict,
51    /// The filesystem backend could not resolve the parent.
52    #[error(transparent)]
53    Backend(#[from] DavBackendError),
54}
55
56/// Rejects collection creation for the DAV root resource.
57///
58/// # Errors
59///
60/// Returns [`DavMutationPlanError`] when the collection target or parent state is invalid.
61pub fn validate_collection_create_target(path: &str) -> Result<(), DavMutationPlanError> {
62    if resource_identity_path(path) == "/" {
63        Err(DavMutationPlanError::MethodNotAllowed)
64    } else {
65        Ok(())
66    }
67}
68
69/// Requires the canonical parent of a mutation target to exist as a collection.
70///
71/// The DAV mount root is an implicit collection and does not require a backend lookup. A target
72/// without a parent identifies the mount root itself and is rejected for mutation.
73///
74/// # Errors
75///
76/// Returns a typed failure when parent metadata lookup fails or the parent is unsuitable.
77pub async fn enforce_parent_collection(
78    filesystem: &dyn DavFileSystem,
79    target: &DavPath,
80) -> Result<(), DavParentCollectionError> {
81    let Some(parent) = target.parent() else {
82        return Err(DavParentCollectionError::MethodNotAllowed);
83    };
84    if parent == DavPath::root() {
85        return Ok(());
86    }
87    match filesystem.metadata(&parent).await {
88        Ok(metadata) if metadata.is_dir() => Ok(()),
89        Ok(_) | Err(FsError::NotFound) => Err(DavParentCollectionError::Conflict),
90        Err(error) => Err(DavParentCollectionError::Backend(error.into())),
91    }
92}
93
94/// Failure while composing a mutation success response header.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
96#[error("invalid mutation Content-Location response header")]
97pub struct DavMutationResponseError;
98
99/// One resource-level failure from a recursive COPY, MOVE, or DELETE operation.
100#[derive(Debug, Clone, PartialEq, Eq)]
101pub struct DavMutationFailure {
102    path: DavPath,
103    status: u16,
104    lock_path: Option<DavPath>,
105}
106
107impl DavMutationFailure {
108    /// Creates a recursive mutation failure caused by an unsubmitted lock token.
109    #[must_use]
110    pub fn locked(path: DavPath, lock_path: DavPath) -> Self {
111        Self {
112            path,
113            status: StatusCode::LOCKED.as_u16(),
114            lock_path: Some(lock_path),
115        }
116    }
117
118    /// Creates a recursive mutation failure with an explicit protocol status.
119    #[must_use]
120    pub fn status(path: DavPath, status: u16) -> Self {
121        Self {
122            path,
123            status,
124            lock_path: None,
125        }
126    }
127
128    /// Returns the resource whose mutation failed.
129    #[must_use]
130    pub fn path(&self) -> &DavPath {
131        &self.path
132    }
133
134    /// Returns the HTTP status associated with this resource failure.
135    #[must_use]
136    pub const fn status_code(&self) -> u16 {
137        self.status
138    }
139
140    /// Returns the lock root whose token was not submitted, when applicable.
141    #[must_use]
142    pub fn lock_path(&self) -> Option<&DavPath> {
143        self.lock_path.as_ref()
144    }
145
146    /// Converts this typed failure into the shared Multi-Status response grammar.
147    #[must_use]
148    pub fn to_multistatus_item(&self, prefix: &str) -> DavMultiStatusItem {
149        let item = DavMultiStatusItem::status(href_for_dav_path(prefix, &self.path), self.status);
150        if self.status == StatusCode::LOCKED.as_u16() {
151            let lock_path = self.lock_path.as_ref().unwrap_or(&self.path);
152            item.with_error(DavErrorCondition::LockTokenSubmitted {
153                href: href_for_dav_path(prefix, lock_path),
154            })
155        } else {
156            item
157        }
158    }
159}
160
161/// Enforces collection DELETE Depth after product metadata resolution.
162///
163/// # Errors
164///
165/// Returns [`DavMutationPlanError`] when DELETE targets the mount root or has invalid state.
166pub fn validate_delete_target(
167    kind: DavResourceKind,
168    depth: Depth,
169) -> Result<(), DavMutationPlanError> {
170    if kind == DavResourceKind::Collection && !depth.is_infinity() {
171        Err(DavMutationPlanError::BadRequest)
172    } else {
173        Ok(())
174    }
175}
176
177/// Plans COPY/MOVE resource-shape behavior after product metadata resolution.
178///
179/// # Errors
180///
181/// Returns [`DavMutationPlanError`] when source and destination relationships are invalid.
182pub fn plan_copy_move_request(
183    method: DavCopyMoveMethod,
184    depth: Depth,
185    source_kind: DavResourceKind,
186    destination_kind: Option<DavResourceKind>,
187    source_path: &str,
188    destination_path: &str,
189    overwrite: bool,
190) -> Result<DavCopyMovePlan, DavMutationPlanError> {
191    if same_resource_path(source_path, destination_path) {
192        return Err(DavMutationPlanError::Forbidden);
193    }
194    if source_kind == DavResourceKind::Collection {
195        match method {
196            DavCopyMoveMethod::Move if !depth.is_infinity() => {
197                return Err(DavMutationPlanError::BadRequest);
198            }
199            DavCopyMoveMethod::Copy if depth == Depth::One => {
200                return Err(DavMutationPlanError::BadRequest);
201            }
202            DavCopyMoveMethod::Copy | DavCopyMoveMethod::Move => {}
203        }
204    }
205    let recursive_collection = source_kind == DavResourceKind::Collection
206        && (method == DavCopyMoveMethod::Move || depth != Depth::Zero);
207    if recursive_collection && is_descendant_path(source_path, destination_path) {
208        return Err(DavMutationPlanError::Forbidden);
209    }
210    if !overwrite && destination_kind.is_some() {
211        return Err(DavMutationPlanError::PreconditionFailed);
212    }
213    let destination_deep = destination_kind == Some(DavResourceKind::Collection)
214        || source_kind == DavResourceKind::Collection
215            && (method == DavCopyMoveMethod::Move || depth != Depth::Zero);
216    Ok(DavCopyMovePlan {
217        recursive_collection,
218        destination_deep,
219    })
220}
221
222/// Builds an empty response for resource-shape validation failure.
223#[must_use]
224pub fn mutation_plan_error_response(error: DavMutationPlanError) -> DavResponse {
225    let status = match error {
226        DavMutationPlanError::BadRequest => StatusCode::BAD_REQUEST,
227        DavMutationPlanError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
228        DavMutationPlanError::Conflict => StatusCode::CONFLICT,
229        DavMutationPlanError::Forbidden => StatusCode::FORBIDDEN,
230        DavMutationPlanError::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
231    };
232    no_store_empty_response(status)
233}
234
235/// Builds the successful MKCOL response.
236///
237/// # Errors
238///
239/// Returns [`DavMutationResponseError`] when `Content-Location` cannot be encoded.
240pub fn collection_created_response(
241    prefix: &str,
242    path: &DavPath,
243) -> Result<DavResponse, DavMutationResponseError> {
244    let mut response = DavResponse::empty(StatusCode::CREATED);
245    let location = HeaderValue::from_str(&href_for_dav_path(prefix, path))
246        .map_err(|_| DavMutationResponseError)?;
247    response.headers.insert(CONTENT_LOCATION, location);
248    Ok(response)
249}
250
251/// Builds the successful DELETE response.
252#[must_use]
253pub fn delete_success_response() -> DavResponse {
254    DavResponse::empty(StatusCode::NO_CONTENT)
255}
256
257/// Compares DAV resource identity while ignoring collection trailing slashes.
258#[must_use]
259pub fn same_resource_path(left: &str, right: &str) -> bool {
260    resource_identity_path(left) == resource_identity_path(right)
261}
262
263/// Returns whether `child` is strictly below `parent` on a DAV path-segment boundary.
264#[must_use]
265pub fn is_descendant_path(parent: &str, child: &str) -> bool {
266    let parent = resource_identity_path(parent);
267    let child = resource_identity_path(child);
268    if parent == "/" || parent == child {
269        return false;
270    }
271    child.starts_with(&format!("{parent}/"))
272}
273
274/// Re-roots a canonical descendant path from one mutation tree to another.
275///
276/// Only a complete path-segment prefix is stripped. An unmatched path is attached to the
277/// destination root unchanged so callers can preserve the original hierarchy in failure paths.
278#[must_use]
279pub fn replace_relative_prefix(
280    path: &str,
281    source_prefix: &str,
282    destination_prefix: &str,
283) -> String {
284    let source_prefix = source_prefix.trim_end_matches('/');
285    let destination_prefix = destination_prefix.trim_end_matches('/');
286    let suffix = path
287        .strip_prefix(source_prefix)
288        .filter(|suffix| suffix.is_empty() || suffix.starts_with('/'))
289        .unwrap_or(path);
290    if suffix.is_empty() {
291        format!("{destination_prefix}/")
292    } else {
293        format!("{destination_prefix}{suffix}")
294    }
295}
296
297/// Builds the cache-safe 201/204 response selected by destination existence.
298#[must_use]
299pub fn mutation_success_response(destination_existed: bool) -> DavResponse {
300    let status = if destination_existed {
301        StatusCode::NO_CONTENT
302    } else {
303        StatusCode::CREATED
304    };
305    no_store_empty_response(status)
306}
307
308/// Builds a 207 response for typed recursive mutation failures.
309///
310/// # Errors
311///
312/// Returns [`DavMultiStatusError`] when the failure response exceeds default limits.
313pub fn mutation_multistatus_response(
314    prefix: &str,
315    failures: &[DavMutationFailure],
316) -> Result<DavResponse, DavMultiStatusError> {
317    mutation_multistatus_response_with_limits(prefix, failures, DavMultiStatusLimits::default())
318}
319
320/// Builds a bounded 207 response for typed recursive mutation failures.
321///
322/// # Errors
323///
324/// Returns [`DavMultiStatusError`] when the failure response exceeds supplied limits.
325pub fn mutation_multistatus_response_with_limits(
326    prefix: &str,
327    failures: &[DavMutationFailure],
328    limits: DavMultiStatusLimits,
329) -> Result<DavResponse, DavMultiStatusError> {
330    let items = failures
331        .iter()
332        .map(|failure| failure.to_multistatus_item(prefix));
333    let body = dav_multistatus_bytes(items, limits)?;
334    let mut response = DavResponse::bytes(StatusCode::MULTI_STATUS, body);
335    response.headers.insert(
336        CONTENT_TYPE,
337        HeaderValue::from_static("application/xml; charset=utf-8"),
338    );
339    response
340        .headers
341        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
342    Ok(response)
343}
344
345/// Normalizes a DAV resource identity by removing collection trailing slashes.
346#[must_use]
347pub fn resource_identity_path(path: &str) -> String {
348    let trimmed = path.trim_end_matches('/');
349    if trimmed.is_empty() {
350        "/".to_string()
351    } else {
352        trimmed.to_string()
353    }
354}