Skip to main content

aster_forge_webdav/
response.rs

1//! Transport-neutral WebDAV response model and download response planning.
2
3use std::time::SystemTime;
4
5use aster_forge_utils::http_range::{HttpByteRange, HttpRangeError, parse_single_byte_range};
6use aster_forge_utils::http_validators::{
7    format_http_date, http_date_epoch_seconds, parse_http_date,
8};
9use bytes::Bytes;
10use http::header::{
11    ACCEPT_RANGES, ALLOW, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE,
12    CONTENT_TYPE, ETAG, IF_RANGE, LAST_MODIFIED, RANGE,
13};
14use http::{HeaderMap, HeaderValue, StatusCode};
15
16use crate::{
17    DavBackendError, DavBackendErrorKind, DavContentStream, DavErrorCondition, DavPrecondition,
18    DavProtocolError, DavProtocolErrorKind, DavXmlElement, DavXmlError, dav_error_element,
19};
20
21/// Methods advertised by the product-neutral DAV protocol engine.
22pub const DAV_ALLOW_HEADER: &str = "OPTIONS, GET, HEAD, PUT, DELETE, MKCOL, COPY, MOVE, PROPFIND, PROPPATCH, LOCK, UNLOCK, REPORT, VERSION-CONTROL";
23
24/// Failure while enforcing a request body policy in the transport adapter.
25#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
26pub enum DavBodyError {
27    #[error("failed to read WebDAV request body")]
28    ReadFailed,
29    #[error("WebDAV XML body is too large")]
30    XmlTooLarge,
31    #[error("WebDAV method does not accept a request body")]
32    BodyNotAllowed,
33}
34
35/// Whether a successful GET/HEAD response needs content from the product backend.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum DavDownloadBody {
38    /// The response has no body because it is a HEAD, 304, or 416 response.
39    Empty,
40    /// Stream the complete representation.
41    Full,
42    /// Stream only the selected representation range.
43    Range(HttpByteRange),
44}
45
46/// A complete response shell plus the storage read selected by the protocol layer.
47pub struct DavDownloadPlan {
48    pub response: DavResponse,
49    pub body: DavDownloadBody,
50}
51
52/// Failure while building a product-neutral download response plan.
53#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
54pub enum DavDownloadPlanError {
55    #[error(transparent)]
56    Protocol(#[from] DavProtocolError),
57    #[error("invalid WebDAV download representation metadata")]
58    InvalidRepresentation,
59}
60
61/// WebDAV response body before transport adaptation.
62pub enum DavResponseBody {
63    Empty,
64    Bytes(Bytes),
65    Stream(DavContentStream),
66}
67
68/// Status, headers, and body produced by the protocol layer.
69pub struct DavResponse {
70    pub status: StatusCode,
71    pub headers: HeaderMap,
72    pub body: DavResponseBody,
73}
74
75impl DavResponse {
76    /// Creates an empty response.
77    #[must_use]
78    pub fn empty(status: StatusCode) -> Self {
79        Self {
80            status,
81            headers: HeaderMap::new(),
82            body: DavResponseBody::Empty,
83        }
84    }
85
86    /// Creates a byte response.
87    #[must_use]
88    pub fn bytes(status: StatusCode, body: impl Into<Bytes>) -> Self {
89        Self {
90            status,
91            headers: HeaderMap::new(),
92            body: DavResponseBody::Bytes(body.into()),
93        }
94    }
95}
96
97pub(crate) fn xml_document_response(
98    status: StatusCode,
99    root: DavXmlElement,
100) -> Result<DavResponse, DavXmlError> {
101    let mut response = DavResponse::bytes(status, root.to_bytes()?);
102    response.headers.insert(
103        CONTENT_TYPE,
104        HeaderValue::from_static("application/xml; charset=utf-8"),
105    );
106    if status.is_client_error() || status.is_server_error() {
107        response
108            .headers
109            .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
110    }
111    Ok(response)
112}
113
114pub(crate) fn text_document_response(status: StatusCode, body: impl Into<String>) -> DavResponse {
115    let mut response = DavResponse::bytes(status, body.into());
116    response.headers.insert(
117        CONTENT_TYPE,
118        HeaderValue::from_static("text/plain; charset=utf-8"),
119    );
120    if status.is_client_error() || status.is_server_error() {
121        response
122            .headers
123            .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
124    }
125    response
126}
127
128pub(crate) fn xml_request_error_response(
129    error: DavXmlError,
130    invalid_grammar_message: &'static str,
131) -> Result<DavResponse, DavXmlError> {
132    match error {
133        DavXmlError::ExternalEntity => xml_document_response(
134            StatusCode::FORBIDDEN,
135            dav_error_element(&DavErrorCondition::NoExternalEntities),
136        ),
137        DavXmlError::TooLarge => Ok(text_document_response(
138            StatusCode::PAYLOAD_TOO_LARGE,
139            "WebDAV XML body too large",
140        )),
141        DavXmlError::TooDeep | DavXmlError::Malformed => Ok(text_document_response(
142            StatusCode::BAD_REQUEST,
143            "Invalid XML body",
144        )),
145        DavXmlError::InvalidGrammar => Ok(text_document_response(
146            StatusCode::BAD_REQUEST,
147            invalid_grammar_message,
148        )),
149    }
150}
151
152/// Maps a protocol parsing/precondition failure to its transport-neutral response.
153#[must_use]
154pub fn protocol_error_response(error: &DavProtocolError) -> DavResponse {
155    match error.kind() {
156        DavProtocolErrorKind::BadRequest => text_document_response(error.status(), error.message()),
157        DavProtocolErrorKind::PreconditionFailed => no_store_empty_response(error.status()),
158    }
159}
160
161/// Maps a classified product backend failure to the WebDAV status contract.
162#[must_use]
163pub fn backend_error_response(error: &DavBackendError) -> DavResponse {
164    let status = match error.kind {
165        DavBackendErrorKind::NotFound => StatusCode::NOT_FOUND,
166        DavBackendErrorKind::Forbidden => StatusCode::FORBIDDEN,
167        DavBackendErrorKind::Conflict | DavBackendErrorKind::AlreadyExists => StatusCode::CONFLICT,
168        DavBackendErrorKind::InsufficientStorage => StatusCode::INSUFFICIENT_STORAGE,
169        DavBackendErrorKind::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE,
170        DavBackendErrorKind::Locked => StatusCode::LOCKED,
171        DavBackendErrorKind::InvalidInput => StatusCode::BAD_REQUEST,
172        DavBackendErrorKind::Unsupported => StatusCode::METHOD_NOT_ALLOWED,
173        DavBackendErrorKind::Internal => StatusCode::INTERNAL_SERVER_ERROR,
174    };
175    no_store_empty_response(status)
176}
177
178pub(crate) fn no_store_empty_response(status: StatusCode) -> DavResponse {
179    let mut response = DavResponse::empty(status);
180    response
181        .headers
182        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
183    response
184}
185
186/// Builds the response to a DAV `OPTIONS` request.
187#[must_use]
188pub fn options_response() -> DavResponse {
189    let mut response = DavResponse::empty(StatusCode::OK);
190    response
191        .headers
192        .insert(ALLOW, HeaderValue::from_static(DAV_ALLOW_HEADER));
193    response
194        .headers
195        .insert("DAV", HeaderValue::from_static("1, 2, version-control"));
196    response
197        .headers
198        .insert("MS-Author-Via", HeaderValue::from_static("DAV"));
199    response
200}
201
202/// Builds the response for an unsupported HTTP/WebDAV method.
203#[must_use]
204pub fn method_not_allowed_response() -> DavResponse {
205    let mut response = DavResponse::empty(StatusCode::METHOD_NOT_ALLOWED);
206    response
207        .headers
208        .insert(ALLOW, HeaderValue::from_static(DAV_ALLOW_HEADER));
209    response
210        .headers
211        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
212    response
213}
214
215/// Builds the protocol response for a transport body-policy failure.
216#[must_use]
217pub fn body_error_response(error: DavBodyError) -> DavResponse {
218    let (status, body) = match error {
219        DavBodyError::ReadFailed => (StatusCode::BAD_REQUEST, Some("Failed to read request body")),
220        DavBodyError::XmlTooLarge => (
221            StatusCode::PAYLOAD_TOO_LARGE,
222            Some("WebDAV XML body too large"),
223        ),
224        DavBodyError::BodyNotAllowed => (StatusCode::UNSUPPORTED_MEDIA_TYPE, None),
225    };
226    let mut response = match body {
227        Some(body) => DavResponse::bytes(status, body),
228        None => DavResponse::empty(status),
229    };
230    response
231        .headers
232        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
233    if body.is_some() {
234        response.headers.insert(
235            CONTENT_TYPE,
236            HeaderValue::from_static("text/plain; charset=utf-8"),
237        );
238    }
239    response
240}
241
242/// Builds the GET/HEAD response and storage-read plan after product metadata has been resolved.
243pub fn plan_download_response(
244    headers: &HeaderMap,
245    head_only: bool,
246    content_length: u64,
247    content_type: &str,
248    etag: Option<&str>,
249    last_modified: SystemTime,
250) -> Result<DavDownloadPlan, DavDownloadPlanError> {
251    match crate::evaluate_http_download_preconditions(headers, etag, Some(last_modified))? {
252        DavPrecondition::Proceed => {}
253        DavPrecondition::NotModified => {
254            let mut response = DavResponse::empty(StatusCode::NOT_MODIFIED);
255            insert_validators(&mut response.headers, etag, last_modified)?;
256            return Ok(DavDownloadPlan {
257                response,
258                body: DavDownloadBody::Empty,
259            });
260        }
261    }
262
263    let range = if head_only || !if_range_matches(headers, etag, last_modified) {
264        None
265    } else if let Some(value) = headers.get(RANGE) {
266        let Ok(raw) = value.to_str() else {
267            return Ok(range_not_satisfiable_plan(content_length));
268        };
269        match parse_single_byte_range(raw, content_length) {
270            Ok(range) => Some(range),
271            Err(HttpRangeError::UnsupportedUnit | HttpRangeError::MultipleRangesUnsupported) => {
272                None
273            }
274            Err(
275                HttpRangeError::Malformed
276                | HttpRangeError::InvalidNumber
277                | HttpRangeError::EmptyRepresentation
278                | HttpRangeError::Unsatisfiable,
279            ) => return Ok(range_not_satisfiable_plan(content_length)),
280        }
281    } else {
282        None
283    };
284
285    let (status, response_length, body) = match range {
286        Some(range) => (
287            StatusCode::PARTIAL_CONTENT,
288            range.length(),
289            DavDownloadBody::Range(range),
290        ),
291        None if head_only => (StatusCode::OK, content_length, DavDownloadBody::Empty),
292        None => (StatusCode::OK, content_length, DavDownloadBody::Full),
293    };
294    let mut response = DavResponse::empty(status);
295    response
296        .headers
297        .insert(CONTENT_LENGTH, header_value(&response_length.to_string())?);
298    response
299        .headers
300        .insert(CONTENT_TYPE, header_value(content_type)?);
301    response
302        .headers
303        .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
304    response
305        .headers
306        .insert(CONTENT_ENCODING, HeaderValue::from_static("identity"));
307    if let Some(range) = range {
308        response
309            .headers
310            .insert(CONTENT_RANGE, header_value(&range.content_range_header())?);
311    }
312    insert_validators(&mut response.headers, etag, last_modified)?;
313
314    Ok(DavDownloadPlan { response, body })
315}
316
317fn if_range_matches(headers: &HeaderMap, etag: Option<&str>, last_modified: SystemTime) -> bool {
318    let Some(value) = headers.get(IF_RANGE) else {
319        return true;
320    };
321    let Ok(raw) = value.to_str() else {
322        return false;
323    };
324    let raw = raw.trim();
325    if raw.starts_with('"')
326        || raw
327            .get(..2)
328            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/"))
329    {
330        return strong_if_range_etag_matches(raw, etag);
331    }
332    parse_http_date(raw)
333        .is_ok_and(|date| http_date_epoch_seconds(date) == http_date_epoch_seconds(last_modified))
334}
335
336fn strong_if_range_etag_matches(candidate: &str, current: Option<&str>) -> bool {
337    if candidate
338        .get(..2)
339        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/"))
340    {
341        return false;
342    }
343    let Some(candidate) = candidate
344        .strip_prefix('"')
345        .and_then(|value| value.strip_suffix('"'))
346        .filter(|value| !value.contains('"'))
347    else {
348        return false;
349    };
350    let Some(current) = current else {
351        return false;
352    };
353    if current
354        .trim()
355        .get(..2)
356        .is_some_and(|prefix| prefix.eq_ignore_ascii_case("W/"))
357    {
358        return false;
359    }
360    let current = current.trim();
361    let current = current
362        .strip_prefix('"')
363        .and_then(|value| value.strip_suffix('"'))
364        .unwrap_or(current);
365    candidate == current
366}
367
368/// Builds the response required when a byte range cannot be served.
369#[must_use]
370pub fn range_not_satisfiable_response(content_length: u64) -> DavResponse {
371    let mut response = DavResponse::empty(StatusCode::RANGE_NOT_SATISFIABLE);
372    response.headers.insert(
373        CONTENT_RANGE,
374        HeaderValue::from_str(&format!("bytes */{content_length}"))
375            .unwrap_or_else(|_| HeaderValue::from_static("bytes */0")),
376    );
377    response
378        .headers
379        .insert(ACCEPT_RANGES, HeaderValue::from_static("bytes"));
380    response
381        .headers
382        .insert(CACHE_CONTROL, HeaderValue::from_static("no-store"));
383    response
384}
385
386fn range_not_satisfiable_plan(content_length: u64) -> DavDownloadPlan {
387    DavDownloadPlan {
388        response: range_not_satisfiable_response(content_length),
389        body: DavDownloadBody::Empty,
390    }
391}
392
393fn insert_validators(
394    headers: &mut HeaderMap,
395    etag: Option<&str>,
396    last_modified: SystemTime,
397) -> Result<(), DavDownloadPlanError> {
398    headers.insert(
399        LAST_MODIFIED,
400        header_value(&format_http_date(last_modified))?,
401    );
402    if let Some(etag) = etag {
403        headers.insert(ETAG, header_value(&format!("\"{etag}\""))?);
404    }
405    Ok(())
406}
407
408fn header_value(value: &str) -> Result<HeaderValue, DavDownloadPlanError> {
409    HeaderValue::from_str(value).map_err(|_| DavDownloadPlanError::InvalidRepresentation)
410}