aster_forge_webdav/
lock.rs

1//! LOCK/UNLOCK protocol request planning and response composition.
2
3use std::time::Duration;
4
5use http::header::{CACHE_CONTROL, CONTENT_TYPE};
6use http::{HeaderMap, HeaderValue, StatusCode};
7
8use crate::DavLockSystem;
9use crate::protocol::submitted_mutation_lock_tokens;
10use crate::response::{no_store_empty_response, xml_request_error_response};
11use crate::{
12    DavBackendError, DavErrorCondition, DavLock, DavLockXml, DavMutationCredentials, DavPath,
13    DavProtocolError, DavRequestHead, DavResponse, DavXmlElement, DavXmlError, IfHeader,
14    dav_error_element, dav_lock_discovery_element, dav_lock_response_element, href_for_dav_path,
15    parse_lock_request, parse_lock_timeout, submitted_lock_tokens,
16};
17
18/// Backend operation selected from a LOCK request.
19#[derive(Debug, Clone, PartialEq, Eq)]
20pub enum DavLockPlan {
21    Acquire {
22        owner: Option<DavXmlElement>,
23        timeout: Duration,
24        shared: bool,
25        deep: bool,
26    },
27    Refresh {
28        token: String,
29        timeout: Duration,
30    },
31}
32
33/// Failure while parsing and selecting a LOCK operation.
34#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum DavLockPlanError {
36    #[error(transparent)]
37    Protocol(#[from] DavProtocolError),
38    #[error(transparent)]
39    Xml(#[from] DavXmlError),
40}
41
42/// Failure while enforcing lock-token submission for a mutation.
43#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
44pub enum DavLockEnforcementError {
45    /// The lock backend could not be queried.
46    #[error(transparent)]
47    Backend(#[from] DavBackendError),
48    /// A conflicting lock did not have a matching submitted token.
49    #[error("a conflicting lock token was not submitted for `{path:?}`")]
50    Conflict { path: DavPath },
51}
52
53/// Rejects an operation when a conflicting lock token was not submitted for its lock root.
54///
55/// # Errors
56///
57/// Returns a typed failure when lock lookup fails or a conflicting lock is not submitted.
58pub async fn enforce_unlocked(
59    lock_system: &dyn DavLockSystem,
60    path: &DavPath,
61    deep: bool,
62    prefix: &str,
63    if_header: Option<&IfHeader>,
64    request_scheme: &str,
65    request_host: &str,
66) -> Result<DavMutationCredentials, DavLockEnforcementError> {
67    let evaluation = evaluate_lock_conflicts(
68        lock_system,
69        path,
70        deep,
71        prefix,
72        if_header,
73        request_scheme,
74        request_host,
75    )
76    .await
77    .map_err(DavLockEnforcementError::from)?;
78    if let Some(lock) = evaluation.unsubmitted.into_iter().next() {
79        return Err(DavLockEnforcementError::Conflict { path: *lock.path });
80    }
81    Ok(evaluation.credentials)
82}
83
84struct DavLockConflictEvaluation {
85    credentials: DavMutationCredentials,
86    unsubmitted: Vec<crate::DavLock>,
87}
88
89async fn evaluate_lock_conflicts(
90    lock_system: &dyn DavLockSystem,
91    path: &DavPath,
92    deep: bool,
93    prefix: &str,
94    if_header: Option<&IfHeader>,
95    request_scheme: &str,
96    request_host: &str,
97) -> Result<DavLockConflictEvaluation, DavBackendError> {
98    let conflicts = lock_system.conflicting_locks(path, deep).await?;
99    let mut credentials = DavMutationCredentials::default();
100    let mut unsubmitted = Vec::new();
101    for (index, lock) in conflicts.iter().enumerate() {
102        if conflicts[..index]
103            .iter()
104            .any(|previous| previous.path == lock.path)
105        {
106            continue;
107        }
108        let lock_href = href_for_dav_path(prefix, &lock.path);
109        let submitted_tokens = if_header.map_or_else(Vec::new, |if_header| {
110            submitted_mutation_lock_tokens(if_header, &lock_href, request_scheme, request_host)
111        });
112        let matching_tokens = conflicts
113            .iter()
114            .filter(|candidate| candidate.path == lock.path)
115            .filter_map(|candidate| {
116                submitted_tokens
117                    .iter()
118                    .find(|token| *token == &candidate.token)
119                    .cloned()
120            })
121            .collect::<Vec<_>>();
122        if matching_tokens.is_empty() {
123            unsubmitted.push(lock.clone());
124        } else {
125            credentials.submitted_lock_tokens.extend(matching_tokens);
126        }
127    }
128    credentials.submitted_lock_tokens.sort();
129    credentials.submitted_lock_tokens.dedup();
130    Ok(DavLockConflictEvaluation {
131        credentials,
132        unsubmitted,
133    })
134}
135
136/// Returns conflicting locks whose tokens were not submitted for their corresponding lock root.
137///
138/// # Errors
139///
140/// Returns [`DavBackendError`] when the product lock backend cannot query conflicting locks.
141pub async fn unsubmitted_lock_conflicts(
142    lock_system: &dyn DavLockSystem,
143    path: &DavPath,
144    deep: bool,
145    prefix: &str,
146    if_header: Option<&IfHeader>,
147    request_scheme: &str,
148    request_host: &str,
149) -> Result<Vec<crate::DavLock>, DavBackendError> {
150    Ok(evaluate_lock_conflicts(
151        lock_system,
152        path,
153        deep,
154        prefix,
155        if_header,
156        request_scheme,
157        request_host,
158    )
159    .await?
160    .unsubmitted)
161}
162
163/// Applies [`enforce_unlocked`] to the canonical parent of a mutation target.
164///
165/// # Errors
166///
167/// Returns a typed failure when the parent lock lookup or submission check fails.
168pub async fn enforce_parent_unlocked(
169    lock_system: &dyn DavLockSystem,
170    path: &DavPath,
171    prefix: &str,
172    if_header: Option<&IfHeader>,
173    request_scheme: &str,
174    request_host: &str,
175) -> Result<DavMutationCredentials, DavLockEnforcementError> {
176    let Some(parent_path) = path.parent() else {
177        return Ok(DavMutationCredentials::default());
178    };
179    enforce_unlocked(
180        lock_system,
181        &parent_path,
182        false,
183        prefix,
184        if_header,
185        request_scheme,
186        request_host,
187    )
188    .await
189}
190
191/// Selects lock acquisition or refresh and validates all protocol-owned inputs.
192///
193/// # Errors
194///
195/// Returns [`DavLockPlanError`] when LOCK headers, body, target state, or timeout is invalid.
196pub fn plan_lock_request(
197    headers: &HeaderMap,
198    body: &[u8],
199    request_head: &DavRequestHead,
200    prefix: &str,
201    maximum_timeout: Duration,
202) -> Result<DavLockPlan, DavLockPlanError> {
203    let timeout = parse_lock_timeout(headers, maximum_timeout)?;
204    if body.is_empty() {
205        let request_href = href_for_dav_path(prefix, &request_head.target);
206        let tokens = request_head
207            .if_header
208            .as_ref()
209            .map_or_else(Vec::new, |if_header| {
210                submitted_lock_tokens(
211                    if_header,
212                    &request_href,
213                    &request_head.origin.scheme,
214                    &request_head.origin.host,
215                )
216            });
217        if tokens.len() != 1 {
218            return Err(DavProtocolError::bad_request("Invalid LOCK refresh token").into());
219        }
220        return Ok(DavLockPlan::Refresh {
221            token: tokens[0].clone(),
222            timeout,
223        });
224    }
225
226    let request = parse_lock_request(body)?;
227    let depth = request_head
228        .depth
229        .ok_or_else(|| DavProtocolError::bad_request("LOCK Depth was not parsed"))?;
230    Ok(DavLockPlan::Acquire {
231        owner: request.owner,
232        timeout,
233        shared: request.shared,
234        deep: depth.is_infinity(),
235    })
236}
237
238fn lock_success_response(
239    lock: &DavLock,
240    status: StatusCode,
241    prefix: &str,
242    include_lock_token_header: bool,
243) -> Result<DavResponse, DavXmlError> {
244    let body = dav_lock_response_element(&[DavLockXml {
245        token: lock.token.clone(),
246        owner: lock.owner.as_deref().cloned(),
247        timeout: lock.timeout,
248        shared: lock.shared,
249        deep: lock.deep,
250        root_href: href_for_dav_path(prefix, &lock.path),
251    }])
252    .to_bytes()?;
253    let mut response = DavResponse::bytes(status, body);
254    response.headers.insert(
255        CONTENT_TYPE,
256        HeaderValue::from_static("application/xml; charset=utf-8"),
257    );
258    if include_lock_token_header {
259        let value = HeaderValue::from_str(&format!("<{}>", lock.token))
260            .map_err(|_| DavXmlError::Malformed)?;
261        response.headers.insert("Lock-Token", value);
262    }
263    Ok(response)
264}
265
266/// Builds the successful response for a LOCK refresh.
267///
268/// # Errors
269///
270/// Returns [`DavXmlError`] when the refreshed lock response cannot be encoded.
271pub fn lock_refresh_success_response(
272    lock: &DavLock,
273    prefix: &str,
274) -> Result<DavResponse, DavXmlError> {
275    lock_success_response(lock, StatusCode::OK, prefix, false)
276}
277
278/// Builds the 200/201 response for a LOCK acquisition.
279///
280/// # Errors
281///
282/// Returns [`DavXmlError`] when the acquired lock response cannot be encoded.
283pub fn lock_acquire_success_response(
284    lock: &DavLock,
285    prefix: &str,
286    resource_existed: bool,
287) -> Result<DavResponse, DavXmlError> {
288    lock_success_response(
289        lock,
290        if resource_existed {
291            StatusCode::OK
292        } else {
293            StatusCode::CREATED
294        },
295        prefix,
296        true,
297    )
298}
299
300/// Builds the `DAV:lockdiscovery` property from backend lock values.
301#[must_use]
302pub fn lock_discovery_element(locks: &[DavLock], prefix: &str) -> DavXmlElement {
303    let locks = locks
304        .iter()
305        .map(|lock| DavLockXml {
306            token: lock.token.clone(),
307            owner: lock.owner.as_deref().cloned(),
308            timeout: lock.timeout,
309            shared: lock.shared,
310            deep: lock.deep,
311            root_href: href_for_dav_path(prefix, &lock.path),
312        })
313        .collect::<Vec<_>>();
314    dav_lock_discovery_element(&locks)
315}
316
317/// Builds a 423 response identifying the lock whose token must be submitted.
318///
319/// # Errors
320///
321/// Returns [`DavXmlError`] when the lock-conflict response cannot be encoded.
322pub fn lock_conflict_response(prefix: &str, path: &DavPath) -> Result<DavResponse, DavXmlError> {
323    lock_condition_response(
324        StatusCode::LOCKED,
325        &DavErrorCondition::LockTokenSubmitted {
326            href: href_for_dav_path(prefix, path),
327        },
328    )
329}
330
331/// Builds the 409 response for an UNLOCK token that does not match the request URI.
332///
333/// # Errors
334///
335/// Returns [`DavXmlError`] when the token-mismatch response cannot be encoded.
336pub fn unlock_token_mismatch_response() -> Result<DavResponse, DavXmlError> {
337    lock_condition_response(
338        StatusCode::CONFLICT,
339        &DavErrorCondition::LockTokenMatchesRequestUri,
340    )
341}
342
343/// Builds the successful cache-safe UNLOCK response.
344#[must_use]
345pub fn unlock_success_response() -> DavResponse {
346    no_store_empty_response(StatusCode::NO_CONTENT)
347}
348
349/// Builds the active-lock capacity response.
350#[must_use]
351pub fn lock_limit_response() -> DavResponse {
352    let mut response = DavResponse::bytes(
353        StatusCode::INSUFFICIENT_STORAGE,
354        "WebDAV active lock limit exceeded",
355    );
356    response.headers.insert(
357        CONTENT_TYPE,
358        HeaderValue::from_static("text/plain; charset=utf-8"),
359    );
360    response
361        .headers
362        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
363    response
364}
365
366/// Maps LOCK XML failures to their protocol response.
367///
368/// # Errors
369///
370/// Returns [`DavXmlError`] when the lock XML error response cannot be encoded.
371pub fn lock_xml_error_response(error: DavXmlError) -> Result<DavResponse, DavXmlError> {
372    xml_request_error_response(error, "Invalid LOCK body")
373}
374
375fn lock_condition_response(
376    status: StatusCode,
377    condition: &DavErrorCondition,
378) -> Result<DavResponse, DavXmlError> {
379    let mut response = DavResponse::bytes(status, dav_error_element(condition).to_bytes()?);
380    response.headers.insert(
381        CONTENT_TYPE,
382        HeaderValue::from_static("application/xml; charset=utf-8"),
383    );
384    response
385        .headers
386        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
387    Ok(response)
388}