aster_forge_webdav/
conditional.rs

1//! Method-aware HTTP and `WebDAV` conditional request planning.
2
3use std::time::SystemTime;
4
5use aster_forge_utils::http_validators;
6use http::header::{ETAG, HeaderName, IF_MODIFIED_SINCE, IF_UNMODIFIED_SINCE, LAST_MODIFIED};
7use http::{HeaderMap, HeaderValue, StatusCode};
8
9use crate::{
10    DavBackendError, DavFileSystem, DavIfEvaluationError, DavIfStateResolver, DavLockSystem,
11    DavMethod, DavPath, DavProtocolError, IfHeader, enforce_if_header,
12    enforce_if_header_with_backends,
13};
14
15/// Protocol-visible metadata for the request target.
16#[derive(Debug, Clone, Copy, PartialEq, Eq)]
17pub struct DavConditionalResource<'a> {
18    /// Whether the request target currently maps to a representation.
19    pub exists: bool,
20    /// Current entity tag, either as an opaque backend value or a complete entity-tag.
21    pub etag: Option<&'a str>,
22    /// Current modification time when the product can provide one authoritatively.
23    pub last_modified: Option<SystemTime>,
24}
25
26impl DavConditionalResource<'_> {
27    /// Metadata for an unmapped request target.
28    #[must_use]
29    pub const fn missing() -> Self {
30        Self {
31            exists: false,
32            etag: None,
33            last_modified: None,
34        }
35    }
36}
37
38/// Result selected by RFC 9110 conditional request precedence.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum DavConditionalOutcome {
41    /// Continue processing the method.
42    Proceed,
43    /// Return `304 Not Modified` for GET or HEAD.
44    NotModified,
45    /// Return `412 Precondition Failed`.
46    PreconditionFailed,
47}
48
49/// Whether GET byte-range processing can run after preconditions.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum DavRangeEvaluation {
52    /// Evaluate `Range` and then `If-Range`.
53    Evaluate,
54    /// Do not evaluate range headers for this method or outcome.
55    Skip,
56}
57
58/// Complete transport-neutral result of HTTP conditional request planning.
59#[derive(Debug, Clone)]
60pub struct DavConditionalPlan {
61    /// Selected request outcome.
62    pub outcome: DavConditionalOutcome,
63    /// Whether the range planner is the next RFC 9110 step.
64    pub range: DavRangeEvaluation,
65    validator_headers: HeaderMap,
66}
67
68impl DavConditionalPlan {
69    /// Copies representation validators onto statuses whose response contract retains them.
70    pub fn apply_response_headers(&self, status: StatusCode, headers: &mut HeaderMap) {
71        if !matches!(
72            status,
73            StatusCode::OK
74                | StatusCode::PARTIAL_CONTENT
75                | StatusCode::NOT_MODIFIED
76                | StatusCode::PRECONDITION_FAILED
77                | StatusCode::RANGE_NOT_SATISFIABLE
78        ) {
79            return;
80        }
81        for (name, value) in &self.validator_headers {
82            headers.insert(name.clone(), value.clone());
83        }
84    }
85}
86
87/// Failure while parsing request conditions or representing product metadata.
88#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
89pub enum DavConditionalPlanError {
90    /// A request conditional header was malformed.
91    #[error(transparent)]
92    Protocol(#[from] DavProtocolError),
93    /// Product metadata could not be represented as HTTP validator headers.
94    #[error("invalid WebDAV conditional representation metadata")]
95    InvalidRepresentation,
96}
97
98/// Failure while executing `WebDAV` `If` before HTTP conditional planning.
99#[derive(Debug, thiserror::Error)]
100pub enum DavConditionalEvaluationError {
101    /// A request header was malformed or a `WebDAV` `If` condition failed.
102    #[error(transparent)]
103    Protocol(#[from] DavProtocolError),
104    /// Resolving a `WebDAV` `If` resource failed in the product backend.
105    #[error(transparent)]
106    Backend(#[from] DavBackendError),
107    /// Product metadata could not be represented as HTTP validator headers.
108    #[error("invalid WebDAV conditional representation metadata")]
109    InvalidRepresentation,
110}
111
112/// Plans RFC 9110 conditional headers for one HTTP/WebDAV method.
113///
114/// The order is `If-Match`, `If-Unmodified-Since`, `If-None-Match`, GET/HEAD
115/// `If-Modified-Since`, and finally GET range eligibility. `If-Match` suppresses
116/// `If-Unmodified-Since`; `If-None-Match` suppresses `If-Modified-Since`.
117///
118/// # Errors
119///
120/// Returns an error when validator syntax is invalid or a response header cannot be encoded.
121pub fn plan_http_conditionals(
122    method: DavMethod,
123    headers: &HeaderMap,
124    resource: DavConditionalResource<'_>,
125) -> Result<DavConditionalPlan, DavConditionalPlanError> {
126    let validator_headers = validator_headers(resource);
127
128    let Ok(if_match) =
129        http_validators::if_match_headers_match(headers, resource.exists, resource.etag)
130    else {
131        if http_validators::if_match_headers_match(headers, resource.exists, None).is_err() {
132            return Err(DavProtocolError::bad_request("Invalid If-Match header").into());
133        }
134        return Err(DavConditionalPlanError::InvalidRepresentation);
135    };
136    if let Some(false) = if_match {
137        return Ok(plan(
138            DavConditionalOutcome::PreconditionFailed,
139            method,
140            validator_headers,
141        ));
142    }
143
144    if if_match.is_none()
145        && let Some(since) = optional_http_date(headers, IF_UNMODIFIED_SINCE)
146        && resource.exists
147        && let Some(last_modified) = resource.last_modified
148    {
149        http_validators::validate_http_date(last_modified)
150            .map_err(|_| DavConditionalPlanError::InvalidRepresentation)?;
151        if http_validators::http_date_epoch_seconds(last_modified)
152            > http_validators::http_date_epoch_seconds(since)
153        {
154            return Ok(plan(
155                DavConditionalOutcome::PreconditionFailed,
156                method,
157                validator_headers,
158            ));
159        }
160    }
161
162    let Ok(if_none_match) =
163        http_validators::if_none_match_headers_match(headers, resource.exists, resource.etag)
164    else {
165        if http_validators::if_none_match_headers_match(headers, resource.exists, None).is_err() {
166            return Err(DavProtocolError::bad_request("Invalid If-None-Match header").into());
167        }
168        return Err(DavConditionalPlanError::InvalidRepresentation);
169    };
170    if let Some(true) = if_none_match {
171        let outcome = if matches!(method, DavMethod::Get | DavMethod::Head) {
172            DavConditionalOutcome::NotModified
173        } else {
174            DavConditionalOutcome::PreconditionFailed
175        };
176        return Ok(plan(outcome, method, validator_headers));
177    }
178
179    if if_none_match.is_none()
180        && matches!(method, DavMethod::Get | DavMethod::Head)
181        && let Some(since) = optional_http_date(headers, IF_MODIFIED_SINCE)
182        && resource.exists
183        && let Some(last_modified) = resource.last_modified
184    {
185        http_validators::validate_http_date(last_modified)
186            .map_err(|_| DavConditionalPlanError::InvalidRepresentation)?;
187        if http_validators::http_date_epoch_seconds(last_modified)
188            <= http_validators::http_date_epoch_seconds(since)
189        {
190            return Ok(plan(
191                DavConditionalOutcome::NotModified,
192                method,
193                validator_headers,
194            ));
195        }
196    }
197
198    Ok(plan(
199        DavConditionalOutcome::Proceed,
200        method,
201        validator_headers,
202    ))
203}
204
205/// Enforces `WebDAV` `If` first, then applies the RFC 9110 HTTP conditional planner.
206///
207/// Tagged destination/resource conditions therefore fail with `WebDAV` `412` before the
208/// request-target HTTP conditions are considered. Product code still chooses the metadata
209/// snapshot passed to the HTTP planner.
210#[expect(
211    clippy::too_many_arguments,
212    reason = "The public conditional planner mirrors the RFC evaluation inputs explicitly."
213)]
214///
215/// # Errors
216///
217/// Returns an error when DAV `If` or HTTP conditional evaluation fails.
218pub async fn plan_conditionals(
219    if_header: Option<&IfHeader>,
220    resolver: &dyn DavIfStateResolver,
221    request_path: &DavPath,
222    prefix: &str,
223    request_scheme: &str,
224    request_host: &str,
225    method: DavMethod,
226    headers: &HeaderMap,
227    resource: DavConditionalResource<'_>,
228) -> Result<DavConditionalPlan, DavConditionalEvaluationError> {
229    enforce_if_header(
230        if_header,
231        resolver,
232        request_path,
233        prefix,
234        request_scheme,
235        request_host,
236    )
237    .await
238    .map_err(map_if_error)?;
239    plan_http_conditionals(method, headers, resource).map_err(map_plan_error)
240}
241
242/// Enforces `WebDAV` `If` through the canonical backend ports, then applies HTTP conditions.
243///
244/// This has the same WebDAV-first ordering as [`plan_conditionals`].
245#[expect(
246    clippy::too_many_arguments,
247    reason = "The backend-aware planner keeps filesystem and lock ports explicit at the boundary."
248)]
249///
250/// # Errors
251///
252/// Returns an error when backend state lookup or conditional evaluation fails.
253pub async fn plan_conditionals_with_backends(
254    if_header: Option<&IfHeader>,
255    filesystem: &dyn DavFileSystem,
256    lock_system: &dyn DavLockSystem,
257    request_path: &DavPath,
258    prefix: &str,
259    request_scheme: &str,
260    request_host: &str,
261    method: DavMethod,
262    headers: &HeaderMap,
263    resource: DavConditionalResource<'_>,
264) -> Result<DavConditionalPlan, DavConditionalEvaluationError> {
265    enforce_if_header_with_backends(
266        if_header,
267        filesystem,
268        lock_system,
269        request_path,
270        prefix,
271        request_scheme,
272        request_host,
273    )
274    .await
275    .map_err(map_if_error)?;
276    plan_http_conditionals(method, headers, resource).map_err(map_plan_error)
277}
278
279fn map_if_error(error: DavIfEvaluationError) -> DavConditionalEvaluationError {
280    match error {
281        DavIfEvaluationError::Protocol(error) => DavConditionalEvaluationError::Protocol(error),
282        DavIfEvaluationError::Backend(error) => DavConditionalEvaluationError::Backend(error),
283    }
284}
285
286fn map_plan_error(error: DavConditionalPlanError) -> DavConditionalEvaluationError {
287    match error {
288        DavConditionalPlanError::Protocol(error) => DavConditionalEvaluationError::Protocol(error),
289        DavConditionalPlanError::InvalidRepresentation => {
290            DavConditionalEvaluationError::InvalidRepresentation
291        }
292    }
293}
294
295fn plan(
296    outcome: DavConditionalOutcome,
297    method: DavMethod,
298    validator_headers: HeaderMap,
299) -> DavConditionalPlan {
300    let range = if outcome == DavConditionalOutcome::Proceed && method == DavMethod::Get {
301        DavRangeEvaluation::Evaluate
302    } else {
303        DavRangeEvaluation::Skip
304    };
305    DavConditionalPlan {
306        outcome,
307        range,
308        validator_headers,
309    }
310}
311
312fn validator_headers(resource: DavConditionalResource<'_>) -> HeaderMap {
313    let mut headers = HeaderMap::new();
314    if !resource.exists {
315        return headers;
316    }
317    if let Some(last_modified) = resource.last_modified
318        && let Ok(value) = http_validators::try_format_http_date(last_modified)
319        && let Ok(value) = HeaderValue::from_str(&value)
320    {
321        headers.insert(LAST_MODIFIED, value);
322    }
323    if let Some(etag) = resource.etag
324        && let Ok(value) = entity_tag_header_value(etag)
325    {
326        headers.insert(ETAG, value);
327    }
328    headers
329}
330
331fn entity_tag_header_value(etag: &str) -> Result<HeaderValue, DavConditionalPlanError> {
332    let rendered = http_validators::try_format_entity_tag(etag)
333        .map_err(|_| DavConditionalPlanError::InvalidRepresentation)?;
334    HeaderValue::from_str(&rendered).map_err(|_| DavConditionalPlanError::InvalidRepresentation)
335}
336
337fn optional_http_date(headers: &HeaderMap, name: HeaderName) -> Option<SystemTime> {
338    let mut values = headers.get_all(name).iter();
339    let value = values.next()?;
340    if values.next().is_some() {
341        return None;
342    }
343    let raw = value.to_str().ok()?;
344    http_validators::parse_http_date(raw).ok()
345}