aster_forge_webdav/
patch.rs

1//! RFC 5789 PATCH format dispatch and request planning.
2
3use aster_forge_utils::http_validators;
4use headers::{ContentType, Header, Mime};
5use http::header::{CACHE_CONTROL, CONTENT_TYPE};
6use http::{HeaderMap, HeaderValue, StatusCode};
7
8use crate::response::no_store_empty_response;
9use crate::{
10    DavBodyPolicy, DavCapabilitySnapshot, DavConditionalOutcome, DavConditionalPlan,
11    DavConditionalPlanError, DavConditionalResource, DavMethod, DavPatchBodyPolicy, DavPatchFormat,
12    DavProtocolError, DavResponse, DavWritePrecondition, method_not_allowed_response,
13    plan_http_conditionals,
14};
15
16/// Selected patch format and transport policy after capability and precondition validation.
17#[derive(Debug, Clone, Copy, PartialEq, Eq)]
18pub struct DavPatchPlan {
19    pub format: &'static DavPatchFormat,
20    pub body_policy: DavBodyPolicy,
21    pub resource_existed: bool,
22}
23
24/// Failure while selecting and validating an RFC 5789 PATCH request.
25#[derive(Debug, thiserror::Error)]
26pub enum DavPatchPlanError {
27    #[error("PATCH is not allowed for this resource")]
28    MethodNotAllowed,
29    #[error("PATCH Content-Type is not supported for this resource")]
30    UnsupportedMediaType,
31    #[error("PATCH requires If-Match with a strong entity-tag")]
32    PreconditionRequired,
33    #[error(transparent)]
34    Protocol(#[from] DavProtocolError),
35    #[error("PATCH request precondition failed")]
36    PreconditionFailed(DavConditionalPlan),
37    #[error("invalid PATCH representation metadata")]
38    InvalidRepresentation,
39}
40
41/// Selects a declared patch document format and applies its conditional request contract.
42///
43/// # Errors
44///
45/// Returns [`DavPatchPlanError`] when PATCH is unavailable or its media type is invalid.
46pub fn plan_patch_request(
47    snapshot: &DavCapabilitySnapshot,
48    headers: &HeaderMap,
49    resource: DavConditionalResource<'_>,
50) -> Result<DavPatchPlan, DavPatchPlanError> {
51    let Some(formats) = snapshot.patch_formats() else {
52        return Err(DavPatchPlanError::MethodNotAllowed);
53    };
54    let content_type = parse_content_type(headers)?;
55    let format = formats
56        .iter()
57        .find(|format| {
58            format
59                .media_type
60                .parse::<Mime>()
61                .is_ok_and(|supported| supported == content_type)
62        })
63        .ok_or(DavPatchPlanError::UnsupportedMediaType)?;
64
65    enforce_write_precondition(format.precondition, headers).map_err(|error| match error {
66        DavWritePreconditionError::Required => DavPatchPlanError::PreconditionRequired,
67        DavWritePreconditionError::Protocol(error) => DavPatchPlanError::Protocol(error),
68    })?;
69    let conditional = plan_http_conditionals(DavMethod::Patch, headers, resource).map_err(
70        |error| match error {
71            DavConditionalPlanError::Protocol(error) => DavPatchPlanError::Protocol(error),
72            DavConditionalPlanError::InvalidRepresentation => {
73                DavPatchPlanError::InvalidRepresentation
74            }
75        },
76    )?;
77    if conditional.outcome != DavConditionalOutcome::Proceed {
78        return Err(DavPatchPlanError::PreconditionFailed(conditional));
79    }
80
81    let body_policy = match format.body_policy {
82        DavPatchBodyPolicy::Bounded { maximum } => DavBodyPolicy::Bounded { maximum },
83        DavPatchBodyPolicy::Stream => DavBodyPolicy::Stream,
84    };
85    Ok(DavPatchPlan {
86        format,
87        body_policy,
88        resource_existed: resource.exists,
89    })
90}
91
92/// Maps PATCH planning failures to the RFC 5789 response contract.
93#[must_use]
94pub fn patch_plan_error_response(
95    error: &DavPatchPlanError,
96    snapshot: &DavCapabilitySnapshot,
97) -> DavResponse {
98    match error {
99        DavPatchPlanError::MethodNotAllowed => method_not_allowed_response(snapshot),
100        DavPatchPlanError::UnsupportedMediaType => {
101            let mut response = no_store_empty_response(StatusCode::UNSUPPORTED_MEDIA_TYPE);
102            if let Some(accept_patch) = snapshot.accept_patch_header() {
103                response
104                    .headers
105                    .insert("Accept-Patch", accept_patch.clone());
106            }
107            response
108        }
109        DavPatchPlanError::PreconditionRequired => {
110            no_store_empty_response(StatusCode::PRECONDITION_REQUIRED)
111        }
112        DavPatchPlanError::Protocol(error) => crate::protocol_error_response(error),
113        DavPatchPlanError::PreconditionFailed(plan) => {
114            let mut response = DavResponse::empty(StatusCode::PRECONDITION_FAILED);
115            response
116                .headers
117                .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
118            plan.apply_response_headers(response.status, &mut response.headers);
119            response
120        }
121        DavPatchPlanError::InvalidRepresentation => {
122            no_store_empty_response(StatusCode::INTERNAL_SERVER_ERROR)
123        }
124    }
125}
126
127pub(crate) enum DavWritePreconditionError {
128    Required,
129    Protocol(DavProtocolError),
130}
131
132pub(crate) fn enforce_write_precondition(
133    policy: DavWritePrecondition,
134    headers: &HeaderMap,
135) -> Result<(), DavWritePreconditionError> {
136    if policy == DavWritePrecondition::Optional {
137        return Ok(());
138    }
139    match http_validators::if_match_headers_have_strong_tag(headers) {
140        Ok(Some(true)) => Ok(()),
141        Ok(Some(false) | None) => Err(DavWritePreconditionError::Required),
142        Err(_) => Err(DavWritePreconditionError::Protocol(
143            DavProtocolError::bad_request("Invalid If-Match header"),
144        )),
145    }
146}
147
148fn parse_content_type(headers: &HeaderMap) -> Result<Mime, DavPatchPlanError> {
149    let mut values = headers.get_all(CONTENT_TYPE).iter();
150    let Some(value) = values.next() else {
151        return Err(DavPatchPlanError::UnsupportedMediaType);
152    };
153    if values.next().is_some() {
154        return Err(DavPatchPlanError::UnsupportedMediaType);
155    }
156    let content_type = ContentType::decode(&mut std::iter::once(value))
157        .map_err(|_| DavPatchPlanError::UnsupportedMediaType)?;
158    Ok(content_type.into())
159}