Skip to main content

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    DavErrorCondition, DavFileSystem, DavMultiStatusItem, DavPath, DavResourceKind, DavResponse,
9    DavXmlError, Depth, FsError, backend_error_response, dav_multistatus_element,
10    href_for_dav_path, parent_relative_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/// Rejects collection creation for the DAV root resource.
43pub fn validate_collection_create_target(path: &str) -> Result<(), DavMutationPlanError> {
44    if resource_identity_path(path) == "/" {
45        Err(DavMutationPlanError::MethodNotAllowed)
46    } else {
47        Ok(())
48    }
49}
50
51/// Requires the canonical parent of a mutation target to exist as a collection.
52///
53/// The DAV mount root is an implicit collection and does not require a backend lookup. A target
54/// without a parent identifies the mount root itself and is rejected for mutation.
55pub async fn enforce_parent_collection(
56    filesystem: &dyn DavFileSystem,
57    target: &DavPath,
58) -> Result<(), DavResponse> {
59    let Some(parent) = parent_relative_path(target.as_str()) else {
60        return Err(mutation_plan_error_response(
61            DavMutationPlanError::MethodNotAllowed,
62        ));
63    };
64    if parent == "/" {
65        return Ok(());
66    }
67    let parent = match DavPath::new(&parent) {
68        Ok(parent) => parent,
69        Err(_) => {
70            return Err(mutation_plan_error_response(
71                DavMutationPlanError::BadRequest,
72            ));
73        }
74    };
75    match filesystem.metadata(&parent).await {
76        Ok(metadata) if metadata.is_dir() => Ok(()),
77        Ok(_) | Err(FsError::NotFound) => {
78            Err(mutation_plan_error_response(DavMutationPlanError::Conflict))
79        }
80        Err(error) => Err(backend_error_response(&error.into())),
81    }
82}
83
84/// Failure while composing a mutation success response header.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
86#[error("invalid mutation Content-Location response header")]
87pub struct DavMutationResponseError;
88
89/// One resource-level failure from a recursive COPY, MOVE, or DELETE operation.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct DavMutationFailure {
92    path: DavPath,
93    status: u16,
94    lock_path: Option<DavPath>,
95}
96
97impl DavMutationFailure {
98    /// Creates a recursive mutation failure caused by an unsubmitted lock token.
99    #[must_use]
100    pub fn locked(path: DavPath, lock_path: DavPath) -> Self {
101        Self {
102            path,
103            status: StatusCode::LOCKED.as_u16(),
104            lock_path: Some(lock_path),
105        }
106    }
107
108    /// Creates a recursive mutation failure with an explicit protocol status.
109    #[must_use]
110    pub fn status(path: DavPath, status: u16) -> Self {
111        Self {
112            path,
113            status,
114            lock_path: None,
115        }
116    }
117}
118
119/// Enforces collection DELETE Depth after product metadata resolution.
120pub fn validate_delete_target(
121    kind: DavResourceKind,
122    depth: Depth,
123) -> Result<(), DavMutationPlanError> {
124    if kind == DavResourceKind::Collection && !depth.is_infinity() {
125        Err(DavMutationPlanError::BadRequest)
126    } else {
127        Ok(())
128    }
129}
130
131/// Plans COPY/MOVE resource-shape behavior after product metadata resolution.
132pub fn plan_copy_move_request(
133    method: DavCopyMoveMethod,
134    depth: Depth,
135    source_kind: DavResourceKind,
136    destination_kind: Option<DavResourceKind>,
137    source_path: &str,
138    destination_path: &str,
139    overwrite: bool,
140) -> Result<DavCopyMovePlan, DavMutationPlanError> {
141    if same_resource_path(source_path, destination_path) {
142        return Err(DavMutationPlanError::Forbidden);
143    }
144    if source_kind == DavResourceKind::Collection {
145        match method {
146            DavCopyMoveMethod::Move if !depth.is_infinity() => {
147                return Err(DavMutationPlanError::BadRequest);
148            }
149            DavCopyMoveMethod::Copy if depth == Depth::One => {
150                return Err(DavMutationPlanError::BadRequest);
151            }
152            DavCopyMoveMethod::Copy | DavCopyMoveMethod::Move => {}
153        }
154    }
155    let recursive_collection = source_kind == DavResourceKind::Collection
156        && (method == DavCopyMoveMethod::Move || depth != Depth::Zero);
157    if recursive_collection && is_descendant_path(source_path, destination_path) {
158        return Err(DavMutationPlanError::Forbidden);
159    }
160    if !overwrite && destination_kind.is_some() {
161        return Err(DavMutationPlanError::PreconditionFailed);
162    }
163    let destination_deep = destination_kind == Some(DavResourceKind::Collection)
164        || source_kind == DavResourceKind::Collection
165            && (method == DavCopyMoveMethod::Move || depth != Depth::Zero);
166    Ok(DavCopyMovePlan {
167        recursive_collection,
168        destination_deep,
169    })
170}
171
172/// Builds an empty response for resource-shape validation failure.
173#[must_use]
174pub fn mutation_plan_error_response(error: DavMutationPlanError) -> DavResponse {
175    let status = match error {
176        DavMutationPlanError::BadRequest => StatusCode::BAD_REQUEST,
177        DavMutationPlanError::MethodNotAllowed => StatusCode::METHOD_NOT_ALLOWED,
178        DavMutationPlanError::Conflict => StatusCode::CONFLICT,
179        DavMutationPlanError::Forbidden => StatusCode::FORBIDDEN,
180        DavMutationPlanError::PreconditionFailed => StatusCode::PRECONDITION_FAILED,
181    };
182    no_store_empty_response(status)
183}
184
185/// Builds the successful MKCOL response.
186pub fn collection_created_response(
187    prefix: &str,
188    path: &DavPath,
189) -> Result<DavResponse, DavMutationResponseError> {
190    let mut response = DavResponse::empty(StatusCode::CREATED);
191    let location = HeaderValue::from_str(&href_for_dav_path(prefix, path))
192        .map_err(|_| DavMutationResponseError)?;
193    response.headers.insert(CONTENT_LOCATION, location);
194    Ok(response)
195}
196
197/// Builds the successful DELETE response.
198#[must_use]
199pub fn delete_success_response() -> DavResponse {
200    DavResponse::empty(StatusCode::NO_CONTENT)
201}
202
203/// Compares DAV resource identity while ignoring collection trailing slashes.
204#[must_use]
205pub fn same_resource_path(left: &str, right: &str) -> bool {
206    resource_identity_path(left) == resource_identity_path(right)
207}
208
209/// Returns whether `child` is strictly below `parent` on a DAV path-segment boundary.
210#[must_use]
211pub fn is_descendant_path(parent: &str, child: &str) -> bool {
212    let parent = resource_identity_path(parent);
213    let child = resource_identity_path(child);
214    if parent == "/" || parent == child {
215        return false;
216    }
217    child.starts_with(&format!("{parent}/"))
218}
219
220/// Re-roots a canonical descendant path from one mutation tree to another.
221///
222/// Only a complete path-segment prefix is stripped. An unmatched path is attached to the
223/// destination root unchanged so callers can preserve the original hierarchy in failure paths.
224#[must_use]
225pub fn replace_relative_prefix(
226    path: &str,
227    source_prefix: &str,
228    destination_prefix: &str,
229) -> String {
230    let source_prefix = source_prefix.trim_end_matches('/');
231    let destination_prefix = destination_prefix.trim_end_matches('/');
232    let suffix = path
233        .strip_prefix(source_prefix)
234        .filter(|suffix| suffix.is_empty() || suffix.starts_with('/'))
235        .unwrap_or(path);
236    if suffix.is_empty() {
237        format!("{destination_prefix}/")
238    } else {
239        format!("{destination_prefix}{suffix}")
240    }
241}
242
243/// Builds the cache-safe 201/204 response selected by destination existence.
244#[must_use]
245pub fn mutation_success_response(destination_existed: bool) -> DavResponse {
246    let status = if destination_existed {
247        StatusCode::NO_CONTENT
248    } else {
249        StatusCode::CREATED
250    };
251    no_store_empty_response(status)
252}
253
254/// Builds a 207 response for typed recursive mutation failures.
255pub fn mutation_multistatus_response(
256    prefix: &str,
257    failures: &[DavMutationFailure],
258) -> Result<DavResponse, DavXmlError> {
259    let items = failures
260        .iter()
261        .map(|failure| {
262            let item = DavMultiStatusItem::status(
263                href_for_dav_path(prefix, &failure.path),
264                failure.status,
265            );
266            if failure.status == StatusCode::LOCKED.as_u16() {
267                let lock_path = failure.lock_path.as_ref().unwrap_or(&failure.path);
268                item.with_error(DavErrorCondition::LockTokenSubmitted {
269                    href: href_for_dav_path(prefix, lock_path),
270                })
271            } else {
272                item
273            }
274        })
275        .collect();
276    let body = dav_multistatus_element(items).to_bytes()?;
277    let mut response = DavResponse::bytes(StatusCode::MULTI_STATUS, body);
278    response.headers.insert(
279        CONTENT_TYPE,
280        HeaderValue::from_static("application/xml; charset=utf-8"),
281    );
282    response
283        .headers
284        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
285    Ok(response)
286}
287
288/// Normalizes a DAV resource identity by removing collection trailing slashes.
289#[must_use]
290pub fn resource_identity_path(path: &str) -> String {
291    let trimmed = path.trim_end_matches('/');
292    if trimmed.is_empty() {
293        "/".to_string()
294    } else {
295        trimmed.to_string()
296    }
297}