aster_forge_webdav/
put.rs

1//! PUT request planning and success response composition.
2
3use std::time::SystemTime;
4
5use headers::{ContentLength, ContentRange, Header};
6use http::header::{
7    CACHE_CONTROL, CONTENT_LENGTH, CONTENT_LOCATION, CONTENT_RANGE, IF_MATCH, IF_NONE_MATCH,
8};
9use http::{HeaderMap, HeaderValue, StatusCode};
10
11use crate::patch::{DavWritePreconditionError, enforce_write_precondition};
12use crate::response::no_store_empty_response;
13use crate::{
14    DavCapabilitySnapshot, DavConditionalOutcome, DavConditionalPlan, DavConditionalPlanError,
15    DavConditionalResource, DavMethod, DavPartialPutCapability, DavPath,
16    DavPrivateUpdateRangeCapability, DavProtocolError, DavResponse, DavVersioningPrecondition,
17    DavWritePrecondition, href_for_dav_path, plan_http_conditionals, plan_versioning_method,
18    versioning_precondition_response,
19};
20
21/// Resolved state of the PUT request target.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub enum DavPutResourceState<'a> {
24    Missing,
25    File {
26        etag: Option<&'a str>,
27        last_modified: Option<SystemTime>,
28    },
29    Collection,
30}
31
32/// Byte range selected for a private-agreement partial PUT.
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub struct DavPartialPutPlan {
35    pub offset: u64,
36    pub length: u64,
37    pub complete_length: Option<u64>,
38}
39
40/// Product write operation selected from the declared PUT compatibility capabilities.
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum DavPutWritePlan {
43    /// Replace the complete representation using ordinary PUT semantics.
44    Replace,
45    /// Replace one byte range relative to the current selected representation.
46    Partial(DavPartialPutPlan),
47    /// Execute the explicitly enabled private header contract in the product adapter.
48    PrivateUpdateRange { value: HeaderValue },
49}
50
51/// Product-side open/write settings selected from capabilities and HTTP preconditions.
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub struct DavPutPlan {
54    pub resource_existed: bool,
55    pub create: bool,
56    pub create_new: bool,
57    pub content_length_hint: Option<u64>,
58    pub write: DavPutWritePlan,
59}
60
61/// Failure while planning a PUT request.
62#[derive(Debug, thiserror::Error)]
63pub enum DavPutPlanError {
64    #[error("PUT is not allowed for this resource")]
65    MethodNotAllowed,
66    #[error(transparent)]
67    Protocol(#[from] DavProtocolError),
68    #[error("PUT request precondition failed")]
69    PreconditionFailed(DavConditionalPlan),
70    #[error("invalid PUT representation metadata")]
71    InvalidRepresentation,
72    #[error("partial PUT requires If-Match with a strong entity-tag")]
73    PreconditionRequired,
74    #[error("partial PUT requires an existing representation")]
75    PartialTargetMissing,
76    #[error("PUT cannot replace a collection")]
77    CollectionTarget,
78    #[error(transparent)]
79    Versioning(#[from] DavVersioningPrecondition),
80}
81
82/// Failure while composing the successful PUT response.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
84#[error("invalid PUT Content-Location response header")]
85pub struct DavPutResponseError;
86
87/// Evaluates PUT preconditions and selects product-side create/open settings.
88///
89/// # Errors
90///
91/// Returns [`DavPutPlanError`] when capabilities, validators, or range headers reject the write.
92pub fn plan_put_request(
93    snapshot: &DavCapabilitySnapshot,
94    headers: &HeaderMap,
95    state: DavPutResourceState<'_>,
96) -> Result<DavPutPlan, DavPutPlanError> {
97    if !snapshot.allows(DavMethod::Put) {
98        return Err(DavPutPlanError::MethodNotAllowed);
99    }
100    plan_versioning_method(snapshot, DavMethod::Put)?;
101    let resource = match state {
102        DavPutResourceState::Missing => DavConditionalResource::missing(),
103        DavPutResourceState::File {
104            etag,
105            last_modified,
106        } => DavConditionalResource {
107            exists: true,
108            etag,
109            last_modified,
110        },
111        DavPutResourceState::Collection => return Err(DavPutPlanError::CollectionTarget),
112    };
113    let (write, precondition) = plan_put_write(snapshot, headers)?;
114    if matches!(write, DavPutWritePlan::Partial(_)) && !resource.exists {
115        return Err(DavPutPlanError::PartialTargetMissing);
116    }
117    enforce_write_precondition(precondition, headers).map_err(|error| match error {
118        DavWritePreconditionError::Required => DavPutPlanError::PreconditionRequired,
119        DavWritePreconditionError::Protocol(error) => DavPutPlanError::Protocol(error),
120    })?;
121    let conditional =
122        plan_http_conditionals(DavMethod::Put, headers, resource).map_err(|error| match error {
123            DavConditionalPlanError::Protocol(error) => DavPutPlanError::Protocol(error),
124            DavConditionalPlanError::InvalidRepresentation => {
125                DavPutPlanError::InvalidRepresentation
126            }
127        })?;
128    if conditional.outcome != DavConditionalOutcome::Proceed {
129        return Err(DavPutPlanError::PreconditionFailed(conditional));
130    }
131    let content_length_hint = match &write {
132        DavPutWritePlan::Partial(partial) => Some(partial.length),
133        DavPutWritePlan::Replace | DavPutWritePlan::PrivateUpdateRange { .. } => {
134            content_length_hint(headers)
135        }
136    };
137    Ok(DavPutPlan {
138        resource_existed: resource.exists,
139        create: !header_equals(headers, IF_MATCH, "*"),
140        create_new: header_equals(headers, IF_NONE_MATCH, "*"),
141        content_length_hint,
142        write,
143    })
144}
145
146/// Maps a PUT planning failure through the same snapshot used for method planning.
147#[must_use]
148pub fn put_plan_error_response(
149    snapshot: &DavCapabilitySnapshot,
150    error: &DavPutPlanError,
151) -> DavResponse {
152    match error {
153        DavPutPlanError::MethodNotAllowed | DavPutPlanError::CollectionTarget => {
154            crate::method_not_allowed_response(snapshot)
155        }
156        DavPutPlanError::Protocol(error) => crate::protocol_error_response(error),
157        DavPutPlanError::PreconditionFailed(plan) => {
158            let mut response = DavResponse::empty(StatusCode::PRECONDITION_FAILED);
159            response
160                .headers
161                .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
162            plan.apply_response_headers(response.status, &mut response.headers);
163            response
164        }
165        DavPutPlanError::InvalidRepresentation => {
166            no_store_empty_response(StatusCode::INTERNAL_SERVER_ERROR)
167        }
168        DavPutPlanError::PreconditionRequired => {
169            no_store_empty_response(StatusCode::PRECONDITION_REQUIRED)
170        }
171        DavPutPlanError::PartialTargetMissing => no_store_empty_response(StatusCode::CONFLICT),
172        DavPutPlanError::Versioning(error) => versioning_precondition_response(snapshot, *error)
173            .unwrap_or_else(|_| no_store_empty_response(StatusCode::INTERNAL_SERVER_ERROR)),
174    }
175}
176
177fn plan_put_write(
178    snapshot: &DavCapabilitySnapshot,
179    headers: &HeaderMap,
180) -> Result<(DavPutWritePlan, DavWritePrecondition), DavPutPlanError> {
181    let has_content_range = headers.contains_key(CONTENT_RANGE);
182    let has_private_range = headers.contains_key("X-Update-Range");
183    if has_content_range && has_private_range {
184        return Err(DavProtocolError::bad_request(
185            "Content-Range and X-Update-Range cannot be combined",
186        )
187        .into());
188    }
189    if has_content_range {
190        let (plan, precondition) = plan_partial_put(snapshot, headers)?;
191        return Ok((DavPutWritePlan::Partial(plan), precondition));
192    }
193    if has_private_range {
194        return plan_private_update_range(snapshot, headers);
195    }
196    Ok((DavPutWritePlan::Replace, DavWritePrecondition::Optional))
197}
198
199fn plan_partial_put(
200    snapshot: &DavCapabilitySnapshot,
201    headers: &HeaderMap,
202) -> Result<(DavPartialPutPlan, DavWritePrecondition), DavPutPlanError> {
203    let precondition = match snapshot.writes().partial_put {
204        DavPartialPutCapability::Disabled => {
205            return Err(DavProtocolError::bad_request("Partial PUT is not supported").into());
206        }
207        DavPartialPutCapability::ContentRangeBytes { precondition } => precondition,
208    };
209    let mut values = headers.get_all(CONTENT_RANGE).iter();
210    let value = values
211        .next()
212        .ok_or_else(|| DavProtocolError::bad_request("Invalid Content-Range header"))?;
213    if values.next().is_some() {
214        return Err(DavProtocolError::bad_request("Invalid Content-Range header").into());
215    }
216    let content_range = ContentRange::decode(&mut std::iter::once(value))
217        .map_err(|_| DavProtocolError::bad_request("Invalid Content-Range header"))?;
218    let (first, last) = content_range
219        .bytes_range()
220        .ok_or_else(|| DavProtocolError::bad_request("Invalid Content-Range header"))?;
221    if content_range
222        .bytes_len()
223        .is_some_and(|complete_length| complete_length <= last)
224    {
225        return Err(DavProtocolError::bad_request("Invalid Content-Range header").into());
226    }
227    let length = last
228        .checked_sub(first)
229        .and_then(|length| length.checked_add(1))
230        .ok_or_else(|| DavProtocolError::bad_request("Invalid Content-Range header"))?;
231    if let Some(content_length) = request_content_length(headers)?
232        && content_length != length
233    {
234        return Err(
235            DavProtocolError::bad_request("Content-Length does not match Content-Range").into(),
236        );
237    }
238    Ok((
239        DavPartialPutPlan {
240            offset: first,
241            length,
242            complete_length: content_range.bytes_len(),
243        },
244        precondition,
245    ))
246}
247
248fn plan_private_update_range(
249    snapshot: &DavCapabilitySnapshot,
250    headers: &HeaderMap,
251) -> Result<(DavPutWritePlan, DavWritePrecondition), DavPutPlanError> {
252    let precondition = match snapshot.writes().private_update_range {
253        DavPrivateUpdateRangeCapability::Disabled => {
254            return Err(DavProtocolError::bad_request("X-Update-Range is not supported").into());
255        }
256        DavPrivateUpdateRangeCapability::XUpdateRange { precondition } => precondition,
257    };
258    let mut values = headers.get_all("X-Update-Range").iter();
259    let value = values
260        .next()
261        .ok_or_else(|| DavProtocolError::bad_request("Invalid X-Update-Range header"))?;
262    if values.next().is_some() || value.to_str().map_or(true, |value| value.trim().is_empty()) {
263        return Err(DavProtocolError::bad_request("Invalid X-Update-Range header").into());
264    }
265    Ok((
266        DavPutWritePlan::PrivateUpdateRange {
267            value: value.clone(),
268        },
269        precondition,
270    ))
271}
272
273fn request_content_length(headers: &HeaderMap) -> Result<Option<u64>, DavPutPlanError> {
274    if !headers.contains_key(CONTENT_LENGTH) {
275        return Ok(None);
276    }
277    let content_length = ContentLength::decode(&mut headers.get_all(CONTENT_LENGTH).iter())
278        .map_err(|_| DavProtocolError::bad_request("Invalid Content-Length header"))?;
279    Ok(Some(content_length.0))
280}
281
282/// Builds the 201/204 response selected by the pre-write resource state.
283///
284/// # Errors
285///
286/// Returns [`DavPutResponseError`] when the success response headers cannot be encoded.
287pub fn put_success_response(
288    plan: &DavPutPlan,
289    prefix: &str,
290    path: &DavPath,
291) -> Result<DavResponse, DavPutResponseError> {
292    if plan.resource_existed {
293        return Ok(DavResponse::empty(StatusCode::NO_CONTENT));
294    }
295    let mut response = DavResponse::empty(StatusCode::CREATED);
296    let location =
297        HeaderValue::from_str(&href_for_dav_path(prefix, path)).map_err(|_| DavPutResponseError)?;
298    response.headers.insert(CONTENT_LOCATION, location);
299    Ok(response)
300}
301
302fn content_length_hint(headers: &HeaderMap) -> Option<u64> {
303    headers
304        .get("X-Expected-Entity-Length")
305        .and_then(|value| value.to_str().ok())
306        .and_then(|value| value.trim().parse::<u64>().ok())
307        .or_else(|| {
308            headers
309                .get(http::header::CONTENT_LENGTH)
310                .and_then(|value| value.to_str().ok())
311                .and_then(|value| value.trim().parse::<u64>().ok())
312        })
313}
314
315fn header_equals(headers: &HeaderMap, name: http::header::HeaderName, expected: &str) -> bool {
316    headers
317        .get(name)
318        .and_then(|value| value.to_str().ok())
319        .is_some_and(|value| value.trim() == expected)
320}