Skip to main content

aster_forge_webdav/
put.rs

1//! PUT request planning and success response composition.
2
3use http::header::{CACHE_CONTROL, CONTENT_LOCATION, IF_MATCH, IF_NONE_MATCH};
4use http::{HeaderMap, HeaderValue, StatusCode};
5
6use crate::{
7    DavPath, DavProtocolError, DavResponse, evaluate_http_etag_preconditions, href_for_dav_path,
8};
9
10/// Resolved state of the PUT request target.
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum DavPutResourceState<'a> {
13    Missing,
14    File { etag: Option<&'a str> },
15    Collection,
16}
17
18/// Product-side open/write settings selected from HTTP preconditions.
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct DavPutPlan {
21    pub resource_existed: bool,
22    pub create: bool,
23    pub create_new: bool,
24    pub content_length_hint: Option<u64>,
25}
26
27/// Failure while planning a PUT request.
28#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
29pub enum DavPutPlanError {
30    #[error(transparent)]
31    Protocol(#[from] DavProtocolError),
32    #[error("PUT cannot replace a collection")]
33    CollectionTarget,
34}
35
36/// Failure while composing the successful PUT response.
37#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
38#[error("invalid PUT Content-Location response header")]
39pub struct DavPutResponseError;
40
41/// Evaluates PUT preconditions and selects product-side create/open settings.
42pub fn plan_put_request(
43    headers: &HeaderMap,
44    state: DavPutResourceState<'_>,
45) -> Result<DavPutPlan, DavPutPlanError> {
46    let (resource_existed, etag) = match state {
47        DavPutResourceState::Missing => (false, None),
48        DavPutResourceState::File { etag } => (true, etag),
49        DavPutResourceState::Collection => return Err(DavPutPlanError::CollectionTarget),
50    };
51    evaluate_http_etag_preconditions(headers, resource_existed, etag, false)?;
52    Ok(DavPutPlan {
53        resource_existed,
54        create: !header_equals(headers, IF_MATCH, "*"),
55        create_new: header_equals(headers, IF_NONE_MATCH, "*"),
56        content_length_hint: content_length_hint(headers),
57    })
58}
59
60/// Maps a PUT planning failure to its protocol response.
61#[must_use]
62pub fn put_plan_error_response(error: &DavPutPlanError) -> DavResponse {
63    match error {
64        DavPutPlanError::Protocol(error) => crate::protocol_error_response(error),
65        DavPutPlanError::CollectionTarget => {
66            let mut response = DavResponse::empty(StatusCode::METHOD_NOT_ALLOWED);
67            response
68                .headers
69                .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
70            response
71        }
72    }
73}
74
75/// Builds the 201/204 response selected by the pre-write resource state.
76pub fn put_success_response(
77    plan: &DavPutPlan,
78    prefix: &str,
79    path: &DavPath,
80) -> Result<DavResponse, DavPutResponseError> {
81    if plan.resource_existed {
82        return Ok(DavResponse::empty(StatusCode::NO_CONTENT));
83    }
84    let mut response = DavResponse::empty(StatusCode::CREATED);
85    let location =
86        HeaderValue::from_str(&href_for_dav_path(prefix, path)).map_err(|_| DavPutResponseError)?;
87    response.headers.insert(CONTENT_LOCATION, location);
88    Ok(response)
89}
90
91fn content_length_hint(headers: &HeaderMap) -> Option<u64> {
92    headers
93        .get("X-Expected-Entity-Length")
94        .and_then(|value| value.to_str().ok())
95        .and_then(|value| value.trim().parse::<u64>().ok())
96        .or_else(|| {
97            headers
98                .get(http::header::CONTENT_LENGTH)
99                .and_then(|value| value.to_str().ok())
100                .and_then(|value| value.trim().parse::<u64>().ok())
101        })
102}
103
104fn header_equals(headers: &HeaderMap, name: http::header::HeaderName, expected: &str) -> bool {
105    headers
106        .get(name)
107        .and_then(|value| value.to_str().ok())
108        .is_some_and(|value| value.trim() == expected)
109}